summaryrefslogtreecommitdiff
path: root/internal/executor/nativerunner.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/executor/nativerunner.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/executor/nativerunner.go')
-rw-r--r--internal/executor/nativerunner.go127
1 files changed, 127 insertions, 0 deletions
diff --git a/internal/executor/nativerunner.go b/internal/executor/nativerunner.go
new file mode 100644
index 0000000..6fe4196
--- /dev/null
+++ b/internal/executor/nativerunner.go
@@ -0,0 +1,127 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/thepeterstone/claudomator/internal/agentloop"
+ "github.com/thepeterstone/claudomator/internal/provider"
+ "github.com/thepeterstone/claudomator/internal/sandbox"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// NativeRunner executes a task against a provider.Provider (a native chat/
+// tool-use backend for some LLM vendor's wire format) using the shared
+// agentloop.Loop tool-use control flow. It supersedes LocalRunner, which drove
+// the same control flow inline against internal/llm.Client directly; that
+// logic was extracted into internal/agentloop (and internal/sandbox for the
+// git/file/bash/glob tool implementations) so any provider.Provider — not
+// just the OpenAI-compatible one — can share it. Like LocalRunner, it does
+// not spawn a subprocess: it produces text/tool-call completions that are
+// streamed to stdout.log in the same stream-json envelope ClaudeRunner uses,
+// so existing parsers (extractSummary, task.ParseChangestatFromFile) keep
+// working unchanged.
+type NativeRunner struct {
+ Provider provider.Provider
+ Logger *slog.Logger
+ LogDir string
+
+ // DefaultTemperature is used when a task doesn't set its own
+ // Agent.Temperature.
+ DefaultTemperature float64
+
+ // DefaultModel names the provider's default model (e.g. config.LocalModel's
+ // configured model). It's used only to decide whether agent tools should be
+ // declared at all (some tiny local models — "tinyllama" in the model name —
+ // don't support tool-use) when the task itself doesn't specify a model,
+ // mirroring LocalRunner's historical fallback to its llm.Client.Model.
+ DefaultModel string
+
+ // MaxTurns bounds the tool-use loop; defaults to agentloop.DefaultMaxTurns
+ // (12, matching the former executor.maxLocalToolTurns) when zero.
+ MaxTurns int
+}
+
+var _ Runner = (*NativeRunner)(nil)
+var _ LogPather = (*NativeRunner)(nil)
+
+// ExecLogDir implements LogPather so the pool can persist log paths before
+// execution starts. Identical to LocalRunner.ExecLogDir.
+func (r *NativeRunner) ExecLogDir(execID string) string {
+ if r.LogDir == "" {
+ return ""
+ }
+ return filepath.Join(r.LogDir, execID)
+}
+
+// Run drives a chat completion against r.Provider with the agent back-channel
+// tools (ask_user/report_summary/spawn_subtask/record_progress) and sandbox
+// tools (read_file/write_file/run_bash/glob) declared, via agentloop.Loop. If
+// the model calls ask_user, the run stops and returns a *BlockedError.
+func (r *NativeRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error {
+ if r.Provider == nil {
+ return fmt.Errorf("native runner: no provider configured")
+ }
+ if t.Agent.Instructions == "" {
+ return fmt.Errorf("native runner: empty instructions")
+ }
+
+ logDir := r.ExecLogDir(e.ID)
+ if logDir == "" {
+ return fmt.Errorf("native runner: LogDir not set")
+ }
+ if err := os.MkdirAll(logDir, 0o700); err != nil {
+ return fmt.Errorf("native runner: mkdir log: %w", err)
+ }
+ stdoutPath := filepath.Join(logDir, "stdout.log")
+ stderrPath := filepath.Join(logDir, "stderr.log")
+ e.StdoutPath = stdoutPath
+ e.StderrPath = stderrPath
+
+ stdout, err := os.Create(stdoutPath)
+ if err != nil {
+ return fmt.Errorf("native runner: create stdout: %w", err)
+ }
+ defer stdout.Close()
+
+ // --- Sandbox setup (git clone into a temp dir, or reuse on resume) ---
+ sb := sandbox.NewHostSandbox("")
+ if t.Agent.ProjectDir != "" {
+ if e.SandboxDir != "" {
+ // Reuse existing sandbox on resume (BLOCKED → QUEUED path).
+ sb = sandbox.NewHostSandbox(e.SandboxDir)
+ } else {
+ if cloneErr := sb.GitClone(ctx, t.Agent.ProjectDir, ""); cloneErr != nil {
+ return fmt.Errorf("native runner: %w", cloneErr)
+ }
+ e.SandboxDir = sb.WorkDir()
+ }
+ }
+
+ // Only provide tools if we aren't using a tiny model known to lack support.
+ effectiveModel := t.Agent.Model
+ if effectiveModel == "" {
+ effectiveModel = r.DefaultModel
+ }
+ toolsEnabled := !strings.Contains(strings.ToLower(effectiveModel), "tinyllama")
+
+ // Build the agent's instructions after the tools decision so the planning
+ // preamble is only included when the model actually supports the agent
+ // tools — same ordering LocalRunner used.
+ instructions := buildAgentInstructions(t, toolsEnabled)
+
+ loop := &agentloop.Loop{
+ Provider: r.Provider,
+ Sandbox: sb,
+ Channel: ch,
+ MaxTurns: r.MaxTurns,
+ DefaultTemperature: r.DefaultTemperature,
+ Logger: r.Logger,
+ }
+ return loop.Run(ctx, t, e, stdout, instructions, toolsEnabled)
+}