Skip to content

Latest commit

 

History

History
787 lines (560 loc) · 43.6 KB

File metadata and controls

787 lines (560 loc) · 43.6 KB

04 · Guards

What you get from this chapter: the complete rule vocabulary — 30 factories, what each one prevents, one minimal example each — plus the four types they are written in and how to write your own when nothing fits. Everything here is from looprun (≡ looprun/core).

Code source. §5 is generated from GUARD_CATALOG (packages/core/src/guards/catalog.ts) by scripts/gen-guards-chapter.mjs; a parity test keeps that array in bijection with the factories src/guards/ actually exports, and pnpm docs:guards --check runs in CI, so a row here cannot describe a kind that does not ship. Every §5 example is compiled: the same generator emits snippets/04-guards-examples.generated.ts, which the snippets package typechecks against the published looprun facade. The hand-written sections quote snippets/04-guards.ts and snippets/scheduler/, typechecked the same way. Signature blocks are quoted from the library source and are not compiled here.

Chapter 03 left the scheduler with one hand-written rule and two obligations already met:

   never double-book         → argRequired + argFormat×2 + a custom clash gate   (§8 of chapter 03)
   never delete without ask  → destructiveTools: ['cancelEvent']
                               ⇒ AgentSpecBase installs confirmFirst + destructiveThrottle

This chapter is the rest of the vocabulary:

   §1  the four types a guard is written in     Guard · GuardCtx · ObservedCall · Dim
   §2  binding one to a moment                  addGuard — the chapter 03 socket, in one line
   §3  the ones you already have                what AgentSpecBase installs before your code runs
   §4  finding the right one                    symptom → kind, the confusable pairs, canonArgs
   §5  THE CATALOG                              30 factories, grouped by hook — generated
   §6  writing your own                         custom, and the five rules a reviewer looks for

Read §1–§4 once; §5 is a reference you come back to.


1. What a guard is — the four types

Every factory in §5 returns one object of the same shape: a deterministic check and an LLM-facing prose (chapter 01 §3).

interface Guard {
  kind: string;                                             // the runtime name, e.g. 'confirmFirst'
  dim: Dim;                                                 // which hooks it is legal on
  check(ctx: GuardCtx): string | null | Promise<string | null>;   // deny text, or null to allow
  prose(): string;                                          // the same rule, for the system prompt
  meta?: { before?: string[]; requiredStrings?: string[] } & Record<string, unknown>;
}

signature, from looprun

Three names, one rule, and they line up. The factory is what you call (confirmFirst(…)); it returns a Guard object; that object's kind is the runtime name ('confirmFirst'), and it is what you read back in a guard id (base:confirmFirst) and in a recoveryEvents entry (run:noDoubleBook:addEvent). The catalog below is indexed by factory name, so the name you call is the name you will see in the audit trail — with one deliberate exception, custom, whose kind you choose yourself (§6).

Two renderings of one rule, and the checker never reads the prose. A guard whose prose says something its check does not enforce is a rule the model is told about and nothing verifies — the failure this whole design exists to make impossible.

check returns the correction, not a log line: that string is what the model reads, so write it as an instruction ("do not book it — name the clash and ask what to do"). Returning null allows.

GuardCtx — everything a check may read

interface GuardCtx {
  args: Record<string, unknown>;      // the proposed call's arguments      (tool hooks)
  tool?: string;                      // the proposed call's name           (tool hooks)
  world: AgentWorld;                  // your world — read it through a type, never bare
  observed: ObservedCall[];           // every call THIS CONVERSATION, oldest first
  turnIndex: number;                  // which turn is being adjudicated
  reply?: string;                     // the reply text                     (onReply only)
  result?: unknown;                   // the tool's result                  (postTool only)
  producedThisTurn?: string[];
  attachmentsThisTurn?: string[];
  notes?: string[];
  siblingCallsThisStep?: ObservedCall[];   // same-step calls still in flight — destructiveThrottle only
}

signature, from looprun — abridged; the JSDoc on each field is worth reading

What is not there is the point: the user's text. No field carries the message. That is the magnet firewall — a rule that could be scoped by what the user said would be a rule the user can talk their way past. Guards key on arguments, world state and observed calls; nothing else.

world is typed as AgentWorld, whose index signature makes a typo compile (chapter 03 §7), so read your accessors through a named type — §6 shows the pattern.

ObservedCall — one recorded call

