1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "strings"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16// SubagentSpawnTool lets the main agent delegate work against the host's cwd
17// via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in
18// the background. Required work records a durable obligation that must be
19// resolved before the parent can produce its terminal response.
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.
24const (
25 SubagentSpawnToolName = "subagent_spawn"
26 SubagentStatusToolName = "subagent_status"
27 SubagentStopToolName = "subagent_stop"
28 SubagentResumeToolName = "subagent_resume"
29)
30
31type SubagentSpawnTool struct {
32 // Supervisor is the supervisor used to spawn agents. Nil means subagents
33 // are unavailable in this mode and the tool always errors.
34 Supervisor *subagents.Supervisor
35
36 // Enabled reports whether the host currently exposes this tool. When nil,
37 // the tool is treated as disabled.
38 Enabled func() bool
39
40 // DefaultModel and DefaultProvider return the host agent's resolved
41 // model and provider. They are used when the tool call omits both
42 // fields and does not select a named profile, so auto-subagents follows
43 // the same auth route as the user sees in the parent session.
44 DefaultModel func() string
45 DefaultProvider func() string
46 DefaultReasoning func() string
47
48 // ResolveSubagent validates and resolves a named markdown profile.
49 // The child receives only the name and loads the profile itself.
50 ResolveSubagent func(name string) (*subagents.Profile, error)
51
52 // OnSpawned, if set, is called after every successful spawn with the
53 // requirement mode. Hosts track every worker for asynchronous completion.
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 FastMode *bool `json:"fast_mode,omitempty"`
64 Required bool `json:"required,omitempty"`
65 Isolation string `json:"isolation,omitempty"`
66}
67
68const subagentSpawnSchemaTemplate = `{
69 "type": "object",
70 "properties": {
71 "task": {
72 "type": "string",
73 "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."
74 },
75 "agent": {
76 "type": "string",
77 "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."
78 },
79 "model": {
80 "type": "string",
81 "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."
82 },
83 "provider": {
84 "type": "string",
85 "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."
86 },
87 "reasoning": {
88 "type": "string",
89 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
90 "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
91 },
92 "fast_mode": {
93 "type": "boolean",
94 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
95 },
96 "required": {
97 "type": "boolean",
98 "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."
99 },
100 "isolation": {
101 "type": "string",
102 "enum": ["shared", "worktree"],
103 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
104 }
105 },
106 "required": ["task"]
107}`
108
109func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
110func (t *SubagentSpawnTool) Description() string {
111 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."
112}
113func (t *SubagentSpawnTool) Schema() json.RawMessage {
114 return json.RawMessage(subagentSpawnSchemaTemplate)
115}
116
117func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
118 prefix := t.Name()
119 if t.Supervisor == nil {
120 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
121 }
122 if t.Enabled == nil || !t.Enabled() {
123 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
124 }
125 var a subagentSpawnArgs
126 decoder := json.NewDecoder(bytes.NewReader(raw))
127 decoder.DisallowUnknownFields()
128 if err := decoder.Decode(&a); err != nil {
129 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
130 }
131 if err := decoder.Decode(&struct{}{}); err != io.EOF {
132 return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value")
133 }
134 task := strings.TrimSpace(a.Task)
135 if task == "" {
136 return protocolToolError(prefix + ": task is required")
137 }
138
139 workspaceMode := subagents.WorkspaceShared
140 if value := strings.TrimSpace(a.Isolation); value != "" {
141 workspaceMode = subagents.WorkspaceMode(value)
142 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
143 return protocolToolError(prefix + ": isolation must be shared or worktree")
144 }
145 }
146 agentName := strings.TrimSpace(a.Agent)
147 var profile *subagents.Profile
148 var fastModeOverride *bool
149 fastModeFromProfile := false
150 if agentName != "" {
151 if t.ResolveSubagent == nil {
152 return protocolToolError(prefix + ": named subagent profiles are unavailable")
153 }
154 var err error
155 profile, err = t.ResolveSubagent(agentName)
156 if err != nil {
157 return protocolToolError(prefix + ": " + err.Error())
158 }
159 if profile == nil {
160 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
161 }
162 agentName = profile.Name
163 fastModeOverride = profile.FastMode
164 fastModeFromProfile = profile.FastMode != nil
165 }
166 if a.FastMode != nil {
167 fastModeOverride = a.FastMode
168 fastModeFromProfile = false
169 }
170
171 model := strings.TrimSpace(a.Model)
172 providerID := strings.TrimSpace(a.Provider)
173 var profileTools []string
174 if profile != nil {
175 profileTools = append([]string(nil), profile.Tools...)
176 }
177 if (model == "") != (providerID == "") {
178 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
179 }
180 if profile != nil {
181 // A profile may specify a qualified model, a bare model, a provider,
182 // or neither. Fill only missing pieces so explicit spawn options win;
183 // the parent session supplies whatever the profile leaves unspecified.
184 profileProvider, profileModel := profile.ModelSelection()
185 if model == "" {
186 model = profileModel
187 }
188 if providerID == "" {
189 providerID = profileProvider
190 }
191 }
192 if model == "" && t.DefaultModel != nil {
193 model = strings.TrimSpace(t.DefaultModel())
194 }
195 if providerID == "" && t.DefaultProvider != nil {
196 providerID = strings.TrimSpace(t.DefaultProvider())
197 }
198
199 reasoning, err := normalizeReasoning(a.Reasoning)
200 if err != nil {
201 return protocolToolError(prefix + ": " + err.Error())
202 }
203 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
204 reasoning, err = normalizeReasoning(profile.Thinking)
205 if err != nil {
206 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
207 }
208 }
209 if reasoning == "" && t.DefaultReasoning != nil {
210 reasoning, err = normalizeReasoning(t.DefaultReasoning())
211 if err != nil {
212 return protocolToolError(prefix + ": host " + err.Error())
213 }
214 }
215
216 agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
217 Task: task,
218 Model: model,
219 Provider: providerID,
220 Reasoning: reasoning,
221 FastMode: fastModeOverride,
222 Subagent: agentName,
223 Required: a.Required,
224 WorkspaceMode: workspaceMode,
225 Tools: profileTools,
226 })
227 if err != nil {
228 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
229 }
230 if t.OnSpawned != nil {
231 t.OnSpawned(agent, task, a.Required)
232 }
233 var sb strings.Builder
234 fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
235 fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
236 fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
237 fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
238 if agentName != "" {
239 fmt.Fprintf(&sb, "agent: %s\n", agentName)
240 }
241 if model != "" {
242 fmt.Fprintf(&sb, "model: %s\n", model)
243 }
244 if providerID != "" {
245 fmt.Fprintf(&sb, "provider: %s\n", providerID)
246 }
247 if reasoning != "" {
248 fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
249 }
250 if agent.FastMode {
251 sb.WriteString("fast mode: enabled\n")
252 }
253 if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
254 sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
255 }
256 if a.Required {
257 sb.WriteString("required: pending\n")
258 }
259 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. ")
260 sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
261 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.")
262 timeoutDetail := "unlimited"
263 if agent.Timeout > 0 {
264 timeoutDetail = agent.Timeout.String()
265 }
266 details := map[string]any{
267 "agent_id": agent.ID,
268 "task": task,
269 "agent": agentName,
270 "model": model,
271 "provider": providerID,
272 "reasoning": reasoning,
273 "fast_mode": agent.FastMode,
274 "required": a.Required,
275 "isolation": string(workspaceMode),
276 "timeout": timeoutDetail,
277 "state": agent.Status(),
278 "process_state": string(agent.ProcessState()),
279 "turn_state": string(agent.TurnState()),
280 "result_ref": subagents.ResultRef(agent.ID),
281 }
282 if a.Required {
283 details["requirement_state"] = string(subagents.RequirementPending)
284 }
285 return core.ToolResult{
286 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
287 Details: details,
288 }, nil
289}
290
291func normalizeReasoning(value string) (string, error) {
292 value = strings.TrimSpace(value)
293 if value == "" {
294 return "", nil
295 }
296 normalized := provider.NormalizeReasoning(value)
297 switch normalized {
298 case "":
299 return "off", nil
300 case "minimum", "low", "medium", "high", "xhigh", "max":
301 return normalized, nil
302 default:
303 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
304 }
305}
306
307// protocolToolError keeps model-visible validation failures in the
308// ToolResult channel rather than treating them as host execution errors.
309func protocolToolError(msg string) (core.ToolResult, error) {
310 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
311 return toolErr(msg), nil
312}
313
314func toolErr(msg string) core.ToolResult {
315 return core.ToolResult{
316 Content: []provider.Content{provider.TextBlock{Text: msg}},
317 IsError: true,
318 }
319}
320
321func truncateTask(s string, n int) string {
322 if len(s) <= n {
323 return s
324 }
325 return s[:n-3] + "..."
326}