Skip to content

Commit c69de6e

Browse files
committed
refactor(v2): minimize experimental runtime API
1 parent 537d999 commit c69de6e

12 files changed

Lines changed: 293 additions & 188 deletions

docs/experimental-v2.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,24 @@ Agents that serve both versions use `AgentProtocolRouter`:
4747
```python
4848
from acp.experimental import AgentProtocolRouter
4949

50-
router = AgentProtocolRouter(v1=v1_agent, v2=v2_agent)
50+
router = AgentProtocolRouter(
51+
v1=lambda connection: V1Agent(connection),
52+
v2=lambda connection: V2Agent(connection),
53+
)
5154
await router.run()
5255
```
5356

57+
The factories create a fresh agent for each connection, so connection state is
58+
never shared accidentally.
59+
60+
Extension method names are explicit and must include the protocol-required `_`
61+
prefix:
62+
63+
```python
64+
result = await connection.send_extension_request("_vendor/method", {"value": 1})
65+
await connection.send_extension_notification("_vendor/event", {"value": 1})
66+
```
67+
5468
The selected runtime remains strict after initialization: v1 messages are not
5569
accepted by a v2 connection, and v2 messages are not translated into v1 calls.
5670
Only the initial v2 request is reduced to the common v1 initialization fields

src/acp/experimental/negotiation.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@
1212
from acp.connection import Connection, MethodHandler
1313
from acp.exceptions import RequestError
1414
from acp.interfaces import Agent as V1Agent
15-
from acp.interfaces import Client as V1Client
1615

1716
from . import v2
1817
from .v2._connection import open_connection
19-
from .v2.agent import AgentFactory as V2AgentFactory
2018
from .v2.agent import AgentSideConnection as V2AgentSideConnection
19+
from .v2.meta import AGENT_METHODS as V2_AGENT_METHODS
2120

2221
__all__ = [
2322
"AgentProtocolConnection",
2423
"AgentProtocolRouter",
2524
]
2625

27-
V1AgentFactory = Callable[[V1Client], V1Agent]
26+
V1AgentFactory = Callable[[V1AgentSideConnection], V1Agent]
27+
V2AgentFactory = Callable[[V2AgentSideConnection], v2.Agent]
2828

2929

3030
def _dump(model: BaseModel) -> dict[str, Any]:
@@ -71,8 +71,8 @@ def _normalize_initialize(params: Any, selected_version: int) -> dict[str, Any]:
7171
class _AgentNegotiationHandler:
7272
def __init__(
7373
self,
74-
v1_agent: V1AgentFactory | V1Agent | None,
75-
v2_agent: V2AgentFactory | v2.Agent | None,
74+
v1_agent: V1AgentFactory | None,
75+
v2_agent: V2AgentFactory | None,
7676
) -> None:
7777
self._v1_agent = v1_agent
7878
self._v2_agent = v2_agent
@@ -88,13 +88,13 @@ async def __call__(self, method: str, params: Any | None, is_notification: bool)
8888
async with self._lock:
8989
if self._selected is None:
9090
return await self._initialize(method, params, is_notification)
91-
if not is_notification and method == v2.AGENT_METHODS["initialize"]:
91+
if not is_notification and method == V2_AGENT_METHODS["initialize"]:
9292
raise RequestError.invalid_request({"details": "ACP connections may only be initialized once"})
9393
handler = self._selected
9494
return await handler(method, params, is_notification)
9595

9696
async def _initialize(self, method: str, params: Any, is_notification: bool) -> Any:
97-
if is_notification or method != v2.AGENT_METHODS["initialize"]:
97+
if is_notification or method != V2_AGENT_METHODS["initialize"]:
9898
raise RequestError.invalid_request({"details": "The first ACP request must be initialize"})
9999
requested = _read_protocol_version(params)
100100
selected = self._select(requested)
@@ -139,7 +139,7 @@ class AgentProtocolConnection:
139139
def __init__(self, connection: Connection) -> None:
140140
self._connection = connection
141141

142-
async def listen(self) -> None:
142+
async def _listen(self) -> None:
143143
await self._connection.main_loop()
144144

145145
async def close(self) -> None:
@@ -158,15 +158,23 @@ class AgentProtocolRouter:
158158
def __init__(
159159
self,
160160
*,
161-
v1: V1AgentFactory | V1Agent | None = None,
162-
v2: V2AgentFactory | v2.Agent | None = None,
161+
v1: V1AgentFactory | None = None,
162+
v2: V2AgentFactory | None = None,
163163
) -> None:
164164
if v1 is None and v2 is None:
165165
raise ValueError("Configure at least one ACP protocol implementation")
166166
self._v1 = v1
167167
self._v2 = v2
168168

169169
def connect(
170+
self,
171+
input_stream: Any,
172+
output_stream: Any = None,
173+
**connection_kwargs: Any,
174+
) -> AgentProtocolConnection:
175+
return self._connect(input_stream, output_stream, **connection_kwargs)
176+
177+
def _connect(
170178
self,
171179
input_stream: Any,
172180
output_stream: Any = None,
@@ -197,13 +205,13 @@ async def run(
197205
from acp.stdio import stdio_streams
198206

199207
output_stream, input_stream = await stdio_streams(limit=stdio_buffer_limit_bytes)
200-
connection = self.connect(
208+
connection = self._connect(
201209
input_stream,
202210
output_stream,
203211
listening=False,
204212
**connection_kwargs,
205213
)
206214
try:
207-
await connection.listen()
215+
await connection._listen()
208216
finally:
209217
await asyncio.shield(connection.close())

src/acp/experimental/v2/__init__.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,13 @@
33
from . import schema
44
from .agent import AgentSideConnection, run_agent
55
from .client import ClientSideConnection, connect_to_agent
6-
from .interfaces import Agent, Client
7-
from .meta import AGENT_METHODS, CLIENT_METHODS, PROTOCOL_METHODS, PROTOCOL_VERSION
6+
from .interfaces import Agent
7+
from .meta import PROTOCOL_VERSION
88

99
__all__ = [
10-
"AGENT_METHODS",
11-
"CLIENT_METHODS",
12-
"PROTOCOL_METHODS",
1310
"PROTOCOL_VERSION",
1411
"Agent",
1512
"AgentSideConnection",
16-
"Client",
1713
"ClientSideConnection",
1814
"connect_to_agent",
1915
"run_agent",

src/acp/experimental/v2/_initialization.py

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from dataclasses import dataclass
54
from typing import Literal
65

76
from acp.exceptions import RequestError
@@ -12,17 +11,9 @@
1211
InitializationPhase = Literal["uninitialized", "initializing", "initialized", "failed"]
1312

1413

15-
@dataclass(frozen=True, slots=True)
16-
class Initialization:
17-
request: schema.InitializeRequest
18-
response: schema.InitializeResponse
19-
20-
2114
class InitializationState:
2215
def __init__(self) -> None:
2316
self._phase: InitializationPhase = "uninitialized"
24-
self._request: schema.InitializeRequest | None = None
25-
self._initialization: Initialization | None = None
2617
self._failure: BaseException | None = None
2718
self._ready = asyncio.Event()
2819

@@ -38,25 +29,18 @@ def begin(self, request: schema.InitializeRequest) -> None:
3829
"expectedProtocolVersion": PROTOCOL_VERSION,
3930
"receivedProtocolVersion": request.protocol_version,
4031
})
41-
self._request = request.model_copy(deep=True)
4232
self._phase = "initializing"
4333

44-
def complete(self, response: schema.InitializeResponse) -> Initialization:
45-
if self._phase != "initializing" or self._request is None:
34+
def complete(self, response: schema.InitializeResponse) -> None:
35+
if self._phase != "initializing":
4636
raise RequestError.invalid_request({"details": "ACP v2 initialization is not in progress"})
4737
if response.protocol_version != PROTOCOL_VERSION:
4838
raise RequestError.invalid_request({
4939
"expectedProtocolVersion": PROTOCOL_VERSION,
5040
"receivedProtocolVersion": response.protocol_version,
5141
})
52-
initialization = Initialization(
53-
request=self._request.model_copy(deep=True),
54-
response=response.model_copy(deep=True),
55-
)
56-
self._initialization = initialization
5742
self._phase = "initialized"
5843
self._ready.set()
59-
return initialization
6044

6145
def fail(self, error: BaseException) -> None:
6246
if self._phase == "initialized":
@@ -65,11 +49,11 @@ def fail(self, error: BaseException) -> None:
6549
self._failure = error
6650
self._ready.set()
6751

68-
async def initialized(self) -> Initialization:
52+
async def initialized(self) -> None:
6953
if self._phase in {"uninitialized", "initializing"}:
7054
await self._ready.wait()
71-
if self._initialization is not None:
72-
return self._initialization
55+
if self._phase == "initialized":
56+
return
7357
if self._failure is not None:
7458
raise self._failure
7559
raise RequestError.invalid_request({"details": "ACP v2 connection has not been initialized"})

src/acp/experimental/v2/_methods.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def notification(method: str, handler: str, params_type: Any) -> NotificationSpe
109109
schema.PromptResponse,
110110
empty_response=True,
111111
),
112-
request(AGENT_METHODS["mcp_message"], "message_mcp", schema.MessageMcpRequest, Any),
112+
request(AGENT_METHODS["mcp_message"], "mcp_message", schema.MessageMcpRequest, Any),
113113
request(AGENT_METHODS["session_list"], "list_sessions", schema.ListSessionsRequest, schema.ListSessionsResponse),
114114
request(
115115
AGENT_METHODS["session_delete"],
@@ -148,8 +148,8 @@ def notification(method: str, handler: str, params_type: Any) -> NotificationSpe
148148
)
149149

150150
AGENT_NOTIFICATIONS = (
151-
notification(AGENT_METHODS["session_cancel"], "cancel", schema.CancelSessionNotification),
152-
notification(AGENT_METHODS["mcp_message"], "message_mcp", schema.MessageMcpNotification),
151+
notification(AGENT_METHODS["session_cancel"], "cancel_session", schema.CancelSessionNotification),
152+
notification(AGENT_METHODS["mcp_message"], "notify_mcp", schema.MessageMcpNotification),
153153
notification(AGENT_METHODS["document_did_open"], "did_open", schema.DidOpenDocumentNotification),
154154
notification(AGENT_METHODS["document_did_change"], "did_change", schema.DidChangeDocumentNotification),
155155
notification(AGENT_METHODS["document_did_close"], "did_close", schema.DidCloseDocumentNotification),
@@ -167,7 +167,7 @@ def notification(method: str, handler: str, params_type: Any) -> NotificationSpe
167167
schema.RequestPermissionResponse,
168168
),
169169
request(CLIENT_METHODS["mcp_connect"], "connect_mcp", schema.ConnectMcpRequest, schema.ConnectMcpResponse),
170-
request(CLIENT_METHODS["mcp_message"], "message_mcp", schema.MessageMcpRequest, Any),
170+
request(CLIENT_METHODS["mcp_message"], "mcp_message", schema.MessageMcpRequest, Any),
171171
request(
172172
CLIENT_METHODS["mcp_disconnect"],
173173
"disconnect_mcp",
@@ -185,7 +185,7 @@ def notification(method: str, handler: str, params_type: Any) -> NotificationSpe
185185

186186
CLIENT_NOTIFICATIONS = (
187187
notification(CLIENT_METHODS["session_update"], "session_update", schema.UpdateSessionNotification),
188-
notification(CLIENT_METHODS["mcp_message"], "message_mcp", schema.MessageMcpNotification),
188+
notification(CLIENT_METHODS["mcp_message"], "notify_mcp", schema.MessageMcpNotification),
189189
notification(
190190
CLIENT_METHODS["elicitation_complete"],
191191
"complete_elicitation",

src/acp/experimental/v2/_router.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def handle_request(self, spec: RequestSpec, params: Any) -> Any:
3838
async def handle_notification(self, spec: NotificationSpec, params: Any) -> None:
3939
handler = getattr(self._target, spec.handler, None)
4040
if handler is None:
41-
raise RequestError.method_not_found(spec.method)
41+
return
4242
await handler(spec.params.validate_python(params))
4343

4444
async def __call__(self, method: str, params: Any | None, is_notification: bool) -> Any:
@@ -47,7 +47,7 @@ async def __call__(self, method: str, params: Any | None, is_notification: bool)
4747
if is_notification:
4848
spec = self._notifications.get(method)
4949
if spec is None:
50-
raise RequestError.method_not_found(method)
50+
return None
5151
await self.handle_notification(spec, params)
5252
return None
5353
spec = self._requests.get(method)
@@ -56,8 +56,10 @@ async def __call__(self, method: str, params: Any | None, is_notification: bool)
5656
return await self.handle_request(spec, params)
5757

5858
async def _handle_extension(self, method: str, params: Any, is_notification: bool) -> Any:
59-
handler_name = "ext_notification" if is_notification else "ext_method"
59+
handler_name = "handle_extension_notification" if is_notification else "handle_extension_request"
6060
handler = getattr(self._target, handler_name, None)
6161
if handler is None:
62+
if is_notification:
63+
return None
6264
raise RequestError.method_not_found(method)
6365
return await handler(method, params)

0 commit comments

Comments
 (0)