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: true

See 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

ctx()

Per-dispatch context. See Raxol.AgentClientProtocol.Connection.Ctx.

update()

A decoded session/update payload (always successfully decoded -- see decode_update/1).

Callbacks

create_terminal(t, t)

handle_ext_notification(wire, params, ctx)

@callback handle_ext_notification(wire :: String.t(), params :: map(), ctx()) :: term()

Handle an inbound "_"-prefixed extension notification. Return value is ignored. Default: :ok.

handle_ext_request(wire, params, ctx)

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

init(handler_arg)

@callback init(handler_arg :: term()) :: {:ok, handler_state :: term()} | {:stop, term()}

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}.

kill_terminal(t, t)

raxol_session_record(t, t)

read_text_file(t, t)

release_terminal(t, t)

request_permission(t, t)

session_update(t, t)

terminal_output(t, t)

wait_for_terminal_exit(t, t)

write_text_file(t, t)

Functions

callbacks()

@spec callbacks() :: [{atom(), arity(), String.t()}]

Introspect the generated callback surface: {callback_atom, arity, wire_method}, in table order.

child_spec(opts)

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

connection(sup)

@spec connection(pid()) :: {:ok, pid()} | {:error, :not_found}

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/2 returns 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) and DynamicSupervisor.start_child/2 both do this for a child_spec/1 entry.
  • A plain Supervisor.start_link(children, ...) where a child_spec/1 entry is one of several children does NOT hand you the ConnectionSupervisor pid directly -- that call returns YOUR OWN supervisor's pid, one level further out. Resolve the ConnectionSupervisor child from Supervisor.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).

decode_update(raw)

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

prompt(conn, request, timeout_ms \\ 60000)

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

prompt_stream(conn, request, on_update, timeout_ms \\ 60000)

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

start_link(handler, opts \\ [])

@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(conn, session_id, subscriber \\ self())

@spec subscribe(pid(), String.t(), pid()) :: :ok

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.

unsubscribe(conn, session_id, subscriber \\ self())

@spec unsubscribe(pid(), String.t(), pid()) :: :ok

Remove a subscription registered via subscribe/3. An unknown/already-removed entry is a no-op.