Sagents.Middleware.ProcessContext (Sagents v0.8.0-rc.7)

Copy Markdown

Propagates caller-process state across the three Sagents process boundaries.

Sagents agents cross three process boundaries during a single invocation:

  1. Caller → AgentServer GenServer — when the agent is started under a supervisor, lifecycle hooks like on_server_start/2 run inside the GenServer process, not the caller's.
  2. AgentServer → chain Task — each turn spawns a Task that runs the LLM call and synchronous tool execution.
  3. Chain Task → per-tool async TaskLangChain.Functions declared with async: true run in a freshly-spawned Task.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 captures Process.get(key) in the caller's process and calls Process.put(key, value) on the receiving side of every boundary.
  • :propagators — a list of {capture_fn, apply_fn} pairs. capture_fn is a 0-arity function called once at init/1 in the caller's process; its return value is later passed to apply_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

update(agent_id)

@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 for agent_id
  • {:error, :no_process_context_middleware} — the agent is running but does not have ProcessContext in 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