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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user