Raxol. AgentClientProtocol. Client behaviour
(Raxol Agent Client Protocol v0.1.0)
Copy Markdown
View Source
use Raxol.AgentClientProtocol.Client to implement the client side of
ACP: the editor/host process that answers permission asks, filesystem, and
terminal requests, and receives session/update streaming notifications.
This is a thin ergonomic layer over Raxol.AgentClientProtocol.Connection
-- see Raxol.AgentClientProtocol.Agent's moduledoc for the shared rationale
(identical wiring, mirrored side). Nothing here re-implements protocol
correlation, cancellation, or the turn/permission state machine; those
live in Connection and (agent-side) Session.
Callback surface
One @callback per Raxol.AgentClientProtocol.MethodTable.rows_for_side(:client)
row that carries an app-level callback -- session/request_permission,
session/update, fs/write_text_file, fs/read_text_file,
terminal/create, terminal/output, terminal/release,
terminal/wait_for_exit, terminal/kill. Naming and arity are generated
straight from the table, not hand-maintained; every current client row
has params != nil, so every generated callback is arity 2:
callback(params, ctx) (request or notification alike).
ctx is Raxol.AgentClientProtocol.Connection.Ctx.t(): read-only
per dispatch, carries conn (the Connection pid, for
Connection.request/async_request/notify/reply/delegate_reply),
handler_state (produced once by init/1), reply_ref, rx_seq,
session_sup, task_sup (client-role connections still get these two --
they are per-Connection infrastructure, not agent-specific).
A request callback (request_permission, write_text_file,
read_text_file, the four terminal/* methods) returns {:ok, result_struct}, {:error, %Error{}}, or :deferred (legal only after
Connection.delegate_reply/3). session_update/2's return value
is always ignored (Router.dispatch/4) -- it is the streaming sink for
session/update notifications during a prompt turn.
Call callbacks/0 to introspect the generated {callback, arity, wire}
list.
Defaults
Every generated callback (and handle_ext_request/3,
handle_ext_notification/3) has an overridable default: requests
answer {:error, Error.method_not_found()}, session_update/2 is a
silent :ok. Override only what your client supports -- an unimplemented,
capability-gated method never needs an explicit override (the client
simply never advertises the capability at initialize, so a well-behaved
agent never sends it; the -32601 default is the honest fallback if one
does anyway). init/1 defaults to {:ok, handler_arg}.
Extension methods
"_"-prefixed wire methods with no MethodTable row are routed by
Connection directly to handle_ext_request/3 /
handle_ext_notification/3 (never through the table-driven callbacks
above -- see Router's moduledoc). Both default the same way as any other
unsupported method.
Example
defmodule MyClient do
use Raxol.AgentClientProtocol.Client
alias Raxol.AgentClientProtocol.Client.FsSandbox
# `handler_arg:` is the project root the agent may read under.
@impl true
def init(root), do: {:ok, %{root: root}}
@impl true
def session_update(notification, _ctx) do
IO.inspect(notification.update, label: "session/update")
end
@impl true
def read_text_file(req, ctx), do: FsSandbox.read(ctx.handler_state.root, req)
# ... only what you support.
end
{:ok, sup} =
Raxol.AgentClientProtocol.Client.start_link(MyClient,
transport: {mod, handle},
handler_arg: "/path/to/project"
)Session-consumer ergonomics: subscribe / prompt / prompt_stream
Overriding session_update/2 yourself works fine, but most clients
just want "give me the updates for session X" without hand-rolling a
mailbox protocol. The generated default session_update/2 (unless you
override it) forwards every decoded update to whichever processes called
subscribe/3 for that {conn, session_id} pair, as
{:acp_session_update, session_id, update} -- update is always an
already-decoded Raxol.AgentClientProtocol.Schema.SessionUpdate.t() (see
decode_update/1's doc for why an undecodable variant never reaches
this path at all). subscribe/3 remains useful for a custom
session_update/2 override that wants a broadcast fan-out, but it is
NOT how prompt/3/prompt_stream/4 get their updates (see below).
{:ok, {updates, response}} =
Client.prompt(client_conn, PromptRequest.new(session_id, prompt_blocks))
{:ok, response} =
Client.prompt_stream(client_conn, request, fn update ->
IO.inspect(update, label: "update")
end)prompt/3 and prompt_stream/4 drive the request through
Connection.async_request/6 and then consume the direct turn-delivery
channel (Raxol.AgentClientProtocol.Delivery's moduledoc):
Connection opens a turn at submit time and, for every session/update
belonging to that turn, sends the calling process
{:acp_turn_update, tag, ordinal, notification} directly, itself --
the same single-sender operation it uses for the terminal
{:acp_result, tag, outcome}. Because one process sends every update,
the turn-end companion, and the result, BEAM's pairwise FIFO guarantees
they arrive in wire order with NO reorder buffer, NO settle timer, and a
synchronous, deterministic failure ({:error, {:delivery_gap, _}}) if
the turn-end count and the delivered-update count ever disagree. This
is why these two functions do not consume subscribe/3: that path runs a
separate handler task per notification while the Connection sends the
terminal result itself -- two different senders, no cross-sender FIFO
guarantee.
fs_sandbox: a working filesystem client with zero callbacks
use Raxol.AgentClientProtocol.Client, fs_sandbox: "/path/to/root" auto-
implements read_text_file/2 and write_text_file/2 (still
defoverridable) confined to that directory: every request path is
joined under the root, ..-normalized, and checked against the root
using BOTH the lexical result and the fully symlink-resolved real path,
so a ../ escape or a symlink planted inside the sandbox that points
outside it are both rejected with -32602 invalid params. The file is
then opened and the open handle checked to be the regular file that was
resolved, and a read is capped at 16 MiB (fs_max_read_bytes: sets
another cap). Raxol.AgentClientProtocol.Client.FsSandbox states what
this does and does not guarantee against a peer that changes the
sandbox tree while requests run.
terminal/* handlers are deliberately NOT defaulted by any option here --
they stay method_not_found unless you implement them yourself.
Library-mode wiring
start_link/2 and child_spec/1 build exactly this subtree
(identical shape to the agent side, role: :client):
ConnectionSupervisor Supervisor, :one_for_all,
│ auto_shutdown: :any_significant
├── Task.Supervisor restart: :permanent
├── DynamicSupervisor restart: :permanent (Session.Supervisor; :temporary children)
└── Connection restart: :temporary, significant: trueSee Raxol.AgentClientProtocol.Agent's moduledoc for why parent_sup is
resolved via self() inside the nested supervisor's init/1 rather than
passed in from outside. A client connection gets its own
Session.Supervisor/SessionRegistry slot too -- unused, since the client
side hosts no sessions, and kept only because the subtree shape is uniform
across roles (one less special case in Connection).
Summary
Types
Per-dispatch context. See Raxol.AgentClientProtocol.Connection.Ctx.
A decoded session/update payload (always successfully decoded -- see decode_update/1).
Callbacks
Handle an inbound "_"-prefixed extension notification. Return value is
ignored. Default: :ok.
Handle an inbound "_"-prefixed extension request with no MethodTable
row. Return shape matches the table-driven request callbacks. Default:
{:error, Error.method_not_found()}.
Produce this handler's per-connection state from handler_arg (the value
passed as handler_arg: to start_link/2 / child_spec/1). Called once,
at Connection boot; the result is threaded read-only through every
dispatch as ctx.handler_state. Default: {:ok, handler_arg}.
Functions
Introspect the generated callback surface: {callback_atom, arity, wire_method}, in table order.
Library-mode child spec: embed one ACP client connection subtree into the caller's own supervision tree, e.g.
Resolve the Connection pid out of a started connection subtree. sup
must be the ConnectionSupervisor's own pid -- this is the ONLY
supported way to reach the pid Connection.request/4, async_request/6,
notify/3, and this module's own subscribe/3/prompt/3/prompt_stream/4
need, since start_link/2/child_spec/1 deliberately return/register the
ConnectionSupervisor, not the Connection child itself (the supervisor is
the stable handle across the child's :temporary restart policy).
Tolerant session/update payload decode: wraps
Raxol.AgentClientProtocol.Schema.SessionUpdate.from_json/1. On success,
{:ok, SessionUpdate.t()}; on failure (an unrecognized sessionUpdate
discriminator, or a variant payload that fails its own required-field
decode), logs a warning and returns {:raw, map} instead of propagating
the error. Never raises. Every variant the pinned v1.19.0 oracle defines
is ported, usage_update included (Schema.UsageUpdate), so an
unrecognized discriminator means a wire value from outside that schema --
see SessionUpdate's moduledoc for the variant list.
Send session/prompt and collect every session/update delivered for
the turn until the response resolves. Returns
{:ok, {updates, response}} -- updates is the list of decoded
SessionUpdate.t() payloads observed during the turn, oldest first;
response is the decoded result struct (or {:ext, map()} for a
non-table result marker) -- or {:error, term()} on timeout/transport
failure/decode failure/delivery gap, same vocabulary as
Connection.async_request/6's outcome plus
{:error, {:delivery_gap, %{delivered:, expected:}}} when the
Connection's own turn-end count disagrees with what this process
actually received (a definite loss signal, not a heuristic).
Streaming-callback variant of prompt/3: on_update.(update) is invoked
synchronously, in this process, for each session/update as it is
received -- no accumulation (O(1) memory: this loop keeps no update
list at all). Returns {:ok, response} / {:error, term()} for the
terminal outcome, same vocabulary as prompt/3 (including the
{:error, {:delivery_gap, _}} fail-the-turn case).
Standalone start: Client.start_link(MyClient, transport: {mod, handle}).
Subscribe subscriber (default self()) to session_id's
session/update deliveries on conn. The generated default
session_update/2 (unless overridden) broadcasts
{:acp_session_update, session_id, update} to every subscriber
registered for {conn, session_id} -- update is always an
already-decoded Raxol.AgentClientProtocol.Schema.SessionUpdate.t()
(see decode_update/1's doc for why an undecodable variant never
reaches this path). NOT idempotent: subscribing the same pid twice for
the same key registers it twice in the underlying :bag -- each
registration receives its own copy of every broadcast, so the pid gets
the update twice. Subscribe once per {conn, session_id, pid}, or
dedupe on receipt if you can't guarantee that.
Remove a subscription registered via subscribe/3. An unknown/already-removed entry is a no-op.
Types
@type ctx() :: Raxol.AgentClientProtocol.Connection.Ctx.t()
Per-dispatch context. See Raxol.AgentClientProtocol.Connection.Ctx.
@type update() :: Raxol.AgentClientProtocol.Schema.SessionUpdate.t()
A decoded session/update payload (always successfully decoded -- see decode_update/1).
Callbacks
@callback create_terminal( Raxol.AgentClientProtocol.Schema.ClientTypes.CreateTerminalRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.CreateTerminalResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
Handle an inbound "_"-prefixed extension notification. Return value is
ignored. Default: :ok.
@callback handle_ext_request(wire :: String.t(), params :: map(), ctx()) :: {:ok, map()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
Handle an inbound "_"-prefixed extension request with no MethodTable
row. Return shape matches the table-driven request callbacks. Default:
{:error, Error.method_not_found()}.
Produce this handler's per-connection state from handler_arg (the value
passed as handler_arg: to start_link/2 / child_spec/1). Called once,
at Connection boot; the result is threaded read-only through every
dispatch as ctx.handler_state. Default: {:ok, handler_arg}.
@callback kill_terminal( Raxol.AgentClientProtocol.Schema.ClientTypes.KillTerminalRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.KillTerminalResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback raxol_session_record( Raxol.AgentClientProtocol.Ext.Schema.SessionRecordNotification.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: term()
@callback read_text_file( Raxol.AgentClientProtocol.Schema.ClientTypes.ReadTextFileRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReadTextFileResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback release_terminal( Raxol.AgentClientProtocol.Schema.ClientTypes.ReleaseTerminalRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReleaseTerminalResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback request_permission( Raxol.AgentClientProtocol.Schema.ClientTypes.RequestPermissionRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.RequestPermissionResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback session_update( Raxol.AgentClientProtocol.Schema.LifecycleExtras.SessionNotification.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: term()
@callback terminal_output( Raxol.AgentClientProtocol.Schema.ClientTypes.TerminalOutputRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.TerminalOutputResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback wait_for_terminal_exit( Raxol.AgentClientProtocol.Schema.ClientTypes.WaitForTerminalExitRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WaitForTerminalExitResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
@callback write_text_file( Raxol.AgentClientProtocol.Schema.ClientTypes.WriteTextFileRequest.t(), Raxol.AgentClientProtocol.Connection.Ctx.t() ) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WriteTextFileResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()} | :deferred
Functions
Introspect the generated callback surface: {callback_atom, arity, wire_method}, in table order.
@spec child_spec(keyword()) :: Supervisor.child_spec()
Library-mode child spec: embed one ACP client connection subtree into the caller's own supervision tree, e.g.
children = [
{Raxol.AgentClientProtocol.Client, handler: MyClient, transport: {Transport.Stdio, handle}}
]Required: :handler (a module using this behaviour), :transport
(a {module, handle} pair per Raxol.AgentClientProtocol.Transport).
Optional: :handler_arg (passed to init/1, default nil), :name
(registers the ConnectionSupervisor), :id (child id in the parent's
supervisor, defaults to Raxol.AgentClientProtocol.Client -- override when
embedding more than one client connection in the same tree).
Resolve the Connection pid out of a started connection subtree. sup
must be the ConnectionSupervisor's own pid -- this is the ONLY
supported way to reach the pid Connection.request/4, async_request/6,
notify/3, and this module's own subscribe/3/prompt/3/prompt_stream/4
need, since start_link/2/child_spec/1 deliberately return/register the
ConnectionSupervisor, not the Connection child itself (the supervisor is
the stable handle across the child's :temporary restart policy).
Getting sup:
start_link/2returns it directly -- pass that return value straight in.- Any starter that hands you back the STARTED CHILD's pid also works
unchanged --
start_supervised!/1(ExUnit) andDynamicSupervisor.start_child/2both do this for achild_spec/1entry. - A plain
Supervisor.start_link(children, ...)where achild_spec/1entry is one of severalchildrendoes NOT hand you theConnectionSupervisorpid directly -- that call returns YOUR OWN supervisor's pid, one level further out. Resolve theConnectionSupervisorchild fromSupervisor.which_children(your_sup)yourself first, then pass THAT pid here.
Returns {:error, :not_found} if sup has no live Connection child
(already torn down, or sup isn't a ConnectionSupervisor at all --
e.g. you passed your own outer supervisor's pid from the last bullet
above by mistake).
@spec decode_update(map()) :: {:ok, Raxol.AgentClientProtocol.Schema.SessionUpdate.t()} | {:raw, map()}
Tolerant session/update payload decode: wraps
Raxol.AgentClientProtocol.Schema.SessionUpdate.from_json/1. On success,
{:ok, SessionUpdate.t()}; on failure (an unrecognized sessionUpdate
discriminator, or a variant payload that fails its own required-field
decode), logs a warning and returns {:raw, map} instead of propagating
the error. Never raises. Every variant the pinned v1.19.0 oracle defines
is ported, usage_update included (Schema.UsageUpdate), so an
unrecognized discriminator means a wire value from outside that schema --
see SessionUpdate's moduledoc for the variant list.
Not on the live wire-dispatch path. Connection/Router decode
session/update centrally, via SessionNotification.from_json/1
(method_table.ex's session/update row), and DROP the whole
notification + telemetry ([:raxol, :acp, :unknown_notification])
before a handler ever runs when that decode fails -- session_update/2,
and therefore subscribe/3's broadcast, is only ever invoked with an
already-successfully-decoded payload. This
function exists for callers that decode a raw "update" wire map
themselves (manual/offline processing, replay tooling, tests) and want
best-effort passthrough instead of Connection's silent drop. The live
pipeline stays strict; tolerating undecodable variants there would require
a change to SessionNotification/Router decode.
@spec prompt(pid(), struct(), pos_integer()) :: {:ok, {[update()], struct() | {:ext, map()}}} | {:error, term()}
Send session/prompt and collect every session/update delivered for
the turn until the response resolves. Returns
{:ok, {updates, response}} -- updates is the list of decoded
SessionUpdate.t() payloads observed during the turn, oldest first;
response is the decoded result struct (or {:ext, map()} for a
non-table result marker) -- or {:error, term()} on timeout/transport
failure/decode failure/delivery gap, same vocabulary as
Connection.async_request/6's outcome plus
{:error, {:delivery_gap, %{delivered:, expected:}}} when the
Connection's own turn-end count disagrees with what this process
actually received (a definite loss signal, not a heuristic).
No settle pass, no timer: the direct channel makes updates, the turn-end count, and the result all arrive from the Connection itself, so mailbox order already equals wire order.
@spec prompt_stream(pid(), struct(), (update() -> any()), pos_integer()) :: {:ok, struct() | {:ext, map()}} | {:error, term()}
Streaming-callback variant of prompt/3: on_update.(update) is invoked
synchronously, in this process, for each session/update as it is
received -- no accumulation (O(1) memory: this loop keeps no update
list at all). Returns {:ok, response} / {:error, term()} for the
terminal outcome, same vocabulary as prompt/3 (including the
{:error, {:delivery_gap, _}} fail-the-turn case).
@spec start_link(module(), keyword()) :: Supervisor.on_start()
Standalone start: Client.start_link(MyClient, transport: {mod, handle}).
Returns the ConnectionSupervisor's Supervisor.on_start() result --
link to (or monitor) the returned pid to observe the whole subtree's
lifecycle (see Agent.start_link/2's doc for the crash-vs-restart
rationale, identical here).
Options: same as child_spec/1 minus :handler (positional) and :id.
Subscribe subscriber (default self()) to session_id's
session/update deliveries on conn. The generated default
session_update/2 (unless overridden) broadcasts
{:acp_session_update, session_id, update} to every subscriber
registered for {conn, session_id} -- update is always an
already-decoded Raxol.AgentClientProtocol.Schema.SessionUpdate.t()
(see decode_update/1's doc for why an undecodable variant never
reaches this path). NOT idempotent: subscribing the same pid twice for
the same key registers it twice in the underlying :bag -- each
registration receives its own copy of every broadcast, so the pid gets
the update twice. Subscribe once per {conn, session_id, pid}, or
dedupe on receipt if you can't guarantee that.
This broadcast path still fans out through a per-notification dispatch
task (one task per session/update), so it has no cross-notification
ordering guarantee on its own -- it is NOT what prompt/3/
prompt_stream/4 use (see the moduledoc's "Session-consumer ergonomics"
section); it remains here for custom session_update/2 overrides that
want a simple fan-out primitive.
Remove a subscription registered via subscribe/3. An unknown/already-removed entry is a no-op.