Run Middleware
Hooks around every model and tool call of a run, with tripwire guards that stop it. PII masking, guardrails and compaction are built on them.
Run observers only watch a run. Middleware changes it: it sits around each model call and each tool call, so masking, guards, caching, argument rewriting, retries and logging live in one place instead of in every tool and every agent.
import { Agent, Cogitator, tripwire, type RunMiddleware } from '@cogitator-ai/core';
const timing: RunMiddleware = {
name: 'timing',
async wrapModelCall(call, next) {
const started = Date.now();
const response = await next(call);
metrics.record(call.model, Date.now() - started);
return response;
},
};
const refundGuard: RunMiddleware = {
name: 'refund-guard',
beforeRun({ input }) {
if (/refund/i.test(input)) tripwire('Refunds go to a person', { queue: 'billing' });
},
};
const cog = new Cogitator({ middleware: [timing] });
const agent = new Agent({
name: 'support',
model: 'openai/gpt-6-luna',
instructions: 'You help with orders.',
middleware: [refundGuard],
});
await cog.run(agent, { input: 'Where is my order?', middleware: [auditFor(userId)] });Where Middleware Goes
| Place | Applies to |
|---|---|
new Cogitator({ middleware }) | Every run of the runtime, and tool calls made with invokeTool() |
new Agent({ middleware }) | The runs of this agent, and the turns of a run handed over to it |
cog.run(agent, { middleware }) | One run. Pass it again to resume() |
The hooks of hooks run first, then the Cogitator's middleware, then the agent's, then the run's, then the built-in middleware closest to the model. before* and afterRun hooks run in that order, wrap* hooks nest with the first one outermost, and afterModel runs in reverse, so the middleware nearest the model sees its answer first.
Middleware is code, so the agent wire format does not carry it: the side that runs an agent gives its own.
Hooks
Every hook is optional. Each one gets the run's context as its last argument.
| Hook | When | What it can do |
|---|---|---|
startRun | A run starts, new or resumed, before its first model call | Return { context } to add text after the system prompt, for this run only |
beforeRun | A new run's input, before it is saved or sent | Check it, return { context } to add text after it. Also sees each queued message of cog.start() |
beforeModel | Before each model turn | Return { messages } to change the conversation from then on |
wrapModelCall | Around each call, once per model a turn tries | Change the request or the stream sink, call next again, or answer without it |
afterModel | After each answer, before the run uses it | Return { content } or { toolCalls } to replace them |
beforeToolCall | Before the permissions decide a tool call | Deny it, ask for it, allow it, defer it to a person, or change its arguments |
decideApproval | A call asks for approval, before onApproval | Approve or decline it as a person would |
wrapToolCall | Around each tool call that passed approval | Change the arguments, or answer for the tool |
afterToolBatch | Every call of a step resolved, before the next model call | Add context (context.addContext) or end the run (context.stop) |
beforeStop | The agent would stop with a final answer | Return { continueWith } to keep it going with that message |
beforeCompaction | Before a compaction of the turn or the thread | Return { skip: true } to keep the conversation as it is |
afterCompaction | After a compaction ran | Log or react |
beforeModelSwitch | A handoff would move the run to another model | Return { block } to keep the run on its agent |
onNotification | The run needs a person (an approval, a question) | Tell someone, it cannot change what happens |
afterRun | A run ended: completed, paused or failed | Keep memory in shape for the next run, clean up after the run |
beforeRun
beforeRun({ input, messages }, context) sees the input of a new run and the messages its first turn starts from: the system prompt, the history and the input, with what memory recalled for the turn and the brief in a <memory_context> block in front of it (the run's context and the insights of reflection are added after beforeRun). The input is not saved to memory and no model turn starts before it returns, so it is the place for input guards. Audio input is already transcribed into input by then. Return { context } and the text goes after the input as a system message, saved to the thread with it, so a later run reads it too.
startRun(event, context) runs before, once per run: event.source is startup for a run on a thread without history and resume for one with history or a resume() (event.resumed). Its { context } goes right after the system prompt and is not saved: each run gets its own.
Tool calls before the permissions
beforeToolCall({ toolCall, tool, resumed }, context) runs for each call right before it starts (after the calls before it in the step ran, or as each is dispatched when calls run side by side) and before the permissions decide it, and again for a call that waited in a paused run once resume() continues it. Return a ToolCallVerdict:
decision | Effect |
|---|---|
deny | The call is refused, the model reads reason |
ask | The call asks a person even when the rules allow it |
allow | The call runs without asking when only the mode or the tool's own approval setting would ask. An ask or deny rule still applies |
defer | The run pauses with the call waiting for approval, without asking onApproval |
updatedArguments replaces the call's arguments: they are checked against the tool's schema and the permissions decide them, not the ones the model sent. allow skips only what the mode or the tool's own approval setting asks (in dontAsk and auto mode too, without the classifier), never a deny or ask rule or an ask that guards a deny rule. With several middleware, deny wins over defer, over ask, over allow, and the last updatedArguments counts. Decisions a hook makes are logged with decidedBy: 'hook'.
decideApproval({ request, tool }, context) answers a call that asks, before onApproval and the guardrails' onToolApproval: return a ToolApprovalDecision ({ approved: true }, with updatedArguments when the permissions are set up, or { approved: false, reason }) and nobody is asked. Return nothing to leave it to them.
afterToolBatch({ calls }, context) runs once every call of a step has its result, before the next model call. beforeStop({ output, continuations }, context) runs when the agent would stop: { continueWith } keeps it going with that message, saved as a user message. continuations counts how often in a row that happened since the agent's last tool call, continued whether it happened since the last user message, and the hooks are not asked once the run used every turn it may take.
beforeModel
beforeModel({ messages, model }, context) runs before each turn, model as the agent names it (before any fallback). Return { messages } and the run goes on with them, in this turn and every later one. The built-in compaction works this way.
const reminder: RunMiddleware = {
name: 'reminder',
beforeModel({ messages }, context) {
if (context.iteration !== 5) return;
return { messages: [...messages, { role: 'user', content: 'Wrap up soon.' }] };
},
};wrapModelCall
wrapModelCall(call, next, context) wraps one call to one model. call.request is the ChatRequest the backend gets, call.model the provider/model it goes to, and call.stream where streamed text goes when the run streams. Hand next the call, or a changed copy of it, once or more, or return a ChatResponse of your own without calling it. When a turn falls back to one of the agent's fallbackModels, the hook runs again for that model. Every call that reaches a backend counts toward the run's usage, cost and budgets and gets its llm.chat span, an answer the hook gives without calling the model counts nothing. In a streamed run such an answer reaches onToken in one piece.
const cache: RunMiddleware = {
name: 'cache',
async wrapModelCall(call, next) {
const key = JSON.stringify(call.request.messages);
const hit = answers.get(key);
if (hit) return hit;
const response = await next(call);
answers.set(key, response);
return response;
},
};To see or rewrite streamed text, hand next a call with a stream sink of your own that passes text on to call.stream.
afterModel
afterModel({ response, messages, model }, context) sees each answer before the run uses it. Return { content } to replace its text, { toolCalls } to replace the calls it makes (an empty list turns it into the final answer). Text a streamed run already sent to onToken cannot be taken back: set holdsStream: true on the middleware and the run holds each turn's text until the turn is done and the hooks saw it, then sends the final text in one piece. The same goes for a wrapModelCall hook that retries a call or blocks its answer: without holdsStream the text of every call it sends streams as it arrives.
A run's own middleware sits inside the Cogitator's and the agent's, so its afterModel sees an answer before theirs do. To see each turn as the run keeps it, after every layer, use onModelTurn in the run options instead.
const noEmails: RunMiddleware = {
name: 'no-emails',
holdsStream: true,
afterModel: ({ response }) => ({
content: response.content.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, '[email removed]'),
}),
};wrapToolCall
wrapToolCall(call, next, context) wraps each tool call that passed approval, also the calls invokeTool() makes for the Cogitator's middleware. call.toolCall is the call as the model made it, call.args the arguments as the tool's schema parsed them, call.approved whether a person approved it. Change the arguments by handing next a toolCall with new ones: they are checked against the schema and the tool's requiresApproval again, and a call they do not fit, or that would need an approval for them, answers the model with the reason. An approval never carries over to new arguments: the layers inside see approved: false. Each layer and the tool get their own copy of plain data (objects, arrays, maps, sets), so an edit in place changes only that copy. Other values (a Buffer, a URL, an instance of a class) are passed as they are: an edit inside them is seen through their own fields or toJSON, but it reaches the caller's object, and a change a class keeps in private fields is not seen. The tool, the call's id and name and approved cannot be changed, and onToolCall, RunResult.toolCalls and spans keep the call as the model made it. Return a ToolResult of your own to answer for the tool.
const normalizeIds: RunMiddleware = {
name: 'normalize-ids',
wrapToolCall(call, next) {
const id = String((call.args as { id: string }).id)
.trim()
.toUpperCase();
return next({ ...call, toolCall: { ...call.toolCall, arguments: { id } } });
},
};afterRun
afterRun(event, context) runs once a run ended, before run() returns or throws: event.status is completed (after the run saved what it said to memory), paused (with the paused result) or failed (with event.error, an abort among them). A completed or paused run has its result, a failed one has none. model is the model the run ended on, as provider/model, after any fallback, and memoryAgentId the id the agent's long-term memory is kept under (memoryAgentId(agent): its configured id, else its name). It is the place for work that keeps memory in shape for the next run, which the caller waits for: the built-in extraction of memory.extraction and compaction of memory.compaction work this way, for completed runs only. The run is done by then, so an error a hook throws is logged as a warning and the next afterRun hook still runs, the caller keeps the result or the error. Skip slow work when context.signal is aborted, the caller has stopped waiting.
const archive: RunMiddleware = {
name: 'archive',
async afterRun(event, context) {
if (event.status !== 'completed') return;
await runs.insert({
threadId: context.threadId,
model: event.model,
output: event.result.output,
});
},
};The Run Context
| Field | What it is |
|---|---|
runId, threadId | The run and its thread (threadId is unset for invokeTool() without one) |
agentId, agentName | The agent whose turn it is, after a handoff the one the run went on as |
userId, channelType, channelId, topicId, conversationId | Who and where the run serves |
iteration | The model turn the hook belongs to, from 1, or 0 before the first turn |
signal | Aborts when the run is cancelled or times out |
deps | The run's tool deps |
state | A Map the run's middleware share. Not kept in a paused run's checkpoint |
parentRunId | The run whose tool call started this one, for a delegated run |
permissionMode | The run's permission mode, when it has permissions set up |
addContext(text) | Adds a system message for the next model call, saved to the thread |
stop(reason) | Ends the run before its next model call: it completes with stopped |
logEvent(event, note) | Writes a custom entry to the thread's log |
addContext and stop are unset outside a run (invokeTool()), logEvent when there is no thread to log to. A run a middleware stopped completes with result.stopped: the reason and the name of the middleware that asked.
Tripwires
A guard that finds something the run must not go on with calls tripwire(reason, info?). The run stops where it is and fails with GUARDRAIL_TRIPPED (HTTP 400): the reason, info and the name of the middleware in details. Unlike a tool error, which the model reads and works around, a tripwire ends the run.
import { isTripwire } from '@cogitator-ai/core';
try {
await cog.run(agent, { input });
} catch (error) {
if (isTripwire(error)) {
console.log(error.details.middleware, error.details.reason, error.details.info);
}
}A tripwire raised inside a tool, such as a nested run of an agent used as a tool, stops the outer run too instead of reaching its model as a tool error. Any other error a hook throws fails the run as it is, and a hook still running when the run is cancelled or times out is no longer waited for. Outside a run, invokeTool() throws an error its wrapToolCall middleware throws, a tripwire among them, while a failing tool still comes back in the result's error. A hook inside wrapModelCall that throws a provider error (LLM_UNAVAILABLE and the like) moves the turn to the next fallback model, as the model's own failure would.
Built-in Middleware
The runtime's own features are middleware too, added from the config closest to the model, in this order:
| Middleware | From | Hooks |
|---|---|---|
cogitator:pii | security.pii | wrapModelCall masks, beforeRun in block mode |
cogitator:prompt-injection | security.promptInjection | beforeRun |
cogitator:guardrails | guardrails | beforeRun, afterModel (holds the stream), wrapToolCall |
cogitator:clear-tool-outputs | context.clearToolOutputs | beforeModel clears, wrapToolCall answers recall_tool_output |
cogitator:compaction | context | beforeModel |
cogitator:memory-extraction | memory.extraction | afterRun |
cogitator:memory-compaction | memory.compaction | afterRun |
The hooks of hooks become one more, cogitator:hooks, the outermost layer of every run, so a hook sees a tool call as the model made it and its result as the model reads it. The compactions of context and memory.compaction ask beforeCompaction first and tell afterCompaction after.
Because PII masking is the innermost layer, it also masks whatever your middleware adds to a request, while your middleware sees the real values. The guardrails check the model's answer and a tool's result before your hooks see them: what your afterModel or wrapToolCall hooks change afterwards is not checked again. piiMiddleware(config), promptInjectionMiddleware(detector), guardrailsMiddleware(constitutionalAI), clearToolOutputsMiddleware(options) and compactionMiddleware(contextManager) build them, to give a feature to one agent only. memoryCompactionMiddleware(config, deps) builds the compaction after a run, given the memory, a summarizer per model and run, and the context window of a model, and memoryExtractionMiddleware(config, deps) the extraction of memory items after a run, given the memory and a summarizer per model and run. The extraction's afterRun runs before the compaction's, so the items of a thread are taken before it is compacted. The example below gives one agent PII masking:
import { piiMiddleware } from '@cogitator-ai/core';
const intake = new Agent({
name: 'intake',
instructions: 'Take the customer details.',
middleware: [piiMiddleware({ detect: ['email', 'phone'] })],
});See Security and Constitutional AI for what the built-in features check.