Skip to content

feat(channels): ACP channel and agents acp stdio bridge - #2501

Open
cjol wants to merge 4 commits into
mainfrom
feat/channels-acp
Open

cjol wants to merge 4 commits into
mainfrom
feat/channels-acp

Conversation

@cjol

@cjol cjol commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

This PR adds AcpChannel, which serves a Channels agent to Agent Client Protocol clients such as T3 Code and Zed, and npx agents acp <url>, the stdio bridge those clients launch. It stacks on #2497 and uses its per-Channel upgrade.

Why

  • Coding clients that speak ACP expect to launch an agent as a local process that talks JSON-RPC on stdio. A Channels agent lives in a Durable Object behind a WebSocket, so these clients had no way to reach one.
  • A Channels conversation already behaves like an ACP session: it is a harness session with a transcript, turns that settle, approvals and cancellation. ACP fits as one more Channel over the agent's WebSockets, next to the Web Channel, and the harness interface does not change.
  • Two other designs were considered and rejected. Running ACP inside the Web Channel's protocol would have put two wire formats on one channel. A separate transport, with an HTTP endpoint per method, would have bypassed ChannelsHost and the live updates other surfaces rely on.
  • Both the Web and ACP channels need WebSocket upgrades, so the agent has to know which Channel took each one. With feat(channels): Channels resolve their own participants; the agent object is the authorization boundary #2497 the gateway already knows the matched Channel's key. It now writes that key into the identity header, and the agent's channel mounted under the same key serves the connection. Gateway and agent keys already have to match for outbound delivery, so this adds no new convention.
  • The bridge forwards stdio to the WebSocket and does nothing else. All ACP state lives in the agent, so clients that reconnect, or several clients in one room, see the same sessions.

Public API Surface

Added, all additive and all experimental:

Symbol Kind Notes
AcpChannel, AcpChannelOptions class, agents/experimental/channels/acp Agent side. agentInfo is what initialize reports
acp(), AcpIngressOptions function, agents/experimental/channels/acp Gateway side, like web(). match defaults to /acp. ACP upgrades name no conversation
AgentInfo type ACP Implementation info
WebIdentity.channel field The key of the gateway Channel that took the upgrade
agents acp <url> [--as] [--header] CLI stdio ⇄ WebSocket bridge. Uses the same Access handling as agents tui

Changed:

  • The gateway sets channel on every upgrade it forwards. A WebChannel ignores connections whose channel names a different key. Headers without channel still go to the Web Channel.

Code Changes

packages/agents

  • acp/protocol.ts is a hand-written subset of ACP v1 plus JSON-RPC helpers. The package gains no dependency on the ACP SDK.
  • acp/channel.ts maps ACP onto ChannelsHost:
    • session/new, load, resume, list, close and fork become conversations. load replays the transcript as session updates.
    • session/prompt dispatches a message and answers with a stop reason once the turn settles: completed is end_turn, aborted is cancelled, and failed is a JSON-RPC error.
    • Response chunks stream as text, thought and tool call updates.
    • A turn that settles waiting on an approval sends session/request_permission, and the answer is dispatched as an approval response.
    • session/cancel cancels the running turn.
    • User messages from other surfaces are forwarded as user_message_chunk, so the ACP client shows the whole conversation.
  • Pending prompts and permission requests are stored in host.state, keyed by session, so they survive the agent being evicted. Turn work runs through a queue per conversation, so a prompt is answered only after its response has been forwarded.
  • The channel accepts the prompt content types the harness can take: text, plus embedded and linked resources. It advertises no image or audio support. It accepts mcpServers and ignores them, because the agent runs in a Worker rather than beside the client.
  • acp/bridge.ts reuses the TUI's argument parsing and Access handling. It writes only JSON-RPC to stdout, which drops the agent's identity frame that ACP clients would reject, and it logs to stderr.
  • gateway.ts adds channel: channelKey to the identity in #forwardUpgrade. web/channel.ts skips connections addressed to another key.

