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
99 changes: 78 additions & 21 deletions docs/experimental-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,40 +5,97 @@

The bindings use `schema-v2.0.0-alpha.5`.

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

Install update handlers before opening a session because updates are independent
connection traffic:

```python
from typing import Any
from acp.experimental import v2

class MyClient:
class MyClient(v2.Client):
async def session_update(
self,
notification: v2.schema.UpdateSessionNotification,
self, session_id: str, update: Any, **kwargs: Any,
) -> None:
handle_update(notification)
handle_update(session_id, update)


connection = v2.connect_to_agent(MyClient(), transport)
initialized = await connection.initialize(
v2.schema.InitializeRequest(
protocol_version=v2.PROTOCOL_VERSION,
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
)
)
session = await connection.new_session(
v2.schema.NewSessionRequest(cwd="/workspace")
protocol_version=v2.PROTOCOL_VERSION,
info=v2.schema.Implementation(name="my-client", version="1.0.0"),
)
session = await connection.new_session(cwd="/workspace")
accepted = await connection.prompt(
v2.schema.PromptRequest(
session_id=session.session_id,
prompt=[v2.schema.TextContentBlock(text="Hello")],
)
session_id=session.session_id,
prompt=[v2.schema.TextContentBlock(text="Hello")],
)
```

Implement an agent with the same expanded handler style:

```python
class MyAgent(v2.Agent):
async def initialize(
self,
protocol_version: int,
info: v2.schema.Implementation,
capabilities: v2.schema.ClientCapabilities | None = None,
**kwargs: Any,
) -> v2.schema.InitializeResponse:
return v2.schema.InitializeResponse(
protocol_version=v2.PROTOCOL_VERSION,
info=v2.schema.Implementation(name="my-agent", version="1.0.0"),
)

async def new_session(
self,
cwd: str,
additional_directories: list[str] | None = None,
mcp_servers: list[Any] | None = None,
**kwargs: Any,
) -> v2.schema.NewSessionResponse:
return v2.schema.NewSessionResponse(session_id="session-1")


await v2.run_agent(MyAgent())
```

`v2.Agent` and `v2.Client` describe the v2 handler signatures; subclassing is
optional. Implement only the methods you support. Unimplemented requests return
method-not-found, and unimplemented notifications are ignored. The v2 protocols
are separate from v1 because initialization, prompt responses, permissions, and
session updates have different contracts. Both versions use `param_model`
metadata to derive their routes. V2 retains strict request/response validation
and requires successful initialization before other traffic.

Previously, v2 methods accepted a whole request model. Replace
`connection.new_session(v2.schema.NewSessionRequest(cwd="/workspace"))` with
`connection.new_session(cwd="/workspace")`, and expand handler parameters likewise.

Union requests also use expanded parameters:

```python
await connection.set_config_option(config_id="thinking", session_id=session.session_id, value=True)
# type defaults to "boolean" for bool values and "id" otherwise.
await connection.set_config_option(
config_id="vendor/limit", session_id=session.session_id, value=10, type="vendor/number",
)
await agent_connection.create_elicitation(
message="Sign in", mode="url", session_id=session.session_id,
elicitation_id="sign-in-1", url="https://example.com/login",
)
```

For elicitation, `session_id` selects session scope; otherwise `request_id`
selects request scope (including `None`). Pass `requested_schema` for form mode,
or `elicitation_id` and `url` for URL mode. Handlers receive the validated
branch's fields, including `type` for config options and `mode` for elicitation.

`session/prompt` returns after the agent inserts the user message into the ACP
conversation, without waiting for processing to finish. The response requires a
non-null `message_id`. Agents return `v2.schema.PromptResponse(message_id=...)`
Expand All @@ -48,7 +105,7 @@ the same ID. That update may arrive before or after the response; use
and do not carry a prompt identifier.

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

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

Setting `replay_from=v2.schema.ReplayFromStartVariant()` on a `ResumeSessionRequest`
Setting `replay_from=v2.schema.ReplayFromStartVariant()` on `connection.resume_session(...)`
requests all retained conversation history; agents need not retain every message.
Accepted elicitation content validates scalar values and string lists; nested
objects are not valid form values.
Expand Down
5 changes: 5 additions & 0 deletions scripts/gen_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def main() -> None:
gen_meta.generate_meta(protocol_version=protocol_version)
if protocol_version == 1:
gen_signature.gen_signature(ROOT / "src" / "acp")
else:
gen_signature.gen_signature(ROOT / "src" / "acp" / "experimental" / "v2", protocol_version=2)
if args.format_output:
format_generated_files(protocol_version)

Expand All @@ -116,6 +118,9 @@ def format_generated_files(protocol_version: int) -> None:
files = [
ROOT / "src" / "acp" / "experimental" / "v2" / "schema.py",
ROOT / "src" / "acp" / "experimental" / "v2" / "meta.py",
ROOT / "src" / "acp" / "experimental" / "v2" / "interfaces.py",
ROOT / "src" / "acp" / "experimental" / "v2" / "agent.py",
ROOT / "src" / "acp" / "experimental" / "v2" / "client.py",
]
subprocess.check_call([sys.executable, "-m", "ruff", "check", "--fix", *(str(path) for path in files)]) # noqa: S603
subprocess.check_call([sys.executable, "-m", "ruff", "format", *(str(path) for path in files)]) # noqa: S603
Expand Down
45 changes: 34 additions & 11 deletions scripts/gen_signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import typing as t
from pathlib import Path

