Skip to content

Commit 537d999

Browse files
committed
refactor(v2): simplify experimental runtime
1 parent 40f67f6 commit 537d999

9 files changed

Lines changed: 95 additions & 509 deletions

File tree

‎docs/experimental-v2.md‎

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,62 +4,43 @@
44
> expect its API and generated models to change with the upstream schema.
55
66
The v2 runtime is separate from the stable v1 API. Its methods accept and return
7-
generated request and response models directly:
7+
generated request and response models directly. Install update handlers on the
8+
client before opening a session because updates are independent connection
9+
traffic:
810

911
```python
1012
from acp.experimental import v2
1113

14+
class MyClient:
15+
async def session_update(
16+
self,
17+
notification: v2.schema.UpdateSessionNotification,
18+
) -> None:
19+
handle_update(notification)
20+
21+
1222
connection = v2.connect_to_agent(MyClient(), transport)
1323
initialized = await connection.initialize(
1424
v2.schema.InitializeRequest(
1525
protocol_version=v2.PROTOCOL_VERSION,
1626
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
1727
)
1828
)
19-
session = await connection.open_session(
29+
session = await connection.new_session(
2030
v2.schema.NewSessionRequest(cwd="/workspace")
2131
)
22-
```
23-
24-
`open_session()` returns an `ActiveSession`. A v2 prompt is accepted before the
25-
agent finishes it, so consume session updates until `SessionStop` marks the
26-
`running` to `idle` transition:
27-
28-
```python
29-
await session.prompt(
32+
await connection.prompt(
3033
v2.schema.PromptRequest(
3134
session_id=session.session_id,
3235
prompt=[v2.schema.TextContentBlock(text="Hello")],
3336
)
3437
)
35-
stopped = await session.wait_for_idle()
3638
```
3739

38-
## Negotiate v1 or v2
39-
40-
Use `ClientNegotiator` when the same client can speak both versions. It sends
41-
exactly one `initialize` request and returns a version-tagged connection:
42-
43-
```python
44-
from acp.experimental import (
45-
ClientNegotiator,
46-
NegotiatedV2,
47-
V1ClientConfig,
48-
V2ClientConfig,
49-
)
50-
51-
negotiator = ClientNegotiator(
52-
transport,
53-
v1=V1ClientConfig(client=v1_client, initialize=v1_initialize),
54-
v2=V2ClientConfig(client=v2_client, initialize=v2_initialize),
55-
)
56-
negotiated = await negotiator.negotiate()
57-
58-
if isinstance(negotiated, NegotiatedV2):
59-
session = await negotiated.connection.open_session(v2_new_session)
60-
else:
61-
session = await negotiated.connection.new_session(cwd="/workspace")
62-
```
40+
`session/prompt` returns when the agent accepts the prompt. It does not define a
41+
boundary for session updates: they may arrive before, during, or after that
42+
request, and they do not carry a prompt identifier. Applications decide how to
43+
buffer or present them.
6344

6445
Agents that serve both versions use `AgentProtocolRouter`:
6546

@@ -74,3 +55,8 @@ The selected runtime remains strict after initialization: v1 messages are not
7455
accepted by a v2 connection, and v2 messages are not translated into v1 calls.
7556
Only the initial v2 request is reduced to the common v1 initialization fields
7657
when an agent selects v1.
58+
59+
Client-side fallback is application controlled and may require opening a new
60+
transport. Protocol-level request cancellation is not yet exposed by the
61+
experimental runtime; `session/cancel` remains available for cancelling active
62+
session work.

‎src/acp/experimental/__init__.py‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,10 @@
44
from .negotiation import (
55
AgentProtocolConnection,
66
AgentProtocolRouter,
7-
ClientNegotiator,
8-
NegotiatedClient,
9-
NegotiatedV1,
10-
NegotiatedV2,
11-
UnsupportedProtocolVersionError,
12-
V1ClientConfig,
13-
V2ClientConfig,
147
)
158

169
__all__ = [
1710
"AgentProtocolConnection",
1811
"AgentProtocolRouter",
19-
"ClientNegotiator",
20-
"NegotiatedClient",
21-
"NegotiatedV1",
22-
"NegotiatedV2",
23-
"UnsupportedProtocolVersionError",
24-
"V1ClientConfig",
25-
"V2ClientConfig",
2612
"v2",
2713
]

‎src/acp/experimental/negotiation.py‎

Lines changed: 0 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22

33
import asyncio
44
from collections.abc import Callable
5-
from dataclasses import dataclass
65
from typing import Any, cast
76

87
from pydantic import BaseModel
98

