Skip to content
Merged
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
3 changes: 3 additions & 0 deletions docs/acpremote.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ framework target still needs adapter dispatch:
acpkit serve examples.pydantic.finance_agent:acp_agent --host 0.0.0.0 --port 8080
```

ACP Kit servers negotiate the `acp.v1` WebSocket subprotocol when a client requests it. Clients
that omit a subprotocol remain supported.

Expose a stdio ACP command:

```bash
Expand Down
16 changes: 16 additions & 0 deletions packages/transports/acpremote/src/acpremote/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
from collections.abc import Sequence
from http import HTTPStatus
from typing import Literal
from urllib.parse import urlsplit
Expand All @@ -11,6 +12,7 @@
from websockets.asyncio.server import Server, ServerConnection, serve
from websockets.datastructures import Headers
from websockets.http11 import Request, Response
from websockets.typing import Subprotocol

from .auth import is_bearer_authorized
from .command import CommandOptions, run_remote_command_connection
Expand All @@ -26,6 +28,16 @@
"serve_stdio_command",
)

_ACP_SUBPROTOCOL = Subprotocol("acp.v1")


def _select_acp_subprotocol(
connection: ServerConnection,
subprotocols: Sequence[Subprotocol],
) -> Subprotocol | None:
del connection
return _ACP_SUBPROTOCOL if _ACP_SUBPROTOCOL in subprotocols else None


async def run_remote_agent_connection(
agent: Agent,
Expand Down Expand Up @@ -86,6 +98,8 @@ async def serve_remote_agent(
close_timeout=resolved_transport.close_timeout,
max_size=resolved_transport.max_size,
max_queue=resolved_transport.max_queue,
subprotocols=[_ACP_SUBPROTOCOL],
select_subprotocol=_select_acp_subprotocol,
)


Expand Down Expand Up @@ -185,6 +199,8 @@ async def serve_stdio_command(
close_timeout=resolved_transport.close_timeout,
max_size=resolved_transport.max_size,
max_queue=resolved_transport.max_queue,
subprotocols=[_ACP_SUBPROTOCOL],
select_subprotocol=_select_acp_subprotocol,
)


Expand Down
24 changes: 24 additions & 0 deletions tests/acpremote/test_phase2.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from websockets.asyncio.client import connect
from websockets.exceptions import InvalidStatus
from websockets.typing import Subprotocol


@dataclass(slots=True)
Expand Down Expand Up @@ -212,6 +213,29 @@ async def test_phase2_http_metadata_and_health_routes_are_served() -> None:
assert missing_body == b"not found\n"


@pytest.mark.asyncio
@pytest.mark.parametrize(
("subprotocols", "expected_subprotocol"),
[([Subprotocol("acp.v1")], "acp.v1"), (None, None)],
)
async def test_phase2_websocket_negotiates_acp_v1_optionally(
subprotocols: list[Subprotocol] | None,
expected_subprotocol: str | None,
) -> None:
server = await serve_acp(cast("Agent", _EchoAgent()))
assert server.sockets is not None
port = next(iter(server.sockets)).getsockname()[1]
try:
async with connect(
f"ws://127.0.0.1:{port}/acp/ws",
subprotocols=subprotocols,
) as websocket:
assert websocket.subprotocol == expected_subprotocol
finally:
server.close()
await server.wait_closed()


@pytest.mark.asyncio
async def test_phase2_websocket_path_and_bearer_auth_are_enforced() -> None:
server = await serve_acp(
Expand Down
Loading