1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
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)
}
|