feat: add initial acp streable http rfc. - #31
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces RFC-0030, which proposes a new StreamableHTTPTransport for ACP agents to align with the Streamable HTTP WebSocket Transport profile using Starlette and uvicorn. The feedback identifies several areas for refinement, including correcting the technical distinction between ASGI servers and frameworks, addressing port inconsistencies with existing transports, and adjusting JSON-RPC error codes to follow idiomatic standards. Additionally, the reviewer requested more detail on how the initialization guard will intercept asynchronous responses to manage connection state.
- Fix Hypercorn terminology: server framework -> ASGI server - Document port default (8080) differs from legacy WebSocketTransport (8765) - Change JSON-RPC error code -32600 -> -32002 for initialize-required state - Add Rejected Options table in Decision Record per RFC rules - Add Initialize Guard implementation notes explaining protocol-layer interception
|
@gemini-code-assist All four review comments have been addressed in commit
All changes are pushed to this branch. Please re-review when convenient. |
There was a problem hiding this comment.
Code Review
This pull request introduces RFC-0030, which proposes a new ACP Streamable HTTP WebSocket Transport based on Starlette and uvicorn to ensure compliance with the ACP specification. The RFC covers connection identification, lifecycle enforcement via an initialization guard, and a phased implementation plan. Review feedback highlights the need to clarify the handling of JSON-RPC notifications before initialization and to resolve contradictions regarding batch request support. Additionally, a technical improvement was suggested for the shutdown sequence to avoid potential concurrency issues when iterating over active connections.
| - Rationale: The transport layer should not duplicate JSON-RPC parsing logic. `Connection._receive_loop()` already parses JSON-RPC messages. The guard intercepts at the protocol layer after parsing. | ||
| - State: `initialized: bool` per connection | ||
| - Behavior: | ||
| - Before initialize: Any request with `method != "initialize"` returns JSON-RPC error `{"jsonrpc":"2.0","error":{"code":-32002,"message":"initialize required"},"id":<request_id>}` |
There was a problem hiding this comment.
The RFC defines the behavior for requests received before initialization but does not specify how notifications should be handled. Since JSON-RPC notifications do not have an id and do not receive a response, the server cannot return the -32002 error code. The RFC should clarify whether such notifications are silently ignored, logged, or if they trigger a connection closure due to a protocol violation.
| - Behavior: | ||
| - Before initialize: Any request with `method != "initialize"` returns JSON-RPC error `{"jsonrpc":"2.0","error":{"code":-32002,"message":"initialize required"},"id":<request_id>}` | ||
| - After successful initialize: All messages pass through unmodified | ||
| - Batch requests: If the first message is a batch, the entire batch is rejected with `-32002` unless all elements are `initialize` (Phase 1 does not support batching) |
There was a problem hiding this comment.
The description of batch request handling is contradictory. If Phase 1 does not support JSON-RPC batching, the RFC should explicitly state that any batch request will be rejected (e.g., with an Invalid Request or Method not found error). The phrase "unless all elements are initialize" implies a level of batch support that conflicts with the statement that batching is not supported. If this design option is being rejected, it should be explicitly marked as 'Rejected' with a reference to the decision to preserve historical context, as per repository rules.
References
- When a design option discussed in an RFC is rejected, explicitly mark it as 'Rejected' with a reference to the decision, rather than removing it, to preserve historical context for future readers.
| await watcher_task | ||
| # Clean up any remaining connections | ||
| # _active_connections: set[AgentSideConnection] maintained by the endpoint handler | ||
| for conn in _active_connections: |
There was a problem hiding this comment.
Iterating directly over _active_connections while awaiting conn.close() is unsafe if the set can be modified concurrently. Since the WebSocket endpoint handlers (which are responsible for removing connections from this set) run in separate tasks that may be finishing during uvicorn's shutdown, this loop could encounter a RuntimeError: Set size changed during iteration. It is safer to iterate over a copy of the set.
| for conn in _active_connections: | |
| for conn in list(_active_connections): |
There was a problem hiding this comment.
This is already addressed in the implementation. The cleanup loop uses list(active_connections) to iterate over a snapshot:
for conn in list(active_connections):
await conn.close()This prevents RuntimeError: Set size changed during iteration by copying the set before iteration. The finally block in handle_acp uses active_connections.discard(conn) which is safe even if the connection was never added.
- Add ACPWebSocketTransport dataclass for streamable-http config - Implement _serve_streamable_http() with Starlette/uvicorn server - Add _StarletteWebSocketReadStream/_StarletteWebSocketWriteStream adapters - Generate Acp-Connection-Id UUID header per connection - Track active connections with graceful cleanup on shutdown - Add initialize guard to AgentSideConnection (-32002 error before init) - Extend ACPPoolServerConfig with transport/host/port fields - Update ACPServer.from_config() to resolve YAML transport config - Extend CLI with --transport streamable-http/--host/--port options - Add deprecation warning for legacy 'websocket' transport literal - Migrate Toad helper in ui.py to use new transport - Add starlette>=0.40 to dependencies - Fix WebSocket accept header format (bytes instead of str) - Add 41 unit/integration/CLI tests for streamable-http transport - Update existing tests to work with initialize guard
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements Phase 1 of the ACP Streamable HTTP WebSocket Transport (RFC-0030), introducing a Starlette-based WebSocket server at /acp. Key changes include the addition of an initialization guard to enforce the protocol handshake, CLI and YAML configuration updates, and the deprecation of the legacy WebSocket transport. Feedback focused on a violation of the ByteReceiveStream contract in the new stream adapters regarding max_bytes handling and buffering. Additionally, recommendations were made to improve type safety, respect encapsulation by avoiding private internal state, and refine message decoding to prevent aggressive whitespace stripping.
| class _StarletteWebSocketReadStream(ByteReceiveStream): | ||
| """Adapter to read from Starlette WebSocket as a ByteReceiveStream.""" | ||
|
|
||
| def __init__(self, websocket: Any) -> None: | ||
| self._websocket = websocket | ||
|
|
||
| async def receive(self, max_bytes: int = 65536) -> bytes: | ||
| try: | ||
| message: str = await self._websocket.receive_text() | ||
| except Exception as e: | ||
| raise anyio.EndOfStream from e | ||
| data = message.encode() | ||
| # Append trailing newline for JSON-RPC line protocol compatibility | ||
| if not data.endswith(b"\n"): | ||
| data += b"\n" | ||
| return data | ||
|
|
||
| async def aclose(self) -> None: | ||
| pass |
There was a problem hiding this comment.
The _StarletteWebSocketReadStream.receive implementation ignores the max_bytes parameter and lacks buffering. This violates the ByteReceiveStream interface contract, which requires receiving at most max_bytes. If the WebSocket frame contains more data than requested, the excess must be buffered for subsequent calls. This inconsistency could lead to buffer overflows or logic errors in protocol parsers that expect the limit to be respected.
class _StarletteWebSocketReadStream(ByteReceiveStream):
"""Adapter to read from Starlette WebSocket as a ByteReceiveStream."""
def __init__(self, websocket: Any) -> None:
self._websocket = websocket
self._buffer = b""
async def receive(self, max_bytes: int = 65536) -> bytes:
if self._buffer:
data = self._buffer[:max_bytes]
self._buffer = self._buffer[max_bytes:]
return data
try:
message: str = await self._websocket.receive_text()
except Exception as e:
raise anyio.EndOfStream from e
data = message.encode()
if not data.endswith(b"\n"):
data += b"\n"
if len(data) > max_bytes:
self._buffer = data[max_bytes:]
return data[:max_bytes]
return data
async def aclose(self) -> None:
passThere was a problem hiding this comment.
Fixed in commit f2feb4a.
Changes made:
- Added
_bufferattribute to_StarletteWebSocketReadStreamto cache excess bytes receive()now checks buffer first before callingreceive_text()- Excess data beyond
max_bytesis stored in buffer for subsequent calls - Added unit test
test_starlette_read_stream_buffers_across_receivesto verify behavior
This respects the ByteReceiveStream interface contract while maintaining compatibility with the JSON-RPC line protocol.
| shutdown = shutdown_event or asyncio.Event() | ||
| active_connections: set[AgentSideConnection] = set() | ||
|
|
||
| async def handle_acp(websocket: Any) -> None: |
There was a problem hiding this comment.
There was a problem hiding this comment.
This is an intentional design choice due to optional dependency constraints.
Constraint: starlette is an optional dependency (only required when using --transport streamable-http). If we import WebSocket at the module level for type hints, users running stdio-only transports would receive ModuleNotFoundError on import.
Current approach: The websocket parameter is typed as Any with the actual type enforced at runtime via duck-typing (Starlette's WebSocket API). Starlette imports are deferred to function scope inside _serve_streamable_http().
Alternative considered: Using TYPE_CHECKING guard:
if TYPE_CHECKING:
from starlette.websockets import WebSocketThis would improve IDE support without runtime impact. However, since handle_acp is a nested closure function inside _serve_streamable_http(), the type annotation would need to reference the import in the outer scope, which adds complexity for minimal gain.
Recommendation: Keep Any for now. If Starlette becomes a required dependency in the future (e.g., when Streamable HTTP Phase 2 lands), we can migrate to proper type annotations.
|
|
||
| try: | ||
| # Wait for shutdown or for the receive loop to end (client disconnect) | ||
| recv_task = conn._conn._recv_task |
There was a problem hiding this comment.
Accessing conn._conn._recv_task violates encapsulation by reaching into private implementation details of the acp library's Connection class. This makes the transport implementation brittle and dependent on internal state that might change in future versions. Consider adding a public method to AgentSideConnection or Connection to wait for the connection lifecycle to end.
There was a problem hiding this comment.
Acknowledged. This is a technical debt item constrained by the acp library architecture.
Constraint: Connection (from acp.connection) is an external library class, not under our control. It does not expose a public API to wait for the receive loop lifecycle. The _recv_task attribute is the only mechanism available to detect when the connection's read loop has terminated.
Mitigation: The access is isolated to a single line in _serve_streamable_http() and is wrapped with isinstance(recv_task, asyncio.Task) and not recv_task.done() guards. If the acp library changes this internal structure in a future version, the isinstance check will fail gracefully (the connection will still close via the finally block).
Long-term: This should be addressed upstream in the acp library by adding a public wait_closed() or lifecycle_task property to Connection. I've noted this as a follow-up item for the acp library maintainers.
- Add buffering to _StarletteWebSocketReadStream.receive to respect max_bytes - Change _StarletteWebSocketWriteStream.send to use rstrip(b'\n') instead of strip() - Add test for buffering behavior across receive calls
No description provided.