summaryrefslogtreecommitdiff
path: root/internal/agentloop/agentloop.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/agentloop/agentloop.go')
-rw-r--r--internal/agentloop/agentloop.go265
1 files changed, 265 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"))
+}