v0.15.0 6b178f8

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. Every spawn returns immediately and runs in
17// the background. Required work records a durable obligation that must be
18// resolved before the parent can produce its terminal response.
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 every worker for asynchronous completion.
53	OnSpawned func(agent *subagents.Agent, task string, required bool)
54}
55
56type subagentSpawnArgs struct {
57	Task      string `json:"task"`
58	Agent     string `json:"agent,omitempty"`
59	Model     string `json:"model,omitempty"`
60	Provider  string `json:"provider,omitempty"`
61	Reasoning string `json:"reasoning,omitempty"`
62	FastMode  *bool  `json:"fast_mode,omitempty"`
63	Required  bool   `json:"required,omitempty"`
64	Isolation string `json:"isolation,omitempty"`
65	Timeout   string `json:"timeout,omitempty"`
66	MaxTurns  *int   `json:"max_turns,omitempty"`
67}
68
69const subagentSpawnSchemaTemplate = `{
70  "type": "object",
71  "properties": {
72    "task": {
73      "type": "string",
74      "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."
75    },
76    "agent": {
77      "type": "string",
78      "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."
79    },
80    "model": {
81      "type": "string",
82      "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."
83    },
84    "provider": {
85      "type": "string",
86      "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."
87    },
88    "reasoning": {
89      "type": "string",
90      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
91      "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
92    },
93    "fast_mode": {
94      "type": "boolean",
95      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
96    },
97    "required": {
98      "type": "boolean",
99      "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. 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."
100    },
101    "isolation": {
102      "type": "string",
103      "enum": ["shared", "worktree"],
104      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
105    },
106    "timeout": {
107      "type": "string",
108      "description": "Optional Go duration such as 20m."
109    },
110    "max_turns": {
111      "type": "integer",
112      "minimum": 1,
113      "maximum": %d,
114      "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
115    }
116  },
117  "required": ["task"]
118}`
119
120func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
121func (t *SubagentSpawnTool) Description() string {
122	return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. 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."
123}
124func (t *SubagentSpawnTool) Schema() json.RawMessage {
125	maxTurns := 3
126	if t.Supervisor != nil {
127		if limit := t.Supervisor.MaxTurns(); limit > 0 {
128			maxTurns = limit
129		}
130	}
131	return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
132}
133
134func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
135	prefix := t.Name()
136	if t.Supervisor == nil {
137		return protocolToolError(prefix + ": subagent supervisor not available in this mode")
138	}
139	if t.Enabled == nil || !t.Enabled() {
140		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
141	}
142	var a subagentSpawnArgs
143	if err := json.Unmarshal(raw, &a); err != nil {
144		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
145	}
146	task := strings.TrimSpace(a.Task)
147	if task == "" {
148		return protocolToolError(prefix + ": task is required")
149	}
150
151	workspaceMode := subagents.WorkspaceShared
152	if value := strings.TrimSpace(a.Isolation); value != "" {
153		workspaceMode = subagents.WorkspaceMode(value)
154		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
155			return protocolToolError(prefix + ": isolation must be shared or worktree")
156		}
157	}
158	var timeout time.Duration
159	if value := strings.TrimSpace(a.Timeout); value != "" {
160		parsed, parseErr := time.ParseDuration(value)
161		if parseErr != nil || parsed <= 0 {
162			return protocolToolError(prefix + ": timeout must be a positive duration")
163		}
164		timeout = parsed
165	}
166	if a.MaxTurns != nil {
167		if *a.MaxTurns < 1 {
168			return protocolToolError(prefix + ": max_turns must be positive")
169		}
170		if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
171			return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
172		}
173	}
174
175	agentName := strings.TrimSpace(a.Agent)
176	var profile *subagents.Profile
177	var fastModeOverride *bool
178	fastModeFromProfile := false
179	if agentName != "" {
180		if t.ResolveSubagent == nil {
181			return protocolToolError(prefix + ": named subagent profiles are unavailable")
182		}
183		var err error
184		profile, err = t.ResolveSubagent(agentName)
185		if err != nil {
186			return protocolToolError(prefix + ": " + err.Error())
187		}
188		if profile == nil {
189			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
190		}
191		agentName = profile.Name
192		fastModeOverride = profile.FastMode
193		fastModeFromProfile = profile.FastMode != nil
194	}
195	if a.FastMode != nil {
196		fastModeOverride = a.FastMode
197		fastModeFromProfile = false
198	}
199
200	model := strings.TrimSpace(a.Model)
201	providerID := strings.TrimSpace(a.Provider)
202	var profileTools []string
203	if profile != nil {
204		profileTools = append([]string(nil), profile.Tools...)
205	}
206	if (model == "") != (providerID == "") {
207		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
208	}
209	if profile != nil {
210		// A profile may specify a qualified model, a bare model, a provider,
211		// or neither. Fill only missing pieces so explicit spawn options win;
212		// the parent session supplies whatever the profile leaves unspecified.
213		profileProvider, profileModel := profile.ModelSelection()
214		if model == "" {
215			model = profileModel
216		}
217		if providerID == "" {
218			providerID = profileProvider
219		}
220	}
221	if model == "" && t.DefaultModel != nil {
222		model = strings.TrimSpace(t.DefaultModel())
223	}
224	if providerID == "" && t.DefaultProvider != nil {
225		providerID = strings.TrimSpace(t.DefaultProvider())
226	}
227
228	reasoning, err := normalizeReasoning(a.Reasoning)
229	if err != nil {
230		return protocolToolError(prefix + ": " + err.Error())
231	}
232	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
233		reasoning, err = normalizeReasoning(profile.Thinking)
234		if err != nil {
235			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
236		}
237	}
238	if reasoning == "" && t.DefaultReasoning != nil {
239		reasoning, err = normalizeReasoning(t.DefaultReasoning())
240		if err != nil {
241			return protocolToolError(prefix + ": host " + err.Error())
242		}
243	}
244
245	maxTurns := 0
246	if a.MaxTurns != nil {
247		maxTurns = *a.MaxTurns
248	}
249	agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
250		Task:          task,
251		Model:         model,
252		Provider:      providerID,
253		Reasoning:     reasoning,
254		FastMode:      fastModeOverride,
255		Subagent:      agentName,
256		Required:      a.Required,
257		Timeout:       timeout,
258		MaxTurns:      maxTurns,
259		WorkspaceMode: workspaceMode,
260		Tools:         profileTools,
261	})
262	if err != nil {
263		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
264	}
265	if t.OnSpawned != nil {
266		t.OnSpawned(agent, task, a.Required)
267	}
268	var sb strings.Builder
269	fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
270	fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
271	fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
272	fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
273	if agentName != "" {
274		fmt.Fprintf(&sb, "agent: %s\n", agentName)
275	}
276	if model != "" {
277		fmt.Fprintf(&sb, "model: %s\n", model)
278	}
279	if providerID != "" {
280		fmt.Fprintf(&sb, "provider: %s\n", providerID)
281	}
282	if reasoning != "" {
283		fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
284	}
285	if agent.FastMode {
286		sb.WriteString("fast mode: enabled\n")
287	}
288	if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
289		sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
290	}
291	if a.Required {
292		sb.WriteString("required: pending\n")
293	}
294	sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
295	sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
296	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.")
297	details := map[string]any{
298		"agent_id":      agent.ID,
299		"task":          task,
300		"agent":         agentName,
301		"model":         model,
302		"provider":      providerID,
303		"reasoning":     reasoning,
304		"fast_mode":     agent.FastMode,
305		"required":      a.Required,
306		"isolation":     string(workspaceMode),
307		"timeout":       agent.Timeout.String(),
308		"max_turns":     agent.MaxTurns,
309		"state":         agent.Status(),
310		"process_state": string(agent.ProcessState()),
311		"turn_state":    string(agent.TurnState()),
312		"result_ref":    subagents.ResultRef(agent.ID),
313	}
314	if a.Required {
315		details["requirement_state"] = string(subagents.RequirementPending)
316	}
317	return core.ToolResult{
318		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
319		Details: details,
320	}, nil
321}
322
323func normalizeReasoning(value string) (string, error) {
324	value = strings.TrimSpace(value)
325	if value == "" {
326		return "", nil
327	}
328	normalized := provider.NormalizeReasoning(value)
329	switch normalized {
330	case "":
331		return "off", nil
332	case "minimum", "low", "medium", "high", "xhigh", "max":
333		return normalized, nil
334	default:
335		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
336	}
337}
338
339// protocolToolError keeps model-visible validation failures in the
340// ToolResult channel rather than treating them as host execution errors.
341func protocolToolError(msg string) (core.ToolResult, error) {
342	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
343	return toolErr(msg), nil
344}
345
346func toolErr(msg string) core.ToolResult {
347	return core.ToolResult{
348		Content: []provider.Content{provider.TextBlock{Text: msg}},
349		IsError: true,
350	}
351}
352
353func truncateTask(s string, n int) string {
354	if len(s) <= n {
355		return s
356	}
357	return s[:n-3] + "..."
358}

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