Middleware and Hooks
Three interception surfaces, one decision rule:
| Surface |
Registration |
Can it change behavior? |
Errors |
Scope |
| Middleware |
agent.event_manager.intercept(kind, fn) |
YES — transform inputs/outputs, short-circuit, block |
propagate (a guardrail raising aborts the call) |
per-agent |
| Observers |
agent.event_manager.on(event_type, fn) |
no — fire-and-forget after the event is recorded |
isolated |
per-agent |
| InstrumentationHooks |
set_hooks(obj) (nooa.runtime.hooks) |
no — observational timing/tracing pairs |
swallowed + logged, overhead metered |
one global slot per async context |
Rule of thumb: enforcing or transforming → intercept(); reacting → on(); building an observability backend → you probably want a tracing exporter (nooa-capturing-traces), not raw hooks — the tracing system already occupies the hooks slot.
Middleware (intercept)
Each middleware is async def mw(ctx, nxt) -> ctx — a typed context object and a nxt callable running the rest of the chain. Three kinds (nooa.runtime.middleware):
| Kind |
Wraps |
Context (ctx) mutables in |
result out |
"agent_call" |
one whole agent-method call (all turns) |
args, kwargs (+ agent, method_name) |
ctx.result |
"llm_call" |
one LLM round-trip inside runtime.generate() |
messages (the rendered prompt), params (tools, output_model, max_tokens, ...) |
ctx.response (LLMResponse) |
"execute_python" |
one CodeAct cell in runtime.execute_code() |
code, params (timeout, restrictions, ...) |
ctx.result (ExecutionResult) |
from nooa.runtime.middleware import LLMCallContext, LLMCallNext
async def redact_secrets(ctx: LLMCallContext, nxt: LLMCallNext) -> LLMCallContext:
for m in ctx.messages: # transform inputs
if isinstance(m.get("content"), str):
m["content"] = m["content"].replace(SECRET, "[redacted]")
ctx = await nxt(ctx) # run the rest of the chain + the real call
return ctx # (could also inspect/patch ctx.response here)
unsubscribe = agent.event_manager.intercept("llm_call", redact_secrets)
...
unsubscribe() # intercept() returns a remover
Verified semantics:
- Order: registration order = execution order; first registered is outermost. Nesting across kinds:
agent_call → per-turn llm_call → per-cell execute_python.
- Short-circuiting (don't call
nxt) is allowed for guardrails/caching, but you MUST set the output slot (ctx.result / ctx.response) — the runtime raises RuntimeError if middleware returns without it. To fake an LLM turn, construct an LLMResponse (content, tool_calls=[], finish_reason="stop", assistant_message={...}, raw_response=None).
- Blocking: raise from the middleware — the exception propagates to the caller exactly like a failure of the wrapped operation (for
llm_call, CodeAct counts it against its session error budget).
- Exceptions are NOT swallowed — middleware is control flow, unlike hooks.
- Per-agent: registered on that agent's
EventManager; subagents have their own.
execute_python has a re-entry guard: code the middleware itself triggers (e.g. it calls agent methods) skips the middleware, while nested generation methods called by executed code re-enter it for their own cells.
- On a context-window error the runtime archives events, rebuilds messages, and retries — so
llm_call middleware can run more than once per logical turn; keep it idempotent.
- The tracing
on_messages_built hook fires inside the innermost core, so traces show the post-middleware messages.
Worked production example: src/nooa/nemo_relay_middleware.py installs all three kinds to route calls through NeMo Relay (guardrails/ATIF); nemo_relay_scope(agent, name) wraps install/uninstall. Runnable: examples/quickstart/13_nemo_relay.py. Test patterns (mutate/short-circuit/ordering): tests/test_event_middleware.py.
Observers (on)
Fire-and-forget callbacks after an event is recorded — they cannot alter it.
unsub = agent.event_manager.on("Error", lambda e: log.warning("agent error: %s", e.content))
agent.event_manager.on("*", audit) # wildcard: every event
- Useful runtime-only events (never rendered to the model):
BeforeTurn / AfterTurn (per generation turn; AfterTurn.is_final marks method completion) and LLMComplete (tokens, cost, model_name, tool_calls, reasoning metadata per round-trip — emitted precisely so you don't need intercept("llm_call") just to read LLM metrics).
- Model-visible events (
Task, Message, Error, PythonOutput, ...) are observable the same way — see nooa-context-and-state for the full list.
- The summarizers are the house pattern: subscribe to
AfterTurn to schedule work, apply it at the next BeforeTurn (agents/summarization.py:159-160).
InstrumentationHooks (set_hooks)
A Protocol of paired callbacks (nooa.runtime.hooks): before_/after_agent_call, before_/after_generation, before_/after_code_execution, before_/after_method_invocation, before_/after_tool_execution, plus the point-in-time on_messages_built. Each before_* may return a context object that is handed to its after_* (span/timing state without globals).
from nooa.runtime.hooks import set_hooks
class TimingHooks:
def before_agent_call(self, agent, method_name, args, kwargs, call_id, parent_call_id, **kw):
return {"t0": time.perf_counter()}
def after_agent_call(self, agent, method_name, result, exception, context, **kw):
print(f"{method_name}: {time.perf_counter() - context['t0']:.2f}s")
# unimplemented methods are fine — calls are defensive
set_hooks(TimingHooks()) # set_hooks(None) removes
- Hook exceptions never affect the agent (logged at WARNING); time spent in hooks is metered as tracing overhead.
- One slot, contextvar-scoped — and tracing uses it.
enable_tracing() installs OpenInferenceHooks via this same set_hooks (tracing/__init__.py:136-148); calling set_hooks(MyHooks()) afterwards silently replaces tracing (and vice versa). If you need both, delegate to the previous hooks object from yours, or prefer a tracing exporter / on() observers.
after_agent_call can receive context=None when agent_call middleware short-circuits before the before-hook ran — handle it.
Pitfalls
- Don't use hooks for app logic (they're swallowed-exception observational); don't use middleware for metrics you can get from
LLMComplete (you'd pay complexity for nothing).
AgentCallContext.result uses a not-set sentinel — a short-circuiting agent_call middleware that "returns None" on purpose must still assign ctx.result = None.
- Middleware lives on the instance's event manager: install in
__init__ (after super().__init__()) or on the constructed agent, not on the class.
- Keep
llm_call middleware fast — it's on the critical path of every turn, and runs again on context-window retries.
Related skills
nooa-context-and-state — the event model that on() observes; EventQuery filtering.
nooa-capturing-traces — the tracing system that occupies the hooks slot; exporters are usually the right telemetry surface.
nooa-codeact-advanced — what execute_python middleware wraps (validator pipeline, restrictions, cells).
1---2name: nooa-middleware-hooks3description: Intercept and observe NOOA execution — middleware via event_manager.intercept() (guardrails, input/output transforms, blocking), event observers via event_manager.on() (react to Task/Error/LLMComplete/turn events), and the InstrumentationHooks protocol for observability backends. Use when adding guardrails, redacting or rewriting prompts, blocking or faking an LLM call or code execution, rate-limiting agent methods, subscribing to lifecycle events, or wiring custom telemetry.4---56# Middleware and Hooks78Three interception surfaces, one decision rule:910| Surface | Registration | Can it change behavior? | Errors | Scope |11|---|---|---|---|---|12| **Middleware** | `agent.event_manager.intercept(kind, fn)` | YES — transform inputs/outputs, short-circuit, block | propagate (a guardrail raising aborts the call) | per-agent |13| **Observers** | `agent.event_manager.on(event_type, fn)` | no — fire-and-forget after the event is recorded | isolated | per-agent |14| **InstrumentationHooks** | `set_hooks(obj)` (`nooa.runtime.hooks`) | no — observational timing/tracing pairs | swallowed + logged, overhead metered | one global slot per async context |1516Rule of thumb: enforcing or transforming → `intercept()`; reacting → `on()`; building an observability backend → you probably want a tracing exporter (`nooa-capturing-traces`), not raw hooks — the tracing system already occupies the hooks slot.1718## Middleware (`intercept`)1920Each middleware is `async def mw(ctx, nxt) -> ctx` — a typed context object and a `nxt` callable running the rest of the chain. Three kinds (`nooa.runtime.middleware`):2122| Kind | Wraps | Context (`ctx`) mutables in | result out |23|---|---|---|---|24| `"agent_call"` | one whole agent-method call (all turns) | `args`, `kwargs` (+ `agent`, `method_name`) | `ctx.result` |25| `"llm_call"` | one LLM round-trip inside `runtime.generate()` | `messages` (the rendered prompt), `params` (tools, output_model, max_tokens, ...) | `ctx.response` (`LLMResponse`) |26| `"execute_python"` | one CodeAct cell in `runtime.execute_code()` | `code`, `params` (timeout, restrictions, ...) | `ctx.result` (`ExecutionResult`) |2728```python29from nooa.runtime.middleware import LLMCallContext, LLMCallNext3031async def redact_secrets(ctx: LLMCallContext, nxt: LLMCallNext) -> LLMCallContext:32 for m in ctx.messages: # transform inputs33 if isinstance(m.get("content"), str):34 m["content"] = m["content"].replace(SECRET, "[redacted]")35 ctx = await nxt(ctx) # run the rest of the chain + the real call36 return ctx # (could also inspect/patch ctx.response here)3738unsubscribe = agent.event_manager.intercept("llm_call", redact_secrets)39...40unsubscribe() # intercept() returns a remover41```4243Verified semantics:4445- **Order**: registration order = execution order; first registered is outermost. Nesting across kinds: `agent_call` → per-turn `llm_call` → per-cell `execute_python`.46- **Short-circuiting** (don't call `nxt`) is allowed for guardrails/caching, but you MUST set the output slot (`ctx.result` / `ctx.response`) — the runtime raises `RuntimeError` if middleware returns without it. To fake an LLM turn, construct an `LLMResponse` (`content`, `tool_calls=[]`, `finish_reason="stop"`, `assistant_message={...}`, `raw_response=None`).47- **Blocking**: raise from the middleware — the exception propagates to the caller exactly like a failure of the wrapped operation (for `llm_call`, CodeAct counts it against its session error budget).48- **Exceptions are NOT swallowed** — middleware is control flow, unlike hooks.49- Per-agent: registered on that agent's `EventManager`; subagents have their own.50- `execute_python` has a re-entry guard: code the middleware itself triggers (e.g. it calls agent methods) skips the middleware, while nested generation methods called by executed code re-enter it for their own cells.51- On a context-window error the runtime archives events, rebuilds messages, and retries — so `llm_call` middleware can run more than once per logical turn; keep it idempotent.52- The tracing `on_messages_built` hook fires inside the innermost core, so traces show the **post-middleware** messages.5354Worked production example: `src/nooa/nemo_relay_middleware.py` installs all three kinds to route calls through NeMo Relay (guardrails/ATIF); `nemo_relay_scope(agent, name)` wraps install/uninstall. Runnable: `examples/quickstart/13_nemo_relay.py`. Test patterns (mutate/short-circuit/ordering): `tests/test_event_middleware.py`.5556## Observers (`on`)5758Fire-and-forget callbacks after an event is recorded — they cannot alter it.5960```python61unsub = agent.event_manager.on("Error", lambda e: log.warning("agent error: %s", e.content))62agent.event_manager.on("*", audit) # wildcard: every event63```6465- Useful runtime-only events (never rendered to the model): `BeforeTurn` / `AfterTurn` (per generation turn; `AfterTurn.is_final` marks method completion) and `LLMComplete` (tokens, cost, model_name, tool_calls, reasoning metadata per round-trip — emitted precisely so you don't need `intercept("llm_call")` just to read LLM metrics).66- Model-visible events (`Task`, `Message`, `Error`, `PythonOutput`, ...) are observable the same way — see `nooa-context-and-state` for the full list.67- The summarizers are the house pattern: subscribe to `AfterTurn` to *schedule* work, apply it at the next `BeforeTurn` (`agents/summarization.py:159-160`).6869## InstrumentationHooks (`set_hooks`)7071A `Protocol` of paired callbacks (`nooa.runtime.hooks`): `before_/after_agent_call`, `before_/after_generation`, `before_/after_code_execution`, `before_/after_method_invocation`, `before_/after_tool_execution`, plus the point-in-time `on_messages_built`. Each `before_*` may return a context object that is handed to its `after_*` (span/timing state without globals).7273```python74from nooa.runtime.hooks import set_hooks7576class TimingHooks:77 def before_agent_call(self, agent, method_name, args, kwargs, call_id, parent_call_id, **kw):78 return {"t0": time.perf_counter()}79 def after_agent_call(self, agent, method_name, result, exception, context, **kw):80 print(f"{method_name}: {time.perf_counter() - context['t0']:.2f}s")81 # unimplemented methods are fine — calls are defensive8283set_hooks(TimingHooks()) # set_hooks(None) removes84```8586- Hook exceptions never affect the agent (logged at WARNING); time spent in hooks is metered as tracing overhead.87- **One slot, contextvar-scoped — and tracing uses it.** `enable_tracing()` installs `OpenInferenceHooks` via this same `set_hooks` (`tracing/__init__.py:136-148`); calling `set_hooks(MyHooks())` afterwards silently replaces tracing (and vice versa). If you need both, delegate to the previous hooks object from yours, or prefer a tracing exporter / `on()` observers.88- `after_agent_call` can receive `context=None` when `agent_call` middleware short-circuits before the before-hook ran — handle it.8990## Pitfalls9192- Don't use hooks for app logic (they're swallowed-exception observational); don't use middleware for metrics you can get from `LLMComplete` (you'd pay complexity for nothing).93- `AgentCallContext.result` uses a not-set sentinel — a short-circuiting `agent_call` middleware that "returns None" on purpose must still assign `ctx.result = None`.94- Middleware lives on the instance's event manager: install in `__init__` (after `super().__init__()`) or on the constructed agent, not on the class.95- Keep `llm_call` middleware fast — it's on the critical path of every turn, and runs again on context-window retries.9697## Related skills9899- `nooa-context-and-state` — the event model that `on()` observes; `EventQuery` filtering.100- `nooa-capturing-traces` — the tracing system that occupies the hooks slot; exporters are usually the right telemetry surface.101- `nooa-codeact-advanced` — what `execute_python` middleware wraps (validator pipeline, restrictions, cells).