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
109 changes: 109 additions & 0 deletions docs/migration-guide-0.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Migrating to ACP Python SDK 0.7

ACP 0.7 reshapes the public surface so that Python-facing names, runtime helpers, and schema models line up with the evolving Agent Client Protocol schema. This guide covers the major changes in 0.7.0 and calls out the mechanical steps you need to apply in downstream agents, clients, and transports.

## 1. `acp.schema` models now expose `snake_case` fields

- Every generated model in `acp.schema` (see `src/acp/schema.py`) now uses Pythonic attribute names such as `session_id`, `stop_reason`, and `field_meta`. The JSON aliases (e.g., `alias="sessionId"`) stay intact so over-the-wire payloads remain camelCase.
- Instantiating a model or accessing response values must now use the `snake_case` form:

```python
# Before (0.6 and earlier)
PromptResponse(stopReason="end_turn")
params.sessionId

# After (0.7 and later)
PromptResponse(stop_reason="end_turn")
params.session_id
```

- If you relied on `model_dump()` to emit camelCase keys automatically, switch to `model_dump(by_alias=True)` (or use helpers such as `text_block`, `start_tool_call`, etc.) so responses continue to match the protocol.
- `field_meta` stays available for extension data. Any extra keys that were nested under `_meta` should now be provided via keyword arguments when constructing the schema models (see section 3).

## 2. `acp.run_agent` and `acp.connect_to_agent` replace manual connection wiring

`AgentSideConnection` and `ClientSideConnection` still exist internally, but the top-level entry points now prefer the helper functions implemented in `src/acp/core.py`.

### Updating agents

- Old pattern:

```python
conn = AgentSideConnection(lambda conn: Agent(), writer, reader)
await asyncio.Event().wait() # keep running
```

- New pattern:

```python
await run_agent(MyAgent(), input_stream=writer, output_stream=reader)
```

- When your agent just runs over stdio, call `await run_agent(MyAgent())` and the helper will acquire asyncio streams via `stdio_streams()` for you.

### Updating clients and tests

- Old pattern:

```python
conn = ClientSideConnection(lambda conn: MyClient(), proc.stdin, proc.stdout)
```

- New pattern:

```python
conn = connect_to_agent(MyClient(), proc.stdin, proc.stdout)
```

- `spawn_agent_process` / `spawn_client_process` now accept concrete `Agent`/`Client` instances instead of factories that received the connection. Instantiate your implementation first and pass it in.
- Importing the legacy connection classes via `acp.AgentSideConnection` / `acp.ClientSideConnection` issues a `DeprecationWarning` (see `src/acp/__init__.py:82-96`). Update your imports to `run_agent` and `connect_to_agent` to silence the warning.

## 3. `Agent` and `Client` interface methods take explicit parameters

Both interfaces in `src/acp/interfaces.py` now look like idiomatic Python protocols: methods use `snake_case` names and receive the individual schema fields rather than a single request model.

### What changed

- Method names follow `snake_case` (`request_permission`, `session_update`, `new_session`, `set_session_model`, etc.).
- Parameters represent the schema fields, so there is no need to unpack `params` manually.
- Each method is decorated with `@param_model(...)`. Combined with the `compatible_class` helper (see `src/acp/utils.py`), this keeps the camelCase wrappers alive for callers that still pass a full Pydantic request object—but those wrappers now emit `DeprecationWarning`s to encourage migration.

### How to update your implementations

1. Rename your method overrides to their `snake_case` equivalents.
2. Replace `params: Model` arguments with the concrete fields plus `**kwargs` to collect future `_meta` keys.
3. Access schema data directly via those parameters.

Example migration for an agent:

```python
# Before
class EchoAgent:
async def prompt(self, params: PromptRequest) -> PromptResponse:
text = params.prompt[0].text
return PromptResponse(stopReason="end_turn")

# After
class EchoAgent:
async def prompt(self, prompt, session_id, **kwargs) -> PromptResponse:
text = prompt[0].text
return PromptResponse(stop_reason="end_turn")
```

Similarly, a client method such as `requestPermission` becomes:

```python
class RecordingClient(Client):
async def request_permission(self, options, session_id, tool_call, **kwargs):
...
```

### Additional notes

