Sagents.Middleware.ProcessContext (Sagents v0.8.0-rc.4)
Copy MarkdownPropagates caller-process state across the three Sagents process boundaries.
Sagents agents cross three process boundaries during a single invocation:
- Caller → AgentServer GenServer — when the agent is started under a
supervisor, lifecycle hooks like
on_server_start/2run inside the GenServer process, not the caller's. - AgentServer → chain Task — each turn spawns a
Taskthat runs the LLM call and synchronous tool execution. - Chain Task → per-tool async Task —
LangChain.Functions declared withasync: truerun in a freshly-spawnedTask.async/1.
Anything stored in the caller's process dictionary, the OpenTelemetry context
stash, the APM (Application Performance Monitoring) context, or any similar
per-process channel is not carried across these boundaries automatically.
This middleware captures that state in the caller's process at init/1 time
and re-applies it on the receiving side of each boundary.
Configuration
Two options, both optional, can be combined freely:
:keys— a list of process-dictionary keys (atoms). For each key the middleware capturesProcess.get(key)in the caller's process and callsProcess.put(key, value)on the receiving side of every boundary.:propagators— a list of{capture_fn, apply_fn}pairs.capture_fnis a 0-arity function called once atinit/1in the caller's process; its return value is later passed toapply_fn, a 1-arity function called on the receiving side of every boundary. Use this for state that lives in something other than the process dictionary (OpenTelemetry's context stash, ETS-backed contexts, etc.).
Example: Sentry-only
{:ok, agent} = Sagents.Agent.new(%{
model: model,
middleware: [
{Sagents.Middleware.ProcessContext, keys: [:sentry_context]}
]
})Example: Sentry + OpenTelemetry + a custom application context
{:ok, agent} = Sagents.Agent.new(%{
model: model,
middleware: [
{Sagents.Middleware.ProcessContext,
keys: [:sentry_context],
propagators: [
{&OpenTelemetry.get_current/0, &OpenTelemetry.attach/1},
{&MyApp.Tenancy.get_context/0, &MyApp.Tenancy.set_context/1}
]}
]
})Capture is one-shot at construction time
init/1 runs in the caller's process when Sagents.Agent.new/2 is called
and captures once. For agents that handle a single request and are then
discarded that one capture is all you need.
For long-lived agents that outlive the calling request — a conversation- scoped AgentServer reused across many user messages, for example — the captured snapshot will go stale.
Use update/1 to refresh it. The middleware already has the spec from
init/1, so the caller only supplies the agent_id. Capture functions run
in the caller of update/1, then the new snapshot replaces the stored
snapshot in the agent's state.runtime and is used for every subsequent
boundary crossing.
Snapshot lives in state.runtime
The captured snapshot intentionally contains non-serializable values
(closures, OTel context tokens, PIDs, tuples). It is therefore stored under
state.runtime[ProcessContext], a virtual field that StateSerializer
never persists. After process restart the snapshot is gone and
on_server_start/2 re-captures from the new caller process — which is the
correct semantic, since a stale OTel/Sentry/tenant token would be wrong to
re-apply anyway.
# In a LiveView, before relaying a new user message to the agent:
Sagents.Middleware.ProcessContext.update(agent_id)
Sagents.AgentServer.add_message(agent_id, message)Both calls go through the same GenServer mailbox in order, so the update takes effect before the next execute begins.
Limitation: within-execute consistency
An execute_loop is a single logical request — one user message resolved by
potentially many LLM turns and tool calls. Within that loop, the snapshot is
intentionally frozen: update/1 arriving mid-execute does not retarget
in-flight tools. This is the right behavior — a single request should see one
consistent context for its duration. Refresh between requests, not during one.
Summary
Functions
Refresh the propagated context for a running agent.
Functions
@spec update(String.t()) :: :ok | {:error, :not_found | :no_process_context_middleware}
Refresh the propagated context for a running agent.
The middleware already knows what to capture — :keys and :propagators
were configured at init/1 time and live in the middleware's stored config.
update/1 looks that spec up from the running agent, runs the capture
functions in the caller's process, and ferries the fresh snapshot to the
agent so it takes effect for every subsequent boundary crossing.
Call this before sending a new message to a long-lived agent whose ambient context has changed since the agent was constructed.
Returns
:ok— snapshot captured and sent{:error, :not_found}— no AgentServer is running foragent_id{:error, :no_process_context_middleware}— the agent is running but does not haveProcessContextin its middleware stack
Example
def handle_event("send_message", %{"text" => text}, socket) do
Sagents.Middleware.ProcessContext.update(socket.assigns.agent_id)
Sagents.AgentServer.add_message(socket.assigns.agent_id,
LangChain.Message.new_user!(text))
{:noreply, socket}
end