Managoat.ACP.Protocol (managoat_acp v0.1.0)

Copy Markdown View Source

Framing and JSON-RPC 2.0 encoding for the Agent Client Protocol.

Pure functions only — no process, no I/O. The peer owns the socket; this module owns the bytes, which is the part worth testing exhaustively and the part that has nothing to do with sprites.

ACP is newline-delimited JSON over stdio. A transport hands the peer bytes in arbitrary chunks that respect no message boundary at all: one chunk can carry three messages and half of a fourth, and the other half arrives later. So framing has to carry a buffer across calls — feed/2 returns the messages it could complete plus whatever tail it could not, and the caller hands that tail back on the next chunk.

Why the decode failure is a value and not a raise

A line that is not JSON is not an exception, it is an event: adapters print warnings, npm prints deprecation notices, and a Node process writes its stack trace to stdout when it dies. feed/2 returns {:invalid, line} for those so the peer can log them as ordinary output rather than crashing a turn on somebody else's diagnostic.

Summary

Types

A framed message, already classified.

Functions

Classify a decoded JSON-RPC object.

Classify one already-framed line.

The client capabilities the peer declares unless told otherwise: none.

Encode an error reply.

Frame data against a carried buffer, returning complete messages and the new buffer.

The initialize params for client_capabilities.

JSON-RPC's method-not-found code.

Encode an outbound notification (no id, no reply expected).

Encode an outbound request. Returns the line, newline included.

Encode a successful reply to an agent→client request.

Whether this line is a session/update carrying session metadata rather than agent activity.

Types

message()

@type message() ::
  {:response, id :: term(), result :: map()}
  | {:error_response, id :: term(), error :: map()}
  | {:request, id :: term(), method :: String.t(), params :: map()}
  | {:notification, method :: String.t(), params :: map()}
  | {:invalid, line :: String.t()}

A framed message, already classified.

Functions

classify(msg)

@spec classify(map()) :: message()

Classify a decoded JSON-RPC object.

classify_line(line)

@spec classify_line(String.t()) :: message()

Classify one already-framed line.

Exposed for a render path that reads stored lines back and has no buffer to carry.

default_client_capabilities()

@spec default_client_capabilities() :: map()

The client capabilities the peer declares unless told otherwise: none.

fs/* and terminal/* are client-implemented, and a client that declares them has to service them against wherever the agent is running. Declaring nothing means a well-behaved adapter never asks; the peer still answers anything that arrives with method_not_found/0, because an unanswered request blocks the agent.

error(id, code, message)

@spec error(term(), integer(), String.t()) :: iodata()

Encode an error reply.

-32601 (method not found) is the one gate 2 sends: we declare no client filesystem or terminal capabilities, so an adapter calling fs/* or terminal/* is asking for something we told it we do not have. Answering is still mandatory — an unanswered request blocks the agent, and a blocked agent is a sprite billing until the lifetime ceiling.

feed(buffer, data)

@spec feed(binary(), binary()) :: {[message()], binary()}

Frame data against a carried buffer, returning complete messages and the new buffer.

The tail after the last newline is always incomplete by definition — a message is only whole once its newline has arrived — so it goes back into the buffer even when it happens to be valid JSON.

initialize_params(client_capabilities)

@spec initialize_params(map()) :: map()

The initialize params for client_capabilities.

protocolVersion is 1, the current version at agentclientprotocol.com; the spec bumps it only for breaking changes, and new capabilities are not breaking. The peer sends this on handle_continue(:initialize) with the capabilities its owner passed (default_client_capabilities/0 by default).

method_not_found()

@spec method_not_found() :: integer()

JSON-RPC's method-not-found code.

notification(method, params)

@spec notification(String.t(), map()) :: iodata()

Encode an outbound notification (no id, no reply expected).

request(id, method, params)

@spec request(term(), String.t(), map()) :: iodata()

Encode an outbound request. Returns the line, newline included.

response(id, result)

@spec response(term(), map()) :: iodata()

Encode a successful reply to an agent→client request.

session_metadata?(line)

@spec session_metadata?(String.t()) :: boolean()

Whether this line is a session/update carrying session metadata rather than agent activity.

The claude adapter generates the session title asynchronously and writes the session_info_update about a second after the prompt response — out of turn. Metadata is nothing the agent said or ran, so it must not be read as "the agent is talking out of turn": it opens no autonomous turn and holds none open (#1300). Exposed for an owner that classifies the peer's reported lines.