v0.19.0 d7aa01a

subagent_spawn.go RawHistoryFolder

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}

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 476

↑↓ moveenter openesc close