summaryrefslogtreecommitdiff
path: root/internal/executor/nativerunner.go
blob: 6fe41967df8abced33f1176c8799d33332d5cbe6 (plain)
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)
}