1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "strings"
10 "time"
11
12 "github.com/bnema/zut/packages/agent/subagents"
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15 "github.com/google/uuid"
16)
17
18// SubagentSpawnTool lets the main agent delegate work through the resident
19// manager. Every spawn returns immediately and runs in the background. Required work records a durable obligation that must be
20// resolved before the parent can produce its terminal response.
21//
22// Available when the host's launch-time tool policy permits delegation. The
23// primary-agent prompt decides whether delegation is proactive or only on an
24// explicit user request.
25const (
26 SubagentSpawnToolName = "subagent_spawn"
27 SubagentStatusToolName = "subagent_status"
28 SubagentStopToolName = "subagent_stop"
29 SubagentResumeToolName = "subagent_resume"
30
31 maxSubagentWaitSeconds = 5 * 60
32)
33
34type SubagentSpawnTool struct {
35 ResidentManager *subagents.ResidentManager
36 BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
37 OnResidentSpawned func(subagents.ResidentChildSpec, string)
38
39 // Enabled reports whether the host currently exposes this tool. When nil,
40 // the tool is treated as disabled.
41 Enabled func() bool
42
43 // DefaultModel and DefaultProvider return the host agent's resolved
44 // model and provider. They are used when the tool call omits both
45 // fields and does not select a named profile, so auto-subagents follows
46 // the same auth route as the user sees in the parent session.
47 DefaultModel func() string
48 DefaultProvider func() string
49 DefaultReasoning func() string
50
51 // ResolveSubagent validates and resolves a named markdown profile.
52 ResolveSubagent func(name string) (*subagents.Profile, error)
53}
54
55// ResidentSpawnRequest is the resolved tool input the host uses to construct
56// a complete non-secret resident ChildSpec.
57type ResidentSpawnRequest struct {
58 Task string
59 Profile *subagents.Profile
60 Model string
61 Provider string
62 Reasoning string
63 FastMode *bool
64 Required bool
65 WorkspaceMode subagents.WorkspaceMode
66}
67
68type subagentSpawnArgs struct {
69 Task string `json:"task"`
70 Agent string `json:"agent,omitempty"`
71 Model string `json:"model,omitempty"`
72 Provider string `json:"provider,omitempty"`
73 Reasoning string `json:"reasoning,omitempty"`
74 FastMode *bool `json:"fast_mode,omitempty"`
75 Required bool `json:"required,omitempty"`
76 Wait *int `json:"wait,omitempty"`
77 Isolation string `json:"isolation,omitempty"`
78}
79
80const subagentSpawnSchemaTemplate = `{
81 "type": "object",
82 "properties": {
83 "task": {
84 "type": "string",
85 "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."
86 },
87 "agent": {
88 "type": "string",
89 "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."
90 },
91 "model": {
92 "type": "string",
93 "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."
94 },
95 "provider": {
96 "type": "string",
97 "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."
98 },
99 "reasoning": {
100 "type": "string",
101 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
102 "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
103 },
104 "fast_mode": {
105 "type": "boolean",
106 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
107 },
108 "required": {
109 "type": "boolean",
110 "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."
111 },
112 "wait": {
113 "type": "integer",
114 "minimum": 1,
115 "maximum": 300,
116 "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
117 },
118 "isolation": {
119 "type": "string",
120 "enum": ["shared", "worktree"],
121 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
122 }
123 },
124 "required": ["task"]
125}`
126
127func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
128func (t *SubagentSpawnTool) Description() string {
129 return "Delegate work to a resident sub-agent. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. 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, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn."
130}
131func (t *SubagentSpawnTool) Schema() json.RawMessage {
132 return json.RawMessage(subagentSpawnSchemaTemplate)
133}
134
135func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
136 prefix := t.Name()
137 if t.ResidentManager == nil {
138 return protocolToolError(prefix + ": subagent runtime not available in this mode")
139 }
140 if t.Enabled == nil || !t.Enabled() {
141 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
142 }
143 var a subagentSpawnArgs
144 if err := decodeSubagentArgs(raw, &a); err != nil {
145 return core.ToolResult{}, err
146 }
147 task := strings.TrimSpace(a.Task)
148 if task == "" {
149 return protocolToolError(prefix + ": task is required")
150 }
151 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
152 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
153 }
154
155 workspaceMode := subagents.WorkspaceShared
156 if value := strings.TrimSpace(a.Isolation); value != "" {
157 workspaceMode = subagents.WorkspaceMode(value)
158 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
159 return protocolToolError(prefix + ": isolation must be shared or worktree")
160 }
161 }
162 agentName := strings.TrimSpace(a.Agent)
163 var profile *subagents.Profile
164 var fastModeOverride *bool
165 if agentName != "" {
166 if t.ResolveSubagent == nil {
167 return protocolToolError(prefix + ": named subagent profiles are unavailable")
168 }
169 var err error
170 profile, err = t.ResolveSubagent(agentName)
171 if err != nil {
172 return protocolToolError(prefix + ": " + err.Error())
173 }
174 if profile == nil {
175 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
176 }
177 fastModeOverride = profile.FastMode
178 }
179 if a.FastMode != nil {
180 fastModeOverride = a.FastMode
181 }
182
183 model := strings.TrimSpace(a.Model)
184 providerID := strings.TrimSpace(a.Provider)
185 if (model == "") != (providerID == "") {
186 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
187 }
188 if profile != nil {
189 // A profile may specify a qualified model, a bare model, a provider,
190 // or neither. Fill only missing pieces so explicit spawn options win;
191 // the parent session supplies whatever the profile leaves unspecified.
192 profileProvider, profileModel := profile.ModelSelection()
193 if model == "" {
194 model = profileModel
195 }
196 if providerID == "" {
197 providerID = profileProvider
198 }
199 }
200 if model == "" && t.DefaultModel != nil {
201 model = strings.TrimSpace(t.DefaultModel())
202 }
203 if providerID == "" && t.DefaultProvider != nil {
204 providerID = strings.TrimSpace(t.DefaultProvider())
205 }
206
207 reasoning, err := normalizeReasoning(a.Reasoning)
208 if err != nil {
209 return protocolToolError(prefix + ": " + err.Error())
210 }
211 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
212 reasoning, err = normalizeReasoning(profile.Thinking)
213 if err != nil {
214 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
215 }
216 }
217 if reasoning == "" && t.DefaultReasoning != nil {
218 reasoning, err = normalizeReasoning(t.DefaultReasoning())
219 if err != nil {
220 return protocolToolError(prefix + ": host " + err.Error())
221 }
222 }
223 if t.BuildResidentSpec == nil {
224 return protocolToolError(prefix + ": resident child factory is unavailable")
225 }
226 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
227 Task: task, Profile: profile, Model: model, Provider: providerID,
228 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
229 WorkspaceMode: workspaceMode,
230 })
231 if err != nil {
232 return protocolToolError(prefix + ": " + err.Error())
233 }
234 var completion subagents.ResidentCompletion
235 waitTimedOut := false
236 if a.Wait != nil {
237 spec.InitialTurnID = uuid.NewString()
238 completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
239 defer cancelWait()
240 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
241 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
242 }
243 waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
244 defer cancel()
245 select {
246 case result, ok := <-completionResult:
247 if !ok {
248 return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
249 }
250 completion = result
251 case <-waitCtx.Done():
252 if ctx.Err() != nil {
253 return core.ToolResult{}, ctx.Err()
254 }
255 select {
256 case result, ok := <-completionResult:
257 if !ok {
258 return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
259 }
260 completion = result
261 default:
262 waitTimedOut = true
263 }
264 }
265 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
266 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
267 }
268 state := "queued"
269 if a.Wait != nil {
270 if waitTimedOut {
271 if current, ok := t.ResidentManager.State(spec.ID); ok {
272 state = string(current)
273 }
274 } else if completion.Err != nil {
275 state = "failed"
276 } else {
277 state = "completed"
278 }
279 }
280 var sb strings.Builder
281 fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
282 if spec.Profile != "" {
283 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
284 }
285 fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
286 if spec.Reasoning != "" {
287 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
288 }
289 if spec.Required {
290 fmt.Fprintf(&sb, "required: %s\n", state)
291 }
292 if a.Wait != nil {
293 if waitTimedOut {
294 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
295 sb.WriteString("\nThe accepted sub-agent remains active in the background. Completion is host-event-driven through [auto-subagents update].")
296 } else if completion.Err != nil {
297 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
298 if completion.Summary != "" {
299 fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
300 }
301 } else {
302 if completion.Summary != "" {
303 fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
304 }
305 }
306 } else {
307 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
308 }
309 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
310}
311
312func decodeSubagentArgs(raw json.RawMessage, target any) error {
313 decoder := json.NewDecoder(bytes.NewReader(raw))
314 decoder.DisallowUnknownFields()
315 if err := decoder.Decode(target); err != nil {
316 return fmt.Errorf("invalid args: %w", err)
317 }
318 if err := decoder.Decode(&struct{}{}); err != io.EOF {
319 return fmt.Errorf("invalid args: trailing JSON value")
320 }
321 return nil
322}
323
324func normalizeReasoning(value string) (string, error) {
325 value = strings.TrimSpace(value)
326 if value == "" {
327 return "", nil
328 }
329 normalized := provider.NormalizeReasoning(value)
330 switch normalized {
331 case "":
332 return "off", nil
333 case "minimum", "low", "medium", "high", "xhigh", "max":
334 return normalized, nil
335 default:
336 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
337 }
338}
339
340// protocolToolError keeps model-visible validation failures in the
341// ToolResult channel rather than treating them as host execution errors.
342func protocolToolError(msg string) (core.ToolResult, error) {
343 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
344 return toolErr(msg), nil
345}
346
347func toolErr(msg string) core.ToolResult {
348 return core.ToolResult{
349 Content: []provider.Content{provider.TextBlock{Text: msg}},
350 IsError: true,
351 }
352}
353
354func truncateTask(s string, n int) string {
355 if len(s) <= n {
356 return s
357 }
358 return s[:n-3] + "..."
359}