package agentloop import ( "context" "encoding/json" "errors" "fmt" "github.com/thepeterstone/claudomator/internal/agentchannel" "github.com/thepeterstone/claudomator/internal/provider" "github.com/thepeterstone/claudomator/internal/role" "github.com/thepeterstone/claudomator/internal/sandbox" ) // agentToolSpecs returns the tools available to the loop: the agent // back-channel tools (mirroring the MCP tools ContainerRunner exposes; // propose_epic was added in Phase 7c, propose_role_config in Phase 8) plus // the four sandbox tools, as provider-neutral ToolSpecs. The original four // (ask_user/report_summary/spawn_subtask/record_progress) plus the sandbox // tools were ported verbatim (same names/descriptions/JSON schemas) from the // former executor.agentToolDefs (internal/executor/localtools.go). func agentToolSpecs() []provider.ToolSpec { strProp := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } return []provider.ToolSpec{ { 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.", ParametersJSONSchema: 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"}, }, }, { Name: "report_summary", Description: "Record a concise 2-5 sentence summary of what you accomplished. Call this before finishing.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{"summary": strProp("the summary text")}, "required": []string{"summary"}, }, }, { Name: "spawn_subtask", Description: "Create a child task to be executed separately. Use this to break large work into focused pieces.", ParametersJSONSchema: 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"}, "role": strProp("optional role name to dispatch the subtask through instead of a fixed model (e.g. an evaluator role); when set, model is ignored and the role's escalation ladder picks the provider/model"), "depends_on": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional list of sibling subtask IDs (returned by prior spawn_subtask calls in this same decomposition) this subtask must wait for before it can run"}, }, "required": []string{"name", "instructions"}, }, }, { Name: "record_progress", Description: "Record a short progress note that appears in the task timeline.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{"message": strProp("a short progress note")}, "required": []string{"message"}, }, }, { Name: "propose_epic", Description: "Group one or more stories under a new or existing epic (matched by exact name) when they form a cohesive initiative. Only call this when you've been given several story IDs and independently judge that they belong together.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{ "name": strProp("short descriptive name for the epic; matched by exact name to reuse an existing epic instead of creating a duplicate"), "description": strProp("optional longer description of the initiative"), "story_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the story IDs to group under this epic"}, }, "required": []string{"name", "story_ids"}, }, }, { Name: "propose_role_config", Description: "Propose a new draft configuration version for a role, after reflecting on what happened (e.g. during a story retro). Creates a new draft role_configs row for a human to review and activate -- it never changes what is currently active.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{ "role": strProp("the role name this config applies to (e.g. an existing role like builder, or a new one)"), "system_prompt": strProp("system prompt appended for tasks dispatched through this role"), "tools": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "optional tool allowlist for this role"}, "sandbox_kind": strProp("optional sandbox kind for this role"), "default_budget_usd": map[string]any{"type": "number", "description": "optional estimated budget in USD, used when the scheduler considers escalating this role's tasks"}, "escalation_ladder": map[string]any{ "type": "array", "description": "ordered list of escalation tiers", "items": map[string]any{ "type": "object", "properties": map[string]any{ "candidates": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "provider": strProp("executor runner key, e.g. anthropic, google, groq, openrouter, openai, local"), "model": strProp("model name"), }, "required": []string{"provider", "model"}, }, "description": "candidate provider/model rungs for this tier", }, "selection_mode": strProp("round_robin (default) or single"), "max_retries": map[string]any{"type": "integer", "description": "attempts allowed at this tier before escalating to the next"}, }, "required": []string{"candidates"}, }, }, }, "required": []string{"role"}, }, }, { Name: "read_file", Description: "Read the contents of a file in the sandbox working directory.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{"path": strProp("path to the file, relative or absolute")}, "required": []string{"path"}, }, }, { Name: "write_file", Description: "Write or overwrite a file in the sandbox working directory.", ParametersJSONSchema: 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"}, }, }, { Name: "run_bash", Description: "Run a shell command in the sandbox working directory. Returns exit code, stdout, and stderr.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{"command": strProp("shell command to execute")}, "required": []string{"command"}, }, }, { Name: "glob", Description: "List files matching a glob pattern relative to the sandbox working directory.", ParametersJSONSchema: map[string]any{ "type": "object", "properties": map[string]any{"pattern": strProp("glob pattern, e.g. **/*.go")}, "required": []string{"pattern"}, }, }, } } // dispatchTool invokes one model-requested tool. Back-channel tools // (ask_user/report_summary/spawn_subtask/record_progress) go to l.Channel; // sandbox tools (read_file/write_file/run_bash/glob) go to l.Sandbox. 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). Ported from the former // executor.dispatchAgentTool (internal/executor/localtools.go), preserving // identical result-string formatting. func (l *Loop) dispatchTool(ctx context.Context, 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 := l.Channel.AskUser(ctx, string(payload)) if errors.Is(askErr, agentchannel.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 := l.Channel.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"` Role string `json:"role"` DependsOn []string `json:"depends_on"` } _ = json.Unmarshal([]byte(argsJSON), &a) id, ssErr := l.Channel.SpawnSubtask(ctx, agentchannel.SubtaskSpec{ Name: a.Name, Instructions: a.Instructions, Model: a.Model, MaxBudgetUSD: a.MaxBudgetUSD, Role: a.Role, DependsOn: a.DependsOn, }) 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 := l.Channel.RecordProgress(ctx, a.Message); rpErr != nil { return "", false, rpErr } return "Noted.", false, nil case "propose_epic": var a struct { Name string `json:"name"` Description string `json:"description"` StoryIDs []string `json:"story_ids"` } _ = json.Unmarshal([]byte(argsJSON), &a) id, peErr := l.Channel.ProposeEpic(ctx, agentchannel.EpicProposal{ Name: a.Name, Description: a.Description, StoryIDs: a.StoryIDs, }) if peErr != nil { return "", false, peErr } return "Proposed epic " + id, false, nil case "propose_role_config": var a role.RoleConfig _ = json.Unmarshal([]byte(argsJSON), &a) version, prErr := l.Channel.ProposeRoleConfig(ctx, a) if prErr != nil { return "", false, prErr } return fmt.Sprintf("Proposed role config %s v%d (draft)", a.Role, version), false, nil case "read_file": var a struct { Path string `json:"path"` } _ = json.Unmarshal([]byte(argsJSON), &a) content, readErr := l.Sandbox.ReadFile(ctx, a.Path) if readErr != nil { return "", false, readErr } return content, false, nil case "write_file": var a struct { Path string `json:"path"` Content string `json:"content"` } _ = json.Unmarshal([]byte(argsJSON), &a) if writeErr := l.Sandbox.WriteFile(ctx, a.Path, a.Content); writeErr != nil { // A guardrail rejection (sandbox.Guarded wrapping the real // Sandbox) is not a fatal execution error: feed the reason back // to the model as ordinary tool content so it can self-correct, // instead of aborting the whole run the way a genuine sandbox // failure (e.g. no working directory) does. var rej *sandbox.RejectionError if errors.As(writeErr, &rej) { return rej.Error(), false, nil } return "", false, writeErr } return "Written.", false, nil case "run_bash": var a struct { Command string `json:"command"` } _ = json.Unmarshal([]byte(argsJSON), &a) out, errOut, exitCode, runErr := l.Sandbox.RunBash(ctx, a.Command) if runErr != nil { var rej *sandbox.RejectionError if errors.As(runErr, &rej) { return rej.Error(), false, nil } return "", false, runErr } return fmt.Sprintf("exit_code: %d\nstdout:\n%s\nstderr:\n%s", exitCode, out, errOut), false, nil case "glob": var a struct { Pattern string `json:"pattern"` } _ = json.Unmarshal([]byte(argsJSON), &a) matches, globErr := l.Sandbox.Glob(ctx, a.Pattern) if globErr != nil { return "", false, globErr } out, _ := json.Marshal(matches) return string(out), false, nil default: return "", false, fmt.Errorf("unknown tool %q", name) } }