返回 DeepSeek-Reasonix
task.go
根目录 / internal / agent / task.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "runtime/debug"
12 "strconv"
13 "strings"
14 "time"
15
16 "reasonix/internal/ablation"
17 "reasonix/internal/checkpoint"
18 "reasonix/internal/event"
19 "reasonix/internal/evidence"
20 "reasonix/internal/jobs"
21 "reasonix/internal/permission"
22 "reasonix/internal/planmode"
23 "reasonix/internal/provider"
24 "reasonix/internal/sessiontemp"
25 "reasonix/internal/tool"
26 "reasonix/internal/workspacelease"
27 )
28
29 // withSubagentSessionTemp installs a fresh session-private temporary directory
30 // Manager for one sub-agent run. The returned release must be deferred by the
31 // caller so the directory is retired when the run ends (including background
32 // sub-agent completion).
33 func withSubagentSessionTemp(ctx context.Context) (context.Context, func()) {
34 m := sessiontemp.New()
35 m.Retain()
36 return sessiontemp.WithManager(ctx, m), m.Release
37 }
38
39 // DefaultTaskSystemPrompt steers a sub-agent toward focused, terse delivery —
40 // it doesn't see the parent's conversation so it must self-contain.
41 const DefaultTaskSystemPrompt = `You are a sub-agent invoked by a parent coding agent to carry out one focused task.
42 Use the provided tools to investigate or act. For MCP, use the stable use_capability
43 proxy (list → inspect → call); do not expect direct mcp__* tool schemas. Return a
44 single final answer that is concise and self-contained — the parent will see only
45 that answer, not your tool calls or reasoning. If you need to ask for clarification,
46 fail with a precise question instead of guessing.`
47
48 // DefaultReadOnlyTaskSystemPrompt steers read-only sub-agents toward isolated
49 // research. They never receive writer tools, persisted transcript controls, or
50 // background process controls, so their final answer is the only handoff.
51 const DefaultReadOnlyTaskSystemPrompt = `You are a read-only research sub-agent invoked by a parent coding agent.
52 Use only the provided read-only tools to inspect code, docs, history, and safe shell output.
53 For MCP, use use_capability only for authorized tools that declare readOnly and are
54 not destructive; never treat missing readOnlyHint as permission to call. Do not
55 attempt to write files, install capabilities, mutate memory, control long-lived
56 processes, or delegate to writer-capable agents. If a read-only delegation tool is
57 available and genuinely useful, you may use it within the configured depth limit.
58 Return a concise, self-contained final answer with the evidence the parent needs.`
59
60 const subagentStartContext = `<subagent-context event="SubagentStart">
61 Before acting, check the available skills and tools. If a relevant skill is available, invoke it before continuing. Delegate to another sub-agent only when the task genuinely benefits from isolated context and the delegation tool is available.
62 </subagent-context>`
63
64 // read_skill is deliberately not listed: it renders playbook text inline and
65 // cannot recurse, so depth-capped sub-agents keep it and can still read
66 // playbooks even when they can no longer delegate.
67 var subagentRecursiveTools = []string{
68 "task",
69 "read_only_task",
70 "run_skill",
71 "read_only_skill",
72 "explore",
73 "research",
74 "review",
75 "security_review",
76 }
77
78 var subagentAlwaysHiddenTools = []string{
79 "parallel_tasks",
80 "fleet",
81 "read_subagent_result",
82 "install_skill",
83 "install_source",
84 }
85
86 var subagentJobTools = []string{
87 "wait",
88 "bash_output",
89 "kill_shell",
90 }
91
92 var readOnlySubagentWorkflowTools = []string{
93 "connect_tool_source",
94 }
95
96 const subagentToolBoundarySummary = "Recursive agent/skill tools are exposed only while max_subagent_depth leaves another delegation layer; unsupported background job tools (parallel_tasks, wait, bash_output, kill_shell) are excluded; bash is exposed as foreground-only inside subagents."
97
98 // maxConcurrentBackgroundTasks is the legacy writer-background fallback used
99 // only when a TaskTool has no session scheduler (tests). Production boots
100 // inject MaxParallelWriters via SubagentScheduler.
101 const maxConcurrentBackgroundTasks = DefaultMaxParallelWriters
102
103 // AlwaysHiddenSubagentTools returns the tool names excluded from every
104 // subagent's registry regardless of an explicit allowlist or delegation
105 // depth (unlike subagentRecursiveTools, which depends on remaining depth).
106 // That covers both subagentAlwaysHiddenTools and subagentJobTools —
107 // SubagentToolRegistryForDepth and its read-only variant strip the job tools
108 // unconditionally too. Host UIs offering a tool picker for a subagent
109 // profile's allowed-tools should exclude these from the offered choices —
110 // selecting them would be silently ignored at runtime.
111 func AlwaysHiddenSubagentTools() []string {
112 names := append([]string(nil), subagentAlwaysHiddenTools...)
113 return append(names, subagentJobTools...)
114 }
115
116 // SubagentMetaTools returns the tool names that spawned agents should not inherit
117 // from the parent registry unless a future call site deliberately opts into a
118 // different boundary. They can spawn or author more agent work, so excluding them
119 // preserves one layer of delegation without adding a spawn-count cap.
120 // read_skill stays listed here so the guardian and planner surfaces, which
121 // exclude these names, keep their provider-visible tool sets byte-identical —
122 // only the sub-agent depth cap deliberately stopped stripping it.
123 func SubagentMetaTools() []string {
124 out := append([]string(nil), subagentRecursiveTools...)
125 out = append(out, "read_skill")
126 out = append(out, subagentAlwaysHiddenTools...)
127 return out
128 }
129
130 // SubagentToolRegistry returns the tool set exposed inside spawned sub-agents:
131 // the requested whitelist (or every parent tool), minus meta tools that would
132 // spawn more agent work and job tools whose runtime manager is not injected into
133 // sub-agents. When bash is present, it is wrapped to advertise and allow only
134 // foreground execution.
135 func SubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
136 return SubagentToolRegistryForDepth(parent, names, 1, 1)
137 }
138
139 // SubagentToolRegistryForDepth returns the writer-capable tool set for a spawned
140 // subagent at childDepth. Recursive delegation tools are available only when the
141 // child still has room to spawn one more subagent.
142 //
143 // Direct mcp__* schemas are never exposed: MCP goes only through the fixed
144 // use_capability proxy so connect/disconnect/tool-list churn cannot change the
145 // child provider-visible tool prefix. With no explicit allowlist the child gets
146 // the full proxy (installed/authorized MCP, including tools without
147 // readOnlyHint). An explicit allowlist converts mcp__* / mcp-tool: names into a
148 // capability-id allowlist on a restricted proxy.
149 func SubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
150 return SubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
151 }
152
153 // SubagentToolRegistryForDepthWithRuntime is SubagentToolRegistryForDepth with
154 // an optional session MCP runtime used when the parent registry has no
155 // use_capability (for example Economy or legacy callers) but sub-agents still
156 // need the proxy.
157 func SubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
158 exclude := append([]string(nil), subagentAlwaysHiddenTools...)
159 if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
160 exclude = append(exclude, subagentRecursiveTools...)
161 }
162 exclude = append(exclude, subagentJobTools...)
163 sub := FilterRegistry(parent, names, exclude...)
164 stripDirectMCPTools(sub)
165 attachSubagentCapabilityProxy(parent, sub, names, runtime)
166 if bash, ok := sub.Get("bash"); ok {
167 sub.Add(foregroundOnlyBash{inner: bash})
168 }
169 return sub
170 }
171
172 type foregroundOnlyBash struct {
173 inner tool.Tool
174 }
175
176 func (b foregroundOnlyBash) Name() string { return "bash" }
177
178 func (b foregroundOnlyBash) Description() string {
179 desc := strings.TrimSpace(b.inner.Description())
180 if desc == "" {
181 desc = "Execute a command in the shell and return combined stdout/stderr."
182 }
183 desc = strings.Replace(desc, "Execute a command in the shell", "Execute a foreground command in the shell", 1)
184 return desc + " Background execution is unavailable inside subagents."
185 }
186
187 func (foregroundOnlyBash) Schema() json.RawMessage {
188 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute in the foreground"}},"required":["command"]}`)
189 }
190
191 func (b foregroundOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
192 var p struct {
193 RunInBackground bool `json:"run_in_background"`
194 }
195 if err := json.Unmarshal(args, &p); err != nil {
196 return "", fmt.Errorf("invalid args: %w", err)
197 }
198 if p.RunInBackground {
199 return "", fmt.Errorf("background bash is unavailable in subagents; run a foreground command or ask the parent agent to start a background job")
200 }
201 return b.inner.Execute(ctx, args)
202 }
203
204 func (b foregroundOnlyBash) ReadOnly() bool { return b.inner.ReadOnly() }
205
206 type readOnlyBash struct {
207 inner tool.Tool
208 }
209
210 func (b readOnlyBash) Name() string { return "bash" }
211
212 func (b readOnlyBash) Description() string {
213 desc := strings.TrimSpace(b.inner.Description())
214 if desc == "" {
215 desc = "Execute a command in the shell and return combined stdout/stderr."
216 }
217 desc = strings.Replace(desc, "Execute a command in the shell", "Execute a foreground read-only command in the shell", 1)
218 return desc + " Only permission-classified read-only commands are allowed; shell operators, background execution, process preservation, and write-capable arguments are blocked."
219 }
220
221 func (readOnlyBash) Schema() json.RawMessage {
222 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Read-only shell command to execute in the foreground. Must match the permission-layer read-only command policy."}},"required":["command"]}`)
223 }
224
225 func (b readOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
226 if !permission.BashCommandIsReadOnly(args) {
227 return "blocked: read-only subagents can run only permission-classified foreground read-only commands", nil
228 }
229 return b.inner.Execute(ctx, args)
230 }
231
232 func (readOnlyBash) ReadOnly() bool { return true }
233
234 // TaskTool spawns a sub-agent in its own session for a focused sub-task. The
235 // sub-agent runs with a filtered tool whitelist and the same step budget shape
236 // as the parent (see Execute); its tool calls are forwarded to the parent's
237 // event stream nested under this call, while only its final assistant message is
238 // returned to the parent model. Use cases: keep noisy tool sequences (multi-file
239 // exploration, repeated grep / read_file) out of the parent's context budget, or
240 // parallel research across independent areas (the parallel-dispatch path picks
241 // these up only when readOnly, which task is not).
242 type TaskTool struct {
243 prov provider.Provider
244 pricing *provider.Pricing
245 parentReg *tool.Registry
246 maxSteps int
247 contextWindow int
248 softCompactRatio float64
249 toolResultSnipRatio float64
250 compactRatio float64
251 compactForceRatio float64
252 recentKeep int
253 temperature float64
254 archiveDir string
255 keepPolicy KeepPolicy
256 sysPrompt string
257 gate Gate
258 subagentModel string
259 subagentEffort string
260 resolveProvider func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error)
261 transcripts *SubagentStore
262 workspaceRoot string
263 baseModel string
264 baseEffort string
265 identityProfile func(modelRef, effort string) (string, string)
266 maxSubagentDepth int
267 deliveryProfile bool
268 ablation ablation.Set
269 workspaceLease *workspacelease.Owner
270 // scheduler is the session-scoped concurrency + write-claim controller.
271 // nil falls back to the legacy jobs.ReserveStart cap for background tasks.
272 scheduler *SubagentScheduler
273 // profileLookup resolves profile= names from the live Skill store without
274 // embedding the name list in the tool schema (cache stability).
275 profileLookup ProfileLookup
276 // profileConfigModel/Effort look up persistent per-profile overrides
277 // (agent.subagent_models / subagent_efforts).
278 profileConfigModel func(profile string) string
279 profileConfigEffort func(profile string) string
280 // bashSandboxEnforced reports whether OS sandbox can honour write roots
281 // for bash inside path-bound writer sub-agents.
282 bashSandboxEnforced func() bool
283 // mutationObserver is shared with spawned sub-agents for checkpoint capture.
284 mutationObserver *checkpoint.MutationObserver
285 // recoveryGate is the shared Auto Guard boundary for
286 // this session (root + sub-agents). nil disables recovery in children.
287 recoveryGate RecoveryGate
288 // capabilityRuntime is the session-shared MCP Host/specs substrate. Each
289 // sub-agent gets its own use_capability frontend so ledger state stays
290 // isolated while connections reuse the parent Host.
291 capabilityRuntime *MCPCapabilityRuntime
292 }
293
294 // TaskToolOptions holds the construction parameters for a TaskTool.
295 // Prefer NewTaskToolWithOptions for new call sites; the positional NewTaskTool
296 // remains as a compatibility wrapper for one full iteration cycle.
297 type TaskToolOptions struct {
298 Provider provider.Provider
299 Pricing *provider.Pricing
300 ParentRegistry *tool.Registry
301 MaxSteps int
302 ContextWindow int
303 RecentKeep int
304 SoftCompactRatio float64
305 ToolResultSnipRatio float64
306 CompactRatio float64
307 CompactForceRatio float64
308 Temperature float64
309 ArchiveDir string
310 SysPrompt string
311 Gate Gate
312 KeepPolicy KeepPolicy
313 SubagentModel string
314 SubagentEffort string
315 ResolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)
316 }
317
318 // NewTaskToolWithOptions is the internal standard constructor for TaskTool.
319 // An empty SysPrompt still resolves to DefaultTaskSystemPrompt. No extra
320 // validation or default overrides are applied beyond the historical NewTaskTool
321 // behavior.
322 func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
323 sysPrompt := opts.SysPrompt
324 if sysPrompt == "" {
325 sysPrompt = DefaultTaskSystemPrompt
326 }
327 return &TaskTool{
328 prov: opts.Provider,
329 pricing: opts.Pricing,
330 parentReg: opts.ParentRegistry,
331 maxSteps: opts.MaxSteps,
332 contextWindow: opts.ContextWindow,
333 recentKeep: opts.RecentKeep,
334 softCompactRatio: opts.SoftCompactRatio,
335 toolResultSnipRatio: opts.ToolResultSnipRatio,
336 compactRatio: opts.CompactRatio,
337 compactForceRatio: opts.CompactForceRatio,
338 temperature: opts.Temperature,
339 archiveDir: opts.ArchiveDir,
340 keepPolicy: opts.KeepPolicy,
341 sysPrompt: sysPrompt,
342 gate: opts.Gate,
343 subagentModel: opts.SubagentModel,
344 subagentEffort: opts.SubagentEffort,
345 resolveProvider: opts.ResolveProvider,
346 maxSubagentDepth: DefaultMaxSubagentDepth,
347 }
348 }
349
350 // NewTaskTool wires a task tool to the parent agent's environment so its
351 // sub-agents can use the same provider and tools. sysPrompt is the system
352 // prompt every sub-agent starts with; pass "" for DefaultTaskSystemPrompt. gate
353 // is the permission gate sub-agents inherit — pass the headless variant so
354 // deny rules still bite while autonomous sub-agents are never blocked on an
355 // interactive prompt (there is no UI to answer one).
356 //
357 // Compatibility wrapper: new call sites should prefer NewTaskToolWithOptions.
358 // The positional form is kept for at least one full iteration cycle.
359 func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *tool.Registry,
360 maxSteps, contextWindow, recentKeep int, softCompactRatio, toolResultSnipRatio, compactRatio, compactForceRatio, temperature float64, archiveDir, sysPrompt string, gate Gate,
361 keepPolicy KeepPolicy, subagentModel, subagentEffort string, resolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
362 return NewTaskToolWithOptions(TaskToolOptions{
363 Provider: prov,
364 Pricing: pricing,
365 ParentRegistry: parentReg,
366 MaxSteps: maxSteps,
367 ContextWindow: contextWindow,
368 RecentKeep: recentKeep,
369 SoftCompactRatio: softCompactRatio,
370 ToolResultSnipRatio: toolResultSnipRatio,
371 CompactRatio: compactRatio,
372 CompactForceRatio: compactForceRatio,
373 Temperature: temperature,
374 ArchiveDir: archiveDir,
375 SysPrompt: sysPrompt,
376 Gate: gate,
377 KeepPolicy: keepPolicy,
378 SubagentModel: subagentModel,
379 SubagentEffort: subagentEffort,
380 ResolveProvider: resolveProvider,
381 })
382 }
383
384 // WithTranscripts enables persisted sub-agent transcript continuation for this
385 // task tool. The base model/effort are the parent provider identity used when no
386 // subagent override is configured.
387 func (t *TaskTool) WithTranscripts(store *SubagentStore, workspaceRoot, baseModel, baseEffort string) *TaskTool {
388 t.transcripts = store
389 t.workspaceRoot = strings.TrimSpace(workspaceRoot)
390 t.baseModel = strings.TrimSpace(baseModel)
391 t.baseEffort = strings.TrimSpace(baseEffort)
392 return t
393 }
394
395 func (t *TaskTool) WithTranscriptIdentityResolver(resolve func(modelRef, effort string) (string, string)) *TaskTool {
396 t.identityProfile = resolve
397 return t
398 }
399
400 func (t *TaskTool) WithMaxSubagentDepth(depth int) *TaskTool {
401 t.maxSubagentDepth = NormalizeMaxSubagentDepth(depth)
402 return t
403 }
404
405 // WithDeliveryProfile propagates the parent's runtime delivery contract into
406 // writer-capable sub-agents. Read-only sub-agents may receive the flag too, but
407 // the mutation gate remains dormant for them.
408 func (t *TaskTool) WithDeliveryProfile(enabled bool) *TaskTool {
409 t.deliveryProfile = enabled
410 return t
411 }
412
413 // WithAblation propagates the parent's benchmark arm so a sub-agent runs with
414 // the same subsystems switched off.
415 func (t *TaskTool) WithAblation(set ablation.Set) *TaskTool {
416 t.ablation = set
417 return t
418 }
419
420 // WithWorkspaceLease shares the parent's workspace-wide delivery write lease
421 // with every spawned sub-agent. A shared owner is required: independent owners
422 // in one session would deadlock when a child tries to write while its parent
423 // already retains the lease.
424 func (t *TaskTool) WithWorkspaceLease(owner *workspacelease.Owner) *TaskTool {
425 t.workspaceLease = owner
426 return t
427 }
428
429 // WithScheduler attaches the session-scoped concurrency and write-claim
430 // controller used by task, fleet, parallel_tasks, and profile skill runners.
431 func (t *TaskTool) WithScheduler(s *SubagentScheduler) *TaskTool {
432 t.scheduler = s
433 return t
434 }
435
436 // Scheduler returns the attached session scheduler (may be nil in unit tests).
437 func (t *TaskTool) Scheduler() *SubagentScheduler {
438 if t == nil {
439 return nil
440 }
441 return t.scheduler
442 }
443
444 // WithProfileLookup enables task/fleet profile= resolution from the Skill store.
445 func (t *TaskTool) WithProfileLookup(lookup ProfileLookup) *TaskTool {
446 t.profileLookup = lookup
447 return t
448 }
449
450 // WithProfileConfigResolvers supplies persistent per-profile model/effort
451 // overrides (agent.subagent_models / subagent_efforts).
452 func (t *TaskTool) WithProfileConfigResolvers(model, effort func(profile string) string) *TaskTool {
453 t.profileConfigModel = model
454 t.profileConfigEffort = effort
455 return t
456 }
457
458 // WithBashSandboxEnforced tells path-bound writer runs whether bash can keep
459 // the same write roots under the OS sandbox.
460 func (t *TaskTool) WithBashSandboxEnforced(fn func() bool) *TaskTool {
461 t.bashSandboxEnforced = fn
462 return t
463 }
464
465 // WithCapabilityRuntime attaches the session-shared MCP runtime so ordinary and
466 // read-only sub-agents receive a stable use_capability frontend without
467 // inheriting dynamic mcp__* schemas.
468 func (t *TaskTool) WithCapabilityRuntime(rt *MCPCapabilityRuntime) *TaskTool {
469 if t != nil {
470 t.capabilityRuntime = rt
471 }
472 return t
473 }
474
475 func (t *TaskTool) Name() string { return "task" }
476
477 func (t *TaskTool) Description() string {
478 return "Spawn a sub-agent for a focused sub-task. Optional profile selects a runAs=subagent Skill whose body becomes the full system prompt (no implicit concise default). Optional write_paths declare non-overlapping write targets so background writers may run in parallel; omitting write_paths on a writer claims the whole workspace and serializes writers. The sub-agent runs in its own session with a filtered tool list (defaults to every parent tool, then applies the subagent boundary: " + subagentToolBoundarySummary + "). Only its final answer is returned."
479 }
480
481 func (t *TaskTool) Schema() json.RawMessage {
482 return json.RawMessage(`{
483 "type":"object",
484 "properties":{
485 "prompt":{"type":"string","description":"What the sub-agent should accomplish. Be specific about the deliverable — the sub-agent does not see this conversation."},
486 "description":{"type":"string","description":"Short label for the sub-task (3-7 words). Surfaced in the dispatch line so the user sees what's running."},
487 "profile":{"type":"string","description":"Optional runAs=subagent profile name. Resolved at runtime from the Skill store; explicit names may invoke invocation=manual profiles. The profile body becomes the full system prompt."},
488 "write_paths":{"type":"array","items":{"type":"string"},"description":"Optional workspace-relative or absolute file/directory paths this writer may modify. Globs and workspace escapes are rejected. Writers without write_paths claim the whole workspace (serializing against every other writer claim). Non-overlapping paths allow parallel writers up to max_parallel_writers. In fleet, multiple whole-workspace claims fail preflight before any task starts."},
489 "tools":{"type":"array","items":{"type":"string"},"description":"Optional tool whitelist. When profile sets allowed-tools, this list is intersected (call args cannot expand profile permissions). ` + subagentToolBoundarySummary + `"},
490 "max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
491 "run_in_background":{"type":"boolean","description":"Run the sub-agent asynchronously: returns a job id immediately and keeps working across turns. Collect its final answer with wait, and you'll be notified when it finishes. Use for long, independent sub-tasks you don't need to block on right now."},
492 "model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name). Precedence: persistent profile config, this argument, profile frontmatter, global subagent default, parent model."},
493 "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max). Same precedence as model."},
494 "continue_from":{"type":"string","description":"Continue a prior compatible subagent transcript in the current conversation context. Pass only the 'sa_...' value from the prior result's 'Subagent reference: ...' line. If the ref belongs to an ancestor conversation, the framework continues a current-conversation copy."}
495 },
496 "required":["prompt"]
497 }`)
498 }
499
500 // ReadOnly is false: a sub-agent can invoke any whitelisted tool, including
501 // writers. Conservative classification keeps the parallel-dispatch path from
502 // running two sub-agents at once and letting their writes race.
503 func (t *TaskTool) ReadOnly() bool { return false }
504
505 // ResolveProfile extracts model/effort from task args (and optional profile
506 // overrides) for dispatch-line display. Runtime execution re-resolves with the
507 // full precedence chain.
508 func (t *TaskTool) ResolveProfile(args json.RawMessage) *event.Profile {
509 var p struct {
510 Model string `json:"model"`
511 Effort string `json:"effort"`
512 Profile string `json:"profile"`
513 }
514 if err := json.Unmarshal(args, &p); err != nil {
515 return nil
516 }
517 profileModel, profileEffort := "", ""
518 configModel, configEffort := "", ""
519 if name := strings.TrimSpace(p.Profile); name != "" {
520 if def, err := ResolveProfileDefinition(t.profileLookup, name); err == nil {
521 profileModel, profileEffort = def.Model, def.Effort
522 }
523 if t.profileConfigModel != nil {
524 configModel = t.profileConfigModel(name)
525 }
526 if t.profileConfigEffort != nil {
527 configEffort = t.profileConfigEffort(name)
528 }
529 }
530 model, effort := ResolveModelEffort(
531 configModel, configEffort,
532 p.Model, p.Effort,
533 profileModel, profileEffort,
534 t.subagentModel, t.subagentEffort,
535 )
536 if model == "" && effort == "" {
537 return nil
538 }
539 return &event.Profile{Model: model, Effort: effort}
540 }
541
542 // ReadOnlyTaskTool runs an isolated sub-agent with a strictly read-only tool
543 // registry. It intentionally omits background execution and transcript
544 // continuation/fork controls so the call has no durable host side effects.
545 type ReadOnlyTaskTool struct {
546 task *TaskTool
547 }
548
549 func NewReadOnlyTaskTool(task *TaskTool) *ReadOnlyTaskTool {
550 return &ReadOnlyTaskTool{task: task}
551 }
552
553 func (*ReadOnlyTaskTool) Name() string { return "read_only_task" }
554
555 func (*ReadOnlyTaskTool) Description() string {
556 return "Spawn a read-only research sub-agent for a focused investigation. The sub-agent runs in an isolated, ephemeral session with read-only tools only; bash is wrapped to allow only permission-classified foreground read-only commands. It cannot write files, install capabilities, mutate memory, run background jobs, continue/fork transcripts, or delegate to writer-capable agents. Read-only nested delegation may be available until max_subagent_depth is reached. Only its final answer is returned."
557 }
558
559 func (*ReadOnlyTaskTool) Schema() json.RawMessage {
560 return json.RawMessage(`{
561 "type":"object",
562 "properties":{
563 "prompt":{"type":"string","description":"What the read-only sub-agent should investigate. Be specific about the evidence or summary to return — the sub-agent does not see this conversation."},
564 "description":{"type":"string","description":"Short label for the read-only sub-task (3-7 words). Surfaced in the dispatch line so the user sees what's running."},
565 "tools":{"type":"array","items":{"type":"string"},"description":"Optional read-only tool whitelist. Writer, installer, memory mutation, background job, and delegation tools are never exposed."},
566 "max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
567 "model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name)."},
568 "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."}
569 },
570 "required":["prompt"]
571 }`)
572 }
573
574 func (*ReadOnlyTaskTool) ReadOnly() bool { return true }
575
576 // PlanModeSafe reports true: read_only_task spawns a strictly read-only research
577 // sub-agent (no writers, installers, memory mutation, background jobs, or
578 // delegation), so it is safe to run while planning.
579 func (*ReadOnlyTaskTool) PlanModeSafe() bool { return true }
580
581 func (r *ReadOnlyTaskTool) ResolveProfile(args json.RawMessage) *event.Profile {
582 if r == nil || r.task == nil {
583 return nil
584 }
585 return r.task.ResolveProfile(args)
586 }
587
588 func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (result string, err error) {
589 if r == nil || r.task == nil {
590 return "", fmt.Errorf("read_only_task is not configured")
591 }
592 // read_only_task shares the same progress tracker as RunProfileSpec so
593 // every sub-agent entry point emits the same phase machine. It owns its
594 // merger (no parent task group) and finishes on every exit path.
595 trk := newSubagentProgressTracker(ctx, subSink(ctx))
596 trk.running()
597 defer func() {
598 if p := recover(); p != nil {
599 trk.finish(nil, fmt.Errorf("panic: %v", p))
600 panic(p)
601 }
602 trk.finish(ctx.Err(), err)
603 }()
604 var p struct {
605 Prompt string `json:"prompt"`
606 Description string `json:"description"`
607 Tools []string `json:"tools"`
608 MaxSteps int `json:"max_steps"`
609 Model string `json:"model"`
610 Effort string `json:"effort"`
611 }
612 if err := json.Unmarshal(args, &p); err != nil {
613 return "", fmt.Errorf("invalid args: %w", err)
614 }
615 if strings.TrimSpace(p.Prompt) == "" {
616 return "", fmt.Errorf("prompt is required")
617 }
618
619 // Ordinary read_only_task keeps the concise default system prompt and does
620 // not accept profile/write_paths (use fleet with read_only for those).
621 releaseSlot, err := r.task.acquireSlot(ctx, AcquireRequest{
622 Writer: false,
623 Nested: SubagentDepth(ctx) > 0,
624 Label: firstNonEmpty(p.Description, "read_only_task"),
625 })
626 if err != nil {
627 return "", err
628 }
629 defer releaseSlot()
630
631 maxSteps := r.task.childMaxSteps(p.MaxSteps)
632
633 childDepth, err := r.task.nextSubagentDepth(ctx)
634 if err != nil {
635 return "", err
636 }
637 subReg := ReadOnlySubagentToolRegistryForDepthWithRuntime(r.task.parentReg, p.Tools, childDepth, r.task.maxDepth(), r.task.capabilityRuntime)
638 if subReg.Len() == 0 {
639 return "", fmt.Errorf("read_only_task has no read-only tools available")
640 }
641 modelRef, effortRef := r.task.effectiveProfile(p.Model, p.Effort)
642 usageModelRef := r.task.usageModelRef(modelRef, effortRef)
643 prov, pricing, ctxWin, err := r.task.resolveSubSessionRuntime(modelRef, effortRef)
644 if err != nil {
645 return "", fmt.Errorf("read-only sub-agent profile: %w", err)
646 }
647 recoveryTaskID := subagentRecoveryTaskID(ctx, "")
648 var mutationObserver *checkpoint.MutationObserver
649 if r.task.mutationObserver != nil {
650 mutationObserver = r.task.mutationObserver.CloneForSubagent(recoveryTaskID, r.task.mutationObserver.OwnershipTurn(), false)
651 }
652 answer, err := r.task.runReadOnlySubSession(ctx, p.Prompt, subReg, trk.wrap(), maxSteps, prov, pricing, ctxWin, NewSession(DefaultReadOnlyTaskSystemPrompt), childDepth, recoveryTaskID, usageModelRef, mutationObserver)
653 if err != nil {
654 return "", err
655 }
656 return GuardSubagentHostDecisionText(answer), nil
657 }
658
659 // childMaxSteps resolves a sub-agent's step budget. An explicit request wins.
660 // Otherwise mirror the parent: a finite parent caps the child at half its
661 // budget (min 5) so a delegated sub-task stays shorter than the whole turn; an
662 // unbounded parent yields an unbounded child (it shares the parent's ctx, so
663 // cancelling the turn stops it, and it compacts its own context — the same
664 // bounds the parent has). Shared by task, read_only_task, and parallel_tasks
665 // children so the default cannot drift per call site.
666 func (t *TaskTool) childMaxSteps(requested int) int {
667 if requested > 0 {
668 return requested
669 }
670 if t.maxSteps <= 0 {
671 return 0
672 }
673 half := t.maxSteps / 2
674 if half < 5 {
675 half = 5
676 }
677 return half
678 }
679
680 func (t *TaskTool) effectiveProfile(model, effort string) (string, string) {
681 model = strings.TrimSpace(model)
682 effort = strings.TrimSpace(effort)
683 if model == "" {
684 model = strings.TrimSpace(t.subagentModel)
685 }
686 if effort == "" {
687 effort = strings.TrimSpace(t.subagentEffort)
688 }
689 return model, effort
690 }
691
692 func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
693 var p struct {
694 Prompt string `json:"prompt"`
695 Description string `json:"description"`
696 Profile string `json:"profile"`
697 WritePaths []string `json:"write_paths"`
698 Tools []string `json:"tools"`
699 MaxSteps int `json:"max_steps"`
700 RunInBackground bool `json:"run_in_background"`
701 Model string `json:"model"`
702 Effort string `json:"effort"`
703 ContinueFrom string `json:"continue_from"`
704 ForkFrom string `json:"fork_from"`
705 }
706 if err := json.Unmarshal(args, &p); err != nil {
707 return "", fmt.Errorf("invalid args: %w", err)
708 }
709 if strings.TrimSpace(p.Prompt) == "" {
710 return "", fmt.Errorf("prompt is required")
711 }
712
713 spec, err := t.buildTaskSpec(ctx, p.Prompt, p.Description, p.Profile, p.WritePaths, p.Tools, p.MaxSteps, p.Model, p.Effort, p.ContinueFrom, p.ForkFrom, p.RunInBackground, false)
714 if err != nil {
715 return "", err
716 }
717 return t.RunProfileSpec(ctx, spec)
718 }
719
720 // buildTaskSpec resolves profile, tools, model/effort, and write claims for a
721 // single task/fleet item. forceReadOnly forces the read-only registry.
722 func (t *TaskTool) buildTaskSpec(ctx context.Context, prompt, description, profile string, writePaths, tools []string, maxSteps int, model, effort, continueFrom, forkFrom string, background, forceReadOnly bool) (ProfileExecSpec, error) {
723 spec := ProfileExecSpec{
724 Kind: "task",
725 Name: "task",
726 Prompt: prompt,
727 Description: description,
728 CallTools: tools,
729 MaxSteps: maxSteps,
730 ContinueFrom: strings.TrimSpace(continueFrom),
731 ForkFrom: strings.TrimSpace(forkFrom),
732 RunInBackground: background,
733 Nested: SubagentDepth(ctx) > 0,
734 SystemPrompt: t.sysPrompt,
735 }
736 profile = strings.TrimSpace(profile)
737 readOnly := forceReadOnly
738 var profileTools []string
739 var profileModel, profileEffort string
740 if profile != "" {
741 def, err := ResolveProfileDefinition(t.profileLookup, profile)
742 if err != nil {
743 return ProfileExecSpec{}, err
744 }
745 spec.Profile = def.Name
746 spec.Name = def.Name
747 spec.Kind = "skill"
748 spec.SystemPrompt = def.Body
749 spec.UseProfilePrompt = true
750 profileTools = def.AllowedTools
751 profileModel, profileEffort = def.Model, def.Effort
752 if def.ReadOnly {
753 readOnly = true
754 }
755 }
756 spec.ReadOnly = readOnly
757 spec.ProfileTools = profileTools
758
759 configModel, configEffort := "", ""
760 if profile != "" {
761 if t.profileConfigModel != nil {
762 configModel = t.profileConfigModel(profile)
763 }
764 if t.profileConfigEffort != nil {
765 configEffort = t.profileConfigEffort(profile)
766 }
767 }
768 spec.Model, spec.Effort = ResolveModelEffort(
769 configModel, configEffort,
770 model, effort,
771 profileModel, profileEffort,
772 t.subagentModel, t.subagentEffort,
773 )
774
775 if !readOnly {
776 // Every writer carries a claim. Omitting write_paths conservatively claims
777 // the whole workspace, including foreground task calls, so they cannot
778 // bypass an already-running background/fleet writer claim. Direct legacy
779 // TaskTool constructions without a workspace/scheduler keep their old
780 // no-claim behavior; production boot always configures both.
781 requireClaim := t.scheduler != nil || strings.TrimSpace(t.workspaceRoot) != "" || background || len(writePaths) > 0
782 claims, err := t.resolveWriterClaims(writePaths, requireClaim)
783 if err != nil {
784 return ProfileExecSpec{}, err
785 }
786 spec.WritePaths = claims
787 if requireClaim && claims.Empty() {
788 return ProfileExecSpec{}, fmt.Errorf("writer claim resolved empty")
789 }
790 } else if len(writePaths) > 0 {
791 return ProfileExecSpec{}, fmt.Errorf("write_paths is not valid for read-only tasks")
792 }
793 return spec, nil
794 }
795
796 func (t *TaskTool) resolveWriterClaims(writePaths []string, requireClaim bool) (WritePathSet, error) {
797 if len(writePaths) > 0 {
798 return NormalizeWritePaths(t.workspaceRoot, writePaths)
799 }
800 if !requireClaim {
801 return WritePathSet{}, nil
802 }
803 return WholeWorkspaceWriteClaim(t.workspaceRoot)
804 }
805
806 // RunProfileSpec executes a unified profile/task specification. Shared by task,
807 // fleet items, and boot-wired skill runners so prompt, tools, claims, and
808 // scheduling cannot drift across entry points.
809 func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (result string, err error) {
810 if t == nil {
811 return "", fmt.Errorf("task tool is not configured")
812 }
813 // Per-child progress tracker: converts the child's reasoning/text/notice/
814 // retrying into reserved ToolProgress previews and guarantees exactly one
815 // terminal status (completed/cancelled/failed). The background job owns
816 // finish after handoff; every other exit finishes here, including
817 // validation errors and panics.
818 trk := newSubagentProgressTracker(ctx, subSink(ctx))
819 backgroundHandoff := false
820 defer func() {
821 if backgroundHandoff {
822 return
823 }
824 if p := recover(); p != nil {
825 trk.finish(nil, fmt.Errorf("panic: %v", p))
826 panic(p)
827 }
828 trk.finish(ctx.Err(), err)
829 }()
830 if !spec.RunInBackground {
831 trk.running()
832 }
833 if strings.TrimSpace(spec.Prompt) == "" {
834 return "", fmt.Errorf("prompt is required")
835 }
836 if strings.TrimSpace(spec.SystemPrompt) == "" {
837 if spec.UseProfilePrompt {
838 return "", fmt.Errorf("profile system prompt is empty")
839 }
840 spec.SystemPrompt = t.sysPrompt
841 }
842
843 maxSteps := t.childMaxSteps(spec.MaxSteps)
844 childDepth, err := t.nextSubagentDepth(ctx)
845 if err != nil {
846 return "", err
847 }
848
849 toolNames, err := IntersectToolLists(t.parentReg, spec.ProfileTools, spec.CallTools)
850 if err != nil {
851 return "", err
852 }
853 var subReg *tool.Registry
854 if spec.ReadOnly {
855 subReg = ReadOnlySubagentToolRegistryForDepthWithRuntime(t.parentReg, toolNames, childDepth, t.maxDepth(), t.capabilityRuntime)
856 if subReg.Len() == 0 && !spec.AllowNoTools {
857 return "", fmt.Errorf("no read-only tools available for this sub-agent")
858 }
859 } else {
860 subReg = t.buildSubReg(toolNames, childDepth)
861 // Explicit paths are an execution boundary and rebind/drop tools that
862 // cannot honor it. A synthesized whole-workspace claim is a scheduling
863 // boundary for omitted write_paths; it preserves the legacy registry and
864 // the parent session's existing sandbox/permission boundaries.
865 if !spec.WritePaths.Empty() && !spec.WritePaths.WholeWorkspace {
866 keepBash := t.bashCanEnforceWriteRoots()
867 bound, removed := BindWritePaths(subReg, spec.WritePaths, t.workspaceRoot, keepBash)
868 subReg = bound
869 if len(removed) > 0 && subReg.Len() == 0 {
870 return "", fmt.Errorf("no path-bound write tools available after dropping unbound writers: %s", strings.Join(removed, ", "))
871 }
872 }
873 }
874
875 modelRef, effortRef := spec.Model, spec.Effort
876 usageModelRef := t.usageModelRef(modelRef, effortRef)
877 parentID, _, _, _ := CallContext(ctx)
878 run, err := t.prepareTranscriptRunWithPrompt(subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
879 if err != nil {
880 return "", err
881 }
882 prov, pricing, ctxWin, err := t.resolveSubSessionRuntime(modelRef, effortRef)
883 if err != nil {
884 run.Release()
885 return "", fmt.Errorf("sub-agent profile: %w", err)
886 }
887
888 isWriter := !spec.ReadOnly
889 acquireReq := AcquireRequest{
890 Writer: isWriter,
891 WritePaths: spec.WritePaths,
892 Nested: spec.Nested,
893 Label: firstNonEmpty(spec.Description, spec.Name, "task"),
894 }
895 // Defensive fallback for callers that manually construct a background spec
896 // instead of going through buildTaskSpec.
897 if isWriter && spec.WritePaths.Empty() && spec.RunInBackground {
898 whole, werr := WholeWorkspaceWriteClaim(t.workspaceRoot)
899 if werr != nil {
900 run.Release()
901 return "", werr
902 }
903 acquireReq.WritePaths = whole
904 spec.WritePaths = whole
905 }
906
907 recoveryTaskID := subagentRecoveryTaskID(ctx, run.Ref)
908 backgroundWriter := (spec.RunInBackground || spec.BackgroundWriter) && !spec.ReadOnly
909 var mutationObserver *checkpoint.MutationObserver
910 if t.mutationObserver != nil {
911 turn := t.mutationObserver.OwnershipTurn()
912 mutationObserver = t.mutationObserver.CloneForSubagent(recoveryTaskID, turn, backgroundWriter)
913 }
914 runSession := func(runCtx context.Context, sink event.Sink, writerAlreadyRegistered bool) (string, error) {
915 if mutationObserver != nil && backgroundWriter && !writerAlreadyRegistered {
916 turn := mutationObserver.OwnershipTurn()
917 if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
918 return "", err
919 }
920 defer mutationObserver.UnregisterWriter(recoveryTaskID)
921 }
922 if spec.ReadOnly {
923 return t.runReadOnlySubSession(runCtx, spec.Prompt, subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver)
924 }
925 return t.runSubSession(runCtx, spec.Prompt, subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver)
926 }
927
928 if spec.RunInBackground {
929 jm, ok := jobs.FromContext(ctx)
930 if !ok {
931 run.Release()
932 return "", fmt.Errorf("background execution is not available in this context")
933 }
934 // Legacy hard-cap remains only when no scheduler is attached. With a
935 // scheduler, return the job immediately and queue for a slot inside the
936 // job so the parent turn is not blocked at concurrency limits.
937 var releaseStart func()
938 if t.scheduler == nil {
939 var running int
940 var okReserve bool
941 releaseStart, running, okReserve = jm.ReserveStartForSession(jobs.SessionFromContext(ctx), "task", maxConcurrentBackgroundTasks)
942 if !okReserve {
943 run.Release()
944 return "", fmt.Errorf("%d background tasks are already running for this session (limit %d); collect their results with wait — or run this sub-task in the foreground — before starting more", running, maxConcurrentBackgroundTasks)
945 }
946 defer releaseStart()
947 } else {
948 releaseStart = func() {}
949 }
950 label := firstNonEmpty(spec.Description, spec.Name, "task")
951 if t.transcripts != nil && run != nil && run.Ref != "" {
952 if err := t.transcripts.MarkRunning(run); err != nil {
953 releaseStart()
954 run.Release()
955 return "", err
956 }
957 }
958 writerRegistered := false
959 if mutationObserver != nil && backgroundWriter {
960 turn := mutationObserver.OwnershipTurn()
961 if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
962 releaseStart()
963 run.Release()
964 return "", errors.Join(err, t.transcripts.SaveFailed(run))
965 }
966 writerRegistered = true
967 }
968 parentSession := ParentSession(ctx)
969 backgroundEvidence := evidence.NewLedger()
970 // Capture acquire request by value for the job goroutine.
971 slotReq := acquireReq
972 // Emit queued before the job goroutine can start so the status slot
973 // never regresses to a stale queued after running.
974 trk.queued()
975 job := jm.StartForSession(jobs.SessionFromContext(ctx), "task", label, func(jobCtx context.Context, _ io.Writer) (result string, err error) {
976 if writerRegistered {
977 defer mutationObserver.UnregisterWriter(recoveryTaskID)
978 }
979 jobCtx = WithParentSession(jobCtx, parentSession)
980 jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence)
981 defer run.Release()
982 defer func() { jobs.PublishEvidence(jobCtx, backgroundEvidence.Summary()) }()
983 defer func() {
984 if r := recover(); r != nil {
985 panicErr := fmt.Errorf("internal error: panic: %v\n%s", r, debug.Stack())
986 result = FormatSubagentRunResult("", run, true)
987 err = errors.Join(panicErr, t.transcripts.SaveFailed(run))
988 }
989 // The job owns the terminal status: the parent tool call has
990 // already returned its job id by now.
991 trk.finish(jobCtx.Err(), err)
992 }()
993 // Queue for a concurrency/write slot here — not before Start —
994 // so the parent tool call returns a job id immediately.
995 releaseSlot, slotErr := t.acquireSlot(jobCtx, slotReq)
996 if slotErr != nil {
997 return FormatSubagentRunResult("", run, true), errors.Join(slotErr, t.transcripts.SaveFailed(run))
998 }
999 defer releaseSlot()
1000 trk.running()
1001 answer, err := runSession(jobCtx, trk.wrap(), writerRegistered)
1002 if err != nil {
1003 return FormatSubagentRunResult("", run, true), errors.Join(err, t.transcripts.SaveFailed(run))
1004 }
1005 if err := t.transcripts.SaveCompleted(run); err != nil {
1006 return FormatSubagentRunResult("", run, true), errors.Join(err, t.transcripts.SaveFailed(run))
1007 }
1008 return FormatSubagentRunResult(answer, run, false), nil
1009 })
1010 releaseStart()
1011 // Hand the tracker to the job goroutine: the outer defer must not
1012 // finish (and close) it while the job still runs.
1013 backgroundHandoff = true
1014 queuedNote := ""
1015 if t.scheduler != nil {
1016 queuedNote = " It may wait in the session queue until a concurrency/write slot is free."
1017 }
1018 if run != nil && run.Ref != "" {
1019 return fmt.Sprintf("Started background task %q (%s).%s\n%s\nIt runs across turns; collect its final answer with wait (or wait will return it once done), and you'll be notified when it finishes.", job.ID, label, queuedNote, FormatSubagentReference(run)), nil
1020 }
1021 return fmt.Sprintf("Started background task %q (%s).%s It runs across turns; collect its final answer with wait (or wait will return it once done), and you'll be notified when it finishes.", job.ID, label, queuedNote), nil
1022 }
1023
1024 // Foreground: acquire a slot (queue if needed), then run synchronously.
1025 releaseSlot, err := t.acquireSlot(ctx, acquireReq)
1026 if err != nil {
1027 run.Release()
1028 return "", err
1029 }
1030 defer releaseSlot()
1031 defer run.Release()
1032 answer, err := runSession(ctx, trk.wrap(), false)
1033 if err != nil {
1034 return "", errors.Join(err, t.transcripts.SaveFailed(run))
1035 }
1036 if t.transcripts != nil && run.Ref != "" {
1037 if err := t.transcripts.SaveCompleted(run); err != nil {
1038 return "", errors.Join(err, t.transcripts.SaveFailed(run))
1039 }
1040 return FormatSubagentRunResult(answer, run, false), nil
1041 }
1042 return GuardSubagentHostDecisionText(answer), nil
1043 }
1044
1045 func (t *TaskTool) acquireSlot(ctx context.Context, req AcquireRequest) (func(), error) {
1046 noop := func() {}
1047 if t.scheduler == nil {
1048 return noop, nil
1049 }
1050 return t.scheduler.Acquire(ctx, req)
1051 }
1052
1053 func (t *TaskTool) bashCanEnforceWriteRoots() bool {
1054 if t != nil && t.bashSandboxEnforced != nil {
1055 return t.bashSandboxEnforced()
1056 }
1057 return false
1058 }
1059
1060 func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
1061 continueFrom = strings.TrimSpace(continueFrom)
1062 legacyForkFrom = strings.TrimSpace(legacyForkFrom)
1063 parentSession = strings.TrimSpace(parentSession)
1064 if continueFrom != "" && legacyForkFrom != "" {
1065 return nil, fmt.Errorf("continue_from and fork_from are mutually exclusive; pass only continue_from")
1066 }
1067 if t.transcripts == nil {
1068 return nil, fmt.Errorf("subagent transcript store is required")
1069 }
1070 if systemPrompt == "" {
1071 systemPrompt = t.sysPrompt
1072 }
1073 if kind == "" {
1074 kind = "task"
1075 }
1076 if name == "" {
1077 name = "task"
1078 }
1079 if parentSession == "" {
1080 if continueFrom != "" || legacyForkFrom != "" {
1081 return nil, fmt.Errorf("subagent continuation requires a persisted session; none is active in this run")
1082 }
1083 return EphemeralSubagentRun(systemPrompt), nil
1084 }
1085 identityModel, identityEffort := t.effectiveIdentity(modelRef, effortRef)
1086 spec := SubagentSpec{
1087 Kind: kind,
1088 Name: name,
1089 WorkspaceRoot: t.workspaceRoot,
1090 ParentSession: parentSession,
1091 ParentToolCallID: parentID,
1092 SystemPrompt: systemPrompt,
1093 Registry: subReg,
1094 Model: identityModel,
1095 Effort: identityEffort,
1096 }
1097 if continueFrom != "" {
1098 return t.transcripts.PrepareContinue(continueFrom, spec)
1099 }
1100 if legacyForkFrom != "" {
1101 return t.transcripts.PrepareLegacyForkFrom(legacyForkFrom, spec)
1102 }
1103 return t.transcripts.PrepareFresh(spec)
1104 }
1105
1106 func (t *TaskTool) effectiveIdentity(modelRef, effort string) (string, string) {
1107 if t.identityProfile != nil {
1108 model, eff := t.identityProfile(modelRef, effort)
1109 return strings.TrimSpace(model), strings.TrimSpace(eff)
1110 }
1111 return t.effectiveModelIdentity(modelRef), t.effectiveEffortIdentity(effort)
1112 }
1113
1114 // usageModelRef returns the canonical provider/model identity of the runtime
1115 // selected for a child. The resolver expands aliases and supplies the parent
1116 // model when no child override is configured.
1117 func (t *TaskTool) usageModelRef(modelRef, effort string) string {
1118 model, _ := t.effectiveIdentity(modelRef, effort)
1119 if model != "" {
1120 return model
1121 }
1122 return firstNonEmpty(modelRef, t.baseModel, t.subagentModel)
1123 }
1124
1125 func (t *TaskTool) effectiveModelIdentity(modelRef string) string {
1126 if strings.TrimSpace(modelRef) != "" {
1127 return strings.TrimSpace(modelRef)
1128 }
1129 return strings.TrimSpace(t.baseModel)
1130 }
1131
1132 func (t *TaskTool) effectiveEffortIdentity(effort string) string {
1133 if strings.TrimSpace(effort) != "" {
1134 return strings.TrimSpace(effort)
1135 }
1136 return strings.TrimSpace(t.baseEffort)
1137 }
1138
1139 // buildSubReg returns the sub-agent's tool set: the named whitelist (minus
1140 // unavailable sub-agent tools), or every parent tool except those tools.
1141 func (t *TaskTool) buildSubReg(names []string, childDepth int) *tool.Registry {
1142 return SubagentToolRegistryForDepthWithRuntime(t.parentReg, names, childDepth, t.maxDepth(), t.capabilityRuntime)
1143 }
1144
1145 func (t *TaskTool) maxDepth() int {
1146 if t == nil {
1147 return DefaultMaxSubagentDepth
1148 }
1149 if t.maxSubagentDepth == 0 {
1150 return DefaultMaxSubagentDepth
1151 }
1152 return NormalizeMaxSubagentDepth(t.maxSubagentDepth)
1153 }
1154
1155 func (t *TaskTool) nextSubagentDepth(ctx context.Context) (int, error) {
1156 current := SubagentDepth(ctx)
1157 next := current + 1
1158 maxDepth := t.maxDepth()
1159 if next > maxDepth {
1160 return 0, fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxDepth)
1161 }
1162 return next, nil
1163 }
1164
1165 // FilterRegistry builds a sub-registry from parent: the named whitelist (empty =
1166 // every parent tool), minus any excluded names. Used to scope what a spawned
1167 // sub-agent — a `task` sub-agent or a subagent skill — may call, e.g. excluding
1168 // `task` to bar recursive nesting, or restricting to a skill's allowed-tools.
1169 // Direct MCP tools may be copied here; callers that need a stable MCP surface
1170 // should strip them and attach use_capability via attachSubagentCapabilityProxy.
1171 func FilterRegistry(parent *tool.Registry, names []string, exclude ...string) *tool.Registry {
1172 sub := tool.NewRegistry()
1173 if parent == nil {
1174 return sub
1175 }
1176 ex := make(map[string]bool, len(exclude))
1177 for _, e := range exclude {
1178 ex[e] = true
1179 }
1180 customAllowlist := len(names) > 0
1181 src := names
1182 if !customAllowlist {
1183 src = parent.Names()
1184 } else {
1185 src = expandToolPatterns(parent, src)
1186 }
1187 for _, name := range src {
1188 if ex[name] {
1189 continue
1190 }
1191 // MCP never enters through the generic filter when named as capability
1192 // ids; model-visible mcp__* may still be listed for conversion later.
1193 if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
1194 continue
1195 }
1196 tl, ok := parent.Get(name)
1197 if !ok {
1198 continue
1199 }
1200 sub.Add(tl)
1201 }
1202 return sub
1203 }
1204
1205 // stripDirectMCPTools removes provider-visible mcp__* tools so sub-agents use
1206 // only the stable use_capability proxy for MCP.
1207 func stripDirectMCPTools(reg *tool.Registry) {
1208 if reg == nil {
1209 return
1210 }
1211 for _, name := range append([]string(nil), reg.Names()...) {
1212 if strings.HasPrefix(name, tool.MCPNamePrefix) {
1213 reg.RemovePrefix(name)
1214 }
1215 }
1216 }
1217
1218 // restrictedCapabilityProxy preserves a subagent allowed-tools boundary when
1219 // MCP is available only through use_capability. The pseudo mcp-tool: and
1220 // mcp-server: entries never become provider tools; they select one proxy schema
1221 // whose resolver rejects every capability outside the exact allowlist.
1222 //
1223 // Provider-visible name/description/schema stay identical to the unrestricted
1224 // proxy so allowlist expansion never changes the child cache prefix. Allowlist
1225 // enforcement is host-local (check + filtered list results).
1226 type restrictedCapabilityProxy struct {
1227 tool.Tool
1228 resolver tool.CallResolver
1229 allowed map[string]bool
1230 // servers is the set of MCP server names implied by allowed IDs; list
1231 // results are filtered to this set so profile isolation covers discovery.
1232 servers map[string]bool
1233 }
1234
1235 // Description is fixed: never embed dynamic capability IDs (they change with
1236 // MCP install/tool-list and would break the stable provider tool prefix).
1237 func (t *restrictedCapabilityProxy) Description() string {
1238 return t.Tool.Description()
1239 }
1240
1241 func (t *restrictedCapabilityProxy) check(args json.RawMessage) error {
1242 var p struct {
1243 Action string `json:"action"`
1244 CapabilityID string `json:"capability_id"`
1245 }
1246 if err := json.Unmarshal(args, &p); err != nil {
1247 return fmt.Errorf("invalid args: %w", err)
1248 }
1249 if strings.EqualFold(strings.TrimSpace(p.Action), "list") {
1250 return nil
1251 }
1252 id := strings.TrimSpace(p.CapabilityID)
1253 if id == "" {
1254 return fmt.Errorf("capability_id is required")
1255 }
1256 if !t.allowed[id] {
1257 return fmt.Errorf("capability %q is outside this subagent's allowed-tools", id)
1258 }
1259 return nil
1260 }
1261
1262 func (t *restrictedCapabilityProxy) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
1263 if err := t.check(args); err != nil {
1264 return tool.ResolvedCall{}, err
1265 }
1266 rc, err := t.resolver.ResolveCall(ctx, args)
1267 if err != nil {
1268 return rc, err
1269 }
1270 var p struct {
1271 Action string `json:"action"`
1272 }
1273 _ = json.Unmarshal(args, &p)
1274 if strings.EqualFold(strings.TrimSpace(p.Action), "list") && rc.SkipExecute {
1275 rc.Result = filterCapabilityListResult(rc.Result, t.servers)
1276 }
1277 return rc, nil
1278 }
1279
1280 func (t *restrictedCapabilityProxy) Execute(ctx context.Context, args json.RawMessage) (string, error) {
1281 if err := t.check(args); err != nil {
1282 return "", err
1283 }
1284 out, err := t.Tool.Execute(ctx, args)
1285 if err != nil {
1286 return out, err
1287 }
1288 var p struct {
1289 Action string `json:"action"`
1290 }
1291 _ = json.Unmarshal(args, &p)
1292 if strings.EqualFold(strings.TrimSpace(p.Action), "list") {
1293 return filterCapabilityListResult(out, t.servers), nil
1294 }
1295 return out, nil
1296 }
1297
1298 // emptyCapabilityListResult is the fail-closed list payload: no server metadata.
1299 func emptyCapabilityListResult(note string) string {
1300 if strings.TrimSpace(note) == "" {
1301 note = "list is filtered to this subagent's allowed MCP servers."
1302 }
1303 b, err := json.MarshalIndent(map[string]any{
1304 "servers": []listServerInfo{},
1305 "note": note,
1306 }, "", " ")
1307 if err != nil {
1308 return `{"servers":[],"note":"list is filtered to this subagent's allowed MCP servers."}`
1309 }
1310 return string(b)
1311 }
1312
1313 // filterCapabilityListResult keeps only servers in the allowlist for restricted
1314 // proxies. Empty allowlist or unreadable payloads fail closed (empty server
1315 // list) so discovery never leaks the full configured MCP inventory.
1316 func filterCapabilityListResult(raw string, servers map[string]bool) string {
1317 const baseNote = "list is filtered to this subagent's allowed MCP servers."
1318 if len(servers) == 0 {
1319 return emptyCapabilityListResult(baseNote + " No allowed MCP servers were resolved from the profile allowlist.")
1320 }
1321 var payload struct {
1322 Servers []listServerInfo `json:"servers"`
1323 Note string `json:"note"`
1324 }
1325 if err := json.Unmarshal([]byte(raw), &payload); err != nil {
1326 return emptyCapabilityListResult(baseNote + " List payload was unreadable; returning no servers (fail-closed).")
1327 }
1328 filtered := make([]listServerInfo, 0, len(payload.Servers))
1329 for _, s := range payload.Servers {
1330 if servers[strings.TrimSpace(s.Name)] {
1331 filtered = append(filtered, s)
1332 }
1333 }
1334 payload.Servers = filtered
1335 if payload.Note == "" {
1336 payload.Note = baseNote
1337 } else if !strings.Contains(payload.Note, "Filtered to this subagent") {
1338 payload.Note = payload.Note + " Filtered to this subagent's allowed MCP servers."
1339 }
1340 b, err := json.MarshalIndent(payload, "", " ")
1341 if err != nil {
1342 return emptyCapabilityListResult(baseNote + " Failed to encode filtered list (fail-closed).")
1343 }
1344 return string(b)
1345 }
1346
1347 // validMCPServerCapabilityID accepts mcp-server:<non-empty-name> only.
1348 func validMCPServerCapabilityID(id string) (server string, ok bool) {
1349 if !strings.HasPrefix(id, "mcp-server:") {
1350 return "", false
1351 }
1352 server = strings.TrimSpace(strings.TrimPrefix(id, "mcp-server:"))
1353 // Reject empty and path-like fragments that are not bare server names.
1354 return server, server != "" && !strings.Contains(server, "/")
1355 }
1356
1357 // validMCPToolCapabilityID accepts mcp-tool:<server>/<tool> with both parts non-empty.
1358 func validMCPToolCapabilityID(id string) (server, raw string, ok bool) {
1359 if !strings.HasPrefix(id, "mcp-tool:") {
1360 return "", "", false
1361 }
1362 rest := strings.TrimPrefix(id, "mcp-tool:")
1363 server, raw, cut := strings.Cut(rest, "/")
1364 server = strings.TrimSpace(server)
1365 raw = strings.TrimSpace(raw)
1366 return server, raw, cut && server != "" && raw != ""
1367 }
1368
1369 func serversFromCapabilityAllowlist(allowed map[string]bool) map[string]bool {
1370 servers := map[string]bool{}
1371 for id := range allowed {
1372 id = strings.TrimSpace(id)
1373 if server, ok := validMCPServerCapabilityID(id); ok {
1374 servers[server] = true
1375 continue
1376 }
1377 if server, _, ok := validMCPToolCapabilityID(id); ok {
1378 servers[server] = true
1379 }
1380 }
1381 return servers
1382 }
1383
1384 // attachSubagentCapabilityProxy installs a per-agent use_capability frontend.
1385 // Any parent-copied proxy is replaced so children never share Executor ledger
1386 // state. No allowlist → full proxy. Explicit allowlist with MCP names →
1387 // restricted proxy. Explicit "use_capability" → full proxy. Explicit allowlist
1388 // without MCP entries → no proxy.
1389 func attachSubagentCapabilityProxy(parent, sub *tool.Registry, names []string, runtime *MCPCapabilityRuntime) {
1390 if sub == nil {
1391 return
1392 }
1393 // Drop any provider-copied use_capability so we always install an isolated
1394 // frontend (shared Host/runtime, independent ledger/audit).
1395 if _, ok := sub.Get("use_capability"); ok {
1396 sub.RemovePrefix("use_capability")
1397 }
1398 frontend := newSubagentCapabilityFrontend(parent, runtime)
1399 if frontend == nil {
1400 return
1401 }
1402 if len(names) == 0 || allowlistRequestsUnrestrictedProxy(names) {
1403 sub.Add(frontend)
1404 return
1405 }
1406 allowed := mcpCapabilityAllowlist(parent, names)
1407 if len(allowed) == 0 {
1408 // Custom allowlist with no valid MCP entries: do not expose the proxy.
1409 return
1410 }
1411 servers := serversFromCapabilityAllowlist(allowed)
1412 if len(servers) == 0 {
1413 // Incomplete capability IDs produced an empty server set: fail closed
1414 // rather than installing a restricted proxy that would list everything.
1415 return
1416 }
1417 resolver, ok := frontend.(tool.CallResolver)
1418 if !ok {
1419 return
1420 }
1421 sub.Add(&restrictedCapabilityProxy{
1422 Tool: frontend,
1423 resolver: resolver,
1424 allowed: allowed,
1425 servers: servers,
1426 })
1427 }
1428
1429 func newSubagentCapabilityFrontend(parent *tool.Registry, runtime *MCPCapabilityRuntime) tool.Tool {
1430 if runtime != nil {
1431 return runtime.NewFrontend(nil, nil)
1432 }
1433 if parent == nil {
1434 return nil
1435 }
1436 inner, ok := parent.Get("use_capability")
1437 if !ok {
1438 return nil
1439 }
1440 if uc, ok := inner.(*UseCapabilityTool); ok {
1441 return uc.CloneForAgent(nil, nil)
1442 }
1443 return inner
1444 }
1445
1446 // mcpCapabilityAllowlist converts profile/call tool names into capability IDs
1447 // for the restricted use_capability proxy. Accepts complete mcp-tool:<s>/<t>,
1448 // mcp-server:<s>, model-visible mcp__* names, and wildcards expanded against
1449 // the parent. Incomplete prefixes such as "mcp-server:" or "mcp-tool:foo" are
1450 // rejected so they cannot install a restricted proxy with an empty server set.
1451 func mcpCapabilityAllowlist(parent *tool.Registry, names []string) map[string]bool {
1452 if len(names) == 0 {
1453 return nil
1454 }
1455 expanded := names
1456 if parent != nil {
1457 expanded = expandToolPatterns(parent, names)
1458 }
1459 allowed := map[string]bool{}
1460 for _, name := range expanded {
1461 name = strings.TrimSpace(name)
1462 switch {
1463 case name == "use_capability":
1464 // Explicit proxy grant is handled as a full frontend by the caller
1465 // when this is the only MCP-related entry; leave empty here so a
1466 // bare use_capability allowlist entry still installs unrestricted.
1467 continue
1468 case strings.HasPrefix(name, "mcp-server:"):
1469 if server, ok := validMCPServerCapabilityID(name); ok {
1470 allowed["mcp-server:"+server] = true
1471 }
1472 case strings.HasPrefix(name, "mcp-tool:"):
1473 if server, raw, ok := validMCPToolCapabilityID(name); ok {
1474 allowed["mcp-tool:"+server+"/"+raw] = true
1475 }
1476 default:
1477 if parent != nil {
1478 if tl, ok := parent.Get(name); ok {
1479 if m, ok := tl.(tool.MCPMetadata); ok {
1480 server := strings.TrimSpace(m.MCPServerName())
1481 raw := strings.TrimSpace(m.MCPRawToolName())
1482 if server != "" && raw != "" {
1483 allowed["mcp-tool:"+server+"/"+raw] = true
1484 continue
1485 }
1486 }
1487 }
1488 }
1489 if server, raw, ok := tool.SplitMCPName(name); ok {
1490 allowed["mcp-tool:"+server+"/"+raw] = true
1491 }
1492 }
1493 }
1494 return allowed
1495 }
1496
1497 func allowlistRequestsUnrestrictedProxy(names []string) bool {
1498 for _, name := range names {
1499 if strings.TrimSpace(name) == "use_capability" {
1500 return true
1501 }
1502 }
1503 return false
1504 }
1505
1506 var plannerNonResearchTools = []string{
1507 "ask",
1508 "bash_output",
1509 "complete_step",
1510 "slash_command",
1511 "todo_write",
1512 "wait",
1513 }
1514
1515 // PlannerToolRegistry returns the tool set exposed to the two-model planner:
1516 // built-in read-only research tools plus the stable use_capability proxy. Direct
1517 // mcp__* schemas are excluded so MCP connect/disconnect/tool-list churn never
1518 // changes the Planner provider-visible tool prefix. Workflow/meta tools that are
1519 // technically read-only but can prompt the user, update visible task state, wait
1520 // on jobs, or expand commands are also excluded.
1521 func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
1522 exclude := append(SubagentMetaTools(), plannerNonResearchTools...)
1523 base := FilterReadOnlyRegistry(parent, exclude...)
1524 sub := tool.NewRegistry()
1525 if base != nil {
1526 for _, name := range base.Names() {
1527 // Never copy the parent proxy or direct MCP: Delivery would share
1528 // Executor ledger/audit; MCP schemas are proxy-only for the planner.
1529 if name == "use_capability" || strings.HasPrefix(name, tool.MCPNamePrefix) {
1530 continue
1531 }
1532 if tl, ok := base.Get(name); ok {
1533 sub.Add(tl)
1534 }
1535 }
1536 }
1537 // Always install an isolated frontend (independent ledger/audit; shared Host).
1538 if parent != nil {
1539 if tl, ok := parent.Get("use_capability"); ok {
1540 if uc, ok := tl.(*UseCapabilityTool); ok {
1541 sub.Add(uc.CloneForAgent(nil, nil))
1542 } else {
1543 sub.Add(tl)
1544 }
1545 }
1546 }
1547 return sub
1548 }
1549
1550 // ReadOnlySubagentToolRegistry returns the tool set exposed to read-only
1551 // sub-agents: read-only research tools plus a bash wrapper that enforces the
1552 // permission-layer read-only command policy at execution time. Workflow/meta tools are
1553 // excluded even when their Tool.ReadOnly contract is true.
1554 func ReadOnlySubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
1555 return ReadOnlySubagentToolRegistryForDepth(parent, names, 1, 1)
1556 }
1557
1558 // ReadOnlySubagentToolRegistryForDepth returns the tool set exposed to read-only
1559 // subagents. It permits only read-only delegation tools while another depth
1560 // layer is available. Direct mcp__* schemas are never exposed; MCP goes only
1561 // through use_capability. Dynamic execution still requires authorized server +
1562 // readOnlyHint + non-destructive (enforced by ReadOnlyExecution), so strict
1563 // agents share the stable proxy schema and connection reuse without permission
1564 // relaxation.
1565 //
1566 // Custom profile/call allowlists remain authoritative and convert MCP names
1567 // into a capability-id allowlist on a restricted proxy.
1568 func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
1569 return ReadOnlySubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
1570 }
1571
1572 // ReadOnlySubagentToolRegistryForDepthWithRuntime is the read-only registry
1573 // builder with an optional session MCP runtime for proxy injection.
1574 func ReadOnlySubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
1575 exclude := append([]string(nil), subagentAlwaysHiddenTools...)
1576 if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
1577 exclude = append(exclude, subagentRecursiveTools...)
1578 } else {
1579 exclude = append(exclude, "task", "run_skill", "explore", "research", "review", "security_review")
1580 }
1581 exclude = append(exclude, subagentJobTools...)
1582 exclude = append(exclude, plannerNonResearchTools...)
1583 exclude = append(exclude, readOnlySubagentWorkflowTools...)
1584 ex := make(map[string]bool, len(exclude))
1585 for _, e := range exclude {
1586 ex[e] = true
1587 }
1588 sub := tool.NewRegistry()
1589 if parent == nil {
1590 return sub
1591 }
1592 src := names
1593 if len(src) == 0 {
1594 src = parent.Names()
1595 } else {
1596 src = expandToolPatterns(parent, src)
1597 }
1598 for _, name := range src {
1599 if ex[name] {
1600 continue
1601 }
1602 if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
1603 continue
1604 }
1605 tl, ok := parent.Get(name)
1606 if !ok {
1607 continue
1608 }
1609 if name == "bash" {
1610 sub.Add(readOnlyBash{inner: tl})
1611 continue
1612 }
1613 // Direct MCP never enters the strict registry — use_capability only.
1614 if isInstalledMCPTool(tl) || strings.HasPrefix(name, tool.MCPNamePrefix) {
1615 continue
1616 }
1617 if !tl.ReadOnly() {
1618 continue
1619 }
1620 sub.Add(tl)
1621 }
1622 attachSubagentCapabilityProxy(parent, sub, names, runtime)
1623 return sub
1624 }
1625
1626 // expandToolPatterns resolves explicit wildcard allowlist entries from imported
1627 // agent profiles against the current registry. Expansion is deterministic and
1628 // session-local, so optional MCP tools only enter a child after connection.
1629 func expandToolPatterns(parent *tool.Registry, names []string) []string {
1630 if parent == nil {
1631 return nil
1632 }
1633 available := parent.Names()
1634 seen := map[string]bool{}
1635 out := make([]string, 0, len(names))
1636 for _, name := range names {
1637 if !strings.ContainsAny(name, "*?[") {
1638 if !seen[name] {
1639 seen[name] = true
1640 out = append(out, name)
1641 }
1642 continue
1643 }
1644 for _, candidate := range available {
1645 matched, err := filepath.Match(name, candidate)
1646 if err == nil && matched && !seen[candidate] {
1647 seen[candidate] = true
1648 out = append(out, candidate)
1649 }
1650 }
1651 }
1652 return out
1653 }
1654
1655 // FilterReadOnlyRegistry builds a sub-registry containing only tools whose
1656 // ReadOnly contract is true, minus explicit exclusions. MCP tools must
1657 // additionally come from an authorized server and must not carry
1658 // destructiveHint.
1659 func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Registry {
1660 ex := make(map[string]bool, len(exclude))
1661 for _, e := range exclude {
1662 ex[e] = true
1663 }
1664 sub := tool.NewRegistry()
1665 if parent == nil {
1666 return sub
1667 }
1668 for _, name := range parent.Names() {
1669 if ex[name] {
1670 continue
1671 }
1672 tl, ok := parent.Get(name)
1673 if !ok || !tl.ReadOnly() {
1674 continue
1675 }
1676 if isInstalledMCPTool(tl) && (!mcpServerAuthorized(tl) || mcpDestructiveHint(tl)) {
1677 continue
1678 }
1679 sub.Add(tl)
1680 }
1681 return sub
1682 }
1683
1684 func (t *TaskTool) resolveSubSessionRuntime(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
1685 prov, pricing, ctxWin := t.prov, t.pricing, t.contextWindow
1686 if t.resolveProvider != nil && (modelRef != "" || effort != "") {
1687 p, pr, cw, err := t.resolveProvider(modelRef, effort)
1688 if err != nil {
1689 return nil, nil, 0, err
1690 }
1691 prov, pricing, ctxWin = p, pr, cw
1692 }
1693 return prov, pricing, ctxWin, nil
1694 }
1695
1696 func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver) (string, error) {
1697 opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
1698 opts.ModelRef = modelRef
1699 // Capture the pristine task before host framing is prepended: delivery
1700 // intent classification must judge the task, not the wrapper.
1701 opts.ClassifierTaskText = prompt
1702 prompt = t.withWorkspaceContext(prompt)
1703 return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
1704 }
1705
1706 func (t *TaskTool) runReadOnlySubSession(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session, childDepth int, recoveryTaskID, modelRef string, mutationObserver *checkpoint.MutationObserver) (string, error) {
1707 opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
1708 opts.ModelRef = modelRef
1709 // Capture the pristine task before host framing is prepended: delivery
1710 // intent classification must judge the task, not the wrapper.
1711 opts.ClassifierTaskText = prompt
1712 prompt = t.withWorkspaceContext(prompt)
1713 return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
1714 }
1715
1716 // subagentOptions is the single construction point for the run options every
1717 // sub-agent spawned through this tool shares (task, read_only_task, and
1718 // parallel_tasks children). Compaction, language preferences, and depth limits
1719 // must stay uniform across those paths — add new fields here, not at call sites.
1720 func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *provider.Pricing, ctxWin, childDepth int, recoveryTaskID string, mutationObserver *checkpoint.MutationObserver) Options {
1721 opts := Options{
1722 MaxSteps: maxSteps,
1723 Temperature: t.temperature,
1724 Pricing: pricing,
1725 UsageSource: event.UsageSourceSubagent,
1726 Gate: t.gate,
1727 ContextWindow: ctxWin,
1728 RecentKeep: t.recentKeep,
1729 SoftCompactRatio: t.softCompactRatio,
1730 ToolResultSnipRatio: t.toolResultSnipRatio,
1731 CompactRatio: t.compactRatio,
1732 CompactForceRatio: t.compactForceRatio,
1733 ArchiveDir: t.archiveDir,
1734 KeepPolicy: t.keepPolicy,
1735 ResponseLanguage: ResponseLanguageFromContext(ctx),
1736 ReasoningLanguage: ReasoningLanguageFromContext(ctx),
1737 SubagentDepth: childDepth,
1738 MaxSubagentDepth: t.maxDepth(),
1739 DeliveryProfile: t.deliveryProfile,
1740 Ablation: t.ablation,
1741 WorkspaceLease: t.workspaceLease,
1742 RecoveryGate: t.recoveryGate,
1743 RecoveryAgentID: "subagent",
1744 RecoveryTaskID: recoveryTaskID,
1745 MutationObserver: mutationObserver,
1746 }
1747 return opts
1748 }
1749
1750 func subagentRecoveryTaskID(ctx context.Context, ref string) string {
1751 if ref = strings.TrimSpace(ref); ref != "" {
1752 return "subagent:" + ref
1753 }
1754 if callID, _, _, ok := CallContext(ctx); ok && strings.TrimSpace(callID) != "" {
1755 return "subagent:" + strings.TrimSpace(callID)
1756 }
1757 return "subagent"
1758 }
1759
1760 // WithRecoveryGate shares Auto Guard with spawned sub-agents.
1761 func (t *TaskTool) WithRecoveryGate(g RecoveryGate) *TaskTool {
1762 if t == nil {
1763 return nil
1764 }
1765 t.recoveryGate = g
1766 return t
1767 }
1768
1769 // WithMutationObserver shares the host mutation observer with spawned sub-agents.
1770 // Foreground children inherit the parent ownership turn; background children
1771 // keep the turn that spawned them (set via OwnershipTurn at Begin).
1772 func (t *TaskTool) WithMutationObserver(obs *checkpoint.MutationObserver) *TaskTool {
1773 if t == nil {
1774 return nil
1775 }
1776 t.mutationObserver = obs
1777 return t
1778 }
1779
1780 func (t *TaskTool) withWorkspaceContext(prompt string) string {
1781 if t == nil {
1782 return prompt
1783 }
1784 ctx := subagentWorkspaceContext(t.workspaceRoot)
1785 if ctx == "" {
1786 return prompt
1787 }
1788 return ctx + "\n\n" + prompt
1789 }
1790
1791 func subagentWorkspaceContext(root string) string {
1792 root = strings.TrimSpace(root)
1793 if root == "" {
1794 return ""
1795 }
1796 // Wording note: avoid incidental action verbs ("resolve", "fix", …) in this
1797 // host framing — it is prepended to every sub-agent prompt and must never
1798 // read as task intent (see classifierTaskText, which also strips it).
1799 return `<workspace-context event="SubagentWorkspace">
1800 Current workspace: ` + strconv.Quote(root) + `
1801 File tools interpret relative paths against this workspace. For project inspection, prefer "." or relative paths unless the user explicitly named another absolute path.
1802 </workspace-context>`
1803 }
1804
1805 func FormatSubagentReference(run *SubagentRun) string {
1806 if run == nil || run.Ref == "" {
1807 return ""
1808 }
1809 var b strings.Builder
1810 fmt.Fprintf(&b, "Subagent reference: %s\n", run.Ref)
1811 if strings.TrimSpace(run.ForkedFrom) != "" {
1812 fmt.Fprintf(&b, "Forked from: %s\n", strings.TrimSpace(run.ForkedFrom))
1813 b.WriteString("The requested ref resolves to an ancestor conversation transcript, so the framework continues a copy owned by the current conversation. To continue this copied subagent transcript in a later call, pass ")
1814 b.WriteString(run.Ref)
1815 b.WriteString(" as `continue_from`. Start a fresh subagent when the next task is independent.")
1816 return b.String()
1817 }
1818 b.WriteString("To continue this same subagent transcript in a later call, pass this ref as `continue_from`. Start a fresh subagent when the next task is independent.")
1819 return b.String()
1820 }
1821
1822 func FormatSubagentRunResult(answer string, run *SubagentRun, failed bool) string {
1823 answer = GuardSubagentHostDecisionText(answer)
1824 if run == nil || run.Ref == "" {
1825 return answer
1826 }
1827 if failed {
1828 if answer == "" {
1829 return "Subagent reference (failed): " + run.Ref
1830 }
1831 return "Subagent reference (failed): " + run.Ref + "\n\nFinal answer:\n" + answer
1832 }
1833 return FormatSubagentReference(run) + "\n\nFinal answer:\n" + answer
1834 }
1835
1836 // GuardSubagentHostDecisionText appends a fixed boundary warning only when a
1837 // child agent result appears to discuss host approval or user-owned decisions.
1838 // The implementation lives in internal/tool so the skill tools share the exact
1839 // same phrase list and notice.
1840 func GuardSubagentHostDecisionText(answer string) string {
1841 return tool.GuardSubagentHostDecisionText(answer)
1842 }
1843
1844 // maxReviewReportNudges bounds the in-session completion nudges sent to a
1845 // review subagent that finished without submitting review_report. Each nudge is
1846 // one cheap continuation request on the same (cached) subagent session — far
1847 // cheaper than discarding the run and re-reviewing from scratch.
1848 const maxReviewReportNudges = 2
1849
1850 // reviewReportTaskContract is appended to the task prompt of a review subagent
1851 // whose run must end with a typed report. The skill body describes how to
1852 // review; this states the non-negotiable submission protocol.
1853 func reviewReportTaskContract(kind evidence.ReviewKind) string {
1854 return fmt.Sprintf(`<review-report-contract event="SubagentReviewReport">
1855 Before your final answer you MUST call the review_report tool exactly once with kind=%q, your verdict (pass | warn | block), reviewed_paths listing only files you actually read this run, and your findings. The host discards a review run that ends without a successful review_report call — your prose summary alone does not count.
1856 </review-report-contract>`, string(kind))
1857 }
1858
1859 // reviewReportNudgePrompt asks an already-finished review subagent to submit
1860 // the missing typed report without redoing the review.
1861 func reviewReportNudgePrompt(kind evidence.ReviewKind) string {
1862 return fmt.Sprintf("You finished the review without calling the review_report tool, so the host cannot accept the run yet. Do not redo the review. Call review_report now with kind=%q, your verdict (pass | warn | block), reviewed_paths listing only the files you actually read in this conversation, and the findings you already reported. Then restate your final verdict in one sentence.", string(kind))
1863 }
1864
1865 // RunSubAgentWithSession continues an existing sub-agent session with prompt and
1866 // returns the latest final assistant answer. Fresh sub-agents pass a newly-created
1867 // session; continued sub-agents pass a loaded transcript session.
1868 //
1869 // Each call installs an independent session-private temporary directory Manager
1870 // so parent, sibling, and nested sub-agents never share temporary files.
1871 // continue_from restores conversation history only — a new run still gets a
1872 // fresh temporary directory.
1873 func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
1874 if sess == nil {
1875 return "", fmt.Errorf("sub-agent session is nil")
1876 }
1877 // Isolate temporary files for this run before any tool execution.
1878 ctx, releaseTemp := withSubagentSessionTemp(ctx)
1879 defer releaseTemp()
1880 if opts.SubagentDepth > 0 {
1881 ctx = WithSubagentDepth(ctx, opts.SubagentDepth)
1882 }
1883 // Callers that wrap the prompt themselves (runSubSession) set
1884 // ClassifierTaskText before wrapping; for everyone else the prompt is
1885 // still pristine here, so capture it before host framing is prepended.
1886 if strings.TrimSpace(opts.ClassifierTaskText) == "" {
1887 opts.ClassifierTaskText = prompt
1888 }
1889 planWorkflow := PlanModeFromContext(ctx)
1890 if opts.SubagentDepth > 0 && isFreshSubagentSession(sess) {
1891 prompt = subagentStartContext + "\n\n" + prompt
1892 }
1893 if planWorkflow && !strings.Contains(prompt, planmode.Marker) {
1894 prompt = planmode.Marker + "\n\n" + prompt
1895 }
1896 if kind := opts.RequireReviewReportKind; kind != "" {
1897 prompt = prompt + "\n\n" + reviewReportTaskContract(kind)
1898 }
1899 sub := New(prov, reg, sess, opts, sink)
1900 sub.SetPlanMode(planWorkflow)
1901 if err := sub.Run(ctx, prompt); err != nil {
1902 // Still merge any partial child evidence so parent gates see real writes.
1903 mergeChildEvidence(ctx, sub)
1904 if answer, ok := salvageReadinessExhaustedAnswer(sub, sess, opts, err); ok {
1905 return answer, nil
1906 }
1907 return "", fmt.Errorf("sub-agent: %w", err)
1908 }
1909 // Review/security subagents must hand back a typed report the parent's
1910 // delivery gate can verify; prose alone would leave the gate demanding a
1911 // review forever with no way to tell why it never arrives. A run that
1912 // finished without the report gets bounded completion nudges on the same
1913 // session (evidence preserved, so review_report can still cite the reads it
1914 // already earned) before the whole run is declared failed.
1915 if kind := opts.RequireReviewReportKind; kind != "" {
1916 nudges := 0
1917 for !sub.HasSuccessfulReviewReport(kind) && nudges < maxReviewReportNudges {
1918 nudges++
1919 sub.preserveEvidenceOnce = true
1920 if err := sub.Run(ctx, reviewReportNudgePrompt(kind)); err != nil {
1921 mergeChildEvidence(ctx, sub)
1922 return "", fmt.Errorf("sub-agent: %w", err)
1923 }
1924 }
1925 if !sub.HasSuccessfulReviewReport(kind) {
1926 mergeChildEvidence(ctx, sub)
1927 dumpRef := dumpFailedSubagentSession(opts.ArchiveDir, string(kind), sess)
1928 return "", fmt.Errorf("%s subagent finished without submitting review_report (kind=%s) even after %d host nudges; the report must be submitted by the review subagent itself (the parent has no review_report tool) — re-run the review skill%s", kind, kind, nudges, dumpRef)
1929 }
1930 }
1931 mergeChildEvidence(ctx, sub)
1932 if answer := latestAssistantAnswer(sess); answer != "" {
1933 return answer, nil
1934 }
1935 return "", fmt.Errorf("sub-agent finished without producing a final answer")
1936 }
1937
1938 // readOnlyAgentConstruction is the single pairing every strictly read-only
1939 // loop shares: the permanent ReadOnlyExecution flag plus the final registry
1940 // filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
1941 // that still use NewReadOnlyAgent build through it, so a missed call site
1942 // cannot set only half the boundary. The interactive two-model planner uses
1943 // NewPlannerAgent instead (PlannerMCPExecution).
1944 func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
1945 opts.ReadOnlyExecution = true
1946 opts.PlannerMCPExecution = false
1947 return strictReadOnlyExecutionRegistry(reg), opts
1948 }
1949
1950 // NewReadOnlyAgent constructs a long-lived, strictly read-only agent through
1951 // the shared construction boundary. Prefer NewPlannerAgent for the two-model
1952 // planner so authorized non-destructive MCP can run via use_capability.
1953 func NewReadOnlyAgent(prov provider.Provider, reg *tool.Registry, sess *Session, opts Options, sink event.Sink) *Agent {
1954 reg, opts = readOnlyAgentConstruction(reg, opts)
1955 return New(prov, reg, sess, opts, sink)
1956 }
1957
1958 // NewPlannerAgent constructs the interactive two-model planner: permanent
1959 // ReadOnlyExecution still blocks bash, file writers, and ordinary non-MCP
1960 // writers, while PlannerMCPExecution allows authorized, non-destructive MCP
1961 // through the stable use_capability proxy without requiring readOnlyHint.
1962 func NewPlannerAgent(prov provider.Provider, reg *tool.Registry, sess *Session, opts Options, sink event.Sink) *Agent {
1963 opts.ReadOnlyExecution = true
1964 opts.PlannerMCPExecution = true
1965 // Keep construction-time filter for ordinary tools; use_capability stays
1966 // because it is ReadOnly. Direct mcp__* tools are already excluded by
1967 // PlannerToolRegistry. Dynamic MCP targets are re-checked after resolve.
1968 reg = plannerExecutionRegistry(reg)
1969 return New(prov, reg, sess, opts, sink)
1970 }
1971
1972 // plannerExecutionRegistry is the construction-time filter for NewPlannerAgent.
1973 // It removes ordinary writers and destructive direct MCP tools while keeping
1974 // use_capability and built-in research tools. Host-starting deferred MCP
1975 // targets are allowed at execution time under PlannerMCPExecution.
1976 func plannerExecutionRegistry(reg *tool.Registry) *tool.Registry {
1977 filtered := tool.NewRegistry()
1978 if reg == nil {
1979 return filtered
1980 }
1981 for _, name := range reg.Names() {
1982 target, ok := reg.Get(name)
1983 if !ok {
1984 continue
1985 }
1986 if name == "use_capability" {
1987 filtered.Add(target)
1988 continue
1989 }
1990 if strings.HasPrefix(name, tool.MCPNamePrefix) {
1991 // Defense in depth: planner never exposes direct MCP schemas.
1992 continue
1993 }
1994 if !target.ReadOnly() || mcpDestructiveHint(target) {
1995 continue
1996 }
1997 if h, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() {
1998 // Ordinary host mutations stay out; MCP startup is only via proxy.
1999 continue
2000 }
2001 filtered.Add(target)
2002 }
2003 return filtered
2004 }
2005
2006 // RunReadOnlySubAgentWithSession is the construction boundary for every
2007 // strictly read-only child loop. Registry filtering limits the visible surface;
2008 // this permanent execution flag also re-checks targets resolved dynamically by
2009 // proxy tools such as use_capability. It never enables PlannerMCPExecution.
2010 func RunReadOnlySubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
2011 reg, opts = readOnlyAgentConstruction(reg, opts)
2012 return RunSubAgentWithSession(ctx, prov, reg, sess, prompt, opts, sink)
2013 }
2014
2015 // strictReadOnlyExecutionRegistry is the final construction-time filter shared
2016 // by every strict child. Callers still apply role-specific filtering (review,
2017 // planner, profile allowlists), while this layer guarantees that a missed call
2018 // site cannot expose writers, destructive MCP tools, readers from unauthorized
2019 // servers, or an unauthorized host-starting target to the model.
2020 func strictReadOnlyExecutionRegistry(reg *tool.Registry) *tool.Registry {
2021 filtered := tool.NewRegistry()
2022 if reg == nil {
2023 return filtered
2024 }
2025 for _, name := range reg.Names() {
2026 target, ok := reg.Get(name)
2027 if !ok || !target.ReadOnly() || mcpDestructiveHint(target) {
2028 continue
2029 }
2030 if isInstalledMCPTool(target) && !mcpServerAuthorized(target) {
2031 continue
2032 }
2033 if mutation, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && mutation.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(target) {
2034 continue
2035 }
2036 filtered.Add(target)
2037 }
2038 return filtered
2039 }
2040
2041 // latestAssistantAnswer walks the session backwards for the last assistant
2042 // message with content — that's the sub-agent's final answer. Intermediate
2043 // assistant messages with tool_calls but no text don't count.
2044 func latestAssistantAnswer(sess *Session) string {
2045 if sess == nil {
2046 return ""
2047 }
2048 for i := len(sess.Messages) - 1; i >= 0; i-- {
2049 m := sess.Messages[i]
2050 if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
2051 return m.Content
2052 }
2053 }
2054 return ""
2055 }
2056
2057 // salvageReadinessExhaustedAnswer degrades a sub-agent's readiness exhaustion
2058 // from a hard failure to an explicitly unverified result. The gate exists to
2059 // stop unverified *claims*, not to discard finished *work*: when the child has
2060 // a real successful mutation on disk and a visible answer, failing the whole
2061 // run makes the parent believe the work is broken and spawn repair tasks for
2062 // changes that already landed — the failure cascade users see as a wall of
2063 // "background task failed" notices. The child's receipts were already merged
2064 // into the parent ledger, so the parent's own delivery gates still require
2065 // verification and review of those writes before it can final-answer.
2066 //
2067 // Salvage is refused when the child produced no successful mutation (an
2068 // unbacked "done" claim must keep failing, e.g. a spoofed or lazy run) and for
2069 // report-required review sub-agents, whose contract is the typed review_report
2070 // rather than prose.
2071 func salvageReadinessExhaustedAnswer(sub *Agent, sess *Session, opts Options, err error) (string, bool) {
2072 var readinessErr *FinalReadinessError
2073 if !errors.As(err, &readinessErr) {
2074 return "", false
2075 }
2076 if opts.RequireReviewReportKind != "" {
2077 return "", false
2078 }
2079 if sub == nil || !sub.EvidenceSummary().HasMutation() {
2080 return "", false
2081 }
2082 answer := latestAssistantAnswer(sess)
2083 if answer == "" {
2084 return "", false
2085 }
2086 return "[unverified] The sub-agent finished its work but exhausted the host delivery sign-off checks before reporting (" +
2087 readinessErr.Reason +
2088 "). Its successful writes are already on disk and its receipts were merged into this turn's evidence. " +
2089 "Inspect the diff and run the relevant checks before relying on the result below; do not re-run or \"fix\" the same work without first checking what already changed.\n\nSub-agent answer:\n" +
2090 answer, true
2091 }
2092
2093 // dumpFailedSubagentSession best-effort persists a failed report-required
2094 // subagent transcript for post-hoc diagnosis (read-only skill subagents are
2095 // otherwise ephemeral, so a protocol failure leaves no trace). Returns a
2096 // human-readable suffix naming the dump, or "" when disabled/failed.
2097 func dumpFailedSubagentSession(archiveDir, kind string, sess *Session) string {
2098 if strings.TrimSpace(archiveDir) == "" || sess == nil {
2099 return ""
2100 }
2101 dir := filepath.Join(archiveDir, "subagent-report-failures")
2102 if err := os.MkdirAll(dir, 0o755); err != nil {
2103 return ""
2104 }
2105 path := filepath.Join(dir, fmt.Sprintf("%s-%d.jsonl", kind, time.Now().UnixNano()))
2106 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
2107 if err != nil {
2108 return ""
2109 }
2110 defer f.Close()
2111 enc := json.NewEncoder(f)
2112 for _, m := range sess.Messages {
2113 if err := enc.Encode(m); err != nil {
2114 return ""
2115 }
2116 }
2117 return "; transcript dumped to " + path
2118 }
2119
2120 // mergeChildEvidence folds a sub-agent's real receipts into the parent ledger
2121 // carried on ctx. Meta tools themselves are never mutations.
2122 func mergeChildEvidence(ctx context.Context, sub *Agent) {
2123 if sub == nil {
2124 return
2125 }
2126 parent, ok := evidence.FromContext(ctx)
2127 if !ok || parent == nil {
2128 return
2129 }
2130 parent.MergeChild(sub.EvidenceSummary())
2131 }
2132
2133 // EvidenceSummary exports this agent's turn-scoped receipts for parent merge.
2134 func (a *Agent) EvidenceSummary() evidence.ChildEvidenceSummary {
2135 if a == nil || a.evidence == nil {
2136 return evidence.ChildEvidenceSummary{}
2137 }
2138 return a.evidence.Summary()
2139 }
2140
2141 func isFreshSubagentSession(sess *Session) bool {
2142 if sess == nil {
2143 return false
2144 }
2145 snap := sess.Snapshot()
2146 return len(snap) == 1 && snap[0].Role == provider.RoleSystem
2147 }
2148
2149 // NestedSink returns a sink that forwards a sub-agent's tool activity to the
2150 // parent stream, nested under the tool call carried by ctx, so a frontend shows
2151 // it beneath that call (the same nesting `task` uses). Falls back to the given
2152 // sink when ctx carries no call context. Used by subagent skills.
2153 func NestedSink(ctx context.Context, fallback event.Sink) event.Sink {
2154 parentID, parent, _, ok := CallContext(ctx)
2155 if !ok || parent == nil {
2156 return fallback
2157 }
2158 return subSinkFor(parentID, parent)
2159 }
2160
2161 // subSink forwards a sub-agent's tool dispatch/result/progress events and
2162 // billable usage to the parent's event stream. Only tool activity is nested
2163 // visually; the sub-agent's text/reasoning stays isolated (progress previews
2164 // travel as reserved ToolProgress channels, not as parent Text/Reasoning) and
2165 // only its final answer is returned.
2166 //
2167 // The sub-agent's own turn/text/reasoning events are dropped — forwarding them
2168 // would make the parent transcript noisy and could imply they belong to the
2169 // parent model context, which they do not.
2170 //
2171 // Usage events are observability only, so forwarding them preserves billing
2172 // totals without polluting the parent provider-visible prefix.
2173 //
2174 // Tool events are tagged with the parent task call's ID so a frontend nests them
2175 // under it. The forwarded call IDs are namespaced with the parent ID so a
2176 // sub-agent call can never collide with a parent call in the frontend's
2177 // dispatch→result matching. ToolProgress covers both the sub-agent's real tool
2178 // output and nested sub-agent progress previews, which ride the same sink so
2179 // their IDs match the cards they belong to. Falls back to Discard when there's
2180 // no parent stream (the headless run loop, or a direct Execute in tests).
2181 func subSink(ctx context.Context) event.Sink {
2182 parentID, parent, _, ok := CallContext(ctx)
2183 if !ok || parent == nil {
2184 return event.Discard
2185 }
2186 return subSinkFor(parentID, parent)
2187 }
2188
2189 // subSinkFor builds the nesting sink from an already-captured parent ID + stream,
2190 // for the background path where the job runs under a context that no longer
2191 // carries the call context. Falls back to Discard when there's no parent stream.
2192 func subSinkFor(parentID string, parent event.Sink) event.Sink {
2193 if parent == nil {
2194 return event.Discard
2195 }
2196 return event.FuncSink(func(e event.Event) {
2197 switch e.Kind {
2198 case event.ToolDispatch, event.ToolResult, event.ToolProgress:
2199 e.Tool.ParentID = parentID
2200 e.Tool.ID = parentID + "/" + e.Tool.ID
2201 parent.Emit(e)
2202 case event.Usage:
2203 if e.UsageSource == "" {
2204 e.UsageSource = event.UsageSourceSubagent
2205 }
2206 parent.Emit(e)
2207 }
2208 })
2209 }
2210
2210 lines GO