CrowdControl.Agent behaviour (crowd_control v0.2.0)

Copy Markdown View Source

Adapter behaviour for a coding-agent CLI.

A session talks to exactly one CLI, and every CLI differs in two places: the argv it is launched with, and the newline-delimited JSON it speaks on stdin/stdout. Everything else in CrowdControl.Session -- buffering, cursors, backpressure, retention, backends -- is agent-agnostic.

Built-in adapters:

Adapters normalize their wire format into CrowdControl.Protocol.message/0, so a subscriber written against Claude Code works unchanged against omp: the session id still arrives as {:system_init, %{"session_id" => id}} and the end of a turn still arrives as {:result, subtype, map}.

Selecting an adapter

Pass :agent (an alias atom or a module implementing this behaviour):

CrowdControl.run("hi", agent: :omp)

When :agent is omitted it is inferred from the :executable basename ("omp" => CrowdControl.Agent.Omp), defaulting to CrowdControl.Agent.ClaudeCode.

Summary

Types

One file an adapter needs inside the sandbox: an absolute in-sandbox path, its bytes, and its octal mode.

Callbacks

Builds {executable, args, env} for launching the CLI.

Decodes one line of CLI stdout into a tagged message.

Encodes a user prompt as a stdin frame.

Frames written to the CLI's stdin immediately after exec, before any prompt.

Files this adapter needs written inside the sandbox before its CLI starts.

Functions

Resolves the adapter module for a set of session options.

The sandbox files module needs, or [] when it declares none.

Types

env()

@type env() :: %{optional(String.t()) => String.t()}

sandbox_file()

@type sandbox_file() :: {Path.t(), iodata(), non_neg_integer()}

One file an adapter needs inside the sandbox: an absolute in-sandbox path, its bytes, and its octal mode.

Callbacks

build_command(keyword)

@callback build_command(keyword()) :: {String.t(), [String.t()], env()}

Builds {executable, args, env} for launching the CLI.

Raises ArgumentError on options the adapter cannot express.

decode_line(binary)

@callback decode_line(binary()) :: CrowdControl.Protocol.message()

Decodes one line of CLI stdout into a tagged message.

encode_prompt(prompt, seq, keyword)

@callback encode_prompt(prompt :: binary(), seq :: non_neg_integer(), keyword()) ::
  binary()

Encodes a user prompt as a stdin frame.

seq is a per-session counter starting at 0, so adapters that need request ids can mint stable ones without extra state.

init_frames(keyword)

@callback init_frames(keyword()) :: [binary()]

Frames written to the CLI's stdin immediately after exec, before any prompt.

Used for protocol handshakes; return [] when the CLI needs none.

sandbox_files(keyword)

(optional)
@callback sandbox_files(keyword()) :: [sandbox_file()]

Files this adapter needs written inside the sandbox before its CLI starts.

Optional; an adapter that does not implement it stages nothing, which is why CrowdControl.Agent.ClaudeCode needs no change.

A CLI that reads configuration from disk — CrowdControl.Agent.Omp resolving a custom provider's baseUrl out of models.yml — has a problem no argv or environment variable solves on a remote substrate: the file has to exist on the sandbox's filesystem, and a host temp directory is not visible there. Rendering is the adapter's job; writing is the backend's, since only the backend knows how bytes cross into its substrate.

Must be pure and deterministic: it is called once per exec, after CrowdControl.Backend.provision/1 (there is no sandbox to write to before that) and again on nothing else. Refuse a malformed option in build_command/1 instead, which runs before a sandbox has been created and billed.

Functions

resolve(opts)

@spec resolve(keyword()) :: module()

Resolves the adapter module for a set of session options.

Explicit :agent wins; otherwise the :executable basename decides; otherwise CrowdControl.Agent.ClaudeCode.

iex> CrowdControl.Agent.resolve(agent: :omp)
CrowdControl.Agent.Omp

iex> CrowdControl.Agent.resolve(executable: "/opt/homebrew/bin/omp")
CrowdControl.Agent.Omp

iex> CrowdControl.Agent.resolve([])
CrowdControl.Agent.ClaudeCode

sandbox_files(module, opts)

@spec sandbox_files(
  module(),
  keyword()
) :: [sandbox_file()]

The sandbox files module needs, or [] when it declares none.

Probed rather than required, so an adapter opts in by defining sandbox_files/1 and every other adapter is unaffected.