Raxol.AgentClientProtocol.Session.SteerAdapter behaviour (Raxol Agent Client Protocol v0.1.0)

Copy Markdown View Source

The Session's steer seam — the one injection point through which a live turn's compare-and-swap steer decision is driven.

Raxol.AgentClientProtocol.Session is the single-writer turn owner Steer's contract demands (its turn field is :idle | {:prompting, Turn} | {:cancelling, Turn}, mutated only from its own mailbox). A steer decision is a read-modify-write over per-turn CAS state, and the concurrent-steer guarantee holds ONLY if that read-modify-write is serialized by exactly one owner process. This behaviour lets the Session BE that owner — it holds the opaque steer state and calls these callbacks from inside its own handle_call, so the serialization is satisfied by construction — WITHOUT the ACP package owning the CAS decision itself. A host injects an adapter that makes that decision; this package ships only the seam and a default that refuses.

The three callbacks and how the Session drives them

The Session holds one OPAQUE steer_state term (the adapter's — this package never inspects it) and threads it through:

  • turn_started(steer_state, turn_token) — a prompt turn began; turn_token is the Session's own per-session turn ordinal (turn_seq), the same value the durable turn_started journal record and the client's turn events carry. The adapter binds it as the running turn's CAS token so a subsequent steer's expected_turn_id can match it. Returns the next steer_state.
  • turn_ended(steer_state) — the turn drained (any outcome). The adapter clears the CAS token so a steer arriving in the idle window between turns rejects :no_live_turn rather than landing on a dead turn. Returns the next steer_state. The DEDUP index (session-lifetime window) is NOT cleared here — only the turn liveness.
  • resolve(steer_state, request) — the CAS decision itself, run from the Session's mailbox. request is a plain map %{text, expected_turn_id, client_msg_id} (see request/0). Returns {result, next_steer_state}; result is the honest outcome vocabulary (result/0) the Session returns synchronously on the wire and — on {:ok, {:accepted, _}} only — the trigger to forward the steered text to the running turn and append one durable steer record. Rejections change nothing and journal nothing; they are still the wire response.

Default: refuse

Raxol.AgentClientProtocol.Session.SteerAdapter.Unsupported is the default. It holds no state, no-ops both turn hooks, and answers every resolve/2 with {:error, :no_steer_channel} — so a base Session with no injected adapter never mutates steer state (the turn hooks are identity) and honestly reports that live steering is not wired. Live steering works only once a real adapter is injected.

Summary

Types

A reference to an accepted (or de-duplicated) steer.

The normalized steer request the Session hands to resolve/2. Mirrors the _raxol/session.steer wire payload

The honest CAS outcome vocabulary — the same the Surface banner renders and Raxol.AgentClientProtocol.Ext.Schema.SteerResponse encodes on the wire.

Callbacks

Resolve one steer request against the live turn's CAS state (single-writer).

Clear the CAS token when the turn drained; preserve the dedup index.

Bind the CAS token for a turn that just began (its per-session ordinal).

Types

accepted_ref()

@type accepted_ref() :: %{
  turn_id: term(),
  offset: non_neg_integer(),
  client_msg_id: term() | nil
}

A reference to an accepted (or de-duplicated) steer.

request()

@type request() :: %{
  text: String.t() | nil,
  expected_turn_id: term(),
  client_msg_id: term() | nil
}

The normalized steer request the Session hands to resolve/2. Mirrors the _raxol/session.steer wire payload:

  • :text — the steering text (forwarded to the turn on accept).
  • :expected_turn_id — the turn ordinal the client believes is running (the CAS target).
  • :client_msg_id — the client-supplied idempotency key (may be nil).

result()

@type result() ::
  {:ok, {:accepted, accepted_ref()}}
  | {:ok, {:duplicate, accepted_ref()}}
  | {:error, {:stale_turn, term(), term()}}
  | {:error, :no_live_turn}
  | {:error, :client_msg_id_reuse}
  | {:error, :no_steer_channel}

The honest CAS outcome vocabulary — the same the Surface banner renders and Raxol.AgentClientProtocol.Ext.Schema.SteerResponse encodes on the wire.

Callbacks

resolve(steer_state, request)

@callback resolve(steer_state :: term(), request()) :: {result(), term()}

Resolve one steer request against the live turn's CAS state (single-writer).

turn_ended(steer_state)

@callback turn_ended(steer_state :: term()) :: term()

Clear the CAS token when the turn drained; preserve the dedup index.

turn_started(steer_state, turn_token)

@callback turn_started(steer_state :: term(), turn_token :: term()) :: term()

Bind the CAS token for a turn that just began (its per-session ordinal).