package executor import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "github.com/thepeterstone/claudomator/internal/llm" ) // agentToolDefs returns the four agent back-channel tools as OpenAI // function-calling definitions, for runners that talk to an OpenAI-compatible // endpoint (LocalRunner). They mirror the MCP tools the container runners expose. func agentToolDefs() []llm.Tool { strProp := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } return []llm.Tool{ {Type: "function", Function: llm.ToolFunction{ Name: "ask_user", Description: "Ask the user a question when you genuinely need a decision to proceed. The task pauses until the user answers; do not call other tools after this.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "question": strProp("the question to ask, phrased as a real question"), "options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional suggested answer choices"}, }, "required": []string{"question"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "report_summary", Description: "Record a concise 2-5 sentence summary of what you accomplished. Call this before finishing.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{"summary": strProp("the summary text")}, "required": []string{"summary"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "spawn_subtask", Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "name": strProp("short descriptive name for the subtask"), "instructions": strProp("complete instructions for the subtask agent"), "model": strProp("optional model override"), "max_budget_usd": map[string]any{"type": "number", "description": "optional budget cap in USD"}, }, "required": []string{"name", "instructions"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "record_progress", Description: "Record a short progress note that appears in the task timeline.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{"message": strProp("a short progress note")}, "required": []string{"message"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "read_file", Description: "Read the contents of a file in the sandbox working directory.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{"path": strProp("path to the file, relative or absolute")}, "required": []string{"path"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "write_file", Description: "Write or overwrite a file in the sandbox working directory.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": strProp("path to the file, relative or absolute"), "content": strProp("content to write"), }, "required": []string{"path", "content"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "run_bash", Description: "Run a shell command in the sandbox working directory. Returns exit code, stdout, and stderr.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{"command": strProp("shell command to execute")}, "required": []string{"command"}, }, }}, {Type: "function", Function: llm.ToolFunction{ Name: "glob", Description: "List files matching a glob pattern relative to the sandbox working directory.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{"pattern": strProp("glob pattern, e.g. **/*.go")}, "required": []string{"pattern"}, }, }}, } } // dispatchAgentTool invokes one model-requested tool against the AgentChannel. // workDir is the sandbox working directory; file/bash tools return an error if // it is empty. It returns the text to feed back to the model as the tool // result, and a blocked flag set when ask_user could not be answered in-session // (the run must stop and the task block). func dispatchAgentTool(ctx context.Context, ch AgentChannel, workDir, name, argsJSON string) (result string, blocked bool, err error) { switch name { case "ask_user": var a struct { Question string `json:"question"` Options []string `json:"options"` } _ = json.Unmarshal([]byte(argsJSON), &a) q := map[string]any{"text": a.Question} if len(a.Options) > 0 { q["options"] = a.Options } payload, _ := json.Marshal(q) ans, askErr := ch.AskUser(ctx, string(payload)) if errors.Is(askErr, ErrAgentBlocked) { return "", true, nil } if askErr != nil { return "", false, askErr } return ans, false, nil case "report_summary": var a struct { Summary string `json:"summary"` } _ = json.Unmarshal([]byte(argsJSON), &a) if rsErr := ch.ReportSummary(ctx, a.Summary); rsErr != nil { return "", false, rsErr } return "Summary recorded.", false, nil case "spawn_subtask": var a struct { Name string `json:"name"` Instructions string `json:"instructions"` Model string `json:"model"` MaxBudgetUSD float64 `json:"max_budget_usd"` } _ = json.Unmarshal([]byte(argsJSON), &a) id, ssErr := ch.SpawnSubtask(ctx, SubtaskSpec{ Name: a.Name, Instructions: a.Instructions, Model: a.Model, MaxBudgetUSD: a.MaxBudgetUSD, }) if ssErr != nil { return "", false, ssErr } return "Created subtask " + id, false, nil case "record_progress": var a struct { Message string `json:"message"` } _ = json.Unmarshal([]byte(argsJSON), &a) if rpErr := ch.RecordProgress(ctx, a.Message); rpErr != nil { return "", false, rpErr } return "Noted.", false, nil case "read_file": if workDir == "" { return "", false, fmt.Errorf("read_file: no sandbox working directory") } var a struct { Path string `json:"path"` } _ = json.Unmarshal([]byte(argsJSON), &a) p := a.Path if !filepath.IsAbs(p) { p = filepath.Join(workDir, p) } data, readErr := os.ReadFile(p) if readErr != nil { return "", false, readErr } return string(data), false, nil case "write_file": if workDir == "" { return "", false, fmt.Errorf("write_file: no sandbox working directory") } var a struct { Path string `json:"path"` Content string `json:"content"` } _ = json.Unmarshal([]byte(argsJSON), &a) p := a.Path if !filepath.IsAbs(p) { p = filepath.Join(workDir, p) } if mkErr := os.MkdirAll(filepath.Dir(p), 0o755); mkErr != nil { return "", false, mkErr } if writeErr := os.WriteFile(p, []byte(a.Content), 0o644); writeErr != nil { return "", false, writeErr } return "Written.", false, nil case "run_bash": if workDir == "" { return "", false, fmt.Errorf("run_bash: no sandbox working directory") } var a struct { Command string `json:"command"` } _ = json.Unmarshal([]byte(argsJSON), &a) cmd := exec.CommandContext(ctx, "sh", "-c", a.Command) cmd.Dir = workDir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr runErr := cmd.Run() exitCode := 0 if runErr != nil { if exitErr, ok := runErr.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { return "", false, runErr } } const maxOutput = 8 * 1024 out := stdout.String() errOut := stderr.String() if len(out) > maxOutput { out = out[:maxOutput] + "\n[truncated]" } if len(errOut) > maxOutput { errOut = errOut[:maxOutput] + "\n[truncated]" } return fmt.Sprintf("exit_code: %d\nstdout:\n%s\nstderr:\n%s", exitCode, out, errOut), false, nil case "glob": if workDir == "" { return "", false, fmt.Errorf("glob: no sandbox working directory") } var a struct { Pattern string `json:"pattern"` } _ = json.Unmarshal([]byte(argsJSON), &a) pattern := a.Pattern if !filepath.IsAbs(pattern) { pattern = filepath.Join(workDir, pattern) } matches, globErr := filepath.Glob(pattern) if globErr != nil { return "", false, globErr } // Return paths relative to workDir for readability. rel := make([]string, 0, len(matches)) for _, m := range matches { r, relErr := filepath.Rel(workDir, m) if relErr != nil { r = m } rel = append(rel, r) } out, _ := json.Marshal(rel) return string(out), false, nil default: return "", false, fmt.Errorf("unknown tool %q", name) } }