Skip to content

Sequence governance (recipe)

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.

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

Section titled “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.

examples/features/11-sequence-policy.ts (region: 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);
});
}
examples/features/11-sequence-policy.ts (region: sequence-policy)
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

Section titled “Three rule categories — same mechanism, three product pitches”
CategoryPatternWhat it blocksAction
🛡 Securityforbidden with cross-tool / cross-hub matchersExfil chains, privilege escalation'halt' — terminate run
💸 Costforbidden (wasteful patterns) + limits (caps)Context bloat, runaway loops, expensive repeats'deny' — LLM recovers
Correctnesslimits + ordering checksDouble-charges, idempotency violations'halt' — hard cap

Same code path. The reason field’s namespace prefix carries the framing for telemetry routing.

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' });
console.log(result.content);
} 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)

Section titled “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

Section titled “Strict halt ordering — what the framework guarantees”

When your checker returns { result: 'halt', ... }:

  1. Synthetic tool_result appended to scope.history with tellLLM content
  2. agentfootprint.permission.halt event emitted with {checkerId, reason, tellLLM, iteration, target, sequenceLength}
  3. Stage commitsscope.history written, runtimeStageId complete, commitLog has the entry
  4. scope.$break(...) propagates the halt
  5. Agent.run() catches at the API boundary, constructs PolicyHaltError with full forensic context
  6. PolicyHaltError thrown 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

Section titled “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.

GoalUse
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 succeedsEither — both are O(1) per check
  • 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 tellLLM to your reason value. reason is 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 reason tags so telemetry routing works (security:* → PagerDuty, cost:* → Slack channel).

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).

Same answer as the v2.11.6 discoveryProvider recipe — the library extends the primitive to enable consumer policies; consumers ship the convenience layer. Reasons:

  1. Lock-in risk — committing to ONE matcher API (forbidden / patterns / wildcards / providerId) before real consumer patterns emerge would lock us into the wrong shape
  2. Cost-benefit — extending PermissionChecker is ~250 LOC of library work; shipping a full toolSequencePolicy({...}) factory is ~700 LOC plus a new subpath plus more tests, for the same consumer outcome
  3. Future option — if 5+ consumers build the same factory shape, we promote it to agentfootprint/governance in a future minor with a known-good API