Input and output processors
A seam on each side of the model call — rewrite the prompt going out, gate the answer coming back.
A PromptBuilder could append to the system prompt, and nothing at all could look at the answer. For
an application that runs generated SQL over sensitive data that is a gap in the controls, not a
missing convenience: the one place where the model's output can still be stopped is between the
provider and the reader, and there was no such place.
const deps: AgentLoopDeps = {
// …
inputProcessors: [maskAccountNumbers],
outputProcessors: [redactEmails, moderate],
}Processors are wired on AgentLoopDeps and apply to every agent and persona: a control one
persona can opt out of is not a control.
Input: rewriting the prompt
An InputProcessor rewrites { system, messages } before every model call of a turn — every
call, not once per run, because the transcript grows between steps and a redactor that only saw the
opening prompt would wave through whatever a tool result carried back.
const maskAccountNumbers: InputProcessor = {
name: 'mask-accounts',
process: (prompt) => ({
system: prompt.system,
messages: prompt.messages.map((message) => ({
...message,
content: message.content.replace(ACCOUNT, '[account]'),
})),
}),
}What a processor produces is a derived prompt. The loop's canonical transcript is untouched, so a redaction is what leaves the process and never becomes the thread's own memory of what was said — and two steps of one turn never compound each other's rewrites.
Not a second HistoryWindow
Selection — which of the thread's messages ride into the turn — stays with historyWindow, which
is pure and runs outside any checkpoint. Processors transform what selection produced, and run
inside one, so they may call a model. Splitting the same decision across both means neither can
be reasoned about alone.
Output: gating the answer
An OutputProcessor sees each step's answer and returns pass, replace (a redaction is a
replacement), or reject — which ends the run with an OutputRejectedError rather than an answer.
const moderate: OutputProcessor = {
name: 'moderate',
process: async (answer) =>
(await classify(answer.text)).safe
? { action: 'pass' }
: { action: 'reject', reason: 'failed moderation' },
}A refused turn still records its chat usage row before it fails: those tokens were genuinely
spent, and a gate that hid its own cost could burn a budget invisibly.
What gating costs the reader, and how to pay less
Registering any output processor takes the turn's model call off the run's live sink — a gate that
must read the whole answer cannot run after the answer has already reached the reader. The turn
writes to a buffer instead, and the loop releases it, as one text frame, once the chain has passed.
That is the right answer for a moderation pass. It is much too expensive for a regex redactor that does not need the whole answer, so a processor can say so:
const redactEmails: OutputProcessor = {
name: 'redact-emails',
incremental: { lookbackChars: 320 },
process: (answer) => ({ action: 'replace', text: answer.text.replace(EMAIL, '[email]') }),
}Undeclared means whole-answer, and a chain is incremental only when EVERY member declares it. An
author who wrote process against the complete text is never downgraded because a neighbour opted
in.
Declaring incremental is a promise about every prefix of the answer. The chain sees the growing
prefix rather than each new frame, so it always gets well-formed text:
- outside the last
lookbackCharscharacters of its own output, areplacenever changes as the prefix grows; - a
rejectis decidable from a prefix. One that only emerges from the whole answer still fails the run, but the reader has already seen text — that is the cost of opting in.
lookbackChars is per-processor (default 64) and the gate uses the widest in the chain. The
whole-answer pass stays authoritative for both the stream and the store; the incremental release
only emits its prefix early, and the gate then asserts the settled answer startsWith what was
already released, raising ProcessorFailedError if not. A window too short for a pattern fails
loudly instead of streaming the text it was supposed to redact.
Tool calls on the incremental path
A provider reports its tool calls when runTurn returns, so on the incremental path
ModelAnswer.toolCalls is empty until the authoritative whole-answer pass, which always sees them.
Determinism
Both chains run inside checkpoints of their own — process:input:<step> and process:output:<step>
— and neither exists unless it is configured, so a deployment that registers nothing records
byte-identical checkpoints.
The buffer, the released prefix and a prefix refusal all ride the llm:<step> checkpoint rather
than a local variable. A run that suspends between the model call and the gate resumes in a process
that never saw the model's stream: the release is computed from the journaled releasedText, so the
resumed turn emits only the tail it still owes instead of flushing the same answer twice — even if
the chain was re-declared in between.
Failures
A processor that throws surfaces as ProcessorFailedError naming the phase and the processor, so it
can never be read as the model call failing. On the incremental path a throw during a prefix pass
is swallowed — text simply stops flowing and the whole-answer pass, which runs the same chain, is
what surfaces the failure.