Agent guardrails — input processors#
Every turn's messages pass through a processor chain before they reach the model. Each processor can rewrite the turn or abort it. Guide: The agent and your copilot.
import {
runProcessors, unicodeNormalizer, historyTokenBudget, piiFilter, injectionGuard, approxTokens,
} from '@janux/agent';
export default defineAgent({
harness: {
processors: [unicodeNormalizer(), piiFilter(), historyTokenBudget(120_000)],
},
});The contract#
interface InputProcessor {
name: string;
run(turn: TurnContext): TurnContext | Promise<TurnContext>;
}
interface TurnContext {
messages: TurnMessage[];
aborted?: { reason: string };
}runProcessors(processors, turn) runs them in order and short-circuits: the first processor that returns an aborted turn stops the chain, so a blocking guard is never undone by a later step. Order matters — normalize before you classify, filter before you budget.
unicodeNormalizer()#
NFKC-normalizes text and strips zero-width and bidi control characters. Those are the classic smuggling vector: invisible codepoints that a model reads but a human reviewer never sees. Put it first, so every later processor sees the same text the model will.
piiFilter()#
Masks known PII patterns in message text before the turn leaves your process — the difference between "we don't log PII" and "PII never reaches the provider". Non-string content is passed through untouched.
historyTokenBudget(maxInputTokens)#
Caps the turn by an approximate token budget, dropping the oldest non-system messages first. It never drops the system prompt and never drops the newest user turn — so a long conversation degrades by forgetting the middle, not by losing its instructions or the question just asked.
historyTokenBudget(120_000)Pair it with memory.lastMessages: that's a message window, this is a token ceiling. Long messages blow a message window; this catches them.
injectionGuard(classify, mode?)#
injectionGuard(async (text) => (await isSuspicious(text)) ? 'suspicious' : 'ok', 'block')Runs your classifier over the latest user message and, on 'suspicious', either aborts the turn (mode: 'block', the default) or lets it through for logging (mode: 'warn'). The classifier is yours — a regex set, a small local model, a hosted moderation call — because what counts as an injection depends on what your tools can do.
Start in warn while you tune the classifier against real traffic; flip to block once the false-positive rate is acceptable. A confirm guard on the dangerous intents is the belt to this braces: even a turn that slips through can't act unattended (intents and guards).
approxTokens(text)#
text.length / 4, rounded up — the estimator historyTokenBudget uses. Deliberately cheap and provider-agnostic: no tokenizer download, no per-model table, and it errs high enough to keep you under a real limit. Import it when your own processor needs to size a message the same way.
Writing your own#
const dropToolNoise: InputProcessor = {
name: 'drop-tool-noise',
run: (turn) => ({ ...turn, messages: turn.messages.filter((m) => m.role !== 'tool') }),
};Return a new turn rather than mutating the one you were given, and set aborted: { reason } to stop the chain.
Related: The agent and your copilot · Intents and guards · Agent memory