Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions pkg/runtime/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,20 @@ func ToolCallConfirmation(toolCall tools.ToolCall, toolDefinition tools.Tool, ag
}

type ToolCallResponseEvent struct {
Type string `json:"type"`
ToolCall tools.ToolCall `json:"tool_call"`
ToolDefinition tools.Tool `json:"tool_definition"`
Response string `json:"response"`
Type string `json:"type"`
ToolCall tools.ToolCall `json:"tool_call"`
ToolDefinition tools.Tool `json:"tool_definition"`
Response string `json:"response"`
Result *tools.ToolCallResult `json:"result,omitempty"`
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is weird... For now, at some point in the future we will be able to remove the Response, once no other parts depend on it

AgentContext
}

func ToolCallResponse(toolCall tools.ToolCall, toolDefinition tools.Tool, response, agentName string) Event {
func ToolCallResponse(toolCall tools.ToolCall, toolDefinition tools.Tool, result *tools.ToolCallResult, response, agentName string) Event {
return &ToolCallResponseEvent{
Type: "tool_call_response",
ToolCall: toolCall,
Response: response,
Result: result,
ToolDefinition: toolDefinition,
AgentContext: AgentContext{AgentName: agentName},
}
Expand Down
22 changes: 10 additions & 12 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,7 @@ func (r *LocalRuntime) runTool(ctx context.Context, tool tools.Tool, toolCall to
slog.Debug("Agent tool call completed", "tool", toolCall.Function.Name, "output_length", len(res.Output))
}

events <- ToolCallResponse(toolCall, tool, res.Output, a.Name())
events <- ToolCallResponse(toolCall, tool, res, res.Output, a.Name())

// Ensure tool response content is not empty for API compatibility
content := res.Output
Expand Down Expand Up @@ -1173,29 +1173,23 @@ func (r *LocalRuntime) runAgentTool(ctx context.Context, handler ToolHandlerFunc

telemetry.RecordToolCall(ctx, toolCall.Function.Name, sess.ID, a.Name(), duration, err)

var output string
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(ctx.Err(), context.Canceled) {
slog.Debug("Runtime tool handler canceled by context", "tool", toolCall.Function.Name, "agent", a.Name(), "session_id", sess.ID)
// Synthesize a cancellation response so the transcript remains consistent
output = "The tool call was canceled by the user."
res.Output = "The tool call was canceled by the user."
span.SetStatus(codes.Ok, "runtime tool handler canceled by user")
} else {
span.RecordError(err)
span.SetStatus(codes.Error, "runtime tool handler error")
output = fmt.Sprintf("error calling tool: %v", err)
slog.Error("Error executing tool", "tool", toolCall.Function.Name, "error", err)
}
} else {
output = res.Output
span.SetStatus(codes.Ok, "runtime tool handler completed")
slog.Debug("Tool executed successfully", "tool", toolCall.Function.Name)
}

events <- ToolCallResponse(toolCall, tool, output, a.Name())
events <- ToolCallResponse(toolCall, tool, res, res.Output, a.Name())

// Ensure tool response content is not empty for API compatibility
content := output
content := res.Output
if strings.TrimSpace(content) == "" {
content = "(no output)"
}
Expand All @@ -1215,7 +1209,9 @@ func (r *LocalRuntime) addToolRejectedResponse(ctx context.Context, sess *sessio

result := "The user rejected the tool call."

events <- ToolCallResponse(toolCall, tool, result, a.Name())
events <- ToolCallResponse(toolCall, tool, &tools.ToolCallResult{
Output: result,
}, result, a.Name())

toolResponseMsg := chat.Message{
Role: chat.MessageRoleTool,
Expand All @@ -1232,7 +1228,9 @@ func (r *LocalRuntime) addToolCancelledResponse(ctx context.Context, sess *sessi

result := "The tool call was canceled by the user."

events <- ToolCallResponse(toolCall, tool, result, a.Name())
events <- ToolCallResponse(toolCall, tool, &tools.ToolCallResult{
Output: result,
}, result, a.Name())

toolResponseMsg := chat.Message{
Role: chat.MessageRoleTool,
Expand Down
63 changes: 51 additions & 12 deletions pkg/tools/builtin/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ type ReadMultipleFilesArgs struct {
JSON bool `json:"json,omitempty" jsonschema:"Whether to return the result as JSON"`
}

type ReadMultipleFilesEntry struct {
Path string `json:"path"`
Content string `json:"content"`
LineCount int `json:"lineCount"`
Error string `json:"error,omitempty"`
}

type ReadMultipleFilesMeta struct {
Files []ReadMultipleFilesEntry `json:"files"`
}

type SearchFilesArgs struct {
Path string `json:"path" jsonschema:"The starting directory path"`
Pattern string `json:"pattern" jsonschema:"The glob pattern to match file names against"`
Expand All @@ -141,6 +152,12 @@ type ListDirectoryArgs struct {
Path string `json:"path" jsonschema:"The directory path to list"`
}

type ListDirectoryMeta struct {
Files []string `json:"files"`
Dirs []string `json:"dirs"`
Truncated bool `json:"truncated"`
}

type ReadFileArgs struct {
Path string `json:"path" jsonschema:"The file path to read"`
}
Expand Down Expand Up @@ -549,27 +566,33 @@ func (t *FilesystemTool) handleListDirectory(_ context.Context, args ListDirecto
}

var result strings.Builder
meta := ListDirectoryMeta{}
count := 0
for _, entry := range entries {
// Check if entry should be ignored by VCS rules
entryPath := filepath.Join(args.Path, entry.Name())
if t.shouldIgnorePath(entryPath) {
continue
}

if entry.IsDir() {
result.WriteString(fmt.Sprintf("DIR %s\n", entry.Name()))
meta.Dirs = append(meta.Dirs, entry.Name())
} else {
result.WriteString(fmt.Sprintf("FILE %s\n", entry.Name()))
meta.Files = append(meta.Files, entry.Name())
}
count++
if count >= maxFiles {
result.WriteString("...output truncated due to file limit...\n")
meta.Truncated = true
break
}
}

return tools.ResultSuccess(result.String()), nil
return &tools.ToolCallResult{
Output: result.String(),
Meta: meta,
}, nil
}

func (t *FilesystemTool) handleReadFile(_ context.Context, args ReadFileArgs) (*tools.ToolCallResult, error) {
Expand All @@ -592,50 +615,66 @@ func (t *FilesystemTool) handleReadMultipleFiles(ctx context.Context, args ReadM
}

var contents []PathContent
meta := ReadMultipleFilesMeta{}

for _, path := range args.Paths {
if ctx.Err() != nil {
return nil, ctx.Err()
}

entry := ReadMultipleFilesEntry{Path: path}

if err := t.isPathAllowed(path); err != nil {
errMsg := fmt.Sprintf("Error: %s", err)
contents = append(contents, PathContent{
Path: path,
Content: fmt.Sprintf("Error: %s", err),
Content: errMsg,
})
entry.Error = errMsg
meta.Files = append(meta.Files, entry)
continue
}

content, err := os.ReadFile(path)
if err != nil {
errMsg := fmt.Sprintf("Error reading file: %s", err)
contents = append(contents, PathContent{
Path: path,
Content: fmt.Sprintf("Error reading file: %s", err),
Content: errMsg,
})
entry.Error = errMsg
meta.Files = append(meta.Files, entry)
continue
}

contents = append(contents, PathContent{
Path: path,
Content: string(content),
})
entry.Content = string(content)
entry.LineCount = strings.Count(string(content), "\n") + 1
meta.Files = append(meta.Files, entry)
}

var output string
if args.JSON {
jsonResult, err := json.MarshalIndent(contents, "", " ")
if err != nil {
return tools.ResultError(fmt.Sprintf("Error formatting JSON: %s", err)), nil
}

return tools.ResultSuccess(string(jsonResult)), nil
}

var result strings.Builder
for _, content := range contents {
result.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", content.Path, content.Content))
output = string(jsonResult)
} else {
var result strings.Builder
for _, content := range contents {
result.WriteString(fmt.Sprintf("=== %s ===\n%s\n\n", content.Path, content.Content))
}
output = result.String()
}

return tools.ResultSuccess(result.String()), nil
return &tools.ToolCallResult{
Output: output,
Meta: meta,
}, nil
}

func (t *FilesystemTool) handleSearchFiles(_ context.Context, args SearchFilesArgs) (*tools.ToolCallResult, error) {
Expand Down
60 changes: 40 additions & 20 deletions pkg/tools/builtin/todo.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ type todoHandler struct {
todos *concurrent.Map[string, Todo]
}

func (h *todoHandler) allTodos() []Todo {
var todos []Todo
h.todos.Range(func(_ string, todo Todo) bool {
todos = append(todos, todo)
return true
})
return todos
}

var NewSharedTodoTool = sync.OnceValue(NewTodoTool)

func NewTodoTool() *TodoTool {
Expand Down Expand Up @@ -81,17 +90,20 @@ This toolset is REQUIRED for maintaining task state and ensuring all steps are c

func (h *todoHandler) createTodo(_ context.Context, params CreateTodoArgs) (*tools.ToolCallResult, error) {
id := fmt.Sprintf("todo_%d", h.todos.Length()+1)
h.todos.Store(id, Todo{
todo := Todo{
ID: id,
Description: params.Description,
Status: "pending",
})
}
h.todos.Store(id, todo)

return tools.ResultSuccess(fmt.Sprintf("Created todo [%s]: %s", id, params.Description)), nil
return &tools.ToolCallResult{
Output: fmt.Sprintf("Created todo [%s]: %s", id, params.Description),
Meta: h.allTodos(),
}, nil
}

func (h *todoHandler) createTodos(_ context.Context, params CreateTodosArgs) (*tools.ToolCallResult, error) {
ids := make([]string, len(params.Descriptions))
start := h.todos.Length()
for i, desc := range params.Descriptions {
id := fmt.Sprintf("todo_%d", start+i+1)
Expand All @@ -100,43 +112,51 @@ func (h *todoHandler) createTodos(_ context.Context, params CreateTodosArgs) (*t
Description: desc,
Status: "pending",
})
ids[i] = id
}

output := fmt.Sprintf("Created %d todos: ", len(params.Descriptions))
for i, id := range ids {
var output strings.Builder
fmt.Fprintf(&output, "Created %d todos: ", len(params.Descriptions))
for i := range params.Descriptions {
if i > 0 {
output += ", "
output.WriteString(", ")
}
output += fmt.Sprintf("[%s]", id)
fmt.Fprintf(&output, "[todo_%d]", start+i+1)
}

return tools.ResultSuccess(output), nil
return &tools.ToolCallResult{
Output: output.String(),
Meta: h.allTodos(),
}, nil
}

func (h *todoHandler) updateTodo(_ context.Context, params UpdateTodoArgs) (*tools.ToolCallResult, error) {
todo, exists := h.todos.Load(params.ID)
if !exists {
return nil, fmt.Errorf("todo [%s] not found", params.ID)
return tools.ResultError(fmt.Sprintf("todo [%s] not found", params.ID)), nil
}

todo.Status = params.Status
h.todos.Store(params.ID, todo)

return tools.ResultSuccess(fmt.Sprintf("Updated todo [%s] to status: [%s]", params.ID, params.Status)), nil
return &tools.ToolCallResult{
Output: fmt.Sprintf("Updated todo [%s] to status: [%s]", params.ID, params.Status),
Meta: h.allTodos(),
}, nil
}

func (h *todoHandler) listTodos(_ context.Context, _ map[string]any) (*tools.ToolCallResult, error) {
func (h *todoHandler) listTodos(_ context.Context, _ tools.ToolCall) (*tools.ToolCallResult, error) {
var output strings.Builder
output.WriteString("Current todos:\n")

h.todos.Range(func(_ string, todo Todo) bool {
output.WriteString(fmt.Sprintf("- [%s] %s (Status: %s)\n",
todo.ID, todo.Description, todo.Status))
return true
})
todos := h.allTodos()
for _, todo := range todos {
fmt.Fprintf(&output, "- [%s] %s (Status: %s)\n", todo.ID, todo.Description, todo.Status)
}

return tools.ResultSuccess(output.String()), nil
return &tools.ToolCallResult{
Output: output.String(),
Meta: todos,
}, nil
}

func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) {
Expand Down Expand Up @@ -182,7 +202,7 @@ func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) {
Category: "todo",
Description: "List all current todos with their status",
OutputSchema: tools.MustSchemaFor[string](),
Handler: NewHandler(t.handler.listTodos),
Handler: t.handler.listTodos,
Annotations: tools.ToolAnnotations{
Title: "List TODOs",
ReadOnlyHint: true,
Expand Down
Loading