v0.50.1 a637010

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	// SubagentToolName is the single model-facing subagent tool. One call
29	// carries one action; see the SubagentAction* constants.
30	SubagentToolName = "subagent"
31
32	SubagentActionSpawn  = "spawn"
33	SubagentActionStatus = "status"
34	SubagentActionStop   = "stop"
35	SubagentActionResume = "resume"
36	// SubagentActionInterrupt cancels only the running turn and keeps the child.
37	SubagentActionInterrupt = "interrupt"
38
39	maxSubagentWaitSeconds = 5 * 60
40)
41
42type SubagentSpawnTool struct {
43	ResidentManager   *subagents.ResidentManager
44	BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
45	OnResidentSpawned func(subagents.ResidentChildSpec, string)
46
47	// Enabled reports whether the host currently exposes this tool. When nil,
48	// the tool is treated as disabled.
49	Enabled func() bool
50
51	// DefaultModel and DefaultProvider return the host agent's resolved
52	// model and provider. They are used when the tool call omits both
53	// fields and does not select a named profile, so auto-subagents follows
54	// the same auth route as the user sees in the parent session.
55	DefaultModel     func() string
56	DefaultProvider  func() string
57	DefaultReasoning func() string
58
59	// ResolveSubagent validates and resolves a named markdown profile.
60	ResolveSubagent func(name string) (*subagents.Profile, error)
61}
62
63// ResidentSpawnRequest is the resolved tool input the host uses to construct
64// a complete non-secret resident ChildSpec.
65type ResidentSpawnRequest struct {
66	Task          string
67	Profile       *subagents.Profile
68	Model         string
69	Provider      string
70	Reasoning     string
71	FastMode      *bool
72	Required      bool
73	WorkspaceMode subagents.WorkspaceMode
74}
75
76type subagentSpawnArgs struct {
77	Task      string `json:"task"`
78	Agent     string `json:"agent,omitempty"`
79	Model     string `json:"model,omitempty"`
80	Provider  string `json:"provider,omitempty"`
81	Reasoning string `json:"reasoning,omitempty"`
82	FastMode  *bool  `json:"fast_mode,omitempty"`
83	Required  bool   `json:"required,omitempty"`
84	Wait      *int   `json:"wait,omitempty"`
85	Isolation string `json:"isolation,omitempty"`
86}
87
88// Name returns the shared facade name: the four subagent tool types are
89// internal implementations and are never registered on their own.
90func (t *SubagentSpawnTool) Name() string { return SubagentToolName }
91
92func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
93	prefix := t.Name()
94	if t.ResidentManager == nil {
95		return protocolToolError(prefix + ": subagent runtime not available in this mode")
96	}
97	if t.Enabled == nil || !t.Enabled() {
98		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
99	}
100	var a subagentSpawnArgs
101	if err := decodeSubagentArgs(raw, &a); err != nil {
102		return core.ToolResult{}, err
103	}
104	task := strings.TrimSpace(a.Task)
105	if task == "" {
106		return protocolToolError(prefix + ": task is required")
107	}
108	if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
109		return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
110	}
111
112	workspaceMode := subagents.WorkspaceShared
113	if value := strings.TrimSpace(a.Isolation); value != "" {
114		workspaceMode = subagents.WorkspaceMode(value)
115		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
116			return protocolToolError(prefix + ": isolation must be shared or worktree")
117		}
118	}
119	agentName := strings.TrimSpace(a.Agent)
120	var profile *subagents.Profile
121	var fastModeOverride *bool
122	if agentName != "" {
123		if t.ResolveSubagent == nil {
124			return protocolToolError(prefix + ": named subagent profiles are unavailable")
125		}
126		var err error
127		profile, err = t.ResolveSubagent(agentName)
128		if err != nil {
129			return protocolToolError(prefix + ": " + err.Error())
130		}
131		if profile == nil {
132			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
133		}
134		fastModeOverride = profile.FastMode
135	}
136	if a.FastMode != nil {
137		fastModeOverride = a.FastMode
138	}
139	model := strings.TrimSpace(a.Model)
140	providerID := strings.TrimSpace(a.Provider)
141	if (model == "") != (providerID == "") {
142		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
143	}
144	if profile != nil {
145		// A profile may specify a qualified model, a bare model, a provider,
146		// or neither. Fill only missing pieces so explicit spawn options win;
147		// the parent session supplies whatever the profile leaves unspecified.
148		profileProvider, profileModel := profile.ModelSelection()
149		if model == "" {
150			model = profileModel
151		}
152		if providerID == "" {
153			providerID = profileProvider
154		}
155	}
156	if model == "" && t.DefaultModel != nil {
157		model = strings.TrimSpace(t.DefaultModel())
158	}
159	if providerID == "" && t.DefaultProvider != nil {
160		providerID = strings.TrimSpace(t.DefaultProvider())
161	}
162
163	reasoning, err := normalizeReasoning(a.Reasoning)
164	if err != nil {
165		return protocolToolError(prefix + ": " + err.Error())
166	}
167	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
168		reasoning, err = normalizeReasoning(profile.Thinking)
169		if err != nil {
170			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
171		}
172	}
173	if reasoning == "" && t.DefaultReasoning != nil {
174		reasoning, err = normalizeReasoning(t.DefaultReasoning())
175		if err != nil {
176			return protocolToolError(prefix + ": host " + err.Error())
177		}
178	}
179	if t.BuildResidentSpec == nil {
180		return protocolToolError(prefix + ": resident child factory is unavailable")
181	}
182	spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
183		Task: task, Profile: profile, Model: model, Provider: providerID,
184		Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
185		WorkspaceMode: workspaceMode,
186	})
187	if err != nil {
188		return protocolToolError(prefix + ": " + err.Error())
189	}
190	var completion subagents.ResidentCompletion
191	waitTimedOut := false
192	if a.Wait != nil {
193		spec.InitialTurnID = uuid.NewString()
194		completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
195		defer cancelWait()
196		if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
197			return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
198		}
199		completion, waitTimedOut, err = waitForResidentCompletion(ctx, completionResult, *a.Wait, prefix)
200		if err != nil {
201			return core.ToolResult{}, err
202		}
203	} else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
204		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
205	}
206	state := string(subagents.ResidentQueued)
207	if a.Wait != nil {
208		if waitTimedOut {
209			if current, ok := t.ResidentManager.State(spec.ID); ok {
210				state = string(current)
211			}
212		} else {
213			state = completion.Completion().Status
214		}
215	}
216	var sb strings.Builder
217	fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
218	if spec.Profile != "" {
219		fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
220	}
221	fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
222	if spec.Reasoning != "" {
223		fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
224	}
225	if spec.Required {
226		fmt.Fprintf(&sb, "required: %s\n", state)
227	}
228	if a.Wait != nil {
229		if waitTimedOut {
230			fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
231			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].")
232		} else if completion.HostUpdate {
233			fmt.Fprintf(&sb, "report_delivery: %s\n", subagentReportHostUpdate)
234			if completion.Err != nil {
235				fmt.Fprintf(&sb, "error: %s\n", completion.Err)
236			}
237			sb.WriteString("\nThe full report arrives through [auto-subagents update]. Do not resume or poll solely to retrieve it.")
238		} else if completion.Err != nil {
239			fmt.Fprintf(&sb, "error: %s\n", completion.Err)
240			if completion.Summary != "" {
241				fmt.Fprintf(&sb, "partial: %s\n", completion.Summary)
242			}
243		} else {
244			if completion.Summary != "" {
245				fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
246			}
247		}
248	} else {
249		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].")
250	}
251	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
252}
253
254// waitForResidentCompletion blocks until the watched resident turn reports its
255// completion or the explicit bounded wait expires. A cancelled parent context
256// wins over an expiring wait, and a completion that arrives in the same instant
257// as the deadline is reported instead of a timeout.
258func waitForResidentCompletion(ctx context.Context, completionResult <-chan subagents.ResidentCompletion, seconds int, prefix string) (completion subagents.ResidentCompletion, timedOut bool, err error) {
259	waitCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
260	defer cancel()
261	select {
262	case result, ok := <-completionResult:
263		if !ok {
264			return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
265		}
266		return result, false, nil
267	case <-waitCtx.Done():
268		if ctx.Err() != nil {
269			return subagents.ResidentCompletion{}, false, ctx.Err()
270		}
271		select {
272		case result, ok := <-completionResult:
273			if !ok {
274				return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
275			}
276			return result, false, nil
277		default:
278			return subagents.ResidentCompletion{}, true, nil
279		}
280	}
281}
282
283func decodeSubagentArgs(raw json.RawMessage, target any) error {
284	decoder := json.NewDecoder(bytes.NewReader(raw))
285	decoder.DisallowUnknownFields()
286	if err := decoder.Decode(target); err != nil {
287		return fmt.Errorf("invalid args: %w", err)
288	}
289	if err := decoder.Decode(&struct{}{}); err != io.EOF {
290		return fmt.Errorf("invalid args: trailing JSON value")
291	}
292	return nil
293}
294
295func normalizeReasoning(value string) (string, error) {
296	value = strings.TrimSpace(value)
297	if value == "" {
298		return "", nil
299	}
300	normalized := provider.NormalizeReasoning(value)
301	switch normalized {
302	case "":
303		return "off", nil
304	case "minimum", "low", "medium", "high", "xhigh", "max":
305		return normalized, nil
306	default:
307		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
308	}
309}
310
311// protocolToolError keeps model-visible validation failures in the
312// ToolResult channel rather than treating them as host execution errors.
313func protocolToolError(msg string) (core.ToolResult, error) {
314	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
315	return toolErr(msg), nil
316}
317
318func toolErr(msg string) core.ToolResult {
319	return core.ToolResult{
320		Content: []provider.Content{provider.TextBlock{Text: msg}},
321		IsError: true,
322	}
323}
324
325func truncateTask(s string, n int) string {
326	if len(s) <= n {
327		return s
328	}
329	return s[:n-3] + "..."
330}

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