v0.27.1 131edba

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	BudgetRatio   *float64
69	BudgetTokens  *int64
70}
71
72type subagentSpawnArgs struct {
73	Task         string   `json:"task"`
74	Agent        string   `json:"agent,omitempty"`
75	Model        string   `json:"model,omitempty"`
76	Provider     string   `json:"provider,omitempty"`
77	Reasoning    string   `json:"reasoning,omitempty"`
78	FastMode     *bool    `json:"fast_mode,omitempty"`
79	Required     bool     `json:"required,omitempty"`
80	Wait         *int     `json:"wait,omitempty"`
81	Isolation    string   `json:"isolation,omitempty"`
82	BudgetRatio  *float64 `json:"budget_ratio,omitempty"`
83	BudgetTokens *int64   `json:"budget_tokens,omitempty"`
84}
85
86const subagentSpawnSchemaTemplate = `{
87  "type": "object",
88  "properties": {
89    "task": {
90      "type": "string",
91      "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."
92    },
93    "agent": {
94      "type": "string",
95      "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."
96    },
97    "model": {
98      "type": "string",
99      "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."
100    },
101    "provider": {
102      "type": "string",
103      "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."
104    },
105    "reasoning": {
106      "type": "string",
107      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
108      "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
109    },
110    "fast_mode": {
111      "type": "boolean",
112      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
113    },
114    "required": {
115      "type": "boolean",
116      "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."
117    },
118    "wait": {
119      "type": "integer",
120      "minimum": 1,
121      "maximum": 300,
122      "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."
123    },
124    "isolation": {
125      "type": "string",
126      "enum": ["shared", "worktree"],
127      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
128    },
129    "budget_ratio": {
130      "type": "number",
131      "exclusiveMinimum": 0,
132      "maximum": 1,
133      "description": "Optional cumulative rollout budget as a fraction of the parent model context window. Overrides the selected profile and host default. Mutually exclusive with budget_tokens."
134    },
135    "budget_tokens": {
136      "type": "integer",
137      "minimum": 1,
138      "description": "Optional absolute cumulative weighted-token budget. Overrides the selected profile and host default. Mutually exclusive with budget_ratio."
139    }
140  },
141  "required": ["task"]
142}`
143
144func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
145func (t *SubagentSpawnTool) Description() string {
146	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."
147}
148func (t *SubagentSpawnTool) Schema() json.RawMessage {
149	return json.RawMessage(subagentSpawnSchemaTemplate)
150}
151
152func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
153	prefix := t.Name()
154	if t.ResidentManager == nil {
155		return protocolToolError(prefix + ": subagent runtime not available in this mode")
156	}
157	if t.Enabled == nil || !t.Enabled() {
158		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
159	}
160	var a subagentSpawnArgs
161	if err := decodeSubagentArgs(raw, &a); err != nil {
162		return core.ToolResult{}, err
163	}
164	task := strings.TrimSpace(a.Task)
165	if task == "" {
166		return protocolToolError(prefix + ": task is required")
167	}
168	if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
169		return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
170	}
171	if a.BudgetRatio != nil && (*a.BudgetRatio <= 0 || *a.BudgetRatio > 1) {
172		return protocolToolError(prefix + ": budget_ratio must be greater than 0 and at most 1")
173	}
174	if a.BudgetTokens != nil && *a.BudgetTokens <= 0 {
175		return protocolToolError(prefix + ": budget_tokens must be positive")
176	}
177	if a.BudgetRatio != nil && a.BudgetTokens != nil {
178		return protocolToolError(prefix + ": budget_ratio and budget_tokens are mutually exclusive")
179	}
180
181	workspaceMode := subagents.WorkspaceShared
182	if value := strings.TrimSpace(a.Isolation); value != "" {
183		workspaceMode = subagents.WorkspaceMode(value)
184		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
185			return protocolToolError(prefix + ": isolation must be shared or worktree")
186		}
187	}
188	agentName := strings.TrimSpace(a.Agent)
189	var profile *subagents.Profile
190	var fastModeOverride *bool
191	if agentName != "" {
192		if t.ResolveSubagent == nil {
193			return protocolToolError(prefix + ": named subagent profiles are unavailable")
194		}
195		var err error
196		profile, err = t.ResolveSubagent(agentName)
197		if err != nil {
198			return protocolToolError(prefix + ": " + err.Error())
199		}
200		if profile == nil {
201			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
202		}
203		fastModeOverride = profile.FastMode
204	}
205	if a.FastMode != nil {
206		fastModeOverride = a.FastMode
207	}
208	model := strings.TrimSpace(a.Model)
209	providerID := strings.TrimSpace(a.Provider)
210	if (model == "") != (providerID == "") {
211		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
212	}
213	if profile != nil {
214		// A profile may specify a qualified model, a bare model, a provider,
215		// or neither. Fill only missing pieces so explicit spawn options win;
216		// the parent session supplies whatever the profile leaves unspecified.
217		profileProvider, profileModel := profile.ModelSelection()
218		if model == "" {
219			model = profileModel
220		}
221		if providerID == "" {
222			providerID = profileProvider
223		}
224	}
225	if model == "" && t.DefaultModel != nil {
226		model = strings.TrimSpace(t.DefaultModel())
227	}
228	if providerID == "" && t.DefaultProvider != nil {
229		providerID = strings.TrimSpace(t.DefaultProvider())
230	}
231
232	reasoning, err := normalizeReasoning(a.Reasoning)
233	if err != nil {
234		return protocolToolError(prefix + ": " + err.Error())
235	}
236	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
237		reasoning, err = normalizeReasoning(profile.Thinking)
238		if err != nil {
239			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
240		}
241	}
242	if reasoning == "" && t.DefaultReasoning != nil {
243		reasoning, err = normalizeReasoning(t.DefaultReasoning())
244		if err != nil {
245			return protocolToolError(prefix + ": host " + err.Error())
246		}
247	}
248	if t.BuildResidentSpec == nil {
249		return protocolToolError(prefix + ": resident child factory is unavailable")
250	}
251	spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
252		Task: task, Profile: profile, Model: model, Provider: providerID,
253		Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
254		WorkspaceMode: workspaceMode, BudgetRatio: a.BudgetRatio, BudgetTokens: a.BudgetTokens,
255	})
256	if err != nil {
257		return protocolToolError(prefix + ": " + err.Error())
258	}
259	var completion subagents.ResidentCompletion
260	waitTimedOut := false
261	if a.Wait != nil {
262		spec.InitialTurnID = uuid.NewString()
263		completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
264		defer cancelWait()
265		if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
266			return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
267		}
268		waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
269		defer cancel()
270		select {
271		case result, ok := <-completionResult:
272			if !ok {
273				return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
274			}
275			completion = result
276		case <-waitCtx.Done():
277			if ctx.Err() != nil {
278				return core.ToolResult{}, ctx.Err()
279			}
280			select {
281			case result, ok := <-completionResult:
282				if !ok {
283					return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
284				}
285				completion = result
286			default:
287				waitTimedOut = true
288			}
289		}
290	} else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
291		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
292	}
293	state := "queued"
294	if a.Wait != nil {
295		if waitTimedOut {
296			if current, ok := t.ResidentManager.State(spec.ID); ok {
297				state = string(current)
298			}
299		} else if completion.Err != nil {
300			state = "failed"
301		} else {
302			state = "completed"
303		}
304	}
305	var sb strings.Builder
306	fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
307	if spec.Profile != "" {
308		fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
309	}
310	fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
311	if spec.Reasoning != "" {
312		fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
313	}
314	if spec.BudgetLimit > 0 {
315		fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
316	}
317	if spec.Required {
318		fmt.Fprintf(&sb, "required: %s\n", state)
319	}
320	if a.Wait != nil {
321		if waitTimedOut {
322			fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
323			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].")
324		} else if completion.Err != nil {
325			fmt.Fprintf(&sb, "error: %s\n", completion.Err)
326			if completion.Summary != "" {
327				fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
328			}
329		} else {
330			if completion.Summary != "" {
331				fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
332			}
333		}
334	} else {
335		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].")
336	}
337	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
338}
339
340func decodeSubagentArgs(raw json.RawMessage, target any) error {
341	decoder := json.NewDecoder(bytes.NewReader(raw))
342	decoder.DisallowUnknownFields()
343	if err := decoder.Decode(target); err != nil {
344		return fmt.Errorf("invalid args: %w", err)
345	}
346	if err := decoder.Decode(&struct{}{}); err != io.EOF {
347		return fmt.Errorf("invalid args: trailing JSON value")
348	}
349	return nil
350}
351
352func normalizeReasoning(value string) (string, error) {
353	value = strings.TrimSpace(value)
354	if value == "" {
355		return "", nil
356	}
357	normalized := provider.NormalizeReasoning(value)
358	switch normalized {
359	case "":
360		return "off", nil
361	case "minimum", "low", "medium", "high", "xhigh", "max":
362		return normalized, nil
363	default:
364		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
365	}
366}
367
368// protocolToolError keeps model-visible validation failures in the
369// ToolResult channel rather than treating them as host execution errors.
370func protocolToolError(msg string) (core.ToolResult, error) {
371	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
372	return toolErr(msg), nil
373}
374
375func toolErr(msg string) core.ToolResult {
376	return core.ToolResult{
377		Content: []provider.Content{provider.TextBlock{Text: msg}},
378		IsError: true,
379	}
380}
381
382func truncateTask(s string, n int) string {
383	if len(s) <= n {
384		return s
385	}
386	return s[:n-3] + "..."
387}

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