Normandy.Agents.Turn.Session (normandy v2.2.0)

View Source

A handle on one durable session. new/1 checks the options once; run/2 routes a turn to the session's Turn.Server, approve/2 delivers approval decisions to it and delete/1 stops it and deletes it from the store.

session =
  Turn.Session.new(
    session_id: "user-42",
    config: config,
    store: {Normandy.Behaviours.SessionStore.Postgres, MyApp.Repo},
    registry: {Normandy.Behaviours.SessionRegistry.Native, MyApp.SessionRegistry},
    supervisor: MyApp.TurnSupervisor
  )

{:ok, output} = Turn.Session.run(session, "hello")

run/2 resolves session_id to a live pid via the SessionRegistry; on a miss, it starts a Turn.Server under the supervisor with the caller-supplied config (the store never holds config/credentials). The server loads its turn state and conversation memory from the SessionStore on every start, so a supervisor restart resumes from the store, not from the state captured when the session first started. A %Turn.Session{} is plain data: building one starts nothing, and the same handle can be used from any process.

Options

  • :session_id (String.t/0) - Required. The session's id: its key in the registry and in the store. A non-empty string without { or } (the Redis stores put it inside a cluster hash tag).

  • :registry - Required. The Normandy.Behaviours.SessionRegistry, as {module, handle}.

  • :supervisor - Required. The supervisor that starts the session's Turn.Server: a pid or a name.

  • :supervisor_mod (module/0) - A Normandy.Agents.Turn.ServerSupervisor: the module whose start_server/2 starts the server and whose stop_server/2 stops it (Normandy.Agents.Turn.Supervisor.Horde in a cluster). The default value is Normandy.Agents.Turn.Supervisor.

  • :store - Required. The Normandy.Behaviours.SessionStore, as {module, handle}.

  • :config (a %Normandy.Agents.BaseAgentConfig{}) - The %Normandy.Agents.BaseAgentConfig{} a server starts with. With :template_provider too, it is saved as the session's template on first start.

  • :template_provider - A Normandy.Behaviours.AgentTemplate, as {module, handle}: the server rebuilds its config from the stored template and this node's supplement.

  • :template_id (String.t/0) - The supplement's id in the template provider. Default: the config's :name, else "default".

  • :resume_policy - :eager resumes an unfinished turn when the server restarts, and a Tier-2 start saves it so ResumeReaper restarts the session after a node loss. The default value is :lazy.

  • :subscriber (function of arity 2) - fn event_name, meta -> any end, passed to Turn.Server.

  • :handlers (a Normandy.Agents.Turn.Driver.Handlers or nil) - A Turn.Driver.Handlers set, passed to Turn.Server; nil is its default.

  • :approval_timeout_ms (timeout/0) - Passed to Turn.Server, in ms or :infinity; saved with a Tier-2 template.

  • :on_approval_timeout - Passed to Turn.Server; saved with a Tier-2 template.

  • :idle_timeout_ms (timeout/0) - Passed to Turn.Server, in ms or :infinity; saved with a Tier-2 template.

Tier-2 thin start (when :template_provider is set)

With a :config and a :template_provider, the first start persists a secret-free ConfigTemplate derived from the config, then launches Turn.Server with thin options (no :config). The server reconstructs the full config node-locally from the stored template + supplement + credential token. Later starts (after passivation) find the persisted template in the store and reconstruct without any caller config.

A session may also have only a :template_provider (no :config), for example on a surviving node after the session's node died: the server starts from the template already saved in the store. With no saved template, run/2 returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: {:no_config_template, session_id}}}.

Resume policy (:resume_policy, :lazy or :eager)

:lazy (default) restarts a session only when a caller routes to it. With :eager, a restarted server resumes a non-terminal turn on its own, and a Tier-2 start saves the policy in the template, so ResumeReaper restarts the session when its node goes down. A session started from a saved template keeps the policy it was saved with.

Start-time race

Two callers that both find no server may both start one; one start wins and the other gets {:error, {:already_started, pid}}, which run/2 treats as {:ok, pid}, so all callers converge on the single winner. For via-based registries (Horde, Redis) the supervisor supplies a :name computed by child_name/2, so the OTP supervisor registers atomically at start. For :self_register (Native) registries the losing server finds the session taken in init/1 and stops before loading anything, with the same {:already_started, pid}.

Summary

Functions

Delivers approval decisions (tool_call_id => :approve | :reject) to the session's parked server and returns :ok; the turn's result goes to the run/2 caller waiting on it. Never starts a server: with none live it returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: :no_session}}, and a registry that cannot answer returns that TurnError with the registry's reason.

Deletes the session: stops its live server, if any, through :supervisor_mod's stop_server/2, then deletes it from the store (SessionStore.delete_session/2: turn state, template, in-flight index membership, and the history no fork still uses). Returns :ok, also for a session that does not exist.