interface ObservedCall {
  name: string;
  args: Record<string, unknown>;
  ok: boolean;                                          // did the call succeed
  turnIndex: number;                                    // which turn it happened on
  resultFlags?: { requiresConfirmation?: boolean };     // the two-step protocol's probe
  tookEffect?: boolean;                                 // did it MUTATE the world (vs a read)
}

signature, from looprun

Three fields do the heavy lifting across the catalog. turnIndex is what makes "in an earlier turn" expressible, which is the whole of confirmFirst. ok separates a call that happened from one that succeeded. tookEffect separates a write that landed from a pure read or a refused write — it is why noFalseFailureClaim does not veto an honest "I could not find it" on a read-only turn.

Two names in observed are runtime-owned rather than yours: replyToUser and askUser are pushed in with ok: true, so a check that means "did the model do any work" must filter them out.

Dim — the five enforcement dims, and why they exist

type Dim = 'spatial' | 'input' | 'run' | 'output' | 'behavior';

signature, from looprun

A dim is a claim about which GuardCtx fields the check reads, and it is the only thing that decides which hooks the guard may be installed on:

   dim        reads                          legal hooks
   ────────   ────────────────────────────   ──────────────────────────────
   spatial    ctx.tool / ctx.args            onInput · preTool · postTool
   input      ctx.tool / ctx.args            onInput · preTool · postTool
   run        ctx.tool / ctx.args + world    onInput · preTool · postTool
   output     ctx.result                     postTool                        ← only hook that has it
   behavior   ctx.reply                      onReply                         ← only hook that has it

You never pass a dim for a catalog factory — each one carries its own. You pass one exactly once: to custom (§6). Get it wrong and addGuard throws at construction, which is the point: a behavior guard installed on preTool would read ctx.reply === undefined, never fire, and still print its prose into the prompt — coverage that does not exist.


2. Binding one: addGuard

Chapter 03 §8 teaches the socket; this is the one-line reminder.

spec.addGuard(hook, target, guard, opts?)      // hook: Hook, target: ToolTarget, guard: Guard
spec.addReplyCheck(guard, opts?)               // ≡ addGuard('onReply', 'any', guard)
spec.addMutator(mutator, opts?)                // for a ReplyMutator — §5's onReplyMutate row

signatures — methods of AgentSpecBase

Every example in §5 is the third argument, and the section it is listed under is the hook it is normally installed on — a convention, not the rule. The rule is §1's dim×hook matrix, which addGuard enforces at construction.

Reach for addReplyCheck when you are binding a reply kind and 'any' is what you want anyway: it is the same call with the two constant arguments removed, and it keeps a spec's reply block from being a column of repeated 'onReply', 'any'.

Give every binding an id. It is what a GuardExecutionError names when a check throws, what the eval output attributes a veto to, and what makes a spec diff readable.


3. Five are already installed — plus a conditional sixth

AgentSpecBase's constructor installs the universal invariants before your code runs, and the destructive-safety protocol iff you declared destructiveTools (chapter 03 §2):

   ALWAYS (3)                    noDuplicateCall   (preTool)
                                 degenerationGuard (onReply)
                                 emptyReply        (onReply)
   IFF destructiveTools (2)      confirmFirst + destructiveThrottle, on exactly those tools
   ───────────────────────────   the five a spec like the scheduler's gets
   IFF lexicon.falseFail… (1)    noFalseFailureClaim (onReply) — the conditional sixth, and the
                                 tutorial teaches no lexicon, so the scheduler does not get it

They have catalog rows in §5 because they are real kinds you must be able to read — not because you should call them. Re-adding one by hand renders the same rule twice in the prompt, from two sources that will drift.


4. Finding the right one

Start from the symptom

You almost never shop the catalog top to bottom. You have a trace where the model did something, and you want the kind that makes that impossible. Read this column as "the model …":

