v0.23.0 edaaa7c

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

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