1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13)
14
15// SubagentSpawnTool lets the main agent delegate work against the host's cwd
16// via subagents.Supervisor.SpawnReq. Optional work returns immediately and runs
17// in the background. Required work keeps the tool call open until the delegated
18// turn reaches a durable terminal outcome.
19//
20// Available when the host's launch-time tool policy permits delegation. The
21// primary-agent prompt decides whether delegation is proactive or only on an
22// explicit user request.
23const (
24 SubagentSpawnToolName = "subagent_spawn"
25 SubagentStatusToolName = "subagent_status"
26 SubagentStopToolName = "subagent_stop"
27 SubagentResumeToolName = "subagent_resume"
28)
29
30type SubagentSpawnTool struct {
31 // Supervisor is the supervisor used to spawn agents. Nil means subagents
32 // are unavailable in this mode and the tool always errors.
33 Supervisor *subagents.Supervisor
34
35 // Enabled reports whether the host currently exposes this tool. When nil,
36 // the tool is treated as disabled.
37 Enabled func() bool
38
39 // DefaultModel and DefaultProvider return the host agent's resolved
40 // model and provider. They are used when the tool call omits both
41 // fields and does not select a named profile, so auto-subagents follows
42 // the same auth route as the user sees in the parent session.
43 DefaultModel func() string
44 DefaultProvider func() string
45 DefaultReasoning func() string
46
47 // ResolveSubagent validates and resolves a named markdown profile.
48 // The child receives only the name and loads the profile itself.
49 ResolveSubagent func(name string) (*subagents.Profile, error)
50
51 // OnSpawned, if set, is called after every successful spawn with the
52 // requirement mode. Hosts track optional work for asynchronous completion;
53 // required work is delivered by this blocking tool call instead.
54 OnSpawned func(agent *subagents.Agent, task string, required bool)
55}
56
57type subagentSpawnArgs struct {
58 Task string `json:"task"`
59 Agent string `json:"agent,omitempty"`
60 Model string `json:"model,omitempty"`
61 Provider string `json:"provider,omitempty"`
62 Reasoning string `json:"reasoning,omitempty"`
63 Thinking string `json:"thinking,omitempty"`
64 FastMode *bool `json:"fast_mode,omitempty"`
65 Required bool `json:"required,omitempty"`
66 Isolation string `json:"isolation,omitempty"`
67 Timeout string `json:"timeout,omitempty"`
68 MaxTurns *int `json:"max_turns,omitempty"`
69}
70
71const subagentSpawnSchemaTemplate = `{
72 "type": "object",
73 "properties": {
74 "task": {
75 "type": "string",
76 "description": "The full task description for the sub-agent. 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 shares this working directory and starts with NO context from this conversation."
77 },
78 "agent": {
79 "type": "string",
80 "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."
81 },
82 "model": {
83 "type": "string",
84 "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."
85 },
86 "provider": {
87 "type": "string",
88 "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."
89 },
90 "reasoning": {
91 "type": "string",
92 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
93 "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided."
94 },
95 "thinking": {
96 "type": "string",
97 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
98 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
99 },
100 "fast_mode": {
101 "type": "boolean",
102 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
103 },
104 "required": {
105 "type": "boolean",
106 "description": "Set true when the parent must receive this delegated result before it can finish. Required calls wait event-driven for a terminal outcome; failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
107 },
108 "isolation": {
109 "type": "string",
110 "enum": ["shared", "worktree"],
111 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
112 },
113 "timeout": {
114 "type": "string",
115 "description": "Optional Go duration such as 20m."
116 },
117 "max_turns": {
118 "type": "integer",
119 "minimum": 1,
120 "maximum": %d,
121 "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
122 }
123 },
124 "required": ["task"]
125}`
126
127func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
128func (t *SubagentSpawnTool) Description() string {
129 return "Delegate work to a sub-agent. Set required=true when its outcome is mandatory before the parent can finish; the tool then waits event-driven and returns that outcome in the current turn. Optional work runs in the background and returns an id immediately. Optional completion is host-event-driven: wait only for the injected [auto-subagents update]; never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
130}
131func (t *SubagentSpawnTool) Schema() json.RawMessage {
132 maxTurns := 3
133 if t.Supervisor != nil {
134 if limit := t.Supervisor.MaxTurns(); limit > 0 {
135 maxTurns = limit
136 }
137 }
138 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
139}
140
141func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
142 prefix := t.Name()
143 if t.Supervisor == nil {
144 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
145 }
146 if t.Enabled == nil || !t.Enabled() {
147 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
148 }
149 var a subagentSpawnArgs
150 if err := json.Unmarshal(raw, &a); err != nil {
151 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
152 }
153 task := strings.TrimSpace(a.Task)
154 if task == "" {
155 return protocolToolError(prefix + ": task is required")
156 }
157
158 workspaceMode := subagents.WorkspaceShared
159 if value := strings.TrimSpace(a.Isolation); value != "" {
160 workspaceMode = subagents.WorkspaceMode(value)
161 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
162 return protocolToolError(prefix + ": isolation must be shared or worktree")
163 }
164 }
165 var timeout time.Duration
166 if value := strings.TrimSpace(a.Timeout); value != "" {
167 parsed, parseErr := time.ParseDuration(value)
168 if parseErr != nil || parsed <= 0 {
169 return protocolToolError(prefix + ": timeout must be a positive duration")
170 }
171 timeout = parsed
172 }
173 if a.MaxTurns != nil {
174 if *a.MaxTurns < 1 {
175 return protocolToolError(prefix + ": max_turns must be positive")
176 }
177 if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
178 return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
179 }
180 }
181
182 agentName := strings.TrimSpace(a.Agent)
183 var profile *subagents.Profile
184 var fastModeOverride *bool
185 fastModeFromProfile := false
186 if agentName != "" {
187 if t.ResolveSubagent == nil {
188 return protocolToolError(prefix + ": named subagent profiles are unavailable")
189 }
190 var err error
191 profile, err = t.ResolveSubagent(agentName)
192 if err != nil {
193 return protocolToolError(prefix + ": " + err.Error())
194 }
195 if profile == nil {
196 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
197 }
198 agentName = profile.Name
199 fastModeOverride = profile.FastMode
200 fastModeFromProfile = profile.FastMode != nil
201 }
202 if a.FastMode != nil {
203 fastModeOverride = a.FastMode
204 fastModeFromProfile = false
205 }
206
207 model := strings.TrimSpace(a.Model)
208 providerID := strings.TrimSpace(a.Provider)
209 var profileTools []string
210 if profile != nil {
211 profileTools = append([]string(nil), profile.Tools...)
212 }
213 if (model == "") != (providerID == "") {
214 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
215 }
216 if profile != nil {
217 // A profile may specify a qualified model, a bare model, a provider,
218 // or neither. Fill only missing pieces so explicit spawn options win;
219 // the parent session supplies whatever the profile leaves unspecified.
220 profileProvider, profileModel := profile.ModelSelection()
221 if model == "" {
222 model = profileModel
223 }
224 if providerID == "" {
225 providerID = profileProvider
226 }
227 }
228 if model == "" && t.DefaultModel != nil {
229 model = strings.TrimSpace(t.DefaultModel())
230 }
231 if providerID == "" && t.DefaultProvider != nil {
232 providerID = strings.TrimSpace(t.DefaultProvider())
233 }
234
235 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
236 if err != nil {
237 return protocolToolError(prefix + ": " + err.Error())
238 }
239 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
240 reasoning, err = reasoningOverride(profile.Thinking, "")
241 if err != nil {
242 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
243 }
244 }
245 if reasoning == "" && t.DefaultReasoning != nil {
246 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
247 if err != nil {
248 return protocolToolError(prefix + ": host " + err.Error())
249 }
250 }
251
252 maxTurns := 0
253 if a.MaxTurns != nil {
254 maxTurns = *a.MaxTurns
255 }
256 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
257 Task: task,
258 Model: model,
259 Provider: providerID,
260 Reasoning: reasoning,
261 FastMode: fastModeOverride,
262 Subagent: agentName,
263 Required: a.Required,
264 Timeout: timeout,
265 MaxTurns: maxTurns,
266 WorkspaceMode: workspaceMode,
267 Tools: profileTools,
268 })
269 if err != nil {
270 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
271 }
272 if t.OnSpawned != nil {
273 t.OnSpawned(agent, task, a.Required)
274 }
275 if a.Required {
276 return waitRequiredSubagent(ctx, t.Name(), t.Supervisor, agent, task, progress)
277 }
278
279 var sb strings.Builder
280 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
281 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
282 fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
283 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
284 if agentName != "" {
285 fmt.Fprintf(&sb, "agent: %s\n", agentName)
286 }
287 if model != "" {
288 fmt.Fprintf(&sb, "model: %s\n", model)
289 }
290 if providerID != "" {
291 fmt.Fprintf(&sb, "provider: %s\n", providerID)
292 }
293 if reasoning != "" {
294 fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
295 }
296 if agent.FastMode {
297 sb.WriteString("fast mode: enabled\n")
298 }
299 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
300 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
301 }
302 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven: wait for the injected [auto-subagents update], the only completion signal. ")
303 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
304 sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")
305 return core.ToolResult{
306 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
307 Details: map[string]any{
308 "agent_id": agent.ID,
309 "task": task,
310 "agent": agentName,
311 "model": model,
312 "provider": providerID,
313 "reasoning": reasoning,
314 "fast_mode": agent.FastMode,
315 "required": false,
316 "isolation": string(workspaceMode),
317 "timeout": agent.Timeout.String(),
318 "max_turns": agent.MaxTurns,
319 "state": agent.Status(),
320 "process_state": string(agent.ProcessState()),
321 "turn_state": string(agent.TurnState()),
322 "result_ref": subagents.ResultRef(agent.ID),
323 },
324 }, nil
325}
326
327func waitRequiredSubagent(ctx context.Context, toolName string, supervisor *subagents.Supervisor, agent *subagents.Agent, task string, progress func(string)) (core.ToolResult, error) {
328 if progress != nil {
329 progress(fmt.Sprintf("waiting for required sub-agent %s", agent.ID))
330 }
331 requirement, err := supervisor.WaitRequired(ctx, agent.ID)
332 if err != nil {
333 return core.ToolResult{}, fmt.Errorf("%s: wait for required agent %s: %w", toolName, agent.ID, err)
334 }
335 snapshot := agent.Snapshot()
336 var sb strings.Builder
337 fmt.Fprintf(&sb, "required sub-agent %s reached %s\n", agent.ID, requirement.State)
338 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
339 fmt.Fprintf(&sb, "requirement: %s\n", requirement.State)
340 if requirement.ErrorCode != "" {
341 fmt.Fprintf(&sb, "error_code: %s\n", requirement.ErrorCode)
342 fmt.Fprintf(&sb, "diagnostic: %s\n", subagents.ResultRef(agent.ID))
343 }
344 output := strings.TrimSpace(snapshot.LastAssistant)
345 if snapshot.Result != nil && strings.TrimSpace(snapshot.Result.Output) != "" {
346 output = strings.TrimSpace(snapshot.Result.Output)
347 }
348 if output != "" {
349 sb.WriteString("\nresult:\n")
350 sb.WriteString(output)
351 sb.WriteByte('\n')
352 }
353 if requirement.Unmet() {
354 sb.WriteString("\nThis required work is still unmet. Retry it with subagent_resume before producing a terminal answer. The user may explicitly waive it by removing the terminal worker with /subagents rm.")
355 } else {
356 sb.WriteString("\nThe required result is now available in this turn; continue with it before answering.")
357 }
358 return core.ToolResult{
359 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
360 Details: map[string]any{
361 "agent_id": agent.ID,
362 "task": task,
363 "required": true,
364 "requirement_state": string(requirement.State),
365 "state": snapshot.Status,
366 "process_state": string(snapshot.ProcessState),
367 "turn_state": string(snapshot.TurnState),
368 "result_ref": subagents.ResultRef(agent.ID),
369 },
370 IsError: requirement.Unmet(),
371 }, nil
372}
373
374func reasoningOverride(reasoning, thinking string) (string, error) {
375 reasoning = strings.TrimSpace(reasoning)
376 thinking = strings.TrimSpace(thinking)
377 if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) {
378 return "", fmt.Errorf("reasoning and thinking disagree; provide only one")
379 }
380 value := reasoning
381 if value == "" {
382 value = thinking
383 }
384 if value == "" {
385 return "", nil
386 }
387 normalized := provider.NormalizeReasoning(value)
388 switch normalized {
389 case "":
390 return "off", nil
391 case "minimum", "low", "medium", "high", "xhigh", "max":
392 return normalized, nil
393 default:
394 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
395 }
396}
397
398// protocolToolError keeps model-visible validation failures in the
399// ToolResult channel rather than treating them as host execution errors.
400func protocolToolError(msg string) (core.ToolResult, error) {
401 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
402 return toolErr(msg), nil
403}
404
405func toolErr(msg string) core.ToolResult {
406 return core.ToolResult{
407 Content: []provider.Content{provider.TextBlock{Text: msg}},
408 IsError: true,
409 }
410}
411
412func truncateTask(s string, n int) string {
413 if len(s) <= n {
414 return s
415 }
416 return s[:n-3] + "..."
417}