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 through the resident
17// manager. Every spawn returns immediately and runs in 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 ResidentManager *subagents.ResidentManager
32 BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error)
33 OnResidentSpawned func(subagents.ResidentChildSpec, string)
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 ResolveSubagent func(name string) (*subagents.Profile, error)
49}
50
51// ResidentSpawnRequest is the resolved tool input the host uses to construct
52// a complete non-secret resident ChildSpec.
53type ResidentSpawnRequest struct {
54 Task string
55 Profile *subagents.Profile
56 Model string
57 Provider string
58 Reasoning string
59 FastMode *bool
60 Required bool
61 WorkspaceMode subagents.WorkspaceMode
62}
63
64type subagentSpawnArgs struct {
65 Task string `json:"task"`
66 Agent string `json:"agent,omitempty"`
67 Model string `json:"model,omitempty"`
68 Provider string `json:"provider,omitempty"`
69 Reasoning string `json:"reasoning,omitempty"`
70 FastMode *bool `json:"fast_mode,omitempty"`
71 Required bool `json:"required,omitempty"`
72 Isolation string `json:"isolation,omitempty"`
73}
74
75const subagentSpawnSchemaTemplate = `{
76 "type": "object",
77 "properties": {
78 "task": {
79 "type": "string",
80 "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."
81 },
82 "agent": {
83 "type": "string",
84 "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."
85 },
86 "model": {
87 "type": "string",
88 "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."
89 },
90 "provider": {
91 "type": "string",
92 "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."
93 },
94 "reasoning": {
95 "type": "string",
96 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
97 "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
98 },
99 "fast_mode": {
100 "type": "boolean",
101 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
102 },
103 "required": {
104 "type": "boolean",
105 "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."
106 },
107 "isolation": {
108 "type": "string",
109 "enum": ["shared", "worktree"],
110 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
111 }
112 },
113 "required": ["task"]
114}`
115
116func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
117func (t *SubagentSpawnTool) Description() string {
118 return "Delegate work to a resident 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, dashboard, metadata, or 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."
119}
120func (t *SubagentSpawnTool) Schema() json.RawMessage {
121 return json.RawMessage(subagentSpawnSchemaTemplate)
122}
123
124func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
125 prefix := t.Name()
126 if t.ResidentManager == nil {
127 return protocolToolError(prefix + ": subagent runtime not available in this mode")
128 }
129 if t.Enabled == nil || !t.Enabled() {
130 return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
131 }
132 var a subagentSpawnArgs
133 if err := decodeSubagentArgs(raw, &a); err != nil {
134 return core.ToolResult{}, err
135 }
136 task := strings.TrimSpace(a.Task)
137 if task == "" {
138 return protocolToolError(prefix + ": task is required")
139 }
140
141 workspaceMode := subagents.WorkspaceShared
142 if value := strings.TrimSpace(a.Isolation); value != "" {
143 workspaceMode = subagents.WorkspaceMode(value)
144 if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
145 return protocolToolError(prefix + ": isolation must be shared or worktree")
146 }
147 }
148 agentName := strings.TrimSpace(a.Agent)
149 var profile *subagents.Profile
150 var fastModeOverride *bool
151 if agentName != "" {
152 if t.ResolveSubagent == nil {
153 return protocolToolError(prefix + ": named subagent profiles are unavailable")
154 }
155 var err error
156 profile, err = t.ResolveSubagent(agentName)
157 if err != nil {
158 return protocolToolError(prefix + ": " + err.Error())
159 }
160 if profile == nil {
161 return protocolToolError(prefix + ": unknown subagent profile " + agentName)
162 }
163 fastModeOverride = profile.FastMode
164 }
165 if a.FastMode != nil {
166 fastModeOverride = a.FastMode
167 }
168
169 model := strings.TrimSpace(a.Model)
170 providerID := strings.TrimSpace(a.Provider)
171 if (model == "") != (providerID == "") {
172 return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
173 }
174 if profile != nil {
175 // A profile may specify a qualified model, a bare model, a provider,
176 // or neither. Fill only missing pieces so explicit spawn options win;
177 // the parent session supplies whatever the profile leaves unspecified.
178 profileProvider, profileModel := profile.ModelSelection()
179 if model == "" {
180 model = profileModel
181 }
182 if providerID == "" {
183 providerID = profileProvider
184 }
185 }
186 if model == "" && t.DefaultModel != nil {
187 model = strings.TrimSpace(t.DefaultModel())
188 }
189 if providerID == "" && t.DefaultProvider != nil {
190 providerID = strings.TrimSpace(t.DefaultProvider())
191 }
192
193 reasoning, err := normalizeReasoning(a.Reasoning)
194 if err != nil {
195 return protocolToolError(prefix + ": " + err.Error())
196 }
197 if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
198 reasoning, err = normalizeReasoning(profile.Thinking)
199 if err != nil {
200 return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
201 }
202 }
203 if reasoning == "" && t.DefaultReasoning != nil {
204 reasoning, err = normalizeReasoning(t.DefaultReasoning())
205 if err != nil {
206 return protocolToolError(prefix + ": host " + err.Error())
207 }
208 }
209 if t.BuildResidentSpec == nil {
210 return protocolToolError(prefix + ": resident child factory is unavailable")
211 }
212 spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{
213 Task: task, Profile: profile, Model: model, Provider: providerID,
214 Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required,
215 WorkspaceMode: workspaceMode,
216 })
217 if err != nil {
218 return protocolToolError(prefix + ": " + err.Error())
219 }
220 if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
221 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
222 }
223 var sb strings.Builder
224 fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200))
225 if spec.Profile != "" {
226 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
227 }
228 fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider)
229 if spec.Reasoning != "" {
230 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
231 }
232 if spec.Required {
233 sb.WriteString("required: pending\n")
234 }
235 sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
236 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
237}
238
239func decodeSubagentArgs(raw json.RawMessage, target any) error {
240 decoder := json.NewDecoder(bytes.NewReader(raw))
241 decoder.DisallowUnknownFields()
242 if err := decoder.Decode(target); err != nil {
243 return fmt.Errorf("invalid args: %w", err)
244 }
245 if err := decoder.Decode(&struct{}{}); err != io.EOF {
246 return fmt.Errorf("invalid args: trailing JSON value")
247 }
248 return nil
249}
250
251func normalizeReasoning(value string) (string, error) {
252 value = strings.TrimSpace(value)
253 if value == "" {
254 return "", nil
255 }
256 normalized := provider.NormalizeReasoning(value)
257 switch normalized {
258 case "":
259 return "off", nil
260 case "minimum", "low", "medium", "high", "xhigh", "max":
261 return normalized, nil
262 default:
263 return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
264 }
265}
266
267// protocolToolError keeps model-visible validation failures in the
268// ToolResult channel rather than treating them as host execution errors.
269func protocolToolError(msg string) (core.ToolResult, error) {
270 //nolint:nilerr // ToolResult.IsError is the established tool protocol.
271 return toolErr(msg), nil
272}
273
274func toolErr(msg string) core.ToolResult {
275 return core.ToolResult{
276 Content: []provider.Content{provider.TextBlock{Text: msg}},
277 IsError: true,
278 }
279}
280
281func truncateTask(s string, n int) string {
282 if len(s) <= n {
283 return s
284 }
285 return s[:n-3] + "..."
286}