v0.44.1 22e42bf

subagent.go RawHistoryFolder

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	Interrupt *SubagentInterruptTool
24}
25
26// subagentSpawnGuidance is the operational contract for delegation. It is the
27// facade description tail and the internal spawn tool description, so the text
28// cannot drift between them.
29const 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."
30
31// subagentSchema merges the action schemas into the flat, model-facing schema
32// the project keeps for provider compatibility. Every property is annotated
33// with the action that owns it; agent_id carries one merged description because
34// it means "omit to list" for status and "required" for stop, resume, and
35// interrupt. Because the schema advertises every action's arguments at
36// once, a field that belongs to a different action is reported instead of
37// ignored, so a mis-selected action cannot silently run as another one. Fields
38// that no action owns still reach the sub-tool, whose strict decoder rejects
39// them. TestSubagentFacadeSchemaCoversActionFields keeps this schema in sync
40// with subagentActionFields.
41const subagentSchema = `{
42  "type": "object",
43  "properties": {
44    "action": {
45      "type": "string",
46      "enum": ["spawn", "status", "stop", "resume", "interrupt"],
47      "description": "One action per call. spawn delegates a new resident sub-agent. status reads bounded state for one child, including its current live activity and pending follow-ups, or lists the current set. resume sends an existing child a follow-up, keeping its session context; by default it steers a running child mid-turn, and it can wait for the answering turn. interrupt cancels only the child's running turn and keeps the child and its context, so a later resume can ask it to wrap up. stop terminates a stuck child."
48    },
49    "task": {
50      "type": "string",
51      "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."
52    },
53    "agent": {
54      "type": "string",
55      "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."
56    },
57    "model": {
58      "type": "string",
59      "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."
60    },
61    "provider": {
62      "type": "string",
63      "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."
64    },
65    "reasoning": {
66      "type": "string",
67      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
68      "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided. Only valid when action is spawn."
69    },
70    "fast_mode": {
71      "type": "boolean",
72      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting. Only valid when action is spawn."
73    },
74    "required": {
75      "type": "boolean",
76      "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."
77    },
78    "wait": {
79      "type": "integer",
80      "minimum": 1,
81      "maximum": 300,
82      "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."
83    },
84    "isolation": {
85      "type": "string",
86      "enum": ["shared", "worktree"],
87      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it. Only valid when action is spawn."
88    },
89    "agent_id": {
90      "type": "string",
91      "description": "Child id or unique id prefix. With status, omit it to list all resident sub-agents. Required when action is stop, resume, or interrupt."
92    },
93    "include_result": {
94      "type": "boolean",
95      "description": "With status and agent_id, retrieve the saved terminal result without running the child. Only valid when action is status."
96    },
97    "watch": {
98      "type": "integer",
99      "minimum": 1,
100      "maximum": 60,
101      "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."
102    },
103    "prompt": {
104      "type": "string",
105      "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 the turn that answers it."
106    },
107    "mode": {
108      "type": "string",
109      "enum": ["steer", "queue"],
110      "description": "How resume reaches a running child. steer (default) injects the prompt into its current turn at the next tool or model boundary, so the child sees it without waiting for the turn to end. queue runs the prompt as a separate turn after the current one. An idle child starts a new turn either way. Only valid when action is resume."
111    }
112  },
113  "required": ["action"]
114}`
115
116// subagentActionFields lists the arguments each action owns. A field owned by
117// the selected action, or by no action, is forwarded; a field owned by another
118// action is rejected before dispatch.
119var subagentActionFields = map[string][]string{
120	SubagentActionSpawn:     {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
121	SubagentActionStatus:    {"agent_id", "include_result", "watch"},
122	SubagentActionStop:      {"agent_id"},
123	SubagentActionResume:    {"agent_id", "prompt", "mode", "wait"},
124	SubagentActionInterrupt: {"agent_id"},
125}
126
127var (
128	errSubagentActionRequired = errors.New("subagent action is required")
129	errSubagentActionType     = errors.New("subagent action must be a string")
130	errSubagentActionUnknown  = errors.New("subagent action is unknown")
131)
132
133// subagentForeignFieldError reports an argument that belongs only to other
134// actions than the one the caller selected.
135type subagentForeignFieldError struct {
136	field  string
137	action string
138	owners []string
139}
140
141func (e *subagentForeignFieldError) Error() string {
142	if len(e.owners) == 1 {
143		return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to action %s", e.field, e.action, e.owners[0])
144	}
145	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, ", "))
146}
147
148func (t *SubagentTool) Name() string { return SubagentToolName }
149func (t *SubagentTool) Description() string {
150	return "Spawn, inspect, resume, interrupt, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
151}
152func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
153
154// ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
155// several actions under one tool name, so the session "always allow" grant is
156// scoped per action instead of per name. A missing, empty, or non-string
157// action returns the plain base name so a malformed call cannot widen the
158// remembered grant.
159func (t *SubagentTool) ConfirmationKey(args json.RawMessage) string {
160	var values map[string]json.RawMessage
161	if err := json.Unmarshal(args, &values); err != nil {
162		return SubagentToolName
163	}
164	var action string
165	if err := json.Unmarshal(values["action"], &action); err != nil {
166		return SubagentToolName
167	}
168	action = strings.TrimSpace(action)
169	if action == "" {
170		return SubagentToolName
171	}
172	return SubagentToolName + ":" + action
173}
174
175var _ core.ConfirmationKeyer = (*SubagentTool)(nil)
176
177func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
178	action, payload, err := parseSubagentAction(raw)
179	switch {
180	case errors.Is(err, errSubagentActionRequired):
181		return protocolToolError("subagent: action is required")
182	case errors.Is(err, errSubagentActionType):
183		return protocolToolError("subagent: action must be a string")
184	case errors.Is(err, errSubagentActionUnknown):
185		return protocolToolError("subagent: unknown action")
186	}
187	var foreign *subagentForeignFieldError
188	if errors.As(err, &foreign) {
189		return protocolToolError(foreign.Error())
190	}
191	if err != nil {
192		return core.ToolResult{}, err
193	}
194	switch action {
195	case SubagentActionSpawn:
196		if t.Spawn == nil {
197			return protocolToolError("subagent: spawn action is unavailable in this mode")
198		}
199		return t.Spawn.Execute(ctx, payload, progress)
200	case SubagentActionStatus:
201		if t.Status == nil {
202			return protocolToolError("subagent: status action is unavailable in this mode")
203		}
204		return t.Status.Execute(ctx, payload, progress)
205	case SubagentActionStop:
206		if t.Stop == nil {
207			return protocolToolError("subagent: stop action is unavailable in this mode")
208		}
209		return t.Stop.Execute(ctx, payload, progress)
210	case SubagentActionResume:
211		if t.Resume == nil {
212			return protocolToolError("subagent: resume action is unavailable in this mode")
213		}
214		return t.Resume.Execute(ctx, payload, progress)
215	case SubagentActionInterrupt:
216		if t.Interrupt == nil {
217			return protocolToolError("subagent: interrupt action is unavailable in this mode")
218		}
219		return t.Interrupt.Execute(ctx, payload, progress)
220	}
221	return protocolToolError("subagent: unknown action")
222}
223
224// parseSubagentAction validates the action and returns the payload that action
225// owns. Raw field bytes are forwarded unchanged so argument validation and
226// number handling stay in the sub-tools.
227func parseSubagentAction(raw json.RawMessage) (string, json.RawMessage, error) {
228	var values map[string]json.RawMessage
229	if err := json.Unmarshal(raw, &values); err != nil {
230		return "", nil, fmt.Errorf("invalid args: %w", err)
231	}
232	rawAction, present := values["action"]
233	if !present {
234		return "", nil, errSubagentActionRequired
235	}
236	var action string
237	if err := json.Unmarshal(rawAction, &action); err != nil {
238		return "", nil, fmt.Errorf("%w: %v", errSubagentActionType, err)
239	}
240	action = strings.TrimSpace(action)
241	if action == "" {
242		return "", nil, errSubagentActionRequired
243	}
244	if _, ok := subagentActionFields[action]; !ok {
245		return "", nil, errSubagentActionUnknown
246	}
247	delete(values, "action")
248	// A field can belong to several actions (agent_id serves status, stop and
249	// resume), so collect every owner before deciding.
250	owners := make(map[string][]string, len(subagentActionFields))
251	for ownerAction, fields := range subagentActionFields {
252		for _, name := range fields {
253			owners[name] = append(owners[name], ownerAction)
254		}
255	}
256	payload := make(map[string]json.RawMessage, len(values))
257	for name, value := range values {
258		fieldOwners, claimed := owners[name]
259		if claimed && !slices.Contains(fieldOwners, action) {
260			sort.Strings(fieldOwners)
261			return "", nil, &subagentForeignFieldError{field: name, action: action, owners: fieldOwners}
262		}
263		payload[name] = value
264	}
265	encoded, err := json.Marshal(payload)
266	if err != nil {
267		return "", nil, err
268	}
269	return action, encoded, nil
270}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close