Monitor

Context Integrity

Typed checks that catch a run contradicting itself — and a ledger that proves the checkers ran.

In a recorded turn, a subsystem was parked — its instructions stopped being sent — while four tools it owned kept riding every call. One channel said inactive, another showed it available, and nothing in the system was responsible for noticing. Context Integrity makes that noticing someone's job: deterministic checks at the seams where contradictions are CREATED, filed as typed events. Checks never block or rewrite anything — detection converts a silent inconsistency into an attributed one.

The finding event

Every detected defect is one agentfootprint.integrity.context_error event — one per defect per run, however many passes re-detect it. The payload is the finding: kind ('invariant-violation' | 'dangling-reference' | 'unsupported-argument' | 'unsupported-claim' | 'empty-lookup' today), seam ('compose' | 'wire' | 'choice' | 'claim' | 'write'), subjects (who it is about), witnesses (both channels' assertions — neither preferred), message (plain sentence naming the fix). A finding may also carry predicate — WHICH relation the defect is about, when the subjects alone do not say (the claimed field, the argument's dot-path); it is part of the finding's identity, so two bad arguments of one call are two defects rather than one. A finding may also carry advisory: true — a seam saying "this is not a defect" (the model declining to claim a verified fact is doubt, not contradiction; a lookup for a run-produced identifier coming back empty is a place to look, not a proven fault); advisories are counted apart from defects everywhere the family reports. Example:

agent.on('agentfootprint.integrity.context_error', (e) => {
  console.log(e.payload.kind, e.payload.seam, e.payload.message);
});

The checks that ship today

  1. Parked but still served (compose seam) — with .maps() mounted, every pass compares each parked map's owned tool names against the final merged wire list. Day one it caught a real leak: the park hold-out filters registry and skill tools, but a .toolProvider() copy of a parked member's name stays on the wire.
  2. The wire manifest (wire seam) — the anthropic and browser-anthropic adapters state LLMResponse.wireManifest (a WireToolManifest: the tool names read back from the FINAL serialized request body, after every transform). The loop compares it against the exact request it handed the adapter, so a frame that says "removed" while the body still carries the schemas is caught — the defect every pre-serialization check structurally misses. A provider that states no manifest leaves the call incomparable: silence, never a guess. An empty manifest is a stated zero.
  3. Dangling reference (compose seam) — a tool author declares where a tool's arguments come from: defineTool({ name: 'screen_fire', argumentsFrom: ['whats_here'], ... }). When a window strategy has evicted those results and nothing re-fetched them, offering the tool files a finding telling the model the honest fix (call the ground again). Never dropped = silent (not-yet-grounded is legitimate sequencing); re-fetched = silent.
  4. Unsupported argument (choice seam) — the same argumentsFrom declaration, one seam later: when the model CALLS an armed tool, every identifier-like string argument must appear somewhere in the frame it chose from. Its own earlier answers are not part of that frame. See below.
  5. Unsupported claim (claim seam) — the answer against the run's own settled facts. See below.
  6. Empty lookup (write seam, opt-in) — the run itself produced an identifier, and the lookup keyed on it came back empty. The SAME argumentsFrom declaration, one seam later still, plus noticeEmptyLookups: true. Always an advisory, because an empty answer can be perfectly true and nothing here can tell that from a lookup that could never have matched. See Empty lookups.
  7. Prior-turn evidence (claim seam, opt-in) — the answer is grounded and NOTHING this turn fetched grounds it: every value in it was last served in an earlier turn. noticePriorTurnEvidence: true plus .namesAndNumbersFromEvidence(), whose extractor decides which tokens are values. Always an advisory, because referring back to an earlier result is ordinary conversation and nothing here can tell that from an answer that has gone stale. See Prior-turn evidence.

The choice seam — an argument nothing served

The failure this exists for. Turn two of a triage conversation. The window had dropped the user message carrying the true machine id and kept the assistant's own rendered answer from turn one. Asked "what's the backup status for that machine?", the model resolved the reference out of its OWN prior prose — it took the fragment 4417-ganymede out of the job name bkp-4417-ganymede-tier2 it had rendered a minute earlier, called the backup tool with it, got an honest "nothing found", and told the person their actually-protected machine had no backup record.

Every shipped rail passed, and passed honestly. The coverage envelope was complete. The absence envelope was truthful. The evidence gate agreed, because every value in the answer really was grounded — the tool really did return "no record" for the string it was handed. The defect was not a value. It was the referent, bound wrong at the argument, at the one seam in the loop that had no check: the model's choice of tool arguments.

The rule. After each LLM response, for every call to an armed tool, every identifier-like string argument must appear — case-insensitive substring — in the system prompt, any USER message, or any TOOL-result message of the exact request that call was assembled from. Assistant messages are deliberately not ground: the system prompt, the user's words and tool results are things the RUN put in front of the model; its own earlier turns are things it wrote, and a value re-read out of rendered prose can be a fragment of something else entirely. Nothing is blocked — the call goes out exactly as the model made it.

The arming: one declaration, two seams. argumentsFrom is the same field the dangling-reference check reads, and declaring it once arms both. Dangling-reference asks whether the ground is still in reach while the tool is offered; this asks whether the value the model chose came from that ground when the tool was called.

import { Agent, defineTool } from 'agentfootprint';

const fleetReport = defineTool({
  name: 'fleet_report',
  description: 'List the machines in the fleet by their real names.',
  inputSchema: { type: 'object', properties: {} },
  execute: async () => 'FLEET: callisto-02 (online), europa-03 (online)',
});

const backupStatus = defineTool({
  name: 'backup_status',
  description: 'Read the backup record for one machine.',
  inputSchema: { type: 'object', properties: { machine: { type: 'string' } }, required: ['machine'] },
  argumentsFrom: ['fleet_report'], // ← arms BOTH the compose and choice checks
  execute: async ({ machine }: { machine: string }) => `no backup record for ${machine}`,
});

const agent = Agent.create({ provider, model }).tool(fleetReport).tool(backupStatus).build();

agent.on('agentfootprint.integrity.context_error', (e) => {
  if (e.payload.kind === 'unsupported-argument') console.log(e.payload.seam, e.payload.message);
});
// choice  'backup_status' was called with machine = "4417-ganymede", and the only place that
//         value appears in the frame the model chose from is the model's own earlier answer.
//         Rendered text is not evidence … To ground it, call fleet_report again.

The fences — they are the check's honesty, and each one is silence by design:

SituationVerdict
The argument is a number, boolean or nullnever checked — not identifier-shaped
The trimmed value is shorter than 4 charactersnever checked — substring matching below that is noise
The value appears in the system prompt, a user message or any tool resultno finding — it was served
The value is declared in an enum in the tool's own inputSchemano finding — declared vocabulary is served by the schema
The value appears ONLY in an assistant messagefinding: the only ground is the model's own prose — re-fetch the real ground
The value appears nowhere in the framefinding, with a different message: nothing served it at all

The last two say different things because they need different fixes. Self-reference has a real ground upstream and the message names the tool to call for it; "nowhere" has no ground in the window at all, and says so.

Two stated limits. A value the model composed from several grounds ("host1,host2") contains no single served string and will file — the declaration means arguments come from results, so a tool whose arguments are legitimately model-composed should not declare argumentsFrom. And substring grounding is deliberately lenient in the other direction: a value inside ANY tool result passes, even an unrelated one. This check catches fabrication and self-reference, not misattribution — a false accusation costs a reader's trust in every finding the family files, so the bias points the lenient way.

The finding's predicate is the argument's dot-path (machine, filter.hosts.0) and is part of its identity: two bad arguments of one call are two defects, and the same argument re-chosen on a later iteration stays one.

.claims() — the answer checked against the facts

