GNL
Docs menu
Core · Free@gnldev/processors

Input/output processors

Transform what goes into a run and what comes out of it — PII redaction before the input is ever persisted, token limits, tool filtering, and the marker that turns a tool's output into untrusted content.

What it is for#

A processor sits in the pipeline around a durable run: processInput reshapes what the model is about to see, processOutput reshapes what it produced, and processToolResult reshapes what a tool returned. They compose, and they are ordinary functions — the built-ins here are a starting set, not a closed list.

The placement matters more than the list. Input processors run before the input is persisted, so a redaction is not a display filter over stored data — the raw value never reaches the journal at all. And because the pure ones are deterministic, they need no journaling of their own: a replay reproduces the same transformation.

PII redaction#

piiRedactor masks the types you name (all of them by default) across the system prompt, the prompt and the message list, with a mask generator you can replace. Because it runs on the input path, what lands in the journal is already masked — which is the difference between "the raw card number is stored but hidden in the UI" and "the raw card number was never stored".

redact before it is written
import { piiRedactor } from '@gnldev/processors';

await runDurable({
  runId: 'ticket-9',
  journal,
  model,
  tools,
  processors: [piiRedactor()],   // all PII types; pass { types } to narrow
  prompt: 'my card is 4111 1111 1111 1111',
});
// the journal holds the masked text the raw number was never written

What the output side covers, and what it does not#

processOutput transforms the {text, messages} view, so result.text and result.response.messages come back masked on both runDurable and streamDurable, and so does what is written to thread memory. Read those.

result.steps and result.content do not. They still hold the model's raw output, and a caller reading either sees unredacted text. This is not an oversight that a later release closes: a processor's output arity is unconstrained — a summariser legally returns one message for a turn that produced three — so there is no mapping back onto per-step records, and content is a parts array rather than messages.

On the stream path the textStream and fullStream deltas are raw as well. They reach the client before the turn ends, so nothing downstream can mask them after the fact. If the wire itself must never carry the raw value, use {on: 'input'} or runDurable.

what comes back masked
const r = await runDurable({ processors: [piiRedactor()], /* … */ });

await r.text              // masked
r.response.messages       // masked
await memory.getMessages(threadId)  // masked

r.steps                   // RAW the model's own output, per step
r.content                 // RAW — a parts array, not messages

// streamDurable: the same two are masked, but the deltas are not
const s = await streamDurable({ processors: [piiRedactor()], /* … */ });
for await (const chunk of s.textStream) { /* RAW — already on the wire */ }
await s.text              // masked

Limits, moderation and tool visibility#

tokenLimit keeps the newest messages that fit a budget — a simple sliding window. tokenLimiter is its fuller sibling: a pluggable countTokens, an oldest-first trim that always protects the system messages and the last user message, and an 'error' strategy that raises a ProcessorTripwire for callers who would rather fail loudly than silently lose context.

outputLimit caps what comes back. toolFilter changes which tools the model can even see — an allowlist wins over a denylist. moderationProcessor checks a blocklist on the input side, the output side or both.

a pipeline
import { piiRedactor, toolFilter, tokenLimiter, outputLimit } from '@gnldev/processors';

processors: [
  piiRedactor(),
  toolFilter({ allow: ['searchDocs', 'summarise'] }),   // allow wins over deny
  tokenLimiter({ maxInputTokens: 8000, strategy: 'error' }), // throw rather than drop context
  outputLimit({ maxChars: 20_000 }),
]

Untrusted content — and what it is not#

untrustedToolContent wraps a tool's output in markers that tell the model this text is data, not instruction. It is also the source end of the taint chain: a tool declared untrusted is what makes the taint guard gate every side effect that follows it.

mark it, then gate on it
import { untrustedToolContent } from '@gnldev/processors';

await runDurable({
  runId: 'chat-42',
  journal,
  model,
  tools: { fetchWeb, chargeCard },
  processors: [untrustedToolContent()],
  limits: { taintedSideEffects: 'suspend' },  // the rung that actually stops it
  prompt: '…',
});

The package is unusually direct about the limits of both this and the injection detector, and that honesty is worth repeating rather than softening.

API#

fnpiiRedactor

Masks PII on the input path — before persistInput, so the raw value is never journaled — and on the output path for text and response.messages. Options: types (default all), a mask generator, on ('input' | 'output' | 'both'), redactToolResults.

fnmoderationProcessor

Blocklist check on input, output or both. Substring matching, normalized to lowercase.

fntoolFilter

Controls which tools are visible to the model: allow (whitelist, wins) or deny (blacklist).

fntokenLimit

Keeps the newest messages that fit maxTokens — a simple sliding window.

fntokenLimiter

The fuller sibling: pluggable countTokens, oldest-first trim protecting the system messages and the last user message, and an 'error' strategy that throws ProcessorTripwire instead of dropping context.

fnoutputLimit

Caps output length at maxChars.

fnpromptInjectionDetector

Pattern-based first-line detection; a tripwire fires on a match. Read the warning below before relying on it.

fnuntrustedToolContent

Wraps a tool result in untrusted markers — the source end of the taint chain.

typeProcessorTripwire

The error a processor throws to stop a run rather than degrade it silently.

fntoolSearch

Semantic tool selection — documented on its own page.

Read text and response.messages; treat steps and content as raw
The two surfaces a redactor governs are the ones a chat backend, an audit log and a "copy the answer into the ticket" step actually read. The two it does not are the ones a debugger reads. Both statements are in the package's own documentation, and the second one is the reason it is worth stating here rather than leaving to be discovered from a support ticket.
Prompt injection is unsolved, and the code says so
promptInjectionDetector's own documentation states that prompt injection remains unsolved and that this detector catches none of the common bypasses — paraphrasing, base64/rot13/unicode encoding, another language, or staged and indirect instructions. Use it as noise reduction, never as the only thing between a payment or a deletion and an attacker. The layers that actually gate are the taint guard's block/suspend rungs, a human approval step, and least-privilege tool design.
Marking is a signal, not a boundary
untrustedToolContent wraps content; it does not make the model obey the wrapper. Language models cannot reliably separate instruction from data, so there is no guarantee the model ignores instructions inside the wrapped text. Treat it as a risk-reducing signal that feeds the taint guard — the enforcement lives there, not here. The same applies to moderationProcessor: substring matching is bypassable and is not a security boundary.