Skip to content

Commit 9d07d78

Browse files
authored
refactor(v2)!: expand parameters and derive routes from protocols (#150)
1 parent a11bb6e commit 9d07d78

14 files changed

Lines changed: 1441 additions & 389 deletions

‎docs/experimental-v2.md‎

Lines changed: 78 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,97 @@
55
66
The bindings use `schema-v2.0.0-alpha.5`.
77

8-
The v2 runtime is separate from the stable v1 API. Its methods accept and return
9-
generated request and response models directly. Install update handlers on the
10-
client before opening a session because updates are independent connection
11-
traffic:
8+
The v2 runtime is separate from the stable v1 API. Like v1, connection methods
9+
and agent/client handlers accept expanded, snake-case parameters. Responses and
10+
nested values (content blocks, updates, capabilities) use `v2.schema` models.
11+
Extra keyword arguments carry request `_meta`.
12+
13+
Install update handlers before opening a session because updates are independent
14+
connection traffic:
1215

1316
```python
17+
from typing import Any
1418
from acp.experimental import v2
1519

16-
class MyClient:
20+
class MyClient(v2.Client):
1721
async def session_update(
18-
self,
19-
notification: v2.schema.UpdateSessionNotification,
22+
self, session_id: str, update: Any, **kwargs: Any,
2023
) -> None:
21-
handle_update(notification)
24+
handle_update(session_id, update)
2225

2326

2427
connection = v2.connect_to_agent(MyClient(), transport)
2528
initialized = await connection.initialize(
26-
v2.schema.InitializeRequest(
27-
protocol_version=v2.PROTOCOL_VERSION,
28-
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
29-
)
30-
)
31-
session = await connection.new_session(
32-
v2.schema.NewSessionRequest(cwd="/workspace")
29+
protocol_version=v2.PROTOCOL_VERSION,
30+
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
3331
)
32+
session = await connection.new_session(cwd="/workspace")
3433
accepted = await connection.prompt(
35-
v2.schema.PromptRequest(
36-
session_id=session.session_id,
37-
prompt=[v2.schema.TextContentBlock(text="Hello")],
38-
)
34+
session_id=session.session_id,
35+
prompt=[v2.schema.TextContentBlock(text="Hello")],
36+
)
37+
```
38+
39+
Implement an agent with the same expanded handler style:
40+
41+
```python
42+
class MyAgent(v2.Agent):
43+
async def initialize(
44+
self,
45+
protocol_version: int,
46+
info: v2.schema.Implementation,
47+
capabilities: v2.schema.ClientCapabilities | None = None,
48+
**kwargs: Any,
49+
) -> v2.schema.InitializeResponse:
50+
return v2.schema.InitializeResponse(
51+
protocol_version=v2.PROTOCOL_VERSION,
52+
info=v2.schema.Implementation(name="my-agent", version="1.0.0"),
53+
)
54+
55+
async def new_session(
56+
self,
57+
cwd: str,
58+
additional_directories: list[str] | None = None,
59+
mcp_servers: list[Any] | None = None,
60+
**kwargs: Any,
61+
) -> v2.schema.NewSessionResponse:
62+
return v2.schema.NewSessionResponse(session_id="session-1")
63+
64+
65+
await v2.run_agent(MyAgent())
66+
```
67+
68+
`v2.Agent` and `v2.Client` describe the v2 handler signatures; subclassing is
69+
optional. Implement only the methods you support. Unimplemented requests return
70+
method-not-found, and unimplemented notifications are ignored. The v2 protocols
71+
are separate from v1 because initialization, prompt responses, permissions, and
72+
session updates have different contracts. Both versions use `param_model`
73+
metadata to derive their routes. V2 retains strict request/response validation
74+
and requires successful initialization before other traffic.
75+
76+
Previously, v2 methods accepted a whole request model. Replace
77+
`connection.new_session(v2.schema.NewSessionRequest(cwd="/workspace"))` with
78+
`connection.new_session(cwd="/workspace")`, and expand handler parameters likewise.
79+
80+
Union requests also use expanded parameters:
81+
82+
```python
83+
await connection.set_config_option(config_id="thinking", session_id=session.session_id, value=True)
84+
# type defaults to "boolean" for bool values and "id" otherwise.
85+
await connection.set_config_option(
86+
config_id="vendor/limit", session_id=session.session_id, value=10, type="vendor/number",
87+
)
88+
await agent_connection.create_elicitation(
89+
message="Sign in", mode="url", session_id=session.session_id,
90+
elicitation_id="sign-in-1", url="https://example.com/login",
3991
)
4092
```
4193

94+
For elicitation, `session_id` selects session scope; otherwise `request_id`
95+
selects request scope (including `None`). Pass `requested_schema` for form mode,
96+
or `elicitation_id` and `url` for URL mode. Handlers receive the validated
97+
branch's fields, including `type` for config options and `mode` for elicitation.
98+
4299
`session/prompt` returns after the agent inserts the user message into the ACP
43100
conversation, without waiting for processing to finish. The response requires a
44101
non-null `message_id`. Agents return `v2.schema.PromptResponse(message_id=...)`
@@ -48,7 +105,7 @@ the same ID. That update may arrive before or after the response; use
48105
and do not carry a prompt identifier.
49106

50107
Agents can send `v2.schema.SessionNotice(severity="warning", title="Context is nearly full")`
51-
in an `UpdateSessionNotification`. V2 notices require no client capability and
108+
with `await agent_connection.session_update(session_id=session_id, update=notice)`. V2 notices require no client capability and
52109
are live advisory events, outside retained session history. Clients may ignore
53110
them. Titles must be non-empty, and severity also accepts custom or future strings.
54111

@@ -59,7 +116,7 @@ tool name, while omitting `name` leaves it unchanged. This also applies to
59116
terminal updates and patch metadata. When applying received patches, use
60117
`update.model_dump(by_alias=True, exclude_unset=True)` to retain that distinction.
61118

62-
Setting `replay_from=v2.schema.ReplayFromStartVariant()` on a `ResumeSessionRequest`
119+
Setting `replay_from=v2.schema.ReplayFromStartVariant()` on `connection.resume_session(...)`
63120
requests all retained conversation history; agents need not retain every message.
64121
Accepted elicitation content validates scalar values and string lists; nested
65122
objects are not valid form values.

‎scripts/gen_all.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ def main() -> None:
9494
gen_meta.generate_meta(protocol_version=protocol_version)
9595
if protocol_version == 1:
9696
gen_signature.gen_signature(ROOT / "src" / "acp")
97+
else:
98+
gen_signature.gen_signature(ROOT / "src" / "acp" / "experimental" / "v2", protocol_version=2)
9799
if args.format_output:
98100
format_generated_files(protocol_version)
99101

@@ -116,6 +118,9 @@ def format_generated_files(protocol_version: int) -> None:
116118
files = [
117119
ROOT / "src" / "acp" / "experimental" / "v2" / "schema.py",
118120
ROOT / "src" / "acp" / "experimental" / "v2" / "meta.py",
121+
ROOT / "src" / "acp" / "experimental" / "v2" / "interfaces.py",
122+
ROOT / "src" / "acp" / "experimental" / "v2" / "agent.py",
123+
ROOT / "src" / "acp" / "experimental" / "v2" / "client.py",
119124
]
120125
subprocess.check_call([sys.executable, "-m", "ruff", "check", "--fix", *(str(path) for path in files)]) # noqa: S603
121126
subprocess.check_call([sys.executable, "-m", "ruff", "format", *(str(path) for path in files)]) # noqa: S603

‎scripts/gen_signature.py‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import typing as t
77
from pathlib import Path
88

9-
from pydantic import BaseModel
9+
from pydantic import AnyUrl, BaseModel
1010
from pydantic.fields import FieldInfo
1111
from pydantic_core import PydanticUndefined
1212

@@ -33,10 +33,14 @@ def _load_schema_module() -> t.Any:
3333

3434

3535
class NodeTransformer(ast.NodeTransformer):
36-
def __init__(self) -> None:
36+
def __init__(self, schema_module: t.Any = None) -> None:
37+
self._schema = schema_module if schema_module is not None else schema
38+
self._qualified_schema: str | None = None
3739
self._type_import_node: ast.ImportFrom | None = None
3840
self._schema_import_node: ast.ImportFrom | None = None
39-
self._literals = {name: value for name, value in schema.__dict__.items() if t.get_origin(value) is t.Literal}
41+
self._literals = {
42+
name: value for name, value in self._schema.__dict__.items() if t.get_origin(value) is t.Literal
43+
}
4044
self._current_model_name: str | None = None
4145
self._type_aliases: dict[str, ast.expr] = {}
4246
self._schema_names: dict[str, str] = {}
@@ -92,6 +96,9 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST:
9296
)
9397
elif node.module is None:
9498
self._schema_modules.update(alias.asname or alias.name for alias in node.names if alias.name == "schema")
99+
self._qualified_schema = next(
100+
(alias.asname or alias.name for alias in node.names if alias.name == "schema"), None
101+
)
95102
return node
96103

