Vstorm Open Source
Hooks, guardrails, and lifecycle middleware for PydanticAI agents.
0.2.x
7 lifecycle events
PydanticAI + Python 3.10+
ASGI-style middleware for AI agents — intercept, modify, or block at every stage.
# Install pip install pydantic-ai-middleware # Or with uv uv add pydantic-ai-middleware
from pydantic_ai import Agent from pydantic_ai_middleware import ( AgentMiddleware, MiddlewareAgent ) class MyMiddleware(AgentMiddleware[None]): async def before_run(self, prompt, deps, ctx): print(f"Prompt: {prompt}") return prompt agent = Agent("openai:gpt-4.1") mw_agent = MiddlewareAgent( agent=agent, middleware=[MyMiddleware()], ) result = await mw_agent.run("Hello!")
Every agent run passes through 7 hooks in a defined order.
Intercept before the agent acts.
Can modify input or block execution.
Intercept after the agent acts.
Can modify output or log results.
First hook — intercept the user prompt before the agent starts.
class BeforeRunLogger(AgentMiddleware[None]): async def before_run(self, prompt, deps, ctx): print(f"[before_run] {prompt!r}") return prompt agent = Agent("openai:gpt-4.1") mw_agent = MiddlewareAgent( agent=agent, middleware=[BeforeRunLogger()], ) result = await mw_agent.run( "What is the capital of France?" )
raise InputBlocked)Last hook — inspect or modify the final output. Runs in REVERSE order.
class TimingMiddleware(AgentMiddleware[None]): def __init__(self): self._start = 0 async def before_run(self, prompt, deps, ctx): self._start = time.perf_counter() return prompt async def after_run(self, prompt, output, deps, ctx): elapsed = time.perf_counter() - self._start print(f"[after_run] {output!r}") print(f"[after_run] Elapsed: {elapsed:.3f}s") return output
Intercept every tool invocation — log, modify args, measure timing.
class BeforeToolCallLogger(AgentMiddleware[None]): async def before_tool_call( self, tool_name, tool_args, deps, ctx ): print(f"[before] {tool_name}({tool_args})") return tool_args toolset = FunctionToolset() @toolset.tool def add(a: int, b: int) -> str: """Add two numbers.""" return str(a + b)
class AfterToolCallLogger(AgentMiddleware[None]): async def after_tool_call( self, tool_name, tool_args, result, deps, ctx ): print(f"[after] {tool_name} → {result!r}") return result toolset = FunctionToolset() @toolset.tool def multiply(a: int, b: int) -> str: """Multiply two numbers.""" return str(a * b)
Handle failures gracefully — tool-specific context or global catch-all.
async def on_tool_error( self, tool_name, tool_args, error, deps, ctx ): print(f"[on_tool_error] {tool_name} failed!") print(f" Error: {error}") # Replace with user-friendly error return RuntimeError( f"'{tool_name}' is unavailable" ) # Provides tool_name + tool_args context # that on_error does NOT have
async def on_error( self, error, deps, ctx ): print(f"[on_error] {type(error).__name__}") # Return None to re-raise original # Return new Exception to replace return None # Catches ANY exception during the run # Global fallback — no tool context
Structured permission decisions — block sensitive tools or defer to a handler.
from pydantic_ai_middleware import ( ToolDecision, ToolPermissionResult ) async def before_tool_call( self, tool_name, tool_args, deps, ctx ): path = tool_args.get("path", "") if ".env" in path: return ToolPermissionResult( decision=ToolDecision.DENY, reason="Access to .env blocked", ) return ToolPermissionResult( decision=ToolDecision.ALLOW )
async def before_tool_call( self, tool_name, tool_args, deps, ctx ): if tool_name == "delete_file": return ToolPermissionResult( decision=ToolDecision.ASK, reason="Destructive — needs approval", ) return tool_args # ASK defers to permission_handler callback: mw_agent = MiddlewareAgent( agent=agent, middleware=[guard], permission_handler=my_handler, # async (name, args, reason) → bool )
Lightweight alternatives — no class needed. Filter by tool name.
from pydantic_ai_middleware.decorators import ( before_run, after_run, before_tool_call, after_tool_call, before_model_request, on_error, on_tool_error, ) @before_run async def log_prompt(prompt, deps, ctx): print(f"Prompt: {prompt}") return prompt @after_tool_call async def log_result(name, args, result, deps, ctx): print(f"{name} → {result}") return result # Use as regular middleware: middleware=[log_prompt, log_result]
class EmailGuard(AgentMiddleware[None]): # Only fires for these tools: tool_names = {"send_email", "draft_email"} async def before_tool_call( self, tool_name, tool_args, deps, ctx ): if not tool_args.get("to"): raise ToolBlocked( tool_name, "Recipient required!" ) return tool_args # get_weather → guard skipped # send_email → guard fires
Share data between hooks. Track tokens and USD costs with budgets.
from pydantic_ai_middleware import MiddlewareContext from pydantic_ai_middleware.context import HookType async def before_run(self, prompt, deps, ctx): ctx.set("start_time", time.perf_counter()) return prompt async def after_run(self, prompt, output, deps, ctx): start = ctx.get_from( HookType.BEFORE_RUN, "start_time" ) print(f"Elapsed: {time.perf_counter()-start:.3f}s") return output # Access control: each hook can only # read from earlier hooks
from pydantic_ai_middleware.cost_tracking import ( CostTrackingMiddleware, CostInfo ) cost_mw = CostTrackingMiddleware( model_name="openai:gpt-4.1", budget_limit_usd=1.00, on_cost_update=lambda info: print( f"Run #{info.run_count}: ${info.run_cost_usd:.6f}" ), ) # After runs: print(cost_mw.total_cost) # $0.001234 print(cost_mw.total_request_tokens) # 1523 # Raises BudgetExceededError if over budget
All hooks in one middleware — see the complete execution flow.
class FullLifecycleLogger(AgentMiddleware[None]): async def before_run(self, prompt, deps, ctx): self._log("before_run") return prompt async def before_model_request(self, msgs, deps, ctx): self._log("before_model_request") return msgs async def before_tool_call(self, name, args, deps, ctx): self._log(f"before_tool_call({name})") return args async def after_tool_call(self, name, args, res, deps, ctx): self._log(f"after_tool_call({name})") return res async def after_run(self, prompt, output, deps, ctx): self._log("after_run") return output # + on_error, on_tool_error
How middleware powers a production AI agent framework — cost tracking, context management, permissions, and hooks.
from pydantic_deep import create_deep_agent agent = create_deep_agent( model="openai:gpt-4.1", # Cost tracking (enabled by default) cost_tracking=True, cost_budget_usd=5.00, on_cost_update=my_cost_callback, # Context manager (enabled by default) context_manager=True, context_manager_max_tokens=200_000, on_context_update=my_ctx_callback, # Custom middleware middleware=[audit_mw, permission_mw], # Claude Code-style hooks hooks=[safety_gate, audit_logger], )
# Middleware assembly order: # 1. User middleware (audit_mw, permission_mw) # 2. HooksMiddleware (from hooks=[]) # 3. CheckpointMiddleware (if enabled) # 4. ContextManagerMiddleware (default ON) # 5. CostTrackingMiddleware (default ON) # Auto-wraps in MiddlewareAgent when # any middleware is active: if middleware or cost_tracking or hooks: return MiddlewareAgent( agent=agent, middleware=all_middleware, context=middleware_context, permission_handler=permission_handler, )
Token & USD tracking, budget limits, per-run callbacks
Auto-compression at 90% of 200K token budget
Claude Code-style shell/Python hooks with matchers
Audit, permissions, logging — compose freely
Production middleware from pydantic-deep — regex-based path blocking for file tools.
BLOCKED_PATTERNS = [
r"/etc/passwd", r"\.env$",
r"/root/", r"\.ssh/",
r"id_rsa", r"/proc/",
]
FILE_TOOLS = {"read_file", "write_file", "edit_file"}
class PermissionMiddleware(AgentMiddleware[DeepAgentDeps]):
async def before_tool_call(self, tool_name, tool_args, deps, ctx):
if tool_name not in FILE_TOOLS:
return tool_args
path = tool_args.get("path", "")
for pattern in BLOCKED_PATTERNS:
if re.search(pattern, path):
return ToolPermissionResult(
decision=ToolDecision.DENY,
reason=f"Blocked: {pattern}",
)
return tool_args
Python handler hooks — block dangerous commands, log tool calls in the background.
async def safety_gate_handler(hook_input: HookInput) -> HookResult: """PRE_TOOL_USE: blocks dangerous commands.""" command = hook_input.tool_input.get("command", "") dangerous = [ r"rm\s+-rf\s+/", r"mkfs\.", r"dd\s+if=.*of=/dev/", r":\(\)\{", # fork bomb ] for pattern in dangerous: if re.search(pattern, command): return HookResult( allow=False, reason=f"BLOCKED: {command}", ) return HookResult(allow=True)
async def audit_logger_handler( hook_input: HookInput, ) -> HookResult: """POST_TOOL_USE: logs all tool calls. Runs in background (fire-and-forget).""" args = str(hook_input.tool_input)[:200] logger.info( f"AUDIT: {hook_input.tool_name}({args})" ) return HookResult(allow=True)
from pydantic_deep import Hook, HookEvent HOOKS = [ # Background audit — fires after every tool Hook( event=HookEvent.POST_TOOL_USE, handler=audit_logger_handler, background=True, ), # Safety gate — blocks dangerous 'execute' calls Hook( event=HookEvent.PRE_TOOL_USE, handler=safety_gate_handler, matcher="execute", # only 'execute' tool timeout=5, ), ] agent = create_deep_agent(hooks=HOOKS)
Runs before tool execution. Return allow=False to block. Supports matcher regex to target specific tools.
Runs after tool completes. Can modify result. Set background=True for fire-and-forget logging.
Runs when a tool raises an exception. Access error details via hook_input.tool_error.
16 standalone examples covering every feature.
| # | File | Hook / Feature | Description |
|---|---|---|---|
| 01 | 01_before_run.py | before_run | Log incoming prompts |
| 02 | 02_after_run.py | after_run | Measure wall-clock time |
| 03 | 03_before_model_request.py | before_model_request | Count LLM API calls |
| 04 | 04_before_tool_call.py | before_tool_call | Log tool invocations |
| 05 | 05_after_tool_call.py | after_tool_call | Log results + timing |
| 06 | 06_on_tool_error.py | on_tool_error | Handle tool failures |
| 07 | 07_on_error.py | on_error | Global error handler |
| 08 | 08_tool_blocking.py | ToolPermissionResult | ALLOW / DENY decisions |
| 09 | 09_permission_handler.py | ToolDecision.ASK | Interactive approval flow |
| 10 | 10_tool_name_filtering.py | tool_names | Per-tool middleware |
| 11 | 11_middleware_chain.py | MiddlewareChain | Compose middleware |
| 12 | 12_decorator_middleware.py | @before_run | Decorator syntax |
| 13 | 13_context_sharing.py | MiddlewareContext | Share data between hooks |
| 14 | 14_cost_tracking.py | CostTrackingMiddleware | Token & USD tracking |
| 15 | 15_multiple_middleware.py | Execution order | Multiple middleware pipeline |
| 16 | 16_full_lifecycle.py | All hooks | Complete lifecycle trace |
pip install pydantic-ai-middleware
Guardrails
Block harmful inputs
Tool Control
ALLOW / DENY / ASK
Cost Tracking
Tokens & USD budgets
Composable
Chains & conditionals