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