mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
1966 lines
80 KiB
Go
1966 lines
80 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/ablation"
|
|
"reasonix/internal/checkpoint"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/evidence"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/memory"
|
|
"reasonix/internal/planmode"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/sessiontemp"
|
|
"reasonix/internal/tool"
|
|
"reasonix/internal/workspacelease"
|
|
)
|
|
|
|
// withSubagentSessionTemp installs a fresh session-private temporary directory
|
|
// Manager for one sub-agent run. The returned release must be deferred by the
|
|
// caller so the directory is retired when the run ends (including background
|
|
// sub-agent completion).
|
|
func withSubagentSessionTemp(ctx context.Context) (context.Context, func()) {
|
|
m := sessiontemp.New()
|
|
m.Retain()
|
|
return sessiontemp.WithManager(ctx, m), m.Release
|
|
}
|
|
|
|
// DefaultTaskSystemPrompt steers a sub-agent toward focused, terse delivery —
|
|
// it doesn't see the parent's conversation so it must self-contain.
|
|
const DefaultTaskSystemPrompt = `You are a sub-agent invoked by a parent coding agent to carry out one focused task.
|
|
Use the provided tools to investigate or act. For MCP, use the stable use_capability
|
|
proxy (list → inspect → call); do not expect direct mcp__* tool schemas. Return a
|
|
single final answer that is concise and self-contained — the parent will see only
|
|
that answer, not your tool calls or reasoning. If you need to ask for clarification,
|
|
fail with a precise question instead of guessing.`
|
|
|
|
// DefaultReadOnlyTaskSystemPrompt steers read-only sub-agents toward isolated
|
|
// research. They never receive writer tools, persisted transcript controls, or
|
|
// background process controls, so their final answer is the only handoff.
|
|
const DefaultReadOnlyTaskSystemPrompt = `You are a read-only research sub-agent invoked by a parent coding agent.
|
|
Use only the provided read-only tools to inspect code, docs, history, and safe shell output.
|
|
For MCP, use use_capability only for authorized tools that declare readOnly and are
|
|
not destructive; never treat missing readOnlyHint as permission to call. Do not
|
|
attempt to write files, install capabilities, mutate memory, control long-lived
|
|
processes, or delegate to writer-capable agents. If a read-only delegation tool is
|
|
available and genuinely useful, you may use it within the configured depth limit.
|
|
Return a concise, self-contained final answer with the evidence the parent needs.`
|
|
|
|
const subagentStartContext = `<subagent-context event="SubagentStart">
|
|
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.
|
|
</subagent-context>`
|
|
|
|
// read_skill is deliberately not listed: it renders playbook text inline and
|
|
// cannot recurse, so depth-capped sub-agents keep it and can still read
|
|
// playbooks even when they can no longer delegate.
|
|
var subagentRecursiveTools = []string{
|
|
"task",
|
|
"read_only_task",
|
|
"run_skill",
|
|
"read_only_skill",
|
|
"explore",
|
|
"research",
|
|
"review",
|
|
"security_review",
|
|
}
|
|
|
|
var subagentAlwaysHiddenTools = []string{
|
|
"parallel_tasks",
|
|
"fleet",
|
|
"read_subagent_result",
|
|
"list_subagents",
|
|
"install_skill",
|
|
"install_source",
|
|
}
|
|
|
|
var subagentJobTools = []string{
|
|
"wait",
|
|
"bash_output",
|
|
"kill_shell",
|
|
}
|
|
|
|
var readOnlySubagentWorkflowTools = []string{
|
|
"connect_tool_source",
|
|
}
|
|
|
|
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."
|
|
|
|
// maxConcurrentBackgroundTasks is the legacy writer-background fallback used
|
|
// only when a TaskTool has no session scheduler (tests). Production boots
|
|
// inject MaxParallelWriters via SubagentScheduler.
|
|
const maxConcurrentBackgroundTasks = DefaultMaxParallelWriters
|
|
|
|
// SubagentMetaTools returns the tool names that spawned agents should not inherit
|
|
// from the parent registry unless a future call site deliberately opts into a
|
|
// different boundary. They can spawn or author more agent work, so excluding them
|
|
// preserves one layer of delegation without adding a spawn-count cap.
|
|
// read_skill stays listed here so the guardian and planner surfaces, which
|
|
// exclude these names, keep their provider-visible tool sets byte-identical —
|
|
// only the sub-agent depth cap deliberately stopped stripping it.
|
|
func SubagentMetaTools() []string {
|
|
out := append([]string(nil), subagentRecursiveTools...)
|
|
out = append(out, "read_skill")
|
|
out = append(out, subagentAlwaysHiddenTools...)
|
|
return out
|
|
}
|
|
|
|
// SubagentToolRegistry returns the tool set exposed inside spawned sub-agents:
|
|
// the requested whitelist (or every parent tool), minus meta tools that would
|
|
// spawn more agent work and job tools whose runtime manager is not injected into
|
|
// sub-agents. When bash is present, it is wrapped to advertise and allow only
|
|
// foreground execution.
|
|
func SubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
|
|
return SubagentToolRegistryForDepth(parent, names, 1, 1)
|
|
}
|
|
|
|
// SubagentToolRegistryForDepth returns the writer-capable tool set for a spawned
|
|
// subagent at childDepth. Recursive delegation tools are available only when the
|
|
// child still has room to spawn one more subagent.
|
|
//
|
|
// Direct mcp__* schemas are never exposed: MCP goes only through the fixed
|
|
// use_capability proxy so connect/disconnect/tool-list churn cannot change the
|
|
// child provider-visible tool prefix. With no explicit allowlist the child gets
|
|
// the full proxy (installed/authorized MCP, including tools without
|
|
// readOnlyHint). An explicit allowlist converts mcp__* / mcp-tool: names into a
|
|
// capability-id allowlist on a restricted proxy.
|
|
func SubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
|
|
return SubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
|
|
}
|
|
|
|
// SubagentToolRegistryForDepthWithRuntime is SubagentToolRegistryForDepth with
|
|
// an optional session MCP runtime used when the parent registry has no
|
|
// use_capability (for example Economy or legacy callers) but sub-agents still
|
|
// need the proxy.
|
|
func SubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
|
|
exclude := append([]string(nil), subagentAlwaysHiddenTools...)
|
|
if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
|
|
exclude = append(exclude, subagentRecursiveTools...)
|
|
}
|
|
exclude = append(exclude, subagentJobTools...)
|
|
sub := FilterRegistry(parent, names, exclude...)
|
|
stripDirectMCPTools(sub)
|
|
AttachCompleteSubtaskTool(sub)
|
|
attachSubagentCapabilityProxy(parent, sub, names, runtime)
|
|
if bash, ok := sub.Get("bash"); ok {
|
|
sub.Add(foregroundOnlyBash{inner: bash})
|
|
}
|
|
return sub
|
|
}
|
|
|
|
type foregroundOnlyBash struct {
|
|
inner tool.Tool
|
|
}
|
|
|
|
func (b foregroundOnlyBash) Name() string { return "bash" }
|
|
|
|
func (b foregroundOnlyBash) Description() string {
|
|
desc := strings.TrimSpace(b.inner.Description())
|
|
if desc == "" {
|
|
desc = "Execute a command in the shell and return combined stdout/stderr."
|
|
}
|
|
desc = strings.Replace(desc, "Execute a command in the shell", "Execute a foreground command in the shell", 1)
|
|
return desc + " Background execution is unavailable inside subagents."
|
|
}
|
|
|
|
func (foregroundOnlyBash) Schema() json.RawMessage {
|
|
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute in the foreground"}},"required":["command"]}`)
|
|
}
|
|
|
|
func (b foregroundOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
RunInBackground bool `json:"run_in_background"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
if p.RunInBackground {
|
|
return "", tool.Blocked("blocked: background bash is unavailable in subagents; run a foreground command or ask the parent agent to start a background job")
|
|
}
|
|
return b.inner.Execute(ctx, args)
|
|
}
|
|
|
|
func (b foregroundOnlyBash) ReadOnly() bool { return b.inner.ReadOnly() }
|
|
|
|
// TaskTool spawns a sub-agent in its own session for a focused sub-task. The
|
|
// sub-agent runs with a filtered tool whitelist and the same step budget shape
|
|
// as the parent (see Execute); its tool calls are forwarded to the parent's
|
|
// event stream nested under this call, while only its final assistant message is
|
|
// returned to the parent model. Use cases: keep noisy tool sequences (multi-file
|
|
// exploration, repeated grep / read_file) out of the parent's context budget, or
|
|
// parallel research across independent areas (the parallel-dispatch path picks
|
|
// these up only when readOnly, which task is not).
|
|
type TaskTool struct {
|
|
prov provider.Provider
|
|
pricing *provider.Pricing
|
|
parentReg *tool.Registry
|
|
maxSteps int
|
|
contextWindow int
|
|
compactRatio float64
|
|
recentKeep int
|
|
budgets CompactionBudgets
|
|
temperature float64
|
|
archiveDir string
|
|
keepPolicy KeepPolicy
|
|
sysPrompt string
|
|
gate Gate
|
|
subagentModel, subagentEffort string
|
|
resolveProvider func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error)
|
|
transcripts *SubagentStore
|
|
workspaceRoot string
|
|
baseModel string
|
|
baseEffort string
|
|
identityProfile func(modelRef, effort string) (string, string)
|
|
maxSubagentDepth int
|
|
deliveryProfile bool
|
|
ablation ablation.Set
|
|
workspaceLease *workspacelease.Owner
|
|
// scheduler is the session-scoped concurrency + write-claim controller.
|
|
// nil falls back to the legacy jobs.ReserveStart cap for background tasks.
|
|
scheduler *SubagentScheduler
|
|
// profileLookup resolves profile= names from the live Skill store without
|
|
// embedding the name list in the tool schema (cache stability).
|
|
profileLookup ProfileLookup
|
|
// profileConfigModel/Effort look up persistent per-profile overrides
|
|
// (agent.subagent_models / subagent_efforts).
|
|
profileConfigModel func(profile string) string
|
|
profileConfigEffort func(profile string) string
|
|
// bashSandboxEnforced reports whether OS sandbox can honour write roots
|
|
// for bash inside path-bound writer sub-agents.
|
|
bashSandboxEnforced func() bool
|
|
// mutationObserver is shared with spawned sub-agents for checkpoint capture.
|
|
mutationObserver *checkpoint.MutationObserver
|
|
// recoveryGate is the shared Auto Guard boundary for
|
|
// this session (root + sub-agents). nil disables recovery in children.
|
|
recoveryGate RecoveryGate
|
|
// capabilityRuntime is the session-shared MCP Host/specs substrate. Each
|
|
// sub-agent gets its own use_capability frontend so ledger state stays
|
|
// isolated while connections reuse the parent Host.
|
|
capabilityRuntime *MCPCapabilityRuntime
|
|
}
|
|
|
|
// TaskToolOptions holds the construction parameters for a TaskTool.
|
|
// Prefer NewTaskToolWithOptions for new call sites; the positional NewTaskTool
|
|
// remains as a compatibility wrapper for one full iteration cycle.
|
|
type TaskToolOptions struct {
|
|
Provider provider.Provider
|
|
Pricing *provider.Pricing
|
|
ParentRegistry *tool.Registry
|
|
MaxSteps int
|
|
ContextWindow int
|
|
RecentKeep int
|
|
CompactionBudgets CompactionBudgets
|
|
CompactRatio float64
|
|
Temperature float64
|
|
ContextEditing, ArchiveDir, SysPrompt string
|
|
Gate Gate
|
|
KeepPolicy KeepPolicy
|
|
SubagentModel string
|
|
SubagentEffort string
|
|
ResolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)
|
|
}
|
|
|
|
// NewTaskToolWithOptions is the internal standard constructor for TaskTool.
|
|
// An empty SysPrompt still resolves to DefaultTaskSystemPrompt. No extra
|
|
// validation or default overrides are applied beyond the historical NewTaskTool
|
|
// behavior.
|
|
func NewTaskToolWithOptions(opts TaskToolOptions) *TaskTool {
|
|
sysPrompt := opts.SysPrompt
|
|
if sysPrompt == "" {
|
|
sysPrompt = DefaultTaskSystemPrompt
|
|
}
|
|
return &TaskTool{
|
|
prov: opts.Provider,
|
|
pricing: opts.Pricing,
|
|
parentReg: opts.ParentRegistry,
|
|
maxSteps: opts.MaxSteps,
|
|
contextWindow: opts.ContextWindow,
|
|
recentKeep: opts.RecentKeep,
|
|
budgets: opts.CompactionBudgets,
|
|
compactRatio: opts.CompactRatio,
|
|
temperature: opts.Temperature,
|
|
archiveDir: opts.ArchiveDir,
|
|
keepPolicy: opts.KeepPolicy,
|
|
sysPrompt: sysPrompt,
|
|
gate: opts.Gate,
|
|
subagentModel: opts.SubagentModel,
|
|
subagentEffort: opts.SubagentEffort,
|
|
resolveProvider: opts.ResolveProvider,
|
|
maxSubagentDepth: DefaultMaxSubagentDepth,
|
|
}
|
|
}
|
|
|
|
// NewTaskTool wires a task tool to the parent agent's environment so its
|
|
// sub-agents can use the same provider and tools. sysPrompt is the system
|
|
// prompt every sub-agent starts with; pass "" for DefaultTaskSystemPrompt. gate
|
|
// is the permission gate sub-agents inherit — pass the headless variant so
|
|
// deny rules still bite while autonomous sub-agents are never blocked on an
|
|
// interactive prompt (there is no UI to answer one).
|
|
//
|
|
// Compatibility wrapper: new call sites should prefer NewTaskToolWithOptions.
|
|
// The positional form is kept for at least one full iteration cycle.
|
|
func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *tool.Registry,
|
|
maxSteps, contextWindow, recentKeep int, compactRatio, temperature float64, archiveDir, sysPrompt string, gate Gate,
|
|
keepPolicy KeepPolicy, subagentModel, subagentEffort string, resolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
|
|
return NewTaskToolWithOptions(TaskToolOptions{
|
|
Provider: prov,
|
|
Pricing: pricing,
|
|
ParentRegistry: parentReg,
|
|
MaxSteps: maxSteps,
|
|
ContextWindow: contextWindow,
|
|
RecentKeep: recentKeep,
|
|
CompactRatio: compactRatio,
|
|
Temperature: temperature,
|
|
ArchiveDir: archiveDir,
|
|
SysPrompt: sysPrompt,
|
|
Gate: gate,
|
|
KeepPolicy: keepPolicy,
|
|
SubagentModel: subagentModel,
|
|
SubagentEffort: subagentEffort,
|
|
ResolveProvider: resolveProvider,
|
|
})
|
|
}
|
|
|
|
// WithTranscripts enables persisted sub-agent transcript continuation for this
|
|
// task tool. The base model/effort are the parent provider identity used when no
|
|
// subagent override is configured.
|
|
func (t *TaskTool) WithTranscripts(store *SubagentStore, workspaceRoot, baseModel, baseEffort string) *TaskTool {
|
|
t.transcripts = store
|
|
t.workspaceRoot = strings.TrimSpace(workspaceRoot)
|
|
t.baseModel = strings.TrimSpace(baseModel)
|
|
t.baseEffort = strings.TrimSpace(baseEffort)
|
|
return t
|
|
}
|
|
|
|
func (t *TaskTool) WithTranscriptIdentityResolver(resolve func(modelRef, effort string) (string, string)) *TaskTool {
|
|
t.identityProfile = resolve
|
|
return t
|
|
}
|
|
|
|
func (t *TaskTool) WithMaxSubagentDepth(depth int) *TaskTool {
|
|
t.maxSubagentDepth = NormalizeMaxSubagentDepth(depth)
|
|
return t
|
|
}
|
|
|
|
// WithDeliveryProfile propagates the parent's runtime delivery contract into
|
|
// writer-capable sub-agents. Read-only sub-agents may receive the flag too, but
|
|
// the mutation gate remains dormant for them.
|
|
func (t *TaskTool) WithDeliveryProfile(enabled bool) *TaskTool {
|
|
t.deliveryProfile = enabled
|
|
return t
|
|
}
|
|
|
|
// WithAblation propagates the parent's benchmark arm so a sub-agent runs with
|
|
// the same subsystems switched off.
|
|
func (t *TaskTool) WithAblation(set ablation.Set) *TaskTool {
|
|
t.ablation = set
|
|
return t
|
|
}
|
|
|
|
// WithWorkspaceLease shares the parent's workspace-wide delivery write lease
|
|
// with every spawned sub-agent. A shared owner is required: independent owners
|
|
// in one session would deadlock when a child tries to write while its parent
|
|
// already retains the lease.
|
|
func (t *TaskTool) WithWorkspaceLease(owner *workspacelease.Owner) *TaskTool {
|
|
t.workspaceLease = owner
|
|
return t
|
|
}
|
|
|
|
// WithScheduler attaches the session-scoped concurrency and write-claim
|
|
// controller used by task, fleet, parallel_tasks, and profile skill runners.
|
|
func (t *TaskTool) WithScheduler(s *SubagentScheduler) *TaskTool {
|
|
t.scheduler = s
|
|
return t
|
|
}
|
|
|
|
// Scheduler returns the attached session scheduler (may be nil in unit tests).
|
|
func (t *TaskTool) Scheduler() *SubagentScheduler {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.scheduler
|
|
}
|
|
|
|
// WithProfileLookup enables task/fleet profile= resolution from the Skill store.
|
|
func (t *TaskTool) WithProfileLookup(lookup ProfileLookup) *TaskTool {
|
|
t.profileLookup = lookup
|
|
return t
|
|
}
|
|
|
|
// WithProfileConfigResolvers supplies persistent per-profile model/effort
|
|
// overrides (agent.subagent_models / subagent_efforts).
|
|
func (t *TaskTool) WithProfileConfigResolvers(model, effort func(profile string) string) *TaskTool {
|
|
t.profileConfigModel = model
|
|
t.profileConfigEffort = effort
|
|
return t
|
|
}
|
|
|
|
// WithBashSandboxEnforced tells path-bound writer runs whether bash can keep
|
|
// the same write roots under the OS sandbox.
|
|
func (t *TaskTool) WithBashSandboxEnforced(fn func() bool) *TaskTool {
|
|
t.bashSandboxEnforced = fn
|
|
return t
|
|
}
|
|
|
|
// WithCapabilityRuntime attaches the session-shared MCP runtime so ordinary and
|
|
// read-only sub-agents receive a stable use_capability frontend without
|
|
// inheriting dynamic mcp__* schemas.
|
|
func (t *TaskTool) WithCapabilityRuntime(rt *MCPCapabilityRuntime) *TaskTool {
|
|
if t != nil {
|
|
t.capabilityRuntime = rt
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (t *TaskTool) Name() string { return "task" }
|
|
|
|
func (t *TaskTool) Description() string {
|
|
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."
|
|
}
|
|
|
|
func (t *TaskTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"prompt":{"type":"string","description":"What the sub-agent should accomplish. Be specific about the deliverable — the sub-agent does not see this conversation."},
|
|
"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."},
|
|
"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."},
|
|
"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."},
|
|
"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 + `"},
|
|
"max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
|
|
"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."},
|
|
"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."},
|
|
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max). Same precedence as model."},
|
|
"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."}
|
|
},
|
|
"required":["prompt"]
|
|
}`)
|
|
}
|
|
|
|
// ReadOnly is false: a sub-agent can invoke any whitelisted tool, including
|
|
// writers. Conservative classification keeps the parallel-dispatch path from
|
|
// running two sub-agents at once and letting their writes race.
|
|
func (t *TaskTool) ReadOnly() bool { return false }
|
|
|
|
// ReadOnlyTaskTool runs an isolated sub-agent with a strictly read-only tool
|
|
// registry. It intentionally omits background execution and transcript
|
|
// continuation/fork controls so the call has no durable host side effects.
|
|
type ReadOnlyTaskTool struct {
|
|
task *TaskTool
|
|
}
|
|
|
|
func NewReadOnlyTaskTool(task *TaskTool) *ReadOnlyTaskTool {
|
|
return &ReadOnlyTaskTool{task: task}
|
|
}
|
|
|
|
func (*ReadOnlyTaskTool) Name() string { return "read_only_task" }
|
|
|
|
func (*ReadOnlyTaskTool) Description() string {
|
|
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."
|
|
}
|
|
|
|
func (*ReadOnlyTaskTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"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."},
|
|
"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."},
|
|
"tools":{"type":"array","items":{"type":"string"},"description":"Optional read-only tool whitelist. Writer, installer, memory mutation, background job, and delegation tools are never exposed."},
|
|
"max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1},
|
|
"model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name)."},
|
|
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."}
|
|
},
|
|
"required":["prompt"]
|
|
}`)
|
|
}
|
|
|
|
func (*ReadOnlyTaskTool) ReadOnly() bool { return true }
|
|
|
|
// PlanModeSafe reports true: read_only_task spawns a strictly read-only research
|
|
// sub-agent (no writers, installers, memory mutation, background jobs, or
|
|
// delegation), so it is safe to run while planning.
|
|
func (*ReadOnlyTaskTool) PlanModeSafe() bool { return true }
|
|
|
|
func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
if r == nil || r.task == nil {
|
|
return "", fmt.Errorf("read_only_task is not configured")
|
|
}
|
|
var p struct {
|
|
Prompt string `json:"prompt"`
|
|
Description string `json:"description"`
|
|
Tools []string `json:"tools"`
|
|
MaxSteps int `json:"max_steps"`
|
|
Model string `json:"model"`
|
|
Effort string `json:"effort"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
// Every entry point compiles to a spec and runs through RunProfileSpec, so a
|
|
// boundary added there cannot be missed by one caller. read_only_task keeps
|
|
// its own promise of no durable side effects through Ephemeral.
|
|
spec, err := r.task.buildTaskSpec(ctx, p.Prompt, p.Description, "", nil, p.Tools, p.MaxSteps, p.Model, p.Effort, "", "", false, true)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
spec.Worker.SystemPrompt = DefaultReadOnlyTaskSystemPrompt
|
|
spec.Context.Ephemeral = true
|
|
return r.task.RunProfileSpec(ctx, spec)
|
|
}
|
|
|
|
func (t *TaskTool) effectiveProfile(model, effort string) (string, string) {
|
|
model = strings.TrimSpace(model)
|
|
effort = strings.TrimSpace(effort)
|
|
if model == "" {
|
|
model = strings.TrimSpace(t.subagentModel)
|
|
}
|
|
if effort == "" {
|
|
effort = strings.TrimSpace(t.subagentEffort)
|
|
}
|
|
return model, effort
|
|
}
|
|
|
|
func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
var p struct {
|
|
Prompt string `json:"prompt"`
|
|
Description string `json:"description"`
|
|
Profile string `json:"profile"`
|
|
WritePaths []string `json:"write_paths"`
|
|
Tools []string `json:"tools"`
|
|
MaxSteps int `json:"max_steps"`
|
|
RunInBackground bool `json:"run_in_background"`
|
|
Model string `json:"model"`
|
|
Effort string `json:"effort"`
|
|
ContinueFrom string `json:"continue_from"`
|
|
ForkFrom string `json:"fork_from"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return "", fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
if strings.TrimSpace(p.Prompt) == "" {
|
|
return "", fmt.Errorf("prompt is required")
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return t.RunProfileSpec(ctx, spec)
|
|
}
|
|
|
|
// buildTaskSpec resolves profile, tools, model/effort, and write claims for a
|
|
// single task/fleet item. forceReadOnly forces the read-only registry.
|
|
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) {
|
|
spec := ProfileExecSpec{
|
|
Task: TaskSpec{Objective: prompt, Description: description},
|
|
Worker: WorkerSpec{Kind: "task", Name: "task", SystemPrompt: t.sysPrompt},
|
|
Grant: CapabilityGrant{CallTools: tools},
|
|
Context: ContextRequest{ContinueFrom: strings.TrimSpace(continueFrom), ForkFrom: strings.TrimSpace(forkFrom)},
|
|
Sched: SchedulerPolicy{MaxSteps: maxSteps, RunInBackground: background, Nested: SubagentDepth(ctx) > 0},
|
|
}
|
|
profile = strings.TrimSpace(profile)
|
|
readOnly := forceReadOnly
|
|
var profileTools []string
|
|
var profileModel, profileEffort string
|
|
if profile != "" {
|
|
def, err := ResolveProfileDefinition(t.profileLookup, profile)
|
|
if err != nil {
|
|
return ProfileExecSpec{}, err
|
|
}
|
|
spec.Worker.Profile = def.Name
|
|
spec.Worker.Name = def.Name
|
|
spec.Worker.Kind = "skill"
|
|
spec.Worker.SystemPrompt = def.Body
|
|
spec.Worker.UseProfilePrompt = true
|
|
// What the worker owes comes with the worker, and so does what it may
|
|
// prove. Deriving either from the profile's name here would put an
|
|
// undeclared rule in a second place.
|
|
spec.Worker.ReviewReport = def.Delivery.ReviewReport
|
|
spec.Worker.ReviewAuthority = def.Authority.Review
|
|
profileTools = def.AllowedTools
|
|
profileModel, profileEffort = def.Model, def.Effort
|
|
if def.ReadOnly {
|
|
readOnly = true
|
|
}
|
|
}
|
|
spec.Grant.ReadOnly = readOnly
|
|
spec.Grant.ProfileTools = profileTools
|
|
|
|
configModel, configEffort := "", ""
|
|
if profile != "" {
|
|
if t.profileConfigModel != nil {
|
|
configModel = t.profileConfigModel(profile)
|
|
}
|
|
if t.profileConfigEffort != nil {
|
|
configEffort = t.profileConfigEffort(profile)
|
|
}
|
|
}
|
|
spec.Worker.Model, spec.Worker.Effort = ResolveModelEffort(
|
|
configModel, configEffort,
|
|
model, effort,
|
|
profileModel, profileEffort,
|
|
t.subagentModel, t.subagentEffort,
|
|
)
|
|
|
|
if !readOnly {
|
|
// Every writer carries a claim. Omitting write_paths conservatively claims
|
|
// the whole workspace, including foreground task calls, so they cannot
|
|
// bypass an already-running background/fleet writer claim. Direct legacy
|
|
// TaskTool constructions without a workspace/scheduler keep their old
|
|
// no-claim behavior; production boot always configures both.
|
|
requireClaim := t.scheduler != nil || strings.TrimSpace(t.workspaceRoot) != "" || background || len(writePaths) > 0
|
|
claims, err := t.resolveWriterClaims(writePaths, requireClaim)
|
|
if err != nil {
|
|
return ProfileExecSpec{}, err
|
|
}
|
|
spec.Grant.WritePaths = claims
|
|
if requireClaim && claims.Empty() {
|
|
return ProfileExecSpec{}, fmt.Errorf("writer claim resolved empty")
|
|
}
|
|
} else if len(writePaths) > 0 {
|
|
return ProfileExecSpec{}, fmt.Errorf("write_paths is not valid for read-only tasks")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
func (t *TaskTool) resolveWriterClaims(writePaths []string, requireClaim bool) (WritePathSet, error) {
|
|
if len(writePaths) > 0 {
|
|
return NormalizeWritePaths(t.workspaceRoot, writePaths)
|
|
}
|
|
if !requireClaim {
|
|
return WritePathSet{}, nil
|
|
}
|
|
return WholeWorkspaceWriteClaim(t.workspaceRoot)
|
|
}
|
|
|
|
// settleSpecPrompts rejects a run with nothing to do and fills in the system
|
|
// prompt a plain task inherits. A profile that declared one and lost it is an
|
|
// error rather than a fallback: it would run under the wrong instructions.
|
|
func (t *TaskTool) settleSpecPrompts(spec *ProfileExecSpec) error {
|
|
if strings.TrimSpace(spec.Task.Objective) == "" {
|
|
return fmt.Errorf("prompt is required")
|
|
}
|
|
if strings.TrimSpace(spec.Worker.SystemPrompt) == "" {
|
|
if spec.Worker.UseProfilePrompt {
|
|
return fmt.Errorf("profile system prompt is empty")
|
|
}
|
|
spec.Worker.SystemPrompt = t.sysPrompt
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RunProfileSpec executes a unified profile/task specification. Shared by task,
|
|
// fleet items, and boot-wired skill runners so prompt, tools, claims, and
|
|
// scheduling cannot drift across entry points.
|
|
func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (result string, err error) {
|
|
if t == nil {
|
|
return "", fmt.Errorf("task tool is not configured")
|
|
}
|
|
// Per-child progress tracker: converts the child's reasoning/text/notice/
|
|
// retrying into reserved ToolProgress previews and guarantees exactly one
|
|
// terminal status (completed/cancelled/failed). The background job owns
|
|
// finish after handoff; every other exit finishes here, including
|
|
// validation errors and panics.
|
|
trk := newSubagentProgressTracker(ctx, subSink(ctx))
|
|
backgroundHandoff := false
|
|
defer func() {
|
|
if backgroundHandoff {
|
|
return
|
|
}
|
|
if p := recover(); p != nil {
|
|
trk.finish(nil, fmt.Errorf("panic: %v", p))
|
|
panic(p)
|
|
}
|
|
trk.finish(ctx.Err(), err)
|
|
}()
|
|
if err := t.settleSpecPrompts(&spec); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
maxSteps := t.childMaxStepsForContext(ctx, spec.Sched.MaxSteps)
|
|
childDepth, err := t.nextSubagentDepth(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
subReg, err := t.subRegistryFor(&spec, childDepth)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
modelRef, effortRef := visionRefFor(ctx, spec.Worker.Model), spec.Worker.Effort
|
|
usageModelRef := t.usageModelRef(modelRef, effortRef)
|
|
parentID, _, _, _ := CallContext(ctx)
|
|
prov, pricing, ctxWin, err := t.resolveSubSessionRuntime(modelRef, effortRef)
|
|
if err != nil {
|
|
return "", fmt.Errorf("sub-agent profile: %w", err)
|
|
}
|
|
acquireReq, err := t.acquireRequestFor(&spec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Every deterministic refusal has answered by here, so this is where work
|
|
// enters orchestration: recorded, drawn pending, and handed the scheduler's
|
|
// two moments. A fan-out item's group opened it already.
|
|
life, err := t.openDelegation(ctx, &spec, &acquireReq, trk)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// A handoff moves the ending to the job, which is where the child stops.
|
|
defer life.settleOnReturn(ctx, &backgroundHandoff, &result, &err)
|
|
// Prove, admit, allocate: a foreground run holds its slot from here because
|
|
// the transcript below is the first thing it reserves, and a fan-out with
|
|
// sixty queued items must not hold sixty of them open.
|
|
if !spec.Sched.RunInBackground {
|
|
releaseSlot, slotErr := t.acquireSlot(ctx, acquireReq, spec.Sched)
|
|
if slotErr != nil {
|
|
return "", slotErr
|
|
}
|
|
defer releaseSlot()
|
|
}
|
|
// Two identities, taken apart from here on: which execution this is, and
|
|
// which provider-visible call it descends from. They are the same string
|
|
// for everything a model delegated and cannot be for anything else.
|
|
execution := life.executionID(parentID)
|
|
// Never on the parent surface: the gate reads a child's report, and exposing
|
|
// it upward would let a turn file a verdict on its own behalf. Mounted here
|
|
// rather than with the rest of the registry because it is bound to a grant,
|
|
// and the execution that grant names does not exist until now.
|
|
grant := reviewGrantFor(spec.Worker, execution)
|
|
attachReviewReport(subReg, grant)
|
|
run, err := t.prepareTranscriptRunWithPrompt(withUpstream(ctx, spec.Context.Upstream), subReg, modelRef, effortRef, spec.Context.parentSession(ctx), execution, spec.Context.executionParent(execution), spec.Context.ContinueFrom, spec.Context.ForkFrom, spec.Worker.SystemPrompt, spec.Worker.Kind, spec.Worker.Name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
recoveryTaskID := subagentRecoveryTaskID(ctx, run.Ref)
|
|
backgroundWriter := (spec.Sched.RunInBackground || spec.Sched.BackgroundWriter) && !spec.Grant.ReadOnly
|
|
var mutationObserver *checkpoint.MutationObserver
|
|
if t.mutationObserver != nil {
|
|
turn := t.mutationObserver.OwnershipTurn()
|
|
mutationObserver = t.mutationObserver.CloneForSubagent(recoveryTaskID, turn, backgroundWriter)
|
|
}
|
|
runSession := func(runCtx context.Context, sink event.Sink, writerAlreadyRegistered bool) (string, error) {
|
|
if mutationObserver != nil && backgroundWriter && !writerAlreadyRegistered {
|
|
turn := mutationObserver.OwnershipTurn()
|
|
if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
|
|
return "", err
|
|
}
|
|
defer mutationObserver.UnregisterWriter(recoveryTaskID)
|
|
}
|
|
if spec.Grant.ReadOnly {
|
|
return t.runReadOnlySubSession(withUpstream(runCtx, spec.Context.Upstream), spec.Task.Objective, subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver, "read_only_"+spec.Worker.Kind, grant)
|
|
}
|
|
return t.runSubSession(withUpstream(WithSubagentWriteClaim(runCtx, spec.Grant.WritePaths), spec.Context.Upstream), spec.Task.Objective, subReg, sink, maxSteps, prov, pricing, ctxWin, run.Session, childDepth, recoveryTaskID, usageModelRef, mutationObserver, spec.Worker.Kind, grant)
|
|
}
|
|
|
|
if spec.Sched.RunInBackground {
|
|
jm, ok := jobs.FromContext(ctx)
|
|
if !ok {
|
|
run.Release()
|
|
return "", fmt.Errorf("background execution is not available in this context")
|
|
}
|
|
// Legacy hard-cap remains only when no scheduler is attached. With a
|
|
// scheduler, return the job immediately and queue for a slot inside the
|
|
// job so the parent turn is not blocked at concurrency limits.
|
|
var releaseStart func()
|
|
if t.scheduler == nil {
|
|
var running int
|
|
var okReserve bool
|
|
releaseStart, running, okReserve = jm.ReserveStartForSession(jobs.SessionFromContext(ctx), "task", maxConcurrentBackgroundTasks)
|
|
if !okReserve {
|
|
run.Release()
|
|
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)
|
|
}
|
|
defer releaseStart()
|
|
} else {
|
|
releaseStart = func() {}
|
|
}
|
|
label := firstNonEmpty(spec.Task.Description, spec.Worker.Name, "task")
|
|
writerRegistered := false
|
|
if mutationObserver != nil && backgroundWriter {
|
|
turn := mutationObserver.OwnershipTurn()
|
|
if err := mutationObserver.RegisterWriter(recoveryTaskID, "background_subagent", turn); err != nil {
|
|
releaseStart()
|
|
run.Release()
|
|
return "", err
|
|
}
|
|
writerRegistered = true
|
|
}
|
|
parentSession := ParentSession(ctx)
|
|
backgroundEvidence := evidence.NewLedger()
|
|
// Capture acquire request by value for the job goroutine.
|
|
slotReq := acquireReq
|
|
job := jm.StartForSession(jobs.SessionFromContext(ctx), "task", label, func(jobCtx context.Context, _ io.Writer) (result string, err error) {
|
|
if writerRegistered {
|
|
defer mutationObserver.UnregisterWriter(recoveryTaskID)
|
|
}
|
|
jobCtx = WithParentSession(jobCtx, parentSession)
|
|
jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence)
|
|
defer run.Release()
|
|
defer func() { jobs.PublishEvidence(jobCtx, backgroundEvidence.Summary()) }()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
panicErr := fmt.Errorf("internal error: panic: %v\n%s", r, debug.Stack())
|
|
result = FormatSubagentRunResult("", run, true)
|
|
err = errors.Join(panicErr, t.transcripts.SaveFailed(run))
|
|
}
|
|
// The job owns the ending after the handoff, and settles the
|
|
// delegation where it can see the child stop.
|
|
life.settle(jobCtx, result, err)
|
|
// The job owns the terminal status: the parent tool call has
|
|
// already returned its job id by now.
|
|
trk.finish(jobCtx.Err(), err)
|
|
}()
|
|
// Queue for a concurrency/write slot here — not before Start —
|
|
// so the parent tool call returns a job id immediately.
|
|
releaseSlot, slotErr := t.acquireSlot(jobCtx, slotReq, spec.Sched)
|
|
if slotErr != nil {
|
|
// A refusal is orchestration, not execution: nothing ran under
|
|
// this run, so the store is owed no terminal for it.
|
|
return FormatSubagentRunResult("", run, true), slotErr
|
|
}
|
|
defer releaseSlot()
|
|
if err := t.beginExecution(jobCtx, life, spec.Context.Ephemeral, trk, run); err != nil {
|
|
return FormatSubagentRunResult("", run, true), err
|
|
}
|
|
answer, err := runSession(jobCtx, trk.wrap(), writerRegistered)
|
|
if err != nil {
|
|
return FormatSubagentRunResult("", run, true), errors.Join(err, t.saveRunTerminal(run, err))
|
|
}
|
|
if err := t.transcripts.SaveCompleted(run); err != nil {
|
|
return FormatSubagentRunResult("", run, true), errors.Join(err, t.transcripts.SaveFailed(run))
|
|
}
|
|
return FormatSubagentRunResult(answer, run, false), nil
|
|
})
|
|
releaseStart()
|
|
// Hand the tracker to the job goroutine: the outer defer must not
|
|
// finish (and close) it while the job still runs.
|
|
backgroundHandoff = true
|
|
queuedNote := ""
|
|
if t.scheduler != nil {
|
|
queuedNote = " It may wait in the session queue until a concurrency/write slot is free."
|
|
}
|
|
if run != nil && run.Ref != "" {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// Foreground: the slot has been held since before the transcript existed.
|
|
defer run.Release()
|
|
if err := t.beginExecution(ctx, life, spec.Context.Ephemeral, trk, run); err != nil {
|
|
return "", err
|
|
}
|
|
answer, err := runSession(ctx, trk.wrap(), false)
|
|
if err != nil {
|
|
return "", errors.Join(err, t.saveRunTerminal(run, err))
|
|
}
|
|
if t.transcripts != nil && run.Ref != "" {
|
|
if err := t.transcripts.SaveCompleted(run); err != nil {
|
|
return "", errors.Join(err, t.transcripts.SaveFailed(run))
|
|
}
|
|
return FormatSubagentRunResult(answer, run, false), nil
|
|
}
|
|
return GuardSubagentHostDecisionText(answer), nil
|
|
}
|
|
|
|
// saveRunTerminal records what happened to a child that actually ran. It is the
|
|
// only place a cancellation may be read as a terminal outcome: everywhere else
|
|
// a context error means the host failed to do something, and classifying those
|
|
// would file an infrastructure failure as work the caller stopped.
|
|
func (t *TaskTool) saveRunTerminal(run *SubagentRun, runErr error) error {
|
|
switch {
|
|
case errors.Is(runErr, context.DeadlineExceeded):
|
|
return t.transcripts.SaveCancelled(run, TerminalDeadline)
|
|
case errors.Is(runErr, context.Canceled):
|
|
return t.transcripts.SaveCancelled(run, TerminalCancelled)
|
|
default:
|
|
return t.transcripts.SaveFailed(run)
|
|
}
|
|
}
|
|
|
|
func (t *TaskTool) bashCanEnforceWriteRoots() bool {
|
|
if t != nil && t.bashSandboxEnforced != nil {
|
|
return t.bashSandboxEnforced()
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *tool.Registry, modelRef, effortRef, parentSession, execution, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
|
|
continueFrom = strings.TrimSpace(continueFrom)
|
|
legacyForkFrom = strings.TrimSpace(legacyForkFrom)
|
|
parentSession = strings.TrimSpace(parentSession)
|
|
if continueFrom != "" && legacyForkFrom != "" {
|
|
return nil, fmt.Errorf("continue_from and fork_from are mutually exclusive; pass only continue_from")
|
|
}
|
|
if t.transcripts == nil {
|
|
return nil, fmt.Errorf("subagent transcript store is required")
|
|
}
|
|
if systemPrompt == "" {
|
|
systemPrompt = t.sysPrompt
|
|
}
|
|
kind = firstNonEmpty(kind, "task")
|
|
name = firstNonEmpty(name, "task")
|
|
if parentSession == "" {
|
|
if continueFrom != "" || legacyForkFrom != "" {
|
|
return nil, fmt.Errorf("subagent continuation requires a persisted session; none is active in this run")
|
|
}
|
|
return EphemeralSubagentRun(systemPrompt), nil
|
|
}
|
|
identityModel, identityEffort := t.effectiveIdentity(modelRef, effortRef)
|
|
spec := SubagentSpec{
|
|
Kind: kind,
|
|
Name: name,
|
|
WorkspaceRoot: t.workspaceRoot,
|
|
ParentSession: parentSession,
|
|
ExecutionID: execution,
|
|
ParentToolCallID: parentID,
|
|
SystemPrompt: systemPrompt,
|
|
Registry: subReg,
|
|
ToolContext: childToolIdentityContext(ctx),
|
|
Model: identityModel,
|
|
Effort: identityEffort,
|
|
ResumedFrom: firstNonEmpty(continueFrom, legacyForkFrom),
|
|
UpstreamFrom: upstreamSources(upstreamFromContext(ctx)),
|
|
}
|
|
if continueFrom != "" {
|
|
return t.transcripts.PrepareContinue(continueFrom, spec)
|
|
}
|
|
if legacyForkFrom != "" {
|
|
return t.transcripts.PrepareLegacyForkFrom(legacyForkFrom, spec)
|
|
}
|
|
return t.transcripts.PrepareFresh(spec)
|
|
}
|
|
|
|
func childToolIdentityContext(ctx context.Context) context.Context {
|
|
ctx = tool.WithoutGoalTurnRecorder(ctx)
|
|
ctx = memory.WithoutQueue(ctx)
|
|
ctx = jobs.WithoutManager(ctx)
|
|
return planmode.WithActive(ctx, PlanModeFromContext(ctx))
|
|
}
|
|
|
|
func (t *TaskTool) effectiveIdentity(modelRef, effort string) (string, string) {
|
|
if t.identityProfile != nil {
|
|
model, eff := t.identityProfile(modelRef, effort)
|
|
return strings.TrimSpace(model), strings.TrimSpace(eff)
|
|
}
|
|
return t.effectiveModelIdentity(modelRef), t.effectiveEffortIdentity(effort)
|
|
}
|
|
|
|
// usageModelRef returns the canonical provider/model identity of the runtime
|
|
// selected for a child. The resolver expands aliases and supplies the parent
|
|
// model when no child override is configured.
|
|
func (t *TaskTool) usageModelRef(modelRef, effort string) string {
|
|
model, _ := t.effectiveIdentity(modelRef, effort)
|
|
if model != "" {
|
|
return model
|
|
}
|
|
return firstNonEmpty(modelRef, t.baseModel, t.subagentModel)
|
|
}
|
|
|
|
func (t *TaskTool) effectiveModelIdentity(modelRef string) string {
|
|
if strings.TrimSpace(modelRef) != "" {
|
|
return strings.TrimSpace(modelRef)
|
|
}
|
|
return strings.TrimSpace(t.baseModel)
|
|
}
|
|
|
|
func (t *TaskTool) effectiveEffortIdentity(effort string) string {
|
|
if strings.TrimSpace(effort) != "" {
|
|
return strings.TrimSpace(effort)
|
|
}
|
|
return strings.TrimSpace(t.baseEffort)
|
|
}
|
|
|
|
// buildSubReg returns the sub-agent's tool set: the named whitelist (minus
|
|
// unavailable sub-agent tools), or every parent tool except those tools.
|
|
func (t *TaskTool) buildSubReg(names []string, childDepth int) *tool.Registry {
|
|
return SubagentToolRegistryForDepthWithRuntime(t.parentReg, names, childDepth, t.maxDepth(), t.capabilityRuntime)
|
|
}
|
|
|
|
func (t *TaskTool) maxDepth() int {
|
|
if t == nil {
|
|
return DefaultMaxSubagentDepth
|
|
}
|
|
if t.maxSubagentDepth == 0 {
|
|
return DefaultMaxSubagentDepth
|
|
}
|
|
return NormalizeMaxSubagentDepth(t.maxSubagentDepth)
|
|
}
|
|
|
|
func (t *TaskTool) nextSubagentDepth(ctx context.Context) (int, error) {
|
|
current := SubagentDepth(ctx)
|
|
next := current + 1
|
|
maxDepth := t.maxDepth()
|
|
if next > maxDepth {
|
|
return 0, fmt.Errorf("subagent delegation depth limit reached (max_subagent_depth=%d)", maxDepth)
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
// FilterRegistry builds a sub-registry from parent: the named whitelist (empty =
|
|
// every parent tool), minus any excluded names. Used to scope what a spawned
|
|
// sub-agent — a `task` sub-agent or a subagent skill — may call, e.g. excluding
|
|
// `task` to bar recursive nesting, or restricting to a skill's allowed-tools.
|
|
// Direct MCP tools may be copied here; callers that need a stable MCP surface
|
|
// should strip them and attach use_capability via attachSubagentCapabilityProxy.
|
|
func FilterRegistry(parent *tool.Registry, names []string, exclude ...string) *tool.Registry {
|
|
sub := tool.NewRegistry()
|
|
if parent == nil {
|
|
return sub
|
|
}
|
|
ex := make(map[string]bool, len(exclude))
|
|
for _, e := range exclude {
|
|
ex[e] = true
|
|
}
|
|
customAllowlist := len(names) > 0
|
|
src := names
|
|
if !customAllowlist {
|
|
src = parent.Names()
|
|
} else {
|
|
src = expandToolPatterns(parent, src)
|
|
}
|
|
for _, name := range src {
|
|
if ex[name] {
|
|
continue
|
|
}
|
|
// MCP never enters through the generic filter when named as capability
|
|
// ids; model-visible mcp__* may still be listed for conversion later.
|
|
if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
|
|
continue
|
|
}
|
|
tl, ok := parent.Get(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
sub.Add(tl)
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// stripDirectMCPTools removes provider-visible mcp__* tools so sub-agents use
|
|
// only the stable use_capability proxy for MCP.
|
|
func stripDirectMCPTools(reg *tool.Registry) {
|
|
if reg == nil {
|
|
return
|
|
}
|
|
for _, name := range append([]string(nil), reg.Names()...) {
|
|
if strings.HasPrefix(name, tool.MCPNamePrefix) {
|
|
reg.RemovePrefix(name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// restrictedCapabilityProxy preserves a subagent allowed-tools boundary when
|
|
// MCP is available only through use_capability. The pseudo mcp-tool: and
|
|
// mcp-server: entries never become provider tools; they select one proxy schema
|
|
// whose resolver rejects every capability outside the exact allowlist.
|
|
//
|
|
// Provider-visible name/description/schema stay identical to the unrestricted
|
|
// proxy so allowlist expansion never changes the child cache prefix. Allowlist
|
|
// enforcement is host-local (check + filtered list results).
|
|
type restrictedCapabilityProxy struct {
|
|
tool.Tool
|
|
resolver tool.CallResolver
|
|
allowed map[string]bool
|
|
// servers is the set of MCP server names implied by allowed IDs; list
|
|
// results are filtered to this set so profile isolation covers discovery.
|
|
servers map[string]bool
|
|
}
|
|
|
|
// Description is fixed: never embed dynamic capability IDs (they change with
|
|
// MCP install/tool-list and would break the stable provider tool prefix).
|
|
func (t *restrictedCapabilityProxy) Description() string {
|
|
return t.Tool.Description()
|
|
}
|
|
|
|
func (t *restrictedCapabilityProxy) check(args json.RawMessage) error {
|
|
var p struct {
|
|
Action string `json:"action"`
|
|
CapabilityID string `json:"capability_id"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
|
if action == "list" || action == "search" {
|
|
return nil
|
|
}
|
|
id := strings.TrimSpace(p.CapabilityID)
|
|
if id == "" {
|
|
return fmt.Errorf("capability_id is required")
|
|
}
|
|
if !t.allowed[id] {
|
|
return fmt.Errorf("capability %q is outside this subagent's allowed-tools", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *restrictedCapabilityProxy) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
|
|
if err := t.check(args); err != nil {
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
rc, err := t.resolver.ResolveCall(ctx, args)
|
|
if err != nil {
|
|
return rc, err
|
|
}
|
|
var p struct {
|
|
Action string `json:"action"`
|
|
}
|
|
_ = json.Unmarshal(args, &p)
|
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
|
if action == "list" && rc.SkipExecute {
|
|
rc.Result = filterCapabilityListResult(rc.Result, t.servers)
|
|
} else if action == "search" && rc.SkipExecute {
|
|
rc.Result = filterCapabilitySearchResult(rc.Result, t.allowed)
|
|
}
|
|
return rc, nil
|
|
}
|
|
|
|
func (t *restrictedCapabilityProxy) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
if err := t.check(args); err != nil {
|
|
return "", err
|
|
}
|
|
out, err := t.Tool.Execute(ctx, args)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
var p struct {
|
|
Action string `json:"action"`
|
|
}
|
|
_ = json.Unmarshal(args, &p)
|
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
|
if action == "list" {
|
|
return filterCapabilityListResult(out, t.servers), nil
|
|
} else if action == "search" {
|
|
return filterCapabilitySearchResult(out, t.allowed), nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// emptyCapabilityListResult is the fail-closed list payload: no server metadata.
|
|
func emptyCapabilityListResult(note string) string {
|
|
if strings.TrimSpace(note) == "" {
|
|
note = "list is filtered to this subagent's allowed MCP servers."
|
|
}
|
|
b, err := json.MarshalIndent(map[string]any{
|
|
"servers": []listServerInfo{},
|
|
"note": note,
|
|
}, "", " ")
|
|
if err != nil {
|
|
return `{"servers":[],"note":"list is filtered to this subagent's allowed MCP servers."}`
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// filterCapabilityListResult keeps only servers in the allowlist for restricted
|
|
// proxies. Empty allowlist or unreadable payloads fail closed (empty server
|
|
// list) so discovery never leaks the full configured MCP inventory.
|
|
func filterCapabilityListResult(raw string, servers map[string]bool) string {
|
|
const baseNote = "list is filtered to this subagent's allowed MCP servers."
|
|
if len(servers) == 0 {
|
|
return emptyCapabilityListResult(baseNote + " No allowed MCP servers were resolved from the profile allowlist.")
|
|
}
|
|
var payload struct {
|
|
Servers []listServerInfo `json:"servers"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return emptyCapabilityListResult(baseNote + " List payload was unreadable; returning no servers (fail-closed).")
|
|
}
|
|
filtered := make([]listServerInfo, 0, len(payload.Servers))
|
|
for _, s := range payload.Servers {
|
|
if servers[strings.TrimSpace(s.Name)] {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
payload.Servers = filtered
|
|
if payload.Note == "" {
|
|
payload.Note = baseNote
|
|
} else if !strings.Contains(payload.Note, "Filtered to this subagent") {
|
|
payload.Note = payload.Note + " Filtered to this subagent's allowed MCP servers."
|
|
}
|
|
b, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return emptyCapabilityListResult(baseNote + " Failed to encode filtered list (fail-closed).")
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// validMCPServerCapabilityID accepts mcp-server:<non-empty-name> only.
|
|
func validMCPServerCapabilityID(id string) (server string, ok bool) {
|
|
if !strings.HasPrefix(id, "mcp-server:") {
|
|
return "", false
|
|
}
|
|
server = strings.TrimSpace(strings.TrimPrefix(id, "mcp-server:"))
|
|
// Reject empty and path-like fragments that are not bare server names.
|
|
return server, server != "" && !strings.Contains(server, "/")
|
|
}
|
|
|
|
// validMCPToolCapabilityID accepts mcp-tool:<server>/<tool> with both parts non-empty.
|
|
func validMCPToolCapabilityID(id string) (server, raw string, ok bool) {
|
|
if !strings.HasPrefix(id, "mcp-tool:") {
|
|
return "", "", false
|
|
}
|
|
rest := strings.TrimPrefix(id, "mcp-tool:")
|
|
server, raw, cut := strings.Cut(rest, "/")
|
|
server = strings.TrimSpace(server)
|
|
raw = strings.TrimSpace(raw)
|
|
return server, raw, cut && server != "" && raw != ""
|
|
}
|
|
|
|
func serversFromCapabilityAllowlist(allowed map[string]bool) map[string]bool {
|
|
servers := map[string]bool{}
|
|
for id := range allowed {
|
|
id = strings.TrimSpace(id)
|
|
if server, ok := validMCPServerCapabilityID(id); ok {
|
|
servers[server] = true
|
|
continue
|
|
}
|
|
if server, _, ok := validMCPToolCapabilityID(id); ok {
|
|
servers[server] = true
|
|
}
|
|
}
|
|
return servers
|
|
}
|
|
|
|
// attachSubagentCapabilityProxy installs a per-agent use_capability frontend.
|
|
// Any parent-copied proxy is replaced so children never share Executor ledger
|
|
// state. No allowlist → full proxy. Explicit allowlist with MCP names →
|
|
// restricted proxy. Explicit "use_capability" → full proxy. Explicit allowlist
|
|
// without MCP entries → no proxy.
|
|
func attachSubagentCapabilityProxy(parent, sub *tool.Registry, names []string, runtime *MCPCapabilityRuntime) {
|
|
if sub == nil {
|
|
return
|
|
}
|
|
// Drop any provider-copied use_capability so we always install an isolated
|
|
// frontend (shared Host/runtime, independent ledger/audit).
|
|
if _, ok := sub.Get("use_capability"); ok {
|
|
sub.RemovePrefix("use_capability")
|
|
}
|
|
frontend := newSubagentCapabilityFrontend(parent, runtime)
|
|
if frontend == nil {
|
|
return
|
|
}
|
|
if len(names) == 0 || allowlistRequestsUnrestrictedProxy(names) {
|
|
sub.Add(frontend)
|
|
return
|
|
}
|
|
allowed := mcpCapabilityAllowlist(parent, names)
|
|
if len(allowed) == 0 {
|
|
// Custom allowlist with no valid MCP entries: do not expose the proxy.
|
|
return
|
|
}
|
|
servers := serversFromCapabilityAllowlist(allowed)
|
|
if len(servers) == 0 {
|
|
// Incomplete capability IDs produced an empty server set: fail closed
|
|
// rather than installing a restricted proxy that would list everything.
|
|
return
|
|
}
|
|
resolver, ok := frontend.(tool.CallResolver)
|
|
if !ok {
|
|
return
|
|
}
|
|
sub.Add(&restrictedCapabilityProxy{
|
|
Tool: frontend,
|
|
resolver: resolver,
|
|
allowed: allowed,
|
|
servers: servers,
|
|
})
|
|
}
|
|
|
|
func newSubagentCapabilityFrontend(parent *tool.Registry, runtime *MCPCapabilityRuntime) tool.Tool {
|
|
if runtime != nil {
|
|
return runtime.NewFrontend(nil, nil)
|
|
}
|
|
if parent == nil {
|
|
return nil
|
|
}
|
|
inner, ok := parent.Get("use_capability")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if uc, ok := inner.(*UseCapabilityTool); ok {
|
|
return uc.CloneForAgent(nil, nil)
|
|
}
|
|
return inner
|
|
}
|
|
|
|
// mcpCapabilityAllowlist converts profile/call tool names into capability IDs
|
|
// for the restricted use_capability proxy. Accepts complete mcp-tool:<s>/<t>,
|
|
// mcp-server:<s>, model-visible mcp__* names, and wildcards expanded against
|
|
// the parent. Incomplete prefixes such as "mcp-server:" or "mcp-tool:foo" are
|
|
// rejected so they cannot install a restricted proxy with an empty server set.
|
|
func mcpCapabilityAllowlist(parent *tool.Registry, names []string) map[string]bool {
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
expanded := names
|
|
if parent != nil {
|
|
expanded = expandToolPatterns(parent, names)
|
|
}
|
|
allowed := map[string]bool{}
|
|
for _, name := range expanded {
|
|
name = strings.TrimSpace(name)
|
|
switch {
|
|
case name == "use_capability":
|
|
// Explicit proxy grant is handled as a full frontend by the caller
|
|
// when this is the only MCP-related entry; leave empty here so a
|
|
// bare use_capability allowlist entry still installs unrestricted.
|
|
continue
|
|
case strings.HasPrefix(name, "mcp-server:"):
|
|
if server, ok := validMCPServerCapabilityID(name); ok {
|
|
allowed["mcp-server:"+server] = true
|
|
}
|
|
case strings.HasPrefix(name, "mcp-tool:"):
|
|
if server, raw, ok := validMCPToolCapabilityID(name); ok {
|
|
allowed["mcp-tool:"+server+"/"+raw] = true
|
|
}
|
|
default:
|
|
if parent != nil {
|
|
if tl, ok := parent.Get(name); ok {
|
|
if m, ok := tl.(tool.MCPMetadata); ok {
|
|
server := strings.TrimSpace(m.MCPServerName())
|
|
raw := strings.TrimSpace(m.MCPRawToolName())
|
|
if server != "" && raw != "" {
|
|
allowed["mcp-tool:"+server+"/"+raw] = true
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if server, raw, ok := tool.SplitMCPName(name); ok {
|
|
allowed["mcp-tool:"+server+"/"+raw] = true
|
|
}
|
|
}
|
|
}
|
|
return allowed
|
|
}
|
|
|
|
func allowlistRequestsUnrestrictedProxy(names []string) bool {
|
|
for _, name := range names {
|
|
if strings.TrimSpace(name) == "use_capability" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ReadOnlySubagentToolRegistry returns the tool set exposed to read-only
|
|
// sub-agents: read-only research tools plus a bash wrapper that enforces the
|
|
// permission-layer read-only command policy at execution time. Workflow/meta tools are
|
|
// excluded even when their Tool.ReadOnly contract is true.
|
|
func ReadOnlySubagentToolRegistry(parent *tool.Registry, names []string) *tool.Registry {
|
|
return ReadOnlySubagentToolRegistryForDepth(parent, names, 1, 1)
|
|
}
|
|
|
|
// ReadOnlySubagentToolRegistryForDepth returns the tool set exposed to read-only
|
|
// subagents. It permits only read-only delegation tools while another depth
|
|
// layer is available. Direct mcp__* schemas are never exposed; MCP goes only
|
|
// through use_capability. Dynamic execution still requires authorized server +
|
|
// readOnlyHint + non-destructive (enforced by ReadOnlyExecution), so strict
|
|
// agents share the stable proxy schema and connection reuse without permission
|
|
// relaxation.
|
|
//
|
|
// Custom profile/call allowlists remain authoritative and convert MCP names
|
|
// into a capability-id allowlist on a restricted proxy.
|
|
func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
|
|
return ReadOnlySubagentToolRegistryForDepthWithRuntime(parent, names, childDepth, maxDepth, nil)
|
|
}
|
|
|
|
// ReadOnlySubagentToolRegistryForDepthWithRuntime is the read-only registry
|
|
// builder with an optional session MCP runtime for proxy injection.
|
|
func ReadOnlySubagentToolRegistryForDepthWithRuntime(parent *tool.Registry, names []string, childDepth, maxDepth int, runtime *MCPCapabilityRuntime) *tool.Registry {
|
|
exclude := append([]string(nil), subagentAlwaysHiddenTools...)
|
|
if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
|
|
exclude = append(exclude, subagentRecursiveTools...)
|
|
} else {
|
|
exclude = append(exclude, "task", "run_skill", "explore", "research", "review", "security_review")
|
|
}
|
|
exclude = append(exclude, subagentJobTools...)
|
|
exclude = append(exclude, plannerNonResearchTools...)
|
|
exclude = append(exclude, readOnlySubagentWorkflowTools...)
|
|
ex := make(map[string]bool, len(exclude))
|
|
for _, e := range exclude {
|
|
ex[e] = true
|
|
}
|
|
sub := tool.NewRegistry()
|
|
if parent == nil {
|
|
return sub
|
|
}
|
|
src := names
|
|
if len(src) == 0 {
|
|
src = parent.Names()
|
|
} else {
|
|
src = expandToolPatterns(parent, src)
|
|
}
|
|
for _, name := range src {
|
|
if ex[name] {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(name, "mcp-tool:") || strings.HasPrefix(name, "mcp-server:") {
|
|
continue
|
|
}
|
|
tl, ok := parent.Get(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name == "bash" {
|
|
sub.Add(readOnlyBash{inner: tl})
|
|
continue
|
|
}
|
|
// Direct MCP never enters the strict registry — use_capability only.
|
|
if isInstalledMCPTool(tl) || strings.HasPrefix(name, tool.MCPNamePrefix) {
|
|
continue
|
|
}
|
|
if !tl.ReadOnly() {
|
|
continue
|
|
}
|
|
sub.Add(tl)
|
|
}
|
|
attachSubagentCapabilityProxy(parent, sub, names, runtime)
|
|
return sub
|
|
}
|
|
|
|
// expandToolPatterns resolves explicit wildcard allowlist entries from imported
|
|
// agent profiles against the current registry. Expansion is deterministic and
|
|
// session-local, so optional MCP tools only enter a child after connection.
|
|
func expandToolPatterns(parent *tool.Registry, names []string) []string {
|
|
if parent == nil {
|
|
return nil
|
|
}
|
|
available := parent.Names()
|
|
seen := map[string]bool{}
|
|
out := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
if !strings.ContainsAny(name, "*?[") {
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
out = append(out, name)
|
|
}
|
|
continue
|
|
}
|
|
for _, candidate := range available {
|
|
matched, err := filepath.Match(name, candidate)
|
|
if err == nil && matched && !seen[candidate] {
|
|
seen[candidate] = true
|
|
out = append(out, candidate)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FilterReadOnlyRegistry builds a sub-registry containing only tools whose
|
|
// ReadOnly contract is true, minus explicit exclusions. MCP tools must
|
|
// additionally come from an authorized server and must not carry
|
|
// destructiveHint.
|
|
func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Registry {
|
|
ex := make(map[string]bool, len(exclude))
|
|
for _, e := range exclude {
|
|
ex[e] = true
|
|
}
|
|
sub := tool.NewRegistry()
|
|
if parent == nil {
|
|
return sub
|
|
}
|
|
for _, name := range parent.Names() {
|
|
if ex[name] {
|
|
continue
|
|
}
|
|
tl, ok := parent.Get(name)
|
|
if !ok || !tl.ReadOnly() {
|
|
continue
|
|
}
|
|
if isInstalledMCPTool(tl) && (!mcpServerAuthorized(tl) || mcpDestructiveHint(tl)) {
|
|
continue
|
|
}
|
|
sub.Add(tl)
|
|
}
|
|
return sub
|
|
}
|
|
|
|
func (t *TaskTool) resolveSubSessionRuntime(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
|
|
prov, pricing, ctxWin := t.prov, t.pricing, t.contextWindow
|
|
if t.resolveProvider != nil && (modelRef != "" || effort != "") {
|
|
p, pr, cw, err := t.resolveProvider(modelRef, effort)
|
|
if err != nil {
|
|
return nil, nil, 0, err
|
|
}
|
|
prov, pricing, ctxWin = p, pr, cw
|
|
}
|
|
return prov, pricing, ctxWin, nil
|
|
}
|
|
|
|
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, entrance string, grant ReviewReportGrant) (string, error) {
|
|
opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
|
|
opts.ModelRef = modelRef
|
|
opts.RequireReviewReportKind = grant.Delivery
|
|
// Capture the pristine task before host framing is prepended: delivery
|
|
// intent classification must judge the task, not the wrapper.
|
|
opts.ClassifierTaskText = prompt
|
|
prompt = subagentImageNote(ctx) + t.withWorkspaceContext(upstreamNote(ctx)+prompt) + "\n\n" + completeSubtaskContract
|
|
opts.ExpectCompletionReport, opts.HandoffEntrance = true, entrance
|
|
// Child provider owns the final vision decision. Text-only providers
|
|
// retain the attachment metadata but omit image parts during serialization.
|
|
ctx = WithUserImages(ctx, SubagentImageCandidates(ctx))
|
|
return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
|
|
}
|
|
|
|
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, entrance string, grant ReviewReportGrant) (string, error) {
|
|
opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
|
|
opts.RequireReviewReportKind = grant.Delivery
|
|
ctx, prompt, opts = t.prepareSubSession(ctx, prompt, opts, modelRef, entrance)
|
|
return RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, prompt, opts, sink)
|
|
}
|
|
|
|
// subagentOptions is the single construction point for the run options every
|
|
// sub-agent spawned through this tool shares (task, read_only_task, and
|
|
// parallel_tasks children). Compaction, language preferences, and depth limits
|
|
// must stay uniform across those paths — add new fields here, not at call sites.
|
|
func (t *TaskTool) subagentOptions(ctx context.Context, maxSteps int, pricing *provider.Pricing, ctxWin, childDepth int, recoveryTaskID string, mutationObserver *checkpoint.MutationObserver) Options {
|
|
opts := Options{
|
|
MaxSteps: maxSteps,
|
|
Temperature: t.temperature,
|
|
Pricing: pricing,
|
|
UsageSource: event.UsageSourceSubagent,
|
|
Gate: t.gate,
|
|
ContextWindow: ctxWin,
|
|
RecentKeep: t.recentKeep,
|
|
CompactionBudgets: t.budgets,
|
|
CompactRatio: t.compactRatio,
|
|
ArchiveDir: t.archiveDir,
|
|
KeepPolicy: t.keepPolicy,
|
|
ResponseLanguage: ResponseLanguageFromContext(ctx),
|
|
ReasoningLanguage: ReasoningLanguageFromContext(ctx),
|
|
SubagentDepth: childDepth,
|
|
MaxSubagentDepth: t.maxDepth(),
|
|
DeliveryProfile: t.deliveryProfile,
|
|
Ablation: t.ablation,
|
|
WorkspaceLease: t.workspaceLease,
|
|
RecoveryGate: t.recoveryGate,
|
|
RecoveryAgentID: "subagent",
|
|
RecoveryTaskID: recoveryTaskID,
|
|
MutationObserver: mutationObserver,
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func subagentRecoveryTaskID(ctx context.Context, ref string) string {
|
|
if ref = strings.TrimSpace(ref); ref != "" {
|
|
return "subagent:" + ref
|
|
}
|
|
if callID, _, _, ok := CallContext(ctx); ok && strings.TrimSpace(callID) != "" {
|
|
return "subagent:" + strings.TrimSpace(callID)
|
|
}
|
|
return "subagent"
|
|
}
|
|
|
|
// WithRecoveryGate shares Auto Guard with spawned sub-agents.
|
|
func (t *TaskTool) WithRecoveryGate(g RecoveryGate) *TaskTool {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
t.recoveryGate = g
|
|
return t
|
|
}
|
|
|
|
// WithMutationObserver shares the host mutation observer with spawned sub-agents.
|
|
// Foreground children inherit the parent ownership turn; background children
|
|
// keep the turn that spawned them (set via OwnershipTurn at Begin).
|
|
func (t *TaskTool) WithMutationObserver(obs *checkpoint.MutationObserver) *TaskTool {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
t.mutationObserver = obs
|
|
return t
|
|
}
|
|
|
|
func (t *TaskTool) withWorkspaceContext(prompt string) string {
|
|
if t == nil {
|
|
return prompt
|
|
}
|
|
ctx := subagentWorkspaceContext(t.workspaceRoot)
|
|
if ctx == "" {
|
|
return prompt
|
|
}
|
|
return ctx + "\n\n" + prompt
|
|
}
|
|
|
|
func subagentWorkspaceContext(root string) string {
|
|
root = strings.TrimSpace(root)
|
|
if root == "" {
|
|
return ""
|
|
}
|
|
// Wording note: avoid incidental action verbs ("resolve", "fix", …) in this
|
|
// host framing — it is prepended to every sub-agent prompt and must never
|
|
// read as task intent (see classifierTaskText, which also strips it).
|
|
return `<workspace-context event="SubagentWorkspace">
|
|
Current workspace: ` + strconv.Quote(root) + `
|
|
File tools interpret relative paths against this workspace. For project inspection, prefer "." or relative paths unless the user explicitly named another absolute path.
|
|
</workspace-context>`
|
|
}
|
|
|
|
func FormatSubagentReference(run *SubagentRun) string {
|
|
if run == nil || run.Ref == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "Subagent reference: %s\n", run.Ref)
|
|
if strings.TrimSpace(run.ForkedFrom) != "" {
|
|
fmt.Fprintf(&b, "Forked from: %s\n", strings.TrimSpace(run.ForkedFrom))
|
|
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 ")
|
|
b.WriteString(run.Ref)
|
|
b.WriteString(" as `continue_from`. Start a fresh subagent when the next task is independent.")
|
|
return b.String()
|
|
}
|
|
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.")
|
|
return b.String()
|
|
}
|
|
|
|
func FormatSubagentRunResult(answer string, run *SubagentRun, failed bool) string {
|
|
answer = GuardSubagentHostDecisionText(answer)
|
|
if run == nil || run.Ref == "" {
|
|
return answer
|
|
}
|
|
if failed {
|
|
if answer == "" {
|
|
return "Subagent reference (failed): " + run.Ref
|
|
}
|
|
return "Subagent reference (failed): " + run.Ref + "\n\nFinal answer:\n" + answer
|
|
}
|
|
return FormatSubagentReference(run) + "\n\nFinal answer:\n" + answer
|
|
}
|
|
|
|
// GuardSubagentHostDecisionText appends a fixed boundary warning only when a
|
|
// child agent result appears to discuss host approval or user-owned decisions.
|
|
// The implementation lives in internal/tool so the skill tools share the exact
|
|
// same phrase list and notice.
|
|
func GuardSubagentHostDecisionText(answer string) string {
|
|
return tool.GuardSubagentHostDecisionText(answer)
|
|
}
|
|
|
|
// maxReviewReportNudges bounds the in-session completion nudges sent to a
|
|
// review subagent that finished without submitting review_report. Each nudge is
|
|
// one cheap continuation request on the same (cached) subagent session — far
|
|
// cheaper than discarding the run and re-reviewing from scratch.
|
|
// maxReviewReportNudges is the single in-session retry after a review run that
|
|
// produced no typed report. One is the useful number: the nudge asks only for
|
|
// the submission, and a second refusal means the tool is unreachable rather
|
|
// than forgotten, which no further asking fixes.
|
|
const maxReviewReportNudges = 1
|
|
|
|
// reviewWithoutVerdictNote marks a review the host has no typed verdict for, so
|
|
// the prose is not read as one the gate weighed and let through.
|
|
const reviewWithoutVerdictNote = "[no recorded verdict] The reviewer did not submit a review_report, so the host holds no verdict for this run: nothing below was checked against its receipts, and a blocking finding here will not stop delivery on its own.\n\n"
|
|
|
|
// reviewReportTaskContract is appended to the task prompt of a review subagent
|
|
// whose run must end with a typed report. The skill body describes how to
|
|
// review; this states the non-negotiable submission protocol.
|
|
func reviewReportTaskContract(kind evidence.ReviewKind) string {
|
|
return fmt.Sprintf(`<review-report-contract event="SubagentReviewReport">
|
|
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.
|
|
</review-report-contract>`, string(kind))
|
|
}
|
|
|
|
// reviewReportNudgePrompt asks an already-finished review subagent to submit
|
|
// the missing typed report without redoing the review.
|
|
func reviewReportNudgePrompt(kind evidence.ReviewKind) string {
|
|
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))
|
|
}
|
|
|
|
// RunSubAgentWithSession continues an existing sub-agent session with prompt and
|
|
// returns the latest final assistant answer. Fresh sub-agents pass a newly-created
|
|
// session; continued sub-agents pass a loaded transcript session.
|
|
//
|
|
// Each call installs an independent session-private temporary directory Manager
|
|
// so parent, sibling, and nested sub-agents never share temporary files.
|
|
// continue_from restores conversation history only — a new run still gets a
|
|
// fresh temporary directory.
|
|
func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (answer string, err error) {
|
|
if sess == nil {
|
|
return "", fmt.Errorf("sub-agent session is nil")
|
|
}
|
|
// Isolate temporary files for this run before any tool execution.
|
|
ctx = tool.WithoutGoalTurnRecorder(ctx)
|
|
if opts.MemoryQueue != nil {
|
|
ctx = memory.WithQueue(ctx, opts.MemoryQueue)
|
|
} else {
|
|
ctx = memory.WithoutQueue(ctx)
|
|
}
|
|
if opts.Jobs == nil {
|
|
ctx = jobs.WithoutManager(ctx)
|
|
}
|
|
ctx, releaseTemp := withSubagentSessionTemp(ctx)
|
|
defer releaseTemp()
|
|
if opts.SubagentDepth > 0 {
|
|
ctx = WithSubagentDepth(ctx, opts.SubagentDepth)
|
|
}
|
|
// Callers that wrap the prompt themselves (runSubSession) set
|
|
// ClassifierTaskText before wrapping; for everyone else the prompt is
|
|
// still pristine here, so capture it before host framing is prepended.
|
|
if strings.TrimSpace(opts.ClassifierTaskText) == "" {
|
|
opts.ClassifierTaskText = prompt
|
|
}
|
|
planWorkflow := PlanModeFromContext(ctx)
|
|
if opts.SubagentDepth > 0 && isFreshSubagentSession(sess) {
|
|
prompt = subagentStartContext + "\n\n" + prompt
|
|
}
|
|
if planWorkflow && !strings.Contains(prompt, planmode.Marker) {
|
|
prompt = planmode.Marker + "\n\n" + prompt
|
|
}
|
|
if kind := opts.RequireReviewReportKind; kind != "" {
|
|
prompt = prompt + "\n\n" + reviewReportTaskContract(kind)
|
|
}
|
|
// Nested reasoning stays isolated; the parent consumes only final Content.
|
|
// Require it so a reasoning-only stop cannot fall back to older tool text.
|
|
opts.RequireVisibleFinal = true
|
|
sub := newDelegatedAgent(ctx, prov, reg, sess, opts, sink)
|
|
// One defer: this returns from a salvage, a review failure, a provider
|
|
// error and a clean answer, and per-path calls would miss one.
|
|
defer func() { observeSubagentHandoff(sink, sub, sess, opts, answer, err) }()
|
|
sub.SetPlanMode(planWorkflow)
|
|
if err := sub.Run(ctx, prompt); err != nil {
|
|
// Still merge any partial child evidence so parent gates see real writes.
|
|
mergeChildEvidence(ctx, sub)
|
|
if answer, ok := salvageReadinessExhaustedAnswer(sub, sess, opts, err); ok {
|
|
return composeSubagentAnswer(ctx, answer, sub, SubagentWriteClaim(ctx), opts.ClassifierTaskText), nil
|
|
}
|
|
return "", fmt.Errorf("sub-agent: %w", err)
|
|
}
|
|
// Review subagents hand back a typed report the parent's gate can verify.
|
|
// A run that finished without one is nudged on the same session, evidence
|
|
// preserved so review_report can cite the reads it already earned.
|
|
if kind := opts.RequireReviewReportKind; kind != "" {
|
|
nudges := 0
|
|
for !sub.HasSuccessfulReviewReport(kind) && nudges < maxReviewReportNudges {
|
|
nudges++
|
|
sub.pending.preserveEvidence = true
|
|
if err := sub.Run(ctx, reviewReportNudgePrompt(kind)); err != nil {
|
|
mergeChildEvidence(ctx, sub)
|
|
// A retry that fails still keeps local parent mutations; the
|
|
// parent turns this into Partial/Unverified rather than rolling back.
|
|
return "", fmt.Errorf("sub-agent: %w", err)
|
|
}
|
|
}
|
|
if !sub.HasSuccessfulReviewReport(kind) {
|
|
mergeChildEvidence(ctx, sub)
|
|
// Nudging pays at every role setting: a verdict the host can read is
|
|
// how a block reaches the gate. Failing over its absence does not —
|
|
// no report is no block, the state a killed run leaves anyway.
|
|
if !opts.DeliveryProfile {
|
|
if answer := latestAssistantAnswer(sess); answer != "" {
|
|
return composeSubagentAnswer(ctx, reviewWithoutVerdictNote+answer, sub, SubagentWriteClaim(ctx), opts.ClassifierTaskText), nil
|
|
}
|
|
}
|
|
dumpRef := dumpFailedSubagentSession(opts.ArchiveDir, string(kind), sess)
|
|
// Partial path: local changes are retained; the parent readiness
|
|
// layer treats missing review as Partial/Unverified (not rollback).
|
|
return "", &ReviewUnavailableError{
|
|
Kind: string(kind),
|
|
Nudges: nudges,
|
|
Dump: dumpRef,
|
|
}
|
|
}
|
|
}
|
|
mergeChildEvidence(ctx, sub)
|
|
if answer := latestAssistantAnswer(sess); answer != "" {
|
|
return composeSubagentAnswer(ctx, answer, sub, SubagentWriteClaim(ctx), opts.ClassifierTaskText), nil
|
|
}
|
|
return "", fmt.Errorf("sub-agent finished without producing a final answer")
|
|
}
|
|
|
|
// readOnlyAgentConstruction is the single pairing every strictly read-only
|
|
// loop shares: the permanent ReadOnlyExecution flag plus the final registry
|
|
// filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
|
|
// that still use NewReadOnlyAgent build through it, so a missed call site
|
|
// cannot set only half the boundary. The interactive two-model planner uses
|
|
// NewPlannerAgent instead (PlannerMCPExecution).
|
|
func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
|
|
opts.ReadOnlyExecution = true
|
|
opts.PlannerMCPExecution = false
|
|
return strictReadOnlyExecutionRegistry(reg), opts
|
|
}
|
|
|
|
// NewPlannerAgent constructs the interactive two-model planner: permanent
|
|
// ReadOnlyExecution still blocks bash, file writers, and ordinary non-MCP
|
|
// writers, while PlannerMCPExecution allows authorized, non-destructive MCP
|
|
// through the stable use_capability proxy without requiring readOnlyHint.
|
|
func NewPlannerAgent(prov provider.Provider, reg *tool.Registry, sess *Session, opts Options, sink event.Sink) *Agent {
|
|
opts.EventSource = event.UsageSourcePlanner
|
|
opts.ReadOnlyExecution = true
|
|
opts.PlannerMCPExecution = true
|
|
// The coordinator needs visible plan text to hand off to the executor;
|
|
// reasoning shown in a frontend is not a substitute for that contract.
|
|
opts.RequireVisibleFinal = true
|
|
// Keep construction-time filter for ordinary tools; use_capability stays
|
|
// because it is ReadOnly. Direct mcp__* tools are already excluded by
|
|
// PlannerToolRegistry. Dynamic MCP targets are re-checked after resolve.
|
|
reg = plannerExecutionRegistry(reg)
|
|
return New(prov, reg, sess, opts, sink)
|
|
}
|
|
|
|
// plannerExecutionRegistry is the construction-time filter for NewPlannerAgent.
|
|
// It removes ordinary writers and destructive direct MCP tools while keeping
|
|
// use_capability and built-in research tools. Host-starting deferred MCP
|
|
// targets are allowed at execution time under PlannerMCPExecution.
|
|
func plannerExecutionRegistry(reg *tool.Registry) *tool.Registry {
|
|
filtered := tool.NewRegistry()
|
|
if reg == nil {
|
|
return filtered
|
|
}
|
|
for _, name := range reg.Names() {
|
|
target, ok := reg.Get(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name == "use_capability" {
|
|
filtered.Add(target)
|
|
continue
|
|
}
|
|
if strings.HasPrefix(name, tool.MCPNamePrefix) {
|
|
// Defense in depth: planner never exposes direct MCP schemas.
|
|
continue
|
|
}
|
|
if !target.ReadOnly() || mcpDestructiveHint(target) {
|
|
continue
|
|
}
|
|
if h, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() {
|
|
// Ordinary host mutations stay out; MCP startup is only via proxy.
|
|
continue
|
|
}
|
|
filtered.Add(target)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// RunReadOnlySubAgentWithSession is the construction boundary for every
|
|
// strictly read-only child loop. Registry filtering limits the visible surface;
|
|
// this permanent execution flag also re-checks targets resolved dynamically by
|
|
// proxy tools such as use_capability. It never enables PlannerMCPExecution.
|
|
func RunReadOnlySubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
|
|
reg, opts = readOnlyAgentConstruction(reg, opts)
|
|
return RunSubAgentWithSession(ctx, prov, reg, sess, prompt, opts, sink)
|
|
}
|
|
|
|
// strictReadOnlyExecutionRegistry is the final construction-time filter shared
|
|
// by every strict child. Callers still apply role-specific filtering (review,
|
|
// planner, profile allowlists), while this layer guarantees that a missed call
|
|
// site cannot expose writers, destructive MCP tools, readers from unauthorized
|
|
// servers, or an unauthorized host-starting target to the model.
|
|
func strictReadOnlyExecutionRegistry(reg *tool.Registry) *tool.Registry {
|
|
filtered := tool.NewRegistry()
|
|
if reg == nil {
|
|
return filtered
|
|
}
|
|
for _, name := range reg.Names() {
|
|
target, ok := reg.Get(name)
|
|
if !ok || !target.ReadOnly() || mcpDestructiveHint(target) {
|
|
continue
|
|
}
|
|
if isInstalledMCPTool(target) && !mcpServerAuthorized(target) {
|
|
continue
|
|
}
|
|
if mutation, ok := target.(tool.ReadOnlyExecutionHostMutation); ok && mutation.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(target) {
|
|
continue
|
|
}
|
|
filtered.Add(target)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// latestAssistantAnswer walks the session backwards for the last assistant
|
|
// message with content — that's the sub-agent's final answer. Intermediate
|
|
// assistant messages with tool_calls but no text don't count.
|
|
func latestAssistantAnswer(sess *Session) string {
|
|
if sess == nil {
|
|
return ""
|
|
}
|
|
for _, v := range slices.Backward(sess.Messages) {
|
|
m := v
|
|
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
|
|
return m.Content
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// dumpFailedSubagentSession best-effort persists a failed report-required
|
|
// subagent transcript for post-hoc diagnosis (read-only skill subagents are
|
|
// otherwise ephemeral, so a protocol failure leaves no trace). Returns a
|
|
// human-readable suffix naming the dump, or "" when disabled/failed.
|
|
func dumpFailedSubagentSession(archiveDir, kind string, sess *Session) string {
|
|
if strings.TrimSpace(archiveDir) == "" || sess == nil {
|
|
return ""
|
|
}
|
|
dir := filepath.Join(archiveDir, "subagent-report-failures")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return ""
|
|
}
|
|
path := filepath.Join(dir, fmt.Sprintf("%s-%d.jsonl", kind, time.Now().UnixNano()))
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
enc := json.NewEncoder(f)
|
|
for _, m := range sess.Messages {
|
|
if err := enc.Encode(m); err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
return "; transcript dumped to " + path
|
|
}
|
|
|
|
// mergeChildEvidence folds a sub-agent's real receipts into the parent ledger
|
|
// carried on ctx. Meta tools themselves are never mutations.
|
|
func mergeChildEvidence(ctx context.Context, sub *Agent) {
|
|
if sub == nil {
|
|
return
|
|
}
|
|
parent, ok := evidence.FromContext(ctx)
|
|
if !ok || parent == nil {
|
|
return
|
|
}
|
|
parent.MergeChild(sub.EvidenceSummary())
|
|
}
|
|
|
|
// EvidenceSummary exports this agent's turn-scoped receipts for parent merge.
|
|
func (a *Agent) EvidenceSummary() evidence.ChildEvidenceSummary {
|
|
if a == nil || a.task.ledger == nil {
|
|
return evidence.ChildEvidenceSummary{}
|
|
}
|
|
return a.task.ledger.Summary()
|
|
}
|
|
|
|
func isFreshSubagentSession(sess *Session) bool {
|
|
if sess == nil {
|
|
return false
|
|
}
|
|
snap := sess.Snapshot()
|
|
return len(snap) == 1 && snap[0].Role == provider.RoleSystem
|
|
}
|
|
|
|
// NestedSink returns a sink that forwards a sub-agent's tool activity to the
|
|
// parent stream, nested under the tool call carried by ctx, so a frontend shows
|
|
// it beneath that call (the same nesting `task` uses). Falls back to the given
|
|
// sink when ctx carries no call context. Used by subagent skills.
|
|
func NestedSink(ctx context.Context, fallback event.Sink) event.Sink {
|
|
parentID, parent, _, ok := CallContext(ctx)
|
|
if !ok || parent == nil {
|
|
return fallback
|
|
}
|
|
return subSinkFor(parentID, parent)
|
|
}
|