Skip to content

About

A generic ACP library for Common Lisp

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

43 Commits

Folders and files

Repository files navigation

agentcomms

Agentcomms is a Common Lisp implementation of the Agent Client Protocol (ACP), the open protocol that editors such as Zed, Neovim, Emacs, and JetBrains use to drive coding agents over newline-delimited JSON-RPC on standard I/O. The library speaks ACP protocol version 1, the stable revision; the v2 draft is not implemented yet. It leaves product decisions to the program that embeds it: an agent subclasses the agent peer and specializes a handful of generic functions, and a client does the same on its side.

The library handles:

  • JSON-RPC 2.0 framing, request correlation, and bidirectional requests
  • threaded request handling so cancellation reaches a running prompt
  • $/cancel_request, session/cancel, and the cancelled stop reason
  • protocol version negotiation and capability gating on both sides
  • every ACP v1 method, notification, and object constructor
  • standard I/O channels for agents and a subprocess launcher for clients
  • WebSocket channels for remote agents, with an HTTP/1.1 request reader
  • a remote agent server for Streamable HTTP and WebSocket clients
  • an in-process channel pair for tests and embedded peers
  • bounded message sizes and bounded JSON documents

Systems and package

  • ASDF system: agentcomms
  • Test system: agentcomms/tests
  • Package: AGENTCOMMS, nickname ACP

Install the locked dependencies and run all tests:

./script/bootstrap
./script/check

Values

Wire values follow argo’s value model: objects are EQUAL hash tables with string keys, arrays are vectors, true is T, false is argo’s (JSON-FALSE) marker, and null is NIL. JSON-OBJECT builds an object from alternating keys and values and omits NIL values, so optional fields can be passed straight through; write :NULL for an explicit null. JSON-GET reads a field with a default and reports false and null as NIL; GETHASH tells them apart. JSON-SEQUENCE->LIST turns an array into a list, and ACP-FIELD validates one field of a received object, signalling an Invalid Params error for the peer when the type is wrong.

Enumerations map between keywords and wire strings: (STOP-REASON-STRING :END-TURN) is "end_turn" and (TOOL-KIND-KEYWORD "read") is :READ. The -KEYWORD functions take a :DEFAULT for values newer peers may send.

Constructors named ACP- build every protocol object: content blocks (ACP-TEXT-CONTENT, ACP-IMAGE-CONTENT, ACP-RESOURCE-LINK, ACP-EMBEDDED-RESOURCE), tool calls (ACP-TOOL-CALL, ACP-TOOL-CALL-UPDATE, ACP-DIFF-CONTENT, ACP-TERMINAL-CONTENT), plans, permission options, modes, configuration options, MCP server configurations, capabilities, and the session updates (ACP-UPDATE-AGENT-MESSAGE, ACP-UPDATE-TOOL-CALL, ACP-UPDATE-PLAN, and the rest).

Writing an agent

Subclass ACP-AGENT, specialize the generic functions you support, and serve standard I/O:

(defclass my-agent (acp:acp-agent) ())

(defmethod acp:agent-implementation ((agent my-agent))
  (acp:acp-implementation "my-agent" "1.0.0" :title "My Agent"))

(defmethod acp:agent-capabilities ((agent my-agent))
  (acp:acp-agent-capabilities :image t :mcp-http t))

(defmethod acp:agent-new-session ((agent my-agent) &key cwd mcp-servers additional-directories params)
  (declare (ignore mcp-servers additional-directories params))
  (values (start-conversation cwd)
          (acp:json-object "modes" (acp:acp-session-mode-state
                                    "code" (list (acp:acp-session-mode "code" "Code"))))))

(defmethod acp:agent-prompt ((agent my-agent) session-id prompt &key params)
  (declare (ignore params))
  (dolist (block prompt)
    (acp:agent-check-cancelled agent session-id)
    (acp:agent-send-update agent session-id
                           (acp:acp-update-agent-message
                            (acp:acp-text-content (answer (acp:acp-content-text block))))))
  :end-turn)

