1087 lines
44 KiB
Go
1087 lines
44 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/evidence"
|
|
"reasonix/internal/jobs"
|
|
"reasonix/internal/planmode"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// 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. 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.
|
|
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",
|
|
"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."
|
|
|
|
// AlwaysHiddenSubagentTools returns the tool names excluded from every
|
|
// subagent's registry regardless of an explicit allowlist or delegation
|
|
// depth (unlike subagentRecursiveTools, which depends on remaining depth).
|
|
// That covers both subagentAlwaysHiddenTools and subagentJobTools —
|
|
// SubagentToolRegistryForDepth and its read-only variant strip the job tools
|
|
// unconditionally too. Host UIs offering a tool picker for a subagent
|
|
// profile's allowed-tools should exclude these from the offered choices —
|
|
// selecting them would be silently ignored at runtime.
|
|
func AlwaysHiddenSubagentTools() []string {
|
|
names := append([]string(nil), subagentAlwaysHiddenTools...)
|
|
return append(names, subagentJobTools...)
|
|
}
|
|
|
|
// 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.
|
|
func SubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *tool.Registry {
|
|
exclude := append([]string(nil), subagentAlwaysHiddenTools...)
|
|
if childDepth >= NormalizeMaxSubagentDepth(maxDepth) {
|
|
exclude = append(exclude, subagentRecursiveTools...)
|
|
}
|
|
exclude = append(exclude, subagentJobTools...)
|
|
sub := FilterRegistry(parent, names, exclude...)
|
|
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 "", fmt.Errorf("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() }
|
|
|
|
type readOnlyBash struct {
|
|
inner tool.Tool
|
|
}
|
|
|
|
func (b readOnlyBash) Name() string { return "bash" }
|
|
|
|
func (b readOnlyBash) 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 read-only command in the shell", 1)
|
|
return desc + " Only plan-mode safe read-only commands are allowed; shell operators, background execution, process preservation, and write-capable arguments are blocked."
|
|
}
|
|
|
|
func (readOnlyBash) Schema() json.RawMessage {
|
|
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string","description":"Read-only shell command to execute in the foreground. Must match the plan-mode safe bash policy."}},"required":["command"]}`)
|
|
}
|
|
|
|
func (b readOnlyBash) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
decision := planmode.Policy{}.Decide(planmode.Call{Name: "bash", Args: args})
|
|
if decision.Blocked {
|
|
return decision.Message, nil
|
|
}
|
|
return b.inner.Execute(ctx, args)
|
|
}
|
|
|
|
func (readOnlyBash) ReadOnly() bool { return true }
|
|
|
|
// 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
|
|
softCompactRatio float64
|
|
toolResultSnipRatio float64
|
|
compactRatio float64
|
|
compactForceRatio float64
|
|
recentKeep int
|
|
temperature float64
|
|
archiveDir string
|
|
keepPolicy KeepPolicy
|
|
sysPrompt string
|
|
gate Gate
|
|
subagentModel string
|
|
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
|
|
}
|
|
|
|
// 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).
|
|
func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *tool.Registry,
|
|
maxSteps, contextWindow, recentKeep int, softCompactRatio, toolResultSnipRatio, compactRatio, compactForceRatio, temperature float64, archiveDir, sysPrompt string, gate Gate,
|
|
keepPolicy KeepPolicy, subagentModel, subagentEffort string, resolveProvider func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
|
|
if sysPrompt == "" {
|
|
sysPrompt = DefaultTaskSystemPrompt
|
|
}
|
|
return &TaskTool{
|
|
prov: prov,
|
|
pricing: pricing,
|
|
parentReg: parentReg,
|
|
maxSteps: maxSteps,
|
|
contextWindow: contextWindow,
|
|
recentKeep: recentKeep,
|
|
softCompactRatio: softCompactRatio,
|
|
toolResultSnipRatio: toolResultSnipRatio,
|
|
compactRatio: compactRatio,
|
|
compactForceRatio: compactForceRatio,
|
|
temperature: temperature,
|
|
archiveDir: archiveDir,
|
|
keepPolicy: keepPolicy,
|
|
sysPrompt: sysPrompt,
|
|
gate: gate,
|
|
subagentModel: subagentModel,
|
|
subagentEffort: subagentEffort,
|
|
resolveProvider: resolveProvider,
|
|
maxSubagentDepth: DefaultMaxSubagentDepth,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (t *TaskTool) Name() string { return "task" }
|
|
|
|
func (t *TaskTool) Description() string {
|
|
return "Spawn a sub-agent for a focused sub-task. The sub-agent runs in its own session with the same provider and a filtered tool list (defaults to every parent tool, then applies the subagent boundary: " + subagentToolBoundarySummary + "). Only its final answer is returned. Use this to (a) keep long exploration sequences out of the parent's context budget, or (b) delegate self-contained work like 'find every place that calls X and summarise the patterns'."
|
|
}
|
|
|
|
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."},
|
|
"tools":{"type":"array","items":{"type":"string"},"description":"Optional tool whitelist. ` + 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)."},
|
|
"effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."},
|
|
"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 }
|
|
|
|
// ResolveProfile extracts model/effort from task args and applies config defaults.
|
|
func (t *TaskTool) ResolveProfile(args json.RawMessage) *event.Profile {
|
|
var p struct {
|
|
Model string `json:"model"`
|
|
Effort string `json:"effort"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return nil
|
|
}
|
|
model, effort := t.effectiveProfile(p.Model, p.Effort)
|
|
if model == "" && effort == "" {
|
|
return nil
|
|
}
|
|
return &event.Profile{Model: model, Effort: effort}
|
|
}
|
|
|
|
// 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 plan-mode safe foreground 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) ResolveProfile(args json.RawMessage) *event.Profile {
|
|
if r == nil || r.task == nil {
|
|
return nil
|
|
}
|
|
return r.task.ResolveProfile(args)
|
|
}
|
|
|
|
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)
|
|
}
|
|
if strings.TrimSpace(p.Prompt) == "" {
|
|
return "", fmt.Errorf("prompt is required")
|
|
}
|
|
|
|
maxSteps := r.task.childMaxSteps(p.MaxSteps)
|
|
|
|
childDepth, err := r.task.nextSubagentDepth(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
subReg := ReadOnlySubagentToolRegistryForDepth(r.task.parentReg, p.Tools, childDepth, r.task.maxDepth())
|
|
if subReg.Len() == 0 {
|
|
return "", fmt.Errorf("read_only_task has no read-only tools available")
|
|
}
|
|
modelRef, effortRef := r.task.effectiveProfile(p.Model, p.Effort)
|
|
prov, pricing, ctxWin, err := r.task.resolveSubSessionRuntime(modelRef, effortRef)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read-only sub-agent profile: %w", err)
|
|
}
|
|
answer, err := r.task.runSubSession(ctx, p.Prompt, subReg, subSink(ctx), maxSteps, prov, pricing, ctxWin, NewSession(DefaultReadOnlyTaskSystemPrompt), childDepth)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return GuardSubagentHostDecisionText(answer), nil
|
|
}
|
|
|
|
// childMaxSteps resolves a sub-agent's step budget. An explicit request wins.
|
|
// Otherwise mirror the parent: a finite parent caps the child at half its
|
|
// budget (min 5) so a delegated sub-task stays shorter than the whole turn; an
|
|
// unbounded parent yields an unbounded child (it shares the parent's ctx, so
|
|
// cancelling the turn stops it, and it compacts its own context — the same
|
|
// bounds the parent has). Shared by task, read_only_task, and parallel_tasks
|
|
// children so the default cannot drift per call site.
|
|
func (t *TaskTool) childMaxSteps(requested int) int {
|
|
if requested > 0 {
|
|
return requested
|
|
}
|
|
if t.maxSteps <= 0 {
|
|
return 0
|
|
}
|
|
half := t.maxSteps / 2
|
|
if half < 5 {
|
|
half = 5
|
|
}
|
|
return half
|
|
}
|
|
|
|
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"`
|
|
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 p.Prompt == "" {
|
|
return "", fmt.Errorf("prompt is required")
|
|
}
|
|
|
|
maxSteps := t.childMaxSteps(p.MaxSteps)
|
|
|
|
childDepth, err := t.nextSubagentDepth(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
subReg := t.buildSubReg(p.Tools, childDepth)
|
|
modelRef, effortRef := t.effectiveProfile(p.Model, p.Effort)
|
|
parentID, parent, _, _ := CallContext(ctx)
|
|
run, err := t.prepareTranscriptRun(subReg, modelRef, effortRef, ParentSession(ctx), parentID, p.ContinueFrom, p.ForkFrom)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
prov, pricing, ctxWin, err := t.resolveSubSessionRuntime(modelRef, effortRef)
|
|
if err != nil {
|
|
run.Release()
|
|
return "", fmt.Errorf("sub-agent profile: %w", err)
|
|
}
|
|
|
|
// Background: register a job that runs the sub-agent under the manager's
|
|
// session context (so it survives this turn) and return immediately. The
|
|
// sub-agent's tool activity still streams, nested under this call, because the
|
|
// nested sink captures the parent ID + stream now (not from the job ctx).
|
|
if p.RunInBackground {
|
|
jm, ok := jobs.FromContext(ctx)
|
|
if !ok {
|
|
if run != nil {
|
|
run.Release()
|
|
}
|
|
return "", fmt.Errorf("background execution is not available in this context")
|
|
}
|
|
nested := subSinkFor(parentID, parent)
|
|
label := p.Description
|
|
if label == "" {
|
|
label = "task"
|
|
}
|
|
if t.transcripts != nil && run != nil && run.Ref != "" {
|
|
if err := t.transcripts.MarkRunning(run); err != nil {
|
|
run.Release()
|
|
return "", err
|
|
}
|
|
}
|
|
job := jm.StartForSession(jobs.SessionFromContext(ctx), "task", label, func(jobCtx context.Context, _ io.Writer) (result string, err error) {
|
|
defer run.Release()
|
|
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))
|
|
}
|
|
}()
|
|
answer, err := t.runSubSession(jobCtx, p.Prompt, subReg, nested, maxSteps, prov, pricing, ctxWin, run.Session, childDepth)
|
|
if err != nil {
|
|
return FormatSubagentRunResult("", run, true), errors.Join(err, t.transcripts.SaveFailed(run))
|
|
}
|
|
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
|
|
})
|
|
if run != nil && run.Ref != "" {
|
|
return fmt.Sprintf("Started background task %q (%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, FormatSubagentReference(run)), nil
|
|
}
|
|
return fmt.Sprintf("Started background task %q (%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), nil
|
|
}
|
|
|
|
// Foreground: run synchronously, nesting events under this call.
|
|
defer run.Release()
|
|
answer, err := t.runSubSession(ctx, p.Prompt, subReg, subSink(ctx), maxSteps, prov, pricing, ctxWin, run.Session, childDepth)
|
|
if err != nil {
|
|
return "", errors.Join(err, t.transcripts.SaveFailed(run))
|
|
}
|
|
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
|
|
}
|
|
|
|
func (t *TaskTool) prepareTranscriptRun(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom 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")
|
|
}
|
|
// A task tool wired without a transcript store is a caller bug: fail loudly
|
|
// instead of silently dropping persistence (contract pinned since #3586).
|
|
if t.transcripts == nil {
|
|
return nil, fmt.Errorf("subagent transcript store is required")
|
|
}
|
|
// Headless runs (e.g. `reasonix run`) never mint a session path, so there is
|
|
// no parent session to own a transcript. Run the sub-agent ephemerally —
|
|
// exactly as before persisted transcripts existed — instead of failing the
|
|
// call. Continuation/fork need a persisted owner, so they error here.
|
|
if parentSession == "" {
|
|
if continueFrom != "" || legacyForkFrom != "" {
|
|
return nil, fmt.Errorf("subagent continuation requires a persisted session; none is active in this run")
|
|
}
|
|
return EphemeralSubagentRun(t.sysPrompt), nil
|
|
}
|
|
identityModel, identityEffort := t.effectiveIdentity(modelRef, effortRef)
|
|
spec := SubagentSpec{
|
|
Kind: "task",
|
|
Name: "task",
|
|
WorkspaceRoot: t.workspaceRoot,
|
|
ParentSession: parentSession,
|
|
ParentToolCallID: parentID,
|
|
SystemPrompt: t.sysPrompt,
|
|
Registry: subReg,
|
|
Model: identityModel,
|
|
Effort: identityEffort,
|
|
}
|
|
if continueFrom != "" {
|
|
return t.transcripts.PrepareContinue(continueFrom, spec)
|
|
}
|
|
if legacyForkFrom != "" {
|
|
return t.transcripts.PrepareLegacyForkFrom(legacyForkFrom, spec)
|
|
}
|
|
return t.transcripts.PrepareFresh(spec)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 SubagentToolRegistryForDepth(t.parentReg, names, childDepth, t.maxDepth())
|
|
}
|
|
|
|
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.
|
|
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
|
|
}
|
|
src := names
|
|
if len(src) == 0 {
|
|
src = parent.Names()
|
|
}
|
|
for _, name := range src {
|
|
if ex[name] {
|
|
continue
|
|
}
|
|
if tl, ok := parent.Get(name); ok {
|
|
sub.Add(tl)
|
|
}
|
|
}
|
|
return sub
|
|
}
|
|
|
|
var plannerNonResearchTools = []string{
|
|
"ask",
|
|
"bash_output",
|
|
"complete_step",
|
|
"slash_command",
|
|
"todo_write",
|
|
"wait",
|
|
}
|
|
|
|
// PlannerToolRegistry returns the tool set exposed to the two-model planner:
|
|
// read-only research tools only. It deliberately excludes workflow/meta tools
|
|
// that are technically read-only but can prompt the user, update visible task
|
|
// state, wait on jobs, or expand commands instead of inspecting context.
|
|
func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
|
|
exclude := append(SubagentMetaTools(), plannerNonResearchTools...)
|
|
return FilterReadOnlyRegistry(parent, exclude...)
|
|
}
|
|
|
|
// ReadOnlySubagentToolRegistry returns the tool set exposed to read-only
|
|
// sub-agents: read-only research tools plus a bash wrapper that enforces the
|
|
// plan-mode safe 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.
|
|
func ReadOnlySubagentToolRegistryForDepth(parent *tool.Registry, names []string, childDepth, maxDepth int) *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()
|
|
}
|
|
for _, name := range src {
|
|
if ex[name] {
|
|
continue
|
|
}
|
|
tl, ok := parent.Get(name)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name == "bash" {
|
|
sub.Add(readOnlyBash{inner: tl})
|
|
continue
|
|
}
|
|
if !tl.ReadOnly() {
|
|
continue
|
|
}
|
|
if u, ok := tl.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
|
// An external tool's self-reported readOnlyHint isn't trusted for a
|
|
// read-only research sub-agent; exclude it like a writer.
|
|
continue
|
|
}
|
|
sub.Add(tl)
|
|
}
|
|
return sub
|
|
}
|
|
|
|
// FilterReadOnlyRegistry builds a sub-registry containing only tools whose
|
|
// ReadOnly contract is true, minus explicit exclusions.
|
|
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 u, ok := tl.(tool.PlanModeUntrustedReadOnly); ok && u.PlanModeUntrustedReadOnly() {
|
|
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) (string, error) {
|
|
opts := t.subagentOptions(ctx, maxSteps, pricing, ctxWin, childDepth)
|
|
// Capture the pristine task before host framing is prepended: delivery
|
|
// intent classification must judge the task, not the wrapper.
|
|
opts.ClassifierTaskText = prompt
|
|
prompt = t.withWorkspaceContext(prompt)
|
|
return RunSubAgentWithSession(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) Options {
|
|
return Options{
|
|
MaxSteps: maxSteps,
|
|
Temperature: t.temperature,
|
|
Pricing: pricing,
|
|
UsageSource: event.UsageSourceSubagent,
|
|
Gate: t.gate,
|
|
ContextWindow: ctxWin,
|
|
RecentKeep: t.recentKeep,
|
|
SoftCompactRatio: t.softCompactRatio,
|
|
ToolResultSnipRatio: t.toolResultSnipRatio,
|
|
CompactRatio: t.compactRatio,
|
|
CompactForceRatio: t.compactForceRatio,
|
|
ArchiveDir: t.archiveDir,
|
|
KeepPolicy: t.keepPolicy,
|
|
ResponseLanguage: ResponseLanguageFromContext(ctx),
|
|
ReasoningLanguage: ReasoningLanguageFromContext(ctx),
|
|
SubagentDepth: childDepth,
|
|
MaxSubagentDepth: t.maxDepth(),
|
|
DeliveryProfile: t.deliveryProfile,
|
|
}
|
|
}
|
|
|
|
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.
|
|
const maxReviewReportNudges = 2
|
|
|
|
// 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.
|
|
func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) {
|
|
if sess == nil {
|
|
return "", fmt.Errorf("sub-agent session is nil")
|
|
}
|
|
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
|
|
}
|
|
if opts.SubagentDepth > 0 && isFreshSubagentSession(sess) {
|
|
prompt = subagentStartContext + "\n\n" + prompt
|
|
}
|
|
if kind := opts.RequireReviewReportKind; kind != "" {
|
|
prompt = prompt + "\n\n" + reviewReportTaskContract(kind)
|
|
}
|
|
sub := New(prov, reg, sess, opts, sink)
|
|
if err := sub.Run(ctx, prompt); err != nil {
|
|
// Still merge any partial child evidence so parent gates see real writes.
|
|
mergeChildEvidence(ctx, sub)
|
|
return "", fmt.Errorf("sub-agent: %w", err)
|
|
}
|
|
// Review/security subagents must hand back a typed report the parent's
|
|
// delivery gate can verify; prose alone would leave the gate demanding a
|
|
// review forever with no way to tell why it never arrives. A run that
|
|
// finished without the report gets bounded completion nudges on the same
|
|
// session (evidence preserved, so review_report can still cite the reads it
|
|
// already earned) before the whole run is declared failed.
|
|
if kind := opts.RequireReviewReportKind; kind != "" {
|
|
nudges := 0
|
|
for !sub.HasSuccessfulReviewReport(kind) && nudges < maxReviewReportNudges {
|
|
nudges++
|
|
sub.preserveEvidenceOnce = true
|
|
if err := sub.Run(ctx, reviewReportNudgePrompt(kind)); err != nil {
|
|
mergeChildEvidence(ctx, sub)
|
|
return "", fmt.Errorf("sub-agent: %w", err)
|
|
}
|
|
}
|
|
if !sub.HasSuccessfulReviewReport(kind) {
|
|
mergeChildEvidence(ctx, sub)
|
|
dumpRef := dumpFailedSubagentSession(opts.ArchiveDir, string(kind), sess)
|
|
return "", fmt.Errorf("%s subagent finished without submitting review_report (kind=%s) even after %d host nudges; the report must be submitted by the review subagent itself (the parent has no review_report tool) — re-run the review skill%s", kind, kind, nudges, dumpRef)
|
|
}
|
|
}
|
|
mergeChildEvidence(ctx, sub)
|
|
// Walk 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.
|
|
for i := len(sess.Messages) - 1; i >= 0; i-- {
|
|
m := sess.Messages[i]
|
|
if m.Role == provider.RoleAssistant && strings.TrimSpace(m.Content) != "" {
|
|
return m.Content, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("sub-agent finished without producing a final answer")
|
|
}
|
|
|
|
// 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.evidence == nil {
|
|
return evidence.ChildEvidenceSummary{}
|
|
}
|
|
return a.evidence.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)
|
|
}
|
|
|
|
// subSink forwards a sub-agent's tool dispatch/result events and billable usage
|
|
// to the parent's event stream. Only tool activity is nested visually; the
|
|
// sub-agent's text/reasoning stays isolated and only its final answer is returned.
|
|
//
|
|
// The sub-agent's own turn/text/reasoning events are dropped — forwarding them
|
|
// would make the parent transcript noisy and could imply they belong to the
|
|
// parent model context, which they do not.
|
|
//
|
|
// Usage events are observability only, so forwarding them preserves billing
|
|
// totals without polluting the parent provider-visible prefix.
|
|
//
|
|
// Tool events are tagged with the parent task call's ID so a frontend nests them
|
|
// under it. The forwarded call IDs are namespaced with the parent ID so a
|
|
// sub-agent call can never collide with a parent call in the frontend's
|
|
// dispatch→result matching. Falls back to Discard when there's no parent stream
|
|
// (the headless run loop, or a direct Execute in tests).
|
|
func subSink(ctx context.Context) event.Sink {
|
|
parentID, parent, _, ok := CallContext(ctx)
|
|
if !ok || parent == nil {
|
|
return event.Discard
|
|
}
|
|
return subSinkFor(parentID, parent)
|
|
}
|
|
|
|
// subSinkFor builds the nesting sink from an already-captured parent ID + stream,
|
|
// for the background path where the job runs under a context that no longer
|
|
// carries the call context. Falls back to Discard when there's no parent stream.
|
|
func subSinkFor(parentID string, parent event.Sink) event.Sink {
|
|
if parent == nil {
|
|
return event.Discard
|
|
}
|
|
return event.FuncSink(func(e event.Event) {
|
|
switch e.Kind {
|
|
case event.ToolDispatch, event.ToolResult:
|
|
e.Tool.ParentID = parentID
|
|
e.Tool.ID = parentID + "/" + e.Tool.ID
|
|
parent.Emit(e)
|
|
case event.Usage:
|
|
if e.UsageSource == "" {
|
|
e.UsageSource = event.UsageSourceSubagent
|
|
}
|
|
parent.Emit(e)
|
|
}
|
|
})
|
|
}
|