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/executor/nativerunner.go | |
| 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/executor/nativerunner.go')
| -rw-r--r-- | internal/executor/nativerunner.go | 127 |
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) +} |
