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: true marks the last call, made with tool_choice: :none after 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:

Anything else is a Normandy.TurnError, whose :type says what failed:

  • :handler_crashed - a handler or hook raised, exited or threw; :handler names it: a synchronous handler (:append, :convert, :validate, :guard, :emit), admission (:admit: an on_prompt hook or an input guardrail crashing under on_error: :reraise, or a hook's return-contract violation), an after_tool hook on a result the server built itself (an approval rejected, expired or interrupted result), or a before_llm or after_compact hook.
  • :persist_failed - the session store rejected a write; :reason is 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 and on_approval_timeout: :fail; :reason is 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 (a before_tool or after_tool hook crashing inside the dispatch under on_error: :reraise included), or its worker exited or threw; :reason is the exception or the exit reason.
  • :session_unavailable - the session could not be loaded or started (see "Store unavailable" and "Session start"); :reason is 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

approve(server, decisions)

@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".

run(server, user_input)

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

start_link(opts)

@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. The Normandy.Behaviours.SessionStore as {module, handle}.

  • :registry - Required. The Normandy.Behaviours.SessionRegistry as {module, handle}. The server registers itself under :session_id unless it was started under a :via name.

  • :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}) - The Normandy.Behaviours.AgentTemplate as {module, handle}, for a thin start.

  • :resume_policy - :lazy (the default) or :eager: :eager resumes 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 (a Normandy.Agents.Turn.Driver.Handlers or nil) - A Normandy.Agents.Turn.Driver.Handlers set (default Normandy.Agents.BaseAgent's non-streaming handlers). A nil :emit is skipped. Hook modules' before_llm and after_compact run around whichever set is used. BaseAgent's validate makes the blank-answer check and the chat agent's tool-round fallback; a custom validate makes neither. The server marks the run :stop answer_source: :tool_round around any validate, a custom one included, when it turns a blank chat_message into a non-blank one. Never saved with the template.

  • :approval_timeout_ms (timeout/0) - How long a parked turn waits for approve/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 an expired: true tool result; :continue hands it to the model, :fail ends 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_statem server name. Turn.Supervisor.start_server/2 sets it from the registry's child_name/2 and rejects one passed to it.