the model … reach for which is on
acts destructively without ever having asked confirmFirst (auto-installed by destructiveTools) preTool
asks and acts in the same breath, or chains two destructive calls in one turn noActAfterAskSameTurn · destructiveThrottle preTool
makes the same call again, hoping for a different answer noDuplicateCall preTool
calls a legitimate tool too many times — sweeps, repeat contact maxCalls preTool
runs a step before the one it depends on requiresBefore preTool
acts while the world says it must not (closed account, no consent on record) precondition · consentRequired preTool
does as it is told by text that came back INSIDE a tool result noInstructionFromData preTool
summarises an empty or partial result as if it satisfied the request resultInvariant postTool
says a tool's work is done when it is not noFabricatedSuccess · destructiveClaimRequiresSuccess onReply
apologises for failing on a turn where the work went through noFalseFailureClaim onReply
promises a handoff — billing, legal, dispatch — as if it had done it noOutOfSurfaceActionClaim onReply
pours other people's personal fields into one reply minimalDisclosure — the PII cap: it counts personal FIELD names per record and demands each one came from a tool result this turn onReply
answers with nothing, leaked think-blocks, or the same line five times emptyReply · degenerationGuard (both auto-installed) onReply
writes internal status codes and field names at the user jargonScrub — rewrites, never vetoes onReplyMutate
breaks a rule that is about YOUR domain and nothing in this table fits custom (§6) you choose

The five confusable clusters

Every row's when to reach for it is written against its neighbours. These are the five groups where reading only one row will pick the wrong kind — the four in the table below, plus the honesty cluster that follows it:

cluster the axis that separates them
requiresBefore · precondition which call came first, vs what state the world is in
forbidThisTurn · noDuplicateCall the first call is illegitimate, vs only the repeat is
confirmFirst · consentRequired · pendingConfirmMustAsk evidence in the CONVERSATION (an earlier turn) · a standing flag in the WORLD · gating the REPLY rather than the call
replyMustMention · replyConfirmsLabels any one keyword suffices, vs every label is required

And the honesty cluster — four kinds that all mean "the reply lied", separated by what it lied about:

   noFabricatedSuccess              a tool YOU own did not succeed this turn      → do not claim its effect
   destructiveClaimRequiresSuccess  …and the effect was DESTRUCTIVE               → attempt-keyed, sentence-scoped
   noOutOfSurfaceActionClaim        the tool is not on this agent's surface       → it was never yours to do
   noFalseFailureClaim              the work SUCCEEDED and the reply claims it failed  ← the mirror image

The first three catch invented success; the fourth catches invented failure. They are written not to double-fire: noOutOfSurfaceActionClaim stops at the surface boundary the owned-action kinds start at, and noFalseFailureClaim only adjudicates a turn that mutated the world.

canonArgs — the fingerprint noDuplicateCall compares

It is exported and public, but it is a helper, not a factory: it returns a string, not a Guard, so it has no catalog row.

function canonArgs(v: unknown): string      // key-order-independent canonical fingerprint

signature, from looprun

noDuplicateCall does not compare argument objects — it compares canonArgs(args), so key order is not identity and a re-ordered retry is still the same call:

/** Key order is not identity: to `noDuplicateCall`, both of these are the SAME call. */
export const sameCallFingerprint =
  canonArgs({ start: '2026-03-02T10:00', title: 'Standup' }) === canonArgs({ title: 'Standup', start: '2026-03-02T10:00' });

/** …and a different VALUE is a different call, so a corrected retry is never denied as a repeat. */
export const differentCallFingerprint =
  canonArgs({ title: 'Standup' }) !== canonArgs({ title: 'Stand-up' });

excerpt · snippets/04-guards.ts

maxCalls deliberately does not use it. It counts successful calls by TOOL NAME within its scope, arguments ignored — which is what you want from a budget:

   noDuplicateCall   keyed on (tool, canonArgs(args))   a rephrased retry is a DIFFERENT call → allowed
   maxCalls          keyed on (tool)                    a rephrased retry is the same tool    → still burns budget

So the two are complementary rather than redundant: the escape hatch out of one is closed by the other. Reach for canonArgs directly when a custom guard of yours needs to decide whether two calls are "the same" — using the same fingerprint means your rule and noDuplicateCall cannot disagree about it.


5. The catalog — 32 factories

Grouped by the hook each one is installed on, because the hook decides what a rule can see and therefore what it can enforce (chapter 03 §8). 15 preTool · 1 postTool · 14 onReply · 1 onReplyMutate · 1 escape hatch.

A fourth hook exists and has no section here: onInput fires before the model runs, and §1's matrix makes it legal for every spatial/input/run guard — but no shipped kind is installed there, because a rule that can refuse the whole turn before a call is even proposed is a domain decision, not a default. custom is how you reach it.

preTool — before the call runs

A call has been proposed and not yet executed. A deny returns to the model AS the tool result, in the governance envelope, and the model retries inside the same generation — so the correction text is written as an instruction. Nothing has happened to the world yet, which is why every gate that must PREVENT something lives here.

