v0.16.0 042538b

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 against the host's cwd
17// via subagents.Supervisor.SpawnReq. Every spawn returns immediately and runs in
18// the background. Required work records a durable obligation that must be
19// resolved before the parent can produce its terminal response.
20//
21// Available when the host's launch-time tool policy permits delegation. The
22// primary-agent prompt decides whether delegation is proactive or only on an
23// explicit user request.
24const (
25	SubagentSpawnToolName  = "subagent_spawn"
26	SubagentStatusToolName = "subagent_status"
27	SubagentStopToolName   = "subagent_stop"
28	SubagentResumeToolName = "subagent_resume"
29)
30
31type SubagentSpawnTool struct {
32	// Supervisor is the supervisor used to spawn agents. Nil means subagents
33	// are unavailable in this mode and the tool always errors.
34	Supervisor *subagents.Supervisor
35
36	// Enabled reports whether the host currently exposes this tool. When nil,
37	// the tool is treated as disabled.
38	Enabled func() bool
39
40	// DefaultModel and DefaultProvider return the host agent's resolved
41	// model and provider. They are used when the tool call omits both
42	// fields and does not select a named profile, so auto-subagents follows
43	// the same auth route as the user sees in the parent session.
44	DefaultModel     func() string
45	DefaultProvider  func() string
46	DefaultReasoning func() string
47
48	// ResolveSubagent validates and resolves a named markdown profile.
49	// The child receives only the name and loads the profile itself.
50	ResolveSubagent func(name string) (*subagents.Profile, error)
51
52	// OnSpawned, if set, is called after every successful spawn with the
53	// requirement mode. Hosts track every worker for asynchronous completion.
54	OnSpawned func(agent *subagents.Agent, task string, required bool)
55}
56
57type subagentSpawnArgs struct {
58	Task      string `json:"task"`
59	Agent     string `json:"agent,omitempty"`
60	Model     string `json:"model,omitempty"`
61	Provider  string `json:"provider,omitempty"`
62	Reasoning string `json:"reasoning,omitempty"`
63	FastMode  *bool  `json:"fast_mode,omitempty"`
64	Required  bool   `json:"required,omitempty"`
65	Isolation string `json:"isolation,omitempty"`
66}
67
68const subagentSpawnSchemaTemplate = `{
69  "type": "object",
70  "properties": {
71    "task": {
72      "type": "string",
73      "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."
74    },
75    "agent": {
76      "type": "string",
77      "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."
78    },
79    "model": {
80      "type": "string",
81      "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."
82    },
83    "provider": {
84      "type": "string",
85      "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."
86    },
87    "reasoning": {
88      "type": "string",
89      "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"],
90      "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided."
91    },
92    "fast_mode": {
93      "type": "boolean",
94      "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting."
95    },
96    "required": {
97      "type": "boolean",
98      "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."
99    },
100    "isolation": {
101      "type": "string",
102      "enum": ["shared", "worktree"],
103      "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it."
104    }
105  },
106  "required": ["task"]
107}`
108
109func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
110func (t *SubagentSpawnTool) Description() string {
111	return "Delegate work to a 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, or dashboard/metadata/event-log/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."
112}
113func (t *SubagentSpawnTool) Schema() json.RawMessage {
114	return json.RawMessage(subagentSpawnSchemaTemplate)
115}
116
117func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
118	prefix := t.Name()
119	if t.Supervisor == nil {
120		return protocolToolError(prefix + ": subagent supervisor not available in this mode")
121	}
122	if t.Enabled == nil || !t.Enabled() {
123		return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
124	}
125	var a subagentSpawnArgs
126	decoder := json.NewDecoder(bytes.NewReader(raw))
127	decoder.DisallowUnknownFields()
128	if err := decoder.Decode(&a); err != nil {
129		return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
130	}
131	if err := decoder.Decode(&struct{}{}); err != io.EOF {
132		return core.ToolResult{}, fmt.Errorf("invalid args: trailing JSON value")
133	}
134	task := strings.TrimSpace(a.Task)
135	if task == "" {
136		return protocolToolError(prefix + ": task is required")
137	}
138
139	workspaceMode := subagents.WorkspaceShared
140	if value := strings.TrimSpace(a.Isolation); value != "" {
141		workspaceMode = subagents.WorkspaceMode(value)
142		if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree {
143			return protocolToolError(prefix + ": isolation must be shared or worktree")
144		}
145	}
146	agentName := strings.TrimSpace(a.Agent)
147	var profile *subagents.Profile
148	var fastModeOverride *bool
149	fastModeFromProfile := false
150	if agentName != "" {
151		if t.ResolveSubagent == nil {
152			return protocolToolError(prefix + ": named subagent profiles are unavailable")
153		}
154		var err error
155		profile, err = t.ResolveSubagent(agentName)
156		if err != nil {
157			return protocolToolError(prefix + ": " + err.Error())
158		}
159		if profile == nil {
160			return protocolToolError(prefix + ": unknown subagent profile " + agentName)
161		}
162		agentName = profile.Name
163		fastModeOverride = profile.FastMode
164		fastModeFromProfile = profile.FastMode != nil
165	}
166	if a.FastMode != nil {
167		fastModeOverride = a.FastMode
168		fastModeFromProfile = false
169	}
170
171	model := strings.TrimSpace(a.Model)
172	providerID := strings.TrimSpace(a.Provider)
173	var profileTools []string
174	if profile != nil {
175		profileTools = append([]string(nil), profile.Tools...)
176	}
177	if (model == "") != (providerID == "") {
178		return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly")
179	}
180	if profile != nil {
181		// A profile may specify a qualified model, a bare model, a provider,
182		// or neither. Fill only missing pieces so explicit spawn options win;
183		// the parent session supplies whatever the profile leaves unspecified.
184		profileProvider, profileModel := profile.ModelSelection()
185		if model == "" {
186			model = profileModel
187		}
188		if providerID == "" {
189			providerID = profileProvider
190		}
191	}
192	if model == "" && t.DefaultModel != nil {
193		model = strings.TrimSpace(t.DefaultModel())
194	}
195	if providerID == "" && t.DefaultProvider != nil {
196		providerID = strings.TrimSpace(t.DefaultProvider())
197	}
198
199	reasoning, err := normalizeReasoning(a.Reasoning)
200	if err != nil {
201		return protocolToolError(prefix + ": " + err.Error())
202	}
203	if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" {
204		reasoning, err = normalizeReasoning(profile.Thinking)
205		if err != nil {
206			return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error())
207		}
208	}
209	if reasoning == "" && t.DefaultReasoning != nil {
210		reasoning, err = normalizeReasoning(t.DefaultReasoning())
211		if err != nil {
212			return protocolToolError(prefix + ": host " + err.Error())
213		}
214	}
215
216	agent, err := t.Supervisor.SpawnReq(ctx, subagents.SpawnRequest{
217		Task:          task,
218		Model:         model,
219		Provider:      providerID,
220		Reasoning:     reasoning,
221		FastMode:      fastModeOverride,
222		Subagent:      agentName,
223		Required:      a.Required,
224		WorkspaceMode: workspaceMode,
225		Tools:         profileTools,
226	})
227	if err != nil {
228		return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
229	}
230	if t.OnSpawned != nil {
231		t.OnSpawned(agent, task, a.Required)
232	}
233	var sb strings.Builder
234	fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
235	fmt.Fprintf(&sb, "state: %s/%s\n", agent.ProcessState(), agent.TurnState())
236	fmt.Fprintf(&sb, "workspace: %s\n", agent.WorkspaceMode)
237	fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
238	if agentName != "" {
239		fmt.Fprintf(&sb, "agent: %s\n", agentName)
240	}
241	if model != "" {
242		fmt.Fprintf(&sb, "model: %s\n", model)
243	}
244	if providerID != "" {
245		fmt.Fprintf(&sb, "provider: %s\n", providerID)
246	}
247	if reasoning != "" {
248		fmt.Fprintf(&sb, "reasoning: %s\n", reasoning)
249	}
250	if agent.FastMode {
251		sb.WriteString("fast mode: enabled\n")
252	}
253	if fastModeFromProfile && fastModeOverride != nil && *fastModeOverride && agent.FastModeOverridesHost() {
254		sb.WriteString("warning: subagent profile has fast mode enabled, overriding global fast mode off\n")
255	}
256	if a.Required {
257		sb.WriteString("required: pending\n")
258	}
259	sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update], the only completion signal; the manager remains free for independent work. ")
260	sb.WriteString("Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, or dashboard/metadata/event-log/file checks solely to wait. ")
261	sb.WriteString("Work on unrelated independent tasks; otherwise end or yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed.")
262	timeoutDetail := "unlimited"
263	if agent.Timeout > 0 {
264		timeoutDetail = agent.Timeout.String()
265	}
266	details := map[string]any{
267		"agent_id":      agent.ID,
268		"task":          task,
269		"agent":         agentName,
270		"model":         model,
271		"provider":      providerID,
272		"reasoning":     reasoning,
273		"fast_mode":     agent.FastMode,
274		"required":      a.Required,
275		"isolation":     string(workspaceMode),
276		"timeout":       timeoutDetail,
277		"state":         agent.Status(),
278		"process_state": string(agent.ProcessState()),
279		"turn_state":    string(agent.TurnState()),
280		"result_ref":    subagents.ResultRef(agent.ID),
281	}
282	if a.Required {
283		details["requirement_state"] = string(subagents.RequirementPending)
284	}
285	return core.ToolResult{
286		Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
287		Details: details,
288	}, nil
289}
290
291func normalizeReasoning(value string) (string, error) {
292	value = strings.TrimSpace(value)
293	if value == "" {
294		return "", nil
295	}
296	normalized := provider.NormalizeReasoning(value)
297	switch normalized {
298	case "":
299		return "off", nil
300	case "minimum", "low", "medium", "high", "xhigh", "max":
301		return normalized, nil
302	default:
303		return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max")
304	}
305}
306
307// protocolToolError keeps model-visible validation failures in the
308// ToolResult channel rather than treating them as host execution errors.
309func protocolToolError(msg string) (core.ToolResult, error) {
310	//nolint:nilerr // ToolResult.IsError is the established tool protocol.
311	return toolErr(msg), nil
312}
313
314func toolErr(msg string) core.ToolResult {
315	return core.ToolResult{
316		Content: []provider.Content{provider.TextBlock{Text: msg}},
317		IsError: true,
318	}
319}
320
321func truncateTask(s string, n int) string {
322	if len(s) <= n {
323		return s
324	}
325	return s[:n-3] + "..."
326}

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