Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, feat/unstable-v2-session-inject]
workflow_dispatch:

permissions:
Expand Down
7 changes: 3 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ agent-client-protocol-trace-viewer = { path = "src/agent-client-protocol-trace-v
yopo = { package = "agent-client-protocol-yopo", path = "src/yopo" }

# Protocol
agent-client-protocol-schema = { version = "=1.7.0", features = ["tracing"] }
agent-client-protocol-schema = { git = "https://github.com/danielkov/agent-client-protocol", rev = "6e7e044f9464c4fd652d90699a09e9edc8b3bbad", features = ["tracing"] }

# Core async runtime
tokio = { version = "1.52", default-features = false }
Expand Down
113 changes: 113 additions & 0 deletions md/http-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,116 @@ and it will open a single bidirectional connection instead of using POST + SSE:
let transport = HttpClient::new("ws://127.0.0.1:8080")?;
my_client().connect_to(transport).await?;
```

## Opt-in Bounded HTTP/SSE

Existing constructors preserve the compatibility-only unbounded transport.
Select finite admission explicitly on each endpoint:

```rust
use agent_client_protocol_http::{AcpHttpServer, HttpClient, HttpClientLimits, ServerLimits};

let app = AcpHttpServer::new_bounded(|| my_agent(), ServerLimits::default())?
.into_router();
let transport = HttpClient::new("http://127.0.0.1:8080/acp")?
.with_limits(HttpClientLimits::default())?;
my_client().connect_to(transport).await?;
```

These return `BoundedAcpHttpServer` and `BoundedHttpClient`. The server retains
`with_options(ServerOptions)` and `into_router()`. No fields were added to
`ServerOptions`. Both endpoints use the core bounded connection interface
directly, without forwarding through the legacy unbounded `Channel`.
Custom components must implement bounded extraction; unsupported legacy-only
components fail closed instead of silently losing the admission guarantee.
Raw adapters can use `BoundedHttpClient::into_bounded_channel_and_future()`;
they must poll the returned driver and retain each `ChargedFrame` until their
own consumption boundary. See [bounded core transports](./transport-architecture.md)
for producer admission and charged frame ownership.

### Finite Defaults

Zero limits are invalid. The limits structures are public and can be configured
before construction. Budgets are independent and conservative: fitting one
limit does not guarantee admission through every other limit.

| Budget | Server default | Client default |
| --- | --- | --- |
| Core maximum serialized frame | 256 KiB | 1 MiB |
| Core reserved bytes / frames, each direction | 16 MiB / 256 | 16 MiB / 256 |
| Core pending requests / tasks | 256 / 256 | 256 / 256 |
| Active logical connections | 64 | One per transport |
| Concurrent POSTs / aggregate request-body reservation | 32 / 8 MiB | Separate request and response lanes |
| HTTP maximum request frame / batch entries | 256 KiB / 128 | Core frame limit |
| HTTP egress bytes / frames | 4 MiB / 64 per connection | 64 MiB / 128 HTTP reservations |
| Pending routed RPC entries | 256 per connection | 128 |
| Registered sessions / active SSE streams | 64 / 65 per connection | 8 streams, including connection stream |
| Queued plus active POSTs | Global concurrent POST admission | 32 request and 32 response-only |
| Response body / SSE line / event / chunk | Outbound frame and egress limits | 1 MiB each |

Client reservations cover POST bodies, pending metadata, bounded response
workspaces, and SSE parser workspaces; each reservation also consumes a frame
slot. Server POST bodies reserve the maximum frame size before polling the body.
Server connection admission precedes factory invocation. Duplicate request IDs
consume separate pending entries; a successful POST does not imply RPC completion.
Response-only callback POSTs have an independent bounded client lane. Mixed
batches remain in request order. Registered server session mailboxes last until
connection termination and count against the configured session limit.

### Ownership and Exhaustion

Core producer admission happens before queueing. Charges survive intermediate
dequeue, routing, and body construction; the server releases yielded body
charges on the subsequent poll or body drop. Client bodies retain charges
through HTTP handoff. This bounds SDK-owned queued data and work, not an
application's cumulative output. A healthy consumer can process more than any
single budget over the connection's lifetime.

Exhaustion is explicit and fail-fast, not an unbounded queue of waiting sends.
The server rejects pre-acceptance overload with an HTTP error; terminal errors
revoke producers and release pending routes, mailboxes, and local tasks.
POST/body admission exhaustion for an addressed connection terminates that
connection so callbacks cannot remain indefinitely blocked behind saturated
request bodies. Cancellation/drop releases local reservations; client teardown
does not guarantee a remote DELETE completed.

By default DELETE aborts the server connection. To drain accepted work instead,
call `.with_graceful_delete(std::time::Duration::from_secs(30))` on the bounded
server before `into_router()`. DELETE atomically seals inbound HTTP admission
without reserving a POST body or core frame. Later POSTs return 410 before body
admission; already-reading POSTs recheck the seal before enqueue. The existing
connection task continues to process accepted frames even if the DELETE waiter
is canceled. A 202 response requires successful component-future completion
and clean output EOF, not merely an empty queue or a terminal-failure EOF.
This does not acknowledge consumption by the remote HTTP peer.

Each DELETE waits at most the configured duration. A 503 reports a deadline or
failure; a deadline leaves the connection closing and accepted work running,
never reopens admission, and retains its capacity slot until work finishes.
Repeated DELETE joins the same drain while the connection exists; after actual
completion removes it, subsequent DELETE returns 404. Existing subscribed SSE
streams can consume queued output through EOF after clean completion.

Encoded-byte budgets are **not hard peak-heap limits**. Parsed JSON and bounded
serialization scratch add overhead; application conversion hooks can allocate
intermediate values before capped normalization. Allocator capacity, arbitrary
application task captures, and HTTP/TLS/socket buffers are outside encoded-byte
accounting. Core reservations and HTTP reservations are additional budgets, not
one shared process-memory counter.

### Delivery and Recovery Boundaries

A body poll transfers bytes to the HTTP stack. It is **not** proof of socket
flush, peer parsing, or ACP application consumption. An event already yielded
when SSE disconnects can be lost. No cursor/replay or exactly-once delivery is
provided, and no `Last-Event-ID` recovery is performed.

The bounded client does not automatically retry accepted or uncertain POSTs;
streaming request bodies are non-replayable, including with custom reqwest
redirect/retry policies. A lost response can leave acceptance unknown. Existing
JSON-RPC IDs remain correlation IDs, not idempotency keys.

The bounded path supports HTTP/SSE only. The bounded client rejects `ws`/`wss`
URLs, and bounded server WebSocket upgrades return HTTP 501. Legacy WebSocket
support is unchanged. ACP JSON-RPC messages and HTTP connection/session headers
are unchanged; bounded endpoints do not require a private protocol extension.
90 changes: 90 additions & 0 deletions md/transport-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,96 @@ boundary, although serializing a relayed batch may normalize whitespace. The
protocol actor, not the parser, decides whether malformed input requires a
response.

## Opt-in bounded core transport

`Channel` and its public unbounded sender/receiver fields remain unchanged for
compatibility. They do **not** provide admission or memory bounds. New transports
can instead use `BoundedChannel`, selected through
`ConnectTo::into_transport_and_future` and `TransportChannel::Bounded`.
`DynConnectTo` preserves this selection. A transport that accepts a component
factory can call `into_bounded_channel_and_future(limits)` to give its `Builder`
a bounded endpoint directly, without an unbounded adapter pump. Attempting to
extract a bounded endpoint through the legacy `into_channel_and_future` method
returns a disconnected channel and a failing driver future; it never inserts a
legacy bridge.

```rust
use agent_client_protocol::{BoundedChannel, ChannelLimits};

let limits = ChannelLimits::default();
let (protocol_endpoint, transport_endpoint) = BoundedChannel::duplex(limits)?;
// Connect the Builder to protocol_endpoint. The transport uses
// transport_endpoint.tx.try_send_serialized(...) and receives ChargedFrame
// values from transport_endpoint.rx.
# Ok::<(), agent_client_protocol::Error>(())
```

The default limits per direction are:

| Limit | Default |
| --- | ---: |
| Maximum encoded frame | 1 MiB |
| Reserved encoded bytes | 16 MiB |
| Preparing, queued, and handed-off frames/control items | 256 |
| Pending outgoing requests | 256 |
| Queued/running tasks and registered dynamic handlers | 256 |

Zero limits are invalid; the byte budget must fit one maximum-sized frame.
Admission reserves the **maximum frame size**, not the eventual encoded length,
so the default byte limit permits at most 16 simultaneous frame reservations.
This conservative reservation allows synchronous producer admission before a
message's size is known. The transport driver itself occupies one task slot.

Bounded protocol connections use fail-fast admission before outgoing message
conversion/enqueue and before task/dynamic-handler enqueue. Pending requests
are registered under a capped registry lock. Overload is terminal: it wakes the
driver, rejects escaped producer handles, and drops queued/running work. There
is no queue of producer futures awaiting a permit. An accepted bounded
notification that later fails protocol preparation or raw-message conversion
fails the connection; it is not silently discarded. The legacy unbounded path
retains its prior log-and-continue behavior. Internal control messages
also require admission; overload during destructor-originated batch completion
terminates the connection rather than silently stranding the batch.

`BoundedSender::close_channel()` gracefully closes one direction for every
sender clone. It rejects later sends without signaling terminal failure, keeps
already-enqueued transport frames available for drain, and delivers EOF after
the queue drains. The opposite direction remains open. Successful protocol
driver completion likewise preserves admitted transport frames; cancellation,
overload, and driver errors still make the connection terminal.

A `ChargedFrame` owns serialized JSON and its RAII reservation. Dequeueing does
not release that reservation. `try_forward(frame)` transfers ownership; a
cross-budget handoff reserves destination capacity before releasing source
capacity. The protocol actor retains producer charges through conversion and
batch accumulation. Incoming dispatch, deferred messages, and response slots
retain their incoming charges while the core owns their data. A batch is still
one wire frame and must fit the configured maximum including array punctuation.
HTTP adapters must keep the charged frame alive through their declared body
handoff/drop point. **Body consumption is not a peer acknowledgment**: it does
not establish socket flush, peer parsing, ACP dispatch, replay, or exactly-once
delivery.

These are encoded-data and work-count bounds, not a claim about exact process
heap usage. The bounded frame queue stores compact serialized bytes. Logical
outgoing values are normalized with capped serialization and decoding before
queueing, so caller-provided spare `String`/`Vec` capacity is not retained.
Decoded JSON, batch entries, request metadata, and temporary serialization have
additional structural overhead proportional to admitted data; pending request
metadata also has its separately capped entry count. Application-owned values,
future captures, retained copies, and intermediate allocations in
`JsonRpcMessage`/`JsonRpcResponse` conversion are outside this byte budget.
This includes **standard SDK conversion implementations** that clone an untyped
value or construct a raw `serde_json::Value`, not only user-provided hooks.
A conversion holds admission before it runs, but these existing traits can
produce a raw value before the core checks its encoded size. Admission bounds
the number of concurrent conversions; it does not bound a conversion's peak
allocation. The later wire serializer and retained normalized queue values are
capped. Applications needing a hard peak/process-heap limit must also bound
their conversion inputs and implementations.
Transport-specific HTTP body, session, stream, and POST concurrency budgets
remain the responsibility of the HTTP transport, not these core limits.

## Actor Architecture

### Protocol Actors
Expand Down
8 changes: 8 additions & 0 deletions src/agent-client-protocol-http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Added

- Opt-in bounded HTTP/SSE client and server integrated with core bounded
producer admission. Configurable byte/frame, pending-work, POST, connection,
session, and stream limits fail explicitly on exhaustion while preserving
existing constructors and ACP wire shapes. Bounded transports do not provide
SSE replay or automatic POST retries.

## [2.0.0](https://github.com/agentclientprotocol/rust-sdk/compare/agent-client-protocol-http-v1.3.0...agent-client-protocol-http-v2.0.0) - 2026-07-23

### Breaking changes
Expand Down
29 changes: 29 additions & 0 deletions src/agent-client-protocol-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,33 @@ with `CorsOptions::allow_origins(...)` to allow specific browser origins.

Core SDK request cancellation support is forwarded through this transport.

## Opt-in bounded HTTP/SSE

Use `AcpHttpServer::new_bounded(factory, ServerLimits::default())?` and
`HttpClient::new(url)?.with_limits(HttpClientLimits::default())?` to select the
bounded transport path. Existing constructors and `ServerOptions` literals keep
their compatibility behavior; legacy transports remain unbounded.

The bounded server also offers `.with_graceful_delete(Duration)` before
`into_router()`. It seals POST admission without consuming body/frame capacity,
then waits for actual component completion and clean output EOF. DELETE returns
202 only for clean completion; timeout/failure returns 503. Timeout or canceled
DELETE waiters do not abort accepted work or reopen admission. Concurrent DELETE
waiters join the same drain; after completion removes the connection, later
DELETE returns 404. Without this option, DELETE remains abortive.

The bounded path integrates directly with the core `BoundedChannel` and
producer admission. Finite defaults constrain serialized bytes, frame counts,
pending work, POSTs, sessions, and streams. Exhaustion fails explicitly rather
than retaining arbitrarily many waiters. Reservations survive dequeue and
remain held until the documented HTTP handoff or cancellation/drop.

These limits are **not** peer-delivery acknowledgments or hard process-heap
limits. Application conversion allocations, allocator overhead, and
reqwest/TLS/socket buffers are outside encoded-byte accounting. HTTP/SSE is
supported; bounded WebSocket endpoints are rejected. There is no SSE replay,
cursor recovery, or automatic retry of accepted/uncertain POSTs. See the book's
[HTTP transport chapter](https://agentclientprotocol.github.io/rust-sdk/http-transport.html)
for limits, ownership, and overload semantics.

See the [documentation](https://docs.rs/agent-client-protocol-http) for usage examples.
Loading