Sagents.Middleware behaviour (Sagents v0.4.0)

Copy Markdown

Behavior 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

  1. Initialization - init/1 is called when middleware is configured
  2. Tool Collection - tools/1 provides tools to add to the agent
  3. Prompt Assembly - system_prompt/1 contributes to the system prompt
  4. Callback Collection - callbacks/1 provides LLM event handlers
  5. Before Model - before_model/2 preprocesses state before LLM call
  6. After Model - after_model/2 postprocesses 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
end

Middleware 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

config()

@type config() :: keyword()

middleware_config()

@type middleware_config() :: any()

middleware_result()

@type middleware_result() ::
  {:ok, Sagents.State.t()}
  | {:interrupt, Sagents.State.t(), any()}
  | {:error, term()}

Callbacks

after_model(t, middleware_config)

(optional)
@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 current Sagents.State struct (with LLM response)
  • config - The middleware configuration from init/1

Returns

  • {:ok, updated_state} - Success with potentially modified state
  • {:interrupt, state, interrupt_data} - Pause execution for human intervention
  • {:error, reason} - Failure, halts execution

before_model(t, middleware_config)

(optional)
@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 current Sagents.State struct
  • config - The middleware configuration from init/1

Returns

  • {:ok, updated_state} - Success with potentially modified state
  • {:error, reason} - Failure, halts execution

callbacks(middleware_config)

(optional)
@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 from init/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

handle_message(message, t, middleware_config)

(optional)
@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)}
end

2. 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)}
end

Defaults to {:ok, state} if not implemented.

Parameters

  • message - The message payload (any term — typically a tagged tuple)
  • state - The current Sagents.State struct
  • config - The middleware configuration from init/1

Returns

  • {:ok, updated_state} - Success with potentially modified state
  • {:error, reason} - Failure (logged but does not halt agent execution)

init(config)

(optional)
@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: opts as keyword list
  • Output: config as 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

on_server_start(t, middleware_config)

(optional)
@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 current Sagents.State struct
  • config - The middleware configuration from init/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

state_schema()

(optional)
@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.

system_prompt(middleware_config)

(optional)
@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.

tools(middleware_config)

(optional)
@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

apply_after_model(state, middleware_entry)

@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

apply_before_model(state, middleware_entry)

@spec apply_before_model(Sagents.State.t(), Sagents.MiddlewareEntry.t()) ::
  middleware_result()

Apply before_model hook from middleware.

Parameters

  • state - The current agent state
  • entry - MiddlewareEntry struct with module and config

Returns

  • {:ok, updated_state} - Success with potentially modified state
  • {:error, reason} - Error from middleware

apply_handle_message(message, state, middleware_entry)

@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 handle
  • state - The current agent state
  • entry - MiddlewareEntry struct with module and config

Returns

  • {:ok, updated_state} - Success with potentially modified state
  • {:error, reason} - Error from middleware

apply_on_server_start(state, middleware_entry)

@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 state
  • entry - MiddlewareEntry struct with module and config

Returns

  • {:ok, state} - Success (state typically unchanged)
  • {:error, reason} - Error from middleware

collect_callbacks(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_callbacks(middleware_entry)

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(middleware_entry)

Get system prompt from middleware.

get_tools(middleware_entry)

Get tools from middleware.

init_middleware(middleware)

Initialize a middleware module with its configuration. Returns a MiddlewareEntry struct.

Configuration Convention

  • Input opts should be a keyword list
  • Returned config should be a map for efficient runtime access

normalize(middleware)

Normalize middleware specification to {module, config} tuple.

Accepts:

  • Module atom: MyMiddleware -> {MyMiddleware, []}
  • Tuple with keyword list: {MyMiddleware, [key: value]} -> {MyMiddleware, [key: value]}