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. Every spawn returns immediately and runs in
17// the background. Required work records a durable obligation that must be
18// resolved before the parent can produce its terminal response.
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 every worker for asynchronous completion.
53 OnSpawned func(agent *subagents.Agent, task string, required bool)
54}
55
56type subagentSpawnArgs struct {
57 Task string `json:"task"`
58 Agent string `json:"agent,omitempty"`
59 Model string `json:"model,omitempty"`
60 Provider string `json:"provider,omitempty"`
61 Reasoning string `json:"reasoning,omitempty"`
62 Thinking string `json:"thinking,omitempty"`
63 FastMode *bool `json:"fast_mode,omitempty"`
64 Required bool `json:"required,omitempty"`
65 Isolation string `json:"isolation,omitempty"`
66 Timeout string `json:"timeout,omitempty"`
67 MaxTurns *int `json:"max_turns,omitempty"`
68}
69
70const subagentSpawnSchemaTemplate = `{
71 "type": "object",
72 "properties": {
73 "task": {
74 "type": "string",
75 "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."
76 },
77 "agent": {
78 "type": "string",
79 "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."
80 },
81 "model": {
82 "type": "string",
83 "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."
84 },
85 "provider": {
86 "type": "string",
87 "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."
88 },
89 "reasoning": {
90 "type": "string",
91 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
92 "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided."
93 },
94 "thinking": {
95 "type": "string",
96 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
97 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
98 },
99 "fast_mode": {
100 "type": "boolean",
101 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
102 },
103 "required": {
104 "type": "boolean",
105 "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. 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."
106 },
107 "isolation": {
108 "type": "string",
109 "enum": ["shared", "worktree"],
110 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
111 },
112 "timeout": {
113 "type": "string",
114 "description": "Optional Go duration such as 20m."
115 },
116 "max_turns": {
117 "type": "integer",
118 "minimum": 1,
119 "maximum": %d,
120 "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
121 }
122 },
123 "required": ["task"]
124}`
125
126func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
127func (t *SubagentSpawnTool) Description() string {
128 return "Delegate work to a sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. 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, 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."
129}
130func (t *SubagentSpawnTool) Schema() json.RawMessage {
131 maxTurns := 3
132 if t.Supervisor != nil {
133 if limit := t.Supervisor.MaxTurns(); limit > 0 {
134 maxTurns = limit
135 }
136 }
137 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
138}
139
140func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
141 prefix := t.Name()
142 if t.Supervisor == nil {
143 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
144 }
145 if t.Enabled == nil || !t.Enabled() {
146 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
147 }
148 var a subagentSpawnArgs
149 if err := json.Unmarshal(raw, &a); err != nil {
150 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
151 }
152 task := strings.TrimSpace(a.Task)
153 if task == "" {
154 return protocolToolError(prefix + ": task is required")
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 var timeout time.Duration
165 if value := strings.TrimSpace(a.Timeout); value != "" {
166 parsed, parseErr := time.ParseDuration(value)
167 if parseErr != nil || parsed <= 0 {
168 return protocolToolError(prefix + ": timeout must be a positive duration")
169 }
170 timeout = parsed
171 }
172 if a.MaxTurns != nil {
173 if *a.MaxTurns < 1 {
174 return protocolToolError(prefix + ": max_turns must be positive")
175 }
176 if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
177 return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
178 }
179 }
180
181 agentName := strings.TrimSpace(a.Agent)
182 var profile *subagents.Profile
183 var fastModeOverride *bool
184 fastModeFromProfile := false
185 if agentName != "" {
186 if t.ResolveSubagent == nil {
187 return protocolToolError(prefix + ": named subagent profiles are unavailable")
188 }
189 var err error
190 profile, err = t.ResolveSubagent(agentName)
191 if err != nil {
192 return protocolToolError(prefix + ": " + err.Error())
193 }
194 if profile == nil {
195 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
196 }
197 agentName = profile.Name
198 fastModeOverride = profile.FastMode
199 fastModeFromProfile = profile.FastMode != nil
200 }
201 if a.FastMode != nil {
202 fastModeOverride = a.FastMode
203 fastModeFromProfile = false
204 }
205
206 model := strings.TrimSpace(a.Model)
207 providerID := strings.TrimSpace(a.Provider)
208 var profileTools []string
209 if profile != nil {
210 profileTools = append([]string(nil), profile.Tools...)
211 }
212 if (model == "") != (providerID == "") {
213 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
214 }
215 if profile != nil {
216 // A profile may specify a qualified model, a bare model, a provider,
217 // or neither. Fill only missing pieces so explicit spawn options win;
218 // the parent session supplies whatever the profile leaves unspecified.
219 profileProvider, profileModel := profile.ModelSelection()
220 if model == "" {
221 model = profileModel
222 }
223 if providerID == "" {
224 providerID = profileProvider
225 }
226 }
227 if model == "" && t.DefaultModel != nil {
228 model = strings.TrimSpace(t.DefaultModel())
229 }
230 if providerID == "" && t.DefaultProvider != nil {
231 providerID = strings.TrimSpace(t.DefaultProvider())
232 }
233
234 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
235 if err != nil {
236 return protocolToolError(prefix + ": " + err.Error())
237 }
238 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
239 reasoning, err = reasoningOverride(profile.Thinking, "")
240 if err != nil {
241 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
242 }
243 }
244 if reasoning == "" && t.DefaultReasoning != nil {
245 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
246 if err != nil {
247 return protocolToolError(prefix + ": host " + err.Error())
248 }
249 }
250
251 maxTurns := 0
252 if a.MaxTurns != nil {
253 maxTurns = *a.MaxTurns
254 }
255 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
256 Task: task,
257 Model: model,
258 Provider: providerID,
259 Reasoning: reasoning,
260 FastMode: fastModeOverride,
261 Subagent: agentName,
262 Required: a.Required,
263 Timeout: timeout,
264 MaxTurns: maxTurns,
265 WorkspaceMode: workspaceMode,
266 Tools: profileTools,
267 })
268 if err != nil {
269 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
270 }
271 if t.OnSpawned != nil {
272 t.OnSpawned(agent, task, a.Required)
273 }
274 var sb strings.Builder
275 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
276 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
277 fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
278 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
279 if agentName != "" {
280 fmt.Fprintf(&sb, "agent: %s\n", agentName)
281 }
282 if model != "" {
283 fmt.Fprintf(&sb, "model: %s\n", model)
284 }
285 if providerID != "" {
286 fmt.Fprintf(&sb, "provider: %s\n", providerID)
287 }
288 if reasoning != "" {
289 fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
290 }
291 if agent.FastMode {
292 sb.WriteString("fast mode: enabled\n")
293 }
294 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
295 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
296 }
297 if a.Required {
298 sb.WriteString("required: pending\n")
299 }
300 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
301 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
302 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.")
303 details := map[string]any{
304 "agent_id": agent.ID,
305 "task": task,
306 "agent": agentName,
307 "model": model,
308 "provider": providerID,
309 "reasoning": reasoning,
310 "fast_mode": agent.FastMode,
311 "required": a.Required,
312 "isolation": string(workspaceMode),
313 "timeout": agent.Timeout.String(),
314 "max_turns": agent.MaxTurns,
315 "state": agent.Status(),
316 "process_state": string(agent.ProcessState()),
317 "turn_state": string(agent.TurnState()),
318 "result_ref": subagents.ResultRef(agent.ID),
319 }
320 if a.Required {
321 details["requirement_state"] = string(subagents.RequirementPending)
322 }
323 return core.ToolResult{
324 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
325 Details: details,
326 }, nil
327}
328
329func reasoningOverride(reasoning, thinking string) (string, error) {
330 reasoning = strings.TrimSpace(reasoning)
331 thinking = strings.TrimSpace(thinking)
332 if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) {
333 return "", fmt.Errorf("reasoning and thinking disagree; provide only one")
334 }
335 value := reasoning
336 if value == "" {
337 value = thinking
338 }
339 if value == "" {
340 return "", nil
341 }
342 normalized := provider.NormalizeReasoning(value)
343 switch normalized {
344 case "":
345 return "off", nil
346 case "minimum", "low", "medium", "high", "xhigh", "max":
347 return normalized, nil
348 default:
349 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
350 }
351}
352
353// protocolToolError keeps model-visible validation failures in the
354// ToolResult channel rather than treating them as host execution errors.
355func protocolToolError(msg string) (core.ToolResult, error) {
356 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
357 return toolErr(msg), nil
358}
359
360func toolErr(msg string) core.ToolResult {
361 return core.ToolResult{
362 Content: []provider.Content{provider.TextBlock{Text: msg}},
363 IsError: true,
364 }
365}
366
367func truncateTask(s string, n int) string {
368 if len(s) <= n {
369 return s
370 }
371 return s[:n-3] + "..."
372}