// 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")) }