Sequence governance (recipe)
Build sequence-aware tool governance on the v2.12 PermissionChecker — security (exfil chains), cost (wasteful patterns), correctness (idempotency caps). Same plug point as single-call permission, no new factory.
Single-call permission answers "is this tool allowed?" — necessary but not sufficient. An agent can be fully compliant on every individual call and still produce harm by chaining individually-allowed steps. v2.12 enriches
PermissionChecker.check()with sequence + history + iteration + identity + signal, and adds'halt'as a Decision result. Sequence-aware governance is now ~80 LOC of user-land code on top — no new library factory required.
What v2.12 added (small primitive change)
// PermissionRequest — enriched
interface PermissionRequest {
// existing
capability: 'tool_call' | ...;
actor: string;
target?: string;
context?: Record<string, unknown>;
// NEW in v2.12
sequence?: readonly ToolCallEntry[]; // dispatched calls so far
history?: readonly LLMMessage[]; // full conversation
iteration?: number; // current ReAct iteration
identity?: { tenant?, principal?, conversationId };
signal?: AbortSignal; // for async checkers
}
// PermissionDecision — extended
interface PermissionDecision {
result: 'allow' | 'deny' | 'halt' | 'gate_open'; // 'halt' is NEW
reason?: string; // NEW — telemetry tag
tellLLM?: ToolResultContent; // NEW — LLM-facing message
rationale?: string;
policyRuleId?: string;
gateId?: string;
}'halt' terminates the run via PolicyHaltError — the framework writes a synthetic tool_result (using tellLLM) to scope.history BEFORE throwing, so:
- Anthropic / OpenAI tool_use ↔ tool_result pairing protocol stays satisfied
- Conversation history is consistent for
agent.resumeOnError(checkpoint) - Lens /
getNarrative()shows what the LLM was told before the halt
The recipe — sequencePolicy({ forbidden, limits }) in user-land
Two helpers + a factory. The full runnable file is examples/features/11-sequence-policy.ts — paste it into your project and adapt.
Suffix matcher
/** Suffix-match a sequence against a pattern. Pattern items can be: * - a string for exact name match * - { namePrefix } for prefix match (e.g. 'slack.*' for any slack tool) */function suffixMatches( seq: readonly ToolCallEntry[], pattern: ReadonlyArray<string | { namePrefix: string }>,): boolean { if (seq.length < pattern.length) return false; const tail = seq.slice(-pattern.length); return pattern.every((p, i) => { const call = tail[i]!; if (typeof p === 'string') return call.name === p; return call.name.startsWith(p.namePrefix); });}The policy factory
interface ForbiddenRule { readonly sequence: ReadonlyArray<string | { namePrefix: string }>; readonly action: 'deny' | 'halt'; readonly reason: string; readonly tellLLM?: string;}interface LimitRule { readonly maxPerSession: number; readonly action: 'deny' | 'halt'; readonly reason: string; readonly tellLLM?: string;}interface SequencePolicyOptions { readonly forbidden?: readonly ForbiddenRule[]; readonly limits?: Record<string, LimitRule>; readonly id?: string;}/** Build a `PermissionChecker` over the v2.12 enriched ctx. */function sequencePolicy(opts: SequencePolicyOptions): PermissionChecker { return { name: opts.id ?? 'sequence-policy', check({ capability, target, context, sequence }) { if (capability !== 'tool_call' || !target) return { result: 'allow' }; const wouldBe: ToolCallEntry[] = [ ...(sequence ?? []), { name: target, args: context, iteration: 1 }, ]; // Forbidden suffixes for (const rule of opts.forbidden ?? []) { if (suffixMatches(wouldBe, rule.sequence)) { return { result: rule.action, reason: rule.reason, ...(rule.tellLLM !== undefined && { tellLLM: rule.tellLLM }), }; } } // Frequency limits const limit = opts.limits?.[target]; if (limit) { const count = (sequence ?? []).filter((c) => c.name === target).length; if (count >= limit.maxPerSession) { return { result: limit.action, reason: limit.reason, ...(limit.tellLLM !== undefined && { tellLLM: limit.tellLLM }), }; } } return { result: 'allow' }; }, };}That's the whole recipe. Three concepts (forbidden rules, limits, tellLLM) over the v2.12 enriched ctx.
Three rule categories — same mechanism, three product pitches
| Category | Pattern | What it blocks | Action |
|---|---|---|---|
| 🛡 Security | forbidden with cross-tool / cross-hub matchers | Exfil chains, privilege escalation | 'halt' — terminate run |
| 💸 Cost | forbidden (wasteful patterns) + limits (caps) | Context bloat, runaway loops, expensive repeats | 'deny' — LLM recovers |
| ✅ Correctness | limits + ordering checks | Double-charges, idempotency violations | 'halt' — hard cap |
Same code path. The reason field's namespace prefix carries the framing for telemetry routing.
Wiring it up
import { Agent, PolicyHaltError } from 'agentfootprint';
const policy = sequencePolicy({
forbidden: [
{
sequence: ['runPython', { namePrefix: 'slack.' }],
action: 'halt',
reason: 'security:exfiltration',
tellLLM: 'This tool combination is restricted. Operation has been logged for security review.',
},
{
sequence: ['fetchAllUsers', 'summarize'],
action: 'deny',
reason: 'cost:context-bloat',
tellLLM: 'fetchAllUsers returns too much data. Try searchUsers with a filter, then summarize.',
},
],
limits: {
processRefund: {
maxPerSession: 3,
action: 'halt',
reason: 'correctness:idempotency',
tellLLM: 'Refund limit reached (3/3). Escalate to a human agent.',
},
},
});
const agent = Agent.create({ provider, model, permissionChecker: policy })
.system("You're a customer support agent.")
.tools([lookupOrder, runPython, slackDM, fetchAllUsers, processRefund, summarize])
.build();
try {
const result = await agent.run({ message: 'help me with order #42' }); // AgentOutput is a string
console.log(result);
} catch (e) {
if (e instanceof PolicyHaltError) {
console.log(`HALT: rule='${e.reason}' iteration=${e.iteration}`);
console.log(`Sequence: ${e.sequence.map(c => c.name).join(' → ')}`);
if (e.reason.startsWith('security:')) await pagerDuty.notify(e);
if (e.reason.startsWith('correctness:')) await opsDashboard.flag(e);
} else {
throw e;
}
}
agent.on('agentfootprint.permission.halt', (e) => {
metrics.increment('policy.halt', { reason: e.payload.reason, checker: e.payload.checkerId });
});How sequence is derived (no parallel state)
The framework derives sequence on demand from scope.history via the extractSequence helper. No parallel state in scope — the conversation history IS the source of truth. This means:
- ✅ Survives
agent.resumeOnError(checkpoint)— resumed runs see the same sequence the failed run did - ✅ Survives parallel tool calls — siblings already dispatched in the same turn appear in the sequence for later siblings
- ✅ Skips synthetic-deny tool_results — denied calls don't count as "dispatched"
- ✅ Skips in-flight calls — proposed calls without a tool_result yet don't pollute the sequence
You can use the helper directly if you need it outside a checker:
import { extractSequence } from 'agentfootprint/security';
const sequence = extractSequence(history, currentIteration);
// → readonly ToolCallEntry[] of {name, args, iteration, providerId?}Strict halt ordering — what the framework guarantees
When your checker returns { result: 'halt', ... }:
- Synthetic
tool_resultappended toscope.historywithtellLLMcontent agentfootprint.permission.haltevent emitted with{checkerId, reason, tellLLM, iteration, target, sequenceLength}- Stage commits —
scope.historywritten,runtimeStageIdcomplete,commitLoghas the entry scope.$break(...)propagates the haltAgent.run()catches at the API boundary, constructsPolicyHaltErrorwith full forensic contextPolicyHaltErrorthrown to the caller
Steps 1–3 happen inside a try/finally-equivalent ordering — if anything in the halt path throws, the audit trail is still committed before the run terminates. Same correctness pattern as the v2.11.5 reliability gate.
Default tellLLM — deliberately generic, never leaks reason
When you omit tellLLM on a halt decision, the framework defaults to:
Tool '${name}' is not available in this context.This is deliberately generic and NEVER falls back to reason. Why? reason is telemetry — your tag is something like 'security:exfiltration' or 'cost:context-bloat'. Leaking that to the LLM teaches it the rule space; over enough turns a determined model can probe edges. Consumers who want a richer LLM-facing message provide tellLLM explicitly.
For deny decisions (which let the LLM recover), the default DOES include rationale text (kept for v2.4 back-compat). If you want a generic deny message too, set tellLLM explicitly.
When to use deny vs halt
| Goal | Use |
|---|---|
| LLM should pick a different path | 'deny' — synthetic tool_result lands, run continues |
| Run must terminate (security / hard idempotency cap) | 'halt' — PolicyHaltError thrown |
| Want a clean error type for the caller to branch on | 'halt' always |
| Want zero impact on cost / latency unless attack succeeds | Either — both are O(1) per check |
Anti-patterns
- ❌ Never put protected data in
tellLLM.tellLLM: "Cannot exfiltrate: file has 1543 SSNs"literally leaks the count. The LLM sees this content; the developer is responsible for keeping sensitive details out. - ❌ Don't default
tellLLMto yourreasonvalue.reasonis telemetry, not user-facing. The framework default deliberately uses a generic message — match that pattern for your custom defaults. - ❌ Don't throw inside
check()unless you mean it. Throwing is treated as deny-by-default with the error message echoed in the synthetic tool_result. Use{ result: 'halt' }for clean termination. - ❌ Don't maintain parallel state in scope to track sequence. Use
request.sequence(derived from history) — it survives resume correctly. - ❌ Don't overload one rule for multiple categories. Split into separate rules with distinct
reasontags so telemetry routing works (security:*→ PagerDuty,cost:*→ Slack channel).
Composition with staticTools + gatedTools
PermissionChecker runs at dispatch time. gatedTools hides tools at visibility time (the LLM doesn't see them). Use both for defense-in-depth:
const provider = gatedTools(
staticTools(allTools),
(toolName) => roleCanCall(toolName), // visibility — fewer calls to deny
);
const agent = Agent.create({ provider: llm, model, permissionChecker: sequencePolicy({...}) })
.toolProvider(provider)
.build();gatedTools reduces wasted LLM cycles ("don't show what we'll deny"); permissionChecker enforces what gatedTools couldn't catch (sequence patterns the LLM couldn't have seen at visibility time).
Why no library factory ships in v2.12
Same answer as the v2.11.6 discoveryProvider recipe — the library extends the primitive to enable consumer policies; consumers ship the convenience layer. Reasons:
- Lock-in risk — committing to ONE matcher API (forbidden / patterns / wildcards / providerId) before real consumer patterns emerge would lock us into the wrong shape
- Cost-benefit — extending
PermissionCheckeris ~250 LOC of library work; shipping a fulltoolSequencePolicy({...})factory is ~700 LOC plus a new subpath plus more tests, for the same consumer outcome - Future option — if 5+ consumers build the same factory shape, we promote it to
agentfootprint/governancein a future minor with a known-good API
Next steps
examples/features/11-sequence-policy.ts— the runnable file behind this recipe- Permission policy — single-call governance + role allowlists (
PermissionPolicy.fromRoles(...)) - Tool providers —
gatedToolsfor visibility-time filtering - Reliability gate —
ReliabilityFailFastErrorfor LLM-call-level termination (sibling pattern toPolicyHaltError) - Observability — the
agentfootprint.permission.haltevent in the full taxonomy
Tool discovery (async ToolProvider)
Runtime tool catalogs over hubs, MCP registries, and per-tenant indexes. Async ToolProvider.list(ctx) with TTL caching, AbortSignal propagation, and discovery_started/completed/failed events — no library API additions required.
Flowchart as tool
Wrap a footprintjs FlowChart as one Agent tool. Compose existing multi-step procedures (decision evidence, branches, loops, pause/resume) into the LLM's tool surface — no flatten/restructure required.