factory file what it enforces
requiresBefore flow.ts A tool may run only after every named dependency has already run successfully this conversation.
forbidThisTurn flow.ts An unconditional deny of the bound tool while the binding is installed — the first call is denied too.
maxCalls flow.ts A tool may succeed at most n times per turn (default) or per conversation.
noDuplicateCall flow.ts Denies a call whose tool and canonical arguments already succeeded earlier in the same turn.
argRequired args.ts The named argument must be present and non-empty (a blank string counts as missing).
argAbsent args.ts The named argument must not be passed at all.
argFormat args.ts A present, non-empty string argument must match the given pattern; absent or empty is left to argRequired.
precondition world.ts The call is allowed only while a predicate over the host world holds.
consentRequired world.ts A set of writes may run only while the world says this person's consent is on record.
confirmFirst confirmation.ts A destructive tool needs the user's go-ahead from an EARLIER turn — via a confirm flag probe or a prior ask. Passing a mechanism NAME to the string overload throws at construction.
noActAfterAskSameTurn confirmation.ts Denies the listed tools on a turn in which the model already asked the user a question.
destructiveThrottle confirmation.ts At most one destructive action that TOOK EFFECT per turn (a confirmation probe does not count).
noInstructionFromData reply.ts Denies the listed destructive tools while an imperative sits in the conversation's tool results and no earlier turn exposed the action to the user.
askedEarlier structural.ts A gated argument may be recorded only when an askUser succeeded in an EARLIER turn; a same-turn ask does not count.
confirmedNeedsEarlierProbe structural.ts A confirmed:true call is denied unless the SAME tool ran as a probe (confirmed!=true, matching args) in an EARLIER turn.

1. requiresBefore

A tool may run only after every named dependency has already run successfully this conversation.

When to reach for it. An ordered flow where a step is meaningless without its predecessors — bind one gate per downstream tool naming all of them. Use this for "which call came first", not for "what state is the world in" (that is precondition).

requiresBefore(['findBooking'])

2. forbidThisTurn

An unconditional deny of the bound tool while the binding is installed — the first call is denied too.

When to reach for it. A tool must be off, no matter what. Its scope is the BINDING'S LIFETIME — the check is () => reason, with no turn logic in it at all, so the ban holds for as long as the binding is installed (the name is historical). It is not a repeat detector: reach for noDuplicateCall when the FIRST call is legitimate and only the repeat is not.

forbidThisTurn('Do not reschedule while a cancellation is pending — resolve that first.')

3. maxCalls

A tool may succeed at most n times per turn (default) or per conversation.

When to reach for it. A bulk cap on a tool that is legitimate but expensive or nagging — sweeps, notifications, repeat contact. Pick scope: 'conversation' for retention-style limits, scope: 'turn' for per-answer budgets.

maxCalls('sendEmail', 1, 'You already emailed this person.', { scope: 'conversation' })

4. noDuplicateCall

Denies a call whose tool and canonical arguments already succeeded earlier in the same turn.

When to reach for it. Always on (the spec class auto-installs it): it stops the same-turn retry loop where a model re-reads an identical query hoping for a different answer. Cross-turn repeats stay legal — a later turn is a genuine refresh.

noDuplicateCall()

5. argRequired

The named argument must be present and non-empty (a blank string counts as missing).

When to reach for it. A field the tool cannot do its job without, and the model tends to omit or fill with whitespace. For a field that must be well-FORMED rather than merely present, add argFormat.

argRequired('bookingId')

6. argAbsent

The named argument must not be passed at all.

When to reach for it. A parameter the model keeps inventing for this tool, or the excluded half of a mutually exclusive pair — bind argAbsent on each side of the pair.

argAbsent('customerEmail')

7. argFormat

A present, non-empty string argument must match the given pattern; absent or empty is left to argRequired.

When to reach for it. The value has a shape the model can plausibly fabricate — an id, a date, a code. Compose it with argRequired when the field is also mandatory; alone it only polices the values that are actually sent.

argFormat('bookingId', '^BK-\\d{6}$')

8. precondition

The call is allowed only while a predicate over the host world holds.

When to reach for it. A gate whose discriminator lives in WORLD state, not in this call — the predicate never sees the acting call's arguments. If the discriminator is in the args, use custom instead.

precondition((world) => world.accountActive === true, 'This account is closed — you cannot act on it.', 'act on an account only while it is open')

9. consentRequired

A set of writes may run only while the world says this person's consent is on record.

