Skip to content

Commit c1004f8

Browse files
authored
refactor: simplify web transports with Starlette (#142)
* refactor: simplify web transports with Starlette * fix: support loading sessions over web transports
1 parent 8375c7c commit c1004f8

17 files changed

Lines changed: 1000 additions & 533 deletions

docs/web-transport.md

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ Both reuse the existing JSON-RPC message format and ACP lifecycle
2121
pip install "agent-client-protocol[http]"
2222
```
2323

24-
This pulls in `httpx[http2]` (HTTP/2 + SSE consumption) and `websockets`.
24+
This pulls in `httpx[http2]` (HTTP/2 + SSE consumption), `websockets`, and
25+
`starlette` (the server application). The core SDK and stdio transport do not
26+
require these optional dependencies.
2527

2628
## Client
2729

@@ -53,10 +55,35 @@ header, then opens the connection-scoped SSE stream. When a new `sessionId`
5355
appears it opens that session-scoped stream too. A single SSE attempt is made per
5456
stream; reconnect/retry is the caller's responsibility (v1 of the RFD).
5557

58+
### Loading an existing session
59+
60+
Both HTTP and WebSocket support `load_session()` when the agent advertises
61+
`loadSession` and implements session persistence:
62+
63+
```python
64+
init = await conn.initialize(protocol_version=1)
65+
if init.agent_capabilities.load_session:
66+
await conn.load_session(session_id="saved-session-id", cwd="/workspace", mcp_servers=[])
67+
await conn.prompt(session_id="saved-session-id", prompt=[...])
68+
```
69+
70+
For HTTP, history replay and the load response use the connection SSE stream.
71+
The client correlates the response with the session ID from the load request,
72+
then opens the session SSE stream for further prompts and agent callbacks. This
73+
also works with an empty history: a load response need not contain `sessionId`.
74+
Replay is consumed as it arrives, so histories larger than the SSE buffer do not
75+
wait for a session stream to open. WebSocket uses its existing bidirectional
76+
connection for both replay and subsequent messages.
77+
78+
A failed load returns its JSON-RPC error on the connection stream and can be
79+
retried. The server removes streams provisioned only for failed loads, while
80+
preserving established sessions and overlapping loads. It does not change the
81+
agent's load response or automatically enable the agent's `loadSession` capability.
82+
5683
## Server
5784

58-
The server core is framework-agnostic; a thin ASGI adapter bridges it to your
59-
web framework:
85+
The server uses Starlette for HTTP requests, responses, routing, streaming,
86+
WebSocket handling, and application lifespan:
6087

6188
```python
6289
from acp.http.asgi import create_asgi_app
@@ -65,8 +92,112 @@ from acp.http.asgi import create_asgi_app
6592
app = create_asgi_app(lambda conn: MyAgent())
6693
```
6794

68-
`app` is a standard ASGI 3.0 application handling `POST`/`GET`/`DELETE` and
69-
WebSocket upgrades on the ACP endpoint.
95+
`app` is a `starlette.applications.Starlette` instance handling
96+
`POST`/`GET`/`DELETE` and WebSocket upgrades at `/acp` by default. Set the
97+
keyword-only `path` argument to use a different endpoint for both transports:
98+
99+
```python
100+
app = create_asgi_app(lambda conn: MyAgent(), path="/rpc")
101+
```
102+
103+
Other paths do not serve ACP. Starlette supplies
104+
`Request`, `JSONResponse`, `StreamingResponse`, and `WebSocket`; the SDK keeps
105+
ACP connection and session routing.
106+
107+
### Mounting in another application
108+
109+
Mount the app at the desired prefix. The parent must enter the child lifespan
110+
so HTTP connections are cleaned up during shutdown (mounted application
111+
lifespans are not run automatically):
112+
113+
```python
114+
from contextlib import asynccontextmanager
115+
from starlette.applications import Starlette
116+
from starlette.routing import Mount
117+
118+
acp_app = create_asgi_app(lambda conn: MyAgent())
119+
120+
@asynccontextmanager
121+
async def lifespan(app):
122+
async with acp_app.router.lifespan_context(acp_app):
123+
yield
124+
125+
app = Starlette(routes=[Mount("/agents", app=acp_app)], lifespan=lifespan)
126+
# Connect to /agents/acp using either HTTP or WebSocket.
127+
```
128+
129+
With `path="/rpc"`, the mounted endpoint is `/agents/rpc`. Use `path="/"` to
130+
serve ACP at the mount root (`/agents/`).
131+
132+
### How the server fits together
133+
134+
Start reading at `acp/http/asgi.py`. It creates Starlette routes, passes parsed
135+
HTTP requests to `AcpServer`, and binds WebSockets in `acp/ws/server.py`. Both use the existing
136+
`AgentSideConnection` and its message-level `Transport` interface:
137+
138+
```text
139+
HTTP POST → _HttpTransport incoming queue → AgentSideConnection → agent
140+
HTTP GET ← StreamingResponse ← SSE buffer ← _HttpTransport.send() ← agent output
141+
142+
Starlette WebSocket ↔ _WebSocketTransport ↔ AgentSideConnection ↔ agent
143+
```
144+
145+
For HTTP, `AcpServer` owns a dictionary of active connections. Each connection
146+
has one incoming queue and one SSE buffer per stream. The incoming queue lets
147+
POST return `202` while the agent handles the request. Output goes directly to
148+
the relevant SSE buffer; there is no intermediate transport pair or pump task.
149+
150+
HTTP output follows these routing rules:
151+
152+
| Message | Destination | Why |
153+
| --- | --- | --- |
154+
| `initialize` response | POST body, via one Future | Establishes the connection before GET streams open |
155+
| Response containing a new `sessionId` | Connection SSE stream | The client needs the ID before it can open the session stream |
156+
| `session/load` replay and response | Connection SSE stream | Replay precedes the response; the client gets the session ID from the original request |
157+
| Other messages | Session SSE stream when known, otherwise connection stream | Responses use their request's recorded session; requests/notifications carry `sessionId` |
158+
159+
`OutboundStream` retains a bounded buffer, backpressure, and close handling.
160+
Idle SSE streams emit keepalives. These support slow readers, streams that open
161+
after messages arrive, and orderly teardown. `DELETE` and server shutdown close
162+
the HTTP connections and cancel their agent work.
163+
164+
WebSocket already provides one bidirectional stream. Its transport adapts
165+
Starlette's socket to JSON-RPC messages; it needs no HTTP connection registry,
166+
session routing, SSE buffers, or multiplex mode. The ASGI handler owns the agent
167+
connection and closes it on socket disconnect or handler cancellation.
168+
169+
### Simplification experiment
170+
171+
The original 80 HTTP, WebSocket, and RPC tests passed after each ablation.
172+
The Starlette migration also passes these behaviors; assertions now inspect
173+
Starlette response objects and WebSocket tests use the framework's socket:
174+
175+
| Stage | Removed | Lines across the three server files |
176+
| --- | --- | ---: |
177+
| Baseline || 715 |
178+
| First ablation | `ConnectionRegistry`, WebSocket multiplex mode, WebSocket pump tasks, forwarding-only ASGI method | 647 |
179+
| Second ablation | HTTP memory transport pair and pump, `ConnectionState`, generic response-waiter map | 581 |
180+
| Starlette migration | Custom ASGI app, request/header parsing, response encoding, WebSocket state tracking, `PostResult` | 483 |
181+
182+
This measures structural simplification and regression coverage, not throughput
183+
or latency. Additional tests cover interrupted initialization, closing a full
184+
SSE buffer, WebSocket cancellation/disconnect, invalid frames, and session routing
185+
of concurrent success/error responses.
186+
187+
`create_asgi_app(agent_factory, *, path="/acp")` returns a Starlette application.
188+
The default route is now `/acp`, replacing the earlier catch-all route.
189+
`AcpServer.handle_post()` and `handle_delete()` return
190+
Starlette responses (`status_code`, byte `body`, and case-insensitive `headers`).
191+
`open_stream()` and `close()` keep their signatures.
192+
The experimental `AcpAsgiApp` and `PostResult` wrappers were removed, along with
193+
`ConnectionRegistry`, `ConnectionState`, `AcpServer.registry`, and
194+
`create_websocket_connection()`. Direct WebSocket integrations now use
195+
`handle_websocket(agent_factory, websocket)` with a Starlette `WebSocket`.
196+
WebSocket lifetimes belong to their ASGI handlers; `AcpServer.close()` manages
197+
HTTP connections.
198+
199+
The migration adds checks for HTTP error statuses, unsupported methods, mounting,
200+
lifespan cleanup, and reopening an SSE stream after disconnect.
70201

71202
### HTTP/2 server requirement
72203

examples/http_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def prompt(self, session_id: str, prompt: list[Any], **kwargs: Any) -> Pro
5959
return PromptResponse(stop_reason="end_turn")
6060

6161

62-
# One agent instance per connection.
62+
# A Starlette application with one agent instance per connection.
6363
app = create_asgi_app(lambda conn: EchoAgent())
6464

6565

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,13 @@ dev = [
4848
"httpx[http2]>=0.27",
4949
"websockets>=12.0",
5050
"uvicorn>=0.30",
51+
"starlette>=0.49.3",
5152
]
5253

5354
[project.optional-dependencies]
5455
logfire = ["logfire>=0.14", "opentelemetry-sdk>=1.28.0"]
5556
# Experimental remote transports (Streamable HTTP + WebSocket), client + server.
56-
http = ["httpx[http2]>=0.27", "websockets>=12.0"]
57+
http = ["httpx[http2]>=0.27", "websockets>=12.0", "starlette>=0.49.3"]
5758

5859
[build-system]
5960
requires = ["pdm-backend"]

src/acp/_transport.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
The existing stdio path is re-expressed on top of this seam via
99
:class:`NdjsonTransport`, which wraps the current byte-stream framing so there
1010
is **zero behaviour change** for stdio users. :func:`memory_transport_pair`
11-
gives two linked in-memory transports, used by the HTTP/WS server to bind an
12-
``AgentSideConnection`` to its message pump.
11+
gives two linked in-memory transports for in-process connections and tests.
1312
"""
1413

1514
from __future__ import annotations
@@ -140,9 +139,7 @@ def memory_transport_pair() -> tuple[Transport, Transport]:
140139
"""Return two linked in-memory transports.
141140
142141
A message ``send`` on one end becomes available via ``receive`` on the
143-
other. Closing an end enqueues an EOF (``None``) for its peer. This mirrors
144-
the ``TransformStream`` pair the TypeScript SDK uses to bind a server-side
145-
connection to its HTTP/WS message pump.
142+
other. Closing an end enqueues an EOF (``None``) for its peer.
146143
"""
147144
a_to_b: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
148145
b_to_a: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()

src/acp/http/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Streamable HTTP transport for ACP (experimental).
22
33
Public exports are import-guarded: the heavy client/server implementations pull
4-
in optional dependencies (``httpx[http2]``). Importing a symbol without the
4+
in optional dependencies (``httpx[http2]`` and ``starlette``). Importing a symbol without the
55
extra installed raises a friendly ``ImportError`` pointing at
66
``pip install agent-client-protocol[http]``.
77
"""

0 commit comments

Comments
 (0)