(acp:acp-serve-standard-io (make-instance 'my-agent))

The baseline methods are AGENT-NEW-SESSION, AGENT-PROMPT, and AGENT-CANCEL. The library records sessions, validates parameters, answers initialize with the negotiated version, and only dispatches session/load, session/resume, session/close, session/list, session/delete, and logout when AGENT-CAPABILITIES advertises them.

A prompt turn ends when AGENT-PROMPT returns a stop reason keyword. When the client sends session/cancel, the library marks the session, calls AGENT-CANCEL so the implementation can interrupt provider requests, and turns the turn’s result into the :CANCELLED stop reason, whether the implementation returned normally, signalled ACP-PROMPT-CANCELLED from AGENT-CHECK-CANCELLED, or failed with any other error.

While a turn runs, the agent calls the client through AGENT-REQUEST-PERMISSION, AGENT-READ-TEXT-FILE, AGENT-WRITE-TEXT-FILE, AGENT-CREATE-TERMINAL and the other terminal functions, and AGENT-CREATE-ELICITATION. Each one signals ACP-CAPABILITY-ERROR when the client never advertised the capability, so implementations can fall back before sending anything. AGENT-SEND-UPDATE streams session updates.

Batching visible thoughts

Create one update buffer per prompt to combine small text thoughts:

(let ((updates (acp:make-agent-update-buffer agent session-id :thought-batch-size 800)))
  (acp:update-buffer-send updates
                          (acp:acp-update-agent-thought (acp:acp-text-content "Thinking...")))
  (acp:update-buffer-flush updates))

Send every update for that prompt through UPDATE-BUFFER-SEND to preserve ordering. Flush before requesting permission and before returning a prompt result, including cancellation and error exits. Use a synchronous notification sender; request client decisions after flushing, outside the buffer’s lock. Compatible text thoughts retain their metadata and coalesce up to the character threshold. Large fragments and other content pass through after pending text. If delivery fails, retry UPDATE-BUFFER-FLUSH to publish the retained batch.

MAKE-AGENT-UPDATE-BUFFER validates complete notifications against the connected channel’s message limit before joining fragments. For another transport, use MAKE-ACP-UPDATE-BUFFER with a sender and a :validator function that checks its complete wire message.

Extension methods, whose names begin with an underscore, reach AGENT-EXTENSION-REQUEST and AGENT-EXTENSION-NOTIFICATION.

Writing a client

Subclass ACP-CLIENT, launch the agent, and drive it:

(defclass my-client (acp:acp-client) ())

(defmethod acp:client-capabilities ((client my-client))
  (acp:acp-client-capabilities :read-text-file t :write-text-file t))

(defmethod acp:client-session-update ((client my-client) session-id update &key params)
  (declare (ignore session-id params))
  (when (eq (acp:acp-update-kind update) :agent-message-chunk)
    (write-string (acp:acp-content-text (acp:json-get update "content")))))

(defmethod acp:client-request-permission ((client my-client) session-id tool-call &key options params)
  (declare (ignore session-id tool-call params))
  (values :selected (acp:json-get (first options) "optionId")))

(defmethod acp:client-read-text-file ((client my-client) session-id path &key line limit params)
  (declare (ignore session-id line limit params))
  (uiop:read-file-string path))

(let* ((channel (acp:acp-launch-agent "/usr/local/bin/some-agent" :arguments '("--acp")))
       (client (make-instance 'my-client)))
  (acp:acp-client-connect client channel)
  (unwind-protect
       (progn
         (acp:client-initialize client)
         (let ((session-id (acp:client-new-session client "/home/me/project")))
           (acp:client-prompt client session-id
                              (list (acp:acp-text-content "Summarize this project.")))))
    (acp:connection-close (acp:acp-client-connection client))))

CLIENT-INITIALIZE sends this client’s capabilities and implementation info, records the agent’s capabilities and authentication methods, and signals ACP-UNSUPPORTED-VERSION when the agent selects a version the library cannot speak. CLIENT-NEW-SESSION, CLIENT-LOAD-SESSION, CLIENT-PROMPT accepts :ON-SENT to receive the written wire request id before waiting for the prompt result. CLIENT-CANCEL, CLIENT-SET-CONFIG-OPTION, CLIENT-LIST-SESSIONS, and the rest mirror the agent methods; the optional ones signal ACP-CAPABILITY-ERROR when the agent never advertised them.

Agent requests arrive through CLIENT-REQUEST-PERMISSION, CLIENT-READ-TEXT-FILE, CLIENT-WRITE-TEXT-FILE, the CLIENT- terminal functions, and CLIENT-CREATE-ELICITATION. File system, terminal, and elicitation requests are only dispatched when CLIENT-CAPABILITIES advertised them; otherwise the agent receives Method Not Found.

Closing the connection closes the agent’s input, waits for it to exit, and terminates it when it does not. ACP-PROCESS-CHANNEL-STDERR-TEXT holds a bounded tail of the agent’s standard error and ACP-PROCESS-CHANNEL-EXIT-CODE its exit code.

Connections and channels

ACP-CONNECTION is a symmetric JSON-RPC connection over an ACP-CHANNEL. Incoming requests run on their own threads so a long request never blocks the notification that cancels it; incoming notifications run on the reader thread in arrival order. CONNECTION-REQUEST returns the peer’s result or signals ACP-REMOTE-ERROR, ACP-TIMEOUT after sending $/cancel_request, or ACP-CONNECTION-CLOSED. Its optional :ON-SENT callback receives the integer wire request id after the request is written and before waiting for the result, which allows a caller to send a matching cancellation request without guessing the id. Request handlers signal ACP-METHOD-ERROR to answer with a specific JSON-RPC error and call ACP-CHECK-CANCELLED to honor cancellation.

Channels carry message lines. ACP-STANDARD-IO-CHANNEL wraps this process’s standard streams as UTF-8, ACP-LAUNCH-AGENT wraps a subprocess, MAKE-ACP-STREAM-CHANNEL wraps any pair of character streams, and MAKE-ACP-CHANNEL-PAIR returns two connected in-memory channels, and MAKE-ACP-WEBSOCKET-CHANNEL and ACP-WEBSOCKET-CONNECT carry messages over a WebSocket. Every channel bounds its messages at *ACP-MAXIMUM-MESSAGE-CHARACTERS*, 16 MiB by default; longer incoming messages are discarded and answered with a Parse Error.

HTTP/1.1 messages

HTTP1-READ-REQUEST reads one request from a binary stream and returns an HTTP-REQUEST, or NIL when the input ends before the request starts. It accepts HTTP/1.0 and HTTP/1.1, CRLF or bare LF line ends, and bodies framed by Content-Length or chunked Transfer-Encoding. The head is limited to *HTTP-MAXIMUM-HEADER-OCTETS* and the body to *HTTP-MAXIMUM-BODY-OCTETS*; both limits are also keyword arguments. When the request has Expect: 100-continue and the stream is also an output stream, the reader sends the interim response before it reads the body.

A request it cannot accept signals HTTP-REQUEST-ERROR. HTTP-REQUEST-ERROR-STATUS is the status to answer with: 400 for a malformed request, 413 for a large body, 431 for a large head, 501 for an unknown transfer coding, and 505 for another protocol version. HTTP-REQUEST-ERROR-HEADERS lists header fields that belong in that answer.

The HTTP/2 connection preface starts like a request. When the request line is PRI * HTTP/2.0, the reader returns that request after the empty line that follows it and reads nothing more, so the caller can read the rest of the preface and switch protocols.

MAKE-HTTP-REQUEST builds the same object from parts, for requests that arrive another way. The readers are HTTP-REQUEST-METHOD, HTTP-REQUEST-TARGET, HTTP-REQUEST-PATH (the target without its query), HTTP-REQUEST-QUERY (decoded name and value pairs), HTTP-REQUEST-VERSION, HTTP-REQUEST-HEADERS (lowercase names in arrival order), and HTTP-REQUEST-BODY (octets). HTTP-REQUEST-HEADER returns one field and joins repeated fields with a comma, HTTP-REQUEST-HEADER-TOKEN-P tests a comma-separated field such as Connection for a token, and HTTP-REQUEST-KEEP-ALIVE-P tells whether the connection stays open after the response.

HTTP1-WRITE-RESPONSE writes a status line, header fields, a computed Content-Length, and a body given as octets or a UTF-8 string. HTTP1-WRITE-RESPONSE-HEAD writes only the status line and header fields, for 101 responses and for bodies that end when the connection closes, such as Server-Sent Events.

(let ((request (acp:http1-read-request stream)))
  (when request
    (acp:http1-write-response stream 200
                              :headers '(("Content-Type" . "text/plain"))
                              :body    "ready")))

WebSocket

The WebSocket layer follows RFC 6455 without extensions or TLS. On a server, WEBSOCKET-UPGRADE-REQUEST-P recognizes an upgrade request and WEBSOCKET-ACCEPT checks it, writes the 101 response with any extra header fields, and returns a WEBSOCKET-CONNECTION. An invalid handshake signals HTTP-REQUEST-ERROR before anything is written, with status 426 and a Sec-WebSocket-Version header for an unsupported version and 400 otherwise. Pass the socket as :SOCKET so that closing the connection can wake a thread blocked reading it.

(let* ((socket  (ls-compat.tcp:tcp-accept listener))
       (stream  (ls-compat.tcp:tcp-stream socket))
       (request (acp:http1-read-request stream)))
  (if (acp:websocket-upgrade-request-p request)
      (acp:acp-agent-serve agent
                           (acp:make-acp-websocket-channel
                            (acp:websocket-accept stream request
                                                  :headers '(("Acp-Connection-Id" . "c-1"))
                                                  :socket  socket)))
      (acp:http1-write-response stream 404)))

On a client, WEBSOCKET-CONNECT opens a ws:// URL with extra request headers such as Authorization, and ACP-WEBSOCKET-CONNECT returns an ACP channel for ACP-CLIENT-CONNECT. A wss:// URL signals WEBSOCKET-ERROR; put a TLS proxy in front instead. A refused handshake signals WEBSOCKET-HANDSHAKE-ERROR with the server’s status in WEBSOCKET-HANDSHAKE-ERROR-STATUS. WEBSOCKET-HANDSHAKE-HEADERS holds the 101 response’s header fields.

WEBSOCKET-READ-MESSAGE returns the next text message as a string or binary message as octets, with :TEXT or :BINARY as a second value, and NIL after the close handshake or the end of the stream. It answers pings, reassembles fragments, and fails the connection on a protocol error (status 1002), invalid UTF-8 text (1007), or a message longer than *WEBSOCKET-MAXIMUM-MESSAGE-OCTETS* (1009); the failure signals WEBSOCKET-ERROR with that status in WEBSOCKET-ERROR-CODE. WEBSOCKET-SEND-TEXT, WEBSOCKET-SEND-BINARY, and WEBSOCKET-SEND-PING send one frame each and may run on several threads while one thread reads.

WEBSOCKET-CLOSE sends a close frame. While another thread reads, it waits up to *WEBSOCKET-CLOSE-SECONDS* for the peer’s close frame, then shuts the socket down. WEBSOCKET-CLOSE-CODE and WEBSOCKET-CLOSE-REASON report how the connection ended.

The ACP channel sends each message as one text frame and ignores binary messages. Its read limit is four octets per allowed character: a longer message fails the WebSocket with status 1009, and a shorter one with too many characters is discarded and answered with a Parse Error, as on the other channels.

Remote agent server

ACP-REMOTE-SERVER-START serves an agent to remote clients under the ACP proposal “Streamable HTTP & WebSocket Transport”. One endpoint, /acp, takes three kinds of client:

  • WebSocket: a GET with Upgrade: websocket, then one JSON-RPC message per text frame.
  • Streamable HTTP: the client POSTs each message. initialize creates a connection and answers 200 with the agent’s response and an Acp-Connection-Id header; every later request carries that header and gets 202. The agent’s messages arrive as Server-Sent Events on GET streams: one for the connection, and one per session opened with an Acp-Session-Id header. Responses to session/new and session/load travel on the connection stream. DELETE ends the connection.
  • HTTP/2, through llh2, with prior knowledge or an Upgrade: h2c. HTTP/1.1 clients are served as well, since many cleartext HTTP clients cannot speak HTTP/2; their event streams end with the connection.
(let ((server (acp:acp-remote-server-start
               (lambda (channel) (acp:acp-agent-serve (make-instance 'my-agent) channel))
               :host  "127.0.0.1"
               :port  8737
               :token "s3cret")))
  (format t "Serving ACP at ~A~%" (acp:acp-remote-server-url server))
  ...
  (acp:acp-remote-server-stop server))

The function receives one channel per client connection, in its own thread, and returns when that connection ends. :TRANSPORTS is (:HTTP :WEBSOCKET) by default; (:WEBSOCKET) serves WebSocket alone. A port of 0 picks a free one, which ACP-REMOTE-SERVER-PORT reports.

With :TOKEN, every request needs Authorization: Bearer TOKEN or the token in a token query parameter, or it gets 401. Requests with an Origin header get 403 unless :ALLOWED-ORIGINS lists it: browsers send it, so a web page cannot drive a local agent. The server speaks cleartext only; a reverse proxy adds TLS. Streamable HTTP connections without an open event stream or a request for *ACP-REMOTE-IDLE-SECONDS* end, and their sessions stay with the agent for a later session/load.

Dependencies

  • argo
  • babel
  • Bordeaux Threads
  • cl-base64
  • Ironclad
  • llh2
  • ls-compat (ls-compat/tcp)
  • Serapeum
  • usocket
  • ASDF/UIOP

The protocol types follow the upstream schema at *ACP-SCHEMA-REFERENCE*.

Development

./script/bootstrap
./script/check
./script/check --fix

./script/check runs the tests, then checks the source with cheque as cheque.sexp configures it. --fix skips the tests and repairs what cheque can repair. cheque and its tools are development dependencies: checks/qlfile lists them, and the check installs them on first use into ~/.cache/cheque/tools/.

License

COLL-Attribution, copyright 2026 Lambda Symbolics OÜ. See LICENSE.lisp.

About

A generic ACP library for Common Lisp

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages