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.

python
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.

Diagram

Tool Path

Tool Trigger

before_tool

Tool Execute

after_tool

Tool Result

Request Path

User Input

before_model

LLM Call

after_model

Response

Quick Start

  1. 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?")
  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.")
  3. Async parity

    The same hooks gate async tool calls — write them once and they fire for both agent.start(...) and agent.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_call chain 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

Diagram
after_modelLLMwrap_model_callbefore_modelAgentafter_modelLLMwrap_model_callbefore_modelAgentModelRequestModified ModelRequestModelRequest + call_nextModelRequestModelResponseModelResponseModelResponseModified ModelResponse

Execution order for a model call:

  1. All before_model hooks run in registration order
  2. wrap_model_call middleware chain wraps the actual LLM call
  3. The real LLM call executes
  4. All after_model hooks run in reverse registration order

Under the hood:

  • _chat_completion_with_retry / _achat_completion_with_retry build a ModelRequest (with InvocationContext), pass it through MiddlewareManager.execute_model_call, and unwrap the returned ModelResponse back 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_call mutations to request.messages, request.temperature, and request.tools are threaded into the retry core.
  • after_model / wrap_model_call edits to response.content win 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:

Diagram
async toolThread-pool workerwrap_tool_call chain (sync)AgentUserasync toolThread-pool workerwrap_tool_call chain (sync)AgentUserworker wait bounded by ToolConfig.timeoutawait agent.astart(…)schedule middleware chainrun(request, call_next)call_next(request)asyncio.run_coroutine_threadsafe(…)resultToolResponseToolResponseToolResponseresponse
DecoratorReceivesReturnsUse for
@before_modelModelRequestModelRequestInject context, modify messages
@after_modelModelResponseModelResponseLog, transform, or redact output
@wrap_model_call(ModelRequest, call_next)ModelResponseRetry, caching, circuit-breaking
@before_toolToolRequestToolRequestValidate arguments, add auth
@after_toolToolResponseToolResponseSanitize results, log usage
@wrap_tool_call(ToolRequest, call_next)ToolResponseRetry flaky tools, mock in tests

Configuration Options

Pass middleware as a flat list to Agent(hooks=[...]). Mix decorator types freely.

python
agent = Agent(
    name="MyAgent",
    instructions="...",
    hooks=[log_request, retry_model, validate_tools]  # order matters
)

Data types available:

python
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
)
Middleware API Reference

TypeScript middleware configuration

Hooks Rust Reference

Rust hooks configuration


Common Patterns

Short-circuit the model with a cached response:

python
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 cache

Inject a system prompt on every call:

python
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 request

Block a specific tool:

python
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 request

Cache expensive tool results:

python
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 result

Gate a tool through an external policy engine:

python
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.

python
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.


Related