// Package dispatchguard is the single chokepoint that bounds agent/skill // composition: it stops a run from invoking one of its own ancestors // (loop), from nesting past a depth cap, and from spawning more than a // budget of descendant runs under one root. // // Why a standalone package: "invoke a skill/agent from inside a run" has // three dispatch surfaces — the agent_invoke/skill_invoke TOOLS, the // palette skill__/agent__ wrappers, and the agent-as-chatbot tool. The // guards historically lived only in the TOOLS, so the other two paths // recursed unbounded (the 2026-06-09 general-agent self-recursion // incident was exactly this) and the DB-walk guard the tools used failed // OPEN when the audit store was nil or a run's parent_run_id was empty // (the chatbot-tool path produces parentless runs). Every dispatch path // ultimately funnels through Executor.Run -> runInner, so enforcing the // guard there — against an in-memory ancestor chain carried on the // context — covers all three paths at once, synchronously, with no // dependency on the audit table. // // The chain + descendant budget ride on context.Context, so propagation // is automatic: a run stamps itself onto the ctx it hands to its agent // loop, every tool handler inherits that ctx, and any sub-invocation's // Executor.Run receives it — so the child sees its full ancestry without // anyone threading it explicitly. The budget counter is a shared pointer // created at the root and seen by every descendant; it is garbage // collected with the context, so there is no global map to clean up. // // This package deliberately imports nothing from skillexec/agentexec/ // agents (only the standard library) so it can be called from both // executors — and the future merged engine — without an import cycle. package dispatchguard import ( "context" "fmt" "slices" "sync/atomic" ) // Default limits, used when Enter is called with a non-positive value. // Chosen to allow real fan-out (general -> researcher -> sub-task) while // still capping a runaway recursion or fan-out tree well before it can // exhaust a lane or the model budget. const ( DefaultMaxDepth = 5 DefaultMaxDescendant = 64 ) // AncestorRef identifies one run in the current dispatch chain. type AncestorRef struct { Kind string // "agent" | "skill" ID string // the noun's stable UUID RunID string // this run's audit id (for diagnostics) } type chainKeyT struct{} type budgetKeyT struct{} var chainKey chainKeyT var budgetKey budgetKeyT type descendantBudget struct { count atomic.Int64 cap int64 } // Chain returns the ancestor refs carried on ctx, oldest-first (the root // run is index 0). Never nil-panics; returns nil when ctx carries none. func Chain(ctx context.Context) []AncestorRef { if v, ok := ctx.Value(chainKey).([]AncestorRef); ok { return v } return nil } // Rejection describes why a run must not proceed. It is intentionally a // soft outcome: the caller records an audit row and returns the Message // as the run's output so a delegating parent agent sees a clear, // actionable refusal rather than a hard error. type Rejection struct { // Kind is one of "loop", "depth", "budget" — used for the audit // status (status_for) and for tests. Kind string Detail string } // Status maps a rejection to the audit row status. func (r *Rejection) Status() string { return "rejected_" + r.Kind } // Message is the human/LLM-facing refusal text returned as the run's // output. func (r *Rejection) Message() string { return "⚠️ delegation refused (" + r.Kind + "): " + r.Detail + ". Synthesize an answer from what you already have instead of re-delegating." } // Enter is called once at the top of every run, BEFORE the agent loop // starts. It checks the loop / depth / descendant-budget guards against // the ancestor chain on ctx and returns: // // - a child context with `ref` appended to the chain (and, at the root, // a fresh descendant budget) — use this as the base context for the // run's agent loop so sub-invocations inherit the ancestry; and // - a non-nil *Rejection when the run must NOT proceed (in which case // the returned context equals the input and should be ignored). // // maxDepth / maxDescendant <= 0 fall back to the package defaults. func Enter(ctx context.Context, ref AncestorRef, maxDepth, maxDescendant int) (context.Context, *Rejection) { if maxDepth <= 0 { maxDepth = DefaultMaxDepth } if maxDescendant <= 0 { maxDescendant = DefaultMaxDescendant } chain := Chain(ctx) // 1) Loop: refuse to invoke a noun already executing in this chain. for _, a := range chain { if a.Kind == ref.Kind && a.ID == ref.ID { return ctx, &Rejection{ Kind: "loop", Detail: fmt.Sprintf("%s %q is already running higher in this dispatch chain (depth %d)", ref.Kind, ref.ID, len(chain)), } } } // 2) Depth: refuse to nest past the cap. if len(chain) >= maxDepth { return ctx, &Rejection{ Kind: "depth", Detail: fmt.Sprintf("dispatch chain depth %d reached the cap of %d", len(chain), maxDepth), } } // 3) Descendant budget: only descendants (non-root) count against the // per-root budget. The root creates the shared counter below. if len(chain) > 0 { if b, ok := ctx.Value(budgetKey).(*descendantBudget); ok && b != nil && b.cap > 0 { if b.count.Add(1) > b.cap { return ctx, &Rejection{ Kind: "budget", Detail: fmt.Sprintf("this run tree already spawned its budget of %d descendant runs", b.cap), } } } } // Stamp the child context. slices.Clone keeps each branch's chain // independent so sibling sub-invocations don't see each other. newChain := append(slices.Clone(chain), ref) out := context.WithValue(ctx, chainKey, newChain) if len(chain) == 0 { // Root: install the shared descendant budget every descendant // will increment. out = context.WithValue(out, budgetKey, &descendantBudget{cap: int64(maxDescendant)}) } return out, nil }