Middleware
Intercept and modify model and tool calls with before/after hooks and wrap decorators
Middleware lets your agent intercept every LLM call and every tool call — log them, modify inputs, retry on failure, or block execution entirely.
from praisonaiagents import Agent
from praisonaiagents.hooks import before_model
@before_model
def log_request(event):
print(f"Model call: {len(event.messages)} messages")
agent = Agent(name="Guarded", instructions="Answer helpfully.")
agent.start("Explain middleware in one paragraph.")The user adds hooks or wrap decorators; middleware runs on every model and tool call in the agent loop.
Quick Start
- Add a logging middleware
python from praisonaiagents import Agent from praisonaiagents.hooks import before_model, after_model @before_model def log_request(request): print(f"Calling model: {request.model}") return request @after_model def log_response(response): print(f"Model replied: {response.content[:80]}") return response agent = Agent( name="Assistant", instructions="Answer questions helpfully.", hooks=[log_request, log_response] ) result = agent.start("What is 2 + 2?") - Wrap calls for retry logic
python from praisonaiagents import Agent from praisonaiagents.hooks import wrap_model_call, wrap_tool_call @wrap_model_call def retry_model(request, call_next): for attempt in range(3): try: return call_next(request) except Exception as exc: if attempt == 2: raise return call_next(request) @wrap_tool_call def retry_tool(request, call_next): for attempt in range(3): try: return call_next(request) except Exception as exc: if attempt == 2: raise agent = Agent( name="RobustBot", instructions="Answer questions with retry protection.", hooks=[retry_model, retry_tool] ) result = agent.start("Search for today's news.") - Async parity
The same hooks gate async tool calls — write them once and they fire for both
agent.start(...)andagent.astart(...).python import asyncio from praisonaiagents import Agent from praisonaiagents.hooks import before_tool, ToolRequest @before_tool def audit(request: ToolRequest): print(f"[audit] {request.tool_name}({request.arguments})") return request agent = Agent(name="AsyncAudited", instructions="Answer with tools.", hooks=[audit]) async def main(): await agent.astart("Look up the weather in Paris.") asyncio.run(main())Under the hood the synchronous
wrap_tool_callchain runs in a thread-pool worker and the actual async tool execution is scheduled back on the event loop — so you write hooks the same way for sync and async.
How It Works
Execution order for a model call:
- All
before_modelhooks run in registration order wrap_model_callmiddleware chain wraps the actual LLM call- The real LLM call executes
- All
after_modelhooks run in reverse registration order
Under the hood:
_chat_completion_with_retry/_achat_completion_with_retrybuild aModelRequest(withInvocationContext), pass it throughMiddlewareManager.execute_model_call, and unwrap the returnedModelResponseback to the agent’s expected return shape.- The retry/backoff logic is the final handler of the middleware chain — so if a hook wraps
call_next, it wraps the retry loop. before_model/wrap_model_callmutations torequest.messages,request.temperature, andrequest.toolsare threaded into the retry core.after_model/wrap_model_calledits toresponse.contentwin over the raw provider payload; unchanged content passes the original object through untouched.- The async path runs the sync middleware chain in an executor and bridges back to the async retry core via
asyncio.run_coroutine_threadsafe— hooks are written the same way for sync and async.
The same order applies for tool calls with before_tool, wrap_tool_call, and after_tool — including async tool calls:
| Decorator | Receives | Returns | Use for |
|---|---|---|---|
@before_model | ModelRequest | ModelRequest | Inject context, modify messages |
@after_model | ModelResponse | ModelResponse | Log, transform, or redact output |
@wrap_model_call | (ModelRequest, call_next) | ModelResponse | Retry, caching, circuit-breaking |
@before_tool | ToolRequest | ToolRequest | Validate arguments, add auth |
@after_tool | ToolResponse | ToolResponse | Sanitize results, log usage |
@wrap_tool_call | (ToolRequest, call_next) | ToolResponse | Retry flaky tools, mock in tests |
Configuration Options
Pass middleware as a flat list to Agent(hooks=[...]). Mix decorator types freely.
agent = Agent(
name="MyAgent",
instructions="...",
hooks=[log_request, retry_model, validate_tools] # order matters
)Data types available:
from praisonaiagents.hooks import (
ModelRequest, # messages, model, temperature, context, tools, extra
ModelResponse, # content, model, usage, context, tool_calls, extra
ToolRequest, # tool_name, arguments, context, extra
ToolResponse, # tool_name, result, error, context, extra
InvocationContext, # agent_id, run_id, session_id, tool_name, model_name, metadata
)TypeScript middleware configuration
Hooks Rust ReferenceRust hooks configuration
Common Patterns
Short-circuit the model with a cached response:
from praisonaiagents import Agent
from praisonaiagents.hooks import wrap_model_call, ModelResponse
_cache = {}
@wrap_model_call
def cache_model(request, call_next):
key = (request.model, tuple((m["role"], m["content"]) for m in request.messages))
if key in _cache:
return _cache[key]
response = call_next(request)
_cache[key] = response
return response
agent = Agent(name="Cached", instructions="Answer briefly.", hooks=[cache_model])
agent.start("What is 2+2?") # hits the LLM
agent.start("What is 2+2?") # served from cacheInject a system prompt on every call:
from praisonaiagents.hooks import before_model
@before_model
def add_safety_prompt(request):
safety = {"role": "system", "content": "Always be safe and helpful."}
if safety not in request.messages:
request.messages.insert(0, safety)
return requestBlock a specific tool:
from praisonaiagents.hooks import before_tool, ToolRequest
@before_tool
def block_delete(request: ToolRequest):
if request.tool_name == "delete_file":
raise PermissionError("delete_file is disabled in this environment.")
return requestCache expensive tool results:
import hashlib, json
from praisonaiagents.hooks import wrap_tool_call
_cache = {}
@wrap_tool_call
def cache_results(request, call_next):
key = hashlib.md5(json.dumps(request.arguments, sort_keys=True).encode()).hexdigest()
if key in _cache:
return _cache[key]
result = call_next(request)
_cache[key] = result
return resultGate a tool through an external policy engine:
from praisonaiagents.hooks import wrap_tool_call, ToolResponse
@wrap_tool_call
def guard_shell(request, call_next):
if request.tool_name != "run_shell":
return call_next(request)
if is_command_allowed(request.arguments.get("command", "")):
return call_next(request)
return ToolResponse(
tool_name=request.tool_name,
result="Blocked by policy.",
error="Blocked by policy.",
context=request.context,
)See HOL Guard Tool Policy for a complete fail-closed integration with an external security engine.
Best Practices
Always return the request/response object
Every before_ and after_ hook must return the (possibly modified) object. Returning None breaks the chain and raises a runtime error.
Use wrap_* for retry and circuit-breaking
wrap_model_call and wrap_tool_call give you full control over whether call_next is called — perfect for retries, timeouts, and feature flags. before_/after_ hooks cannot short-circuit the call.
Keep hooks stateless or use thread-local state
Agents can run concurrently. Avoid mutable global state in hooks. If you need per-request state, use request.context.metadata or Python’s contextvars.
Async tools keep ContextVars across hooks
Hooked async tools keep the caller’s ContextVars: session identity, the per-turn cancel token, and the handoff chain reach both your hook and the tool.
from praisonaiagents import Agent
from praisonaiagents.hooks import before_tool
from praisonaiagents.session.context import get_session_context, set_session_context
@before_tool
def audit(req):
return req
async def whoami():
"current user"
return get_session_context().user_id
agent = Agent(name="t", instructions="x", tools=[whoami], hooks=[audit])
set_session_context(user_id="alice", platform="telegram")
# whoami() returns "alice" even with the hook attached.Zero overhead when hooks are empty
The middleware manager checks for registered hooks before executing any code. When hooks=[] (the default), the fast path skips all hook processing entirely — for both model calls (_chat_completion_with_retry / _achat_completion_with_retry) and tool calls (sync execute_tool and async execute_tool_async). Agents with no model hooks call the retry core directly, and the async path only spins up the thread-pool middleware worker when hooks are actually registered.
HooksConfig.on_step / on_tool_call route through this chain
The on_step and on_tool_call callbacks on HooksConfig run through this same middleware chain: on_step is adapted to an after_model slot and on_tool_call to a before_tool slot. They are observers — their return value is ignored, so they cannot short-circuit or rewrite. When both a controller middleware and an observer on_tool_call are set, the middleware runs first. Use middleware=[...] (returning HookResult) when you need control.