Files
executus/dispatchguard/dispatchguard.go
steve ca243a2d50
executus CI / test (push) Failing after 24s
P0: stand up executus harness module above majordomo
Batteries-included agent-harness base, extracted from mort's agent layer.
This first cut establishes the module + the zero-coupling core primitives:

- lane, dispatchguard, pendingattach, run/progress.go: moved verbatim from mort
- config: host config Source seam + env-var default (nil-safe helpers)
- deliver: output-egress seam + Discard/Stdout defaults
- identity: AdminPolicy + MemberResolver seams (nil-safe)
- fanout: programmatic N×M swarm (bounded global + per-key concurrency)
- README/CLAUDE.md with the vibe-coded banner; CI with Go gates +
  the "core stays majordomo+stdlib only" invariant

Core builds with stdlib only today; majordomo enters at P1 (model/structured).
go build/vet/test -race all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:18:37 -04:00

160 lines
5.9 KiB
Go

// 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
}