Skip to content

Commit 5ae0734

Browse files
committed
refactor: simplify web transports with Starlette
1 parent 8375c7c commit 5ae0734

14 files changed

Lines changed: 760 additions & 528 deletions

File tree

docs/web-transport.md

Lines changed: 110 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

@@ -55,8 +57,8 @@ stream; reconnect/retry is the caller's responsibility (v1 of the RFD).
5557

5658
## Server
5759

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

6163
```python
6264
from acp.http.asgi import create_asgi_app
@@ -65,8 +67,111 @@ from acp.http.asgi import create_asgi_app
6567
app = create_asgi_app(lambda conn: MyAgent())
6668
```
6769

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

71176
### HTTP/2 server requirement
72177

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)