When to reach for it. Storing, sharing or transmitting personal data. It is precondition specialised to a TOOL SET, which is what makes the consent posture auditable in a spec header; pair it with a conversation-scoped maxCalls for repeat contact.

consentRequired({ tools: ['storeProfile'], consentOk: (world) => world.consentOnRecord === true, reason: 'No consent on record — ask for it before storing anything.' })

10. confirmFirst

A destructive tool needs the user's go-ahead from an EARLIER turn — via a confirm flag probe or a prior ask. Passing a mechanism NAME to the string overload throws at construction.

When to reach for it. The user must have agreed before this call runs, and the evidence has to be cross-turn — this is the consent gate itself. Its neighbours answer different questions: destructiveThrottle caps the blast radius of a turn that IS approved, consentRequired reads a standing world flag rather than the conversation, and pendingConfirmMustAsk gates the REPLY rather than the call. Mechanism: 'arg' when the tool carries a confirm flag, 'prior-ask' when it has none and an earlier question is the only possible evidence; the string overload sets the FLAG NAME, so confirmFirst('prior-ask') throws rather than silently building a guard that can never fire.

confirmFirst('confirmed')

11. noActAfterAskSameTurn

Denies the listed tools on a turn in which the model already asked the user a question.

When to reach for it. The mirror image of confirmFirst's cross-turn requirement: it closes the multi-tool step that asks and executes back to back, which reads as "asked" but never gave the user a chance to answer.

noActAfterAskSameTurn(['cancelBooking'])

12. destructiveThrottle

At most one destructive action that TOOK EFFECT per turn (a confirmation probe does not count).

When to reach for it. Auto-installed alongside confirmFirst. It is the blast-radius cap, not a consent gate: it stops chained destructive calls in one turn even when each one is individually confirmed.

destructiveThrottle(['cancelBooking', 'refundOrder'])

13. noInstructionFromData

Denies the listed destructive tools while an imperative sits in the conversation's tool results and no earlier turn exposed the action to the user.

When to reach for it. Tool results can carry attacker-controlled text (notes, messages, tickets). The proxy is deliberately conservative — it converts a poisoned same-turn request into the legal ask-then-act two-turn flow.

noInstructionFromData({ tools: ['cancelBooking'], instructionRe: /please cancel|delete all/i })

14. askedEarlier

A gated argument may be recorded only when an askUser succeeded in an EARLIER turn; a same-turn ask does not count.

When to reach for it. A value the agent must not write until it has asked the operator for it and they answered in a later message — the structural replacement for a hand-written regex over "did we ask?". It keys on the presence of the gated arg plus an earlier-turn askUser, never on any text.

askedEarlier({ tool: 'completeMaintenance', arg: 'condition' })

15. confirmedNeedsEarlierProbe

A confirmed:true call is denied unless the SAME tool ran as a probe (confirmed!=true, matching args) in an EARLIER turn.

When to reach for it. A destructive tool that carries its own confirm flag and must be previewed before it is confirmed — the preview and the go-ahead have to live in different messages. Pins the probe to the same act by args equality, all structural: no text is matched.

confirmedNeedsEarlierProbe({ tools: ['chargeDeposit'] })

postTool — the call has run

The only hook that sees ctx.result. It cannot veto anything — the effect already happened — so its job is to stop the RESULT from being reported as something it was not: a violation here joins the reply redrive set.

factory file what it enforces
resultInvariant world.ts A post-execution check on the tool RESULT: when the predicate fails, the violation joins the reply redrive set.

16. resultInvariant

A post-execution check on the tool RESULT: when the predicate fails, the violation joins the reply redrive set.

When to reach for it. The call already ran and cannot be undone, but its result must not be reported as if it satisfied the request — an empty report, a partial write. It never vetoes the call; it corrects the reply.

resultInvariant((result) => Array.isArray(result) && result.length > 0, 'The search returned nothing — say so instead of summarising it.', 'report an empty result as empty')

onReply — the reply exists, and has not been sent

The reply text is in ctx.reply and no tool can run any more. A deny costs a bounded no-tools re-generation and, if that still violates, the deterministic honest closure — so these kinds are written to fire on what was ASSERTED, never on what was merely mentioned.

