Normandy.Agents.Turn.Server
(normandy v2.2.0)
View Source
Asynchronous :gen_statem interpreter of the pure Turn FSM — the async analog
of Turn.Driver. Coarse lifecycle states (:running, :awaiting_approval,
:idle) hang Tasks and state_timeouts off the turn; they are NOT a
re-encoding of the seven Turn.State statuses (the core stays the source of
truth). Blocking effects (:call_llm, :dispatch_tools, :execute_approved)
run in a monitored Task; non-blocking effects run synchronously in the handler.
Options
See start_link/1. Approval expiry emits [:normandy, :turn, :approval, :expired] for the
batch and a [:normandy, :tool, :denied] (reason :approval_expired) per call; a rejected
call emits [:normandy, :tool, :denied] with :approval_rejected.
Each park is wrapped in a [:normandy, :turn, :approval, :start | :stop] span
(hand-emitted; both events carry the same :telemetry_span_context). :start
metadata: session_id, tool_names, count and the run's ids (turn_id,
conversation_id). :stop adds outcome (:approved,
:rejected, :mixed, :expired, or :terminated when the server stops while
parked) and measures duration. The run span stays one span across parks; its
:stop/:exception measurements add approval_wait (native units, the sum of all
parks in the turn, 0 if never parked). BaseAgent.run/2 never parks, so its run
events carry no approval_wait. A turn resumed after a restart that lands in
:awaiting_approval opens a fresh approval span (the old one died with the old
process) inside the resumed run's span. Normandy.Telemetry catalogues every event.
A Tier-2 Turn.Session saves :approval_timeout_ms, :on_approval_timeout and
:idle_timeout_ms with the config template. A thin start (no :config) restores
them; explicit start options still win. :handlers and :subscriber are
functions and are never saved, so whoever restarts the server passes them (see
ResumeReaper).
Store unavailable
Every start, supervisor restarts included, loads the session from the store, in the
server's first event after init/1 returns: Turn.Supervisor.start_server/2 returns
as soon as the server is registered, and sessions load concurrently. A run/2 sent
meanwhile waits for the load. If the store fails that load, or the load raises, exits
or throws (a template or credential provider, the template's rebuild), the start still
succeeds: the server waits in :unavailable, retries the load on a backoff timer
(200 ms doubling to 30 s) and on every run/2, and replies {:error, %Normandy.TurnError{type: :session_unavailable, reason: reason}} until a load
succeeds. reason is the store's reason reduced to its tags (atoms and integers kept,
an exception as its module, any other term as its type: {:query_failed, "a map"}),
{:store_exit, exit_reason} for a store call that exited, or the exception the load
raised. It then carries on as a normal start would, eager resume included. An approval
that arrives while the load fails is dropped with a warning. With no call for
:idle_timeout_ms, the server passivates (stops with {:shutdown, :passivated}).
A hook list this node cannot run ({:invalid_hooks, message}: a hook module missing
on this node, or invalid hook opts) is handled the same way: the server waits
:unavailable, retries, and replies that reason in a :session_unavailable TurnError.
So is a stored template that Normandy.Agents.ConfigTemplate.check_loaded!/1 refuses,
or whose security settings differ from the supplement's :pinned
({:invalid_template, message}): nothing in it is used, and its credential provider is
not called.
Terminal saves
A turn ends by saving its terminal turn state (:stopped or :failed), which also
takes an eager session out of the store's in-flight index (list_resumable/1). The
reply does not wait on that save: if the store refuses it, the turn's reply stands
({:ok, _} for a turn that succeeded), the server logs a warning by the reason's tag
(Turn.Server session "sid": turn state not saved; retrying (:store_down)), keeps the
state and saves it again on a timer (1 s, doubling to 30 s), before the next run/2
and before passivating. Until a save lands, run/2 replies {:error, %Normandy.TurnError{type: :persist_failed, reason: {:terminal_unsaved, reason}}}
without running the turn or appending its input, and the server does not passivate.
If the server dies before a save lands, the store still lists the session in flight,
and the next eager start (a restart, Turn.Session.run/2, ResumeReaper) resumes the
turn. A resumed :steering turn whose answer is already the last stored message
(Normandy.Agents.Turn.answered?/1) is saved as finished with no model call; any
other turn runs again.
Session start
A session whose store holds no entries and no turn state is new. Its
on_session_start hooks (Normandy.Behaviours.Hooks) run as the event right after the
load, so the supervisor starting the server is not blocked by them. What
they inject is stored as one user message. A hook crash or a store fault writing that
message sends the server :unavailable, which retries both like a load; meanwhile
run/2 replies a :session_unavailable TurnError whose :reason is the
:handler_crashed (handler :on_session_start) or :persist_failed TurnError. A session
whose hooks injected nothing is still new on its next start, and runs them again.
Events (:subscriber)
:iteration—%{iteration: n, iterations_left: m, final: boolean}before each LLM call;final: truemarks the last call, made withtool_choice: :noneafter the budget is spent.:steering—%{iterations_left: m}after a tool batch, once its state is saved.:awaiting_approval—%{parked: n, resumed: boolean}when a batch parks, and again (resumed: true) when a restarted server resumes a parked turn.:approval_expired—%{expired: n, mode: :continue | :fail}.
Telemetry
Each turn started by run/2 emits [:normandy, :agent, :run, :start], then
:stop or :exception (kind: :error, reason: the exception run/2 replies,
stacktrace: []), with model, agent_name and session_id metadata, like
BaseAgent.run/2. A rejected input emits a :start and an :exception too.
A turn that a restarted server resumes (eager resume) emits a run :start of its
own with resumed: true in the metadata, and later :stop or :exception; its
approval_wait counts only the parks after the restart. The OpenTelemetry context
of the original run/2 caller is saved with the turn state as a W3C text-map
carrier (Turn.State.trace_carrier), and the resumed run's :start fires with
that context current, so a bridged run span becomes a child of the original
caller's span, in its trace. Parenting is the only relation a telemetry bridge can
express (it reads the current context); a span link would need bridge support. The
original run span is not the parent: it ends when the old server stops, or is
never exported when the old server was killed, while the caller's span is.
approve/2 carries the approver's OpenTelemetry context. It is current while the
approval :stop and the [:normandy, :tool, :denied] events of rejected calls
fire, so a handler can correlate them with the approver's trace; the run keeps its
own context.
A server that stops with a turn in flight, through a callback-initiated stop or
:gen_statem.stop/3, emits :exception with kind: :exit and the stop reason in
terminate/3. The server does not trap exits, so an exit signal that kills it
(:kill, a supervisor's Process.exit/2, a linked process dying) runs no callback
and emits no :exception. Stray messages are logged at debug and dropped.
Errors from run/2
run/2 returns {:error, exception}, never a tagged tuple or a stacktrace.
Normandy's own exceptions are replied bare:
Normandy.LLMError- the LLM call failed (a 429 after the tools ran included).Normandy.Schema.ValidationError- the input failed its schema.Normandy.Guardrails.ViolationError- an input guardrail blocked the input, or an output guardrail blocked the answer.Normandy.HookHaltError- anon_prompthook (event: :on_prompt) or abefore_llmhook (event: :before_llm) halted the turn.Normandy.ModelCatalogError- compaction could not look up the model's context window.
Anything else is a Normandy.TurnError, whose :type says what failed:
:handler_crashed- a handler or hook raised, exited or threw;:handlernames it: a synchronous handler (:append,:convert,:validate,:guard,:emit), admission (:admit: anon_prompthook or an input guardrail crashing underon_error: :reraise, or a hook's return-contract violation), anafter_toolhook on a result the server built itself (an approval rejected, expired or interrupted result), or abefore_llmorafter_compacthook.:persist_failed- the session store rejected a write;:reasonis its reason reduced to its tags ({:sql, "SELECT ..."}becomes{:sql, "a string (N bytes)"}: atoms and integers kept, tuples walked, an exception its module, any other term its type), or{:store_exit, exit_reason}when the store call exited. The write is not adopted and the server stays up; a store that raises is a bug and crashes the server.:approval_timeout- nobody approved in time andon_approval_timeout: :fail;:reasonis the list of tool call ids.:task_down- the LLM call, the compactor or the tool dispatch raised an exception that is not one of the above (abefore_toolorafter_toolhook crashing inside the dispatch underon_error: :reraiseincluded), or its worker exited or threw;:reasonis the exception or the exit reason.:session_unavailable- the session could not be loaded or started (see "Store unavailable" and "Session start");:reasonis that failure.:unexpected_event,:unresumable_state- a persisted turn that can't continue, or a Normandy bug.
A rejected input or a blocked answer persists nothing of the answer (the user message
is kept for a blocked answer); the server stays alive in every case. on_turn_end
hooks receive {:rejected, exception} for a rejection and {:error, exception},
the replied exception, for every other failure.
Interrupted tool calls
A turn that fails or is killed after the assistant's tool_use was appended leaves
it unanswered. The next turn whose input is admitted (a rejected input repairs
nothing) appends an interrupted: true tool result for each missing call, in memory
and store, before the user message, and logs a warning.
Each interrupted result passes the hook modules' after_tool once
(Normandy.Behaviours.Hooks); so do the results the core builds for approval
rejections and expiries.
If a store write fails part-way, memory keeps only the results the store accepted,
so the next turn completes the repair without duplicating any result. The same
holds for tool results written during a turn: a rejected write is not adopted into
memory. A crash in the :emit handler while re-announcing a resumed parked turn
fails that turn (no caller is waiting), and the next turn closes its batch with
interrupted results. An eagerly resumed :steering turn closes the batch the same
way before its next LLM call, and counts the tool rounds its stored history holds
(Normandy.Agents.Turn.resume/2), so a round appended after the last state save still
counts against max_tool_iterations; a resumed parked turn is left alone, since its
unanswered calls await a decision.
A turn that fails or is killed after the API paused it (stop_reason pause_turn, a
server-tool or MCP-connector call in progress) leaves a history that ends in the paused
turn (Normandy.Agents.Turn.paused_last?/1), and the Claude API rejects a request with
a user message after it. The next run/2 continues the paused turn first, as a resumed
run (a run :start with resumed: true, then its :stop or :exception), and then
runs its input as a normal turn. A continuation that parks for approval holds the input
until the approval resolves. A continuation that fails fails that run/2 with its
error and appends nothing of the input; the next run/2 tries the continuation again.
Summary
Functions
Deliver approval decisions to a parked server.
Run a turn synchronously; replies once the turn finalizes or fails, with
{:ok, response} or {:error, exception} (see "Errors from run/2").
Starts a session server. Options are checked here, in the caller, so a bad option raises
ArgumentError instead of failing init/1.
Functions
@spec approve(:gen_statem.server_ref(), %{optional(String.t()) => :approve | :reject}) :: :ok
Deliver approval decisions to a parked server.
The caller's OpenTelemetry context travels with the decisions; see "Telemetry".
@spec run(:gen_statem.server_ref(), term()) :: {:ok, term()} | {:error, Exception.t()}
Run a turn synchronously; replies once the turn finalizes or fails, with
{:ok, response} or {:error, exception} (see "Errors from run/2").
The caller's OpenTelemetry context travels with the request, so the turn's run span nests under the caller's active span (a no-op without OTel).
@spec start_link(keyword()) :: :gen_statem.start_ret()
Starts a session server. Options are checked here, in the caller, so a bad option raises
ArgumentError instead of failing init/1.
Options
:session_id- Required. The session this server runs.:store- Required. TheNormandy.Behaviours.SessionStoreas{module, handle}.:registry- Required. TheNormandy.Behaviours.SessionRegistryas{module, handle}. The server registers itself under:session_idunless it was started under a:vianame.:config(a%Normandy.Agents.BaseAgentConfig{}) - The agent config. Without it the server rebuilds the config from the template saved in:store(a thin start), which needs:template_provider.:template_provider({module, handle}) - TheNormandy.Behaviours.AgentTemplateas{module, handle}, for a thin start.:resume_policy-:lazy(the default) or:eager::eagerresumes an in-flight turn as soon as the server starts. A saved template's value wins over this option.:subscriber-fn event_name, meta -> any end, called in the server process for every event. A crash in it is logged, reported as[:normandy, :turn, :subscriber, :exception]telemetry, and ignored. Never saved with the template.:handlers(aNormandy.Agents.Turn.Driver.Handlersornil) - ANormandy.Agents.Turn.Driver.Handlersset (defaultNormandy.Agents.BaseAgent's non-streaming handlers). Anil:emitis skipped. Hook modules'before_llmandafter_compactrun around whichever set is used. BaseAgent'svalidatemakes the blank-answer check and the chat agent's tool-round fallback; a customvalidatemakes neither. The server marks the run:stopanswer_source: :tool_roundaround anyvalidate, a custom one included, when it turns a blankchat_messageinto a non-blank one. Never saved with the template.:approval_timeout_ms(timeout/0) - How long a parked turn waits forapprove/2, in ms (default 300_000). Wins over a saved template's value.:on_approval_timeout-:continue(the default) or:fail. On expiry every parked call gets anexpired: truetool result;:continuehands it to the model,:failends the turn with{:error, %Normandy.TurnError{type: :approval_timeout}}. Wins over a saved template's value.:idle_timeout_ms(timeout/0) - Idle time before passivation, in ms (default 60_000). Wins over a saved template's value.:name(term/0) - A:gen_statemserver name.Turn.Supervisor.start_server/2sets it from the registry'schild_name/2and rejects one passed to it.