Skip to content

Commit 09717d5

Browse files
authored
feat: Redesign the APIs to be more intuitive, and make them backward-compatible (#37)
* feat: Expose new APIs for running agent and connect with client Signed-off-by: Frost Ming <me@frostming.com> * fix: allow camelCase for accessing Signed-off-by: Frost Ming <me@frostming.com> * fix: make the docs clearer Signed-off-by: Frost Ming <me@frostming.com> * doc: add a migration guide Signed-off-by: Frost Ming <me@frostming.com> --------- Signed-off-by: Frost Ming <me@frostming.com>
1 parent 856ea0d commit 09717d5

24 files changed

+1090
-554
lines changed

docs/migration-guide-0.7.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Migrating to ACP Python SDK 0.7
2+
3+
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.
4+
5+
## 1. `acp.schema` models now expose `snake_case` fields
6+
7+
- 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.
8+
- Instantiating a model or accessing response values must now use the `snake_case` form:
9+
10+
```python
11+
# Before (0.6 and earlier)
12+
PromptResponse(stopReason="end_turn")
13+
params.sessionId
14+
15+
# After (0.7 and later)
16+
PromptResponse(stop_reason="end_turn")
17+
params.session_id
18+
```
19+
20+
- 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.
21+
- `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).
22+
23+
## 2. `acp.run_agent` and `acp.connect_to_agent` replace manual connection wiring
24+
25+
`AgentSideConnection` and `ClientSideConnection` still exist internally, but the top-level entry points now prefer the helper functions implemented in `src/acp/core.py`.
26+
27+
### Updating agents
28+
29+
- Old pattern:
30+
31+
```python
32+
conn = AgentSideConnection(lambda conn: Agent(), writer, reader)
33+
await asyncio.Event().wait() # keep running
34+
```
35+
36+
- New pattern:
37+
38+
```python
39+
await run_agent(MyAgent(), input_stream=writer, output_stream=reader)
40+
```
41+
42+
- When your agent just runs over stdio, call `await run_agent(MyAgent())` and the helper will acquire asyncio streams via `stdio_streams()` for you.
43+
44+
### Updating clients and tests
45+
46+
- Old pattern:
47+
48+
```python
49+
conn = ClientSideConnection(lambda conn: MyClient(), proc.stdin, proc.stdout)
50+
```
51+
52+
- New pattern:
53+
54+
```python
55+
conn = connect_to_agent(MyClient(), proc.stdin, proc.stdout)
56+
```
57+
58+
- `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.
59+
- 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.
60+
61+
## 3. `Agent` and `Client` interface methods take explicit parameters
62+
63+
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.
64+
65+
### What changed
66+
67+
- Method names follow `snake_case` (`request_permission`, `session_update`, `new_session`, `set_session_model`, etc.).
68+
- Parameters represent the schema fields, so there is no need to unpack `params` manually.
69+
- 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.
70+
71+
### How to update your implementations
72+
73+
1. Rename your method overrides to their `snake_case` equivalents.
74+
2. Replace `params: Model` arguments with the concrete fields plus `**kwargs` to collect future `_meta` keys.
75+
3. Access schema data directly via those parameters.
76+
77+
Example migration for an agent:
78+
79+
```python
80+
# Before
81+
class EchoAgent:
82+
async def prompt(self, params: PromptRequest) -> PromptResponse:
83+
text = params.prompt[0].text
84+
return PromptResponse(stopReason="end_turn")
85+
86+
# After
87+
class EchoAgent:
88+
async def prompt(self, prompt, session_id, **kwargs) -> PromptResponse:
89+
text = prompt[0].text
90+
return PromptResponse(stop_reason="end_turn")
91+
```
92+
93+
Similarly, a client method such as `requestPermission` becomes:
94+
95+
```python
96+
class RecordingClient(Client):
97+
async def request_permission(self, options, session_id, tool_call, **kwargs):
98+
...
99+
```
100+
101+
### Additional notes
102+
103+
- 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.
104+
- For extension points (`field_meta`), pass keyword arguments from the connection into your handler signature: they arrive inside `**kwargs`.
105+
106+
### Backward compatibility
107+
108+
- 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).
109+
- 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.

