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