Skip to content

feat: add initial acp streable http rfc. - #31

Merged
Leoyzen merged 5 commits into
develop/agenticfrom
feat/acp-streamable-http-websocket-transport
May 22, 2026
Merged

feat: add initial acp streable http rfc.#31
Leoyzen merged 5 commits into
develop/agenticfrom
feat/acp-streamable-http-websocket-transport

Conversation

@Leoyzen

@Leoyzen Leoyzen commented May 19, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/rfcs/draft/RFC-0030-acp-streamable-http-websocket-transport.md Outdated
Comment thread docs/rfcs/draft/RFC-0030-acp-streamable-http-websocket-transport.md
Comment thread docs/rfcs/draft/RFC-0030-acp-streamable-http-websocket-transport.md Outdated
Comment thread docs/rfcs/draft/RFC-0030-acp-streamable-http-websocket-transport.md
- 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
@Leoyzen

Leoyzen commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

@gemini-code-assist All four review comments have been addressed in commit ec743ed:

  1. Hypercorn terminology ✅ — Changed "another server framework" to "another ASGI server" in Option 1 Disadvantages.

  2. Port default inconsistency ✅ — Added documentation in ACPWebSocketTransport docstring explaining that 8080 (standard HTTP port) differs from the legacy WebSocketTransport default 8765, including a migration note for --ws-port users.

  3. JSON-RPC error code ✅ — Changed all occurrences of -32600 to -32002 (Success Criteria, Initialize Guard description, Initialize Lifecycle Flow diagram). Added a Rejected Options table in the Decision Record per repository RFC rules, documenting why -32600 was rejected.

  4. Initialize Guard implementation ✅ — Added Implementation notes under Initialize Guard clarifying that the guard operates at the protocol layer (on parsed JSON-RPC request/response objects, not raw bytes), avoiding stream adapter complexity. The guard wraps the agent's initialize() handler and flips state on successful InitializeResponse return.

All changes are pushed to this branch. Please re-review when convenient.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
for conn in _active_connections:
for conn in list(_active_connections):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@Leoyzen

Leoyzen commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/acp/transports.py
Comment on lines +378 to +396
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:
        pass

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit f2feb4a.

Changes made:

  • Added _buffer attribute to _StarletteWebSocketReadStream to cache excess bytes
  • receive() now checks buffer first before calling receive_text()
  • Excess data beyond max_bytes is stored in buffer for subsequent calls
  • Added unit test test_starlette_read_stream_buffers_across_receives to verify behavior

This respects the ByteReceiveStream interface contract while maintaining compatibility with the JSON-RPC line protocol.

Comment thread src/acp/transports.py
shutdown = shutdown_event or asyncio.Event()
active_connections: set[AgentSideConnection] = set()

async def handle_acp(websocket: Any) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The websocket parameter should be type-hinted as starlette.websockets.WebSocket instead of Any to improve type safety and enable better IDE support.

Suggested change
async def handle_acp(websocket: Any) -> None:
async def handle_acp(websocket: starlette.websockets.WebSocket) -> None:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 WebSocket

This 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.

Comment thread src/acp/transports.py

try:
# Wait for shutdown or for the receive loop to end (client disconnect)
recv_task = conn._conn._recv_task

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/acp/transports.py Outdated
Leoyzen added 2 commits May 22, 2026 14:52
- 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
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