ONE GenServer per peer link, either role — the correlation + dispatch brain of ACP.
The same module runs as :agent or :client; role only selects which
MethodTable direction is inbound (side = role). The Connection:
- never blocks — no
GenServer.callto itself, no synchronous wait on tasks, no transport call that waits on peer progress; - never runs handler code in its own process — every inbound
request/notification is dispatched to a
Task.Supervisor.async_nolinktask; - never creates an atom from wire input — all method→atom mapping is
Router/MethodTablecompile-time clauses.
Correctness spine
- Outbound:
async_request/6is THE primitive;request/4is a caller-parking wrapper on the samepending_outtable. The Connection is the single timeout authority (Process.send_after) — no caller arms a parallel timer. Every pending_out entry leaves through exactly one of: correlated response, timeout,cancel_request/2, orreject_all_outgoing. No fifth path. - Inbound: dispatch via
async_nolink; crash ⇒-32603with the id echoed at its exact wire type; notification crash ⇒ log only, never a frame.$/cancel_requestandsession/cancelare Connection-intercepted BEFORERouter.decode/4(MethodTable rows carry the:protocol/:session_controllayer tags). - Delegated reply:
delegate_reply/3+reply/3+:deferredlet the Session own WHEN thesession/promptresponse is sent while keeping updates and the response on ONE FIFO lane. - Response-count invariant: at most one response frame per inbound
id; exactly one unless the peer
$/cancel_request'd it first (then zero).
Implementation notes
- A pending outbound request's response is decoded through the
Router.result_marker/1marker stored at send time + the result module'sfrom_json/1. Router.decode/4returns the raw schema-decode error tuple; any non-:method_not_founddecode error is mapped to-32602.- The capability gate is located BEFORE decode with the correct
-32601-beats--32602ordering.capability_denied?/2delegates toCapabilities.negotiated?/2, which fails closed on any missing/nil/falsecapability. ThecapsSNAPSHOT is the RECEIVER's OWN capability tree, not the wholeinitializemessage. The agent snapshotsagent_capabilitiesfrom the response it sends; the client snapshotsclient_capabilitiesfrom the request it sends (stashed inown_capsat outbound-submit time, committed at handshake), because the client's own caps are not present in theinitializeRESPONSE it receives. SeeCapabilities+ theMethodTablecapabilitytypedoc (ORACLE-DIVERGENCE note). transport_refand the monitored carrier pid are both taken from the handle's:pidfield (the Transport behaviour exposes no ref accessor;Transport.PairedandTransport.Stdioexpose:pidand tag their inbound frames with that same pid).- Telemetry events are emitted only when
:telemetryis loaded (it is not a package dependency); otherwiseLoggercarries the signal. - Sibling
task_sup/session_supmay be injected directly instart_link/1opts (test convenience) instead ofparent_sup; withparent_sup,handle_continue/2resolves them fromwhich_childrenby child module/type (theTask.Supervisorchild istask_sup, the other:supervisorchild issession_sup).
Summary
Functions
PRIMITIVE outbound submit. Non-blocking: returns :ok once the
frame is accepted by the transport, and later delivers exactly one
{:acp_result, tag, outcome} to owner — unless owner consumed the
entry first via cancel_request/2 (then zero). Connection owns the timeout.
Cancel a pending async_request by its {owner, tag}. Pops the entry, cancels its timer, best-effort $/cancel_request, delivers nothing. Unknown tag ⇒ :ok.
Returns a specification to start this module under a supervisor.
Graceful close: reject outgoing, kill undelegated inbound tasks, close transport, stop :normal.
Transfer an inbound request's reply obligation to adopter. MUST return before the handler returns :deferred. Connection monitors the adopter.
Fire-and-forget notification: a GenServer.call for per-sender FIFO + backpressure; touches no pending table, arms no timer.
Discharge a delegated reply obligation. Idempotent-safe: a consumed/unknown/cancelled reply_ref is a suppressed no-op ([:raxol, :acp, :dup_reply]) and still returns :ok.
WRAPPER outbound request: parks the caller on pending_out and
returns the outcome. Outer GenServer.call timeout is :infinity by
design — the protocol timeout is the Connection's internal timer, the ONE
authority.
Start a Connection. Opts: :role, :transport {mod, handle}, :handler, :handler_arg, :max_inbound (concurrent inbound-request cap, default 1000), and either :parent_sup or direct :task_sup/:session_sup.
Race-free base-ordinal accessor for a session's delivery stream.
Because this is a GenServer.call
(serialized with the demux point that assigns ordinals), a caller that
subscribes to the broadcast path THEN calls this learns an exact base:
every stamp assigned after this call returns is guaranteed to reach a
live subscriber, and every stamp <= ordinal is pre-subscription.
turn: nil means the session currently has no open session/prompt
turn (the out-of-turn namespace applies). The primary direct-delivery
path (Client.prompt/3, prompt_stream/4) does not need it at all.
Types
@type outcome() :: {:ok, struct()} | {:ok, {:ext, map()}} | {:error, Raxol.AgentClientProtocol.Error.t() | :timeout | :connection_closed | {:result_decode, term()}}
Outcome delivered for an accepted outbound submission.
@type role() :: :agent | :client
Functions
@spec async_request( pid(), String.t(), struct() | map() | nil, pid(), term(), pos_integer() | :infinity ) :: :ok | {:error, :not_initialized | :connection_closed | {:transport, term()}}
PRIMITIVE outbound submit. Non-blocking: returns :ok once the
frame is accepted by the transport, and later delivers exactly one
{:acp_result, tag, outcome} to owner — unless owner consumed the
entry first via cancel_request/2 (then zero). Connection owns the timeout.
Cancel a pending async_request by its {owner, tag}. Pops the entry, cancels its timer, best-effort $/cancel_request, delivers nothing. Unknown tag ⇒ :ok.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec close(pid()) :: :ok
Graceful close: reject outgoing, kill undelegated inbound tasks, close transport, stop :normal.
Transfer an inbound request's reply obligation to adopter. MUST return before the handler returns :deferred. Connection monitors the adopter.
Fire-and-forget notification: a GenServer.call for per-sender FIFO + backpressure; touches no pending table, arms no timer.
@spec reply( pid(), reference(), {:ok, struct()} | {:error, Raxol.AgentClientProtocol.Error.t()} ) :: :ok
Discharge a delegated reply obligation. Idempotent-safe: a consumed/unknown/cancelled reply_ref is a suppressed no-op ([:raxol, :acp, :dup_reply]) and still returns :ok.
@spec request(pid(), String.t(), struct() | map() | nil, pos_integer()) :: {:ok, struct()} | {:ok, {:ext, map()}} | {:error, term()}
WRAPPER outbound request: parks the caller on pending_out and
returns the outcome. Outer GenServer.call timeout is :infinity by
design — the protocol timeout is the Connection's internal timer, the ONE
authority.
@spec start_link(keyword()) :: GenServer.on_start()
Start a Connection. Opts: :role, :transport {mod, handle}, :handler, :handler_arg, :max_inbound (concurrent inbound-request cap, default 1000), and either :parent_sup or direct :task_sup/:session_sup.
@spec update_watermark(pid(), String.t()) :: {:ok, %{turn: reference() | nil, ordinal: non_neg_integer()}}
Race-free base-ordinal accessor for a session's delivery stream.
Because this is a GenServer.call
(serialized with the demux point that assigns ordinals), a caller that
subscribes to the broadcast path THEN calls this learns an exact base:
every stamp assigned after this call returns is guaranteed to reach a
live subscriber, and every stamp <= ordinal is pre-subscription.
turn: nil means the session currently has no open session/prompt
turn (the out-of-turn namespace applies). The primary direct-delivery
path (Client.prompt/3, prompt_stream/4) does not need it at all.