Skip to content

Commit 198c46d

Browse files
authored
feat(v2): add experimental runtime and negotiation (#139)
* feat(v2): add strict experimental runtime * feat(v2): track active session lifecycle * feat(protocol): negotiate experimental protocol versions * docs(v2): document experimental runtime * refactor(v2): simplify experimental runtime * refactor(v2): minimize experimental runtime API * refactor(v2): remove redundant type casts * refactor(v2): remove redundant agent protocol
1 parent 3cc75c9 commit 198c46d

16 files changed

Lines changed: 1607 additions & 11 deletions

docs/experimental-v2.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Experimental Protocol v2
2+
3+
> **Experimental.** Protocol v2 is a draft. Import it from `acp.experimental` and
4+
> expect its API and generated models to change with the upstream schema.
5+
6+
The v2 runtime is separate from the stable v1 API. Its methods accept and return
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:
10+
11+
```python
12+
from acp.experimental import v2
13+
14+
class MyClient:
15+
async def session_update(
16+
self,
17+
notification: v2.schema.UpdateSessionNotification,
18+
) -> None:
19+
handle_update(notification)
20+
21+
22+
connection = v2.connect_to_agent(MyClient(), transport)
23+
initialized = await connection.initialize(
24+
v2.schema.InitializeRequest(
25+
protocol_version=v2.PROTOCOL_VERSION,
26+
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
27+
)
28+
)
29+
session = await connection.new_session(
30+
v2.schema.NewSessionRequest(cwd="/workspace")
31+
)
32+
await connection.prompt(
33+
v2.schema.PromptRequest(
34+
session_id=session.session_id,
35+
prompt=[v2.schema.TextContentBlock(text="Hello")],
36+
)
37+
)
38+
```
39+
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.
44+
45+
Agents that serve both versions use `AgentProtocolRouter`:
46+
47+
```python
48+
from acp.experimental import AgentProtocolRouter
49+
50+
router = AgentProtocolRouter(
51+
v1=lambda connection: V1Agent(connection),
52+
v2=lambda connection: V2Agent(connection),
53+
)
54+
await router.run()
55+
```
56+
57+
The selected factory is called once per connection. Return a fresh agent from
58+
each call to avoid sharing connection state.
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+
68+
The selected runtime remains strict after initialization: v1 messages are not
69+
accepted by a v2 connection, and v2 messages are not translated into v1 calls.
70+
Only the initial v2 request is reduced to the common v1 initialization fields
71+
when an agent selects v1.
72+
73+
Client-side fallback is application controlled and may require opening a new
74+
transport. Protocol-level request cancellation is not yet exposed by the
75+
experimental runtime; `session/cancel` remains available for cancelling active
76+
session work.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ nav:
1212
- Quick Start: quickstart.md
1313
- Use Cases: use-cases.md
1414
- Web Transport (HTTP/WS): web-transport.md
15+
- Experimental Protocol v2: experimental-v2.md
1516
- Experimental Contrib: contrib.md
1617
- Releasing: releasing.md
1718
- 0.11 Migration Guide: migration-guide-0.11.md

src/acp/agent/connection.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import TypeAdapter
88

99
from .._transport import Transport
10-
from ..connection import Connection
10+
from ..connection import Connection, MethodHandler
1111
from ..interfaces import Agent, Client
1212
from ..meta import CLIENT_METHODS
1313
from ..schema import (
@@ -88,8 +88,7 @@ def __init__(
8888
use_unstable_protocol: bool = False,
8989
**connection_kwargs: Any,
9090
) -> None:
91-
agent = to_agent(self) if callable(to_agent) else to_agent
92-
handler = build_agent_router(cast(Agent, agent), use_unstable_protocol=use_unstable_protocol)
91+
agent, handler = self._prepare(to_agent, use_unstable_protocol=use_unstable_protocol)
9392
if isinstance(input_stream, Transport):
9493
if output_stream is not None:
9594
raise TypeError(_AGENT_CONNECTION_ERROR)
@@ -100,6 +99,32 @@ def __init__(
10099
):
101100
raise TypeError(_AGENT_CONNECTION_ERROR)
102101
self._conn = Connection(handler, input_stream, output_stream, listening=listening, **connection_kwargs)
102+
self._notify_connected(agent)
103+
104+
@classmethod
105+
def _attach(
106+
cls,
107+
to_agent: Callable[[Client], Agent] | Agent,
108+
connection: Connection,
109+
*,
110+
use_unstable_protocol: bool = False,
111+
) -> tuple[AgentSideConnection, MethodHandler]:
112+
self = cls.__new__(cls)
113+
agent, handler = self._prepare(to_agent, use_unstable_protocol=use_unstable_protocol)
114+
self._conn = connection
115+
self._notify_connected(agent)
116+
return self, handler
117+
118+
def _prepare(
119+
self,
120+
to_agent: Callable[[Client], Agent] | Agent,
121+
*,
122+
use_unstable_protocol: bool,
123+
) -> tuple[Agent, MethodHandler]:
124+
agent = cast(Agent, to_agent(self) if callable(to_agent) else to_agent)
125+
return agent, build_agent_router(agent, use_unstable_protocol=use_unstable_protocol)
126+
127+
def _notify_connected(self, agent: Agent) -> None:
103128
if on_connect := getattr(agent, "on_connect", None):
104129
on_connect(self)
105130

src/acp/client/connection.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, cast, final
77

88
from .._transport import Transport
9-
from ..connection import Connection
9+
from ..connection import Connection, MethodHandler
1010
from ..exceptions import RequestError
1111
from ..interfaces import Agent, Client
1212
from ..meta import AGENT_METHODS, CLIENT_METHODS
@@ -122,9 +122,7 @@ def __init__(
122122
use_unstable_protocol: bool = False,
123123
**connection_kwargs: Any,
124124
) -> None:
125-
client = to_client(self) if callable(to_client) else to_client
126-
self._session_updates = _SessionUpdateTracker(cast(Client, client))
127-
handler = build_client_router(cast(Client, self._session_updates), use_unstable_protocol=use_unstable_protocol)
125+
client, handler = self._prepare(to_client, use_unstable_protocol=use_unstable_protocol)
128126

129127
if isinstance(input_stream, Transport):
130128
if output_stream is not None:
@@ -136,6 +134,34 @@ def __init__(
136134
):
137135
raise TypeError(_CLIENT_CONNECTION_ERROR)
138136
self._conn = Connection(handler, input_stream, output_stream, **connection_kwargs)
137+
self._notify_connected(client)
138+
139+
@classmethod
140+
def _attach(
141+
cls,
142+
to_client: Callable[[Agent], Client] | Client,
143+
connection: Connection,
144+
*,
145+
use_unstable_protocol: bool = False,
146+
) -> tuple[ClientSideConnection, MethodHandler]:
147+
self = cls.__new__(cls)
148+
client, handler = self._prepare(to_client, use_unstable_protocol=use_unstable_protocol)
149+
self._conn = connection
150+
self._notify_connected(client)
151+
return self, handler
152+
153+
def _prepare(
154+
self,
155+
to_client: Callable[[Agent], Client] | Client,
156+
*,
157+
use_unstable_protocol: bool,
158+
) -> tuple[Client, MethodHandler]:
159+
client = cast(Client, to_client(self) if callable(to_client) else to_client)
160+
self._session_updates = _SessionUpdateTracker(client)
161+
handler = build_client_router(cast(Client, self._session_updates), use_unstable_protocol=use_unstable_protocol)
162+
return client, handler
163+
164+
def _notify_connected(self, client: Client) -> None:
139165
if on_connect := getattr(client, "on_connect", None):
140166
on_connect(self)
141167

src/acp/experimental/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,13 @@
11
"""Experimental ACP APIs."""
2+
3+
from . import v2
4+
from .negotiation import (
5+
AgentProtocolConnection,
6+
AgentProtocolRouter,
7+
)
8+
9+
__all__ = [
10+
"AgentProtocolConnection",
11+
"AgentProtocolRouter",
12+
"v2",
13+
]

0 commit comments

Comments
 (0)