109
from acp import meta as v1_meta
1110
from acp import schema as v1_schema
1211
from acp.agent.connection import AgentSideConnection as V1AgentSideConnection
13-
from acp.client.connection import ClientSideConnection as V1ClientSideConnection
1412
from acp.connection import Connection, MethodHandler
1513
from acp.exceptions import RequestError
1614
from acp.interfaces import Agent as V1Agent
@@ -20,23 +18,13 @@
2018
from .v2._connection import open_connection
2119
from .v2.agent import AgentFactory as V2AgentFactory
2220
from .v2.agent import AgentSideConnection as V2AgentSideConnection
23-
from .v2.client import ClientFactory as V2ClientFactory
24-
from .v2.client import ClientSideConnection as V2ClientSideConnection
2521

2622
__all__ = [
2723
"AgentProtocolConnection",
2824
"AgentProtocolRouter",
29-
"ClientNegotiator",
30-
"NegotiatedClient",
31-
"NegotiatedV1",
32-
"NegotiatedV2",
33-
"UnsupportedProtocolVersionError",
34-
"V1ClientConfig",
35-
"V2ClientConfig",
3625
]
3726

3827
V1AgentFactory = Callable[[V1Client], V1Agent]
39-
V1ClientFactory = Callable[[V1Agent], V1Client]
4028

4129

4230
def _dump(model: BaseModel) -> dict[str, Any]:
@@ -80,33 +68,6 @@ def _normalize_initialize(params: Any, selected_version: int) -> dict[str, Any]:
8068
return _dump(request)
8169

8270