The evidence gate (.evidence()) grounds the answer's names and numbers: every value must appear in a tool result. Its stated limit is that a FALSE CLAIM ASSEMBLED FROM REAL VALUES passes — "fc1/3 is healthy" when the data says the port is down uses entirely grounded tokens. .claims() closes that hole for the facts you name. Tools that return semantic({ facts: [...] }) settle typed readings; you declare which answer field reports which fact, and the checker joins:

const agent = Agent.create({ provider, model })
  .tool(screenTool)                      // returns semantic({ facts: [...] })
  .outputSchema(AnswerSchema)            // required — prose is never checked
  .claims({ nav_count: { entity: 'screen2', field: 'nav' } })
  .build();

DECLARED, NEVER INFERRED (the argumentsFrom precedent): the library does not guess that a field named nav_count is about nav(screen2) — you say so. Nothing is blocked: a disagreement files one finding at seam 'claim' and the answer is returned exactly as it was. .outputSchema() is required, and a contract without one is refused at build() rather than silently checking nothing. The fences are the check's honesty: latest settles, earlier rows quote (claiming a superseded value contradicts the history on the record); uncollected is unreachable, never an accusation, and an unknown fact propagates as unknown; doubt is an advisory (null or 'unknown' where the ledger holds a value files advisory: true, counted apart).

The disposition ledger — proof the checkers ran

A findings stream cannot distinguish "no defects" from "the checker was unhooked". So every run files ONE agentfootprint.integrity.disposition event at the run boundary (success, failure, or pause — and inside recordings): one row per registered check counting checked / findings / notApplicable / unreachable (e.g. a provider stating no wire manifest is unreachable — a different fact from clean), plus quarantined synthetic counts. dangling-reference, unsupported-argument, unsupported-claim, empty-lookup, and invariant-violation at the compose seam are armed only when the app declares their precondition (an argumentsFrom tool — which arms two of them — a .claims() contract, a .maps() plan, noticeEmptyLookups beside an argumentsFrom tool) — an app that declares none of them still gets all five rows, filed not-applicable, so "nothing was armed" reads as five explicit not-applicable rows next to the always-present wire row, never as a silent, indistinguishable "everything passed". Set integrityPosture: 'dev' on Agent.create to add the liveness theorems: a run-start canary proves each check still catches its own synthetic defect, and a finished run whose registered checkers demonstrably never ran fails with CheckerDeadError instead of returning green. Default 'observe': rows only, listener-gated like every typed event. Example:

const agent = Agent.create({ provider, model, integrityPosture: 'dev' })
  .tool(myTool)
  .build();
agent.on('agentfootprint.integrity.disposition', (e) => {
  for (const row of e.payload.rows) {
    console.log(row.check, row.seam, `checked=${row.checked}`, `findings=${row.findings}`);
  }
});

Reading it back — find_context_errors

Findings and the ledger ride the event stream, and typed events are delivered live and dropped when nothing is listening. So the read-out needs a recording: wire recordRun before the run (or build with .selfExplain(), whose event tail is on by default), and open the recording with the trace toolpack:

import { recordRun, openRecording, traceToolpack, callTraceTool } from 'agentfootprint/observe';

const recorder = recordRun(agent);              // BEFORE run()
await agent.run({ message });
const tools = traceToolpack(openRecording(recorder.toRecording()));
console.log(await callTraceTool(tools, 'find_context_errors', {}));

find_context_errors mounts unconditionally on traceToolpack(), so a debugger model always has it. Without an event tail it says so — "no evidence", never a clean bill of health — rather than reporting a clean run, and it distinguishes four absences: no tail, a tail with no integrity events, no check registered, and a check registered that never checked anything. Its green is bounded and says so: a headline names how many of the registered checks actually checked something, and every report repeats that green means no REGISTERED check was violated, not that no context error exists. One of the defect classes the kind union names ('duplicate-execution') still has no check in this build; the tool withholds it from its menu and refuses to answer about it instead of implying silence is absence.

On this page