diff --git a/docs/quickstart.md b/docs/quickstart.md index 5e79205..79a5a0e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -16,7 +16,7 @@ Spin up a working ACP agent/client loop in minutes. Keep this page beside the te - Python 3.10–3.14 with `pip` or `uv` - An ACP-capable client such as Zed (recommended for validation) -- Optional: the Gemini CLI (`gemini --experimental-acp`) for the bridge example +- Optional: the Gemini CLI (`gemini --acp`; use `--experimental-acp` for older versions) for the bridge example ## Step 1 — Install the SDK @@ -145,7 +145,7 @@ Run it with `run_agent()` inside an async entrypoint and wire it to your client. - [`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 - [`examples/duet.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/duet.py) to see `spawn_agent_process` in action alongside the interactive client -- [`examples/gemini.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/gemini.py) to drive the Gemini CLI (`--acp`) directly from Python +- [`examples/gemini.py`](https://github.com/agentclientprotocol/python-sdk/blob/main/examples/gemini.py) to drive the Gemini CLI (`--acp`; use `--experimental-acp` for older versions) directly from Python Need builders for common payloads? `acp.helpers` mirrors the Go/TS helper APIs: diff --git a/examples/gemini.py b/examples/gemini.py index bc824af..85c862f 100644 --- a/examples/gemini.py +++ b/examples/gemini.py @@ -328,7 +328,8 @@ async def run(argv: list[str]) -> int: # noqa: C901 *cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, - stderr=None, + # Suppress Gemini CLI diagnostic noise unless debugging + stderr=None if args.debug else asyncio.subprocess.DEVNULL, ) except FileNotFoundError as exc: print(f"Failed to start Gemini CLI: {exc}", file=sys.stderr) @@ -412,6 +413,10 @@ def _print_request_error(stage: str, err: RequestError) -> None: async def _shutdown(proc: asyncio.subprocess.Process, conn: ClientSideConnection) -> None: with contextlib.suppress(Exception): await asyncio.wait_for(conn.close(), timeout=2) + if proc.stdin is not None: + with contextlib.suppress(Exception): + proc.stdin.close() + await asyncio.wait_for(proc.stdin.wait_closed(), timeout=2) if proc.returncode is None: proc.terminate() try: @@ -424,7 +429,10 @@ async def _shutdown(proc: asyncio.subprocess.Process, conn: ClientSideConnection def main(argv: list[str] | None = None) -> int: args = sys.argv if argv is None else argv - return asyncio.run(run(list(args))) + try: + return asyncio.run(run(list(args))) + except KeyboardInterrupt: + return 1 if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index e8eb93d..eaab0e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-client-protocol" -version = "0.10.0" +version = "0.10.1" description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)" authors = [ { name = "Chojan Shang", email = "psiace@apache.org" }, diff --git a/src/acp/connection.py b/src/acp/connection.py index 32f150e..ff1cb19 100644 --- a/src/acp/connection.py +++ b/src/acp/connection.py @@ -86,6 +86,8 @@ def __init__( self._closed = False self._disconnected = False self._sender = (sender_factory or self._default_sender_factory)(self._writer, self._tasks) + self._observers: list[StreamObserver] = list(observers or []) + self._receive_timeout = receive_timeout if listening: self._recv_task = self._tasks.create( self._receive_loop(), @@ -103,8 +105,6 @@ def __init__( self._run_notification, ) self._dispatcher.start() - self._observers: list[StreamObserver] = list(observers or []) - self._receive_timeout = receive_timeout async def close(self) -> None: """Stop the receive loop and cancel any in-flight handler tasks.""" diff --git a/tests/test_rpc.py b/tests/test_rpc.py index ecacaa5..be5e06c 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -676,3 +676,32 @@ async def test_spawn_agent_process_roundtrip(tmp_path): assert test_client.notifications assert process.returncode is not None + + +@pytest.mark.asyncio +async def test_connection_init_under_eager_task_factory(server): + eager_task_factory = getattr(asyncio, "eager_task_factory", None) + if eager_task_factory is None: + pytest.skip("asyncio.eager_task_factory requires Python 3.12+") + + # Regression: under asyncio.eager_task_factory the receive loop runs synchronously + # up to its first await inside Connection.__init__, so every attribute it reads + # (e.g. _receive_timeout) must be assigned before _tasks.create(_receive_loop()). + loop = asyncio.get_running_loop() + previous_factory = loop.get_task_factory() + loop.set_task_factory(eager_task_factory) + try: + conn = Connection( + lambda method, params, is_notification: None, + server.client_writer, + server.client_reader, + receive_timeout=0.5, + ) + finally: + loop.set_task_factory(previous_factory) + + assert conn._receive_timeout == 0.5 + # Let the loop tick once so any deferred receive-task crash would land. + await asyncio.sleep(0) + assert conn._disconnected is False + await conn.close() diff --git a/uv.lock b/uv.lock index 8ac1c1f..c5f8a66 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10, <3.15" [[package]] name = "agent-client-protocol" -version = "0.10.0" +version = "0.10.1" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -424,11 +424,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1081,24 +1081,24 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pymdown-extensions" -version = "10.16.1" +version = "10.21.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] [[package]] @@ -1503,11 +1503,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]