factory file what it enforces
pendingConfirmMustAsk confirmation.ts When a probe returned requiresConfirmation this turn and nothing resolved it, the reply must relay that question.
noFabricatedSuccess honesty.ts The reply may not claim a tool's effect, cite an invented artifact label, or use a banned phrase when the tool did not succeed this turn.
destructiveClaimRequiresSuccess honesty.ts A declarative claim that a destructive action happened is denied unless one actually took effect this turn.
noFalseFailureClaim honesty.ts When every domain call this turn succeeded and one of them mutated the world, the reply may not claim inability.
noOutOfSurfaceActionClaim honesty.ts A declarative claim of an action whose tool is not on this agent's surface is denied.
noUngroundedRegulatedFigure honesty.ts A figure or conclusion of a regulated class may appear only when a tool returned it this turn.
noCompetitorClaim honesty.ts Within one sentence, a named third party plus comparative phrasing or a comparative figure is denied.
replyMustMention reply.ts The reply must contain at least one of the given keywords (case-insensitive).
replyMaxOccurrences reply.ts At most n DISTINCT calls-to-action from the list may appear in one reply.
replySingleQuestion reply.ts The reply must carry exactly one question mark.
replyConfirmsLabels reply.ts The reply must be non-empty and name every one of the given labels.
emptyReply reply.ts The final reply must not be blank.
degenerationGuard reply.ts Catches leaked reasoning or tool markup, chat-template tokens and run-away line repetition in the reply.
minimalDisclosure reply.ts Caps how many records' personal FIELDS one reply may carry, and requires each named field to have been returned by a tool this turn.

17. pendingConfirmMustAsk

When a probe returned requiresConfirmation this turn and nothing resolved it, the reply must relay that question.

When to reach for it. The world runs the two-step protocol itself: the tool answers "I need confirmation" and the risk is a reply that summarises the action as done. It gates the REPLY; confirmFirst gates the call.

pendingConfirmMustAsk({ askRe: /shall I|do you want me to/i })

18. noFabricatedSuccess

The reply may not claim a tool's effect, cite an invented artifact label, or use a banned phrase when the tool did not succeed this turn.

When to reach for it. A tool whose output the model likes to announce before it exists. Arm only the seams you need — claim language, label existence, or an unconditional ban — and ship banProse with every banRe.

noFabricatedSuccess('generateReport', { reason: 'No report was generated this turn — do not say one was.', claimRe: /report is ready/i })

19. destructiveClaimRequiresSuccess

A declarative claim that a destructive action happened is denied unless one actually took effect this turn.

When to reach for it. The destructive counterpart of noFabricatedSuccess, attempt-keyed and sentence-scoped: with no attempt this turn a destructive verb is read-back status and is left alone, and questions or offers never count as claims.

destructiveClaimRequiresSuccess(['cancelBooking'], { claimRe: /cancelled/i, askRe: /shall I cancel/i, offerRe: /would you like/i })

20. noFalseFailureClaim

When every domain call this turn succeeded and one of them mutated the world, the reply may not claim inability.

When to reach for it. The work actually went through and the model still apologises for failing — the mirror image of the fabricated-success kinds, which catch the opposite lie. It only adjudicates a turn that MUTATED the world, so an honest "I could not find it" on a read-only turn is never touched. Auto-installed when the lexicon supplies the pattern; keep that pattern to attempted-work-failure phrasing ("failed to", "went wrong"), since a broad inability regex would veto honest policy refusals.

noFalseFailureClaim({ claimRe: /failed to|something went wrong/i })

21. noOutOfSurfaceActionClaim

A declarative claim of an action whose tool is not on this agent's surface is denied.

When to reach for it. The agent is expected to hand off (billing, legal, dispatch) and the model promises the handoff as done. It deliberately stops at the surface boundary, so it never double-fires with the owned-action honesty kinds.

noOutOfSurfaceActionClaim({ actionClaims: [{ claimRe: /refund (?:has been )?issued/i, tool: 'issueRefund' }], surface: ['findBooking'] })

22. noUngroundedRegulatedFigure

A figure or conclusion of a regulated class may appear only when a tool returned it this turn.

When to reach for it. Legal, medical or financial surfaces. Keep allowFromToolResults true when a tool is authoritative for the class; set it false to ban the class outright where nothing in the world can license it.

noUngroundedRegulatedFigure({ regulatedRe: /\b\d+\s?mg\b/i, allowFromToolResults: true })

23. noCompetitorClaim

Within one sentence, a named third party plus comparative phrasing or a comparative figure is denied.

When to reach for it. Any user-facing sales or support surface. The figure branch is sound by construction: no tool returns a competitor's numbers, so any such number is invented.

noCompetitorClaim({ competitorRe: /\bAcme\b/i, comparativeRe: /\b(?:better|cheaper|faster) than\b/i })