97104
def _single_param_model(self, expression: ast.expr, seen: frozenset[str] = frozenset()) -> t.Any:
@@ -106,11 +113,11 @@ def _single_param_model(self, expression: ast.expr, seen: frozenset[str] = froze
106113
return None
107114
if name in self._type_aliases:
108115
return self._single_param_model(self._type_aliases[name], seen | {name})
109-
model = getattr(schema, self._schema_names.get(name, name), None)
116+
model = getattr(self._schema, self._schema_names.get(name, name), None)
110117
elif isinstance(expression, ast.Attribute) and isinstance(expression.value, ast.Name):
111118
if expression.value.id not in self._schema_modules:
112119
return None
113-
model = getattr(schema, expression.attr, None)
120+
model = getattr(self._schema, expression.attr, None)
114121
elif isinstance(expression, ast.Subscript):
115122
name = ast.unparse(expression.value)
116123
if name not in self._annotated_names and name != "typing.Annotated":
@@ -165,7 +172,7 @@ def visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
165172
def _to_param_def(self, name: str, field: FieldInfo) -> tuple[ast.arg, ast.expr | None]:
166173
arg = ast.arg(arg=name)
167174
ann = field.annotation
168-
override_optional = (self._current_model_name, name) in SIGNATURE_OPTIONAL_FIELDS
175+
override_optional = self._schema is schema and (self._current_model_name, name) in SIGNATURE_OPTIONAL_FIELDS
169176
if override_optional:
170177
if ann is not None:
171178
ann = ann | None
@@ -196,10 +203,10 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
196203
elif (
197204
inspect.isclass(annotation)
198205
and issubclass(annotation, BaseModel)
199-
and annotation.__module__ == schema.__name__
206+
and annotation.__module__ == self._schema.__name__
200207
):
201208
self._add_schema_import(annotation.__name__)
202-
return ast.Name(id=annotation.__name__)
209+
return self._schema_reference(annotation.__name__)
203210
elif args := t.get_args(annotation):
204211
return ast.Subscript(
205212
value=self._format_annotation(origin),
@@ -208,6 +215,11 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
208215
else self._format_annotation(args[0]),
209216
ctx=ast.Load(),
210217
)
218+
return self._format_scalar_annotation(annotation)
219+
220+
def _format_scalar_annotation(self, annotation: t.Any) -> ast.expr:
221+
if annotation is AnyUrl:
222+
return ast.parse("str | AnyUrl", mode="eval").body
211223
elif annotation.__module__ == "typing":
212224
name = annotation.__name__
213225
self._add_typing_import(name)
@@ -221,11 +233,16 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
221233
self._add_typing_import("Any")
222234
return ast.Name(id="Any")
223235

236+
def _schema_reference(self, name: str) -> ast.expr:
237+
if self._qualified_schema:
238+
return ast.Attribute(value=ast.Name(id=self._qualified_schema), attr=name)
239+
return ast.Name(id=name)
240+
224241
def _format_literal(self, annotation: t.Any) -> ast.expr:
225242
if annotation in self._literals.values():
226243
name = next(name for name, value in self._literals.items() if value is annotation)
227244
self._add_schema_import(name)
228-
return ast.Name(id=name)
245+
return self._schema_reference(name)
229246
self._add_typing_import("Literal")
230247
values = [ast.Constant(value=value) for value in t.get_args(annotation)]
231248
return ast.Subscript(
@@ -235,9 +252,15 @@ def _format_literal(self, annotation: t.Any) -> ast.expr:
235252
)
236253

237254

238-
def gen_signature(source_dir: Path) -> None:
255+
def gen_signature(source_dir: Path, *, protocol_version: int = 1) -> None:
239256
global schema
240257
schema = _load_schema_module()
258+
if protocol_version == 2:
259+
from acp.experimental.v2 import schema as version_schema
260+
else:
261+
version_schema = schema
241262
for source_file in source_dir.rglob("*.py"):
242-
transformer = NodeTransformer()
263+
if protocol_version == 1 and "experimental" in source_file.relative_to(source_dir).parts:
264+
continue
265+
transformer = NodeTransformer(version_schema)
243266
transformer.transform(source_file)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
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
67
from .meta import PROTOCOL_VERSION
78

89
__all__ = [
910
"PROTOCOL_VERSION",
11+
"Agent",
1012
"AgentSideConnection",
13+
"Client",
1114
"ClientSideConnection",
1215
"connect_to_agent",
1316
"run_agent",

0 commit comments

Comments
 (0)