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 := string(subagents.ResidentQueued)
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 {
276 state = completion.Completion().Status
277 }
278 }
279 var sb strings.Builder
280 fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
281 if spec.Profile != "" {
282 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
283 }
284 fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
285 if spec.Reasoning != "" {
286 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
287 }
288 if spec.BudgetLimit > 0 {
289 fmt.Fprintf(&sb, "budget: %d weighted tokens (%s)\n", spec.BudgetLimit, spec.BudgetSource)
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, "partial: %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}