24. replyMustMention

The reply must contain at least one of the given keywords (case-insensitive).

When to reach for it. A mandatory element of coverage that is the same on every turn — a referral phrase, a required disclaimer. For "name the records you just acted on", use replyConfirmsLabels instead.

replyMustMention(['support@example.com'], 'Give the support address so the person can follow up.')

25. replyMaxOccurrences

At most n DISTINCT calls-to-action from the list may appear in one reply.

When to reach for it. Anti-nag: the model stacks several different asks onto one message. It counts distinct entries, not repetitions, so one CTA restated inside a single ask still passes.

replyMaxOccurrences(['book now', 'call us', 'subscribe'], 1, 'One ask per reply — drop the extra calls-to-action.')

26. replySingleQuestion

The reply must carry exactly one question mark.

When to reach for it. Recovery and clarification turns, where a pile of questions at once is what stalls the conversation. Bind it to the layer that handles those turns, not to every reply.

replySingleQuestion('Ask exactly one question so the person can answer it.')

27. replyConfirmsLabels

The reply must be non-empty and name every one of the given labels.

When to reach for it. The model just acted on identified records and the user needs to see WHICH ones. Unlike replyMustMention (any one keyword), every label is required.

replyConfirmsLabels(['BK-100234'], 'Name the booking you acted on so the person can check it.')

28. emptyReply

The final reply must not be blank.

When to reach for it. Always on (auto-installed). It is the floor under every other reply kind: a turn that ends with nothing said is a failure no other guard would catch.

emptyReply()

29. degenerationGuard

Catches leaked reasoning or tool markup, chat-template tokens and run-away line repetition in the reply.

When to reach for it. The reply is broken as an ARTIFACT rather than wrong as a claim — think blocks, tool-call markup or the same line five times over. No honesty kind fires on that, because nothing was asserted; this one catches the weak-model failure class every domain shares. Always on (auto-installed); pass selfNarrationRe to add the language-specific third-person self-narration branch.

degenerationGuard({ selfNarrationRe: /the assistant (?:then )?(?:called|checked)/i })

30. minimalDisclosure

Caps how many records' personal FIELDS one reply may carry, and requires each named field to have been returned by a tool this turn.

When to reach for it. Any surface that reads personal records. It keys on field-name tokens, never on entity mentions, so a correct multi-record summary that lists only ids and dates stays legal.

minimalDisclosure({ piiFields: ['phone', 'email'], entityIdRe: /\bCU-\d{5}\b/, maxEntities: 1 })

onReplyMutate — rewrite the reply, never veto it

A ReplyMutator, not a Guard: it is applied to the reply before the onReply checks run and it has no pass/fail. Bind it with spec.addMutator(...), not addGuard.

factory file what it enforces
jargonScrub reply.ts A deterministic egress rewrite of internal vocabulary into user words (word-boundary, case-insensitive).

31. jargonScrub

A deterministic egress rewrite of internal vocabulary into user words (word-boundary, case-insensitive).

When to reach for it. Internal status codes and field names leak into replies and no gate can sensibly deny them. It is a MUTATOR, not a guard: it rewrites and never vetoes, so it has no pass/fail behaviour to prove.

jargonScrub({ CANC_PEND: 'waiting to be cancelled' })

The escape hatch — when no kind fits

One factory, and it is the only one whose hook you choose: custom follows the dim you pass it (spatial/input/run → the tool hooks, outputpostTool, behavioronReply), which is why it is listed apart from the phase sections rather than under one of them. Section 6 walks through writing one.

factory file what it enforces
custom custom.ts The escape hatch: a guard whose kind, dim, check and prose the spec author writes by hand.

32. custom

The escape hatch: a guard whose kind, dim, check and prose the spec author writes by hand.

When to reach for it. Only when no kind fits — typically a domain concept the runtime carries no vocabulary for (media, labels, provenance) read through the world's own accessors. It is the one factory whose hook YOU choose, by the dim you pass: it is classified under preTool here only because this example is a run guard. Replicate the shared kinds' exemptions, since reviewers read this code.

custom({ kind: 'imageQuotaLeft', dim: 'run', check: (ctx) => (ctx.world.imageQuotaRemaining > 0 ? null : 'No image quota left this month — say so instead of generating.'), prose: () => 'generate an image only while quota remains' })

6. When nothing fits: custom

function custom(opts: {
  kind: string;
  dim: Dim;
  check: (ctx: GuardCtx) => string | null | Promise<string | null>;
  prose: () => string;
}): Guard

