v0.12.3 38097d7

subagent_spawn.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"time"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13)
14
15// SubagentSpawnTool lets the main agent fork a background sub-agent
16// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs
17// in parallel: the tool returns the agent id immediately and the main
18// turn continues uninterrupted. The user can monitor / chat with the
19// spawned agent via /subagents.
20//
21// Available when the host's launch-time tool policy permits delegation. The
22// primary-agent prompt decides whether delegation is proactive or only on an
23// explicit user request.
24type SubagentSpawnTool struct {
25	// Supervisor is the supervisor used to spawn agents. Nil means subagents
26	// are unavailable in this mode and the tool always errors.
27	Supervisor *subagents.Supervisor
28
29	// Enabled reports whether the host currently exposes this tool. When nil,
30	// the tool is treated as disabled.
31	Enabled func() bool
32
33	// DefaultModel and DefaultProvider return the host agent's resolved
34	// model and provider. They are used when the tool call omits both
35	// fields and does not select a named profile, so auto-subagents follows
36	// the same auth route as the user sees in the parent session.
37	DefaultModel     func() string
38	DefaultProvider  func() string
39	DefaultReasoning func() string
40
41	// ResolveSubagent validates and resolves a named markdown profile.
42	// The child receives only the name and loads the profile itself.
43	ResolveSubagent func(name string) (*subagents.Profile, error)
44
45	// OnSpawned, if set, is called after every successful spawn with
46	// the new agent + the task it was started with. Used by the
47	// interactive host to track agents and surface a summary back
48	// in chat when all sub-agents finish.
49	OnSpawned func(agent *subagents.Agent, task string)
50}
51
52type subagentSpawnArgs struct {
53	Task      string `json:"task"`
54	Agent     string `json:"agent,omitempty"`
55	Model     string `json:"model,omitempty"`
56	Provider  string `json:"provider,omitempty"`
57	Reasoning string `json:"reasoning,omitempty"`
58	Thinking  string `json:"thinking,omitempty"`
59	FastMode  *bool  `json:"fast_mode,omitempty"`
60	Isolation string `json:"isolation,omitempty"`
61	Timeout   string `json:"timeout,omitempty"`
62	MaxTurns  *int   `json:"max_turns,omitempty"`
63}
64
65const subagentSpawnSchemaTemplate = `{
66  "type": "object",
67  "properties": {
68    "task": {
69      "type": "string",
70      "description": "The full task description for the sub-agent. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and starts with NO context from this conversation."
71    },
72    "agent": {
73      "type": "string",
74      "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child."
75    },
76    "model": {
77      "type": "string",
78      "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider."
79    },
80    "provider": {
81      "type": "string",
82      "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
83    },
84    "reasoning": {
85      "type": "string",
86      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
87      "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided."
88    },
89    "thinking": {
90      "type": "string",
91      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
92      "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
93    },
94    "fast_mode": {
95      "type": "boolean",
96      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
97    },
98    "isolation": {
99      "type": "string",
100      "enum": ["shared", "worktree"],
101      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
102    },
103    "timeout": {
104      "type": "string",
105      "description": "Optional Go duration such as 20m."
106    },
107    "max_turns": {
108      "type": "integer",
109      "minimum": 1,
110      "maximum": %d,
111      "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
112    }
113  },
114  "required": ["task"]
115}`
116
117func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
118func (t *SubagentSpawnTool) Description() string {
119	return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. Completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
120}
121func (t *SubagentSpawnTool) Schema() json.RawMessage {
122	maxTurns := 3
123	if t.Supervisor != nil {
124		if limit := t.Supervisor.MaxTurns(); limit > 0 {
125			maxTurns = limit
126		}
127	}
128	return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
129}
130
131func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
132	prefix := t.Name()
133	if t.Supervisor == nil {
134		return protocolToolError(prefix + ": subagent supervisor not available in this mode")
135	}
136	if t.Enabled == nil || !t.Enabled() {
137		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
138	}
139	var a subagentSpawnArgs
140	if err := json.Unmarshal(raw, &a); err != nil {
141		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
142	}
143	task := strings.TrimSpace(a.Task)
144	if task == "" {
145		return protocolToolError(prefix + ": task is required")
146	}
147
148	workspaceMode := subagents.WorkspaceShared
149	if value := strings.TrimSpace(a.Isolation); value != "" {
150		workspaceMode = subagents.WorkspaceMode(value)
151		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
152			return protocolToolError(prefix + ": isolation must be shared or worktree")
153		}
154	}
155	var timeout time.Duration
156	if value := strings.TrimSpace(a.Timeout); value != "" {
157		parsed, parseErr := time.ParseDuration(value)
158		if parseErr != nil || parsed <= 0 {
159			return protocolToolError(prefix + ": timeout must be a positive duration")
160		}
161		timeout = parsed
162	}
163	if a.MaxTurns != nil {
164		if *a.MaxTurns < 1 {
165			return protocolToolError(prefix + ": max_turns must be positive")
166		}
167		if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
168			return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
169		}
170	}
171
172	agentName := strings.TrimSpace(a.Agent)
173	var profile *subagents.Profile
174	var fastModeOverride *bool
175	fastModeFromProfile := false
176	if agentName != "" {
177		if t.ResolveSubagent == nil {
178			return protocolToolError(prefix + ": named subagent profiles are unavailable")
179		}
180		var err error
181		profile, err = t.ResolveSubagent(agentName)
182		if err != nil {
183			return protocolToolError(prefix + ": " + err.Error())
184		}
185		if profile == nil {
186			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
187		}
188		agentName = profile.Name
189		fastModeOverride = profile.FastMode
190		fastModeFromProfile = profile.FastMode != nil
191	}
192	if a.FastMode != nil {
193		fastModeOverride = a.FastMode
194		fastModeFromProfile = false
195	}
196
197	model := strings.TrimSpace(a.Model)
198	providerID := strings.TrimSpace(a.Provider)
199	var profileTools []string
200	if profile != nil {
201		profileTools = append([]string(nil), profile.Tools...)
202	}
203	if (model == "") != (providerID == "") {
204		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
205	}
206	if profile != nil {
207		// A profile may specify a qualified model, a bare model, a provider,
208		// or neither. Fill only missing pieces so explicit spawn options win;
209		// the parent session supplies whatever the profile leaves unspecified.
210		profileProvider, profileModel := profile.ModelSelection()
211		if model == "" {
212			model = profileModel
213		}
214		if providerID == "" {
215			providerID = profileProvider
216		}
217	}
218	if model == "" && t.DefaultModel != nil {
219		model = strings.TrimSpace(t.DefaultModel())
220	}
221	if providerID == "" && t.DefaultProvider != nil {
222		providerID = strings.TrimSpace(t.DefaultProvider())
223	}
224
225	reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
226	if err != nil {
227		return protocolToolError(prefix + ": " + err.Error())
228	}
229	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
230		reasoning, err = reasoningOverride(profile.Thinking, "")
231		if err != nil {
232			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
233		}
234	}
235	if reasoning == "" && t.DefaultReasoning != nil {
236		reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
237		if err != nil {
238			return protocolToolError(prefix + ": host " + err.Error())
239		}
240	}
241
242	maxTurns := 0
243	if a.MaxTurns != nil {
244		maxTurns = *a.MaxTurns
245	}
246	agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
247		Task:          task,
248		Model:         model,
249		Provider:      providerID,
250		Reasoning:     reasoning,
251		FastMode:      fastModeOverride,
252		Subagent:      agentName,
253		Timeout:       timeout,
254		MaxTurns:      maxTurns,
255		WorkspaceMode: workspaceMode,
256		Tools:         profileTools,
257	})
258	if err != nil {
259		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
260	}
261	if t.OnSpawned != nil {
262		t.OnSpawned(agent, task)
263	}
264
265	var sb strings.Builder
266	fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
267	fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
268	fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
269	fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
270	if agentName != "" {
271		fmt.Fprintf(&sb, "agent: %s\n", agentName)
272	}
273	if model != "" {
274		fmt.Fprintf(&sb, "model: %s\n", model)
275	}
276	if providerID != "" {
277		fmt.Fprintf(&sb, "provider: %s\n", providerID)
278	}
279	if reasoning != "" {
280		fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
281	}
282	if agent.FastMode {
283		sb.WriteString("fast mode: enabled\n")
284	}
285	if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
286		sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
287	}
288	sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ")
289	sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
290	sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")
291	return core.ToolResult{
292		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
293		Details: map[string]any{
294			"agent_id":      agent.ID,
295			"task":          task,
296			"agent":         agentName,
297			"model":         model,
298			"provider":      providerID,
299			"reasoning":     reasoning,
300			"fast_mode":     agent.FastMode,
301			"isolation":     string(workspaceMode),
302			"timeout":       agent.Timeout.String(),
303			"max_turns":     agent.MaxTurns,
304			"state":         agent.Status(),
305			"process_state": string(agent.ProcessState()),
306			"turn_state":    string(agent.TurnState()),
307			"result_ref":    subagents.ResultRef(agent.ID),
308		},
309	}, nil
310}
311
312func reasoningOverride(reasoning, thinking string) (string, error) {
313	reasoning = strings.TrimSpace(reasoning)
314	thinking = strings.TrimSpace(thinking)
315	if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) {
316		return "", fmt.Errorf("reasoning and thinking disagree; provide only one")
317	}
318	value := reasoning
319	if value == "" {
320		value = thinking
321	}
322	if value == "" {
323		return "", nil
324	}
325	normalized := provider.NormalizeReasoning(value)
326	switch normalized {
327	case "":
328		return "off", nil
329	case "minimum", "low", "medium", "high", "xhigh", "max":
330		return normalized, nil
331	default:
332		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
333	}
334}
335
336// protocolToolError keeps model-visible validation failures in the
337// ToolResult channel rather than treating them as host execution errors.
338func protocolToolError(msg string) (core.ToolResult, error) {
339	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
340	return toolErr(msg), nil
341}
342
343func toolErr(msg string) core.ToolResult {
344	return core.ToolResult{
345		Content: []provider.Content{provider.TextBlock{Text: msg}},
346		IsError: true,
347	}
348}
349
350func truncateTask(s string, n int) string {
351	if len(s) <= n {
352		return s
353	}
354	return s[:n-3] + "..."
355}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close