Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Middleware Overview

The middleware system intercepts and modifies agent behavior at every lifecycle point -- before/after each model call, and around each tool call. Use middleware when you need cross-cutting concerns (rate limiting, retries, context management) without modifying your agent logic.

Interceptor Trait

All methods have default no-op implementations. Override only the hooks you need.

#[async_trait]
pub trait Interceptor: Send + Sync {
    async fn before_model(&self, req: &mut ModelRequest) -> Result<(), SynapticError>;
    async fn after_model(&self, req: &ModelRequest, resp: &mut ModelResponse) -> Result<(), SynapticError>;
    async fn wrap_model_call(&self, request: ModelRequest, next: &dyn ModelCaller) -> Result<ModelResponse, SynapticError>;
    async fn wrap_tool_call(&self, request: ToolCallRequest, next: &dyn ToolCaller) -> Result<Value, SynapticError>;
}

Lifecycle Diagram

loop {
  before_model(request)
    -> wrap_model_call(request, next)
  after_model(request, response)
  for each tool_call {
    wrap_tool_call(request, next)
  }
}

The inner loop repeats for each agent step (model call followed by tool execution). before_model / after_model run around every model call and can mutate the request or response. wrap_model_call and wrap_tool_call are onion-style wrappers that receive a next caller to delegate to the next layer.

InterceptorChain

InterceptorChain composes multiple interceptors and executes them in registration order for before_model, and in reverse order for after_model. The wrap_model_call and wrap_tool_call hooks use onion-style nesting.

use synaptic::middleware::InterceptorChain;

let chain = InterceptorChain::new(vec![
    Arc::new(ModelCallLimitMiddleware::new(10)),
    Arc::new(ToolRetryMiddleware::new(3)),
]);

Using Middleware with create_agent

Pass interceptors through AgentOptions::middleware. The agent graph wires them into both the model node and the tool node automatically.

use synaptic::graph::{create_agent, AgentOptions};
use synaptic::middleware::{ModelCallLimitMiddleware, ToolRetryMiddleware};

let options = AgentOptions {
    middleware: vec![
        Arc::new(ModelCallLimitMiddleware::new(10)),
        Arc::new(ToolRetryMiddleware::new(3)),
    ],
    ..Default::default()
};

let graph = create_agent(model, tools, options)?;

File & Shell Hooks

The middleware system also provides hooks for file operations and shell commands. These are called by Deep Agent tools when performing filesystem or command operations, allowing you to intercept and authorize or deny actions.

use synaptic::middleware::{FileOp, FileOpDecision, CommandOp, CommandDecision};

struct MySecurityMiddleware;

#[async_trait]
impl Interceptor for MySecurityMiddleware {
    // ... model/tool hooks as needed ...
}

// File/shell hooks are dispatched separately through the InterceptorChain.

Built-in Middlewares

MiddlewareHook UsedDescription
ModelCallLimitMiddlewarewrap_model_callLimits model invocations per run
ToolCallLimitMiddlewarewrap_tool_callLimits tool invocations per run
ToolRetryMiddlewarewrap_tool_callRetries failed tools with exponential backoff
ModelFallbackMiddlewarewrap_model_callFalls back to alternative models on failure
SummarizationMiddlewarebefore_modelAuto-summarizes when context exceeds token limit
TodoListMiddlewarebefore_modelInjects a task list into the agent context
HumanInTheLoopMiddlewarewrap_tool_callPauses for human approval before tool execution
ContextEditingMiddlewarebefore_modelTrims or filters context before model calls
SsrfGuardMiddlewarewrap_tool_callBlocks SSRF attacks (private IPs, metadata endpoints)
CircuitBreakerMiddlewarewrap_tool_call / wrap_model_callPrevents cascading failures with circuit breaker pattern

Writing a Custom Middleware

The easiest way to define a middleware is with the corresponding macro. Each lifecycle hook has its own macro (#[before_model], #[after_model], #[wrap_model_call], #[wrap_tool_call], #[system_prompt]). The macro generates the struct, Interceptor trait implementation, and a factory function automatically.

use synaptic::macros::before_model;
use synaptic::middleware::ModelRequest;
use synaptic::core::SynapticError;

#[before_model]
async fn log_model_call(request: &mut ModelRequest) -> Result<(), SynapticError> {
    println!("Model call with {} messages", request.messages.len());
    Ok(())
}

Then add it to your agent:

let options = AgentOptions {
    middleware: vec![log_model_call()],
    ..Default::default()
};
let graph = create_agent(model, tools, options)?;

Note: The log_model_call() factory function returns Arc<dyn Interceptor>. For stateful middleware, use #[field] parameters on the function. See Procedural Macros for the full reference, including all five middleware macros and stateful middleware with #[field].