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

Copy Markdown View Source

use Raxol.AgentClientProtocol.Agent to implement the agent side of ACP: the process that answers initialize, creates/loads sessions, and serves session/prompt turns.

This is a thin ergonomic layer over Raxol.AgentClientProtocol.Connection and Raxol.AgentClientProtocol.Session: it only generates the callback surface and wires the supervision tree. Protocol correlation, cancellation, permission fan-out, and turn/streaming semantics live entirely in those two modules -- nothing here re-implements or shortcuts them.

Callback surface

One @callback per Raxol.AgentClientProtocol.MethodTable.rows_for_side(:agent) row that carries an app-level callback (layer: :app -- everything except session/cancel, which is Connection-routed session control with no handler callback at all). Naming and arity are generated straight from the table, not hand-maintained:

  • params: nil rows (currently only logout) get callback arity 1: callback(ctx).
  • every other row gets arity 2: callback(params, ctx) for requests, callback(notification, ctx) for notifications.

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.

A request callback returns {:ok, result_struct}, {:error, %Error{}}, or :deferred (legal only after Connection.delegate_reply/3; a :deferred return without a prior delegation is a contract breach the Connection turns into -32603). A notification callback's return value is always ignored (Router.dispatch/4).

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()}, notifications are a silent :ok. Override only what your agent supports -- an unimplemented, capability-gated method (e.g. session/load without loadSession advertised) never needs an explicit override, the wire-correct -32601 falls out for free. 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 MyAgent do
  use Raxol.AgentClientProtocol.Agent
  alias Raxol.AgentClientProtocol.Schema.AgentTypes.InitializeResponse

  @impl true
  def init(_arg), do: {:ok, %{sessions: %{}}}

  @impl true
  def initialize(_req, _ctx), do: {:ok, InitializeResponse.new(1)}

  # ... new_session/2, prompt/2, etc. -- only what you support.
end

{:ok, sup} = Raxol.AgentClientProtocol.Agent.start_link(MyAgent, transport: {mod, handle})

Library-mode wiring

start_link/2 and child_spec/1 build exactly this subtree:

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

Connection resolves its task_sup/session_sup siblings via Supervisor.which_children/1 in handle_continue/2 (never in init/1), so this layer does not need to name or pre-register them; it only needs to hand Connection the ConnectionSupervisor's own pid as parent_sup, which the nested ConnectionSupervisor.init/1 callback captures via self() (that callback body runs in the supervisor process, before any child starts -- the standard OTP way to hand a supervisor's own pid to a child it is about to start).

Summary

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 -- handlers run as concurrent Tasks, so it is never mutated after init. 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 agent 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, and notify/3 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).

Standalone start: Agent.start_link(MyAgent, transport: {mod, handle}).

Types

Callbacks

authenticate(t, t)

close_session(t, t)

delete_session(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 -- handlers run as concurrent Tasks, so it is never mutated after init. Default: {:ok, handler_arg}.

initialize(t, t)

list_sessions(t, t)

load_session(t, t)

logout(t)

new_session(t, t)

prompt(t, t)

raxol_load_session(t, t)

raxol_steer_session(t, t)

resume_session(t, t)

set_session_config_option(t, t)

set_session_mode(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 agent connection subtree into the caller's own supervision tree, e.g.

children = [
  {Raxol.AgentClientProtocol.Agent, handler: MyAgent, 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.Agent -- override when embedding more than one agent 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, and notify/3 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).

start_link(handler, opts \\ [])

@spec start_link(module(), keyword()) :: Supervisor.on_start()

Standalone start: Agent.start_link(MyAgent, 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. A Connection/Session crash is never silently swallowed: it tears the subtree down (auto_shutdown: :any_significant), it does not restart in place.

Options: same as child_spec/1 minus :handler (positional) and :id (not meaningful outside a parent supervisor's children list).