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}