- The connection layers automatically assemble the right request/response models using the `param_model` metadata, so callers do not need to build Pydantic objects manually anymore.
- For extension points (`field_meta`), pass keyword arguments from the connection into your handler signature: they arrive inside `**kwargs`.

### Backward compatibility

- The change should be 100% backward compatible as long as you update your method names and signatures. The `compatible_class` wrapper ensures that existing callers passing full request models continue to work. The old style API will remain functional before the next major release(1.0).
- Because camelCase wrappers remain for now, you can migrate file-by-file while still running against ACP 0.7. Just watch for the new deprecation warnings in your logs/tests.
4 changes: 2 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ class SimpleClient(Client):

async def main() -> None:
script = Path("examples/echo_agent.py")
async with spawn_agent_process(lambda _agent: SimpleClient(), sys.executable, str(script)) as (conn, _proc):
async with spawn_agent_process(SimpleClient(), sys.executable, str(script)) as (conn, _proc):
await conn.initialize(protocol_version=1)
session = await conn.new_session(cwd=str(script.parent), mcp_servers=[])
await conn.prompt(
Expand Down Expand Up @@ -119,7 +119,7 @@ class MyAgent(Agent):
return PromptResponse(stop_reason="end_turn")
```

Hook it up with `AgentSideConnection` inside an async entrypoint and wire it to your client. Refer to:
Run it with `run_agent()` inside an async entrypoint and wire it to your client. Refer to:

- [`examples/echo_agent.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/echo_agent.py) for the smallest streaming agent
- [`examples/agent.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/agent.py) for an implementation that negotiates capabilities and streams richer updates
Expand Down
15 changes: 9 additions & 6 deletions examples/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
NewSessionResponse,
PromptResponse,
SetSessionModeResponse,
stdio_streams,
run_agent,
text_block,
update_agent_message,
PROTOCOL_VERSION,
)
from acp.interfaces import Client
from acp.schema import (
AgentCapabilities,
AgentMessageChunk,
Expand All @@ -33,11 +34,15 @@


class ExampleAgent(Agent):
def __init__(self, conn: AgentSideConnection) -> None:
self._conn = conn
_conn: Client

def __init__(self) -> None:
self._next_session_id = 0
self._sessions: set[str] = set()

def on_connect(self, conn: Client) -> None:
self._conn = conn

async def _send_agent_message(self, session_id: str, content: Any) -> None:
update = content if isinstance(content, AgentMessageChunk) else update_agent_message(content)
await self._conn.session_update(session_id, update)
Expand Down Expand Up @@ -114,9 +119,7 @@ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:

async def main() -> None:
logging.basicConfig(level=logging.INFO)
reader, writer = await stdio_streams()
AgentSideConnection(ExampleAgent, writer, reader)
await asyncio.Event().wait()
await run_agent(ExampleAgent())


if __name__ == "__main__":
Expand Down
6 changes: 2 additions & 4 deletions examples/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
from acp import (
Client,
ClientSideConnection,
InitializeRequest,
NewSessionRequest,
PromptRequest,
connect_to_agent,
RequestError,
text_block,
PROTOCOL_VERSION,
Expand Down Expand Up @@ -190,7 +188,7 @@ async def main(argv: list[str]) -> int:
return 1

client_impl = ExampleClient()
conn = ClientSideConnection(lambda _agent: client_impl, proc.stdin, proc.stdout)
conn = connect_to_agent(client_impl, proc.stdin, proc.stdout)

await conn.initialize(
protocol_version=PROTOCOL_VERSION,
Expand Down
2 changes: 1 addition & 1 deletion examples/duet.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def main() -> int:
client_module = _load_client_module(root / "client.py")
client = client_module.ExampleClient()

async with spawn_agent_process(lambda _agent: client, sys.executable, str(agent_path), env=env) as (
async with spawn_agent_process(client, sys.executable, str(agent_path), env=env) as (
conn,
process,
):
Expand Down
11 changes: 6 additions & 5 deletions examples/echo_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
InitializeResponse,
NewSessionResponse,
PromptResponse,
stdio_streams,
run_agent,
text_block,
update_agent_message,
)
from acp.interfaces import Client
from acp.schema import (
AudioContentBlock,
ClientCapabilities,
Expand All @@ -27,7 +28,9 @@


class EchoAgent(Agent):
def __init__(self, conn: AgentSideConnection) -> None:
_conn: Client

def on_connect(self, conn: Client) -> None:
self._conn = conn

async def initialize(
Expand Down Expand Up @@ -67,9 +70,7 @@ async def prompt(


async def main() -> None:
reader, writer = await stdio_streams()
AgentSideConnection(EchoAgent, writer, reader)
await asyncio.Event().wait()
await run_agent(EchoAgent())


if __name__ == "__main__":
Expand Down
5 changes: 3 additions & 2 deletions examples/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@

from acp import (
Client,
ClientSideConnection,
connect_to_agent,
PROTOCOL_VERSION,
RequestError,
text_block,
)
from acp.core import ClientSideConnection
from acp.schema import (
AgentMessageChunk,
AgentPlanUpdate,
Expand Down Expand Up @@ -316,7 +317,7 @@ async def run(argv: list[str]) -> int:
return 1

client_impl = GeminiClient(auto_approve=args.yolo)
conn = ClientSideConnection(lambda _agent: client_impl, proc.stdin, proc.stdout)
conn = connect_to_agent(client_impl, proc.stdin, proc.stdout)

try:
init_resp = await conn.initialize(
Expand Down
16 changes: 13 additions & 3 deletions scripts/gen_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import subprocess
import sys
import textwrap
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -327,11 +328,20 @@ def _ensure_custom_base_model(content: str) -> str:
if not has_config:
new_imports.append("ConfigDict")
lines[idx] = "from pydantic import " + ", ".join(new_imports)
to_insert = textwrap.dedent("""\
class BaseModel(_BaseModel):
model_config = ConfigDict(populate_by_name=True)

def __getattr__(self, item: str) -> Any:
if item.lower() != item:
snake_cased = "".join("_" + c.lower() if c.isupper() and i > 0 else c.lower() for i, c in enumerate(item))
return getattr(self, snake_cased)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{item}'")
""")
insert_idx = idx + 1
lines.insert(insert_idx, "")
lines.insert(insert_idx + 1, "class BaseModel(_BaseModel):")
lines.insert(insert_idx + 2, " model_config = ConfigDict(populate_by_name=True)")
lines.insert(insert_idx + 3, "")
for offset, line in enumerate(to_insert.splitlines(), 1):
lines.insert(insert_idx + offset, line)
break
return "\n".join(lines) + "\n"

Expand Down
36 changes: 32 additions & 4 deletions src/acp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from typing import Any

from .core import (
Agent,
AgentSideConnection,
Client,
ClientSideConnection,
RequestError,
TerminalHandle,
connect_to_agent,
run_agent,
)
from .helpers import (
audio_block,
Expand Down Expand Up @@ -73,6 +75,19 @@
from .stdio import spawn_agent_process, spawn_client_process, spawn_stdio_connection, stdio_streams
from .transports import default_environment, spawn_stdio_transport

_DEPRECATED_NAMES = [
(
"AgentSideConnection",
"acp.core:AgentSideConnection",
"Using `AgentSideConnection` directly is deprecated, please use `acp.run_agent` instead.",
),
(
"ClientSideConnection",
"acp.core:ClientSideConnection",
"Using `ClientSideConnection` directly is deprecated, please use `acp.connect_to_agent` instead.",
),
]

__all__ = [ # noqa: RUF022
# constants
"PROTOCOL_VERSION",
Expand Down Expand Up @@ -113,8 +128,8 @@
"ReleaseTerminalRequest",
"ReleaseTerminalResponse",
# core
"AgentSideConnection",
"ClientSideConnection",
"run_agent",
"connect_to_agent",
"RequestError",
"Agent",
"Client",
Expand Down Expand Up @@ -151,3 +166,16 @@
"start_edit_tool_call",
"update_tool_call",
]


def __getattr__(name: str) -> Any:
import warnings
from importlib import import_module

for deprecated_name, new_path, warning in _DEPRECATED_NAMES:
if name == deprecated_name:
warnings.warn(warning, DeprecationWarning, stacklevel=2)
module_name, attr_name = new_path.split(":")
module = import_module(module_name)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__} has no attribute {name}") # noqa: TRY003
Loading