examples/next/channels

  • Both agents mount acp: new AcpChannel(...). The Worker adds acp() for /acp/<harness>/<room> with the same demo-only ?as= participant and open rooms as the web side.
  • bin/acp-agent runs agents acp against the dev server and ignores its arguments, because T3 Code's executable override passes the registry agent's arguments. It is configured with ACP_HARNESS, ACP_ROOM, ACP_PARTICIPANT or ACP_URL.
  • The README covers setup for T3 Code and Zed. wrangler.jsonc adds /acp/* to run_worker_first.

Known limitations, left for follow-ups:

  • T3 Code's provider probe calls session/new on every refresh, so empty sessions pile up.
  • An approval left unanswered when the client disconnects stays in the transcript.
  • Image prompts are not supported.

Devin Review

cjol added 4 commits October 6, 2026 06:21
AcpChannel serves a Channels agent to Agent Client Protocol clients such
as Zed and T3 Code. Each ACP session is a conversation; prompts, tool
calls, approvals and cancellation map onto the harness's turns.

acp() is its side in the gateway, like web(). The gateway now names the
Channel that took an upgrade in the connection identity, so the agent's
channel mounted under the same key serves the connection.

`agents acp <url>` bridges an ACP client's stdio to the channel's
WebSocket. The Channels example mounts the channel on both agents at
/acp/<harness>/<room> and ships bin/acp-agent for local clients.
@changeset-bot

changeset-bot Bot commented Oct 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0ca868d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@agent-think

agent-think Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 1 entry point changed, no growth

Entry point Exports Largest gzip change Size now
🆕 agents/experimental/channels/acp 2 new — 24.4 KiB
Changed exports (2)
Import Gzip change Size now
🆕 agents/experimental/channels/acp#AcpChannel — 24.4 KiB
🆕 agents/experimental/channels/acp#acp — 12.6 KiB
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared e3ee3f9b → 0ca868d0 · workflow run · reported by agent-think[bot]

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 5 potential issues.

Devin Review

Comment on lines +300 to +305
case "session/resume": {
const sessionId = await this.#existing(params);
if (sessionId === undefined) return fail(...notFound());
this.#remember(sessionId, params);
this.#follow(connection, identity, sessionId);
reply({});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Resumed sessions miss running output

When a client resumes during a running turn, session/resume follows the session without reading its active response. The client misses subsequent text and tool updates because #read only starts on a running-turn update or load.

Learn more

A running turn's response is persisted as a stream. #onTurn starts forwarding when the turn first becomes running, and #replay restarts forwarding for loaded sessions, but resume only follows. A reconnecting ACP client therefore joins after the running-turn event and receives none of that stream's output. The prompt itself can still finish on the previous connection, leaving the new client's view incomplete.

Example: Client A prompts session s1, disconnects during a long answer, and reconnects with session/resume. The remaining answer chunks reach no reader on the new connection, although session/load on the same session starts reading them.

Recommended fix: On resume, inspect the current snapshot's running turns and start response reads as #replay does, without replaying saved transcript messages if resume semantics forbid it. Ensure an in-progress read is not duplicated on repeated resume calls.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +740 to +751
.then(
() => {},
(error: unknown) => {
if (!controller.signal.aborted) {
console.error(`Failed to read response "${responseId}"`, error);
}
}
)
.finally(() => {
reads.delete(responseId);
ended.add(responseId);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Interrupted answers report successful completion

When readResponse fails or returns interrupted, #read resolves done normally. #settled then sends end_turn, despite missing answer chunks.

Learn more

The Channels response reader returns an ending of ended, interrupted, or not-found, and it can reject. The handler ignores its ending and catches rejection, making done fulfill in all cases. #settled waits for done before answering the prompt with end_turn for a completed turn. A failed stream thus looks successful to the ACP client, even if text or tool updates are missing.

Example: A stream read throws halfway through an otherwise completed turn. The client gets half an answer followed by {stopReason: "end_turn"} rather than an error indicating incomplete output.

Recommended fix: Preserve the readResponse ending or rejection in Read.done; make #settled answer the affected prompt with an error when output delivery was interrupted or missing, while still handling aborted reads caused by disconnection.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +379 to +385
const recorded = this.#record(id).cwd;
// A session opened from elsewhere is not one of this directory's.
if (cwd !== undefined && recorded !== undefined && recorded !== cwd) {
continue;
}
// ACP requires a directory; sessions no client opened have none.
sessions.push({ sessionId: id, cwd: recorded ?? cwd ?? "/" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Directory listings misattribute unrecorded sessions

When session/list specifies cwd, #list includes sessions without a recorded directory and labels them with that cwd. The same default or Web-created session appears in every project.

Learn more

Channels lists every conversation in the agent object, including the default conversation and those created through Web. Only ACP-created or opened sessions have a stored cwd. With a directory filter, unrecorded conversations bypass the mismatch condition and inherit the filter value in the returned object. Clients querying separate project directories both receive the same session under their own directory.

Example: The agent has default session root with no cwd. session/list({cwd: "/alpha"}) reports root at /alpha; session/list({cwd: "/beta"}) reports root at /beta.

Recommended fix: Choose one stable directory for unrecorded conversations, or exclude them from filtered lists. Do not populate an unrecorded session's cwd from the query filter.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +98 to +102
input.on("close", () => {
stdinClosed = true;
if (socket.readyState === WebSocket.OPEN) {
socket.close(1000, "stdin closed");
} else if (socket.readyState !== WebSocket.CONNECTING) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Input EOF drops pending ACP replies

When a client ends stdin after sending requests, bridge closes the socket before their replies arrive. The client loses outstanding responses, including session/prompt results.

Learn more

ACP JSON-RPC requests can finish long after the bridge has forwarded them, especially session/prompt. Stdin EOF means no more client requests, but this handler immediately initiates the WebSocket close handshake. Outstanding agent responses cannot be forwarded to stdout after the socket closes. The open handler also flushes queued requests and immediately closes the socket if stdin ended during connection setup.

Example: A process pipes one session/prompt line into agents acp, closes stdin, and waits for stdout. The bridge closes its socket while the agent is generating, so it never forwards the stop reason.

Recommended fix: Track forwarded request IDs and outstanding responses, and defer socket closure after stdin EOF until they settle or a bounded shutdown policy applies. Apply the same rule when flushing requests queued before WebSocket open.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/agents/src/experimental/channels/acp/channel.ts
@pkg-pr-new

pkg-pr-new Bot commented Oct 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2501

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2501

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2501

hono-agents

npm i https://pkg.pr.new/hono-agents@2501

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2501

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2501

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2501

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2501

commit: 0ca868d

@cjol
cjol force-pushed the feat/channels-participant-routing branch from d435f12 to 277afd8 Compare October 6, 2026 14:43
Base automatically changed from feat/channels-participant-routing to main October 6, 2026 15:11

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 7 new potential issues.

Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Pi setup snippet omits ACP

The Pi setup snippet still mounts only WebChannel, while PiAgent mounts both channels. Readers copying it will miss the ACP setup.

(Refers to this code)

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +596 to +600
if (!asking) {
this.#turnResponses.delete(turnKey(conversationId, turn.turnId));
this.#answer(conversationId, turn.turnId, (prompt) =>
result(prompt.rpcId, { stopReason: "end_turn" })
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Client-tool prompts strand the conversation

When an ACP prompt requests a client tool, #askPermissions ends the prompt without returning a tool result. The AI SDK harness keeps the call unanswered, so later prompts cannot produce a normal response.

Learn more

A client tool is a tool with no server-side execute function; the example's getLocation uses one here. The harness stops rather than generating another model response while its last assistant message awaits tool input. #askPermissions handles only approval-requested parts, so an input-available client tool makes asking false and reports end_turn while leaving the harness waiting. Subsequent prompts encounter the same unanswered tool.

Example: An ACP user asks "Where am I?" and the agent calls getLocation. ACP receives a pending tool call and a successful prompt response, but no way to supply its result. A follow-up question also ends without a useful model response.

Recommended fix: Either implement the ACP client-tool request/result flow and dispatch a tool-result for the original turn, or reject/resolve unsupported client tools before claiming that the turn completed. Test a follow-up prompt after a client-tool turn against AiSdkHarness.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +770 to +775
#unfollow(connection: Connection, sessionId: string): void {
const identity = this.#identityOf(connection);
if (!identity) return;
identity.sessions = identity.sessions.filter((id) => id !== sessionId);
setConnectionFlag(connection, IDENTITY_KEY, identity);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Closed sessions keep streaming updates

When session/close unfollows a running session, #unfollow leaves its response reader active. Its onChunks callback keeps sending updates for the closed session.

Learn more

Each running turn starts a response reader in #read. Its chunk callback sends updates directly to the socket without consulting the connection's followed sessions. session/close calls #unfollow, but only socket close calls #stopReads, so the reader continues until its response ends.

Example: A client starts a slow prompt on session s1, calls session/close for s1, and receives a successful close reply. Model tokens generated afterwards still arrive as session/update for s1.

Recommended fix: Track the conversation for each read and abort its reader when #unfollow removes the session. Ensure cancelling a prompt still returns its expected prompt response before detaching if required by ACP.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +501 to +503
if (prompts.length === 0) {
if (turn.outcome !== "awaiting-input") this.#turnResponses.delete(key);
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Disconnected approvals retain response state

When a client disconnects during an awaiting-input turn, #close removes its prompt. Later #settled keeps that turn's response entry forever because no prompts remain.

Learn more

#turnResponses is an in-memory map populated whenever a turn starts running. On disconnect, #close removes the connection's pending prompts from session state. An awaiting-input turn can then settle without a pending prompt; this branch retains its entry permanently. The map grows with every such turn until the object is evicted.

Example: Ten clients each submit a prompt that calls an approval tool and disconnect before answering. The turns settle awaiting input, but all ten response IDs remain in the live object's map.

Recommended fix: Delete the response mapping when no prompt remains, including for awaiting-input turns. No later continuation needs that mapping without an outstanding prompt.

Suggested change
if (prompts.length === 0) {
if (turn.outcome !== "awaiting-input") this.#turnResponses.delete(key);
return;
if (prompts.length === 0) {
this.#turnResponses.delete(key);
return;
}

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

## Gateway and agent

Webhooks and WebSocket upgrades both go through the `ChannelGateway` in the Worker, which routes each to a conversation's agent. `publish` pushes turn statuses and transcript updates to each channel; the channel reads response output from Streams itself.
Webhooks and WebSocket upgrades both go through the `ChannelGateway` in the Worker, which routes each to the agent object a route names. That object holds any number of conversations. `publish` pushes turn statuses and transcript updates to each channel; the channel reads response output from Streams itself.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Agent diagram contradicts object boundary

The gateway description now allows multiple conversations per agent object, but the adjacent diagram still labels the object “one conversation.” Update the diagram to match.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

let stdinClosed = false;

socket.addEventListener("open", () => {
log(`connected to ${url}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 ACP bridge exposes URL credentials in logs

When an ACP URL contains a query-string token, bridge prints it on connection. Client logs can retain that token.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

url: string,
init: { headers: Record<string, string> }
) => WebSocket;
const socket = new NodeWebSocket(url, { headers });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 ACP credentials can travel over plaintext WebSockets

When agents acp targets a remote ws:// URL, bridge forwards --header credentials without TLS. Network observers can read them.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant