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 // SandboxKind selects the Sandbox implementation used for this runner's // tool calls: "" or "host" uses sandbox.HostSandbox (host-side git clone // with path-prefix confinement only); "docker" uses sandbox.DockerSandbox // for real container isolation. Config-driven per provider — see // internal/cli/serve.go/run.go, which set this to "docker" for the // "anthropic" runner and leave it "host" (the zero value) for "local": // local models are presumed more trusted/lower-stakes, so they keep the // weaker but zero-overhead host sandbox per the harness design. SandboxKind string // SandboxImage is the docker image used when SandboxKind == "docker". // Empty uses DockerSandbox's own default (images/agent-base, i.e. // "claudomator-agent:latest"). SandboxImage string } 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) } // newBaseSandbox constructs the unguarded Sandbox implementation selected by // r.SandboxKind, rooted at dir ("" for a fresh not-yet-cloned sandbox, an // existing directory to resume one). Callers wrap the result in // sandbox.Guarded before use. func (r *NativeRunner) newBaseSandbox(dir string) sandbox.Sandbox { if r.SandboxKind == "docker" { return sandbox.NewDockerSandbox(r.SandboxImage, dir) } return sandbox.NewHostSandbox(dir) } // 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) --- // Guardrails (DenylistBashHook/ProtectedPathHook) are applied uniformly // by the Sandbox wrapper regardless of provider or sandbox kind — there // is no per-role configurability yet. sb := &sandbox.Guarded{Sandbox: r.newBaseSandbox(""), Hooks: sandbox.DefaultHooks()} if t.Agent.ProjectDir != "" { if e.SandboxDir != "" { // Reuse existing sandbox on resume (BLOCKED → QUEUED path). sb = &sandbox.Guarded{Sandbox: r.newBaseSandbox(e.SandboxDir), Hooks: sandbox.DefaultHooks()} } 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) }