Builds a session handle. Starts nothing.

Returns session with config as the config a later server start uses (a first start, a restart after passivation, a Tier-2 template save). A live server keeps the config it started with.

Routes a turn to the session's server, starting it if needed, and returns what Turn.Server.run/2 returns: {:ok, output} or {:error, exception}. A session that cannot be started (a registry or store fault, no saved template) returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: reason}}. A server option Turn.Server rejects (only reachable by editing the struct, since new/1 checks them too) raises ArgumentError in the caller, from Turn.Supervisor.start_server/2 (Ruling 7).

Types

t()

@type t() :: %Normandy.Agents.Turn.Session{
  config: Normandy.Agents.BaseAgentConfig.t() | nil,
  handlers: Normandy.Agents.Turn.Driver.Handlers.t() | nil,
  registry: {module(), term()},
  resume_policy: :lazy | :eager,
  server_options: keyword(),
  session_id: String.t(),
  store: {module(), term()},
  subscriber: (atom(), map() -> any()) | nil,
  supervisor: GenServer.server(),
  supervisor_mod: module(),
  template_id: String.t() | nil,
  template_provider: {module(), term()} | nil
}

Functions

approve(other, decisions)

@spec approve(t(), %{optional(String.t()) => :approve | :reject}) ::
  :ok | {:error, Normandy.TurnError.t()}

Delivers approval decisions (tool_call_id => :approve | :reject) to the session's parked server and returns :ok; the turn's result goes to the run/2 caller waiting on it. Never starts a server: with none live it returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: :no_session}}, and a registry that cannot answer returns that TurnError with the registry's reason.

delete(session)

@spec delete(t()) :: :ok | {:error, Normandy.TurnError.t()}

Deletes the session: stops its live server, if any, through :supervisor_mod's stop_server/2, then deletes it from the store (SessionStore.delete_session/2: turn state, template, in-flight index membership, and the history no fork still uses). Returns :ok, also for a session that does not exist.

A server is stopped by its supervisor's shutdown, mid-turn included: a run/2 waiting on it exits, and a turn in flight is not resumed. Errors:

  • {:error, %Normandy.TurnError{type: :session_unavailable, reason: reason}} - the registry could not answer (reason is its reason), or the live server is not a child of :supervisor (reason: {:not_stopped, :not_found}; it may have just exited, so calling again is safe). Nothing was deleted.
  • {:error, %Normandy.TurnError{type: :persist_failed, reason: reason}} - the store refused the delete (reason is its reason reduced to its tags, as for run/2's store failures), or its call exited (reason: {:store_exit, tag}, the exit reason reduced the same way, e.g. {:store_exit, {:timeout, {GenServer, :call, "a list"}}}). The server is already stopped; calling again finishes the job. A store that raises raises.

new/1 checks that the handle's :supervisor_mod exports stop_server/2 (Normandy.Agents.Turn.ServerSupervisor); a handle whose struct was edited to another module raises ArgumentError naming it.

Nothing stops a new turn from starting the session again: a run/2 that starts after the server stopped (or a ResumeReaper restart between the stop and the delete) gets a fresh server. One that loads after the delete starts empty. One that loaded just before it holds the old history in memory and appends onto entries the delete removed, so its writes partly bring the session back. Stop routing turns to a session before deleting it, and call delete/1 again once no server runs for it.

new(opts)

@spec new(keyword()) :: t()

Builds a session handle. Starts nothing.

Raises ArgumentError naming the key for an unknown key, a missing required key or a value of the wrong type, and when neither :config nor :template_provider is given.

put_config(session, config)

@spec put_config(t(), Normandy.Agents.BaseAgentConfig.t()) :: t()

Returns session with config as the config a later server start uses (a first start, a restart after passivation, a Tier-2 template save). A live server keeps the config it started with.

run(session, user_input)

@spec run(t(), term()) :: {:ok, term()} | {:error, Exception.t()}

Routes a turn to the session's server, starting it if needed, and returns what Turn.Server.run/2 returns: {:ok, output} or {:error, exception}. A session that cannot be started (a registry or store fault, no saved template) returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: reason}}. A server option Turn.Server rejects (only reachable by editing the struct, since new/1 checks them too) raises ArgumentError in the caller, from Turn.Supervisor.start_server/2 (Ruling 7).

A server that passivates while the call waits for it never runs the turn; run/2 routes the turn again, to a fresh server, up to three servers in all, then returns {:error, %Normandy.TurnError{type: :session_unavailable, reason: :passivated}}. Only a server that stopped with {:shutdown, :passivated} (Turn.Server's passivation) or was already gone is retried; any other exit of the server, :normal included, propagates to the caller.