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
185 model := strings.TrimSpace(a.Model)
186 providerID := strings.TrimSpace(a.Provider)
187 if (model == "") != (providerID == "") {
188 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
189 }
190 if profile != nil {
191 // A profile may specify a qualified model, a bare model, a provider,
192 // or neither. Fill only missing pieces so explicit spawn options win;
193 // the parent session supplies whatever the profile leaves unspecified.
194 profileProvider, profileModel := profile.ModelSelection()
195 if model == "" {
196 model = profileModel
197 }
198 if providerID == "" {
199 providerID = profileProvider
200 }
201 }
202 if model == "" && t.DefaultModel != nil {
203 model = strings.TrimSpace(t.DefaultModel())
204 }
205 if providerID == "" && t.DefaultProvider != nil {
206 providerID = strings.TrimSpace(t.DefaultProvider())
207 }
208
209 reasoning, err := normalizeReasoning(a.Reasoning)
210 if err != nil {
211 return protocolToolError(prefix + ": " + err.Error())
212 }
213 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
214 reasoning, err = normalizeReasoning(profile.Thinking)
215 if err != nil {
216 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
217 }
218 }
219 if reasoning == "" && t.DefaultReasoning != nil {
220 reasoning, err = normalizeReasoning(t.DefaultReasoning())
221 if err != nil {
222 return protocolToolError(prefix + ": host " + err.Error())
223 }
224 }
225 if t.BuildResidentSpec == nil {
226 return protocolToolError(prefix + ": resident child factory is unavailable")
227 }
228 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
229 Task: task, Profile: profile, Model: model, Provider: providerID,
230 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
231 WorkspaceMode: workspaceMode,
232 })
233 if err != nil {
234 return protocolToolError(prefix + ": " + err.Error())
235 }
236 var completion subagents.ResidentCompletion
237 waitTimedOut := false
238 if a.Wait != nil {
239 spec.InitialTurnID = uuid.NewString()
240 completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
241 defer cancelWait()
242 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
243 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
244 }
245 waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
246 defer cancel()
247 select {
248 case result, ok := <-completionResult:
249 if !ok {
250 return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
251 }
252 completion = result
253 case <-waitCtx.Done():
254 if ctx.Err() != nil {
255 return core.ToolResult{}, ctx.Err()
256 }
257 select {
258 case result, ok := <-completionResult:
259 if !ok {
260 return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
261 }
262 completion = result
263 default:
264 waitTimedOut = true
265 }
266 }
267 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
268 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
269 }
270 state := "queued"
271 if a.Wait != nil {
272 if waitTimedOut {
273 if current, ok := t.ResidentManager.State(spec.ID); ok {
274 state = string(current)
275 }
276 } else if completion.Err != nil {
277 state = "failed"
278 } else {
279 state = "completed"
280 }
281 }
282 var sb strings.Builder
283 fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
284 if spec.Profile != "" {
285 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
286 }
287 fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
288 if spec.Reasoning != "" {
289 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
290 }
291 if spec.Required {
292 fmt.Fprintf(&sb, "required: %s\n", state)
293 }
294 if a.Wait != nil {
295 if waitTimedOut {
296 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
297 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].")
298 } else if completion.Err != nil {
299 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
300 if completion.Summary != "" {
301 fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
302 }
303 } else {
304 if completion.Summary != "" {
305 fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
306 }
307 }
308 } else {
309 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].")
310 }
311 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
312}
313
314func decodeSubagentArgs(raw json.RawMessage, target any) error {
315 decoder := json.NewDecoder(bytes.NewReader(raw))
316 decoder.DisallowUnknownFields()
317 if err := decoder.Decode(target); err != nil {
318 return fmt.Errorf("invalid args: %w", err)
319 }
320 if err := decoder.Decode(&struct{}{}); err != io.EOF {
321 return fmt.Errorf("invalid args: trailing JSON value")
322 }
323 return nil
324}
325
326func normalizeReasoning(value string) (string, error) {
327 value = strings.TrimSpace(value)
328 if value == "" {
329 return "", nil
330 }
331 normalized := provider.NormalizeReasoning(value)
332 switch normalized {
333 case "":
334 return "off", nil
335 case "minimum", "low", "medium", "high", "xhigh", "max":
336 return normalized, nil
337 default:
338 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
339 }
340}
341
342// protocolToolError keeps model-visible validation failures in the
343// ToolResult channel rather than treating them as host execution errors.
344func protocolToolError(msg string) (core.ToolResult, error) {
345 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
346 return toolErr(msg), nil
347}
348
349func toolErr(msg string) core.ToolResult {
350 return core.ToolResult{
351 Content: []provider.Content{provider.TextBlock{Text: msg}},
352 IsError: true,
353 }
354}
355
356func truncateTask(s string, n int) string {
357 if len(s) <= n {
358 return s
359 }
360 return s[:n-3] + "..."
361}