Sagents.Middleware behaviour (Sagents v0.4.0)
Copy MarkdownBehavior for DeepAgent middleware components.
Middleware provides a composable pattern for adding capabilities to agents. Each middleware component can contribute:
- System prompt additions
- Tools (Functions)
- State schema modifications
- Pre/post processing hooks
- LLM callback handlers (token usage, tool execution, message processing)
Middleware Lifecycle
- Initialization -
init/1is called when middleware is configured - Tool Collection -
tools/1provides tools to add to the agent - Prompt Assembly -
system_prompt/1contributes to the system prompt - Callback Collection -
callbacks/1provides LLM event handlers - Before Model -
before_model/2preprocesses state before LLM call - After Model -
after_model/2postprocesses state after LLM response
Example
defmodule MyMiddleware do
@behaviour Sagents.Middleware
@impl true
def init(opts) do
config = %{enabled: Keyword.get(opts, :enabled, true)}
{:ok, config}
end
@impl true
def system_prompt(_config) do
"You have access to custom capabilities."
end
@impl true
def tools(_config) do
[my_custom_tool()]
end
@impl true
def callbacks(_config) do
%{
on_llm_token_usage: fn _chain, usage ->
Logger.info("Token usage: #{inspect(usage)}")
end
}
end
@impl true
def before_model(state, _config) do
# Preprocess state
{:ok, state}
end
endMiddleware Configuration
Middleware can be specified as:
- Module name:
MyMiddleware - Tuple with options:
{MyMiddleware, [enabled: true]}
Summary
Callbacks
Process state after receiving LLM response.
Process state before it's sent to the LLM.
Provide LangChain callback handlers for this middleware.
Handle messages sent to this middleware.
Initialize middleware with configuration options.
Called when the AgentServer starts or restarts.
Provide the state schema module for this middleware.
Provide system prompt text for this middleware.
Provide tools (Functions) that this middleware adds to the agent.
Functions
Apply after_model hook from middleware.
Apply before_model hook from middleware.
Apply handle_message callback from middleware.
Apply on_server_start callback from middleware.
Collect callback handler maps from all middleware.
Get LLM callback handlers from middleware.
Get system prompt from middleware.
Get tools from middleware.
Initialize a middleware module with its configuration. Returns a MiddlewareEntry struct.
Normalize middleware specification to {module, config} tuple.
Types
@type config() :: keyword()
@type middleware_config() :: any()
@type middleware_result() :: {:ok, Sagents.State.t()} | {:interrupt, Sagents.State.t(), any()} | {:error, term()}
Callbacks
@callback after_model(Sagents.State.t(), middleware_config()) :: middleware_result()
Process state after receiving LLM response.
Receives the state after the LLM has responded and can modify the response, extract information, or update state.
Defaults to {:ok, state} if not implemented.
Parameters
state- The currentSagents.Statestruct (with LLM response)config- The middleware configuration frominit/1
Returns
{:ok, updated_state}- Success with potentially modified state{:interrupt, state, interrupt_data}- Pause execution for human intervention{:error, reason}- Failure, halts execution
@callback before_model(Sagents.State.t(), middleware_config()) :: middleware_result()
Process state before it's sent to the LLM.
Receives the current agent state and can modify messages, add context, or perform validation before the LLM is invoked.
Defaults to {:ok, state} if not implemented.
Parameters
state- The currentSagents.Statestructconfig- The middleware configuration frominit/1
Returns
{:ok, updated_state}- Success with potentially modified state{:error, reason}- Failure, halts execution
@callback callbacks(middleware_config()) :: map()
Provide LangChain callback handlers for this middleware.
Receives the middleware configuration from init/1 and returns a callback
handler map compatible with LangChain.Chains.LLMChain.add_callback/2.
This allows middleware to observe LLM events such as token usage, tool
execution, and message processing.
When multiple middleware declare callbacks, all handlers are collected and fire in fan-out fashion (every matching handler from every middleware fires).
Defaults to empty map (%{}) if not implemented. Return %{} for no callbacks.
Parameters
config- The middleware configuration frominit/1
Returns
- A map of callback keys to handler functions
Available Callback Keys
These are the LangChain-native keys supported by LLMChain. Use only these
keys in your callback map (see LangChain.Chains.ChainCallbacks for full
type signatures):
Model-level callbacks:
:on_llm_new_delta- Streaming token/delta received:on_llm_new_message- Complete message from LLM:on_llm_ratelimit_info- Rate limit headers from provider:on_llm_token_usage- Token usage information:on_llm_response_headers- Raw response headers
Chain-level callbacks:
:on_message_processed- Message fully processed by chain:on_message_processing_error- Error processing a message:on_error_message_created- Error message created:on_tool_call_identified- Tool call detected during streaming:on_tool_execution_started- Tool begins executing:on_tool_execution_completed- Tool finished successfully:on_tool_execution_failed- Tool execution errored:on_tool_response_created- Tool response message created:on_retries_exceeded- Max retries exhausted
Example
def callbacks(_config) do
%{
on_llm_token_usage: fn _chain, usage ->
Logger.info("Token usage: #{inspect(usage)}")
end,
on_message_processed: fn _chain, message ->
Logger.info("Message: #{inspect(message)}")
end
}
end
@callback handle_message(message :: term(), Sagents.State.t(), middleware_config()) :: {:ok, Sagents.State.t()} | {:error, term()}
Handle messages sent to this middleware.
Messages are routed to a specific middleware by ID through the AgentServer's
middleware registry. Any process can send a targeted message to a middleware
using AgentServer.notify_middleware/3.
This enables two primary patterns:
1. External notifications
LiveViews, controllers, or other processes can send context updates to a
running middleware. The middleware updates state metadata, which before_model/2
reads on the next LLM call.
# In a LiveView — user switched to editing a different blog post
AgentServer.notify_middleware(agent_id, MyApp.UserContext, {:post_changed, %{
slug: "/blog/getting-started-with-elixir",
title: "Getting Started with Elixir"
}})
# In the middleware
def handle_message({:post_changed, post_info}, state, _config) do
{:ok, State.put_metadata(state, "current_post", post_info)}
end2. Async task results
Middleware that spawns background tasks sends results back to itself for state updates.
def handle_message({:title_generated, title}, state, _config) do
{:ok, State.put_metadata(state, "conversation_title", title)}
endDefaults to {:ok, state} if not implemented.
Parameters
message- The message payload (any term — typically a tagged tuple)state- The currentSagents.Statestructconfig- The middleware configuration frominit/1
Returns
{:ok, updated_state}- Success with potentially modified state{:error, reason}- Failure (logged but does not halt agent execution)
@callback init(config()) :: {:ok, middleware_config()} | {:error, term()}
Initialize middleware with configuration options.
Called once when the middleware is added to an agent. Returns configuration that will be passed to other callbacks.
Convention
- Input:
optsas keyword list - Output:
configas map for efficient runtime access
Defaults to converting opts to a map if not implemented.
Example
def init(opts) do
config = %{
enabled: Keyword.get(opts, :enabled, true),
max_retries: Keyword.get(opts, :max_retries, 3)
}
{:ok, config}
end
@callback on_server_start(Sagents.State.t(), middleware_config()) :: {:ok, Sagents.State.t()} | {:error, term()}
Called when the AgentServer starts or restarts.
This allows middleware to perform initialization actions that require the AgentServer to be running, such as broadcasting initial state to subscribers (e.g., TODOs for UI display).
Receives the current state and middleware config.
Returns {:ok, state} (state is not typically modified here but could be).
Defaults to {:ok, state} if not implemented.
Parameters
state- The currentSagents.Statestructconfig- The middleware configuration frominit/1
Returns
{:ok, state}- Success (state typically unchanged){:error, reason}- Failure (logged but does not halt agent)
Example
def on_server_start(state, _config) do
# Broadcast initial todos when AgentServer starts
broadcast_todos(state.agent_id, state.todos)
{:ok, state}
end
@callback state_schema() :: module() | nil
Provide the state schema module for this middleware.
If the middleware needs to add fields to the agent state, it should return a module that defines those fields.
Defaults to nil if not implemented.
@callback system_prompt(middleware_config()) :: String.t() | [String.t()]
Provide system prompt text for this middleware.
Can return a single string or list of strings that will be joined.
Defaults to empty string if not implemented.
@callback tools(middleware_config()) :: [LangChain.Function.t()]
Provide tools (Functions) that this middleware adds to the agent.
Defaults to empty list if not implemented.
Functions
@spec apply_after_model(Sagents.State.t(), Sagents.MiddlewareEntry.t()) :: middleware_result()
Apply after_model hook from middleware.
Parameters
state- The current agent state (with LLM response)entry- MiddlewareEntry struct with module and config
Returns
{:ok, updated_state}- Success with potentially modified state{:error, reason}- Error from middleware
@spec apply_before_model(Sagents.State.t(), Sagents.MiddlewareEntry.t()) :: middleware_result()
Apply before_model hook from middleware.
Parameters
state- The current agent stateentry- MiddlewareEntry struct with module and config
Returns
{:ok, updated_state}- Success with potentially modified state{:error, reason}- Error from middleware
@spec apply_handle_message(term(), Sagents.State.t(), Sagents.MiddlewareEntry.t()) :: {:ok, Sagents.State.t()} | {:error, term()}
Apply handle_message callback from middleware.
Parameters
message- The message payload to handlestate- The current agent stateentry- MiddlewareEntry struct with module and config
Returns
{:ok, updated_state}- Success with potentially modified state{:error, reason}- Error from middleware
@spec apply_on_server_start(Sagents.State.t(), Sagents.MiddlewareEntry.t()) :: {:ok, Sagents.State.t()} | {:error, term()}
Apply on_server_start callback from middleware.
Called when the AgentServer starts to allow middleware to perform initialization actions like broadcasting initial state.
Parameters
state- The current agent stateentry- MiddlewareEntry struct with module and config
Returns
{:ok, state}- Success (state typically unchanged){:error, reason}- Error from middleware
@spec collect_callbacks([Sagents.MiddlewareEntry.t()]) :: [map()]
Collect callback handler maps from all middleware.
Calls get_callbacks/1 on each middleware entry and filters out nils.
Returns a list of callback handler maps suitable for passing
to LLMChain.add_callback/2.
Get LLM callback handlers from middleware.
Returns the callback handler map from the middleware's callbacks/1 callback,
or nil if the callback is not implemented.
Get system prompt from middleware.
Get tools from middleware.
Initialize a middleware module with its configuration. Returns a MiddlewareEntry struct.
Configuration Convention
- Input
optsshould be a keyword list - Returned
configshould be a map for efficient runtime access
Normalize middleware specification to {module, config} tuple.
Accepts:
- Module atom:
MyMiddleware->{MyMiddleware, []} - Tuple with keyword list:
{MyMiddleware, [key: value]}->{MyMiddleware, [key: value]}