v0.42.0 82578de

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

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