1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "slices"
9 "sort"
10 "strings"
11
12 "github.com/bnema/zut/packages/core"
13)
14
15// SubagentTool is the single model-facing subagent tool. One call carries one
16// action; each action delegates to an internal implementation, and a nil
17// implementation means that action is unavailable under the launch-time policy.
18type SubagentTool struct {
19 Spawn *SubagentSpawnTool
20 Status *SubagentStatusTool
21 Stop *SubagentStopTool
22 Resume *SubagentResumeTool
23}
24
25// subagentSpawnGuidance is the operational contract for delegation. It is the
26// facade description tail and the internal spawn tool description, so the text
27// cannot drift between them.
28const subagentSpawnGuidance = "Delegate a concrete, bounded scope to a resident sub-agent. For proactive delegation, use an independent sidecar only when the parent has useful non-overlapping work; keep immediate blockers local. A worker owns its scope until completion, so never duplicate it in the parent. If delegation owns the blocking task, end or yield the parent turn. 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 the resume action. Never use bash sleep, watch, tail -f, polling loops, repeated status calls, dashboard, metadata, or file checks solely to wait."
29
30// subagentSchema merges the four action schemas into the flat, model-facing
31// schema the project keeps for provider compatibility. Every property is
32// annotated with the action that owns it; agent_id carries one merged
33// description because it means "omit to list" for status and "required" for
34// stop and resume. Because the schema advertises every action's arguments at
35// once, a field that belongs to a different action is reported instead of
36// ignored, so a mis-selected action cannot silently run as another one. Fields
37// that no action owns still reach the sub-tool, whose strict decoder rejects
38// them. TestSubagentFacadeSchemaCoversActionFields keeps this schema in sync
39// with subagentActionFields.
40const subagentSchema = `{
41 "type": "object",
42 "properties": {
43 "action": {
44 "type": "string",
45 "enum": ["spawn", "status", "stop", "resume"],
46 "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity, or lists the current set. stop requests termination of a stuck child. resume continues an existing child with a new prompt, keeping its session context, and can wait for that follow-up turn."
47 },
48 "task": {
49 "type": "string",
50 "description": "The full task description for the sub-agent. Assign a concrete, bounded scope and explicit ownership that does not overlap other active work. 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 starts with NO context from this conversation. Shared isolation uses this working directory; worktree isolation captures a patch without merging it. Required when action is spawn."
51 },
52 "agent": {
53 "type": "string",
54 "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. Only valid when action is spawn."
55 },
56 "model": {
57 "type": "string",
58 "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. Only valid when action is spawn."
59 },
60 "provider": {
61 "type": "string",
62 "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. Only valid when action is spawn."
63 },
64 "reasoning": {
65 "type": "string",
66 "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
67 "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided. Only valid when action is spawn."
68 },
69 "fast_mode": {
70 "type": "boolean",
71 "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting. Only valid when action is spawn."
72 },
73 "required": {
74 "type": "boolean",
75 "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. A bounded wait expiring does not terminate the accepted child and must not be retried while it remains active. Terminal failure or cancellation remains unmet until a successful follow-up. An outcome unobserved across host restart requires explicit user reconciliation. Only valid when action is spawn."
76 },
77 "wait": {
78 "type": "integer",
79 "minimum": 1,
80 "maximum": 300,
81 "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. With resume it waits for the accepted follow-up turn instead. Only valid when action is spawn or resume."
82 },
83 "isolation": {
84 "type": "string",
85 "enum": ["shared", "worktree"],
86 "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it. Only valid when action is spawn."
87 },
88 "agent_id": {
89 "type": "string",
90 "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop or resume."
91 },
92 "include_result": {
93 "type": "boolean",
94 "description": "With status and agent_id, retrieve the saved terminal result without running the child. Only valid when action is status."
95 },
96 "watch": {
97 "type": "integer",
98 "minimum": 1,
99 "maximum": 60,
100 "description": "With status and agent_id, observe the child's live activity (waiting for the model, writing text, or running tools) for this many seconds and return a timeline of changes. Returns early when the child stops running. Use it to inspect progress, not to wait for completion. Only valid when action is status."
101 },
102 "prompt": {
103 "type": "string",
104 "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session. Required when action is resume. After a terminal failure, inspect its saved result first; resume continues the retained session without discarding progress or satisfying required work. Combine with wait to block on that follow-up turn."
105 }
106 },
107 "required": ["action"]
108}`
109
110// subagentActionFields lists the arguments each action owns. A field owned by
111// the selected action, or by no action, is forwarded; a field owned by another
112// action is rejected before dispatch.
113var subagentActionFields = map[string][]string{
114 SubagentActionSpawn: {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
115 SubagentActionStatus: {"agent_id", "include_result", "watch"},
116 SubagentActionStop: {"agent_id"},
117 SubagentActionResume: {"agent_id", "prompt", "wait"},
118}
119
120var (
121 errSubagentActionRequired = errors.New("subagent action is required")
122 errSubagentActionType = errors.New("subagent action must be a string")
123 errSubagentActionUnknown = errors.New("subagent action is unknown")
124)
125
126// subagentForeignFieldError reports an argument that belongs only to other
127// actions than the one the caller selected.
128type subagentForeignFieldError struct {
129 field string
130 action string
131 owners []string
132}
133
134func (e *subagentForeignFieldError) Error() string {
135 if len(e.owners) == 1 {
136 return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to action %s", e.field, e.action, e.owners[0])
137 }
138 return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to the %s actions", e.field, e.action, strings.Join(e.owners, ", "))
139}
140
141func (t *SubagentTool) Name() string { return SubagentToolName }
142func (t *SubagentTool) Description() string {
143 return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
144}
145func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
146
147// ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
148// four actions under one tool name, so the session "always allow" grant is
149// scoped per action instead of per name. A missing, empty, or non-string
150// action returns the plain base name so a malformed call cannot widen the
151// remembered grant.
152func (t *SubagentTool) ConfirmationKey(args json.RawMessage) string {
153 var values map[string]json.RawMessage
154 if err := json.Unmarshal(args, &values); err != nil {
155 return SubagentToolName
156 }
157 var action string
158 if err := json.Unmarshal(values["action"], &action); err != nil {
159 return SubagentToolName
160 }
161 action = strings.TrimSpace(action)
162 if action == "" {
163 return SubagentToolName
164 }
165 return SubagentToolName + ":" + action
166}
167
168var _ core.ConfirmationKeyer = (*SubagentTool)(nil)
169
170func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
171 action, payload, err := parseSubagentAction(raw)
172 switch {
173 case errors.Is(err, errSubagentActionRequired):
174 return protocolToolError("subagent: action is required")
175 case errors.Is(err, errSubagentActionType):
176 return protocolToolError("subagent: action must be a string")
177 case errors.Is(err, errSubagentActionUnknown):
178 return protocolToolError("subagent: unknown action")
179 }
180 var foreign *subagentForeignFieldError
181 if errors.As(err, &foreign) {
182 return protocolToolError(foreign.Error())
183 }
184 if err != nil {
185 return core.ToolResult{}, err
186 }
187 switch action {
188 case SubagentActionSpawn:
189 if t.Spawn == nil {
190 return protocolToolError("subagent: spawn action is unavailable in this mode")
191 }
192 return t.Spawn.Execute(ctx, payload, progress)
193 case SubagentActionStatus:
194 if t.Status == nil {
195 return protocolToolError("subagent: status action is unavailable in this mode")
196 }
197 return t.Status.Execute(ctx, payload, progress)
198 case SubagentActionStop:
199 if t.Stop == nil {
200 return protocolToolError("subagent: stop action is unavailable in this mode")
201 }
202 return t.Stop.Execute(ctx, payload, progress)
203 case SubagentActionResume:
204 if t.Resume == nil {
205 return protocolToolError("subagent: resume action is unavailable in this mode")
206 }
207 return t.Resume.Execute(ctx, payload, progress)
208 }
209 return protocolToolError("subagent: unknown action")
210}
211
212// parseSubagentAction validates the action and returns the payload that action
213// owns. Raw field bytes are forwarded unchanged so argument validation and
214// number handling stay in the sub-tools.
215func parseSubagentAction(raw json.RawMessage) (string, json.RawMessage, error) {
216 var values map[string]json.RawMessage
217 if err := json.Unmarshal(raw, &values); err != nil {
218 return "", nil, fmt.Errorf("invalid args: %w", err)
219 }
220 rawAction, present := values["action"]
221 if !present {
222 return "", nil, errSubagentActionRequired
223 }
224 var action string
225 if err := json.Unmarshal(rawAction, &action); err != nil {
226 return "", nil, fmt.Errorf("%w: %v", errSubagentActionType, err)
227 }
228 action = strings.TrimSpace(action)
229 if action == "" {
230 return "", nil, errSubagentActionRequired
231 }
232 if _, ok := subagentActionFields[action]; !ok {
233 return "", nil, errSubagentActionUnknown
234 }
235 delete(values, "action")
236 // A field can belong to several actions (agent_id serves status, stop and
237 // resume), so collect every owner before deciding.
238 owners := make(map[string][]string, len(subagentActionFields))
239 for ownerAction, fields := range subagentActionFields {
240 for _, name := range fields {
241 owners[name] = append(owners[name], ownerAction)
242 }
243 }
244 payload := make(map[string]json.RawMessage, len(values))
245 for name, value := range values {
246 fieldOwners, claimed := owners[name]
247 if claimed && !slices.Contains(fieldOwners, action) {
248 sort.Strings(fieldOwners)
249 return "", nil, &subagentForeignFieldError{field: name, action: action, owners: fieldOwners}
250 }
251 payload[name] = value
252 }
253 encoded, err := json.Marshal(payload)
254 if err != nil {
255 return "", nil, err
256 }
257 return action, encoded, nil
258}