summaryrefslogtreecommitdiff
path: root/internal/agentloop
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
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')
-rw-r--r--internal/agentloop/agentloop.go265
-rw-r--r--internal/agentloop/tools.go226
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)
+ }
+}