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