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 fork a background sub-agent
16// against the host's cwd via subagents.Supervisor.SpawnReq. The sub-agent runs
17// in parallel: the tool returns the agent id immediately and the main
18// turn continues uninterrupted. The user can monitor / chat with the
19// spawned agent via /subagents.
20//
21// Available when the host's launch-time tool policy permits delegation. The
22// primary-agent prompt decides whether delegation is proactive or only on an
23// explicit user request.
24type SubagentSpawnTool struct {
25 // Supervisor is the supervisor used to spawn agents. Nil means subagents
26 // are unavailable in this mode and the tool always errors.
27 Supervisor *subagents.Supervisor
28
29 // Enabled reports whether the host currently exposes this tool. When nil,
30 // the tool is treated as disabled.
31 Enabled func() bool
32
33 // DefaultModel and DefaultProvider return the host agent's resolved
34 // model and provider. They are used when the tool call omits both
35 // fields and does not select a named profile, so auto-subagents follows
36 // the same auth route as the user sees in the parent session.
37 DefaultModel func() string
38 DefaultProvider func() string
39 DefaultReasoning func() string
40
41 // ResolveSubagent validates and resolves a named markdown profile.
42 // The child receives only the name and loads the profile itself.
43 ResolveSubagent func(name string) (*subagents.Profile, error)
44
45 // OnSpawned, if set, is called after every successful spawn with
46 // the new agent + the task it was started with. Used by the
47 // interactive host to track agents and surface a summary back
48 // in chat when all sub-agents finish.
49 OnSpawned func(agent *subagents.Agent, task string)
50}
51
52type subagentSpawnArgs struct {
53 Task string `json:"task"`
54 Agent string `json:"agent,omitempty"`
55 Model string `json:"model,omitempty"`
56 Provider string `json:"provider,omitempty"`
57 Reasoning string `json:"reasoning,omitempty"`
58 Thinking string `json:"thinking,omitempty"`
59 FastMode *bool `json:"fast_mode,omitempty"`
60 Isolation string `json:"isolation,omitempty"`
61 Timeout string `json:"timeout,omitempty"`
62 MaxTurns *int `json:"max_turns,omitempty"`
63}
64
65const subagentSpawnSchemaTemplate = `{
66 "type": "object",
67 "properties": {
68 "task": {
69 "type": "string",
70 "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."
71 },
72 "agent": {
73 "type": "string",
74 "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."
75 },
76 "model": {
77 "type": "string",
78 "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."
79 },
80 "provider": {
81 "type": "string",
82 "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."
83 },
84 "reasoning": {
85 "type": "string",
86 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
87 "description": "Optional reasoning/thinking level for the child. Overrides the selected profile's thinking level when provided."
88 },
89 "thinking": {
90 "type": "string",
91 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
92 "description": "Alias for reasoning, accepted for compatibility with common agent profile terminology. Prefer reasoning when both are available."
93 },
94 "fast_mode": {
95 "type": "boolean",
96 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
97 },
98 "isolation": {
99 "type": "string",
100 "enum": ["shared", "worktree"],
101 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
102 },
103 "timeout": {
104 "type": "string",
105 "description": "Optional Go duration such as 20m."
106 },
107 "max_turns": {
108 "type": "integer",
109 "minimum": 1,
110 "maximum": %d,
111 "description": "Optional maximum prompt-level turns for this worker. Omit to use the supervisor default; the effective policy allows 1 through %d."
112 }
113 },
114 "required": ["task"]
115}`
116
117func (t *SubagentSpawnTool) Name() string { return "subagent_spawn" }
118func (t *SubagentSpawnTool) Description() string {
119 return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile, model/provider/reasoning, timeout, turn limit, and shared/worktree isolation, and fast-mode preference. Returns the sub-agent id immediately. 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."
120}
121func (t *SubagentSpawnTool) Schema() json.RawMessage {
122 maxTurns := 3
123 if t.Supervisor != nil {
124 if limit := t.Supervisor.MaxTurns(); limit > 0 {
125 maxTurns = limit
126 }
127 }
128 return json.RawMessage(fmt.Sprintf(subagentSpawnSchemaTemplate, maxTurns, maxTurns))
129}
130
131func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
132 prefix := t.Name()
133 if t.Supervisor == nil {
134 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
135 }
136 if t.Enabled == nil || !t.Enabled() {
137 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
138 }
139 var a subagentSpawnArgs
140 if err := json.Unmarshal(raw, &a); err != nil {
141 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
142 }
143 task := strings.TrimSpace(a.Task)
144 if task == "" {
145 return protocolToolError(prefix + ": task is required")
146 }
147
148 workspaceMode := subagents.WorkspaceShared
149 if value := strings.TrimSpace(a.Isolation); value != "" {
150 workspaceMode = subagents.WorkspaceMode(value)
151 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
152 return protocolToolError(prefix + ": isolation must be shared or worktree")
153 }
154 }
155 var timeout time.Duration
156 if value := strings.TrimSpace(a.Timeout); value != "" {
157 parsed, parseErr := time.ParseDuration(value)
158 if parseErr != nil || parsed <= 0 {
159 return protocolToolError(prefix + ": timeout must be a positive duration")
160 }
161 timeout = parsed
162 }
163 if a.MaxTurns != nil {
164 if *a.MaxTurns < 1 {
165 return protocolToolError(prefix + ": max_turns must be positive")
166 }
167 if limit := t.Supervisor.MaxTurns(); limit > 0 && *a.MaxTurns > limit {
168 return protocolToolError(fmt.Sprintf("%s: max_turns must be 1 through %d; omit it to use the supervisor default", prefix, limit))
169 }
170 }
171
172 agentName := strings.TrimSpace(a.Agent)
173 var profile *subagents.Profile
174 var fastModeOverride *bool
175 fastModeFromProfile := false
176 if agentName != "" {
177 if t.ResolveSubagent == nil {
178 return protocolToolError(prefix + ": named subagent profiles are unavailable")
179 }
180 var err error
181 profile, err = t.ResolveSubagent(agentName)
182 if err != nil {
183 return protocolToolError(prefix + ": " + err.Error())
184 }
185 if profile == nil {
186 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
187 }
188 agentName = profile.Name
189 fastModeOverride = profile.FastMode
190 fastModeFromProfile = profile.FastMode != nil
191 }
192 if a.FastMode != nil {
193 fastModeOverride = a.FastMode
194 fastModeFromProfile = false
195 }
196
197 model := strings.TrimSpace(a.Model)
198 providerID := strings.TrimSpace(a.Provider)
199 var profileTools []string
200 if profile != nil {
201 profileTools = append([]string(nil), profile.Tools...)
202 }
203 if (model == "") != (providerID == "") {
204 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
205 }
206 if profile != nil {
207 // A profile may specify a qualified model, a bare model, a provider,
208 // or neither. Fill only missing pieces so explicit spawn options win;
209 // the parent session supplies whatever the profile leaves unspecified.
210 profileProvider, profileModel := profile.ModelSelection()
211 if model == "" {
212 model = profileModel
213 }
214 if providerID == "" {
215 providerID = profileProvider
216 }
217 }
218 if model == "" && t.DefaultModel != nil {
219 model = strings.TrimSpace(t.DefaultModel())
220 }
221 if providerID == "" && t.DefaultProvider != nil {
222 providerID = strings.TrimSpace(t.DefaultProvider())
223 }
224
225 reasoning, err := reasoningOverride(a.Reasoning, a.Thinking)
226 if err != nil {
227 return protocolToolError(prefix + ": " + err.Error())
228 }
229 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
230 reasoning, err = reasoningOverride(profile.Thinking, "")
231 if err != nil {
232 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
233 }
234 }
235 if reasoning == "" && t.DefaultReasoning != nil {
236 reasoning, err = reasoningOverride(t.DefaultReasoning(), "")
237 if err != nil {
238 return protocolToolError(prefix + ": host " + err.Error())
239 }
240 }
241
242 maxTurns := 0
243 if a.MaxTurns != nil {
244 maxTurns = *a.MaxTurns
245 }
246 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
247 Task: task,
248 Model: model,
249 Provider: providerID,
250 Reasoning: reasoning,
251 FastMode: fastModeOverride,
252 Subagent: agentName,
253 Timeout: timeout,
254 MaxTurns: maxTurns,
255 WorkspaceMode: workspaceMode,
256 Tools: profileTools,
257 })
258 if err != nil {
259 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
260 }
261 if t.OnSpawned != nil {
262 t.OnSpawned(agent, task)
263 }
264
265 var sb strings.Builder
266 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
267 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
268 fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
269 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
270 if agentName != "" {
271 fmt.Fprintf(&sb, "agent: %s\n", agentName)
272 }
273 if model != "" {
274 fmt.Fprintf(&sb, "model: %s\n", model)
275 }
276 if providerID != "" {
277 fmt.Fprintf(&sb, "provider: %s\n", providerID)
278 }
279 if reasoning != "" {
280 fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
281 }
282 if agent.FastMode {
283 sb.WriteString("fast mode: enabled\n")
284 }
285 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
286 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
287 }
288 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. ")
289 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
290 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.")
291 return core.ToolResult{
292 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
293 Details: map[string]any{
294 "agent_id": agent.ID,
295 "task": task,
296 "agent": agentName,
297 "model": model,
298 "provider": providerID,
299 "reasoning": reasoning,
300 "fast_mode": agent.FastMode,
301 "isolation": string(workspaceMode),
302 "timeout": agent.Timeout.String(),
303 "max_turns": agent.MaxTurns,
304 "state": agent.Status(),
305 "process_state": string(agent.ProcessState()),
306 "turn_state": string(agent.TurnState()),
307 "result_ref": subagents.ResultRef(agent.ID),
308 },
309 }, nil
310}
311
312func reasoningOverride(reasoning, thinking string) (string, error) {
313 reasoning = strings.TrimSpace(reasoning)
314 thinking = strings.TrimSpace(thinking)
315 if reasoning != "" && thinking != "" && provider.NormalizeReasoning(reasoning) != provider.NormalizeReasoning(thinking) {
316 return "", fmt.Errorf("reasoning and thinking disagree; provide only one")
317 }
318 value := reasoning
319 if value == "" {
320 value = thinking
321 }
322 if value == "" {
323 return "", nil
324 }
325 normalized := provider.NormalizeReasoning(value)
326 switch normalized {
327 case "":
328 return "off", nil
329 case "minimum", "low", "medium", "high", "xhigh", "max":
330 return normalized, nil
331 default:
332 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
333 }
334}
335
336// protocolToolError keeps model-visible validation failures in the
337// ToolResult channel rather than treating them as host execution errors.
338func protocolToolError(msg string) (core.ToolResult, error) {
339 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
340 return toolErr(msg), nil
341}
342
343func toolErr(msg string) core.ToolResult {
344 return core.ToolResult{
345 Content: []provider.Content{provider.TextBlock{Text: msg}},
346 IsError: true,
347 }
348}
349
350func truncateTask(s string, n int) string {
351 if len(s) <= n {
352 return s
353 }
354 return s[:n-3] + "..."
355}