docs/quickstart.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class SimpleClient(Client):
9090

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

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

124124
- [`examples/echo_agent.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/echo_agent.py) for the smallest streaming agent
125125
- [`examples/agent.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/agent.py) for an implementation that negotiates capabilities and streams richer updates

examples/agent.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
NewSessionResponse,
1212
PromptResponse,
1313
SetSessionModeResponse,
14-
stdio_streams,
14+
run_agent,
1515
text_block,
1616
update_agent_message,
1717
PROTOCOL_VERSION,
1818
)
19+
from acp.interfaces import Client
1920
from acp.schema import (
2021
AgentCapabilities,
2122
AgentMessageChunk,
@@ -33,11 +34,15 @@
3334

3435

3536
class ExampleAgent(Agent):
36-
def __init__(self, conn: AgentSideConnection) -> None:
37-
self._conn = conn
37+
_conn: Client
38+
39+
def __init__(self) -> None:
3840
self._next_session_id = 0
3941
self._sessions: set[str] = set()
4042

43+
def on_connect(self, conn: Client) -> None:
44+
self._conn = conn
45+
4146
async def _send_agent_message(self, session_id: str, content: Any) -> None:
4247
update = content if isinstance(content, AgentMessageChunk) else update_agent_message(content)
4348
await self._conn.session_update(session_id, update)
@@ -114,9 +119,7 @@ async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
114119

115120
async def main() -> None:
116121
logging.basicConfig(level=logging.INFO)
117-
reader, writer = await stdio_streams()
118-
AgentSideConnection(ExampleAgent, writer, reader)
119-
await asyncio.Event().wait()
122+
await run_agent(ExampleAgent())
120123

121124

122125
if __name__ == "__main__":

examples/client.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
from acp import (
1111
Client,
1212
ClientSideConnection,
13-
InitializeRequest,
14-
NewSessionRequest,
15-
PromptRequest,
13+
connect_to_agent,
1614
RequestError,
1715
text_block,
1816
PROTOCOL_VERSION,
@@ -190,7 +188,7 @@ async def main(argv: list[str]) -> int:
190188
return 1
191189

192190
client_impl = ExampleClient()
193-
conn = ClientSideConnection(lambda _agent: client_impl, proc.stdin, proc.stdout)
191+
conn = connect_to_agent(client_impl, proc.stdin, proc.stdout)
194192

195193
await conn.initialize(
196194
protocol_version=PROTOCOL_VERSION,

examples/duet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def main() -> int:
3030
client_module = _load_client_module(root / "client.py")
3131
client = client_module.ExampleClient()
3232

33-
async with spawn_agent_process(lambda _agent: client, sys.executable, str(agent_path), env=env) as (
33+
async with spawn_agent_process(client, sys.executable, str(agent_path), env=env) as (
3434
conn,
3535
process,
3636
):

examples/echo_agent.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
InitializeResponse,
99
NewSessionResponse,
1010
PromptResponse,
11-
stdio_streams,
11+
run_agent,
1212
text_block,
1313
update_agent_message,
1414
)
15+
from acp.interfaces import Client
1516
from acp.schema import (
1617
AudioContentBlock,
1718
ClientCapabilities,
@@ -27,7 +28,9 @@
2728

2829

2930
class EchoAgent(Agent):
30-
def __init__(self, conn: AgentSideConnection) -> None:
31+
_conn: Client
32+
33+
def on_connect(self, conn: Client) -> None:
3134
self._conn = conn
3235

3336
async def initialize(
@@ -67,9 +70,7 @@ async def prompt(
6770

6871

6972
async def main() -> None:
70-
reader, writer = await stdio_streams()
71-
AgentSideConnection(EchoAgent, writer, reader)
72-
await asyncio.Event().wait()
73+
await run_agent(EchoAgent())
7374

7475

7576
if __name__ == "__main__":

examples/gemini.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313

1414
from acp import (
1515
Client,
16-
ClientSideConnection,
16+
connect_to_agent,
1717
PROTOCOL_VERSION,
1818
RequestError,
1919
text_block,
2020
)
21+
from acp.core import ClientSideConnection
2122
from acp.schema import (
2223
AgentMessageChunk,
2324
AgentPlanUpdate,
@@ -316,7 +317,7 @@ async def run(argv: list[str]) -> int:
316317
return 1
317318

318319
client_impl = GeminiClient(auto_approve=args.yolo)
319-
conn = ClientSideConnection(lambda _agent: client_impl, proc.stdin, proc.stdout)
320+
conn = connect_to_agent(client_impl, proc.stdin, proc.stdout)
320321

321322
try:
322323
init_resp = await conn.initialize(

scripts/gen_schema.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77
import subprocess
88
import sys
9+
import textwrap
910
from collections.abc import Callable
1011
from dataclasses import dataclass
1112
from pathlib import Path
@@ -327,11 +328,20 @@ def _ensure_custom_base_model(content: str) -> str:
327328
if not has_config:
328329
new_imports.append("ConfigDict")
329330
lines[idx] = "from pydantic import " + ", ".join(new_imports)
331+
to_insert = textwrap.dedent("""\
332+
class BaseModel(_BaseModel):
333+
model_config = ConfigDict(populate_by_name=True)
334+
335+
def __getattr__(self, item: str) -> Any:
336+
if item.lower() != item:
337+
snake_cased = "".join("_" + c.lower() if c.isupper() and i > 0 else c.lower() for i, c in enumerate(item))
338+
return getattr(self, snake_cased)
339+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{item}'")
340+
""")
330341
insert_idx = idx + 1
331342
lines.insert(insert_idx, "")
332-
lines.insert(insert_idx + 1, "class BaseModel(_BaseModel):")
333-
lines.insert(insert_idx + 2, " model_config = ConfigDict(populate_by_name=True)")
334-
lines.insert(insert_idx + 3, "")
343+
for offset, line in enumerate(to_insert.splitlines(), 1):
344+
lines.insert(insert_idx + offset, line)
335345
break
336346
return "\n".join(lines) + "\n"
337347

src/acp/__init__.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
from typing import Any
2+
13
from .core import (
24
Agent,
3-
AgentSideConnection,
45
Client,
5-
ClientSideConnection,
66
RequestError,
77
TerminalHandle,
8+
connect_to_agent,
9+
run_agent,
810
)
911
from .helpers import (
1012
audio_block,
@@ -73,6 +75,19 @@
7375
from .stdio import spawn_agent_process, spawn_client_process, spawn_stdio_connection, stdio_streams
7476
from .transports import default_environment, spawn_stdio_transport
7577

78+
_DEPRECATED_NAMES = [
79+
(
80+
"AgentSideConnection",
81+
"acp.core:AgentSideConnection",
82+
"Using `AgentSideConnection` directly is deprecated, please use `acp.run_agent` instead.",
83+
),
84+
(
85+
"ClientSideConnection",
86+
"acp.core:ClientSideConnection",
87+
"Using `ClientSideConnection` directly is deprecated, please use `acp.connect_to_agent` instead.",
88+
),
89+
]
90+
7691
__all__ = [ # noqa: RUF022
7792
# constants
7893
"PROTOCOL_VERSION",
@@ -113,8 +128,8 @@
113128
"ReleaseTerminalRequest",
114129
"ReleaseTerminalResponse",
115130
# core
116-
"AgentSideConnection",
117-
"ClientSideConnection",
131+
"run_agent",
132+
"connect_to_agent",
118133
"RequestError",
119134
"Agent",
120135
"Client",
@@ -151,3 +166,16 @@
151166
"start_edit_tool_call",
152167
"update_tool_call",
153168
]
169+
170+
171+
def __getattr__(name: str) -> Any:
172+
import warnings
173+
from importlib import import_module
174+
175+
for deprecated_name, new_path, warning in _DEPRECATED_NAMES:
176+
if name == deprecated_name:
177+
warnings.warn(warning, DeprecationWarning, stacklevel=2)
178+
module_name, attr_name = new_path.split(":")
179+
module = import_module(module_name)
180+
return getattr(module, attr_name)
181+
raise AttributeError(f"module {__name__} has no attribute {name}") # noqa: TRY003

0 commit comments

Comments
 (0)