v0.13.0 befd698

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 delegate work against the host's cwd
16// via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs
17// in the background. Required work keeps the tool call open until the delegated
18// turn reaches a durable terminal outcome.
19//
20// Available when the host's launch-time tool policy permits delegation. The
21// primary-agent prompt decides whether delegation is proactive or only on an
22// explicit user request.
23const (
24	SubagentSpawnToolName  = "subagent_spawn"
25	SubagentStatusToolName = "subagent_status"
26	SubagentStopToolName   = "subagent_stop"
27	SubagentResumeToolName = "subagent_resume"
28)
29
30type SubagentSpawnTool struct {
31	// Supervisor is the supervisor used to spawn agents. Nil means subagents
32	// are unavailable in this mode and the tool always errors.
33	Supervisor *subagents.Supervisor
34
35	// Enabled reports whether the host currently exposes this tool. When nil,
36	// the tool is treated as disabled.
37	Enabled func() bool
38
39	// DefaultModel and DefaultProvider return the host agent's resolved
40	// model and provider. They are used when the tool call omits both
41	// fields and does not select a named profile, so auto-subagents follows
42	// the same auth route as the user sees in the parent session.
43	DefaultModel     func() string
44	DefaultProvider  func() string
45	DefaultReasoning func() string
46
47	// ResolveSubagent validates and resolves a named markdown profile.
48	// The child receives only the name and loads the profile itself.
49	ResolveSubagent func(name string) (*subagents.Profile, error)
50
51	// OnSpawned, if set, is called after every successful spawn with the
52	// requirement mode. Hosts track optional work for asynchronous completion;
53	// required work is delivered by this blocking tool call instead.
54	OnSpawned func(agent *subagents.Agent, task string, required bool)
55}
56
57type subagentSpawnArgs struct {
58	Task      string `json:"task"`
59	Agent     string `json:"agent,omitempty"`
60	Model     string `json:"model,omitempty"`
61	Provider  string `json:"provider,omitempty"`
62	Reasoning string `json:"reasoning,omitempty"`
63	Thinking  string `json:"thinking,omitempty"`
64	FastMode  *bool  `json:"fast_mode,omitempty"`
65	Required  bool   `json:"required,omitempty"`
66	Isolation string `json:"isolation,omitempty"`
67	Timeout   string `json:"timeout,omitempty"`
68	MaxTurns  *int   `json:"max_turns,omitempty"`
69}
70
71const subagentSpawnSchemaTemplate = `{
72  "type": "object",
73  "properties": {
74    "task": {
75      "type": "string",
76      "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."
77    },
78    "agent": {
79      "type": "string",
80      "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."
81    },
82    "model": {
83      "type": "string",
84      "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."
85    },
86    "provider": {
87      "type": "string",
88      "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."
89    },
90    "reasoning": {
91      "type": "string",
92      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
93      "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided."
94    },
95    "thinking": {
96      "type": "string",
97      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
98      "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
99    },
100    "fast_mode": {
101      "type": "boolean",
102      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
103    },
104    "required": {
105      "type": "boolean",
106      "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
107    },
108    "isolation": {
109      "type": "string",
110      "enum": ["shared", "worktree"],
111      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
112    },
113    "timeout": {
114      "type": "string",
115      "description": "Optional Go duration such as 20m."
116    },
117    "max_turns": {
118      "type": "integer",
119      "minimum": 1,
120      "maximum": %d,
121      "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
122    }
123  },
124  "required": ["task"]
125}`
126
127func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
128func (t *SubagentSpawnTool) Description() string {
129	return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional 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."
130}
131func (t *SubagentSpawnTool) Schema() json.RawMessage {
132	maxTurns := 3
133	if t.Supervisor != nil {
134		if limit := t.Supervisor.MaxTurns(); limit > 0 {
135			maxTurns = limit
136		}
137	}
138	return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
139}
140
141func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
142	prefix := t.Name()
143	if t.Supervisor == nil {
144		return protocolToolError(prefix + ": subagent supervisor not available in this mode")
145	}
146	if t.Enabled == nil || !t.Enabled() {
147		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
148	}
149	var a subagentSpawnArgs
150	if err := json.Unmarshal(raw, &a); err != nil {
151		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
152	}
153	task := strings.TrimSpace(a.Task)
154	if task == "" {
155		return protocolToolError(prefix + ": task is required")
156	}
157
158	workspaceMode := subagents.WorkspaceShared
159	if value := strings.TrimSpace(a.Isolation); value != "" {
160		workspaceMode = subagents.WorkspaceMode(value)
161		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
162			return protocolToolError(prefix + ": isolation must be shared or worktree")
163		}
164	}
165	var timeout time.Duration
166	if value := strings.TrimSpace(a.Timeout); value != "" {
167		parsed, parseErr := time.ParseDuration(value)
168		if parseErr != nil || parsed <= 0 {
169			return protocolToolError(prefix + ": timeout must be a positive duration")
170		}
171		timeout = parsed
172	}
173	if a.MaxTurns != nil {
174		if *a.MaxTurns < 1 {
175			return protocolToolError(prefix + ": max_turns must be positive")
176		}
177		if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
178			return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
179		}
180	}
181
182	agentName := strings.TrimSpace(a.Agent)
183	var profile *subagents.Profile
184	var fastModeOverride *bool
185	fastModeFromProfile := false
186	if agentName != "" {
187		if t.ResolveSubagent == nil {
188			return protocolToolError(prefix + ": named subagent profiles are unavailable")
189		}
190		var err error
191		profile, err = t.ResolveSubagent(agentName)
192		if err != nil {
193			return protocolToolError(prefix + ": " + err.Error())
194		}
195		if profile == nil {
196			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
197		}
198		agentName = profile.Name
199		fastModeOverride = profile.FastMode
200		fastModeFromProfile = profile.FastMode != nil
201	}
202	if a.FastMode != nil {
203		fastModeOverride = a.FastMode
204		fastModeFromProfile = false
205	}
206
207	model := strings.TrimSpace(a.Model)
208	providerID := strings.TrimSpace(a.Provider)
209	var profileTools []string
210	if profile != nil {
211		profileTools = append([]string(nil), profile.Tools...)
212	}
213	if (model == "") != (providerID == "") {
214		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
215	}
216	if profile != nil {
217		// A profile may specify a qualified model, a bare model, a provider,
218		// or neither. Fill only missing pieces so explicit spawn options win;
219		// the parent session supplies whatever the profile leaves unspecified.
220		profileProvider, profileModel := profile.ModelSelection()
221		if model == "" {
222			model = profileModel
223		}
224		if providerID == "" {
225			providerID = profileProvider
226		}
227	}
228	if model == "" && t.DefaultModel != nil {
229		model = strings.TrimSpace(t.DefaultModel())
230	}
231	if providerID == "" && t.DefaultProvider != nil {
232		providerID = strings.TrimSpace(t.DefaultProvider())
233	}
234
235	reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
236	if err != nil {
237		return protocolToolError(prefix + ": " + err.Error())
238	}
239	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
240		reasoning, err = reasoningOverride(profile.Thinking, "")
241		if err != nil {
242			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
243		}
244	}
245	if reasoning == "" && t.DefaultReasoning != nil {
246		reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
247		if err != nil {
248			return protocolToolError(prefix + ": host " + err.Error())
249		}
250	}
251
252	maxTurns := 0
253	if a.MaxTurns != nil {
254		maxTurns = *a.MaxTurns
255	}
256	agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
257		Task:          task,
258		Model:         model,
259		Provider:      providerID,
260		Reasoning:     reasoning,
261		FastMode:      fastModeOverride,
262		Subagent:      agentName,
263		Required:      a.Required,
264		Timeout:       timeout,
265		MaxTurns:      maxTurns,
266		WorkspaceMode: workspaceMode,
267		Tools:         profileTools,
268	})
269	if err != nil {
270		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
271	}
272	if t.OnSpawned != nil {
273		t.OnSpawned(agent, task, a.Required)
274	}
275	if a.Required {
276		return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
277	}
278
279	var sb strings.Builder
280	fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
281	fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
282	fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
283	fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
284	if agentName != "" {
285		fmt.Fprintf(&sb, "agent: %s\n", agentName)
286	}
287	if model != "" {
288		fmt.Fprintf(&sb, "model: %s\n", model)
289	}
290	if providerID != "" {
291		fmt.Fprintf(&sb, "provider: %s\n", providerID)
292	}
293	if reasoning != "" {
294		fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
295	}
296	if agent.FastMode {
297		sb.WriteString("fast mode: enabled\n")
298	}
299	if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
300		sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
301	}
302	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. ")
303	sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
304	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.")
305	return core.ToolResult{
306		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
307		Details: map[string]any{
308			"agent_id":      agent.ID,
309			"task":          task,
310			"agent":         agentName,
311			"model":         model,
312			"provider":      providerID,
313			"reasoning":     reasoning,
314			"fast_mode":     agent.FastMode,
315			"required":      false,
316			"isolation":     string(workspaceMode),
317			"timeout":       agent.Timeout.String(),
318			"max_turns":     agent.MaxTurns,
319			"state":         agent.Status(),
320			"process_state": string(agent.ProcessState()),
321			"turn_state":    string(agent.TurnState()),
322			"result_ref":    subagents.ResultRef(agent.ID),
323		},
324	}, nil
325}
326
327func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328	if progress != nil {
329		progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330	}
331	requirement, err := supervisor.WaitRequired(ctx, agent.ID)
332	if err != nil {
333		return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
334	}
335	snapshot := agent.Snapshot()
336	var sb strings.Builder
337	fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State)
338	fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
339	fmt.Fprintf(&sb, "requirement: %s\n", requirement.State)
340	if requirement.ErrorCode != "" {
341		fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode)
342		fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID))
343	}
344	output := strings.TrimSpace(snapshot.LastAssistant)
345	if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
346		output = strings.TrimSpace(snapshot.Result.Output)
347	}
348	if output != "" {
349		sb.WriteString("\nresult:\n")
350		sb.WriteString(output)
351		sb.WriteByte('\n')
352	}
353	if requirement.Unmet() {
354		sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.")
355	} else {
356		sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.")
357	}
358	return core.ToolResult{
359		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
360		Details: map[string]any{
361			"agent_id":          agent.ID,
362			"task":              task,
363			"required":          true,
364			"requirement_state": string(requirement.State),
365			"state":             snapshot.Status,
366			"process_state":     string(snapshot.ProcessState),
367			"turn_state":        string(snapshot.TurnState),
368			"result_ref":        subagents.ResultRef(agent.ID),
369		},
370		IsError: requirement.Unmet(),
371	}, nil
372}
373
374func reasoningOverride(reasoning, thinking string) (string, error) {
375	reasoning = strings.TrimSpace(reasoning)
376	thinking = strings.TrimSpace(thinking)
377	if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) {
378		return "", fmt.Errorf("reasoning and thinking disagree; provide only one")
379	}
380	value := reasoning
381	if value == "" {
382		value = thinking
383	}
384	if value == "" {
385		return "", nil
386	}
387	normalized := provider.NormalizeReasoning(value)
388	switch normalized {
389	case "":
390		return "off", nil
391	case "minimum", "low", "medium", "high", "xhigh", "max":
392		return normalized, nil
393	default:
394		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
395	}
396}
397
398// protocolToolError keeps model-visible validation failures in the
399// ToolResult channel rather than treating them as host execution errors.
400func protocolToolError(msg string) (core.ToolResult, error) {
401	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
402	return toolErr(msg), nil
403}
404
405func toolErr(msg string) core.ToolResult {
406	return core.ToolResult{
407		Content: []provider.Content{provider.TextBlock{Text: msg}},
408		IsError: true,
409	}
410}
411
412func truncateTask(s string, n int) string {
413	if len(s) <= n {
414		return s
415	}
416	return s[:n-3] + "..."
417}

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 476

↑↓ moveenter openesc close