diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 08:49:43 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 08:49:43 +0000 |
| commit | 67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch) | |
| tree | bc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal/agentloop | |
| parent | 3d286974cdc28c68c5ee536ce9303899dae9540e (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')
| -rw-r--r-- | internal/agentloop/agentloop.go | 265 | ||||
| -rw-r--r-- | internal/agentloop/tools.go | 226 |
2 files changed, 491 insertions, 0 deletions
diff --git a/internal/agentloop/agentloop.go b/internal/agentloop/agentloop.go new file mode 100644 index 0000000..73c4c83 --- /dev/null +++ b/internal/agentloop/agentloop.go @@ -0,0 +1,265 @@ +// Package agentloop implements the provider-neutral tool-use control flow +// extracted from the former executor.LocalRunner.Run: build messages, call a +// provider.Provider in a bounded loop, dispatch any tool calls the model +// makes (back-channel tools to an agentchannel.AgentChannel, sandbox tools to +// a sandbox.Sandbox), and re-feed results until the model stops calling +// tools or the turn budget is exhausted. +// +// Loop deliberately knows nothing about *how* instructions were assembled +// (planning preamble, tinyllama tool-gating, etc.) or how the sandbox's +// working directory was established (fresh git clone vs. resumed BLOCKED +// sandbox) — those decisions stay with the caller (executor.NativeRunner), +// which lives in internal/executor and can freely reuse executor's existing +// buildAgentInstructions/withPlanningPreamble helpers without agentloop +// having to import executor (which would cycle back through NativeRunner). +package agentloop + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "strings" + "time" + + "github.com/thepeterstone/claudomator/internal/agentchannel" + "github.com/thepeterstone/claudomator/internal/provider" + "github.com/thepeterstone/claudomator/internal/sandbox" + "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/task" +) + +// DefaultMaxTurns bounds the tool-use loop so a misbehaving model cannot spin +// forever calling tools without finishing. Matches the former +// executor.maxLocalToolTurns. +const DefaultMaxTurns = 12 + +// Loop drives a provider-neutral tool-use conversation for a single task +// execution. +type Loop struct { + Provider provider.Provider + Sandbox sandbox.Sandbox + Channel agentchannel.AgentChannel + MaxTurns int + + // DefaultTemperature is used when the task doesn't specify its own + // Agent.Temperature. Matches the former LocalRunner.DefaultTemperature. + DefaultTemperature float64 + + Logger *slog.Logger +} + +// Run drives the chat completion loop against l.Provider with the agent +// back-channel + sandbox tools declared (when toolsEnabled), writing +// assistant text and a final result line to stdout in the same stream-json +// envelope ClaudeRunner/ContainerRunner emit, so downstream parsers +// (extractSummary, task.ParseChangestatFromFile) keep working unchanged. +// +// instructions is the fully-assembled user-turn text (already including any +// planning preamble the caller decided to prepend). If the model calls +// ask_user, Run stops and returns a *agentchannel.BlockedError, leaving the +// sandbox uncleaned so a resume can reuse it. +func (l *Loop) Run(ctx context.Context, t *task.Task, e *storage.Execution, stdout io.Writer, instructions string, toolsEnabled bool) error { + maxTurns := l.MaxTurns + if maxTurns <= 0 { + maxTurns = DefaultMaxTurns + } + + temperature := t.Agent.Temperature + if temperature == nil && l.DefaultTemperature > 0 { + v := l.DefaultTemperature + temperature = &v + } + + var tools []provider.ToolSpec + if toolsEnabled { + tools = agentToolSpecs() + } + + system := strings.TrimSpace(t.Agent.SystemPromptAppend) + messages := []provider.Message{{Role: "user", Text: instructions}} + + start := time.Now() + var totalIn, totalOut int + var lastFinish string + blocked := false + var fullText string + + for turn := 0; turn < maxTurns; turn++ { + req := provider.ChatRequest{ + Model: t.Agent.Model, + System: system, + Messages: messages, + Temperature: temperature, + MaxTokens: t.Agent.MaxTokens, + Tools: tools, + } + resp, chatErr := l.Provider.Chat(ctx, req) + if chatErr != nil { + // If the model doesn't support tool-use, retry once without tools. + if tools != nil && strings.Contains(strings.ToLower(chatErr.Error()), "does not support tools") { + tools = nil + req.Tools = nil + resp, chatErr = l.Provider.Chat(ctx, req) + } + if chatErr != nil { + writeResultLine(stdout, "error", chatErr.Error(), totalIn, totalOut) + return fmt.Errorf("agentloop: chat: %w", chatErr) + } + } + totalIn += resp.Usage.InputTokens + totalOut += resp.Usage.OutputTokens + lastFinish = resp.StopReason + + if resp.Text != "" { + writeAssistantTextLine(stdout, resp.Text) + fullText += resp.Text + } + + if len(resp.ToolCalls) == 0 { + break + } + + // Re-feed: record the assistant's tool-call turn, then each tool result. + messages = append(messages, provider.Message{Role: "assistant", Text: resp.Text, ToolCalls: resp.ToolCalls}) + for _, tc := range resp.ToolCalls { + result, didBlock, toolErr := l.dispatchTool(ctx, tc.Name, tc.ArgsJSON) + if toolErr != nil { + writeResultLine(stdout, "error", toolErr.Error(), totalIn, totalOut) + return fmt.Errorf("agentloop: tool %s: %w", tc.Name, toolErr) + } + if didBlock { + blocked = true + break + } + messages = append(messages, provider.Message{ + Role: "tool", + ToolResults: []provider.ToolResult{{ + ToolCallID: tc.ID, + Name: tc.Name, + Content: result, + }}, + }) + } + if blocked { + break + } + } + elapsed := time.Since(start) + + e.CostUSD = 0 + e.TokensIn = int64(totalIn) + e.TokensOut = int64(totalOut) + + if l.Logger != nil { + l.Logger.Info("agentloop run completed", + "taskID", t.ID, + "tokens_in", totalIn, + "tokens_out", totalOut, + "finish_reason", lastFinish, + "blocked", blocked, + "elapsed_ms", elapsed.Milliseconds(), + ) + } + + // If the model asked the user, stop and block. Preserve the sandbox so + // resume can reuse it. + if q, isBlocked := pendingQuestion(l.Channel); isBlocked { + return &agentchannel.BlockedError{QuestionJSON: q} + } + + // Push any commits made inside the sandbox back to the origin. + if l.Sandbox != nil && l.Sandbox.WorkDir() != "" { + if err := l.Sandbox.GitPush(ctx, ""); err != nil { + if l.Logger != nil { + l.Logger.Warn("agentloop: git push failed", "err", err) + } + } + if err := l.Sandbox.Cleanup(ctx); err != nil { + if l.Logger != nil { + l.Logger.Warn("agentloop: sandbox cleanup failed", "err", err) + } + } + e.SandboxDir = "" + } + + // For tool-less models report_summary is never called, so synthesise a + // summary from the raw assistant output so handleRunResult has something + // to store. + if e.Summary == "" && fullText != "" { + s := strings.TrimSpace(fullText) + if len(s) > 500 { + s = s[:500] + } + e.Summary = s + } + + writeResultLine(stdout, "success", "", totalIn, totalOut) + return nil +} + +// pendingAsker is implemented by AgentChannel implementations that buffer an +// ask_user call so Run can convert it into a BlockedError after the loop +// exits. Defined structurally here (rather than imported) so agentloop does +// not need to depend on executor.storeChannel's package to detect it — any +// AgentChannel with this method shape satisfies it, including +// executor.storeChannel. +type pendingAsker interface { + PendingQuestion() (questionJSON string, blocked bool) +} + +func pendingQuestion(ch agentchannel.AgentChannel) (string, bool) { + if pa, ok := ch.(pendingAsker); ok { + return pa.PendingQuestion() + } + return "", false +} + +// writeAssistantTextLine writes a single stream-json line wrapping `text` as +// an assistant text block. Format matches what ClaudeRunner/ContainerRunner +// emit, so extractSummary and ParseChangestatFromFile read it transparently. +func writeAssistantTextLine(w io.Writer, text string) { + line := struct { + Type string `json:"type"` + Message struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + }{Type: "assistant"} + line.Message.Content = []struct { + Type string `json:"type"` + Text string `json:"text"` + }{{Type: "text", Text: text}} + b, err := json.Marshal(line) + if err != nil { + return + } + w.Write(b) + w.Write([]byte("\n")) +} + +// writeResultLine writes a final stream-json terminator line that downstream +// parsers can recognise. Mirrors the shape of the result line ClaudeRunner +// emits. +func writeResultLine(w io.Writer, subtype, errMsg string, promptTokens, outputTokens int) { + line := map[string]any{ + "type": "result", + "subtype": subtype, + "is_error": errMsg != "", + "prompt_tokens": promptTokens, + "output_tokens": outputTokens, + "total_cost_usd": 0.0, + } + if errMsg != "" { + line["result"] = errMsg + } + b, err := json.Marshal(line) + if err != nil { + return + } + w.Write(b) + w.Write([]byte("\n")) +} 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) + } +} |
