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