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. A spawn without wait returns after acceptance; an explicit bounded
20// wait may return the initial completion or expire while the child stays active.
21// Required work records a durable obligation that must be resolved before the
22// parent can produce its terminal response.
23//
24// Available when the host's launch-time tool policy permits delegation. The
25// primary-agent prompt decides whether delegation is proactive or only on an
26// explicit user request.
27const (
28 // SubagentToolName is the single model-facing subagent tool. One call
29 // carries one action; see the SubagentAction* constants.
30 SubagentToolName = "subagent"
31
32 SubagentActionSpawn = "spawn"
33 SubagentActionStatus = "status"
34 SubagentActionStop = "stop"
35 SubagentActionResume = "resume"
36 // SubagentActionInterrupt cancels only the running turn and keeps the child.
37 SubagentActionInterrupt = "interrupt"
38
39 maxSubagentWaitSeconds = 5 * 60
40)
41
42type SubagentSpawnTool struct {
43 ResidentManager *subagents.ResidentManager
44 BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
45 OnResidentSpawned func(subagents.ResidentChildSpec, string)
46
47 // Enabled reports whether the host currently exposes this tool. When nil,
48 // the tool is treated as disabled.
49 Enabled func() bool
50
51 // DefaultModel and DefaultProvider return the host agent's resolved
52 // model and provider. They are used when the tool call omits both
53 // fields and does not select a named profile, so auto-subagents follows
54 // the same auth route as the user sees in the parent session.
55 DefaultModel func() string
56 DefaultProvider func() string
57 DefaultReasoning func() string
58
59 // ResolveSubagent validates and resolves a named markdown profile.
60 ResolveSubagent func(name string) (*subagents.Profile, error)
61}
62
63// ResidentSpawnRequest is the resolved tool input the host uses to construct
64// a complete non-secret resident ChildSpec.
65type ResidentSpawnRequest struct {
66 Task string
67 Profile *subagents.Profile
68 Model string
69 Provider string
70 Reasoning string
71 FastMode *bool
72 Required bool
73 WorkspaceMode subagents.WorkspaceMode
74}
75
76type subagentSpawnArgs struct {
77 Task string `json:"task"`
78 Agent string `json:"agent,omitempty"`
79 Model string `json:"model,omitempty"`
80 Provider string `json:"provider,omitempty"`
81 Reasoning string `json:"reasoning,omitempty"`
82 FastMode *bool `json:"fast_mode,omitempty"`
83 Required bool `json:"required,omitempty"`
84 Wait *int `json:"wait,omitempty"`
85 Isolation string `json:"isolation,omitempty"`
86}
87
88// Name returns the shared facade name: the four subagent tool types are
89// internal implementations and are never registered on their own.
90func (t *SubagentSpawnTool) Name() string { return SubagentToolName }
91
92func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
93 prefix := t.Name()
94 if t.ResidentManager == nil {
95 return protocolToolError(prefix + ": subagent runtime not available in this mode")
96 }
97 if t.Enabled == nil || !t.Enabled() {
98 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
99 }
100 var a subagentSpawnArgs
101 if err := decodeSubagentArgs(raw, &a); err != nil {
102 return core.ToolResult{}, err
103 }
104 task := strings.TrimSpace(a.Task)
105 if task == "" {
106 return protocolToolError(prefix + ": task is required")
107 }
108 if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
109 return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
110 }
111
112 workspaceMode := subagents.WorkspaceShared
113 if value := strings.TrimSpace(a.Isolation); value != "" {
114 workspaceMode = subagents.WorkspaceMode(value)
115 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
116 return protocolToolError(prefix + ": isolation must be shared or worktree")
117 }
118 }
119 agentName := strings.TrimSpace(a.Agent)
120 var profile *subagents.Profile
121 var fastModeOverride *bool
122 if agentName != "" {
123 if t.ResolveSubagent == nil {
124 return protocolToolError(prefix + ": named subagent profiles are unavailable")
125 }
126 var err error
127 profile, err = t.ResolveSubagent(agentName)
128 if err != nil {
129 return protocolToolError(prefix + ": " + err.Error())
130 }
131 if profile == nil {
132 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
133 }
134 fastModeOverride = profile.FastMode
135 }
136 if a.FastMode != nil {
137 fastModeOverride = a.FastMode
138 }
139 model := strings.TrimSpace(a.Model)
140 providerID := strings.TrimSpace(a.Provider)
141 if (model == "") != (providerID == "") {
142 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
143 }
144 if profile != nil {
145 // A profile may specify a qualified model, a bare model, a provider,
146 // or neither. Fill only missing pieces so explicit spawn options win;
147 // the parent session supplies whatever the profile leaves unspecified.
148 profileProvider, profileModel := profile.ModelSelection()
149 if model == "" {
150 model = profileModel
151 }
152 if providerID == "" {
153 providerID = profileProvider
154 }
155 }
156 if model == "" && t.DefaultModel != nil {
157 model = strings.TrimSpace(t.DefaultModel())
158 }
159 if providerID == "" && t.DefaultProvider != nil {
160 providerID = strings.TrimSpace(t.DefaultProvider())
161 }
162
163 reasoning, err := normalizeReasoning(a.Reasoning)
164 if err != nil {
165 return protocolToolError(prefix + ": " + err.Error())
166 }
167 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
168 reasoning, err = normalizeReasoning(profile.Thinking)
169 if err != nil {
170 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
171 }
172 }
173 if reasoning == "" && t.DefaultReasoning != nil {
174 reasoning, err = normalizeReasoning(t.DefaultReasoning())
175 if err != nil {
176 return protocolToolError(prefix + ": host " + err.Error())
177 }
178 }
179 if t.BuildResidentSpec == nil {
180 return protocolToolError(prefix + ": resident child factory is unavailable")
181 }
182 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
183 Task: task, Profile: profile, Model: model, Provider: providerID,
184 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
185 WorkspaceMode: workspaceMode,
186 })
187 if err != nil {
188 return protocolToolError(prefix + ": " + err.Error())
189 }
190 var completion subagents.ResidentCompletion
191 waitTimedOut := false
192 if a.Wait != nil {
193 spec.InitialTurnID = uuid.NewString()
194 completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
195 defer cancelWait()
196 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
197 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
198 }
199 completion, waitTimedOut, err = waitForResidentCompletion(ctx, completionResult, *a.Wait, prefix)
200 if err != nil {
201 return core.ToolResult{}, err
202 }
203 } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
204 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
205 }
206 state := string(subagents.ResidentQueued)
207 if a.Wait != nil {
208 if waitTimedOut {
209 if current, ok := t.ResidentManager.State(spec.ID); ok {
210 state = string(current)
211 }
212 } else {
213 state = completion.Completion().Status
214 }
215 }
216 var sb strings.Builder
217 fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
218 if spec.Profile != "" {
219 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
220 }
221 fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
222 if spec.Reasoning != "" {
223 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
224 }
225 if spec.Required {
226 fmt.Fprintf(&sb, "required: %s\n", state)
227 }
228 if a.Wait != nil {
229 if waitTimedOut {
230 fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
231 sb.WriteString("\nThe accepted sub-agent remains active in the background. It owns the delegated scope: do not repeat that work in the parent. Continue only with a previously selected non-overlapping task, or end/yield for the host-event-driven [auto-subagents update].")
232 } else if completion.HostUpdate {
233 fmt.Fprintf(&sb, "report_delivery: %s\n", subagentReportHostUpdate)
234 if completion.Err != nil {
235 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
236 }
237 sb.WriteString("\nThe full report arrives through [auto-subagents update]. Do not resume or poll solely to retrieve it.")
238 } else if completion.Err != nil {
239 fmt.Fprintf(&sb, "error: %s\n", completion.Err)
240 if completion.Summary != "" {
241 fmt.Fprintf(&sb, "partial: %s\n", completion.Summary)
242 }
243 } else {
244 if completion.Summary != "" {
245 fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
246 }
247 }
248 } else {
249 sb.WriteString("\nThe sub-agent is running in the background and owns the delegated scope. Do not repeat that work in the parent. Continue only with a previously selected non-overlapping task, or end/yield for the host-event-driven [auto-subagents update].")
250 }
251 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
252}
253
254// waitForResidentCompletion blocks until the watched resident turn reports its
255// completion or the explicit bounded wait expires. A cancelled parent context
256// wins over an expiring wait, and a completion that arrives in the same instant
257// as the deadline is reported instead of a timeout.
258func waitForResidentCompletion(ctx context.Context, completionResult <-chan subagents.ResidentCompletion, seconds int, prefix string) (completion subagents.ResidentCompletion, timedOut bool, err error) {
259 waitCtx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second)
260 defer cancel()
261 select {
262 case result, ok := <-completionResult:
263 if !ok {
264 return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
265 }
266 return result, false, nil
267 case <-waitCtx.Done():
268 if ctx.Err() != nil {
269 return subagents.ResidentCompletion{}, false, ctx.Err()
270 }
271 select {
272 case result, ok := <-completionResult:
273 if !ok {
274 return subagents.ResidentCompletion{}, false, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
275 }
276 return result, false, nil
277 default:
278 return subagents.ResidentCompletion{}, true, nil
279 }
280 }
281}
282
283func decodeSubagentArgs(raw json.RawMessage, target any) error {
284 decoder := json.NewDecoder(bytes.NewReader(raw))
285 decoder.DisallowUnknownFields()
286 if err := decoder.Decode(target); err != nil {
287 return fmt.Errorf("invalid args: %w", err)
288 }
289 if err := decoder.Decode(&struct{}{}); err != io.EOF {
290 return fmt.Errorf("invalid args: trailing JSON value")
291 }
292 return nil
293}
294
295func normalizeReasoning(value string) (string, error) {
296 value = strings.TrimSpace(value)
297 if value == "" {
298 return "", nil
299 }
300 normalized := provider.NormalizeReasoning(value)
301 switch normalized {
302 case "":
303 return "off", nil
304 case "minimum", "low", "medium", "high", "xhigh", "max":
305 return normalized, nil
306 default:
307 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
308 }
309}
310
311// protocolToolError keeps model-visible validation failures in the
312// ToolResult channel rather than treating them as host execution errors.
313func protocolToolError(msg string) (core.ToolResult, error) {
314 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
315 return toolErr(msg), nil
316}
317
318func toolErr(msg string) core.ToolResult {
319 return core.ToolResult{
320 Content: []provider.Content{provider.TextBlock{Text: msg}},
321 IsError: true,
322 }
323}
324
325func truncateTask(s string, n int) string {
326 if len(s) <= n {
327 return s
328 }
329 return s[:n-3] + "..."
330}