返回 DeepSeek-Reasonix
task.go
根目录 / internal / agent / task.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "path/filepath"
9 "runtime/debug"
10 "slices"
11 "strconv"
12 "strings"
13
14 "reasonix/internal/ablation"
15 "reasonix/internal/checkpoint"
16 "reasonix/internal/event"
17 "reasonix/internal/evidence"
18 "reasonix/internal/imageinput"
19 "reasonix/internal/jobs"
20 "reasonix/internal/memory"
21 "reasonix/internal/permission"
22 "reasonix/internal/planmode"
23 "reasonix/internal/provider"
24 "reasonix/internal/sandbox"
25 "reasonix/internal/sessiontemp"
26 "reasonix/internal/tool"
27 "reasonix/internal/workspacelease"
28 )
29
30 // DefaultTaskSystemPrompt steers a sub-agent toward focused, terse delivery —
31 // it doesn't see the parent's conversation so it must self-contain.
32 const DefaultTaskSystemPrompt = `You are a sub-agent invoked by a parent coding agent to carry out one focused task.
33 Use the provided tools to investigate or act. For MCP, use the stable use_capability
34 proxy (list → inspect → call); do not expect direct mcp__* tool schemas. Return a
35 single final answer that is concise and self-contained — the parent will see only
36 that answer, not your tool calls or reasoning. If you need to ask for clarification,
37 fail with a precise question instead of guessing.`
38
39 // DefaultReadOnlyTaskSystemPrompt steers read-only sub-agents toward isolated
40 // research. They never receive writer tools, persisted transcript controls, or
41 // background process controls, so their final answer is the only handoff.
42 const DefaultReadOnlyTaskSystemPrompt = `You are a read-only research sub-agent invoked by a parent coding agent.
43 Use only the provided read-only tools to inspect code, docs, history, and safe shell output.
44 For MCP, use use_capability only for authorized tools that declare readOnly and are
45 not destructive; never treat missing readOnlyHint as permission to call. Do not
46 attempt to write files, install capabilities, mutate memory, control long-lived
47 processes, or delegate to writer-capable agents. If a read-only delegation tool is
48 available and genuinely useful, you may use it within the configured depth limit.
49 Return a concise, self-contained final answer with the evidence the parent needs.`
50
51 const subagentStartContext = `<subagent-context event="SubagentStart">
52 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.
53 </subagent-context>`
54
55 // read_skill is deliberately not listed: it renders playbook text inline and
56 // cannot recurse, so depth-capped sub-agents keep it and can still read
57 // playbooks even when they can no longer delegate.
58 var subagentRecursiveTools = []string{
59 "task",
60 "read_only_task",
61 "run_skill",
62 "read_only_skill",
63 "explore",
64 "research",
65 "review",
66 "security_review",
67 }
68
69 var subagentAlwaysHiddenTools = []string{
70 "parallel_tasks",
71 "fleet",
72 "read_subagent_result",
73 "set_session_title",
74 "install_skill",
75 "install_source",
76 // Kept in the parent registry only as a clear retirement tombstone for
77 // replayed/model-stale calls. New child contexts must never advertise it.
78 "complete_step",
79 }
80
81 var subagentJobTools = []string{
82 "job_output",
83 "job_kill",
84 "wait",
85 "bash_output",
86 "kill_shell",
87 }
88
89 var readOnlySubagentWorkflowTools = []string{
90 "connect_tool_source",
91 }
92
93 const subagentToolBoundarySummary = "Recursive agent/skill tools are exposed only while max_subagent_depth leaves another delegation layer; background job tools (job_output/job_kill and the legacy wait/bash_output/kill_shell aliases) are excluded; the platform shell is exposed as foreground-only inside subagents."
94
95 // maxConcurrentBackgroundTasks is the legacy writer-background fallback used
96 // only when a TaskTool has no session scheduler (tests). Production boots
97 // inject MaxParallelWriters via SubagentScheduler.
98 const maxConcurrentBackgroundTasks = DefaultMaxParallelWriters
99
100 // AlwaysHiddenSubagentTools returns the tool names excluded from every
101 // subagent's registry regardless of an explicit allowlist or delegation
102 // depth (unlike subagentRecursiveTools, which depends on remaining depth).
103 // That covers both subagentAlwaysHiddenTools and subagentJobTools —
104 // SubagentToolRegistryForDepth and its read-only variant strip the job tools
105 // unconditionally too. Host UIs offering a tool picker for a subagent
106 // profile's allowed-tools should exclude these from the offered choices —
107 // selecting them would be silently ignored at runtime.
108 func AlwaysHiddenSubagentTools() []string {
109 names := append([]string(nil), subagentAlwaysHiddenTools...)
110 return append(names, subagentJobTools...)
111 }
112
113 // SubagentMetaTools returns the tool names that spawned agents should not inherit
114 // from the parent registry unless a future call site deliberately opts into a
115 // different boundary. They can spawn or author more agent work, so excluding them
116 // preserves one layer of delegation without adding a spawn-count cap.
117 // read_skill stays listed here so the guardian and planner surfaces, which
118 // exclude these names, keep their provider-visible tool sets byte-identical —
119 // only the sub-agent depth cap deliberately stopped stripping it.
120 func SubagentMetaTools() []string {
121 out := append([]string(nil), subagentRecursiveTools...)
122 out = append(out, "read_skill")
123 out = append(out, subagentAlwaysHiddenTools...)
124 return out
125 }
126
127 // SubagentToolRegistry returns the tool set exposed inside spawned sub-agents:
128 // the requested whitelist (or every parent tool), minus meta tools that would
129 // spawn more agent work and job tools whose runtime manager is not injected into
130 // sub-agents. When bash is present, it is wrapped to advertise and allow only
131 // foreground execution.
132 func SubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
133 return SubagentToolRegistryForDepth(parent, names, 1, 1)
134 }
135
136 // SubagentToolRegistryForDepth returns the writer-capable tool set for a spawned
137 // subagent at childDepth. Recursive delegation tools are available only when the
138 // child still has room to spawn one more subagent.
139 //
140 // Direct mcp__* schemas are never exposed: MCP goes only through the fixed
141 // use_capability proxy so connect/disconnect/tool-list churn cannot change the
142 // child provider-visible tool prefix. With no explicit allowlist the child gets
143 // the full proxy (installed/authorized MCP, including tools without
144 // readOnlyHint). An explicit allowlist converts mcp__* / mcp-tool: names into a
145 // capability-id allowlist on a restricted proxy.
146 func SubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
147 return SubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
148 }
149
150 // SubagentToolRegistryForDepthWithRuntime is SubagentToolRegistryForDepth with
151 // an optional session MCP runtime used when the parent registry has no
152 // use_capability (for example Economy or legacy callers) but sub-agents still
153 // need the proxy.
154 func SubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
155 exclude := append([]string(nil), subagentAlwaysHiddenTools...)
156 if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
157 exclude = append(exclude, subagentRecursiveTools...)
158 }
159 exclude = append(exclude, subagentJobTools...)
160 sub := FilterRegistry(parent, names, exclude...)
161 stripDirectMCPTools(sub)
162 AttachCompleteSubtaskTool(sub)
163 attachSubagentCapabilityProxy(parent, sub, names, runtime)
164 shellName := "bash"
165 if _, ok := sub.Get("pwsh"); ok {
166 shellName = "pwsh"
167 sub.RemovePrefix("bash")
168 }
169 if shell, ok := sub.Get(shellName); ok {
170 sub.Add(foregroundOnlyBash{inner: shell})
171 }
172 return sub
173 }
174
175 type foregroundOnlyBash struct {
176 inner tool.Tool
177 }
178
179 func (b foregroundOnlyBash) Name() string { return b.inner.Name() }
180
181 func (b foregroundOnlyBash) Description() string {
182 desc := strings.TrimSpace(b.inner.Description())
183 if desc == "" {
184 desc = "Execute a command in the shell and return combined stdout/stderr."
185 }
186 desc = strings.Replace(desc, "Execute a command in the shell", "Execute a foreground command in the shell", 1)
187 return desc + " Background execution is unavailable inside subagents."
188 }
189
190 func (b foregroundOnlyBash) Schema() json.RawMessage {
191 if b.Name() == "pwsh" {
192 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"PowerShell command to execute in the foreground"},"description":{"type":"string","description":"Clear 5-10 word active-voice description shown in the UI"},"timeout_ms":{"type":"integer","minimum":1}},"required":["command","description"]}`)
193 }
194 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute in the foreground"}},"required":["command"]}`)
195 }
196
197 func (b foregroundOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
198 var p struct {
199 RunInBackground bool `json:"run_in_background"`
200 }
201 if err := json.Unmarshal(args, &p); err != nil {
202 return "", fmt.Errorf("invalid args: %w", err)
203 }
204 if p.RunInBackground {
205 return "", tool.Blocked(fmt.Sprintf("blocked: background %s is unavailable in subagents; run a foreground command or ask the parent agent to start a background job", b.Name()))
206 }
207 return b.inner.Execute(ctx, args)
208 }
209
210 func (b foregroundOnlyBash) ReadOnly() bool { return b.inner.ReadOnly() }
211
212 type readOnlyBash struct {
213 inner tool.Tool
214 }
215
216 func (b readOnlyBash) Name() string { return b.inner.Name() }
217
218 func (b readOnlyBash) Description() string {
219 desc := strings.TrimSpace(b.inner.Description())
220 if desc == "" {
221 desc = "Execute a command in the shell and return combined stdout/stderr."
222 }
223 desc = strings.Replace(desc, "Execute a command in the shell", "Execute a foreground read-only command in the shell", 1)
224 return desc + " Only permission-classified read-only commands are allowed; shell operators, background execution, process preservation, and write-capable arguments are blocked."
225 }
226
227 func (b readOnlyBash) Schema() json.RawMessage {
228 if b.Name() == "pwsh" {
229 return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Read-only PowerShell command to execute in the foreground"},"description":{"type":"string","description":"Clear 5-10 word active-voice description shown in the UI"},"timeout_ms":{"type":"integer","minimum":1}},"required":["command","description"]}`)
230 }
231 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"]}`)
232 }
233
234 func (b readOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
235 if !permission.BashCommandIsReadOnly(args) {
236 return "", tool.Blocked("blocked: read-only subagents can run only permission-classified foreground read-only commands")
237 }
238 return b.inner.Execute(ctx, args)
239 }
240
241 func (readOnlyBash) ReadOnly() bool { return true }
242
243 // TaskTool spawns a sub-agent in its own session for a focused sub-task. The
244 // sub-agent runs with a filtered tool whitelist and the same step budget shape
245 // as the parent (see Execute); its tool calls are forwarded to the parent's
246 // event stream nested under this call, while only its final assistant message is
247 // returned to the parent model. Use cases: keep noisy tool sequences (multi-file
248 // exploration, repeated grep / read_file) out of the parent's context budget, or
249 // parallel research across independent areas (the parallel-dispatch path picks
250 // these up only when readOnly, which task is not).
251 type TaskTool struct {
252 imageInput *imageinput.Config
253 prov provider.Provider
254 pricing *provider.Pricing
255 quoteContext *event.QuoteContext
256 parentReg *tool.Registry
257 maxSteps int
258 contextWindow int
259 compactRatio float64
260 recentKeep int
261 temperature float64
262 archiveDir string
263 keepPolicy KeepPolicy
264 sysPrompt string
265 gate Gate
266 subagentModel, subagentEffort string
267 resolveProvider func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error)
268 transcripts *SubagentStore
269 workspaceRoot string
270 baseModel string
271 baseEffort string
272 identityProfile func(modelRef, effort string) (string, string)
273 maxSubagentDepth int
274 ablation ablation.Set
275 workspaceLease *workspacelease.Owner
276 // scheduler is the session-scoped concurrency + write-claim controller.
277 // nil falls back to the legacy jobs.ReserveStart cap for background tasks.
278 scheduler *SubagentScheduler
279 // profileLookup resolves profile= names from the live Skill store without
280 // embedding the name list in the tool schema (cache stability).
281 profileLookup ProfileLookup
282 // profileConfigModel/Effort look up persistent per-profile overrides
283 // (agent.subagent_models / subagent_efforts).
284 profileConfigModel func(profile string) string
285 profileConfigEffort func(profile string) string
286 // bashSandboxEnforced reports whether OS sandbox can honour write roots
287 // for bash inside path-bound writer sub-agents.
288 bashSandboxEnforced func() bool
289 // mutationObserver is shared with spawned sub-agents for checkpoint capture.
290 mutationObserver *checkpoint.MutationObserver
291 writeRoots *sandbox.WritableRootSet
292 imageResolver ImageRequestResolver
293 // capabilityRuntime is the session-shared MCP Host/specs substrate. Each
294 // sub-agent gets its own use_capability frontend so ledger state stays
295 // isolated while connections reuse the parent Host.
296 capabilityRuntime *MCPCapabilityRuntime
297 }
298
299 // NewTaskTool wires a task tool to the parent agent's environment so its
300 // sub-agents can use the same provider and tools. sysPrompt is the system
301 // prompt every sub-agent starts with; pass "" for DefaultTaskSystemPrompt. gate
302 // is the permission gate sub-agents inherit — pass the headless variant so
303 // deny rules still bite while autonomous sub-agents are never blocked on an
304 // interactive prompt (there is no UI to answer one).
305 //
306 // Compatibility wrapper: new call sites should prefer NewTaskToolWithOptions.
307 // The positional form is kept for at least one full iteration cycle.
308 func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *tool.Registry,
309 maxSteps, contextWindow, recentKeep int, softCompactRatio, toolResultSnipRatio, compactRatio, compactForceRatio, temperature float64, archiveDir, sysPrompt string, gate Gate,
310 keepPolicy KeepPolicy, subagentModel, subagentEffort string, resolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
311 return NewTaskToolWithOptions(TaskToolOptions{
312 Provider: prov,
313 Pricing: pricing,
314 ParentRegistry: parentReg,
315 MaxSteps: maxSteps,
316 ContextWindow: contextWindow,
317 RecentKeep: recentKeep,
318 SoftCompactRatio: softCompactRatio,
319 ToolResultSnipRatio: toolResultSnipRatio,
320 CompactRatio: compactRatio,
321 CompactForceRatio: compactForceRatio,
322 Temperature: temperature,
323 ArchiveDir: archiveDir,
324 SysPrompt: sysPrompt,
325 Gate: gate,
326 KeepPolicy: keepPolicy,
327 SubagentModel: subagentModel,
328 SubagentEffort: subagentEffort,
329 ResolveProvider: resolveProvider,
330 })
331 }
332
333 // WithTranscripts enables persisted sub-agent transcript continuation for this
334 // task tool. The base model/effort are the parent provider identity used when no
335 // subagent override is configured.
336 func (t *TaskTool) WithTranscripts(store *SubagentStore, workspaceRoot, baseModel, baseEffort string) *TaskTool {
337 t.transcripts = store
338 t.workspaceRoot = strings.TrimSpace(workspaceRoot)
339 t.baseModel = strings.TrimSpace(baseModel)
340 t.baseEffort = strings.TrimSpace(baseEffort)
341 return t
342 }
343
344 func (t *TaskTool) WithTranscriptIdentityResolver(resolve func(modelRef, effort string) (string, string)) *TaskTool {
345 t.identityProfile = resolve
346 return t
347 }
348
349 func (t *TaskTool) WithMaxSubagentDepth(depth int) *TaskTool {
350 t.maxSubagentDepth = NormalizeMaxSubagentDepth(depth)
351 return t
352 }
353
354 // WithAblation propagates the parent's benchmark arm so a sub-agent runs with
355 // the same subsystems switched off.
356 func (t *TaskTool) WithAblation(set ablation.Set) *TaskTool {
357 t.ablation = set
358 return t
359 }
360
361 // WithWorkspaceLease shares the parent's workspace-wide delivery write lease
362 // with every spawned sub-agent. A shared owner is required: independent owners
363 // in one session would deadlock when a child tries to write while its parent
364 // already retains the lease.
365 func (t *TaskTool) WithWorkspaceLease(owner *workspacelease.Owner) *TaskTool {
366 t.workspaceLease = owner
367 return t
368 }
369
370 // WithScheduler attaches the session-scoped concurrency and write-claim
371 // controller used by task, fleet, parallel_tasks, and profile skill runners.
372 func (t *TaskTool) WithScheduler(s *SubagentScheduler) *TaskTool {
373 t.scheduler = s
374 return t
375 }
376
377 // Scheduler returns the attached session scheduler (may be nil in unit tests).
378 func (t *TaskTool) Scheduler() *SubagentScheduler {
379 if t == nil {
380 return nil
381 }
382 return t.scheduler
383 }
384
385 // WithProfileLookup enables task/fleet profile= resolution from the Skill store.
386 func (t *TaskTool) WithProfileLookup(lookup ProfileLookup) *TaskTool {
387 t.profileLookup = lookup
388 return t
389 }
390
391 // WithProfileConfigResolvers supplies persistent per-profile model/effort
392 // overrides (agent.subagent_models / subagent_efforts).
393 func (t *TaskTool) WithProfileConfigResolvers(model, effort func(profile string) string) *TaskTool {
394 t.profileConfigModel = model
395 t.profileConfigEffort = effort
396 return t
397 }
398
399 // WithBashSandboxEnforced tells path-bound writer runs whether bash can keep
400 // the same write roots under the OS sandbox.
401 func (t *TaskTool) WithBashSandboxEnforced(fn func() bool) *TaskTool {
402 t.bashSandboxEnforced = fn
403 return t
404 }
405
406 // WithCapabilityRuntime attaches the session-shared MCP runtime so ordinary and
407 // read-only sub-agents receive a stable use_capability frontend without
408 // inheriting dynamic mcp__* schemas.
409 func (t *TaskTool) WithCapabilityRuntime(rt *MCPCapabilityRuntime) *TaskTool {
410 if t != nil {
411 t.capabilityRuntime = rt
412 }
413 return t
414 }
415
416 func (t *TaskTool) Name() string { return tool.HostTask }
417
418 func (t *TaskTool) Description() string {
419 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."
420 }
421
422 func (t *TaskTool) Schema() json.RawMessage {
423 return json.RawMessage(`{
424 "type":"object",
425 "properties":{
426 "prompt":{"type":"string","description":"What the sub-agent should accomplish. Be specific about the deliverable — the sub-agent does not see this conversation."},
427 "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."},
428 "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."},
429 "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."},
430 "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 + `"},
431 "max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
432 "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 job_output, and you'll be notified when it finishes. Use for long, independent sub-tasks you don't need to block on right now."},
433 "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."},
434 "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max). Same precedence as model."},
435 "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."}
436 },
437 "required":["prompt"]
438 }`)
439 }
440
441 // ReadOnly is false: a sub-agent can invoke any whitelisted tool, including
442 // writers. Conservative classification keeps the parallel-dispatch path from
443 // running two sub-agents at once and letting their writes race.
444 func (t *TaskTool) ReadOnly() bool { return false }
445
446 // ResolveProfile extracts model/effort from task args (and optional profile
447 // overrides) for dispatch-line display. Runtime execution re-resolves with the
448 // full precedence chain.
449 func (t *TaskTool) ResolveProfile(args json.RawMessage) *event.Profile {
450 var p struct {
451 Model string `json:"model"`
452 Effort string `json:"effort"`
453 Profile string `json:"profile"`
454 }
455 if err := json.Unmarshal(args, &p); err != nil {
456 return nil
457 }
458 profileModel, profileEffort := "", ""
459 configModel, configEffort := "", ""
460 if name := strings.TrimSpace(p.Profile); name != "" {
461 if def, err := ResolveProfileDefinition(t.profileLookup, name); err == nil {
462 profileModel, profileEffort = def.Model, def.Effort
463 }
464 if t.profileConfigModel != nil {
465 configModel = t.profileConfigModel(name)
466 }
467 if t.profileConfigEffort != nil {
468 configEffort = t.profileConfigEffort(name)
469 }
470 }
471 model, effort := ResolveModelEffort(
472 configModel, configEffort,
473 p.Model, p.Effort,
474 profileModel, profileEffort,
475 t.subagentModel, t.subagentEffort,
476 )
477 if model == "" && effort == "" {
478 return nil
479 }
480 return &event.Profile{Model: model, Effort: effort}
481 }
482
483 // ReadOnlyTaskTool runs an isolated sub-agent with a strictly read-only tool
484 // registry. It intentionally omits background execution and transcript
485 // continuation/fork controls so the call has no durable host side effects.
486 type ReadOnlyTaskTool struct {
487 task *TaskTool
488 }
489
490 func NewReadOnlyTaskTool(task *TaskTool) *ReadOnlyTaskTool {
491 return &ReadOnlyTaskTool{task: task}
492 }
493
494 func (*ReadOnlyTaskTool) Name() string { return tool.HostReadOnlyTask }
495
496 func (*ReadOnlyTaskTool) Description() string {
497 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."
498 }
499
500 func (*ReadOnlyTaskTool) Schema() json.RawMessage {
501 return json.RawMessage(`{
502 "type":"object",
503 "properties":{
504 "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."},
505 "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."},
506 "tools":{"type":"array","items":{"type":"string"},"description":"Optional read-only tool whitelist. Writer, installer, memory mutation, background job, and delegation tools are never exposed."},
507 "max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
508 "model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name)."},
509 "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."}
510 },
511 "required":["prompt"]
512 }`)
513 }
514
515 func (*ReadOnlyTaskTool) ReadOnly() bool { return true }
516
517 // PlanModeSafe reports true: read_only_task spawns a strictly read-only research
518 // sub-agent (no writers, installers, memory mutation, background jobs, or
519 // delegation), so it is safe to run while planning.
520 func (*ReadOnlyTaskTool) PlanModeSafe() bool { return true }
521
522 func (r *ReadOnlyTaskTool) ResolveProfile(args json.RawMessage) *event.Profile {
523 if r == nil || r.task == nil {
524 return nil
525 }
526 return r.task.ResolveProfile(args)
527 }
528
529 func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
530 if r == nil || r.task == nil {
531 return "", fmt.Errorf("read_only_task is not configured")
532 }
533 var p struct {
534 Prompt string `json:"prompt"`
535 Description string `json:"description"`
536 Tools []string `json:"tools"`
537 MaxSteps int `json:"max_steps"`
538 Model string `json:"model"`
539 Effort string `json:"effort"`
540 }
541 if err := json.Unmarshal(args, &p); err != nil {
542 return "", fmt.Errorf("invalid args: %w", err)
543 }
544 // Every entry point compiles to a spec and runs through RunProfileSpec, so a
545 // boundary added there cannot be missed by one caller. read_only_task keeps
546 // its own promise of no durable side effects through Ephemeral.
547 spec, err := r.task.buildTaskSpec(ctx, p.Prompt, p.Description, "", nil, p.Tools, p.MaxSteps, p.Model, p.Effort, "", "", false, true)
548 if err != nil {
549 return "", err
550 }
551 spec.Worker.SystemPrompt = DefaultReadOnlyTaskSystemPrompt
552 spec.Context.Ephemeral = true
553 return r.task.RunProfileSpec(ctx, spec)
554 }
555
556 func (t *TaskTool) effectiveProfile(model, effort string) (string, string) {
557 model = strings.TrimSpace(model)
558 effort = strings.TrimSpace(effort)
559 if model == "" {
560 model = strings.TrimSpace(t.subagentModel)
561 }
562 if effort == "" {
563 effort = strings.TrimSpace(t.subagentEffort)
564 }
565 return model, effort
566 }
567
568 func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
569 var p struct {
570 Prompt string `json:"prompt"`
571 Description string `json:"description"`
572 Profile string `json:"profile"`
573 WritePaths []string `json:"write_paths"`
574 Tools []string `json:"tools"`
575 MaxSteps int `json:"max_steps"`
576 RunInBackground bool `json:"run_in_background"`
577 Model string `json:"model"`
578 Effort string `json:"effort"`
579 ContinueFrom string `json:"continue_from"`
580 ForkFrom string `json:"fork_from"`
581 }
582 if err := json.Unmarshal(args, &p); err != nil {
583 return "", fmt.Errorf("invalid args: %w", err)
584 }
585 if strings.TrimSpace(p.Prompt) == "" {
586 return "", fmt.Errorf("prompt is required")
587 }
588
589 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)
590 if err != nil {
591 return "", err
592 }
593 return t.RunProfileSpec(ctx, spec)
594 }
595
596 // buildTaskSpec resolves profile, tools, model/effort, and write claims for a
597 // single task/fleet item. forceReadOnly forces the read-only registry.
598 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) {
599 spec := ProfileExecSpec{
600 Task: TaskSpec{Objective: prompt, Description: description},
601 Worker: WorkerSpec{Kind: "task", Name: "task", SystemPrompt: t.sysPrompt},
602 Grant: CapabilityGrant{CallTools: tools},
603 Context: ContextRequest{ContinueFrom: strings.TrimSpace(continueFrom), ForkFrom: strings.TrimSpace(forkFrom)},
604 Sched: SchedulerPolicy{MaxSteps: maxSteps, RunInBackground: background, Nested: SubagentDepth(ctx) > 0},
605 }
606 profile = strings.TrimSpace(profile)
607 readOnly := forceReadOnly
608 var profileTools []string
609 var profileModel, profileEffort string
610 if profile != "" {
611 def, err := ResolveProfileDefinition(t.profileLookup, profile)
612 if err != nil {
613 return ProfileExecSpec{}, err
614 }
615 spec.Worker.Profile = def.Name
616 spec.Worker.Name = def.Name
617 spec.Worker.Kind = "skill"
618 spec.Worker.SystemPrompt = def.Body
619 spec.Worker.UseProfilePrompt = true
620 profileTools = def.AllowedTools
621 profileModel, profileEffort = def.Model, def.Effort
622 if def.ReadOnly {
623 readOnly = true
624 }
625 }
626 spec.Grant.ReadOnly = readOnly
627 spec.Grant.ProfileTools = profileTools
628
629 configModel, configEffort := "", ""
630 if profile != "" {
631 if t.profileConfigModel != nil {
632 configModel = t.profileConfigModel(profile)
633 }
634 if t.profileConfigEffort != nil {
635 configEffort = t.profileConfigEffort(profile)
636 }
637 }
638 spec.Worker.Model, spec.Worker.Effort = ResolveModelEffort(
639 configModel, configEffort,
640 model, effort,
641 profileModel, profileEffort,
642 t.subagentModel, t.subagentEffort,
643 )
644
645 if !readOnly {
646 // Every writer carries a claim. Omitting write_paths conservatively claims
647 // the whole workspace, including foreground task calls, so they cannot
648 // bypass an already-running background/fleet writer claim. Direct legacy
649 // TaskTool constructions without a workspace/scheduler keep their old
650 // no-claim behavior; production boot always configures both.
651 requireClaim := t.scheduler != nil || strings.TrimSpace(t.workspaceRoot) != "" || background || len(writePaths) > 0
652 claims, err := t.resolveWriterClaims(writePaths, requireClaim)
653 if err != nil {
654 return ProfileExecSpec{}, err
655 }
656 spec.Grant.WritePaths = claims
657 if requireClaim && claims.Empty() {
658 return ProfileExecSpec{}, fmt.Errorf("writer claim resolved empty")
659 }
660 } else if len(writePaths) > 0 {
661 return ProfileExecSpec{}, fmt.Errorf("write_paths is not valid for read-only tasks")
662 }
663 return spec, nil
664 }
665
666 func (t *TaskTool) resolveWriterClaims(writePaths []string, requireClaim bool) (WritePathSet, error) {
667 if len(writePaths) > 0 {
668 return NormalizeWritePaths(t.workspaceRoot, writePaths)
669 }
670 if !requireClaim {
671 return WritePathSet{}, nil
672 }
673 return WholeWorkspaceWriteClaim(t.workspaceRoot)
674 }
675
676 // RunProfileSpec executes a unified profile/task specification. Shared by task,
677 // fleet items, and boot-wired skill runners so prompt, tools, claims, and
678 // scheduling cannot drift across entry points.
679 func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (result string, err error) {
680 if t == nil {
681 return "", fmt.Errorf("task tool is not configured")
682 }
683 // Per-child progress tracker: converts the child's reasoning/text/notice/
684 // retrying into reserved ToolProgress previews and guarantees exactly one
685 // terminal status (completed/cancelled/failed). The background job owns
686 // finish after handoff; every other exit finishes here, including
687 // validation errors and panics.
688 trk := newSubagentProgressTracker(ctx, subSink(ctx))
689 backgroundHandoff := false
690 defer func() {
691 if backgroundHandoff {
692 return
693 }
694 if p := recover(); p != nil {
695 trk.finish(nil, fmt.Errorf("panic: %v", p))
696 panic(p)
697 }
698 trk.finish(ctx.Err(), err)
699 }()
700 if !spec.Sched.RunInBackground {
701 trk.running()
702 }
703 if strings.TrimSpace(spec.Task.Objective) == "" {
704 return "", fmt.Errorf("prompt is required")
705 }
706 if strings.TrimSpace(spec.Worker.SystemPrompt) == "" {
707 if spec.Worker.UseProfilePrompt {
708 return "", fmt.Errorf("profile system prompt is empty")
709 }
710 spec.Worker.SystemPrompt = t.sysPrompt
711 }
712
713 ctx, maxSteps := t.childMaxStepsForSpec(ctx, &spec)
714 childDepth, err := t.nextSubagentDepth(ctx)
715 if err != nil {
716 return "", err
717 }
718
719 toolNames, err := IntersectToolLists(t.parentReg, spec.Grant.ProfileTools, spec.Grant.CallTools)
720 if err != nil {
721 return "", err
722 }
723 subReg, childWriteRoots, err := t.buildSubagentRegistry(spec, toolNames, childDepth)
724 if err != nil {
725 return "", err
726 }
727
728 modelRef, effortRef := spec.Worker.Model, spec.Worker.Effort
729 usageModelRef := t.usageModelRef(modelRef, effortRef)
730 parentID, parentSink, _, _ := CallContext(ctx)
731 run, err := t.prepareTranscriptRunWithPrompt(ctx, subReg, modelRef, effortRef, spec.Context.parentSession(ctx), parentID, spec.Context.ContinueFrom, spec.Context.ForkFrom, spec.Worker.SystemPrompt, spec.Worker.Kind, spec.Worker.Name)
732 if err != nil {
733 return "", err
734 }
735 prov, pricing, ctxWin, err := t.resolveSubSessionRuntime(modelRef, effortRef)
736 if err != nil {
737 return t.failBeforeSubagentRelease(run, fmt.Errorf("sub-agent profile: %w", err))
738 }
739 lifecyclePhase := "child_created"
740 if strings.TrimSpace(spec.Context.ContinueFrom) != "" || strings.TrimSpace(spec.Context.ForkFrom) != "" {
741 lifecyclePhase = "child_resume"
742 }
743 emitSubagentLifecycle(parentSink, lifecyclePhase, parentID, spec.Worker.Name, usageModelRef, effortRef, run, nil)
744
745 isWriter := !spec.Grant.ReadOnly
746 acquireReq := AcquireRequest{
747 Writer: isWriter,
748 WritePaths: spec.Grant.WritePaths,
749 Nested: spec.Sched.Nested,
750 Label: firstNonEmpty(spec.Task.Description, spec.Worker.Name, "task"),
751 }
752 // Defensive fallback for callers that manually construct a background spec
753 // instead of going through buildTaskSpec.
754 if isWriter && spec.Grant.WritePaths.Empty() && spec.Sched.RunInBackground {
755 whole, werr := WholeWorkspaceWriteClaim(t.workspaceRoot)
756 if werr != nil {
757 return t.failBeforeSubagentRelease(run, werr)
758 }
759 acquireReq.WritePaths = whole
760 spec.Grant.WritePaths = whole
761 }
762
763 recoveryTaskID := subagentRecoveryTaskID(ctx, run.Ref)
764 backgroundWriter := (spec.Sched.RunInBackground || spec.Sched.BackgroundWriter) && !spec.Grant.ReadOnly
765 var mutationObserver *checkpoint.MutationObserver
766 if t.mutationObserver != nil {
767 turn := t.mutationObserver.OwnershipTurn()
768 mutationObserver = t.mutationObserver.CloneForSubagent(recoveryTaskID, turn, backgroundWriter)
769 }
770 runSession := func(runCtx context.Context, sink event.Sink, writerAlreadyRegistered bool) (string, error) {
771 if mutationObserver != nil && backgroundWriter && !writerAlreadyRegistered {
772 turn := mutationObserver.OwnershipTurn()
773 if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
774 return "", err
775 }
776 defer mutationObserver.UnregisterWriter(recoveryTaskID)
777 }
778 if spec.Grant.ReadOnly {
779 return t.runReadOnlySubSession(runCtx, composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver)
780 }
781 return t.runSubSession(WithSubagentWriteClaim(runCtx, spec.Grant.WritePaths), composeChildTaskPrompt(spec), subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver, childWriteRoots)
782 }
783
784 if spec.Sched.RunInBackground {
785 result, runErr, handedOff := t.runBackgroundProfileSpec(ctx, spec, run, trk, parentID, parentSink, usageModelRef, effortRef, runSession, acquireReq, mutationObserver, backgroundWriter, recoveryTaskID)
786 backgroundHandoff = handedOff
787 return result, runErr
788 }
789
790 // Foreground: acquire a slot (queue if needed), then run synchronously.
791 releaseSlot, claimID, err := t.acquireSlot(ctx, acquireReq)
792 if err != nil {
793 return t.failedSubagentResult(run, err)
794 }
795 defer releaseSlot()
796 defer run.Release()
797 ctx = WithSubagentClaimID(ctx, claimID)
798 emitSubagentLifecycle(parentSink, "child_running", parentID, spec.Worker.Name, usageModelRef, effortRef, run, nil)
799 answer, err := runSession(ctx, trk.wrap(), false)
800 if err != nil {
801 result, runErr := t.resolveAmbiguousSubagentFailure(ctx, run, spec.Task.Objective, usageModelRef, parentSink, err)
802 phase, outcome := terminalSubagentLifecycle(runErr)
803 emitSubagentLifecycle(parentSink, phase, parentID, spec.Worker.Name, usageModelRef, effortRef, run, outcome)
804 return result, runErr
805 }
806 if t.transcripts != nil && run.Ref != "" {
807 if err := t.transcripts.SaveCompleted(run); err != nil {
808 result, runErr := t.failedSubagentResult(run, err)
809 phase, outcome := terminalSubagentLifecycle(runErr)
810 emitSubagentLifecycle(parentSink, phase, parentID, spec.Worker.Name, usageModelRef, effortRef, run, outcome)
811 return result, runErr
812 }
813 emitSubagentLifecycle(parentSink, "child_completed", parentID, spec.Worker.Name, usageModelRef, effortRef, run, &SubagentOutcome{Status: SubagentOutcomeCompleted, FinalAnswer: answer})
814 return FormatSubagentRunResult(answer, run, false), nil
815 }
816 return GuardSubagentHostDecisionText(answer), nil
817 }
818
819 func (t *TaskTool) runBackgroundProfileSpec(ctx context.Context, spec ProfileExecSpec, run *SubagentRun, trk *subagentProgressTracker, parentID string, parentSink event.Sink, usageModelRef, effortRef string,
820 runSession func(context.Context, event.Sink, bool) (string, error), acquireReq AcquireRequest, mutationObserver *checkpoint.MutationObserver, backgroundWriter bool, recoveryTaskID string,
821 ) (string, error, bool) {
822 jm, ok := jobs.FromContext(ctx)
823 if !ok {
824 result, err := t.failBeforeSubagentRelease(run, fmt.Errorf("background execution is not available in this context"))
825 return result, err, false
826 }
827 var releaseStart func()
828 if t.scheduler == nil {
829 var running int
830 var okReserve bool
831 releaseStart, running, okReserve = jm.ReserveStartForSession(jobs.SessionFromContext(ctx), "task", maxConcurrentBackgroundTasks)
832 if !okReserve {
833 result, err := t.failBeforeSubagentRelease(run, fmt.Errorf("%d background tasks are already running for this session (limit %d); collect their results with job_output — or run this sub-task in the foreground — before starting more", running, maxConcurrentBackgroundTasks))
834 return result, err, false
835 }
836 defer releaseStart()
837 } else {
838 releaseStart = func() {}
839 }
840 label := firstNonEmpty(spec.Task.Description, spec.Worker.Name, "task")
841 if t.transcripts != nil && run != nil && run.Ref != "" {
842 if err := t.transcripts.MarkRunning(run); err != nil {
843 releaseStart()
844 result, saveErr := t.failBeforeSubagentRelease(run, err)
845 return result, saveErr, false
846 }
847 }
848 writerRegistered := false
849 if mutationObserver != nil && backgroundWriter {
850 turn := mutationObserver.OwnershipTurn()
851 if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
852 releaseStart()
853 result, saveErr := t.failBeforeSubagentRelease(run, err)
854 return result, saveErr, false
855 }
856 writerRegistered = true
857 }
858 parentSession := ParentSession(ctx)
859 backgroundEvidence := evidence.NewLedger()
860 slotReq := acquireReq
861 trk.queued()
862 job := jm.StartForSession(jobs.SessionFromContext(ctx), "task", label, func(jobCtx context.Context, _ io.Writer) (result string, err error) {
863 if writerRegistered {
864 defer mutationObserver.UnregisterWriter(recoveryTaskID)
865 }
866 jobCtx = WithParentSession(jobCtx, parentSession)
867 jobCtx = withInheritedHostConstraints(ctx, jobCtx)
868 jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence)
869 defer run.Release()
870 defer publishBackgroundEvidence(jobCtx, backgroundEvidence, t.workspaceRoot)
871 defer func() {
872 if r := recover(); r != nil {
873 panicErr := fmt.Errorf("internal error: panic: %v\n%s", r, debug.Stack())
874 result, err = t.failedSubagentResult(run, panicErr)
875 }
876 phase, outcome := terminalSubagentLifecycle(err)
877 emitSubagentLifecycle(parentSink, phase, parentID, spec.Worker.Name, usageModelRef, effortRef, run, outcome)
878 trk.finish(jobCtx.Err(), err)
879 }()
880 releaseSlot, claimID, slotErr := t.acquireSlot(jobCtx, slotReq)
881 if slotErr != nil {
882 return t.failedSubagentResult(run, slotErr)
883 }
884 defer releaseSlot()
885 jobCtx = WithSubagentClaimID(jobCtx, claimID)
886 trk.running()
887 emitSubagentLifecycle(parentSink, "child_running", parentID, spec.Worker.Name, usageModelRef, effortRef, run, nil)
888 answer, err := runSession(jobCtx, trk.wrap(), writerRegistered)
889 if err != nil {
890 return t.resolveAmbiguousSubagentFailure(jobCtx, run, spec.Task.Objective, usageModelRef, parentSink, err)
891 }
892 if err := t.transcripts.SaveCompleted(run); err != nil {
893 return t.failedSubagentResult(run, err)
894 }
895 return FormatSubagentRunResult(answer, run, false), nil
896 })
897 releaseStart()
898 queuedNote := ""
899 if t.scheduler != nil {
900 queuedNote = " It may wait in the session queue until a concurrency/write slot is free."
901 }
902 if run != nil && run.Ref != "" {
903 return fmt.Sprintf("Started background task %q (%s).%s\n%s\nIt runs across turns; collect its final answer with job_output, and you'll be notified when it finishes.", job.ID, label, queuedNote, FormatSubagentReference(run)), nil, true
904 }
905 return fmt.Sprintf("Started background task %q (%s).%s It runs across turns; collect its final answer with job_output, and you'll be notified when it finishes.", job.ID, label, queuedNote), nil, true
906 }
907
908 func (t *TaskTool) acquireSlot(ctx context.Context, req AcquireRequest) (func(), int64, error) {
909 noop := func() {}
910 if t.scheduler == nil {
911 return noop, 0, nil
912 }
913 return t.scheduler.AcquireWithID(ctx, req)
914 }
915
916 func (t *TaskTool) bashCanEnforceWriteRoots() bool {
917 if t != nil && t.bashSandboxEnforced != nil {
918 return t.bashSandboxEnforced()
919 }
920 return false
921 }
922
923 func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
924 continueFrom = strings.TrimSpace(continueFrom)
925 legacyForkFrom = strings.TrimSpace(legacyForkFrom)
926 parentSession = strings.TrimSpace(parentSession)
927 if continueFrom != "" && legacyForkFrom != "" {
928 return nil, fmt.Errorf("continue_from and fork_from are mutually exclusive; pass only continue_from")
929 }
930 if t.transcripts == nil {
931 return nil, fmt.Errorf("subagent transcript store is required")
932 }
933 if systemPrompt == "" {
934 systemPrompt = t.sysPrompt
935 }
936 if kind == "" {
937 kind = "task"
938 }
939 if name == "" {
940 name = "task"
941 }
942 if parentSession == "" {
943 if continueFrom != "" || legacyForkFrom != "" {
944 return nil, fmt.Errorf("subagent continuation requires a persisted session; none is active in this run")
945 }
946 return EphemeralSubagentRun(systemPrompt), nil
947 }
948 identityModel, identityEffort := t.effectiveIdentity(modelRef, effortRef)
949 spec := SubagentSpec{
950 Kind: kind,
951 Name: name,
952 WorkspaceRoot: t.workspaceRoot,
953 ParentSession: parentSession,
954 ParentToolCallID: parentID,
955 SystemPrompt: systemPrompt,
956 Registry: subReg,
957 ToolContext: childToolIdentityContext(ctx),
958 Model: identityModel,
959 Effort: identityEffort,
960 ResumedFrom: firstNonEmpty(continueFrom, legacyForkFrom),
961 }
962 if continueFrom != "" {
963 return t.transcripts.PrepareContinue(continueFrom, spec)
964 }
965 if legacyForkFrom != "" {
966 return t.transcripts.PrepareLegacyForkFrom(legacyForkFrom, spec)
967 }
968 return t.transcripts.PrepareFresh(spec)
969 }
970
971 func childToolIdentityContext(ctx context.Context) context.Context {
972 ctx = tool.WithoutGoalLifecycle(ctx)
973 ctx = memory.WithoutQueue(ctx)
974 ctx = jobs.WithoutManager(ctx)
975 return planmode.WithActive(ctx, PlanModeFromContext(ctx))
976 }
977
978 func (t *TaskTool) effectiveIdentity(modelRef, effort string) (string, string) {
979 if t.identityProfile != nil {
980 model, eff := t.identityProfile(modelRef, effort)
981 return strings.TrimSpace(model), strings.TrimSpace(eff)
982 }
983 return t.effectiveModelIdentity(modelRef), t.effectiveEffortIdentity(effort)
984 }
985
986 // usageModelRef returns the canonical provider/model identity of the runtime
987 // selected for a child. The resolver expands aliases and supplies the parent
988 // model when no child override is configured.
989 func (t *TaskTool) usageModelRef(modelRef, effort string) string {
990 model, _ := t.effectiveIdentity(modelRef, effort)
991 if model != "" {
992 return model
993 }
994 return firstNonEmpty(modelRef, t.baseModel, t.subagentModel)
995 }
996
997 func (t *TaskTool) effectiveModelIdentity(modelRef string) string {
998 if strings.TrimSpace(modelRef) != "" {
999 return strings.TrimSpace(modelRef)
1000 }
1001 return strings.TrimSpace(t.baseModel)
1002 }
1003
1004 func (t *TaskTool) effectiveEffortIdentity(effort string) string {
1005 if strings.TrimSpace(effort) != "" {
1006 return strings.TrimSpace(effort)
1007 }
1008 return strings.TrimSpace(t.baseEffort)
1009 }
1010
1011 // buildSubReg returns the sub-agent's tool set: the named whitelist (minus
1012 // unavailable sub-agent tools), or every parent tool except those tools.
1013 func (t *TaskTool) buildSubReg(names []string, childDepth int) *tool.Registry {
1014 return SubagentToolRegistryForDepthWithRuntime(t.parentReg, names, childDepth, t.maxDepth(), t.capabilityRuntime)
1015 }
1016
1017 func (t *TaskTool) maxDepth() int {
1018 if t == nil {
1019 return DefaultMaxSubagentDepth
1020 }
1021 if t.maxSubagentDepth == 0 {
1022 return DefaultMaxSubagentDepth
1023 }
1024 return NormalizeMaxSubagentDepth(t.maxSubagentDepth)
1025 }
1026
1027 func (t *TaskTool) nextSubagentDepth(ctx context.Context) (int, error) {
1028 current := SubagentDepth(ctx)
1029 next := current + 1
1030 maxDepth := t.maxDepth()
1031 if next > maxDepth {
1032 return 0, fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxDepth)
1033 }
1034 return next, nil
1035 }
1036
1037 // FilterRegistry builds a sub-registry from parent: the named whitelist (empty =
1038 // every parent tool), minus any excluded names. Used to scope what a spawned
1039 // sub-agent — a `task` sub-agent or a subagent skill — may call, e.g. excluding
1040 // `task` to bar recursive nesting, or restricting to a skill's allowed-tools.
1041 // Direct MCP tools may be copied here; callers that need a stable MCP surface
1042 // should strip them and attach use_capability via attachSubagentCapabilityProxy.
1043 func FilterRegistry(parent *tool.Registry, names []string, exclude ...string) *tool.Registry {
1044 sub := tool.NewRegistry()
1045 if parent == nil {
1046 return sub
1047 }
1048 ex := make(map[string]bool, len(exclude))
1049 for _, e := range exclude {
1050 ex[e] = true
1051 }
1052 customAllowlist := len(names) > 0
1053 src := normalizeSubagentShellNames(parent, names)
1054 if !customAllowlist {
1055 src = parent.Names()
1056 } else {
1057 src = expandToolPatterns(parent, src)
1058 }
1059 for _, name := range src {
1060 if ex[name] || retiredTool(name) {
1061 continue
1062 }
1063 // MCP never enters through the generic filter when named as capability
1064 // ids; model-visible mcp__* may still be listed for conversion later.
1065 if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
1066 continue
1067 }
1068 tl, ok := parent.Get(name)
1069 if !ok {
1070 continue
1071 }
1072 sub.Add(tl)
1073 }
1074 return sub
1075 }
1076
1077 // stripDirectMCPTools removes provider-visible mcp__* tools so sub-agents use
1078 // only the stable use_capability proxy for MCP.
1079 func stripDirectMCPTools(reg *tool.Registry) {
1080 if reg == nil {
1081 return
1082 }
1083 for _, name := range append([]string(nil), reg.Names()...) {
1084 if strings.HasPrefix(name, tool.MCPNamePrefix) {
1085 reg.RemovePrefix(name)
1086 }
1087 }
1088 }
1089
1090 // restrictedCapabilityProxy preserves a subagent allowed-tools boundary when
1091 // MCP is available only through use_capability. The pseudo mcp-tool: and
1092 // mcp-server: entries never become provider tools; they select one proxy schema
1093 // whose resolver rejects every capability outside the exact allowlist.
1094 //
1095 // Provider-visible name/description/schema stay identical to the unrestricted
1096 // proxy so allowlist expansion never changes the child cache prefix. Allowlist
1097 // enforcement is host-local (check + filtered list results).
1098 type restrictedCapabilityProxy struct {
1099 tool.Tool
1100 resolver tool.CallResolver
1101 allowed map[string]bool
1102 // servers is the set of MCP server names implied by allowed IDs; list
1103 // results are filtered to this set so profile isolation covers discovery.
1104 servers map[string]bool
1105 }
1106
1107 func (t *restrictedCapabilityProxy) ClassifyCall(args json.RawMessage) tool.CallClass {
1108 if t == nil || t.check(args) != nil {
1109 return tool.CallClass{}
1110 }
1111 classifier, ok := t.Tool.(tool.BatchClassifier)
1112 if !ok {
1113 return tool.CallClass{}
1114 }
1115 return classifier.ClassifyCall(args)
1116 }
1117
1118 // Description is fixed: never embed dynamic capability IDs (they change with
1119 // MCP install/tool-list and would break the stable provider tool prefix).
1120 func (t *restrictedCapabilityProxy) Description() string {
1121 return t.Tool.Description()
1122 }
1123
1124 func (t *restrictedCapabilityProxy) check(args json.RawMessage) error {
1125 var p struct {
1126 Action string `json:"action"`
1127 CapabilityID string `json:"capability_id"`
1128 }
1129 if err := json.Unmarshal(args, &p); err != nil {
1130 return fmt.Errorf("invalid args: %w", err)
1131 }
1132 action := strings.ToLower(strings.TrimSpace(p.Action))
1133 if action == "list" || action == "search" {
1134 return nil
1135 }
1136 id := strings.TrimSpace(p.CapabilityID)
1137 if id == "" {
1138 return fmt.Errorf("capability_id is required")
1139 }
1140 if id == sessionToolResultCapabilityID {
1141 return nil
1142 }
1143 if !t.allowed[id] {
1144 return fmt.Errorf("capability %q is outside this subagent's allowed-tools", id)
1145 }
1146 return nil
1147 }
1148
1149 func (t *restrictedCapabilityProxy) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
1150 if err := t.check(args); err != nil {
1151 return tool.ResolvedCall{}, err
1152 }
1153 rc, err := t.resolver.ResolveCall(ctx, args)
1154 if err != nil {
1155 return rc, err
1156 }
1157 var p struct {
1158 Action string `json:"action"`
1159 }
1160 _ = json.Unmarshal(args, &p)
1161 action := strings.ToLower(strings.TrimSpace(p.Action))
1162 if rc.SkipExecute {
1163 switch action {
1164 case "list":
1165 rc.Result = filterCapabilityListResult(rc.Result, t.servers)
1166 case "search":
1167 rc.Result = filterCapabilitySearchResult(rc.Result, t.allowed)
1168 }
1169 }
1170 return rc, nil
1171 }
1172
1173 func (t *restrictedCapabilityProxy) Execute(ctx context.Context, args json.RawMessage) (string, error) {
1174 if err := t.check(args); err != nil {
1175 return "", err
1176 }
1177 out, err := t.Tool.Execute(ctx, args)
1178 if err != nil {
1179 return out, err
1180 }
1181 var p struct {
1182 Action string `json:"action"`
1183 }
1184 _ = json.Unmarshal(args, &p)
1185 switch strings.ToLower(strings.TrimSpace(p.Action)) {
1186 case "list":
1187 return filterCapabilityListResult(out, t.servers), nil
1188 case "search":
1189 return filterCapabilitySearchResult(out, t.allowed), nil
1190 }
1191 return out, nil
1192 }
1193
1194 // validMCPServerCapabilityID accepts mcp-server:<non-empty-name> only.
1195 func validMCPServerCapabilityID(id string) (server string, ok bool) {
1196 return tool.ParseMCPServerReference(id)
1197 }
1198
1199 // validMCPToolCapabilityID accepts mcp-tool:<server>/<tool> with both parts non-empty.
1200 func validMCPToolCapabilityID(id string) (server, raw string, ok bool) {
1201 return tool.ParseMCPToolReference(id)
1202 }
1203
1204 func serversFromCapabilityAllowlist(allowed map[string]bool) map[string]bool {
1205 servers := map[string]bool{}
1206 for id := range allowed {
1207 id = strings.TrimSpace(id)
1208 if server, ok := validMCPServerCapabilityID(id); ok {
1209 servers[server] = true
1210 continue
1211 }
1212 if server, _, ok := validMCPToolCapabilityID(id); ok {
1213 servers[server] = true
1214 }
1215 }
1216 return servers
1217 }
1218
1219 // attachSubagentCapabilityProxy installs a per-agent use_capability frontend.
1220 // Any parent-copied proxy is replaced so children never share Executor ledger
1221 // state. No allowlist → full proxy. Explicit allowlist with MCP names →
1222 // restricted proxy. Explicit "use_capability" → full proxy. Explicit allowlist
1223 // without MCP entries → no proxy.
1224 func attachSubagentCapabilityProxy(parent, sub *tool.Registry, names []string, runtime *MCPCapabilityRuntime) {
1225 if sub == nil {
1226 return
1227 }
1228 // Drop any provider-copied use_capability so we always install an isolated
1229 // frontend (shared Host/runtime, independent ledger/audit).
1230 if _, ok := sub.Get("use_capability"); ok {
1231 sub.RemovePrefix("use_capability")
1232 }
1233 frontend := newSubagentCapabilityFrontend(parent, runtime)
1234 if frontend == nil {
1235 return
1236 }
1237 if len(names) == 0 || allowlistRequestsUnrestrictedProxy(names) {
1238 sub.Add(frontend)
1239 return
1240 }
1241 allowed := mcpCapabilityAllowlist(parent, names)
1242 if len(allowed) == 0 {
1243 // Custom allowlist with no valid MCP entries: do not expose the proxy.
1244 return
1245 }
1246 servers := serversFromCapabilityAllowlist(allowed)
1247 if len(servers) == 0 {
1248 // Incomplete capability IDs produced an empty server set: fail closed
1249 // rather than installing a restricted proxy that would list everything.
1250 return
1251 }
1252 resolver, ok := frontend.(tool.CallResolver)
1253 if !ok {
1254 return
1255 }
1256 sub.Add(&restrictedCapabilityProxy{
1257 Tool: frontend,
1258 resolver: resolver,
1259 allowed: allowed,
1260 servers: servers,
1261 })
1262 }
1263
1264 func newSubagentCapabilityFrontend(parent *tool.Registry, runtime *MCPCapabilityRuntime) tool.Tool {
1265 if runtime != nil {
1266 return runtime.NewFrontend(nil, nil)
1267 }
1268 if parent == nil {
1269 return nil
1270 }
1271 inner, ok := parent.Get("use_capability")
1272 if !ok {
1273 return nil
1274 }
1275 return cloneCapabilityFrontend(inner)
1276 }
1277
1278 // mcpCapabilityAllowlist converts profile/call tool names into capability IDs
1279 // for the restricted use_capability proxy. Accepts complete mcp-tool:<s>/<t>,
1280 // mcp-server:<s>, model-visible mcp__* names, and wildcards expanded against
1281 // the parent. Incomplete prefixes such as "mcp-server:" or "mcp-tool:foo" are
1282 // rejected so they cannot install a restricted proxy with an empty server set.
1283 func mcpCapabilityAllowlist(parent *tool.Registry, names []string) map[string]bool {
1284 if len(names) == 0 {
1285 return nil
1286 }
1287 expanded := names
1288 if parent != nil {
1289 expanded = expandToolPatterns(parent, names)
1290 }
1291 allowed := map[string]bool{}
1292 for _, name := range expanded {
1293 name = strings.TrimSpace(name)
1294 switch {
1295 case name == "use_capability":
1296 // Explicit proxy grant is handled as a full frontend by the caller
1297 // when this is the only MCP-related entry; leave empty here so a
1298 // bare use_capability allowlist entry still installs unrestricted.
1299 continue
1300 case strings.HasPrefix(name, "mcp-server:"):
1301 if server, ok := validMCPServerCapabilityID(name); ok {
1302 allowed["mcp-server:"+server] = true
1303 }
1304 case strings.HasPrefix(name, "mcp-tool:"):
1305 if server, raw, ok := validMCPToolCapabilityID(name); ok {
1306 allowed["mcp-tool:"+server+"/"+raw] = true
1307 }
1308 default:
1309 if parent != nil {
1310 if tl, ok := parent.Get(name); ok {
1311 if m, ok := tl.(tool.MCPMetadata); ok {
1312 server := strings.TrimSpace(m.MCPServerName())
1313 raw := strings.TrimSpace(m.MCPRawToolName())
1314 if server != "" && raw != "" {
1315 allowed["mcp-tool:"+server+"/"+raw] = true
1316 continue
1317 }
1318 }
1319 }
1320 }
1321 if server, raw, ok := tool.SplitMCPName(name); ok {
1322 allowed["mcp-tool:"+server+"/"+raw] = true
1323 }
1324 }
1325 }
1326 return allowed
1327 }
1328
1329 func allowlistRequestsUnrestrictedProxy(names []string) bool {
1330 for _, name := range names {
1331 if strings.TrimSpace(name) == "use_capability" {
1332 return true
1333 }
1334 }
1335 return false
1336 }
1337
1338 // ReadOnlySubagentToolRegistry returns the tool set exposed to read-only
1339 // sub-agents: read-only research tools plus a bash wrapper that enforces the
1340 // permission-layer read-only command policy at execution time. Workflow/meta tools are
1341 // excluded even when their Tool.ReadOnly contract is true.
1342 func ReadOnlySubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
1343 return ReadOnlySubagentToolRegistryForDepth(parent, names, 1, 1)
1344 }
1345
1346 // ReadOnlySubagentToolRegistryForDepth returns the tool set exposed to read-only
1347 // subagents. It permits only read-only delegation tools while another depth
1348 // layer is available. Direct mcp__* schemas are never exposed; MCP goes only
1349 // through use_capability. Dynamic execution still requires authorized server +
1350 // readOnlyHint + non-destructive (enforced by ReadOnlyExecution), so strict
1351 // agents share the stable proxy schema and connection reuse without permission
1352 // relaxation.
1353 //
1354 // Custom profile/call allowlists remain authoritative and convert MCP names
1355 // into a capability-id allowlist on a restricted proxy.
1356 func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
1357 return ReadOnlySubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
1358 }
1359
1360 // ReadOnlySubagentToolRegistryForDepthWithRuntime is the read-only registry
1361 // builder with an optional session MCP runtime for proxy injection.
1362 func ReadOnlySubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
1363 exclude := append([]string(nil), subagentAlwaysHiddenTools...)
1364 if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
1365 exclude = append(exclude, subagentRecursiveTools...)
1366 } else {
1367 exclude = append(exclude, "task", "run_skill", "explore", "research", "review", "security_review")
1368 }
1369 exclude = append(exclude, subagentJobTools...)
1370 exclude = append(exclude, plannerNonResearchTools...)
1371 exclude = append(exclude, readOnlySubagentWorkflowTools...)
1372 ex := make(map[string]bool, len(exclude))
1373 for _, e := range exclude {
1374 ex[e] = true
1375 }
1376 sub := tool.NewRegistry()
1377 if parent == nil {
1378 return sub
1379 }
1380 src := normalizeSubagentShellNames(parent, names)
1381 if len(src) == 0 {
1382 src = parent.Names()
1383 } else {
1384 src = expandToolPatterns(parent, src)
1385 }
1386 for _, name := range src {
1387 if ex[name] || retiredTool(name) {
1388 continue
1389 }
1390 if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
1391 continue
1392 }
1393 tl, ok := parent.Get(name)
1394 if !ok {
1395 continue
1396 }
1397 _, parentHasPwsh := parent.Get("pwsh")
1398 if name == "bash" && parentHasPwsh {
1399 continue
1400 }
1401 if name == "bash" || name == "pwsh" {
1402 sub.Add(readOnlyBash{inner: tl})
1403 continue
1404 }
1405 // Direct MCP never enters the strict registry — use_capability only.
1406 if isInstalledMCPTool(tl) || strings.HasPrefix(name, tool.MCPNamePrefix) {
1407 continue
1408 }
1409 if !tl.ReadOnly() {
1410 continue
1411 }
1412 sub.Add(tl)
1413 }
1414 attachSubagentCapabilityProxy(parent, sub, names, runtime)
1415 return sub
1416 }
1417
1418 // expandToolPatterns resolves explicit wildcard allowlist entries from imported
1419 // agent profiles against the current registry. Expansion is deterministic and
1420 // session-local, so optional MCP tools only enter a child after connection.
1421 func expandToolPatterns(parent *tool.Registry, names []string) []string {
1422 if parent == nil {
1423 return nil
1424 }
1425 available := parent.Names()
1426 seen := map[string]bool{}
1427 out := make([]string, 0, len(names))
1428 for _, name := range names {
1429 if !strings.ContainsAny(name, "*?[") {
1430 if !seen[name] {
1431 seen[name] = true
1432 out = append(out, name)
1433 }
1434 continue
1435 }
1436 for _, candidate := range available {
1437 matched, err := filepath.Match(name, candidate)
1438 if err == nil && matched && !seen[candidate] {
1439 seen[candidate] = true
1440 out = append(out, candidate)
1441 }
1442 }
1443 }
1444 return out
1445 }
1446
1447 // FilterReadOnlyRegistry builds a sub-registry containing only tools whose
1448 // ReadOnly contract is true, minus explicit exclusions. MCP tools must
1449 // additionally come from an authorized server and must not carry
1450 // destructiveHint.
1451 func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Registry {
1452 ex := make(map[string]bool, len(exclude))
1453 for _, e := range exclude {
1454 ex[e] = true
1455 }
1456 sub := tool.NewRegistry()
1457 if parent == nil {
1458 return sub
1459 }
1460 for _, name := range parent.Names() {
1461 if ex[name] || retiredTool(name) {
1462 continue
1463 }
1464 tl, ok := parent.Get(name)
1465 if !ok || !tl.ReadOnly() {
1466 continue
1467 }
1468 if isInstalledMCPTool(tl) && (!mcpServerAuthorized(tl) || mcpDestructiveHint(tl)) {
1469 continue
1470 }
1471 sub.Add(tl)
1472 }
1473 return sub
1474 }
1475
1476 func (t *TaskTool) resolveSubSessionRuntime(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
1477 prov, pricing, ctxWin := t.prov, t.pricing, t.contextWindow
1478 if t.resolveProvider != nil && (modelRef != "" || effort != "") {
1479 p, pr, cw, err := t.resolveProvider(modelRef, effort)
1480 if err != nil {
1481 return nil, nil, 0, err
1482 }
1483 prov, pricing, ctxWin = p, pr, cw
1484 }
1485 return prov, pricing, ctxWin, nil
1486 }
1487
1488 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, writeRoots *sandbox.WritableRootSet) (string, error) {
1489 opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
1490 if writeRoots != nil {
1491 opts.WriteRoots = writeRoots
1492 }
1493 opts.ModelRef = modelRef
1494 // Capture the pristine task before host framing is prepended: delivery
1495 // intent classification must judge the task, not the wrapper.
1496 opts.ClassifierTaskText = prompt
1497 prompt = t.withWorkspaceContext(prompt) + "\n\n" + completeSubtaskContract
1498 // The child provider owns the final vision decision. Text-only providers
1499 // retain the attachment metadata but omit image parts during serialization.
1500 ctx = withSubagentTurnImages(ctx)
1501 return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
1502 }
1503
1504 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) {
1505 opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
1506 opts.ModelRef = modelRef
1507 // Capture the pristine task before host framing is prepended: delivery
1508 // intent classification must judge the task, not the wrapper.
1509 opts.ClassifierTaskText = prompt
1510 prompt = t.withWorkspaceContext(prompt)
1511 ctx = withSubagentTurnImages(ctx)
1512 return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
1513 }
1514
1515 func subagentRecoveryTaskID(ctx context.Context, ref string) string {
1516 if ref = strings.TrimSpace(ref); ref != "" {
1517 return "subagent:" + ref
1518 }
1519 if callID, _, _, ok := CallContext(ctx); ok && strings.TrimSpace(callID) != "" {
1520 return "subagent:" + strings.TrimSpace(callID)
1521 }
1522 return "subagent"
1523 }
1524
1525 func (t *TaskTool) WithWriteRoots(set *sandbox.WritableRootSet) *TaskTool {
1526 if t == nil {
1527 return nil
1528 }
1529 t.writeRoots = set
1530 return t
1531 }
1532
1533 func (t *TaskTool) WithRecoveryGate(g RecoveryGate) *TaskTool {
1534 // Retired source-compatible option. Sub-agents inherit execution facts but
1535 // never an Auto Guard admission policy.
1536 return t
1537 }
1538
1539 // WithMutationObserver shares the host mutation observer with spawned sub-agents.
1540 // Foreground children inherit the parent ownership turn; background children
1541 // keep the turn that spawned them (set via OwnershipTurn at Begin).
1542 func (t *TaskTool) WithMutationObserver(obs *checkpoint.MutationObserver) *TaskTool {
1543 if t == nil {
1544 return nil
1545 }
1546 t.mutationObserver = obs
1547 return t
1548 }
1549
1550 func (t *TaskTool) withWorkspaceContext(prompt string) string {
1551 if t == nil {
1552 return prompt
1553 }
1554 ctx := subagentWorkspaceContext(t.workspaceRoot)
1555 if ctx == "" {
1556 return prompt
1557 }
1558 return ctx + "\n\n" + prompt
1559 }
1560
1561 func subagentWorkspaceContext(root string) string {
1562 root = strings.TrimSpace(root)
1563 if root == "" {
1564 return ""
1565 }
1566 // Wording note: avoid incidental action verbs ("resolve", "fix", …) in this
1567 // host framing — it is prepended to every sub-agent prompt and must never
1568 // read as task intent (see classifierTaskText, which also strips it).
1569 return `<workspace-context event="SubagentWorkspace">
1570 Current workspace: ` + strconv.Quote(root) + `
1571 File tools interpret relative paths against this workspace. For project inspection, prefer "." or relative paths unless the user explicitly named another absolute path.
1572 </workspace-context>`
1573 }
1574
1575 func FormatSubagentReference(run *SubagentRun) string {
1576 if run == nil || run.Ref == "" {
1577 return ""
1578 }
1579 var b strings.Builder
1580 fmt.Fprintf(&b, "Subagent reference: %s\n", run.Ref)
1581 if strings.TrimSpace(run.ForkedFrom) != "" {
1582 fmt.Fprintf(&b, "Forked from: %s\n", strings.TrimSpace(run.ForkedFrom))
1583 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 ")
1584 b.WriteString(run.Ref)
1585 b.WriteString(" as `continue_from`. Start a fresh subagent when the next task is independent.")
1586 return b.String()
1587 }
1588 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.")
1589 return b.String()
1590 }
1591
1592 // GuardSubagentHostDecisionText appends a fixed boundary warning only when a
1593 // child agent result appears to discuss host approval or user-owned decisions.
1594 // The implementation lives in internal/tool so the skill tools share the exact
1595 // same phrase list and notice.
1596 func GuardSubagentHostDecisionText(answer string) string {
1597 return tool.GuardSubagentHostDecisionText(answer)
1598 }
1599
1600 // RunSubAgentWithSession continues an existing sub-agent session with prompt and
1601 // returns the latest final assistant answer. Fresh sub-agents pass a newly-created
1602 // session; continued sub-agents pass a loaded transcript session.
1603 //
1604 // Each call installs an independent session-private temporary directory Manager
1605 // so parent, sibling, and nested sub-agents never share temporary files.
1606 // continue_from restores conversation history only; each run gets a fresh temp dir.
1607 func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
1608 if sess == nil {
1609 return "", fmt.Errorf("sub-agent session is nil")
1610 }
1611 ctx = WithoutTurnContextBundle(ctx)
1612 // Isolate temporary files for this run before any tool execution.
1613 ctx = tool.WithoutGoalLifecycle(ctx)
1614 if opts.MemoryQueue != nil {
1615 ctx = memory.WithQueue(ctx, opts.MemoryQueue)
1616 } else {
1617 ctx = memory.WithoutQueue(ctx)
1618 }
1619 if opts.Jobs == nil {
1620 ctx = jobs.WithoutManager(ctx)
1621 }
1622 ctx, releaseTemp := withSubagentSessionTemp(ctx)
1623 defer releaseTemp()
1624 opts.SessionTemp = sessiontemp.FromContext(ctx)
1625 if opts.SubagentDepth > 0 {
1626 ctx = WithSubagentDepth(ctx, opts.SubagentDepth)
1627 }
1628 // Callers that wrap the prompt themselves (runSubSession) set
1629 // ClassifierTaskText before wrapping; for everyone else the prompt is
1630 // still pristine here, so capture it before host framing is prepended.
1631 if strings.TrimSpace(opts.ClassifierTaskText) == "" {
1632 opts.ClassifierTaskText = prompt
1633 }
1634 planWorkflow := PlanModeFromContext(ctx)
1635 if opts.SubagentDepth > 0 && isFreshSubagentSession(sess) {
1636 prompt = subagentStartContext + "\n\n" + prompt
1637 }
1638 if planWorkflow && !strings.Contains(prompt, planmode.Marker) {
1639 prompt = planmode.Marker + "\n\n" + prompt
1640 }
1641 opts.RequireReviewReportKind = ""
1642 // Nested reasoning stays isolated; the parent consumes only final Content.
1643 // Require it so a reasoning-only stop cannot fall back to older tool text.
1644 opts.RequireVisibleFinal = true
1645 sub := New(prov, reg, sess, opts, sink)
1646 sub.SetPlanMode(planWorkflow)
1647 if err := sub.Run(ctx, prompt); err != nil {
1648 // Preserve actual partial child execution even when the child fails.
1649 mergeChildEvidence(ctx, sub)
1650 return "", fmt.Errorf("sub-agent: %w", err)
1651 }
1652 mergeChildEvidence(ctx, sub)
1653 if answer := latestAssistantAnswer(sess); answer != "" {
1654 return composeSubagentAnswer(ctx, answer, sub, SubagentWriteClaim(ctx), opts.ClassifierTaskText), nil
1655 }
1656 return "", fmt.Errorf("sub-agent finished without producing a final answer")
1657 }
1658
1659 // readOnlyAgentConstruction is the single pairing every strictly read-only
1660 // loop shares: the permanent ReadOnlyExecution flag plus the final registry
1661 // filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
1662 // that still use NewReadOnlyAgent build through it, so a missed call site
1663 // cannot set only half the boundary. The interactive two-model planner uses
1664 // NewPlannerAgent instead (PlannerMCPExecution).
1665 func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
1666 opts.ReadOnlyExecution = true
1667 opts.PlannerMCPExecution = false
1668 return strictReadOnlyExecutionRegistry(reg), opts
1669 }
1670
1671 // NewReadOnlyAgent constructs a long-lived, strictly read-only agent through
1672 // the shared construction boundary. Prefer NewPlannerAgent for the two-model
1673 // planner so authorized non-destructive MCP can run via use_capability.
1674 func NewReadOnlyAgent(prov provider.Provider, reg *tool.Registry, sess *Session, opts Options, sink event.Sink) *Agent {
1675 reg, opts = readOnlyAgentConstruction(reg, opts)
1676 return New(prov, reg, sess, opts, sink)
1677 }
1678
1679 // NewPlannerAgent constructs the interactive two-model planner: permanent
1680 // ReadOnlyExecution still blocks bash, file writers, and ordinary non-MCP
1681 // writers, while PlannerMCPExecution allows authorized, non-destructive MCP
1682 // through the stable use_capability proxy without requiring readOnlyHint.
1683 func NewPlannerAgent(prov provider.Provider, reg *tool.Registry, sess *Session, opts Options, sink event.Sink) *Agent {
1684 opts.ReadOnlyExecution = true
1685 opts.PlannerMCPExecution = true
1686 // The coordinator needs visible plan text to hand off to the executor;
1687 // reasoning shown in a frontend is not a substitute for that contract.
1688 opts.RequireVisibleFinal = true
1689 // Keep construction-time filter for ordinary tools; use_capability stays
1690 // because it is ReadOnly. Direct mcp__* tools are already excluded by
1691 // PlannerToolRegistry. Dynamic MCP targets are re-checked after resolve.
1692 reg = plannerExecutionRegistry(reg)
1693 return New(prov, reg, sess, opts, sink)
1694 }
1695
1696 // plannerExecutionRegistry is the construction-time filter for NewPlannerAgent.
1697 // It removes ordinary writers and destructive direct MCP tools while keeping
1698 // use_capability and built-in research tools. Host-starting deferred MCP
1699 // targets are allowed at execution time under PlannerMCPExecution.
1700 func plannerExecutionRegistry(reg *tool.Registry) *tool.Registry {
1701 filtered := tool.NewRegistry()
1702 if reg == nil {
1703 return filtered
1704 }
1705 for _, name := range reg.Names() {
1706 target, ok := reg.Get(name)
1707 if !ok {
1708 continue
1709 }
1710 if name == "use_capability" {
1711 filtered.Add(target)
1712 continue
1713 }
1714 if strings.HasPrefix(name, tool.MCPNamePrefix) {
1715 // Defense in depth: planner never exposes direct MCP schemas.
1716 continue
1717 }
1718 if !target.ReadOnly() || mcpDestructiveHint(target) {
1719 continue
1720 }
1721 if h, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() {
1722 // Ordinary host mutations stay out; MCP startup is only via proxy.
1723 continue
1724 }
1725 filtered.Add(target)
1726 }
1727 return filtered
1728 }
1729
1730 // RunReadOnlySubAgentWithSession is the construction boundary for every
1731 // strictly read-only child loop. Registry filtering limits the visible surface;
1732 // this permanent execution flag also re-checks targets resolved dynamically by
1733 // proxy tools such as use_capability. It never enables PlannerMCPExecution.
1734 func RunReadOnlySubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
1735 reg, opts = readOnlyAgentConstruction(reg, opts)
1736 return RunSubAgentWithSession(ctx, prov, reg, sess, prompt, opts, sink)
1737 }
1738
1739 // strictReadOnlyExecutionRegistry is the final construction-time filter shared
1740 // by every strict child. Callers still apply role-specific filtering (review,
1741 // planner, profile allowlists), while this layer guarantees that a missed call
1742 // site cannot expose writers, destructive MCP tools, readers from unauthorized
1743 // servers, or an unauthorized host-starting target to the model.
1744 func strictReadOnlyExecutionRegistry(reg *tool.Registry) *tool.Registry {
1745 filtered := tool.NewRegistry()
1746 if reg == nil {
1747 return filtered
1748 }
1749 for _, name := range reg.Names() {
1750 target, ok := reg.Get(name)
1751 if retiredTool(name) || !ok || !target.ReadOnly() || mcpDestructiveHint(target) {
1752 continue
1753 }
1754 if isInstalledMCPTool(target) && !mcpServerAuthorized(target) {
1755 continue
1756 }
1757 if mutation, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && mutation.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(target) {
1758 continue
1759 }
1760 filtered.Add(target)
1761 }
1762 return filtered
1763 }
1764
1765 // latestAssistantAnswer walks the session backwards for the last assistant
1766 // message with content — that's the sub-agent's final answer. Intermediate
1767 // assistant messages with tool_calls but no text don't count.
1768 func latestAssistantAnswer(sess *Session) string {
1769 if sess == nil {
1770 return ""
1771 }
1772 for _, v := range slices.Backward(sess.Messages) {
1773 m := v
1774 if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
1775 return m.Content
1776 }
1777 }
1778 return ""
1779 }
1780
1781 // mergeChildEvidence folds a sub-agent's real receipts into the parent ledger
1782 // carried on ctx. Meta tools themselves are never mutations.
1783 func mergeChildEvidence(ctx context.Context, sub *Agent) {
1784 if sub == nil {
1785 return
1786 }
1787 parent, ok := evidence.FromContext(ctx)
1788 if !ok || parent == nil {
1789 return
1790 }
1791 parent.MergeChild(sub.EvidenceSummary())
1792 }
1793
1794 // EvidenceSummary exports this agent's turn-scoped receipts for parent merge.
1795 func (a *Agent) EvidenceSummary() evidence.ChildEvidenceSummary {
1796 if a == nil || a.task.ledger == nil {
1797 return evidence.ChildEvidenceSummary{}
1798 }
1799 return a.task.ledger.Summary()
1800 }
1801
1802 func isFreshSubagentSession(sess *Session) bool {
1803 if sess == nil {
1804 return false
1805 }
1806 snap := sess.Snapshot()
1807 return len(snap) == 1 && snap[0].Role == provider.RoleSystem
1808 }
1809
1810 // NestedSink returns a sink that forwards a sub-agent's tool activity to the
1811 // parent stream, nested under the tool call carried by ctx, so a frontend shows
1812 // it beneath that call (the same nesting `task` uses). Falls back to the given
1813 // sink when ctx carries no call context. Used by subagent skills.
1814 func NestedSink(ctx context.Context, fallback event.Sink) event.Sink {
1815 parentID, parent, _, ok := CallContext(ctx)
1816 if !ok || parent == nil {
1817 return fallback
1818 }
1819 return subSinkFor(parentID, parent)
1820 }
1821
1822 // subSink forwards a sub-agent's tool dispatch/result/progress events and
1823 // billable usage to the parent's event stream. Only tool activity is nested
1824 // visually; the sub-agent's text/reasoning stays isolated (progress previews
1825 // travel as reserved ToolProgress channels, not as parent Text/Reasoning) and
1826 // only its final answer is returned.
1827 //
1828 // The sub-agent's own turn/text/reasoning events are dropped — forwarding them
1829 // would make the parent transcript noisy and could imply they belong to the
1830 // parent model context, which they do not.
1831 //
1832 // Usage events are observability only, so forwarding them preserves billing
1833 // totals without polluting the parent provider-visible prefix.
1834 //
1835 // Tool events are tagged with the parent task call's ID so a frontend nests them
1836 // under it. The forwarded call IDs are namespaced with the parent ID so a
1837 // sub-agent call can never collide with a parent call in the frontend's
1838 // dispatch→result matching. ToolProgress covers both the sub-agent's real tool
1839 // output and nested sub-agent progress previews, which ride the same sink so
1840 // their IDs match the cards they belong to. Falls back to Discard when there's
1841 // no parent stream (the headless run loop, or a direct Execute in tests).
1842 func subSink(ctx context.Context) event.Sink {
1843 parentID, parent, _, ok := CallContext(ctx)
1844 if !ok || parent == nil {
1845 return event.Discard
1846 }
1847 return subSinkFor(parentID, parent)
1848 }
1849
1850 // subSinkFor builds the nesting sink from an already-captured parent ID + stream,
1851 // for the background path where the job runs under a context that no longer
1852 // carries the call context. Falls back to Discard when there's no parent stream.
1853 func subSinkFor(parentID string, parent event.Sink) event.Sink {
1854 if parent == nil {
1855 return event.Discard
1856 }
1857 return nestedSink{AuditForwarder: event.AuditForwarder{Inner: parent}, parentID: parentID, parent: parent}
1858 }
1859
1859 lines GO