Skip to content

feat: proxy primitive and public JSON-RPC peer layer#219

Open
chazcb wants to merge 3 commits into
agentclientprotocol:mainfrom
chazcb:feat/peer-primitive
Open

feat: proxy primitive and public JSON-RPC peer layer#219
chazcb wants to merge 3 commits into
agentclientprotocol:mainfrom
chazcb:feat/peer-primitive

Conversation

@chazcb

@chazcb chazcb commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What

Adds a proxy() primitive — the TypeScript counterpart of the Rust SDK's Proxy role — and opens up the building blocks it stands on:

  • proxy({ client, agent, fromClient, fromAgent }) (src/proxy.ts): a protocol-terminating relay between a client stream and an agent stream. Each side is a full JSON-RPC connection, so forwarded requests are re-issued with the proxy's own ids and correlated back automatically, in both directions. Unhandled messages forward by default; fromClient / fromAgent handlers can observe, rewrite (Handled.no(message)), answer (respond + Handled.yes()), or drop traffic before it crosses.
  • Public JSON-RPC peer layer: exports the existing Connection, ConnectionBuilder, Handled, JsonRpcHandler, IncomingMessage, message guards, etc. from the package root. Connection's own docs already describe it as the layer "for building generic JSON-RPC middleware or custom dispatch behavior" — this makes that usable outside the package.
  • handlers option on fluent connect(): agent(...)/client(...) apps can prepend raw message-level handlers per connection (logging, authorization, parameter rewriting) without changing their typed registrations. This is thin plumbing onto the pre-existing ConnectionOptions.handlers mechanism.
  • inMemoryStreamPair(): exports the internal helper so in-process endpoints (and proxy sides) can be wired without a transport.
  • linkClosed(a, b): small internal helper deduplicating cross-close wiring; paired connections now propagate the close reason, so pending requests on the surviving side reject with the true cause.
  • Example (src/examples/proxy.ts): a runnable snoop proxy that wraps any agent command and logs traffic in both directions — point Zed at it exactly as you would the agent itself.

Why

ACP intermediaries — session routers, authorization gates, audit taps, message transformers — currently have to reimplement request/response correlation, cancellation propagation, and error passthrough from raw streams, because the SDK's JSON-RPC layer is private and there is no relay primitive. The Rust SDK already ships this concept as the Proxy role with default forwarding and per-peer interception (on_receive_request_from(Client | Agent, ...)), documented in its concepts::proxies module. This PR brings the TypeScript SDK to parity with the same mental model and naming (fromClient / fromAgent), adapted to TS conventions.

Forwarding preserves observable protocol behavior end to end (covered by tests):

  • error responses pass through with their original code/message/data
  • $/cancel_request propagates to the side handling the request; the eventual response still settles the original request
  • method-not-found comes from the far side, not the proxy
  • close on either side closes the other, with the reason propagated

Notes for reviewers

  • The Rust conductor's _proxy/successor envelope protocol is deliberately not included: it exists to chain proxy processes over a single pipe. This proxy owns a real stream per side, so proxies chain by connecting one proxy's agent stream to the next proxy's client stream. A conductor/P-ACP interop layer can build on this later.
  • Open question: this exposes the new surface from the package root. If you'd prefer the package's ./experimental/* subpath convention for new API (e.g. @agentclientprotocol/sdk/experimental/proxy), happy to restructure.
  • No changes to existing behavior for current consumers; the full suite (714 tests) passes, including 12 new proxy tests.

chazcb and others added 3 commits July 14, 2026 17:15
ACP intermediaries — session routers, authz gates, audit taps — currently
have to reimplement request/response correlation, cancellation propagation,
and error passthrough because the SDK's JSON-RPC layer is private and there
is no relay primitive. This exports the existing Connection/handler-chain
layer, adds proxy() (two Connections wired back to back with forwarding
fallbacks, mirroring the Rust SDK's Proxy role), exposes the in-memory
stream pair, and lets fluent apps accept raw handlers on connect() so
message-level middleware composes with typed apps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extracts linkClosed so all three paired-connection sites (proxy and both
in-memory app connects) propagate the close reason instead of two of them
dropping it; narrows openStreamConnection to the one option it uses; hoists
the proxy forwarder to module level; replaces the proxy star re-export with
named exports so future additions to proxy.ts don't silently widen the
public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y role

Renames the interception lists to fromClient/fromAgent to match how the
Rust SDK's Proxy role frames interception (on_receive_request_from(Client),
from(Agent)) so the two SDKs teach the same mental model, and adds a
runnable snoop-proxy example plus README pointers so proxies are
discoverable the same way agents and clients are.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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