83-
class _SwitchingHandler:
84-
def __init__(self) -> None:
85-
self._handler: MethodHandler | None = None
86-
self._failure: BaseException | None = None
87-
self._ready = asyncio.Event()
88-
89-
def bind(self, handler: MethodHandler) -> None:
90-
if self._handler is not None or self._failure is not None:
91-
raise RuntimeError("Protocol handler has already been resolved")
92-
self._handler = handler
93-
self._ready.set()
94-
95-
def fail(self, error: BaseException) -> None:
96-
if self._handler is not None:
97-
return
98-
self._failure = error
99-
self._ready.set()
100-
101-
async def __call__(self, method: str, params: Any | None, is_notification: bool) -> Any:
102-
await self._ready.wait()
103-
if self._failure is not None:
104-
raise self._failure
105-
if self._handler is None:
106-
raise RuntimeError("Protocol handler was not resolved")
107-
return await self._handler(method, params, is_notification)
108-
109-
11071
class _AgentNegotiationHandler:
11172
def __init__(
11273
self,
@@ -246,130 +207,3 @@ async def run(
246207
await connection.listen()
247208
finally:
248209
await asyncio.shield(connection.close())
249-
250-
251-
@dataclass(frozen=True, slots=True)
252-
class V1ClientConfig:
253-
client: V1ClientFactory | V1Client
254-
initialize: v1_schema.InitializeRequest
255-
256-
def __post_init__(self) -> None:
257-
if self.initialize.protocol_version != v1_meta.PROTOCOL_VERSION:
258-
raise ValueError(f"V1ClientConfig requires protocol version {v1_meta.PROTOCOL_VERSION}")
259-
260-
261-
@dataclass(frozen=True, slots=True)
262-
class V2ClientConfig:
263-
client: V2ClientFactory | v2.Client
264-
initialize: v2.schema.InitializeRequest
265-
266-
def __post_init__(self) -> None:
267-
if self.initialize.protocol_version != v2.PROTOCOL_VERSION:
268-
raise ValueError(f"V2ClientConfig requires protocol version {v2.PROTOCOL_VERSION}")
269-
270-
271-
@dataclass(frozen=True, slots=True)
272-
class NegotiatedV1:
273-
connection: V1ClientSideConnection
274-
initialize: v1_schema.InitializeResponse
275-
protocol_version: int = v1_meta.PROTOCOL_VERSION
276-
277-
278-
@dataclass(frozen=True, slots=True)
279-
class NegotiatedV2:
280-
connection: V2ClientSideConnection
281-
initialize: v2.schema.InitializeResponse
282-
protocol_version: int = v2.PROTOCOL_VERSION
283-
284-
285-
NegotiatedClient = NegotiatedV1 | NegotiatedV2
286-
287-
288-
class UnsupportedProtocolVersionError(ValueError):
289-
def __init__(self, requested: int, offered: int, supported: frozenset[int]) -> None:
290-
self.requested = requested
291-
self.offered = offered
292-
self.supported = supported
293-
super().__init__(f"Agent selected ACP protocol {offered}; requested {requested}, supported {sorted(supported)}")
294-
295-
296-
class ClientNegotiator:
297-
"""Send one initialize request and return the selected typed client."""
298-
299-
def __init__(
300-
self,
301-
input_stream: Any,
302-
output_stream: Any = None,
303-
*,
304-
v1: V1ClientConfig | None = None,
305-
v2: V2ClientConfig | None = None,
306-
**connection_kwargs: Any,
307-
) -> None:
308-
if v1 is None and v2 is None:
309-
raise ValueError("Configure at least one ACP client version")
310-
self._v1 = v1
311-
self._v2 = v2
312-
self._handler = _SwitchingHandler()
313-
self._connection = open_connection(
314-
self._handler,
315-
input_stream,
316-
output_stream,
317-
**connection_kwargs,
318-
)
319-
self._lock = asyncio.Lock()
320-
self._resolved: NegotiatedClient | None = None
321-
self._failure: BaseException | None = None
322-
323-
async def negotiate(self) -> NegotiatedClient:
324-
async with self._lock:
325-
if self._resolved is not None:
326-
return self._resolved
327-
if self._failure is not None:
328-
raise self._failure
329-
try:
330-
self._resolved = await self._negotiate_once()
331-
except BaseException as error:
332-
self._failure = error
333-
self._handler.fail(error)
334-
await self._connection.close()
335-
raise
336-
return self._resolved
337-
338-
async def _negotiate_once(self) -> NegotiatedClient:
339-
offered_request: BaseModel = (
340-
self._v2.initialize if self._v2 is not None else cast(V1ClientConfig, self._v1).initialize
341-
)
342-
343-
response = await self._connection.send_request(v2.AGENT_METHODS["initialize"], _dump(offered_request))
344-
offered = _read_protocol_version(response)
345-
requested = offered_request.protocol_version
346-
347-
if offered == v2.PROTOCOL_VERSION and self._v2 is not None:
348-
initialize = v2.schema.InitializeResponse.model_validate(response)
349-
connection, handler = V2ClientSideConnection._attach(self._v2.client, self._connection)
350-
connection._complete_initialization(self._v2.initialize, initialize)
351-
self._handler.bind(handler)
352-
return NegotiatedV2(connection, initialize)
353-
if offered == v1_meta.PROTOCOL_VERSION and self._v1 is not None:
354-
initialize = v1_schema.InitializeResponse.model_validate(response)
355-
connection, handler = V1ClientSideConnection._attach(self._v1.client, self._connection)
356-
self._handler.bind(handler)
357-
return NegotiatedV1(connection, initialize)
358-
supported = frozenset(
359-
version
360-
for version, config in (
361-
(v1_meta.PROTOCOL_VERSION, self._v1),
362-
(v2.PROTOCOL_VERSION, self._v2),
363-
)
364-
if config is not None
365-
)
366-
raise UnsupportedProtocolVersionError(requested, offered, supported)
367-
368-
async def close(self) -> None:
369-
await self._connection.close()
370-
371-
async def __aenter__(self) -> ClientNegotiator:
372-
return self
373-
374-
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
375-
await self.close()

‎src/acp/experimental/v2/__init__.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,16 @@
55
from .client import ClientSideConnection, connect_to_agent
66
from .interfaces import Agent, Client
77
from .meta import AGENT_METHODS, CLIENT_METHODS, PROTOCOL_METHODS, PROTOCOL_VERSION
8-
from .session import ActiveSession, SessionMessage, SessionStop, SessionUpdate
98

109
__all__ = [
1110
"AGENT_METHODS",
1211
"CLIENT_METHODS",
1312
"PROTOCOL_METHODS",
1413
"PROTOCOL_VERSION",
15-
"ActiveSession",
1614
"Agent",
1715
"AgentSideConnection",
1816
"Client",
1917
"ClientSideConnection",
20-
"SessionMessage",
21-
"SessionStop",
22-
"SessionUpdate",
2318
"connect_to_agent",
2419
"run_agent",
2520
"schema",

‎src/acp/experimental/v2/agent.py‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
)
2121
from ._router import MethodRouter
2222
from .interfaces import Agent, Client
23-
from .meta import CLIENT_METHODS, PROTOCOL_METHODS
23+
from .meta import CLIENT_METHODS
2424

2525
__all__ = ["AgentSideConnection", "run_agent"]
2626

@@ -37,11 +37,6 @@ def __init__(self, agent: Agent, state: InitializationState) -> None:
3737
self._state = state
3838

3939
async def __call__(self, method: str, params: Any | None, is_notification: bool) -> Any:
40-
if is_notification and method == PROTOCOL_METHODS["cancel_request"]:
41-
if self._state.phase not in {"initializing", "initialized"}:
42-
await self._state.require(method)
43-
return None
44-
4540
initialize = self._router.request_spec("initialize")
4641
if not is_notification and initialize is not None and method == initialize.method:
4742
request = cast(schema.InitializeRequest, initialize.request.validate_python(params))

0 commit comments

Comments
 (0)