summaryrefslogtreecommitdiff
path: root/internal/agentloop/tools.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
commit67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch)
treebc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal/agentloop/tools.go
parent3d286974cdc28c68c5ee536ce9303899dae9540e (diff)
refactor(executor): extract provider-neutral tool-use loop (Phase 1)
Splits LocalRunner's OpenAI-specific agentic loop into reusable, provider- agnostic pieces so later phases can add native Anthropic/OpenAI/Google/Groq/ OpenRouter adapters without duplicating the control flow: - internal/provider: neutral Provider/ChatRequest/ChatResponse types, plus an openaicompat adapter wrapping the existing internal/llm.Client unchanged - internal/sandbox: Sandbox interface + HostSandbox (git clone/push/cleanup, read_file/write_file/run_bash/glob), lifted verbatim from local.go/localtools.go - internal/agentloop: the extracted tool-use loop (request/response/tool- dispatch/loop, ask_user blocking, stream-json envelope, summary fallback) - internal/agentchannel: AgentChannel/SubtaskSpec/BlockedError/ErrAgentBlocked moved out of internal/executor so agentloop can use them without an import cycle; internal/executor re-exports via type aliases, so no call site changes - internal/executor/nativerunner.go: NativeRunner replaces LocalRunner, wiring agentloop.Loop + openaicompat + HostSandbox together - config.Providers map[string]ProviderConfig added (unused until Phase 2+) Zero intended behavior change: go test -race ./... passes across all packages, and end-to-end stream-json/summary/changestats output was verified byte-compatible against a fake OpenAI-compatible server. Adds test coverage for sandbox tool-dispatch (git clone/push, read/write/bash/glob) that LocalRunner never had. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/agentloop/tools.go')
-rw-r--r--internal/agentloop/tools.go226
1 files changed, 226 insertions, 0 deletions
diff --git a/internal/agentloop/tools.go b/internal/agentloop/tools.go
new file mode 100644
index 0000000..e92a12f
--- /dev/null
+++ b/internal/agentloop/tools.go
@@ -0,0 +1,226 @@
+package agentloop
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/thepeterstone/claudomator/internal/agentchannel"
+ "github.com/thepeterstone/claudomator/internal/provider"
+)
+
+// agentToolSpecs returns the eight tools available to the loop: the four
+// agent back-channel tools (mirroring the MCP tools ContainerRunner exposes)
+// plus the four sandbox tools, as provider-neutral ToolSpecs. 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"},
+ },
+ "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: "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"`
+ }
+ _ = 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,
+ })
+ 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 "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 {
+ 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 {
+ 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)
+ }
+}