v0.29.1 d92a60f

subagent_spawn.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"io"
9	"strings"
10	"time"
11
12	"github.com/bnema/zut/packages/agent/subagents"
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15	"github.com/google/uuid"
16)
17
18// SubagentSpawnTool lets the main agent delegate work through the resident
19// manager. A spawn without wait returns after acceptance; an explicit bounded
20// wait may return the initial completion or expire while the child stays active.
21// Required work records a durable obligation that must be resolved before the
22// parent can produce its terminal response.
23//
24// Available when the host's launch-time tool policy permits delegation. The
25// primary-agent prompt decides whether delegation is proactive or only on an
26// explicit user request.
27const (
28	SubagentSpawnToolName  = "subagent_spawn"
29	SubagentStatusToolName = "subagent_status"
30	SubagentStopToolName   = "subagent_stop"
31	SubagentResumeToolName = "subagent_resume"
32
33	maxSubagentWaitSeconds = 5 * 60
34)
35
36type SubagentSpawnTool struct {
37	ResidentManager   *subagents.ResidentManager
38	BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
39	OnResidentSpawned func(subagents.ResidentChildSpec, string)
40
41	// Enabled reports whether the host currently exposes this tool. When nil,
42	// the tool is treated as disabled.
43	Enabled func() bool
44
45	// DefaultModel and DefaultProvider return the host agent's resolved
46	// model and provider. They are used when the tool call omits both
47	// fields and does not select a named profile, so auto-subagents follows
48	// the same auth route as the user sees in the parent session.
49	DefaultModel     func() string
50	DefaultProvider  func() string
51	DefaultReasoning func() string
52
53	// ResolveSubagent validates and resolves a named markdown profile.
54	ResolveSubagent func(name string) (*subagents.Profile, error)
55}
56
57// ResidentSpawnRequest is the resolved tool input the host uses to construct
58// a complete non-secret resident ChildSpec.
59type ResidentSpawnRequest struct {
60	Task          string
61	Profile       *subagents.Profile
62	Model         string
63	Provider      string
64	Reasoning     string
65	FastMode      *bool
66	Required      bool
67	WorkspaceMode subagents.WorkspaceMode
68}
69
70type subagentSpawnArgs struct {
71	Task      string `json:"task"`
72	Agent     string `json:"agent,omitempty"`
73	Model     string `json:"model,omitempty"`
74	Provider  string `json:"provider,omitempty"`
75	Reasoning string `json:"reasoning,omitempty"`
76	FastMode  *bool  `json:"fast_mode,omitempty"`
77	Required  bool   `json:"required,omitempty"`
78	Wait      *int   `json:"wait,omitempty"`
79	Isolation string `json:"isolation,omitempty"`
80}
81
82const subagentSpawnSchemaTemplate = `{
83  "type": "object",
84  "properties": {
85    "task": {
86      "type": "string",
87      "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. 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 starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it."
88    },
89    "agent": {
90      "type": "string",
91      "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."
92    },
93    "model": {
94      "type": "string",
95      "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."
96    },
97    "provider": {
98      "type": "string",
99      "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."
100    },
101    "reasoning": {
102      "type": "string",
103      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
104      "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
105    },
106    "fast_mode": {
107      "type": "boolean",
108      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
109    },
110    "required": {
111      "type": "boolean",
112      "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. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation."
113    },
114    "wait": {
115      "type": "integer",
116      "minimum": 1,
117      "maximum": 300,
118      "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
119    },
120    "isolation": {
121      "type": "string",
122      "enum": ["shared", "worktree"],
123      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
124    }
125  },
126  "required": ["task"]
127}`
128
129func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
130func (t *SubagentSpawnTool) Description() string {
131	return "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. 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, dashboard, metadata, or file checks solely to wait."
132}
133func (t *SubagentSpawnTool) Schema() json.RawMessage {
134	return json.RawMessage(subagentSpawnSchemaTemplate)
135}
136
137func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
138	prefix := t.Name()
139	if t.ResidentManager == nil {
140		return protocolToolError(prefix + ": subagent runtime not available in this mode")
141	}
142	if t.Enabled == nil || !t.Enabled() {
143		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
144	}
145	var a subagentSpawnArgs
146	if err := decodeSubagentArgs(raw, &a); err != nil {
147		return core.ToolResult{}, err
148	}
149	task := strings.TrimSpace(a.Task)
150	if task == "" {
151		return protocolToolError(prefix + ": task is required")
152	}
153	if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
154		return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
155	}
156
157	workspaceMode := subagents.WorkspaceShared
158	if value := strings.TrimSpace(a.Isolation); value != "" {
159		workspaceMode = subagents.WorkspaceMode(value)
160		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
161			return protocolToolError(prefix + ": isolation must be shared or worktree")
162		}
163	}
164	agentName := strings.TrimSpace(a.Agent)
165	var profile *subagents.Profile
166	var fastModeOverride *bool
167	if agentName != "" {
168		if t.ResolveSubagent == nil {
169			return protocolToolError(prefix + ": named subagent profiles are unavailable")
170		}
171		var err error
172		profile, err = t.ResolveSubagent(agentName)
173		if err != nil {
174			return protocolToolError(prefix + ": " + err.Error())
175		}
176		if profile == nil {
177			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
178		}
179		fastModeOverride = profile.FastMode
180	}
181	if a.FastMode != nil {
182		fastModeOverride = a.FastMode
183	}
184	model := strings.TrimSpace(a.Model)
185	providerID := strings.TrimSpace(a.Provider)
186	if (model == "") != (providerID == "") {
187		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
188	}
189	if profile != nil {
190		// A profile may specify a qualified model, a bare model, a provider,
191		// or neither. Fill only missing pieces so explicit spawn options win;
192		// the parent session supplies whatever the profile leaves unspecified.
193		profileProvider, profileModel := profile.ModelSelection()
194		if model == "" {
195			model = profileModel
196		}
197		if providerID == "" {
198			providerID = profileProvider
199		}
200	}
201	if model == "" && t.DefaultModel != nil {
202		model = strings.TrimSpace(t.DefaultModel())
203	}
204	if providerID == "" && t.DefaultProvider != nil {
205		providerID = strings.TrimSpace(t.DefaultProvider())
206	}
207
208	reasoning, err := normalizeReasoning(a.Reasoning)
209	if err != nil {
210		return protocolToolError(prefix + ": " + err.Error())
211	}
212	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
213		reasoning, err = normalizeReasoning(profile.Thinking)
214		if err != nil {
215			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
216		}
217	}
218	if reasoning == "" && t.DefaultReasoning != nil {
219		reasoning, err = normalizeReasoning(t.DefaultReasoning())
220		if err != nil {
221			return protocolToolError(prefix + ": host " + err.Error())
222		}
223	}
224	if t.BuildResidentSpec == nil {
225		return protocolToolError(prefix + ": resident child factory is unavailable")
226	}
227	spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
228		Task: task, Profile: profile, Model: model, Provider: providerID,
229		Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
230		WorkspaceMode: workspaceMode,
231	})
232	if err != nil {
233		return protocolToolError(prefix + ": " + err.Error())
234	}
235	var completion subagents.ResidentCompletion
236	waitTimedOut := false
237	if a.Wait != nil {
238		spec.InitialTurnID = uuid.NewString()
239		completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
240		defer cancelWait()
241		if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
242			return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
243		}
244		waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
245		defer cancel()
246		select {
247		case result, ok := <-completionResult:
248			if !ok {
249				return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
250			}
251			completion = result
252		case <-waitCtx.Done():
253			if ctx.Err() != nil {
254				return core.ToolResult{}, ctx.Err()
255			}
256			select {
257			case result, ok := <-completionResult:
258				if !ok {
259					return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
260				}
261				completion = result
262			default:
263				waitTimedOut = true
264			}
265		}
266	} else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
267		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
268	}
269	state := string(subagents.ResidentQueued)
270	if a.Wait != nil {
271		if waitTimedOut {
272			if current, ok := t.ResidentManager.State(spec.ID); ok {
273				state = string(current)
274			}
275		} else {
276			state = completion.Completion().Status
277		}
278	}
279	var sb strings.Builder
280	fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
281	if spec.Profile != "" {
282		fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
283	}
284	fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
285	if spec.Reasoning != "" {
286		fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
287	}
288	if spec.BudgetLimit > 0 {
289		fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
290	}
291	if spec.Required {
292		fmt.Fprintf(&sb, "required: %s\n", state)
293	}
294	if a.Wait != nil {
295		if waitTimedOut {
296			fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
297			sb.WriteString("\nThe accepted sub-agent remains active in the background. It owns the delegated scope: do not repeat that work in the parent. Continue only with a previously selected non-overlapping task, or end/yield for the host-event-driven [auto-subagents update].")
298		} else if completion.Err != nil {
299			fmt.Fprintf(&sb, "error: %s\n", completion.Err)
300			if completion.Summary != "" {
301				fmt.Fprintf(&sb, "partial: %s\n", completion.Summary)
302			}
303		} else {
304			if completion.Summary != "" {
305				fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
306			}
307		}
308	} else {
309		sb.WriteString("\nThe sub-agent is running in the background and owns the delegated scope. Do not repeat that work in the parent. Continue only with a previously selected non-overlapping task, or end/yield for the host-event-driven [auto-subagents update].")
310	}
311	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
312}
313
314func decodeSubagentArgs(raw json.RawMessage, target any) error {
315	decoder := json.NewDecoder(bytes.NewReader(raw))
316	decoder.DisallowUnknownFields()
317	if err := decoder.Decode(target); err != nil {
318		return fmt.Errorf("invalid args: %w", err)
319	}
320	if err := decoder.Decode(&struct{}{}); err != io.EOF {
321		return fmt.Errorf("invalid args: trailing JSON value")
322	}
323	return nil
324}
325
326func normalizeReasoning(value string) (string, error) {
327	value = strings.TrimSpace(value)
328	if value == "" {
329		return "", nil
330	}
331	normalized := provider.NormalizeReasoning(value)
332	switch normalized {
333	case "":
334		return "off", nil
335	case "minimum", "low", "medium", "high", "xhigh", "max":
336		return normalized, nil
337	default:
338		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
339	}
340}
341
342// protocolToolError keeps model-visible validation failures in the
343// ToolResult channel rather than treating them as host execution errors.
344func protocolToolError(msg string) (core.ToolResult, error) {
345	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
346	return toolErr(msg), nil
347}
348
349func toolErr(msg string) core.ToolResult {
350	return core.ToolResult{
351		Content: []provider.Content{provider.TextBlock{Text: msg}},
352		IsError: true,
353	}
354}
355
356func truncateTask(s string, n int) string {
357	if len(s) <= n {
358		return s
359	}
360	return s[:n-3] + "..."
361}

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