from pydantic import BaseModel
from pydantic import AnyUrl, BaseModel
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined

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


class NodeTransformer(ast.NodeTransformer):
def __init__(self) -> None:
def __init__(self, schema_module: t.Any = None) -> None:
self._schema = schema_module if schema_module is not None else schema
self._qualified_schema: str | None = None
self._type_import_node: ast.ImportFrom | None = None
self._schema_import_node: ast.ImportFrom | None = None
self._literals = {name: value for name, value in schema.__dict__.items() if t.get_origin(value) is t.Literal}
self._literals = {
name: value for name, value in self._schema.__dict__.items() if t.get_origin(value) is t.Literal
}
self._current_model_name: str | None = None
self._type_aliases: dict[str, ast.expr] = {}
self._schema_names: dict[str, str] = {}
Expand Down Expand Up @@ -92,6 +96,9 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST:
)
elif node.module is None:
self._schema_modules.update(alias.asname or alias.name for alias in node.names if alias.name == "schema")
self._qualified_schema = next(
(alias.asname or alias.name for alias in node.names if alias.name == "schema"), None
)
return node

def _single_param_model(self, expression: ast.expr, seen: frozenset[str] = frozenset()) -> t.Any:
Expand All @@ -106,11 +113,11 @@ def _single_param_model(self, expression: ast.expr, seen: frozenset[str] = froze
return None
if name in self._type_aliases:
return self._single_param_model(self._type_aliases[name], seen | {name})
model = getattr(schema, self._schema_names.get(name, name), None)
model = getattr(self._schema, self._schema_names.get(name, name), None)
elif isinstance(expression, ast.Attribute) and isinstance(expression.value, ast.Name):
if expression.value.id not in self._schema_modules:
return None
model = getattr(schema, expression.attr, None)
model = getattr(self._schema, expression.attr, None)
elif isinstance(expression, ast.Subscript):
name = ast.unparse(expression.value)
if name not in self._annotated_names and name != "typing.Annotated":
Expand Down Expand Up @@ -165,7 +172,7 @@ def visit_func(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.AST:
def _to_param_def(self, name: str, field: FieldInfo) -> tuple[ast.arg, ast.expr | None]:
arg = ast.arg(arg=name)
ann = field.annotation
override_optional = (self._current_model_name, name) in SIGNATURE_OPTIONAL_FIELDS
override_optional = self._schema is schema and (self._current_model_name, name) in SIGNATURE_OPTIONAL_FIELDS
if override_optional:
if ann is not None:
ann = ann | None
Expand Down Expand Up @@ -196,10 +203,10 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
elif (
inspect.isclass(annotation)
and issubclass(annotation, BaseModel)
and annotation.__module__ == schema.__name__
and annotation.__module__ == self._schema.__name__
):
self._add_schema_import(annotation.__name__)
return ast.Name(id=annotation.__name__)
return self._schema_reference(annotation.__name__)
elif args := t.get_args(annotation):
return ast.Subscript(
value=self._format_annotation(origin),
Expand All @@ -208,6 +215,11 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
else self._format_annotation(args[0]),
ctx=ast.Load(),
)
return self._format_scalar_annotation(annotation)

def _format_scalar_annotation(self, annotation: t.Any) -> ast.expr:
if annotation is AnyUrl:
return ast.parse("str | AnyUrl", mode="eval").body
elif annotation.__module__ == "typing":
name = annotation.__name__
self._add_typing_import(name)
Expand All @@ -221,11 +233,16 @@ def _format_annotation(self, annotation: t.Any) -> ast.expr:
self._add_typing_import("Any")
return ast.Name(id="Any")

def _schema_reference(self, name: str) -> ast.expr:
if self._qualified_schema:
return ast.Attribute(value=ast.Name(id=self._qualified_schema), attr=name)
return ast.Name(id=name)

def _format_literal(self, annotation: t.Any) -> ast.expr:
if annotation in self._literals.values():
name = next(name for name, value in self._literals.items() if value is annotation)
self._add_schema_import(name)
return ast.Name(id=name)
return self._schema_reference(name)
self._add_typing_import("Literal")
values = [ast.Constant(value=value) for value in t.get_args(annotation)]
return ast.Subscript(
Expand All @@ -235,9 +252,15 @@ def _format_literal(self, annotation: t.Any) -> ast.expr:
)


def gen_signature(source_dir: Path) -> None:
def gen_signature(source_dir: Path, *, protocol_version: int = 1) -> None:
global schema
schema = _load_schema_module()
if protocol_version == 2:
from acp.experimental.v2 import schema as version_schema
else:
version_schema = schema
for source_file in source_dir.rglob("*.py"):
transformer = NodeTransformer()
if protocol_version == 1 and "experimental" in source_file.relative_to(source_dir).parts:
continue
transformer = NodeTransformer(version_schema)
transformer.transform(source_file)
3 changes: 3 additions & 0 deletions src/acp/experimental/v2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from . import schema
from .agent import AgentSideConnection, run_agent
from .client import ClientSideConnection, connect_to_agent
from .interfaces import Agent, Client
from .meta import PROTOCOL_VERSION

__all__ = [
"PROTOCOL_VERSION",
"Agent",
"AgentSideConnection",
"Client",
"ClientSideConnection",
"connect_to_agent",
"run_agent",
Expand Down
Loading
Loading