signature, from looprun

custom is not a lesser path — chapter 03's "never double-book" gate is one, because no runtime kind knows what a calendar clash is. Reach for it when the discriminator is a domain concept the runtime carries no vocabulary for, read through your world's own accessors.

Here is the second one the scheduler wants: an event that has already started is not cancelled. No catalog kind covers it — the discriminator is in the args (which event) and in the world (its start time), which is exactly what rules out precondition, whose predicate never sees the args.

/**
 * The accessor this guard needs, named once. `AgentWorld`'s index signature would let the typo
 * `snapshto()` compile (chapter 03 §7), so the read goes through a type either way.
 */
type CalendarReader = AgentWorld & { snapshot(): CalendarEvent[] };

/** The event `ctx.args.eventId` names, or `undefined`. Total: a guard's `check()` must never throw —
 *  the runtime does not swallow it, it attributes it and rethrows at the caller. */
function targetEvent(ctx: GuardCtx): CalendarEvent | undefined {
  const eventId = typeof ctx.args.eventId === 'string' ? ctx.args.eventId : '';
  return (ctx.world as CalendarReader).snapshot().find((e) => e.id === eventId);
}

export function noCancelAfterStart(now: string): Guard {
  return custom({
    kind: 'noCancelAfterStart',
    dim: 'run',
    check: (ctx) => {
      const event = targetEvent(ctx);
      if (!event || event.start > now) return null;
      return `"${event.title}" (${event.id}) started at ${event.start} — it is too late to cancel it. Say so and offer to remove the remaining time instead.`;
    },
    prose: () => 'an event that has already started is never cancelled — say it is too late and offer what can still be done',
  });
}

excerpt · snippets/04-guards.ts

/** Binding it: a subclass, so the shared `schedulerSpec` of chapters 02–03 keeps its own surface. */
export class LateCancelSchedulerSpec extends SchedulerSpec {
  constructor() {
    super();
    this.addGuard('preTool', ['cancelEvent'], noCancelAfterStart(REFERENCE_NOW), { id: 'agent:noCancelAfterStart' });
  }
}

excerpt · snippets/04-guards.ts

Five rules, learned from the shipped kinds, that a reviewer will look for:

rule why
dim must match what check reads it is a claim, and addGuard holds you to it. This one reads ctx.args + ctx.worldrun
check must be pure and total no clock, no randomness, no network, no LLM call — and no throw. Same inputs, same verdict, forever, or a failing eval case is not reproducible
check must not read the user's text it is not in GuardCtx at all, and reaching for it through world re-opens the magnet firewall by hand
prose() states the RULE, not the incident present tense, no accusation: it renders into every prompt, including turns where nothing went wrong
replicate the exemptions the shared kinds have e.g. a reply-side rule that fires on questions and offers as if they were claims is a rule that punishes good behaviour

A guard that throws is an author bug, and the runtime treats it as one. It is neither a deny nor an allow, so nothing is guessed: AgentSpecBase wraps every binding, and a check()/prose() that throws is re-thrown as a GuardExecutionError naming the hook, the binding id, the kind and the tool — out of runSpecConversation, loud and addressed, instead of being buried as a model failure. You are not expected to catch it; you are expected to fix the guard, which is why the class ships on @looprun-ai/core/internal rather than the public barrel. Write the total function instead: the typeof … === 'string' ? … : '' read above is what "total" costs.


7. Recap

   Guard         kind · dim · check(ctx) → deny string | null · prose() → the prompt line
   GuardCtx      args · tool · world · observed · turnIndex · reply · result   (never the user text)
   ObservedCall  name · args · ok · turnIndex · resultFlags · tookEffect
   Dim           spatial | input | run | output | behavior  → which hooks are legal
   canonArgs     the key-order-independent call fingerprint — `noDuplicateCall` keys on it, and
                 `pendingConfirmMustAsk` keys on it with the confirm flag stripped

   30 factories, grouped by the hook they run on:
     preTool        13   prevent it — the deny returns as the tool result, the model retries
     postTool        1   the result is in; correct the REPLY, not the call
     onReply        14   the reply exists; a deny costs a re-generation, then the honest closure
     onReplyMutate   1   rewrite, never veto
     custom          1   the escape hatch — you pass the dim

You now have the map, the machine and the rules. Chapter 05 runs all three over a scripted conversation and turns "it seemed fine" into a number you can re-run.

05 · Running and eval