v0.41.0 6084eb1

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}
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 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    "prompt": {
97      "type": "string",
98      "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."
99    }
100  },
101  "required": ["action"]
102}`
103
104// subagentActionFields lists the arguments each action owns. A field owned by
105// the selected action, or by no action, is forwarded; a field owned by another
106// action is rejected before dispatch.
107var subagentActionFields = map[string][]string{
108	SubagentActionSpawn:  {"task", "agent", "model", "provider", "reasoning", "fast_mode", "required", "wait", "isolation"},
109	SubagentActionStatus: {"agent_id", "include_result"},
110	SubagentActionStop:   {"agent_id"},
111	SubagentActionResume: {"agent_id", "prompt", "wait"},
112}
113
114var (
115	errSubagentActionRequired = errors.New("subagent action is required")
116	errSubagentActionType     = errors.New("subagent action must be a string")
117	errSubagentActionUnknown  = errors.New("subagent action is unknown")
118)
119
120// subagentForeignFieldError reports an argument that belongs only to other
121// actions than the one the caller selected.
122type subagentForeignFieldError struct {
123	field  string
124	action string
125	owners []string
126}
127
128func (e *subagentForeignFieldError) Error() string {
129	if len(e.owners) == 1 {
130		return fmt.Sprintf("subagent: %s is not valid for action %s; it belongs to action %s", e.field, e.action, e.owners[0])
131	}
132	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, ", "))
133}
134
135func (t *SubagentTool) Name() string { return SubagentToolName }
136func (t *SubagentTool) Description() string {
137	return "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n" + subagentSpawnGuidance
138}
139func (t *SubagentTool) Schema() json.RawMessage { return json.RawMessage(subagentSchema) }
140
141// ConfirmationKey implements core.ConfirmationKeyer. The facade multiplexes
142// four actions under one tool name, so the session "always allow" grant is
143// scoped per action instead of per name. A missing, empty, or non-string
144// action returns the plain base name so a malformed call cannot widen the
145// remembered grant.
146func (t *SubagentTool) ConfirmationKey(args json.RawMessage) string {
147	var values map[string]json.RawMessage
148	if err := json.Unmarshal(args, &values); err != nil {
149		return SubagentToolName
150	}
151	var action string
152	if err := json.Unmarshal(values["action"], &action); err != nil {
153		return SubagentToolName
154	}
155	action = strings.TrimSpace(action)
156	if action == "" {
157		return SubagentToolName
158	}
159	return SubagentToolName + ":" + action
160}
161
162var _ core.ConfirmationKeyer = (*SubagentTool)(nil)
163
164func (t *SubagentTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
165	action, payload, err := parseSubagentAction(raw)
166	switch {
167	case errors.Is(err, errSubagentActionRequired):
168		return protocolToolError("subagent: action is required")
169	case errors.Is(err, errSubagentActionType):
170		return protocolToolError("subagent: action must be a string")
171	case errors.Is(err, errSubagentActionUnknown):
172		return protocolToolError("subagent: unknown action")
173	}
174	var foreign *subagentForeignFieldError
175	if errors.As(err, &foreign) {
176		return protocolToolError(foreign.Error())
177	}
178	if err != nil {
179		return core.ToolResult{}, err
180	}
181	switch action {
182	case SubagentActionSpawn:
183		if t.Spawn == nil {
184			return protocolToolError("subagent: spawn action is unavailable in this mode")
185		}
186		return t.Spawn.Execute(ctx, payload, progress)
187	case SubagentActionStatus:
188		if t.Status == nil {
189			return protocolToolError("subagent: status action is unavailable in this mode")
190		}
191		return t.Status.Execute(ctx, payload, progress)
192	case SubagentActionStop:
193		if t.Stop == nil {
194			return protocolToolError("subagent: stop action is unavailable in this mode")
195		}
196		return t.Stop.Execute(ctx, payload, progress)
197	case SubagentActionResume:
198		if t.Resume == nil {
199			return protocolToolError("subagent: resume action is unavailable in this mode")
200		}
201		return t.Resume.Execute(ctx, payload, progress)
202	}
203	return protocolToolError("subagent: unknown action")
204}
205
206// parseSubagentAction validates the action and returns the payload that action
207// owns. Raw field bytes are forwarded unchanged so argument validation and
208// number handling stay in the sub-tools.
209func parseSubagentAction(raw json.RawMessage) (string, json.RawMessage, error) {
210	var values map[string]json.RawMessage
211	if err := json.Unmarshal(raw, &values); err != nil {
212		return "", nil, fmt.Errorf("invalid args: %w", err)
213	}
214	rawAction, present := values["action"]
215	if !present {
216		return "", nil, errSubagentActionRequired
217	}
218	var action string
219	if err := json.Unmarshal(rawAction, &action); err != nil {
220		return "", nil, fmt.Errorf("%w: %v", errSubagentActionType, err)
221	}
222	action = strings.TrimSpace(action)
223	if action == "" {
224		return "", nil, errSubagentActionRequired
225	}
226	if _, ok := subagentActionFields[action]; !ok {
227		return "", nil, errSubagentActionUnknown
228	}
229	delete(values, "action")
230	// A field can belong to several actions (agent_id serves status, stop and
231	// resume), so collect every owner before deciding.
232	owners := make(map[string][]string, len(subagentActionFields))
233	for ownerAction, fields := range subagentActionFields {
234		for _, name := range fields {
235			owners[name] = append(owners[name], ownerAction)
236		}
237	}
238	payload := make(map[string]json.RawMessage, len(values))
239	for name, value := range values {
240		fieldOwners, claimed := owners[name]
241		if claimed && !slices.Contains(fieldOwners, action) {
242			sort.Strings(fieldOwners)
243			return "", nil, &subagentForeignFieldError{field: name, action: action, owners: fieldOwners}
244		}
245		payload[name] = value
246	}
247	encoded, err := json.Marshal(payload)
248	if err != nil {
249		return "", nil, err
250	}
251	return action, encoded, nil
252}

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