diff --git a/README.md b/README.md index ea85a9d..ede9990 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A Python implementation of the Agent Client Protocol (ACP). Use it to build agen - Package name: `agent-client-protocol` (import as `acp`) - Repository: https://github.com/psiace/agent-client-protocol-python - Docs: https://psiace.github.io/agent-client-protocol-python/ +- Featured: Listed as the first third-party SDK on the official ACP site — see https://agentclientprotocol.com/libraries/community ## Install @@ -24,51 +25,54 @@ make test # run tests ## Minimal agent example +See a complete streaming echo example in [examples/echo_agent.py](examples/echo_agent.py). It streams back each text block using `session/update` and ends the turn. + ```python import asyncio from acp import ( Agent, AgentSideConnection, - AuthenticateRequest, - CancelNotification, InitializeRequest, InitializeResponse, - LoadSessionRequest, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionNotification, stdio_streams, ) +from acp.schema import ContentBlock1, SessionUpdate2 class EchoAgent(Agent): + def __init__(self, conn): + self._conn = conn + async def initialize(self, params: InitializeRequest) -> InitializeResponse: return InitializeResponse(protocolVersion=params.protocolVersion) async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: return NewSessionResponse(sessionId="sess-1") - async def loadSession(self, params: LoadSessionRequest) -> None: - return None - - async def authenticate(self, params: AuthenticateRequest) -> None: - return None - async def prompt(self, params: PromptRequest) -> PromptResponse: - # Normally you'd stream updates via sessionUpdate + for block in params.prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=text), + ), + ) + ) return PromptResponse(stopReason="end_turn") - async def cancel(self, params: CancelNotification) -> None: - return None - async def main() -> None: reader, writer = await stdio_streams() - # For an agent process, local writes go to client stdin (writer=stdout) - AgentSideConnection(lambda _conn: EchoAgent(), writer, reader) - # Keep running; in a real agent you would await tasks or add your own loop + AgentSideConnection(lambda conn: EchoAgent(conn), writer, reader) await asyncio.Event().wait() diff --git a/docs/index.md b/docs/index.md index 20d3ddd..f742df0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,20 +11,56 @@ pip install agent-client-protocol ## Minimal usage ```python -from acp import Agent, AgentSideConnection, Client, stdio_streams, PROTOCOL_VERSION, InitializeRequest, InitializeResponse, PromptRequest, PromptResponse -from acp.schema import ContentBlock1, SessionUpdate2, SessionNotification - -class MyAgent(Agent): - def __init__(self, client: Client): - self.client = client - async def initialize(self, _p: InitializeRequest) -> InitializeResponse: - return InitializeResponse(protocolVersion=PROTOCOL_VERSION) - async def prompt(self, p: PromptRequest) -> PromptResponse: - await self.client.sessionUpdate(SessionNotification( - sessionId=p.sessionId, - update=SessionUpdate2(sessionUpdate="agent_message_chunk", content=ContentBlock1(type="text", text="Hello from ACP")), - )) +import asyncio + +from acp import ( + Agent, + AgentSideConnection, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionNotification, + stdio_streams, +) +from acp.schema import ContentBlock1, SessionUpdate2 + + +class EchoAgent(Agent): + def __init__(self, conn): + self._conn = conn + + async def initialize(self, params: InitializeRequest) -> InitializeResponse: + return InitializeResponse(protocolVersion=params.protocolVersion) + + async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: + return NewSessionResponse(sessionId="sess-1") + + async def prompt(self, params: PromptRequest) -> PromptResponse: + for block in params.prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=text), + ), + ) + ) return PromptResponse(stopReason="end_turn") + + +async def main() -> None: + reader, writer = await stdio_streams() + AgentSideConnection(lambda conn: EchoAgent(conn), writer, reader) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) ``` - Quickstart: [quickstart.md](quickstart.md) diff --git a/docs/mini-swe-agent.md b/docs/mini-swe-agent.md index 2227b6d..b68f1eb 100644 --- a/docs/mini-swe-agent.md +++ b/docs/mini-swe-agent.md @@ -1,28 +1,49 @@ # Mini SWE Agent bridge -This example wraps mini-swe-agent behind ACP so Zed can run it as an external agent over stdio. +> Just a show of the bridge in action. Not a best-effort or absolutely-correct implementation of the agent. + +This example wraps mini-swe-agent behind ACP so Zed can run it as an external agent over stdio. It also includes a local Textual UI client connected via a duet launcher ## Behavior -- Prompts: text blocks are concatenated into a single task string; referenced resources (`resource_link` / `resource`) are surfaced as hints in the task. -- Streaming: incremental text is sent via `session/update` with `agent_message_chunk`. +- Prompts: text blocks are concatenated into a single task string. (Resource embedding is not used in this example.) +- Streaming: only LM output is streamed via `session/update` → `agent_message_chunk`. - Tool calls: when the agent executes a shell command, the bridge sends: - `tool_call` with `kind=execute`, pending status, and a bash code block containing the command - `tool_call_update` upon completion, including output and a `rawOutput` object with `output` and `returncode` - Final result: on task submission (mini-swe-agent prints `COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the first line), a final `agent_message_chunk` with the submission content is sent. -Non-terminating events (e.g. user rejection or timeouts) are surfaced both as a `tool_call_update` with `status=cancelled|failed` and as a text chunk so the session can continue. - ## Configuration -Environment variables set in the Zed server config control the model: +Environment variables control the model: - `MINI_SWE_MODEL`: model ID (e.g. `openrouter/openai/gpt-4o-mini`) -- `MINI_SWE_MODEL_KWARGS`: JSON string of extra parameters (e.g. `{ "api_base": "https://openrouter.ai/api/v1" }`) -- Vendor API keys (e.g. `OPENROUTER_API_KEY`) must be present in the environment +- `OPENROUTER_API_KEY` for OpenRouter; or `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` for native providers +- Optional `MINI_SWE_MODEL_KWARGS`: JSON, e.g. `{ "api_base": "https://openrouter.ai/api/v1" }` (auto-injected for OpenRouter if missing) + +Agent behavior automatically maps the appropriate API key based on the chosen model and available environment variables. If `mini-swe-agent` is not installed in the venv, the bridge attempts to import a vendored reference copy under `reference/mini-swe-agent/src`. +## How to run + +- In Zed (editor integration): configure an agent server to launch `examples/mini_swe_agent/agent.py` and set the environment variables there. Use Zed’s “Open ACP Logs” to inspect `tool_call`/`tool_call_update` and message chunks. +- In terminal (local TUI): run the duet launcher to start both the agent and the Textual client with the same environment and dedicated pipes: + +```bash +python examples/mini_swe_agent/duet.py +``` + +The launcher loads `.env` from the repo root (using python-dotenv) so both processes share the same configuration. + +### TUI usage + +- Hotkeys: `y` → YOLO, `c` → Confirm, `u` → Human, `Enter` → Continue. +- In Human mode, you’ll be prompted for a bash command; it will be executed and streamed back as a tool call. +- Each executed command appears in the “TOOL CALLS” section with live status and output. + ## Files -- Example entry: [`examples/mini_swe_agent/agent.py`](https://github.com/psiace/agent-client-protocol-python/blob/main/examples/mini_swe_agent/agent.py) +- Agent entry: [`examples/mini_swe_agent/agent.py`](https://github.com/psiace/agent-client-protocol-python/blob/main/examples/mini_swe_agent/agent.py) +- Duet launcher: [`examples/mini_swe_agent/duet.py`](https://github.com/psiace/agent-client-protocol-python/blob/main/examples/mini_swe_agent/duet.py) +- Textual client: [`examples/mini_swe_agent/client.py`](https://github.com/psiace/agent-client-protocol-python/blob/main/examples/mini_swe_agent/client.py) diff --git a/docs/quickstart.md b/docs/quickstart.md index 16b42fa..82bf7ef 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -16,45 +16,46 @@ import asyncio from acp import ( Agent, AgentSideConnection, - AuthenticateRequest, - CancelNotification, InitializeRequest, InitializeResponse, - LoadSessionRequest, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionNotification, stdio_streams, ) +from acp.schema import ContentBlock1, SessionUpdate2 class EchoAgent(Agent): + def __init__(self, conn): + self._conn = conn + async def initialize(self, params: InitializeRequest) -> InitializeResponse: return InitializeResponse(protocolVersion=params.protocolVersion) async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: return NewSessionResponse(sessionId="sess-1") - async def loadSession(self, params: LoadSessionRequest) -> None: - return None - - async def authenticate(self, params: AuthenticateRequest) -> None: - return None - async def prompt(self, params: PromptRequest) -> PromptResponse: - # Normally you'd stream updates via sessionUpdate + for block in params.prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=text), + ), + ) + ) return PromptResponse(stopReason="end_turn") - async def cancel(self, params: CancelNotification) -> None: - return None - async def main() -> None: reader, writer = await stdio_streams() - # For an agent process, local writes go to client stdin (writer=stdout) - AgentSideConnection(lambda _conn: EchoAgent(), writer, reader) - # Keep running; in a real agent you would await tasks or add your own loop + AgentSideConnection(lambda conn: EchoAgent(conn), writer, reader) await asyncio.Event().wait() @@ -84,7 +85,6 @@ Add an agent server to Zed’s `settings.json`: ], "env": { "MINI_SWE_MODEL": "openrouter/openai/gpt-4o-mini", - "MINI_SWE_MODEL_KWARGS": "{\"api_base\":\"https://openrouter.ai/api/v1\"}", "OPENROUTER_API_KEY": "sk-or-..." } } @@ -92,6 +92,19 @@ Add an agent server to Zed’s `settings.json`: } ``` +- For OpenRouter, `api_base` is set automatically to `https://openrouter.ai/api/v1` if not provided. +- Alternatively, use native providers by setting `MINI_SWE_MODEL` accordingly and providing `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. + In Zed, open the Agents panel and select "Mini SWE Agent (Python)". See [mini-swe-agent.md](mini-swe-agent.md) for behavior and message mapping details. + +## Run locally with a TUI + +Use the duet launcher to run both the agent and the local Textual client over dedicated pipes: + +```bash +python examples/mini_swe_agent/duet.py +``` + +The launcher loads `.env` from the repo root so both processes share the same configuration (requires python-dotenv). diff --git a/examples/agent.py b/examples/agent.py index 17a00c7..2a2dfd7 100644 --- a/examples/agent.py +++ b/examples/agent.py @@ -1,48 +1,97 @@ import asyncio +from typing import Any from acp import ( Agent, AgentSideConnection, AuthenticateRequest, + AuthenticateResponse, CancelNotification, InitializeRequest, InitializeResponse, - LoadSessionRequest, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, stdio_streams, + PROTOCOL_VERSION, ) +from acp.schema import ContentBlock1, SessionUpdate2 -class EchoAgent(Agent): +class ExampleAgent(Agent): + def __init__(self, conn: AgentSideConnection) -> None: + self._conn = conn + self._next_session_id = 0 + async def initialize(self, params: InitializeRequest) -> InitializeResponse: - # Avoid serializer warnings by omitting defaults - return InitializeResponse(protocolVersion=params.protocolVersion) + return InitializeResponse(protocolVersion=PROTOCOL_VERSION, agentCapabilities=None, authMethods=[]) - async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: - return NewSessionResponse(sessionId="sess-1") + async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: # noqa: ARG002 + return {} - async def loadSession(self, params: LoadSessionRequest) -> None: - return None + async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: # noqa: ARG002 + session_id = f"sess-{self._next_session_id}" + self._next_session_id += 1 + return NewSessionResponse(sessionId=session_id) - async def authenticate(self, params: AuthenticateRequest) -> None: + async def loadSession(self, params): # type: ignore[override] return None + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: # noqa: ARG002 + return {} + async def prompt(self, params: PromptRequest) -> PromptResponse: - # Normally you'd stream updates via sessionUpdate + # Stream a couple of agent message chunks, then end the turn + # 1) Prefix + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text="Client sent: "), + ), + ) + ) + # 2) Echo text blocks + for block in params.prompt: + if isinstance(block, dict): + # tolerate raw dicts + if block.get("type") == "text": + text = str(block.get("text", "")) + else: + text = f"<{block.get('type', 'content')}>" + else: + # pydantic model ContentBlock1 + text = getattr(block, "text", "") + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=text), + ), + ) + ) return PromptResponse(stopReason="end_turn") - async def cancel(self, params: CancelNotification) -> None: + async def cancel(self, params: CancelNotification) -> None: # noqa: ARG002 + return None + + async def extMethod(self, method: str, params: dict) -> dict: # noqa: ARG002 + return {"example": "response"} + + async def extNotification(self, method: str, params: dict) -> None: # noqa: ARG002 return None async def main() -> None: reader, writer = await stdio_streams() # For an agent process, local writes go to client stdin (writer=stdout) - AgentSideConnection(lambda _conn: EchoAgent(), writer, reader) - # Keep running; in a real agent you would await tasks or add your own loop + AgentSideConnection(lambda conn: ExampleAgent(conn), writer, reader) await asyncio.Event().wait() diff --git a/examples/client.py b/examples/client.py index b4f93c6..5b07cea 100644 --- a/examples/client.py +++ b/examples/client.py @@ -1,6 +1,7 @@ import asyncio import os import sys +from typing import Optional from acp import ( Client, @@ -9,64 +10,66 @@ InitializeRequest, NewSessionRequest, PromptRequest, - ReadTextFileRequest, - ReadTextFileResponse, - RequestPermissionRequest, - RequestPermissionResponse, SessionNotification, - WriteTextFileRequest, - stdio_streams, ) -class MinimalClient(Client): - async def writeTextFile(self, params: WriteTextFileRequest) -> None: - print(f"write {params.path}", file=sys.stderr) - - async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: - return ReadTextFileResponse(content="example") +class ExampleClient(Client): + async def sessionUpdate(self, params: SessionNotification) -> None: + update = params.update + kind = getattr(update, "sessionUpdate", None) if not isinstance(update, dict) else update.get("sessionUpdate") + if kind == "agent_message_chunk": + # Handle both dict and model shapes + content = update["content"] if isinstance(update, dict) else getattr(update, "content", None) + text = content.get("text") if isinstance(content, dict) else getattr(content, "text", "") + print(f"| Agent: {text}") - async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: - return RequestPermissionResponse.model_validate({"outcome": {"outcome": "selected", "optionId": "allow"}}) - async def sessionUpdate(self, params: SessionNotification) -> None: - print(f"session update: {params}", file=sys.stderr) +async def interactive_loop(conn: ClientSideConnection, session_id: str) -> None: + loop = asyncio.get_running_loop() + while True: + try: + line = await loop.run_in_executor(None, lambda: input("> ")) + except EOFError: + break + if not line: + continue + try: + await conn.prompt(PromptRequest(sessionId=session_id, prompt=[{"type": "text", "text": line}])) + except Exception as e: # noqa: BLE001 + print(f"error: {e}", file=sys.stderr) - # Optional terminal methods (not implemented in this minimal client) - async def createTerminal(self, params) -> None: - pass - async def terminalOutput(self, params) -> None: - pass +async def main(argv: list[str]) -> int: + if len(argv) < 2: + print("Usage: python examples/client.py AGENT_PROGRAM [ARGS...]", file=sys.stderr) + return 2 - async def releaseTerminal(self, params) -> None: - pass + # Spawn agent subprocess + proc = await asyncio.create_subprocess_exec( + sys.executable, + *argv[1:], + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + assert proc.stdin and proc.stdout - async def waitForTerminalExit(self, params) -> None: - pass + # Connect to agent stdio + conn = ClientSideConnection(lambda _agent: ExampleClient(), proc.stdin, proc.stdout) - async def killTerminal(self, params) -> None: - pass + # Initialize and create session + await conn.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION, clientCapabilities=None)) + new_sess = await conn.newSession(NewSessionRequest(mcpServers=[], cwd=os.getcwd())) + # Run REPL until EOF + await interactive_loop(conn, new_sess.sessionId) -async def main() -> None: - reader, writer = await stdio_streams() - client_conn = ClientSideConnection(lambda _agent: MinimalClient(), writer, reader) - # 1) initialize - resp = await client_conn.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION)) - print(f"Initialized with protocol version: {resp.protocolVersion}", file=sys.stderr) - # 2) new session - new_sess = await client_conn.newSession(NewSessionRequest(mcpServers=[], cwd=os.getcwd())) - # 3) prompt - await client_conn.prompt( - PromptRequest( - sessionId=new_sess.sessionId, - prompt=[{"type": "text", "text": "Hello from client"}], - ) - ) - # Small grace period to allow duplex messages to flush - await asyncio.sleep(0.2) + try: + proc.terminate() + except ProcessLookupError: + pass + return 0 if __name__ == "__main__": - asyncio.run(main()) + raise SystemExit(asyncio.run(main(sys.argv))) diff --git a/examples/duet.py b/examples/duet.py index 4edf9ab..049f164 100644 --- a/examples/duet.py +++ b/examples/duet.py @@ -1,41 +1,10 @@ import asyncio -import contextlib -import json import os -import signal import sys from pathlib import Path -async def _relay(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, tag: str): - try: - while True: - line = await reader.readline() - if not line: - break - # Mirror to the other end unchanged - writer.write(line) - try: - await writer.drain() - except ConnectionError: - break - # Try to pretty-print the JSON-RPC message for visibility - try: - obj = json.loads(line.decode("utf-8", errors="replace")) - pretty = json.dumps(obj, ensure_ascii=False, indent=2) - print(f"[{tag}] {pretty}", file=sys.stderr) - except Exception: - # Non-JSON (shouldn't happen on the protocol stream) - print(f"[{tag}] {line!r}", file=sys.stderr) - finally: - try: - writer.close() - await writer.wait_closed() - except Exception: - pass - - -async def main() -> None: +async def main() -> int: root = Path(__file__).resolve().parent agent_path = str(root / "agent.py") client_path = str(root / "client.py") @@ -45,68 +14,15 @@ async def main() -> None: src_dir = str((root.parent / "src").resolve()) env["PYTHONPATH"] = src_dir + os.pathsep + env.get("PYTHONPATH", "") - agent = await asyncio.create_subprocess_exec( - sys.executable, - agent_path, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=sys.stderr, - env=env, - ) - client = await asyncio.create_subprocess_exec( + # Run the client and let it spawn the agent, wiring stdio automatically. + proc = await asyncio.create_subprocess_exec( sys.executable, client_path, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=sys.stderr, + agent_path, env=env, ) - - assert agent.stdout and agent.stdin and client.stdout and client.stdin - - # Wire: agent.stdout -> client.stdin, client.stdout -> agent.stdin - t1 = asyncio.create_task(_relay(agent.stdout, client.stdin, "agent→client")) - t2 = asyncio.create_task(_relay(client.stdout, agent.stdin, "client→agent")) - - # Handle shutdown - stop = asyncio.Event() - - def _on_sigint(*_): - stop.set() - - loop = asyncio.get_running_loop() - try: - loop.add_signal_handler(signal.SIGINT, _on_sigint) - loop.add_signal_handler(signal.SIGTERM, _on_sigint) - except NotImplementedError: - pass - - done, _ = await asyncio.wait( - { - t1, - t2, - asyncio.create_task(agent.wait()), - asyncio.create_task(client.wait()), - asyncio.create_task(stop.wait()), - }, - return_when=asyncio.FIRST_COMPLETED, - ) - - # Teardown - for proc in (agent, client): - if proc.returncode is None: - with contextlib.suppress(ProcessLookupError): - proc.terminate() - try: - await asyncio.wait_for(proc.wait(), 2) - except asyncio.TimeoutError: - with contextlib.suppress(ProcessLookupError): - proc.kill() - for task in (t1, t2): - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task + return await proc.wait() if __name__ == "__main__": - asyncio.run(main()) + raise SystemExit(asyncio.run(main())) diff --git a/examples/echo_agent.py b/examples/echo_agent.py new file mode 100644 index 0000000..92e8f46 --- /dev/null +++ b/examples/echo_agent.py @@ -0,0 +1,50 @@ +import asyncio + +from acp import ( + Agent, + AgentSideConnection, + InitializeRequest, + InitializeResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + SessionNotification, + stdio_streams, +) +from acp.schema import ContentBlock1, SessionUpdate2 + + +class EchoAgent(Agent): + def __init__(self, conn): + self._conn = conn + + async def initialize(self, params: InitializeRequest) -> InitializeResponse: + return InitializeResponse(protocolVersion=params.protocolVersion) + + async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: + return NewSessionResponse(sessionId="sess-1") + + async def prompt(self, params: PromptRequest) -> PromptResponse: + for block in params.prompt: + text = block.get("text", "") if isinstance(block, dict) else getattr(block, "text", "") + await self._conn.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=text), + ), + ) + ) + return PromptResponse(stopReason="end_turn") + + +async def main() -> None: + reader, writer = await stdio_streams() + AgentSideConnection(lambda conn: EchoAgent(conn), writer, reader) + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/mini_swe_agent/README.md b/examples/mini_swe_agent/README.md index 8f334e2..64b445e 100644 --- a/examples/mini_swe_agent/README.md +++ b/examples/mini_swe_agent/README.md @@ -1,8 +1,10 @@ # Mini SWE Agent (Python) — ACP Bridge -A minimal Agent Client Protocol (ACP) bridge that wraps mini-swe-agent so it can be run by Zed as an external agent over stdio. +> Just a show of the bridge in action. Not a best-effort or absolutely-correct implementation of the agent. -## Configure in Zed +A minimal Agent Client Protocol (ACP) bridge that wraps mini-swe-agent so it can be run by Zed as an external agent over stdio, and also provides a local Textual UI client. + +## Configure in Zed (recommended for editor integration) Add an `agent_servers` entry to Zed’s `settings.json`. Point `command` to the Python interpreter that has both `agent-client-protocol` and `mini-swe-agent` installed, and `args` to this example script: @@ -32,16 +34,24 @@ Notes - Set `OPENROUTER_API_KEY` to your API key. - Alternatively, you can use native OpenAI/Anthropic APIs. Set `MINI_SWE_MODEL` accordingly and provide the vendor-specific API key; `MINI_SWE_MODEL_KWARGS` is optional. -## Requirements +## Run locally with a TUI (Textual) -Install mini-swe-agent (or at least its core deps) into the same environment: +Use the duet launcher to run both the ACP agent and the local Textual client connected over dedicated pipes. The client keeps your terminal stdio; ACP messages flow over separate FDs. ```bash -pip install agent-client-protocol mini-swe-agent -# or: pip install litellm jinja2 tenacity +# From repo root +python examples/mini_swe_agent/duet.py ``` -Then in Zed, open the Agents panel and select "Mini SWE Agent (Python)" to start a thread. +Environment +- The launcher loads `.env` from the repo root using python-dotenv (override=True) so both child processes inherit the same environment. +- Minimum for OpenRouter: + - `MINI_SWE_MODEL="openrouter/openai/gpt-4o-mini"` + - `OPENROUTER_API_KEY="sk-or-..."` + - Optional: `MINI_SWE_MODEL_KWARGS='{"api_base":"https://openrouter.ai/api/v1"}'` (auto-injected if missing) + +Quit behavior +- Quit from the TUI cleanly ends the background loop; duet will terminate both processes gracefully and force-kill after a short timeout if needed. ## Behavior overview diff --git a/examples/mini_swe_agent/agent.py b/examples/mini_swe_agent/agent.py index ed1ccad..e312136 100644 --- a/examples/mini_swe_agent/agent.py +++ b/examples/mini_swe_agent/agent.py @@ -1,9 +1,11 @@ import asyncio import os +import re import sys import uuid +from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Literal from acp import ( Agent, @@ -18,20 +20,38 @@ PromptRequest, PromptResponse, SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, stdio_streams, PROTOCOL_VERSION, ) from acp.schema import ( ContentBlock1, + PermissionOption, + RequestPermissionRequest, + RequestPermissionResponse, + RequestPermissionOutcome1, + RequestPermissionOutcome2, + SessionUpdate1, SessionUpdate2, + SessionUpdate3, SessionUpdate4, SessionUpdate5, ToolCallContent1, + ToolCallUpdate, ) + # Lazily import mini-swe-agent to avoid hard dependency for users who don't need this example +@dataclass +class ACPAgentConfig: # Extra controls layered on top of mini-swe-agent defaults + mode: Literal["confirm", "yolo", "human"] = "confirm" + whitelist_actions: list[str] = field(default_factory=list) + confirm_exit: bool = True + + def _create_streaming_mini_agent( *, client: Client, @@ -40,6 +60,7 @@ def _create_streaming_mini_agent( model_name: str, model_kwargs: dict[str, Any], loop: asyncio.AbstractEventLoop, + ext_config: ACPAgentConfig, ): """Create a DefaultAgent that emits ACP session/update events during execution. @@ -52,6 +73,7 @@ def _create_streaming_mini_agent( NonTerminatingException, Submitted, LimitsExceeded, + AgentConfig as _BaseCfg, ) # type: ignore from minisweagent.environments.local import LocalEnvironment # type: ignore from minisweagent.models.litellm_model import LitellmModel # type: ignore @@ -66,6 +88,7 @@ def _create_streaming_mini_agent( NonTerminatingException, Submitted, LimitsExceeded, + AgentConfig as _BaseCfg, ) # type: ignore from minisweagent.environments.local import LocalEnvironment # type: ignore from minisweagent.models.litellm_model import LitellmModel # type: ignore @@ -84,19 +107,41 @@ def __init__(self) -> None: self._LimitsExceeded = LimitsExceeded model = LitellmModel(model_name=model_name, model_kwargs=model_kwargs) env = LocalEnvironment(cwd=cwd) - super().__init__(model=model, env=env) + super().__init__(model=model, env=env, config_class=_BaseCfg) + # extra config + self.acp_config = ext_config + # During initial seeding (system/user templates), suppress updates + self._emit_updates = False + + # --- ACP streaming helpers --- def _schedule(self, coro): import asyncio as _asyncio - _asyncio.run_coroutine_threadsafe(coro, self._loop) + return _asyncio.run_coroutine_threadsafe(coro, self._loop) async def _send(self, update_model) -> None: await self._acp_client.sessionUpdate( SessionNotification(sessionId=self._session_id, update=update_model) ) + def _send_cost_hint(self) -> None: + try: + cost = float(getattr(self.model, "cost", 0.0)) + except Exception: + cost = 0.0 + hint = SessionUpdate3( + sessionUpdate="agent_thought_chunk", + content=ContentBlock1(type="text", text=f"__COST__:{cost:.2f}"), + ) + try: + loop = asyncio.get_running_loop() + loop.create_task(self._send(hint)) + except RuntimeError: + self._schedule(self._send(hint)) + async def on_tool_start(self, title: str, command: str, tool_call_id: str) -> None: + """Send a tool_call start notification for a bash command.""" update = SessionUpdate4( sessionUpdate="tool_call", toolCallId=tool_call_id, @@ -105,8 +150,7 @@ async def on_tool_start(self, title: str, command: str, tool_call_id: str) -> No status="pending", content=[ ToolCallContent1( - type="content", - content=ContentBlock1(type="text", text=f"```bash\n{command}\n```"), + type="content", content=ContentBlock1(type="text", text=f"```bash\n{command}\n```") ) ], rawInput={"command": command}, @@ -121,39 +165,111 @@ async def on_tool_complete( *, status: str = "completed", ) -> None: + """Send a tool_call_update with the final output and return code.""" update = SessionUpdate5( sessionUpdate="tool_call_update", toolCallId=tool_call_id, status=status, content=[ ToolCallContent1( - type="content", - content=ContentBlock1(type="text", text=output), + type="content", content=ContentBlock1(type="text", text=f"```ansi\n{output}\n```") ) ], rawOutput={"output": output, "returncode": returncode}, ) await self._send(update) + def add_message(self, role: str, content: str, **kwargs): + super().add_message(role, content, **kwargs) + # Only stream LM output as agent_message_chunk; tool output is handled via tool_call_update. + if not getattr(self, "_emit_updates", True) or role != "assistant": + return + text = str(content) + block = ContentBlock1(type="text", text=text) + update = SessionUpdate2(sessionUpdate="agent_message_chunk", content=block) + try: + loop = asyncio.get_running_loop() + loop.create_task(self._send(update)) + except RuntimeError: + self._schedule(self._send(update)) + # Fire-and-forget + + def _confirm_action_sync(self, tool_call_id: str, command: str) -> bool: + # Build request and block until client responds + req = RequestPermissionRequest( + sessionId=self._session_id, + options=[ + PermissionOption(optionId="allow-once", name="Allow once", kind="allow_once"), + PermissionOption(optionId="reject-once", name="Reject", kind="reject_once"), + ], + toolCall=ToolCallUpdate( + toolCallId=tool_call_id, + title="bash", + kind="execute", + status="pending", + content=[ + ToolCallContent1( + type="content", + content=ContentBlock1(type="text", text=f"```bash\n{command}\n```"), + ) + ], + rawInput={"command": command}, + ), + ) + fut = self._schedule(self._acp_client.requestPermission(req)) + try: + resp: RequestPermissionResponse = fut.result() # type: ignore[assignment] + except Exception: + return False + out = resp.outcome + if isinstance(out, RequestPermissionOutcome2) and out.optionId in ("allow-once", "allow-always"): + return True + return False + def execute_action(self, action: dict) -> dict: # type: ignore[override] self._tool_seq += 1 tool_id = f"mini-bash-{self._tool_seq}-{uuid.uuid4().hex[:8]}" command = action.get("action", "") + + # Always create tool_call first (pending) self._schedule(self.on_tool_start("bash", command, tool_id)) + + # Request permission unless whitelisted + if command.strip() and not any(re.match(r, command) for r in self.acp_config.whitelist_actions): + allowed = self._confirm_action_sync(tool_id, command) + if not allowed: + # Mark as cancelled/failed accordingly and abort this step + self._schedule( + self.on_tool_complete( + tool_id, + "Permission denied by user", + 0, + status="cancelled", + ) + ) + raise self._NonTerminatingException("Command not executed: denied by user") + try: + # Mark in progress + self._schedule( + self._send( + SessionUpdate5( + sessionUpdate="tool_call_update", + toolCallId=tool_id, + status="in_progress", + ) + ) + ) result = super().execute_action(action) output = result.get("output", "") returncode = int(result.get("returncode", 0) or 0) self._schedule(self.on_tool_complete(tool_id, output, returncode, status="completed")) return result - except Submitted as e: - # Finished successfully; e contains the final output without the marker line + except self._Submitted as e: # type: ignore[misc] final_text = str(e) self._schedule(self.on_tool_complete(tool_id, final_text, 0, status="completed")) raise - except NonTerminatingException as e: - # Non-terminating cases: timeouts or user-driven re-plans in interactive flows. - # Map likely user cancellations to a softer status for better UX. + except self._NonTerminatingException as e: # type: ignore[misc] msg = str(e) status = ( "cancelled" @@ -201,68 +317,103 @@ async def initialize(self, _params: InitializeRequest) -> InitializeResponse: async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: session_id = f"sess-{uuid.uuid4().hex[:12]}" + # load config from env for whitelist & confirm_exit + cfg = ACPAgentConfig() + try: + import json as _json + + wl = os.getenv("MINI_SWE_WHITELIST", "[]") + cfg.whitelist_actions = list(_json.loads(wl)) if wl else [] # type: ignore[assignment] + except Exception: + pass + ce = os.getenv("MINI_SWE_CONFIRM_EXIT") + if ce is not None: + cfg.confirm_exit = ce.lower() not in ("0", "false", "no") self._sessions[session_id] = { "cwd": params.cwd, "agent": None, + "task": None, + "config": cfg, } return NewSessionResponse(sessionId=session_id) async def loadSession(self, params) -> None: # type: ignore[override] - # Update/initialize session storage for externally provided sessionId try: session_id = params.sessionId # type: ignore[attr-defined] cwd = params.cwd # type: ignore[attr-defined] except Exception: session_id = getattr(params, "sessionId", "sess-unknown") cwd = getattr(params, "cwd", os.getcwd()) - self._sessions.setdefault(session_id, {"cwd": cwd, "agent": None}) + if session_id not in self._sessions: + cfg = ACPAgentConfig() + try: + import json as _json + + wl = os.getenv("MINI_SWE_WHITELIST", "[]") + cfg.whitelist_actions = list(_json.loads(wl)) if wl else [] # type: ignore[assignment] + except Exception: + pass + ce = os.getenv("MINI_SWE_CONFIRM_EXIT") + if ce is not None: + cfg.confirm_exit = ce.lower() not in ("0", "false", "no") + self._sessions[session_id] = {"cwd": cwd, "agent": None, "task": None, "config": cfg} return None async def authenticate(self, _params: AuthenticateRequest) -> None: - # mini-swe-agent reads credentials from environment (e.g., OpenAI/OpenRouter) + return None + + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: # type: ignore[override] + sess = self._sessions.get(params.sessionId) + if not sess: + return SetSessionModeResponse() + mode = params.modeId.lower() + if mode in ("confirm", "yolo", "human"): + sess["config"].mode = mode # type: ignore[attr-defined] + return SetSessionModeResponse() + + def _extract_mode_from_blocks(self, blocks) -> Literal["confirm", "yolo", "human"] | None: + for b in blocks: + if getattr(b, "type", None) == "text": + t = getattr(b, "text", "") or "" + m = re.search(r"\[\[MODE:([a-zA-Z]+)\]\]", t) + if m: + mode = m.group(1).lower() + if mode in ("confirm", "yolo", "human"): + return mode # type: ignore[return-value] + return None + + def _extract_code_from_blocks(self, blocks) -> str | None: + for b in blocks: + if getattr(b, "type", None) == "text": + t = getattr(b, "text", "") or "" + actions = re.findall(r"```bash\n(.*?)\n```", t, re.DOTALL) + if actions: + return actions[0].strip() return None async def prompt(self, params: PromptRequest) -> PromptResponse: sess = self._sessions.get(params.sessionId) if not sess: - # Create a volatile session if missing - self._sessions[params.sessionId] = {"cwd": os.getcwd(), "agent": None} + self._sessions[params.sessionId] = { + "cwd": os.getcwd(), + "agent": None, + "task": None, + "config": ACPAgentConfig(), + } sess = self._sessions[params.sessionId] - # Build the task string from content blocks - task_parts: list[str] = [] - for block in params.prompt: - btype = getattr(block, "type", None) - if btype == "text": - text = getattr(block, "text", "") - if text: - task_parts.append(str(text)) - elif btype in ("resource", "resource_link"): - # Represent referenced resources as hints - uri = None - if btype == "resource_link": - uri = getattr(block, "uri", None) - else: # resource with embedded contents - res = getattr(block, "resource", None) - uri = getattr(res, "uri", None) - if uri: - task_parts.append(f"\nReference: {uri}\n") - task = "".join(task_parts).strip() or "Help me with the current repository." - - # Create the backing mini-swe-agent on first use per session + # Init or reuse agent agent = sess.get("agent") if agent is None: model_name = os.getenv("MINI_SWE_MODEL", os.getenv("OPENAI_MODEL", "gpt-4o-mini")) - model_kwargs_env = os.getenv("MINI_SWE_MODEL_KWARGS", "{}") try: import json - model_kwargs = json.loads(model_kwargs_env) + model_kwargs = json.loads(os.getenv("MINI_SWE_MODEL_KWARGS", "{}")) if not isinstance(model_kwargs, dict): model_kwargs = {} except Exception: model_kwargs = {} - loop = asyncio.get_running_loop() agent, err = _create_streaming_mini_agent( client=self._client, @@ -271,6 +422,7 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: model_name=model_name, model_kwargs=model_kwargs, loop=loop, + ext_config=sess["config"], ) if err: await self._client.sessionUpdate( @@ -292,74 +444,100 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: return PromptResponse(stopReason="end_turn") sess["agent"] = agent - # Initialize the mini-swe-agent conversation like DefaultAgent.run does - agent.extra_template_vars |= {"task": task} - agent.messages = [] - agent.add_message("system", agent.render_template(agent.config.system_template)) - agent.add_message("user", agent.render_template(agent.config.instance_template)) - - # Immediate UX feedback - await self._client.sessionUpdate( - SessionNotification( - sessionId=params.sessionId, - update=SessionUpdate2( - sessionUpdate="agent_message_chunk", - content=ContentBlock1( - type="text", - text=f"Starting mini-swe-agent…\nTask: {task[:200]}", - ), - ), - ) - ) - - # Step until completion, surfacing updates - final_message = "" - SubmittedT = getattr(agent, "_Submitted", Exception) - NonTerminatingT = getattr(agent, "_NonTerminatingException", Exception) - LimitsExceededT = getattr(agent, "_LimitsExceeded", Exception) - - while True: - try: - await asyncio.to_thread(agent.step) - except NonTerminatingT as e: # type: ignore[misc] - # Feed observation back into the agent state and show to user - note = str(e) - agent.add_message("user", note) + # Mode is controlled entirely client-side via requestPermission behavior; no control blocks are parsed. + + # Initialize conversation on first task + if not sess.get("task"): + # Build task + task_parts: list[str] = [] + for block in params.prompt: + btype = getattr(block, "type", None) + if btype == "text": + text = getattr(block, "text", "") + if text and not text.strip().startswith("[[MODE:"): + task_parts.append(str(text)) + task = "\n".join(task_parts).strip() or "Help me with the current repository." + sess["task"] = task + agent.extra_template_vars |= {"task": task} + agent.messages = [] + # Seed templates without emitting updates + agent._emit_updates = False # type: ignore[attr-defined] + agent.add_message("system", agent.render_template(agent.config.system_template)) + agent.add_message("user", agent.render_template(agent.config.instance_template)) + agent._emit_updates = True # type: ignore[attr-defined] + + # Decide the source of the next action + try: + if sess["config"].mode == "human": + # Expect a bash command from the client + cmd = self._extract_code_from_blocks(params.prompt) + if not cmd: + # Ask user to provide a command and return + await self._client.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text="Human mode: please submit a bash command."), + ), + ) + ) + return PromptResponse(stopReason="end_turn") + # Fabricate assistant message with the command + msg_content = f"\n```bash\n{cmd}\n```" + agent.add_message("assistant", msg_content) + response = {"content": msg_content} + else: + # Query the model in a worker thread to keep the event loop free + response = await asyncio.to_thread(agent.query) + # Send cost hint after each model call + try: + agent._send_cost_hint() # type: ignore[attr-defined] + except Exception: + pass + + # Execute and record observation in worker thread + await asyncio.to_thread(agent.get_observation, response) + except getattr(agent, "_NonTerminatingException") as e: # type: ignore[misc] + agent.add_message("user", str(e)) + except getattr(agent, "_Submitted") as e: # type: ignore[misc] + final_message = str(e) + agent.add_message("user", final_message) + # Ask for confirmation / new task if configured + if sess["config"].confirm_exit: await self._client.sessionUpdate( SessionNotification( sessionId=params.sessionId, update=SessionUpdate2( sessionUpdate="agent_message_chunk", - content=ContentBlock1(type="text", text=note), + content=ContentBlock1( + type="text", + text=( + "Agent finished. Type a new task in the next message to continue, or do nothing to end." + ), + ), ), ) ) - continue - except SubmittedT as e: # type: ignore[misc] - final_message = str(e) - # Mirror what DefaultAgent.run does for completion bookkeeping - agent.add_message("user", final_message) - break - except LimitsExceededT as e: # type: ignore[misc] - final_message = f"Limits exceeded: {e}" - agent.add_message("user", final_message) - break - - # Send the final result text to the client - await self._client.sessionUpdate( - SessionNotification( - sessionId=params.sessionId, - update=SessionUpdate2( - sessionUpdate="agent_message_chunk", - content=ContentBlock1(type="text", text=final_message or "(no final output)"), - ), + # Reset task so that next prompt can set a new one + sess["task"] = None + except getattr(agent, "_LimitsExceeded") as e: # type: ignore[misc] + agent.add_message("user", f"Limits exceeded: {e}") + except Exception as e: + # Surface unexpected errors to the client to avoid silent waits + await self._client.sessionUpdate( + SessionNotification( + sessionId=params.sessionId, + update=SessionUpdate2( + sessionUpdate="agent_message_chunk", + content=ContentBlock1(type="text", text=f"Error while processing: {e}"), + ), + ) ) - ) return PromptResponse(stopReason="end_turn") async def cancel(self, _params: CancelNotification) -> None: - # DefaultAgent is synchronous per step; nothing to cancel mid-step here return None diff --git a/examples/mini_swe_agent/client.py b/examples/mini_swe_agent/client.py new file mode 100644 index 0000000..b3e0381 --- /dev/null +++ b/examples/mini_swe_agent/client.py @@ -0,0 +1,656 @@ +import asyncio +import os +import queue +import re +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Literal, Optional + + +from rich.spinner import Spinner +from rich.text import Text +from textual.app import App, ComposeResult, SystemCommand +from textual.binding import Binding +from textual.containers import Container, Vertical, VerticalScroll +from textual.css.query import NoMatches +from textual.events import Key +from textual.screen import Screen +from textual.widgets import Footer, Header, Input, Static, TextArea + +from acp import ( + Client, + PROTOCOL_VERSION, + ClientSideConnection, + InitializeRequest, + NewSessionRequest, + PromptRequest, + RequestPermissionRequest, + RequestPermissionResponse, + SessionNotification, + SetSessionModeRequest, +) +from acp.schema import ( + ContentBlock1, + PermissionOption, + RequestPermissionOutcome1, + RequestPermissionOutcome2, + SessionUpdate1, + SessionUpdate2, + SessionUpdate3, + SessionUpdate4, + SessionUpdate5, + ToolCallContent1, + ToolCallUpdate, +) +from acp.stdio import _WritePipeProtocol + + +MODE = Literal["confirm", "yolo", "human"] + + +@dataclass +class UIMessage: + role: str # "assistant" or "user" + content: str + + +def _messages_to_steps(messages: list[UIMessage]) -> list[list[UIMessage]]: + steps: list[list[UIMessage]] = [] + current: list[UIMessage] = [] + for m in messages: + current.append(m) + if m.role == "user": + steps.append(current) + current = [] + if current: + steps.append(current) + return steps + + +class SmartInputContainer(Container): + def __init__(self, app: "TextualMiniSweClient"): + super().__init__(classes="smart-input-container") + self._app = app + self._multiline_mode = False + self.can_focus = True + self.display = False + + self.pending_prompt: Optional[str] = None + self._input_event = threading.Event() + self._input_result: Optional[str] = None + + self._header_display = Static(id="input-header-display", classes="message-header input-request-header") + self._hint_text = Static(classes="hint-text") + self._single_input = Input(placeholder="Type your input...") + self._multi_input = TextArea(show_line_numbers=False, classes="multi-input") + self._input_elements_container = Vertical( + self._header_display, + self._hint_text, + self._single_input, + self._multi_input, + classes="message-container", + ) + + def compose(self) -> ComposeResult: + yield self._input_elements_container + + def on_mount(self) -> None: + self._multi_input.display = False + self._update_mode_display() + + def on_focus(self) -> None: + if self._multiline_mode: + self._multi_input.focus() + else: + self._single_input.focus() + + def request_input(self, prompt: str) -> str: + self._input_event.clear() + self._input_result = None + self.pending_prompt = prompt + self._header_display.update(prompt) + self._update_mode_display() + # If we're already on the Textual thread, call directly; otherwise marshal. + if getattr(self._app, "_thread_id", None) == threading.get_ident(): + self._app.update_content() + else: + self._app.call_from_thread(self._app.update_content) + self._input_event.wait() + return self._input_result or "" + + def _complete_input(self, input_text: str): + self._input_result = input_text + self.pending_prompt = None + self.display = False + self._single_input.value = "" + self._multi_input.text = "" + self._multiline_mode = False + self._update_mode_display() + self._app.update_content() + # Reset scroll position to bottom + self._app._vscroll.scroll_y = 0 + self._input_event.set() + + def action_toggle_mode(self) -> None: + if self.pending_prompt is None or self._multiline_mode: + return + self._multiline_mode = True + self._update_mode_display() + self.on_focus() + + def _update_mode_display(self) -> None: + if self._multiline_mode: + self._multi_input.text = self._single_input.value + self._single_input.display = False + self._multi_input.display = True + self._hint_text.update( + "[reverse][bold][$accent] Ctrl+D [/][/][/] to submit, [reverse][bold][$accent] Tab [/][/][/] to switch focus with other controls" + ) + else: + self._hint_text.update( + "[reverse][bold][$accent] Enter [/][/][/] to submit, [reverse][bold][$accent] Ctrl+T [/][/][/] to switch to multi-line input, [reverse][bold][$accent] Tab [/][/][/] to switch focus with other controls", + ) + self._multi_input.display = False + self._single_input.display = True + + def on_input_submitted(self, event: Input.Submitted) -> None: + if not self._multiline_mode: + text = event.input.value.strip() + self._complete_input(text) + + def on_key(self, event: Key) -> None: + if event.key == "ctrl+t" and not self._multiline_mode: + event.prevent_default() + self.action_toggle_mode() + return + if self._multiline_mode and event.key == "ctrl+d": + event.prevent_default() + self._complete_input(self._multi_input.text.strip()) + return + if event.key == "escape": + event.prevent_default() + self.can_focus = False + self._app.set_focus(None) + return + + +class MiniSweClientImpl(Client): + def __init__(self, app: "TextualMiniSweClient") -> None: + self._app = app + + async def sessionUpdate(self, params: SessionNotification) -> None: + upd = params.update + + def _post(msg: UIMessage) -> None: + if getattr(self._app, "_thread_id", None) == threading.get_ident(): + self._app.enqueue_message(msg) + self._app.on_message_added() + else: + self._app.call_from_thread(lambda: (self._app.enqueue_message(msg), self._app.on_message_added())) + + if isinstance(upd, SessionUpdate2): + # agent message + txt = _content_to_text(upd.content) + _post(UIMessage("assistant", txt)) + elif isinstance(upd, SessionUpdate1): + txt = _content_to_text(upd.content) + _post(UIMessage("user", txt)) + elif isinstance(upd, SessionUpdate3): + # agent thought chunk (informational) + txt = _content_to_text(upd.content) + _post(UIMessage("assistant", f"[thought]\n{txt}")) + elif isinstance(upd, SessionUpdate4): + # tool call start → record structured state + self._app._update_tool_call( + upd.toolCallId, title=upd.title or "", status=upd.status or "pending", content=upd.content + ) + self._app.call_from_thread(self._app.update_content) + elif isinstance(upd, SessionUpdate5): + # tool call update → update structured state + self._app._update_tool_call(upd.toolCallId, status=upd.status, content=upd.content) + self._app.call_from_thread(self._app.update_content) + + async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: + # Respect client-side mode shortcuts + mode = self._app.mode + if mode == "yolo": + return RequestPermissionResponse( + outcome=RequestPermissionOutcome2(outcome="selected", optionId="allow-once") + ) + # Prompt user for decision + prompt = "Approve tool call? Press Enter to allow once, type 'n' to reject" + ans = self._app.input_container.request_input(prompt).strip().lower() + if ans in ("", "y", "yes"): + return RequestPermissionResponse( + outcome=RequestPermissionOutcome2(outcome="selected", optionId="allow-once") + ) + return RequestPermissionResponse(outcome=RequestPermissionOutcome2(outcome="selected", optionId="reject-once")) + + # Optional features not used in this example + async def writeTextFile(self, params): + return None + + async def readTextFile(self, params): + return None + + +def _content_to_text(content) -> str: + if hasattr(content, "text"): + return str(content.text) + return str(content) + + +class TextualMiniSweClient(App): + BINDINGS = [ + Binding("right,l", "next_step", "Step++", tooltip="Show next step of the agent"), + Binding("left,h", "previous_step", "Step--", tooltip="Show previous step of the agent"), + Binding("0", "first_step", "Step=0", tooltip="Show first step of the agent", show=False), + Binding("$", "last_step", "Step=-1", tooltip="Show last step of the agent", show=False), + Binding("j,down", "scroll_down", "Scroll down", show=False), + Binding("k,up", "scroll_up", "Scroll up", show=False), + Binding("q,ctrl+q", "quit", "Quit", tooltip="Quit the agent"), + Binding("y,ctrl+y", "yolo", "YOLO mode", tooltip="Switch to YOLO Mode (LM actions will execute immediately)"), + Binding( + "c", + "confirm", + "CONFIRM mode", + tooltip="Switch to Confirm Mode (LM proposes commands and you confirm/reject them)", + ), + Binding("u,ctrl+u", "human", "HUMAN mode", tooltip="Switch to Human Mode (you can now type commands directly)"), + Binding("enter", "continue_step", "Next"), + Binding("f1,question_mark", "toggle_help_panel", "Help", tooltip="Show help"), + ] + + def __init__(self) -> None: + # Load CSS + css_path = os.environ.get( + "MSWEA_MINI_STYLE_PATH", + str( + Path(__file__).resolve().parents[2] + / "reference" + / "mini-swe-agent" + / "src" + / "minisweagent" + / "config" + / "mini.tcss" + ), + ) + try: + self.__class__.CSS = Path(css_path).read_text() + except Exception: + self.__class__.CSS = "" + super().__init__() + self.mode: MODE = "confirm" + self._vscroll = VerticalScroll() + self.input_container = SmartInputContainer(self) + self.messages: list[UIMessage] = [] + self._spinner = Spinner("dots") + self.agent_state: Literal["UNINITIALIZED", "RUNNING", "AWAITING_INPUT", "STOPPED"] = "UNINITIALIZED" + self._bg_loop: Optional[asyncio.AbstractEventLoop] = None + self._bg_thread: Optional[threading.Thread] = None + self._conn: Optional[ClientSideConnection] = None + self._session_id: Optional[str] = None + self._pending_human_command: Optional[str] = None + self._outbox: "queue.Queue[list[ContentBlock1]]" = queue.Queue() + # Pagination and metrics + self._i_step: int = 0 + self.n_steps: int = 1 + # Structured state for tool calls and plan + self._tool_calls: dict[str, dict] = {} + self._plan: list[dict] = [] + self._ask_new_task_pending = False + + # --- Textual lifecycle --- + + def compose(self) -> ComposeResult: + yield Header() + with Container(id="main"): + with self._vscroll: + with Vertical(id="content"): + pass + yield self.input_container + yield Footer() + + def on_mount(self) -> None: + self.agent_state = "RUNNING" + self.update_content() + self.set_interval(1 / 8, self._update_headers) + # Ask for initial task without blocking UI + threading.Thread(target=self._ask_initial_task, daemon=True).start() + + def _ask_initial_task(self) -> None: + task = self.input_container.request_input("Enter your task for mini-swe-agent:") + blocks = [ContentBlock1(type="text", text=task)] + self._outbox.put(blocks) + self._start_connection_thread() + + def on_unmount(self) -> None: + if self._bg_loop: + try: + self._bg_loop.call_soon_threadsafe(self._bg_loop.stop) + except Exception: + pass + + # --- Backend comms --- + + def _start_connection_thread(self) -> None: + """Start a background thread running the ACP connection event loop.""" + + def _runner() -> None: + loop = asyncio.new_event_loop() + self._bg_loop = loop + asyncio.set_event_loop(loop) + loop.run_until_complete(self._run_connection()) + + t = threading.Thread(target=_runner, daemon=True) + t.start() + self._bg_thread = t + + async def _open_acp_streams_from_env(self) -> tuple[Optional[asyncio.StreamReader], Optional[asyncio.StreamWriter]]: + """If launched via duet, open ACP streams from inherited FDs; else return (None, None).""" + read_fd_s = os.environ.get("MSWEA_READ_FD") + write_fd_s = os.environ.get("MSWEA_WRITE_FD") + if not read_fd_s or not write_fd_s: + return None, None + read_fd = int(read_fd_s) + write_fd = int(write_fd_s) + loop = asyncio.get_running_loop() + # Reader + reader = asyncio.StreamReader() + reader_proto = asyncio.StreamReaderProtocol(reader) + r_file = os.fdopen(read_fd, "rb", buffering=0) + await loop.connect_read_pipe(lambda: reader_proto, r_file) + # Writer + write_proto = _WritePipeProtocol() + w_file = os.fdopen(write_fd, "wb", buffering=0) + transport, _ = await loop.connect_write_pipe(lambda: write_proto, w_file) + writer = asyncio.StreamWriter(transport, write_proto, None, loop) + return reader, writer + + async def _run_connection(self) -> None: + """Run the ACP client connection using FDs provided by duet; do not fallback.""" + reader, writer = await self._open_acp_streams_from_env() + if reader is None or writer is None: # type: ignore[truthy-bool] + # Do not fallback; inform user and stop + self.call_from_thread( + lambda: ( + self.enqueue_message( + UIMessage( + "assistant", + "Communication endpoints not provided. Please launch via examples/mini_swe_agent/duet.py", + ) + ), + self.on_message_added(), + ) + ) + self.agent_state = "STOPPED" + return + + self._conn = ClientSideConnection(lambda _agent: MiniSweClientImpl(self), writer, reader) + try: + resp = await self._conn.initialize(InitializeRequest(protocolVersion=PROTOCOL_VERSION)) + self.call_from_thread( + lambda: ( + self.enqueue_message(UIMessage("assistant", f"Initialized v{resp.protocolVersion}")), + self.on_message_added(), + ) + ) + new_sess = await self._conn.newSession(NewSessionRequest(mcpServers=[], cwd=os.getcwd())) + self._session_id = new_sess.sessionId + self.call_from_thread( + lambda: ( + self.enqueue_message(UIMessage("assistant", f"Session {self._session_id} created")), + self.on_message_added(), + ) + ) + except Exception as e: + self.call_from_thread( + lambda: ( + self.enqueue_message(UIMessage("assistant", f"ACP connect error: {e}")), + self.on_message_added(), + ) + ) + self.agent_state = "STOPPED" + return + + # Autostep loop: take queued prompts and send; if none and mode != human, keep stepping + while self.agent_state != "STOPPED": + blocks: list[ContentBlock1] + try: + blocks = self._outbox.get_nowait() + except queue.Empty: + # Auto-advance a step when not in human mode and we're not awaiting input + if self.mode != "human" and self.input_container.pending_prompt is None: + blocks = [] + else: + await asyncio.sleep(0.05) + continue + # Send prompt turn + try: + result = await self._conn.prompt(PromptRequest(sessionId=self._session_id, prompt=blocks)) + # Minimal finish/new task UX: after each stopReason, if not human and idle, offer new task + if ( + self.mode != "human" + and not self._ask_new_task_pending + and self.input_container.pending_prompt is None + ): + self._ask_new_task_pending = True + + def _ask_new(): + task = self.input_container.request_input( + "Turn complete. Type a new task or press Enter to continue:" + ) + if task.strip(): + self._outbox.put([ContentBlock1(type="text", text=task)]) + else: + self._outbox.put([]) + self._ask_new_task_pending = False + + threading.Thread(target=_ask_new, daemon=True).start() + except Exception as e: + # Break on connection shutdowns to stop background thread cleanly + msg = str(e) + if isinstance(e, (BrokenPipeError, ConnectionResetError)) or "Broken pipe" in msg or "closed" in msg: + self.agent_state = "STOPPED" + break + self.call_from_thread(lambda: self.enqueue_message(UIMessage("assistant", f"prompt error: {e}"))) + # Tiny delay to avoid busy-looping + await asyncio.sleep(0.05) + + def send_human_command(self, cmd: str) -> None: + if not cmd.strip(): + return + code = f"```bash\n{cmd.strip()}\n```" + self._outbox.put([ContentBlock1(type="text", text=code)]) + + # --- UI updates --- + + def enqueue_message(self, msg: UIMessage) -> None: + self.messages.append(msg) + + def on_message_added(self) -> None: + auto_follow = self._vscroll.scroll_y <= 1 and self._i_step == self.n_steps - 1 + # recompute step pages + items = _messages_to_steps(self.messages) + self.n_steps = max(1, len(items)) + self.update_content() + if auto_follow: + self.action_last_step() + + # --- Structured state helpers --- + + def _update_tool_call( + self, tool_id: str, *, title: Optional[str] = None, status: Optional[str] = None, content=None + ) -> None: + tc = self._tool_calls.get(tool_id, {"toolCallId": tool_id, "title": "", "status": "pending", "content": []}) + if title is not None: + tc["title"] = title + if status is not None: + tc["status"] = status + if content: + # Append any text content blocks + texts = [] + for c in content: + if isinstance(c, ToolCallContent1) and getattr(c.content, "type", None) == "text": + texts.append(getattr(c.content, "text", "")) + if texts: + tc.setdefault("content", []).append("\n".join(texts)) + self._tool_calls[tool_id] = tc + + def update_content(self) -> None: + container = self.query_one("#content", Vertical) + container.remove_children() + if not self.messages: + container.mount(Static("Waiting for agent…")) + return + items = _messages_to_steps(self.messages) + page = items[self._i_step] if items else [] + for m in page[-400:]: + message_container = Vertical(classes="message-container") + container.mount(message_container) + role = m.role.replace("assistant", "mini-swe-agent").upper() + message_container.mount(Static(role, classes="message-header")) + message_container.mount(Static(Text(m.content, no_wrap=False), classes="message-content")) + # Render structured tool calls at the end of the page + if self._tool_calls: + tc_container = Vertical(classes="message-container") + container.mount(tc_container) + tc_container.mount(Static("TOOL CALLS", classes="message-header")) + for tcid, tc in self._tool_calls.items(): + block = Vertical(classes="message-content") + tc_container.mount(block) + status = tc.get("status", "") + title = tc.get("title", "") + block.mount(Static(Text(f"[TOOL] {title} — {status}", no_wrap=False))) + for chunk in tc.get("content", []) or []: + block.mount(Static(Text(chunk, no_wrap=False))) + if self.input_container.pending_prompt is not None: + self.agent_state = "AWAITING_INPUT" + self.input_container.display = ( + self.input_container.pending_prompt is not None and self._i_step == len(items) - 1 + ) + if self.input_container.display: + self.input_container.on_focus() + self._update_headers() + self.refresh() + + def _update_headers(self) -> None: + status_text = self.agent_state + if self.agent_state == "RUNNING": + spinner_frame = str(self._spinner.render(time.time())).strip() + status_text = f"{self.agent_state} {spinner_frame}" + self.title = f"Step {self._i_step + 1}/{self.n_steps} - {status_text}" + try: + self.query_one("Header").set_class(self.agent_state == "RUNNING", "running") + except NoMatches: + pass + + # --- Actions --- + + # --- Pagination helpers --- + + @property + def i_step(self) -> int: + return self._i_step + + @i_step.setter + def i_step(self, value: int) -> None: + if value != self._i_step: + self._i_step = max(0, min(value, self.n_steps - 1)) + self._vscroll.scroll_to(y=0, animate=False) + self.update_content() + + # --- Actions --- + + def action_next_step(self) -> None: + self.i_step += 1 + + def action_previous_step(self) -> None: + self.i_step -= 1 + + def action_first_step(self) -> None: + self.i_step = 0 + + def action_last_step(self) -> None: + self.i_step = self.n_steps - 1 + + def action_scroll_down(self) -> None: + self._vscroll.scroll_to(y=self._vscroll.scroll_target_y + 15) + + def action_scroll_up(self) -> None: + self._vscroll.scroll_to(y=self._vscroll.scroll_target_y - 15) + + def _set_agent_mode_async(self, mode_id: str) -> None: + if not self._conn or not self._session_id or not self._bg_loop: + return + + def _schedule() -> None: + try: + self._bg_loop.create_task( + self._conn.setSessionMode(SetSessionModeRequest(sessionId=self._session_id, modeId=mode_id)) + ) + except Exception: + pass + + try: + self._bg_loop.call_soon_threadsafe(_schedule) + except Exception: + pass + + def action_yolo(self): + self.mode = "yolo" + self._set_agent_mode_async("yolo") + if self.input_container.pending_prompt is not None: + self.input_container._complete_input("") + self.notify("YOLO mode enabled") + + def action_confirm(self): + self.mode = "confirm" + self._set_agent_mode_async("confirm") + if self.input_container.pending_prompt is not None: + self.input_container._complete_input("") + self.notify("Confirm mode enabled") + + def action_human(self): + self.mode = "human" + self._set_agent_mode_async("human") + + # Ask for a command asynchronously to avoid blocking UI + def _ask(): + cmd = self.input_container.request_input("Type a bash command to run:") + if cmd.strip(): + self.send_human_command(cmd) + + threading.Thread(target=_ask, daemon=True).start() + self.notify("Human mode: commands will be executed as you submit them") + + def action_continue_step(self): + # For non-human modes, enqueue an empty turn to advance one step. + if self.mode != "human": + self._outbox.put([]) + return + + # For human, prompt for next command. + def _ask(): + cmd = self.input_container.request_input("Type a bash command to run:") + if cmd.strip(): + self.send_human_command(cmd) + + threading.Thread(target=_ask, daemon=True).start() + + def action_toggle_help_panel(self) -> None: + if self.query("HelpPanel"): + self.action_hide_help_panel() + else: + self.action_show_help_panel() + + +def main() -> None: + app = TextualMiniSweClient() + app.run() + + +if __name__ == "__main__": + main() diff --git a/examples/mini_swe_agent/duet.py b/examples/mini_swe_agent/duet.py new file mode 100644 index 0000000..a0e0487 --- /dev/null +++ b/examples/mini_swe_agent/duet.py @@ -0,0 +1,91 @@ +import asyncio +import contextlib +import os +import sys +from pathlib import Path + + +async def main() -> None: + # Launch agent and client, wiring a dedicated pipe pair for ACP protocol. + # Client keeps its own stdin/stdout for the Textual UI. + root = Path(__file__).resolve().parent + agent_path = str(root / "agent.py") + client_path = str(root / "client.py") + + # Load .env into process env so children inherit it (prefer python-dotenv if available) + try: + from dotenv import load_dotenv # type: ignore + + # Load .env from repo root: examples/mini_swe_agent -> examples -> REPO + load_dotenv(dotenv_path=str(root.parents[1] / ".env"), override=True) + except Exception: + pass + + base_env = os.environ.copy() + src_dir = str((root.parents[1] / "src").resolve()) + base_env["PYTHONPATH"] = src_dir + os.pathsep + base_env.get("PYTHONPATH", "") + + # Create two pipes: agent->client and client->agent + a2c_r, a2c_w = os.pipe() + c2a_r, c2a_w = os.pipe() + # Ensure the FDs we pass to children are inheritable + for fd in (a2c_r, a2c_w, c2a_r, c2a_w): + os.set_inheritable(fd, True) + + # Start agent: stdin <- client (c2a_r), stdout -> client (a2c_w) + agent = await asyncio.create_subprocess_exec( + sys.executable, + agent_path, + stdin=c2a_r, + stdout=a2c_w, + stderr=sys.stderr, + env=base_env, + close_fds=True, + ) + + # Start client with ACP FDs exported via environment; keep terminal IO for UI + client_env = base_env.copy() + client_env["MSWEA_READ_FD"] = str(a2c_r) # where client reads ACP messages + client_env["MSWEA_WRITE_FD"] = str(c2a_w) # where client writes ACP messages + + client = await asyncio.create_subprocess_exec( + sys.executable, + client_path, + env=client_env, + pass_fds=(a2c_r, c2a_w), # ensure client inherits these FDs + close_fds=True, + ) + + # Close parent's copies of the pipe ends to avoid leaks + for fd in (a2c_r, a2c_w, c2a_r, c2a_w): + with contextlib.suppress(OSError): + os.close(fd) + + # If either process exits, terminate the other gracefully + agent_task = asyncio.create_task(agent.wait()) + client_task = asyncio.create_task(client.wait()) + done, pending = await asyncio.wait({agent_task, client_task}, return_when=asyncio.FIRST_COMPLETED) + + # Terminate the peer process + if agent_task in done and client.returncode is None: + with contextlib.suppress(ProcessLookupError): + client.terminate() + if client_task in done and agent.returncode is None: + with contextlib.suppress(ProcessLookupError): + agent.terminate() + + # Wait a bit, then kill if still running + try: + await asyncio.wait_for(asyncio.gather(agent.wait(), client.wait()), timeout=3) + except asyncio.TimeoutError: + with contextlib.suppress(ProcessLookupError): + if agent.returncode is None: + agent.kill() + with contextlib.suppress(ProcessLookupError): + if client.returncode is None: + client.kill() + await asyncio.gather(agent.wait(), client.wait()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index b7996d5..c14aa3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-client-protocol" -version = "0.0.1" +version = "0.3.0" description = "A Python implement of Agent Client Protocol (ACP, by Zed Industries)" authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] readme = "README.md" @@ -40,6 +40,7 @@ dev = [ "mkdocs-material>=8.5.10", "mkdocstrings[python]>=0.26.1", "mini-swe-agent>=1.10.0", + "python-dotenv>=1.1.1", ] [build-system] diff --git a/schema/meta.json b/schema/meta.json index 741ab3d..ec0ba7d 100644 --- a/schema/meta.json +++ b/schema/meta.json @@ -5,7 +5,8 @@ "session_cancel": "session/cancel", "session_load": "session/load", "session_new": "session/new", - "session_prompt": "session/prompt" + "session_prompt": "session/prompt", + "session_set_mode": "session/set_mode" }, "clientMethods": { "fs_read_text_file": "fs/read_text_file", diff --git a/schema/schema.json b/schema/schema.json index 3aadf42..8e7e476 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -3,11 +3,22 @@ "AgentCapabilities": { "description": "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "loadSession": { "default": false, "description": "Whether the agent supports `session/load`.", "type": "boolean" }, + "mcpCapabilities": { + "$ref": "#/$defs/McpCapabilities", + "default": { + "http": false, + "sse": false + }, + "description": "MCP capabilities supported by the agent." + }, "promptCapabilities": { "$ref": "#/$defs/PromptCapabilities", "default": { @@ -25,6 +36,9 @@ { "$ref": "#/$defs/SessionNotification", "title": "SessionNotification" + }, + { + "title": "ExtNotification" } ], "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response.", @@ -61,8 +75,11 @@ "title": "WaitForTerminalExitRequest" }, { - "$ref": "#/$defs/KillTerminalRequest", - "title": "KillTerminalRequest" + "$ref": "#/$defs/KillTerminalCommandRequest", + "title": "KillTerminalCommandRequest" + }, + { + "title": "ExtMethodRequest" } ], "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Client`] trait.\n\nThis enum encompasses all method calls from agent to client.", @@ -75,20 +92,27 @@ "title": "InitializeResponse" }, { - "title": "AuthenticateResponse", - "type": "null" + "$ref": "#/$defs/AuthenticateResponse", + "title": "AuthenticateResponse" }, { "$ref": "#/$defs/NewSessionResponse", "title": "NewSessionResponse" }, { - "title": "LoadSessionResponse", - "type": "null" + "$ref": "#/$defs/LoadSessionResponse", + "title": "LoadSessionResponse" + }, + { + "$ref": "#/$defs/SetSessionModeResponse", + "title": "SetSessionModeResponse" }, { "$ref": "#/$defs/PromptResponse", "title": "PromptResponse" + }, + { + "title": "ExtMethodResponse" } ], "description": "All possible responses that an agent can send to a client.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding ClientRequest variants.", @@ -97,6 +121,9 @@ "Annotations": { "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "audience": { "items": { "$ref": "#/$defs/Role" @@ -125,6 +152,9 @@ "AudioContent": { "description": "Audio provided to or from an LLM.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -151,6 +181,9 @@ "AuthMethod": { "description": "Describes an available authentication method.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "description": { "description": "Optional description providing more details about this authentication method.", "type": [ @@ -180,6 +213,9 @@ "AuthenticateRequest": { "description": "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "methodId": { "$ref": "#/$defs/AuthMethodId", "description": "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response." @@ -192,9 +228,23 @@ "x-method": "authenticate", "x-side": "agent" }, + "AuthenticateResponse": { + "description": "Response to authenticate method", + "properties": { + "_meta": { + "description": "Extension point for implementations" + } + }, + "type": "object", + "x-method": "authenticate", + "x-side": "agent" + }, "AvailableCommand": { "description": "Information about a command.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "description": { "description": "Human-readable description of what the command does.", "type": "string" @@ -227,7 +277,7 @@ "description": "All text that was typed after the command name is provided as input.", "properties": { "hint": { - "description": "A brief description of the expected input", + "description": "A hint to display when the input hasn't been provided yet", "type": "string" } }, @@ -237,11 +287,15 @@ "title": "UnstructuredCommandInput", "type": "object" } - ] + ], + "description": "The input specification for a command." }, "BlobResourceContents": { "description": "Binary resource contents.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "blob": { "type": "string" }, @@ -264,6 +318,9 @@ "CancelNotification": { "description": "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { "$ref": "#/$defs/SessionId", "description": "The ID of the session to cancel operations for." @@ -279,6 +336,9 @@ "ClientCapabilities": { "description": "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "fs": { "$ref": "#/$defs/FileSystemCapability", "default": { @@ -289,7 +349,7 @@ }, "terminal": { "default": false, - "description": "**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.", + "description": "Whether the Client support all `terminal/*` methods.", "type": "boolean" } }, @@ -300,6 +360,9 @@ { "$ref": "#/$defs/CancelNotification", "title": "CancelNotification" + }, + { + "title": "ExtNotification" } ], "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response.", @@ -323,9 +386,16 @@ "$ref": "#/$defs/LoadSessionRequest", "title": "LoadSessionRequest" }, + { + "$ref": "#/$defs/SetSessionModeRequest", + "title": "SetSessionModeRequest" + }, { "$ref": "#/$defs/PromptRequest", "title": "PromptRequest" + }, + { + "title": "ExtMethodRequest" } ], "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly - instead, use the methods on the [`Agent`] trait.\n\nThis enum encompasses all method calls from client to agent.", @@ -334,8 +404,8 @@ "ClientResponse": { "anyOf": [ { - "title": "WriteTextFileResponse", - "type": "null" + "$ref": "#/$defs/WriteTextFileResponse", + "title": "WriteTextFileResponse" }, { "$ref": "#/$defs/ReadTextFileResponse", @@ -354,16 +424,19 @@ "title": "TerminalOutputResponse" }, { - "title": "ReleaseTerminalResponse", - "type": "null" + "$ref": "#/$defs/ReleaseTerminalResponse", + "title": "ReleaseTerminalResponse" }, { "$ref": "#/$defs/WaitForTerminalExitResponse", "title": "WaitForTerminalExitResponse" }, { - "title": "KillTerminalResponse", - "type": "null" + "$ref": "#/$defs/KillTerminalCommandResponse", + "title": "KillTerminalResponse" + }, + { + "title": "ExtMethodResponse" } ], "description": "All possible responses that a client can send to an agent.\n\nThis enum is used internally for routing RPC responses. You typically won't need\nto use this directly - the responses are handled automatically by the connection.\n\nThese are responses to the corresponding AgentRequest variants.", @@ -375,6 +448,9 @@ { "description": "Plain text content\n\nAll agents MUST support text content blocks in prompts.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -402,6 +478,9 @@ { "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -439,6 +518,9 @@ { "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -470,6 +552,9 @@ { "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -526,6 +611,9 @@ { "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -553,29 +641,38 @@ ] }, "CreateTerminalRequest": { + "description": "Request to create a new terminal and execute a command.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "args": { + "description": "Array of command arguments.", "items": { "type": "string" }, "type": "array" }, "command": { + "description": "The command to execute.", "type": "string" }, "cwd": { + "description": "Working directory for the command (absolute path).", "type": [ "string", "null" ] }, "env": { + "description": "Environment variables for the command.", "items": { "$ref": "#/$defs/EnvVariable" }, "type": "array" }, "outputByteLimit": { + "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", "format": "uint64", "minimum": 0, "type": [ @@ -584,7 +681,8 @@ ] }, "sessionId": { - "$ref": "#/$defs/SessionId" + "$ref": "#/$defs/SessionId", + "description": "The session ID for this request." } }, "required": [ @@ -592,11 +690,17 @@ "command" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/create", + "x-side": "client" }, "CreateTerminalResponse": { + "description": "Response containing the ID of the created terminal.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "terminalId": { + "description": "The unique identifier for the created terminal.", "type": "string" } }, @@ -604,11 +708,15 @@ "terminalId" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/create", + "x-side": "client" }, "EmbeddedResource": { "description": "The contents of a resource, embedded into a prompt or tool call result.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -644,6 +752,9 @@ "EnvVariable": { "description": "An environment variable to set when launching an MCP server.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "name": { "description": "The name of the environment variable.", "type": "string" @@ -662,6 +773,9 @@ "FileSystemCapability": { "description": "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "readTextFile": { "default": false, "description": "Whether the Client supports `fs/read_text_file` requests.", @@ -675,9 +789,33 @@ }, "type": "object" }, + "HttpHeader": { + "description": "An HTTP header to set when making requests to the MCP server.", + "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "name": { + "description": "The name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "The value to set for the HTTP header.", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, "ImageContent": { "description": "An image provided to or from an LLM.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -710,6 +848,9 @@ "InitializeRequest": { "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "clientCapabilities": { "$ref": "#/$defs/ClientCapabilities", "default": { @@ -736,10 +877,17 @@ "InitializeResponse": { "description": "Response from the initialize method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "agentCapabilities": { "$ref": "#/$defs/AgentCapabilities", "default": { "loadSession": false, + "mcpCapabilities": { + "http": false, + "sse": false + }, "promptCapabilities": { "audio": false, "embeddedContext": false, @@ -768,12 +916,18 @@ "x-method": "initialize", "x-side": "agent" }, - "KillTerminalRequest": { + "KillTerminalCommandRequest": { + "description": "Request to kill a terminal command without releasing the terminal.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { - "$ref": "#/$defs/SessionId" + "$ref": "#/$defs/SessionId", + "description": "The session ID for this request." }, "terminalId": { + "description": "The ID of the terminal to kill.", "type": "string" } }, @@ -782,11 +936,26 @@ "terminalId" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/kill", + "x-side": "client" + }, + "KillTerminalCommandResponse": { + "description": "Response to terminal/kill command method", + "properties": { + "_meta": { + "description": "Extension point for implementations" + } + }, + "type": "object", + "x-method": "terminal/kill", + "x-side": "client" }, "LoadSessionRequest": { - "description": "Request parameters for loading an existing session.\n\nOnly available if the agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "description": "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "cwd": { "description": "The working directory for this session.", "type": "string" @@ -812,43 +981,155 @@ "x-method": "session/load", "x-side": "agent" }, - "McpServer": { - "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)", + "LoadSessionResponse": { + "description": "Response from loading an existing session.", "properties": { - "args": { - "description": "Command-line arguments to pass to the MCP server.", - "items": { - "type": "string" - }, - "type": "array" + "_meta": { + "description": "Extension point for implementations" }, - "command": { - "description": "Path to the MCP server executable.", - "type": "string" + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + } + }, + "type": "object", + "x-method": "session/load", + "x-side": "agent" + }, + "McpCapabilities": { + "description": "MCP capabilities supported by the agent", + "properties": { + "_meta": { + "description": "Extension point for implementations" }, - "env": { - "description": "Environment variables to set when launching the MCP server.", - "items": { - "$ref": "#/$defs/EnvVariable" - }, - "type": "array" + "http": { + "default": false, + "description": "Agent supports [`McpServer::Http`].", + "type": "boolean" }, - "name": { - "description": "Human-readable name identifying this MCP server.", - "type": "string" + "sse": { + "default": false, + "description": "Agent supports [`McpServer::Sse`].", + "type": "boolean" } }, - "required": [ - "name", - "command", - "args", - "env" - ], "type": "object" }, + "McpServer": { + "anyOf": [ + { + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`.", + "properties": { + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "type": { + "const": "http", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": [ + "type", + "name", + "url", + "headers" + ], + "type": "object" + }, + { + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`.", + "properties": { + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "items": { + "$ref": "#/$defs/HttpHeader" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "type": { + "const": "sse", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + } + }, + "required": [ + "type", + "name", + "url", + "headers" + ], + "type": "object" + }, + { + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport.", + "properties": { + "args": { + "description": "Command-line arguments to pass to the MCP server.", + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "description": "Path to the MCP server executable.", + "type": "string" + }, + "env": { + "description": "Environment variables to set when launching the MCP server.", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "type": "array" + }, + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + } + }, + "required": [ + "name", + "command", + "args", + "env" + ], + "title": "stdio", + "type": "object" + } + ], + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)" + }, "NewSessionRequest": { "description": "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "cwd": { "description": "The working directory for this session. Must be an absolute path.", "type": "string" @@ -872,6 +1153,20 @@ "NewSessionResponse": { "description": "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "modes": { + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)" + }, "sessionId": { "$ref": "#/$defs/SessionId", "description": "Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation." @@ -887,6 +1182,9 @@ "PermissionOption": { "description": "An option presented to the user when requesting permission.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "kind": { "$ref": "#/$defs/PermissionOptionKind", "description": "Hint about the nature of this permission option." @@ -939,6 +1237,9 @@ "Plan": { "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "entries": { "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", "items": { @@ -955,6 +1256,9 @@ "PlanEntry": { "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "Human-readable description of what this task aims to accomplish.", "type": "string" @@ -1018,6 +1322,9 @@ "PromptCapabilities": { "description": "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "audio": { "default": false, "description": "Agent supports [`ContentBlock::Audio`].", @@ -1039,6 +1346,9 @@ "PromptRequest": { "description": "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "prompt": { "description": "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", "items": { @@ -1062,6 +1372,9 @@ "PromptResponse": { "description": "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "stopReason": { "$ref": "#/$defs/StopReason", "description": "Indicates why the agent stopped processing the turn." @@ -1084,8 +1397,11 @@ "ReadTextFileRequest": { "description": "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "limit": { - "description": "Optional maximum number of lines to read.", + "description": "Maximum number of lines to read.", "format": "uint32", "minimum": 0, "type": [ @@ -1094,7 +1410,7 @@ ] }, "line": { - "description": "Optional line number to start reading from (1-based).", + "description": "Line number to start reading from (1-based).", "format": "uint32", "minimum": 0, "type": [ @@ -1122,6 +1438,9 @@ "ReadTextFileResponse": { "description": "Response containing the contents of a text file.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "type": "string" } @@ -1129,14 +1448,22 @@ "required": [ "content" ], - "type": "object" + "type": "object", + "x-method": "fs/read_text_file", + "x-side": "client" }, "ReleaseTerminalRequest": { + "description": "Request to release a terminal and free its resources.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { - "$ref": "#/$defs/SessionId" + "$ref": "#/$defs/SessionId", + "description": "The session ID for this request." }, "terminalId": { + "description": "The ID of the terminal to release.", "type": "string" } }, @@ -1145,7 +1472,19 @@ "terminalId" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/release", + "x-side": "client" + }, + "ReleaseTerminalResponse": { + "description": "Response to terminal/release method", + "properties": { + "_meta": { + "description": "Extension point for implementations" + } + }, + "type": "object", + "x-method": "terminal/release", + "x-side": "client" }, "RequestPermissionOutcome": { "description": "The outcome of a permission request.", @@ -1186,6 +1525,9 @@ "RequestPermissionRequest": { "description": "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "options": { "description": "Available permission options for the user to choose from.", "items": { @@ -1214,6 +1556,9 @@ "RequestPermissionResponse": { "description": "Response to a permission request.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "outcome": { "$ref": "#/$defs/RequestPermissionOutcome", "description": "The user's decision on the permission request." @@ -1229,6 +1574,9 @@ "ResourceLink": { "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -1289,9 +1637,65 @@ "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from(\"sess_abc123def456\"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", "type": "string" }, + "SessionMode": { + "description": "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "$ref": "#/$defs/SessionModeId" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "SessionModeId": { + "description": "Unique identifier for a Session Mode.", + "type": "string" + }, + "SessionModeState": { + "description": "The set of modes and the one currently active.", + "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "availableModes": { + "description": "The set of modes that the Agent can operate in", + "items": { + "$ref": "#/$defs/SessionMode" + }, + "type": "array" + }, + "currentModeId": { + "$ref": "#/$defs/SessionModeId", + "description": "The current mode the Agent is in." + } + }, + "required": [ + "currentModeId", + "availableModes" + ], + "type": "object" + }, "SessionNotification": { "description": "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { "$ref": "#/$defs/SessionId", "description": "The ID of the session this update pertains to." @@ -1366,6 +1770,9 @@ { "description": "Notification that a new tool call has been initiated.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "Content produced by the tool call.", "items": { @@ -1417,6 +1824,9 @@ { "description": "Update on the status or results of a tool call.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "Replace the content collection.", "items": { @@ -1490,6 +1900,9 @@ { "description": "The agent's execution plan for complex tasks.\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "entries": { "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", "items": { @@ -1526,11 +1939,59 @@ "sessionUpdate", "availableCommands" ], - "type": "object", - "x-docs-ignore": true + "type": "object" + }, + { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "properties": { + "currentModeId": { + "$ref": "#/$defs/SessionModeId" + }, + "sessionUpdate": { + "const": "current_mode_update", + "type": "string" + } + }, + "required": [ + "sessionUpdate", + "currentModeId" + ], + "type": "object" } ] }, + "SetSessionModeRequest": { + "description": "Request parameters for setting a session mode.", + "properties": { + "_meta": { + "description": "Extension point for implementations" + }, + "modeId": { + "$ref": "#/$defs/SessionModeId", + "description": "The ID of the mode to set." + }, + "sessionId": { + "$ref": "#/$defs/SessionId", + "description": "The ID of the session to set the mode for." + } + }, + "required": [ + "sessionId", + "modeId" + ], + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, + "SetSessionModeResponse": { + "description": "Response to `session/set_mode` method.", + "properties": { + "meta": true + }, + "type": "object", + "x-method": "session/set_mode", + "x-side": "agent" + }, "StopReason": { "description": "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", "oneOf": [ @@ -1562,8 +2023,13 @@ ] }, "TerminalExitStatus": { + "description": "Exit status of a terminal command.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", "format": "uint32", "minimum": 0, "type": [ @@ -1572,21 +2038,27 @@ ] }, "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", "type": [ "string", "null" ] } }, - "type": "object", - "x-docs-ignore": true + "type": "object" }, "TerminalOutputRequest": { + "description": "Request to get the current output and status of a terminal.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { - "$ref": "#/$defs/SessionId" + "$ref": "#/$defs/SessionId", + "description": "The session ID for this request." }, "terminalId": { + "description": "The ID of the terminal to get output from.", "type": "string" } }, @@ -1595,10 +2067,15 @@ "terminalId" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/output", + "x-side": "client" }, "TerminalOutputResponse": { + "description": "Response containing the terminal output and exit status.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "exitStatus": { "anyOf": [ { @@ -1607,12 +2084,15 @@ { "type": "null" } - ] + ], + "description": "Exit status if the command has completed." }, "output": { + "description": "The terminal output captured so far.", "type": "string" }, "truncated": { + "description": "Whether the output was truncated due to byte limits.", "type": "boolean" } }, @@ -1621,11 +2101,15 @@ "truncated" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/output", + "x-side": "client" }, "TextContent": { "description": "Text provided to or from an LLM.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "annotations": { "anyOf": [ { @@ -1648,6 +2132,9 @@ "TextResourceContents": { "description": "Text-based resource contents.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "mimeType": { "type": [ "string", @@ -1670,6 +2157,9 @@ "ToolCall": { "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "Content produced by the tool call.", "items": { @@ -1737,6 +2227,9 @@ { "description": "File modification shown as a diff.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "newText": { "description": "The new content after modification.", "type": "string" @@ -1765,6 +2258,7 @@ "type": "object" }, { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminal)", "properties": { "terminalId": { "type": "string" @@ -1789,6 +2283,9 @@ "ToolCallLocation": { "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "line": { "description": "Optional line number within the file.", "format": "uint32", @@ -1836,6 +2333,9 @@ "ToolCallUpdate": { "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "Replace the content collection.", "items": { @@ -1944,6 +2444,11 @@ "description": "Retrieving external data.", "type": "string" }, + { + "const": "switch_mode", + "description": "Switching the current session mode.", + "type": "string" + }, { "const": "other", "description": "Other tool types (default).", @@ -1952,11 +2457,17 @@ ] }, "WaitForTerminalExitRequest": { + "description": "Request to wait for a terminal command to exit.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "sessionId": { - "$ref": "#/$defs/SessionId" + "$ref": "#/$defs/SessionId", + "description": "The session ID for this request." }, "terminalId": { + "description": "The ID of the terminal to wait for.", "type": "string" } }, @@ -1965,11 +2476,17 @@ "terminalId" ], "type": "object", - "x-docs-ignore": true + "x-method": "terminal/wait_for_exit", + "x-side": "client" }, "WaitForTerminalExitResponse": { + "description": "Response containing the exit status of a terminal command.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", "format": "uint32", "minimum": 0, "type": [ @@ -1978,6 +2495,7 @@ ] }, "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", "type": [ "string", "null" @@ -1985,11 +2503,15 @@ } }, "type": "object", - "x-docs-ignore": true + "x-method": "terminal/wait_for_exit", + "x-side": "client" }, "WriteTextFileRequest": { "description": "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", "properties": { + "_meta": { + "description": "Extension point for implementations" + }, "content": { "description": "The text content to write to the file.", "type": "string" @@ -2011,6 +2533,17 @@ "type": "object", "x-method": "fs/write_text_file", "x-side": "client" + }, + "WriteTextFileResponse": { + "description": "Response to fs/write_text_file", + "properties": { + "_meta": { + "description": "Extension point for implementations" + } + }, + "type": "object", + "x-method": "fs/write_text_file", + "x-side": "client" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/src/acp/__init__.py b/src/acp/__init__.py index b0ba14f..c21712a 100644 --- a/src/acp/__init__.py +++ b/src/acp/__init__.py @@ -13,9 +13,14 @@ ) from .schema import ( AuthenticateRequest, + AuthenticateResponse, CancelNotification, + CreateTerminalRequest, + CreateTerminalResponse, InitializeRequest, InitializeResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, LoadSessionRequest, NewSessionRequest, NewSessionResponse, @@ -23,10 +28,19 @@ PromptResponse, ReadTextFileRequest, ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, WriteTextFileRequest, + WriteTextFileResponse, ) from .stdio import stdio_streams @@ -42,15 +56,30 @@ "NewSessionResponse", "LoadSessionRequest", "AuthenticateRequest", + "AuthenticateResponse", "PromptRequest", "PromptResponse", "WriteTextFileRequest", + "WriteTextFileResponse", "ReadTextFileRequest", "ReadTextFileResponse", "RequestPermissionRequest", "RequestPermissionResponse", "CancelNotification", "SessionNotification", + "SetSessionModeRequest", + "SetSessionModeResponse", + # terminal types + "CreateTerminalRequest", + "CreateTerminalResponse", + "TerminalOutputRequest", + "TerminalOutputResponse", + "WaitForTerminalExitRequest", + "WaitForTerminalExitResponse", + "KillTerminalCommandRequest", + "KillTerminalCommandResponse", + "ReleaseTerminalRequest", + "ReleaseTerminalResponse", # core "AgentSideConnection", "ClientSideConnection", diff --git a/src/acp/core.py b/src/acp/core.py index 689e235..2d54c09 100644 --- a/src/acp/core.py +++ b/src/acp/core.py @@ -3,6 +3,7 @@ import asyncio import contextlib import json +import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, Protocol @@ -12,9 +13,14 @@ from .meta import AGENT_METHODS, CLIENT_METHODS, PROTOCOL_VERSION # noqa: F401 from .schema import ( AuthenticateRequest, + AuthenticateResponse, CancelNotification, + CreateTerminalRequest, + CreateTerminalResponse, InitializeRequest, InitializeResponse, + KillTerminalCommandRequest, + KillTerminalCommandResponse, LoadSessionRequest, NewSessionRequest, NewSessionResponse, @@ -22,10 +28,19 @@ PromptResponse, ReadTextFileRequest, ReadTextFileResponse, + ReleaseTerminalRequest, + ReleaseTerminalResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification, + SetSessionModeRequest, + SetSessionModeResponse, + TerminalOutputRequest, + TerminalOutputResponse, + WaitForTerminalExitRequest, + WaitForTerminalExitResponse, WriteTextFileRequest, + WriteTextFileResponse, ) # --- JSON-RPC 2.0 error helpers ------------------------------------------------- @@ -71,7 +86,8 @@ def to_error_obj(self) -> dict: # --- Transport & Connection ------------------------------------------------------ JsonValue = Any -MethodHandler = Callable[[str, JsonValue | None], Awaitable[JsonValue | None]] +MethodHandler = Callable[[str, JsonValue | None, bool], Awaitable[JsonValue | None]] +_NO_MATCH = object() @dataclass(slots=True) @@ -121,19 +137,8 @@ async def _receive_loop(self) -> None: try: message = json.loads(line) except Exception: - # No id to reply to -> ignore; with id -> send parse error - # Try to peek id; if not possible, skip - try: - maybe = json.loads(line.decode("utf-8", errors="ignore")) - msg_id = maybe.get("id") if isinstance(maybe, dict) else None - except Exception: - msg_id = None - if msg_id is not None: - await self._send_obj({ - "jsonrpc": "2.0", - "id": msg_id, - "error": RequestError.parse_error().to_error_obj(), - }) + # Align with Rust/TS: on parse error, do not send a response; just skip + logging.exception("Error parsing JSON-RPC message") continue await self._process_message(message) @@ -155,7 +160,7 @@ async def _handle_request(self, message: dict) -> None: """Handle JSON-RPC request.""" payload = {"jsonrpc": "2.0", "id": message["id"]} try: - result = await self._handler(message["method"], message.get("params")) + result = await self._handler(message["method"], message.get("params"), False) if isinstance(result, BaseModel): result = result.model_dump() payload["result"] = result if result is not None else None @@ -175,7 +180,7 @@ async def _handle_notification(self, message: dict) -> None: """Handle JSON-RPC notification.""" with contextlib.suppress(Exception): # Best-effort; notifications do not produce responses - await self._handler(message["method"], message.get("params")) + await self._handler(message["method"], message.get("params"), True) async def _handle_response(self, message: dict) -> None: """Handle JSON-RPC response.""" @@ -222,20 +227,25 @@ async def requestPermission(self, params: RequestPermissionRequest) -> RequestPe async def sessionUpdate(self, params: SessionNotification) -> None: ... - async def writeTextFile(self, params: WriteTextFileRequest) -> None: ... + async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: ... async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileResponse: ... # Optional/unstable terminal methods - async def createTerminal(self, params: Any) -> Any: ... + async def createTerminal(self, params: CreateTerminalRequest) -> CreateTerminalResponse: ... - async def terminalOutput(self, params: Any) -> Any: ... + async def terminalOutput(self, params: TerminalOutputRequest) -> TerminalOutputResponse: ... - async def releaseTerminal(self, params: Any) -> None: ... + async def releaseTerminal(self, params: ReleaseTerminalRequest) -> ReleaseTerminalResponse | None: ... - async def waitForTerminalExit(self, params: Any) -> Any: ... + async def waitForTerminalExit(self, params: WaitForTerminalExitRequest) -> WaitForTerminalExitResponse: ... - async def killTerminal(self, params: Any) -> None: ... + async def killTerminal(self, params: KillTerminalCommandRequest) -> KillTerminalCommandResponse | None: ... + + # Extension hooks (optional) + async def extMethod(self, method: str, params: dict) -> dict: ... + + async def extNotification(self, method: str, params: dict) -> None: ... class Agent(Protocol): @@ -245,12 +255,19 @@ async def newSession(self, params: NewSessionRequest) -> NewSessionResponse: ... async def loadSession(self, params: LoadSessionRequest) -> None: ... - async def authenticate(self, params: AuthenticateRequest) -> None: ... + async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: ... async def prompt(self, params: PromptRequest) -> PromptResponse: ... async def cancel(self, params: CancelNotification) -> None: ... + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: ... + + # Extension hooks (optional) + async def extMethod(self, method: str, params: dict) -> dict: ... + + async def extNotification(self, method: str, params: dict) -> None: ... + class AgentSideConnection: """ @@ -270,33 +287,87 @@ def __init__( ) -> None: agent = to_agent(self) - async def handler(method: str, params: Any) -> Any: - if method == AGENT_METHODS["initialize"]: - p = InitializeRequest.model_validate(params) - return await agent.initialize(p) - if method == AGENT_METHODS["session_new"]: - p = NewSessionRequest.model_validate(params) - return await agent.newSession(p) - if method == AGENT_METHODS["session_load"]: - if not hasattr(agent, "loadSession"): - raise RequestError.method_not_found(method) - p = LoadSessionRequest.model_validate(params) - return await agent.loadSession(p) - if method == AGENT_METHODS["authenticate"]: - p = AuthenticateRequest.model_validate(params) - return await agent.authenticate(p) - if method == AGENT_METHODS["session_prompt"]: - p = PromptRequest.model_validate(params) - return await agent.prompt(p) - if method == AGENT_METHODS["session_cancel"]: - p = CancelNotification.model_validate(params) - return await agent.cancel(p) - raise RequestError.method_not_found(method) + handler = self._create_agent_handler(agent) if not isinstance(input_stream, asyncio.StreamWriter) or not isinstance(output_stream, asyncio.StreamReader): raise TypeError(_AGENT_CONNECTION_ERROR) self._conn = Connection(handler, input_stream, output_stream) + def _create_agent_handler(self, agent: Agent) -> MethodHandler: + async def handler(method: str, params: Any, is_notification: bool) -> Any: + return await self._handle_agent_method(agent, method, params, is_notification) + + return handler + + async def _handle_agent_method(self, agent: Agent, method: str, params: Any, is_notification: bool) -> Any: + # Init/new + result = await self._handle_agent_init_methods(agent, method, params) + if result is not _NO_MATCH: + return result + # Session-related + result = await self._handle_agent_session_methods(agent, method, params) + if result is not _NO_MATCH: + return result + # Auth + result = await self._handle_agent_auth_methods(agent, method, params) + if result is not _NO_MATCH: + return result + # Extensions + result = await self._handle_agent_ext_methods(agent, method, params, is_notification) + if result is not _NO_MATCH: + return result + raise RequestError.method_not_found(method) + + async def _handle_agent_init_methods(self, agent: Agent, method: str, params: Any) -> Any: + if method == AGENT_METHODS["initialize"]: + p = InitializeRequest.model_validate(params) + return await agent.initialize(p) + if method == AGENT_METHODS["session_new"]: + p = NewSessionRequest.model_validate(params) + return await agent.newSession(p) + return _NO_MATCH + + async def _handle_agent_session_methods(self, agent: Agent, method: str, params: Any) -> Any: + if method == AGENT_METHODS["session_load"]: + if not hasattr(agent, "loadSession"): + raise RequestError.method_not_found(method) + p = LoadSessionRequest.model_validate(params) + return await agent.loadSession(p) + if method == AGENT_METHODS["session_set_mode"]: + if not hasattr(agent, "setSessionMode"): + raise RequestError.method_not_found(method) + p = SetSessionModeRequest.model_validate(params) + result = await agent.setSessionMode(p) + return result.model_dump() if isinstance(result, BaseModel) else (result or {}) + if method == AGENT_METHODS["session_prompt"]: + p = PromptRequest.model_validate(params) + return await agent.prompt(p) + if method == AGENT_METHODS["session_cancel"]: + p = CancelNotification.model_validate(params) + return await agent.cancel(p) + return _NO_MATCH + + async def _handle_agent_auth_methods(self, agent: Agent, method: str, params: Any) -> Any: + if method == AGENT_METHODS["authenticate"]: + p = AuthenticateRequest.model_validate(params) + result = await agent.authenticate(p) + return result.model_dump() if isinstance(result, BaseModel) else (result or {}) + return _NO_MATCH + + async def _handle_agent_ext_methods(self, agent: Agent, method: str, params: Any, is_notification: bool) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(agent, "extNotification"): + await agent.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + else: + if hasattr(agent, "extMethod"): + return await agent.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return _NO_MATCH + return _NO_MATCH + # client-bound methods (agent -> client) async def sessionUpdate(self, params: SessionNotification) -> None: await self._conn.send_notification( @@ -318,15 +389,27 @@ async def readTextFile(self, params: ReadTextFileRequest) -> ReadTextFileRespons ) return ReadTextFileResponse.model_validate(resp) - async def writeTextFile(self, params: WriteTextFileRequest) -> None: - await self._conn.send_request( + async def writeTextFile(self, params: WriteTextFileRequest) -> WriteTextFileResponse | None: + resp = await self._conn.send_request( CLIENT_METHODS["fs_write_text_file"], params.model_dump(exclude_none=True, exclude_defaults=True), ) + # Response may be empty object + return WriteTextFileResponse.model_validate(resp) if isinstance(resp, dict) else None + + async def createTerminal(self, params: CreateTerminalRequest) -> TerminalHandle: + resp = await self._conn.send_request( + CLIENT_METHODS["terminal_create"], + params.model_dump(exclude_none=True, exclude_defaults=True), + ) + create_resp = CreateTerminalResponse.model_validate(resp) + return TerminalHandle(create_resp.terminalId, params.sessionId, self._conn) + + async def extMethod(self, method: str, params: dict) -> dict: + return await self._conn.send_request(f"_{method}", params) - async def createTerminal(self, params: Any) -> TerminalHandle: - resp = await self._conn.send_request(CLIENT_METHODS["terminal_create"], params) - return TerminalHandle(resp["terminalId"], params["sessionId"], self._conn) + async def extNotification(self, method: str, params: dict) -> None: + await self._conn.send_notification(f"_{method}", params) class ClientSideConnection: @@ -356,13 +439,28 @@ def __init__( def _create_handler(self, client: Client) -> MethodHandler: """Create the method handler for client-side connection.""" - async def handler(method: str, params: Any) -> Any: - return await self._handle_client_method(client, method, params) + async def handler(method: str, params: Any, is_notification: bool) -> Any: + return await self._handle_client_method(client, method, params, is_notification) return handler - async def _handle_client_method(self, client: Client, method: str, params: Any) -> Any: + async def _handle_client_method(self, client: Client, method: str, params: Any, is_notification: bool) -> Any: """Handle client method calls.""" + # Core session/file methods + result = await self._handle_client_core_methods(client, method, params) + if result is not _NO_MATCH: + return result + # Terminal methods + result = await self._handle_client_terminal_methods(client, method, params) + if result is not _NO_MATCH: + return result + # Extension methods/notifications + result = await self._handle_client_extension_methods(client, method, params, is_notification) + if result is not _NO_MATCH: + return result + raise RequestError.method_not_found(method) + + async def _handle_client_core_methods(self, client: Client, method: str, params: Any) -> Any: if method == CLIENT_METHODS["fs_write_text_file"]: p = WriteTextFileRequest.model_validate(params) return await client.writeTextFile(p) @@ -375,17 +473,65 @@ async def _handle_client_method(self, client: Client, method: str, params: Any) if method == CLIENT_METHODS["session_update"]: p = SessionNotification.model_validate(params) return await client.sessionUpdate(p) + return _NO_MATCH + + async def _handle_client_terminal_methods(self, client: Client, method: str, params: Any) -> Any: + result = await self._handle_client_terminal_basic(client, method, params) + if result is not _NO_MATCH: + return result + result = await self._handle_client_terminal_lifecycle(client, method, params) + if result is not _NO_MATCH: + return result + return _NO_MATCH + + async def _handle_client_terminal_basic(self, client: Client, method: str, params: Any) -> Any: if method == CLIENT_METHODS["terminal_create"]: - return await client.createTerminal(params) + if hasattr(client, "createTerminal"): + p = CreateTerminalRequest.model_validate(params) + return await client.createTerminal(p) + return None # TS returns null when optional method missing if method == CLIENT_METHODS["terminal_output"]: - return await client.terminalOutput(params) + if hasattr(client, "terminalOutput"): + p = TerminalOutputRequest.model_validate(params) + return await client.terminalOutput(p) + return None + return _NO_MATCH + + async def _handle_client_terminal_lifecycle(self, client: Client, method: str, params: Any) -> Any: if method == CLIENT_METHODS["terminal_release"]: - return await client.releaseTerminal(params) + if hasattr(client, "releaseTerminal"): + p = ReleaseTerminalRequest.model_validate(params) + result = await client.releaseTerminal(p) + return result.model_dump() if isinstance(result, BaseModel) else (result or {}) + return {} # TS returns {} for void optional methods if method == CLIENT_METHODS["terminal_wait_for_exit"]: - return await client.waitForTerminalExit(params) + if hasattr(client, "waitForTerminalExit"): + p = WaitForTerminalExitRequest.model_validate(params) + return await client.waitForTerminalExit(p) + return None if method == CLIENT_METHODS["terminal_kill"]: - return await client.killTerminal(params) - raise RequestError.method_not_found(method) + if hasattr(client, "killTerminal"): + p = KillTerminalCommandRequest.model_validate(params) + result = await client.killTerminal(p) + return result.model_dump() if isinstance(result, BaseModel) else (result or {}) + return {} # TS returns {} for void optional methods + return _NO_MATCH + + async def _handle_client_extension_methods( + self, client: Client, method: str, params: Any, is_notification: bool + ) -> Any: + if isinstance(method, str) and method.startswith("_"): + ext_name = method[1:] + if is_notification: + if hasattr(client, "extNotification"): + await client.extNotification(ext_name, params or {}) # type: ignore[arg-type] + return None + return None + else: + if hasattr(client, "extMethod"): + return await client.extMethod(ext_name, params or {}) # type: ignore[arg-type] + return _NO_MATCH + return _NO_MATCH # agent-bound methods (client -> agent) async def initialize(self, params: InitializeRequest) -> InitializeResponse: @@ -408,11 +554,20 @@ async def loadSession(self, params: LoadSessionRequest) -> None: params.model_dump(exclude_none=True, exclude_defaults=True), ) - async def authenticate(self, params: AuthenticateRequest) -> None: - await self._conn.send_request( + async def setSessionMode(self, params: SetSessionModeRequest) -> SetSessionModeResponse | None: + resp = await self._conn.send_request( + AGENT_METHODS["session_set_mode"], + params.model_dump(exclude_none=True, exclude_defaults=True), + ) + # May be empty object + return SetSessionModeResponse.model_validate(resp) if isinstance(resp, dict) else None + + async def authenticate(self, params: AuthenticateRequest) -> AuthenticateResponse | None: + resp = await self._conn.send_request( AGENT_METHODS["authenticate"], params.model_dump(exclude_none=True, exclude_defaults=True), ) + return AuthenticateResponse.model_validate(resp) if isinstance(resp, dict) else None async def prompt(self, params: PromptRequest) -> PromptResponse: resp = await self._conn.send_request( @@ -427,6 +582,12 @@ async def cancel(self, params: CancelNotification) -> None: params.model_dump(exclude_none=True, exclude_defaults=True), ) + async def extMethod(self, method: str, params: dict) -> dict: + return await self._conn.send_request(f"_{method}", params) + + async def extNotification(self, method: str, params: dict) -> None: + await self._conn.send_notification(f"_{method}", params) + class TerminalHandle: def __init__(self, terminal_id: str, session_id: str, conn: Connection) -> None: @@ -434,26 +595,30 @@ def __init__(self, terminal_id: str, session_id: str, conn: Connection) -> None: self._session_id = session_id self._conn = conn - async def current_output(self) -> dict: - return await self._conn.send_request( + async def current_output(self) -> TerminalOutputResponse: + resp = await self._conn.send_request( CLIENT_METHODS["terminal_output"], {"sessionId": self._session_id, "terminalId": self.id}, ) + return TerminalOutputResponse.model_validate(resp) - async def wait_for_exit(self) -> dict: - return await self._conn.send_request( + async def wait_for_exit(self) -> WaitForTerminalExitResponse: + resp = await self._conn.send_request( CLIENT_METHODS["terminal_wait_for_exit"], {"sessionId": self._session_id, "terminalId": self.id}, ) + return WaitForTerminalExitResponse.model_validate(resp) - async def kill(self) -> None: - await self._conn.send_request( + async def kill(self) -> KillTerminalCommandResponse | None: + resp = await self._conn.send_request( CLIENT_METHODS["terminal_kill"], {"sessionId": self._session_id, "terminalId": self.id}, ) + return KillTerminalCommandResponse.model_validate(resp) if isinstance(resp, dict) else None - async def release(self) -> None: - await self._conn.send_request( + async def release(self) -> ReleaseTerminalResponse | None: + resp = await self._conn.send_request( CLIENT_METHODS["terminal_release"], {"sessionId": self._session_id, "terminalId": self.id}, ) + return ReleaseTerminalResponse.model_validate(resp) if isinstance(resp, dict) else None diff --git a/src/acp/meta.py b/src/acp/meta.py index abb1297..779597e 100644 --- a/src/acp/meta.py +++ b/src/acp/meta.py @@ -1,4 +1,4 @@ # This file is generated from schema/meta.json. Do not edit by hand. -AGENT_METHODS = {'authenticate': 'authenticate', 'initialize': 'initialize', 'session_cancel': 'session/cancel', 'session_load': 'session/load', 'session_new': 'session/new', 'session_prompt': 'session/prompt'} +AGENT_METHODS = {'authenticate': 'authenticate', 'initialize': 'initialize', 'session_cancel': 'session/cancel', 'session_load': 'session/load', 'session_new': 'session/new', 'session_prompt': 'session/prompt', 'session_set_mode': 'session/set_mode'} CLIENT_METHODS = {'fs_read_text_file': 'fs/read_text_file', 'fs_write_text_file': 'fs/write_text_file', 'session_request_permission': 'session/request_permission', 'session_update': 'session/update', 'terminal_create': 'terminal/create', 'terminal_kill': 'terminal/kill', 'terminal_output': 'terminal/output', 'terminal_release': 'terminal/release', 'terminal_wait_for_exit': 'terminal/wait_for_exit'} PROTOCOL_VERSION = 1 diff --git a/src/acp/schema.py b/src/acp/schema.py index ca2d3a8..b9600b1 100644 --- a/src/acp/schema.py +++ b/src/acp/schema.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: schema.json -# timestamp: 2025-09-06T03:09:44+00:00 +# timestamp: 2025-09-13T16:38:08+00:00 from __future__ import annotations @@ -11,6 +11,10 @@ class AuthenticateRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None methodId: Annotated[ str, Field( @@ -19,25 +23,52 @@ class AuthenticateRequest(BaseModel): ] +class AuthenticateResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + + class AvailableCommandInput1(BaseModel): - hint: Annotated[str, Field(description='A brief description of the expected input')] + hint: Annotated[ + str, + Field(description="A hint to display when the input hasn't been provided yet"), + ] class AvailableCommandInput(RootModel[AvailableCommandInput1]): - root: AvailableCommandInput1 + root: Annotated[ + AvailableCommandInput1, + Field(description='The input specification for a command.'), + ] class BlobResourceContents(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None blob: str mimeType: Optional[str] = None uri: str class CreateTerminalResponse(BaseModel): - terminalId: str + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + terminalId: Annotated[ + str, Field(description='The unique identifier for the created terminal.') + ] class EnvVariable(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None name: Annotated[str, Field(description='The name of the environment variable.')] value: Annotated[ str, Field(description='The value to set for the environment variable.') @@ -45,6 +76,10 @@ class EnvVariable(BaseModel): class FileSystemCapability(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None readTextFile: Annotated[ Optional[bool], Field(description='Whether the Client supports `fs/read_text_file` requests.'), @@ -55,7 +90,64 @@ class FileSystemCapability(BaseModel): ] = False -class McpServer(BaseModel): +class HttpHeader(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + name: Annotated[str, Field(description='The name of the HTTP header.')] + value: Annotated[str, Field(description='The value to set for the HTTP header.')] + + +class KillTerminalCommandResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + + +class McpCapabilities(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + http: Annotated[ + Optional[bool], Field(description='Agent supports [`McpServer::Http`].') + ] = False + sse: Annotated[ + Optional[bool], Field(description='Agent supports [`McpServer::Sse`].') + ] = False + + +class McpServer1(BaseModel): + headers: Annotated[ + List[HttpHeader], + Field( + description='HTTP headers to set when making requests to the MCP server.' + ), + ] + name: Annotated[ + str, Field(description='Human-readable name identifying this MCP server.') + ] + type: Literal['http'] + url: Annotated[str, Field(description='URL to the MCP server.')] + + +class McpServer2(BaseModel): + headers: Annotated[ + List[HttpHeader], + Field( + description='HTTP headers to set when making requests to the MCP server.' + ), + ] + name: Annotated[ + str, Field(description='Human-readable name identifying this MCP server.') + ] + type: Literal['sse'] + url: Annotated[str, Field(description='URL to the MCP server.')] + + +class McpServer3(BaseModel): args: Annotated[ List[str], Field(description='Command-line arguments to pass to the MCP server.'), @@ -73,6 +165,10 @@ class McpServer(BaseModel): class NewSessionRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None cwd: Annotated[ str, Field( @@ -80,7 +176,7 @@ class NewSessionRequest(BaseModel): ), ] mcpServers: Annotated[ - List[McpServer], + List[Union[McpServer1, McpServer2, McpServer3]], Field( description='List of MCP (Model Context Protocol) servers the agent should connect to.' ), @@ -88,6 +184,10 @@ class NewSessionRequest(BaseModel): class PromptCapabilities(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None audio: Annotated[ Optional[bool], Field(description='Agent supports [`ContentBlock::Audio`].') ] = False @@ -103,9 +203,20 @@ class PromptCapabilities(BaseModel): class ReadTextFileResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: str +class ReleaseTerminalResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + + class RequestPermissionOutcome1(BaseModel): outcome: Literal['cancelled'] @@ -118,6 +229,10 @@ class RequestPermissionOutcome2(BaseModel): class RequestPermissionResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None outcome: Annotated[ Union[RequestPermissionOutcome1, RequestPermissionOutcome2], Field(description="The user's decision on the permission request."), @@ -129,34 +244,89 @@ class Role(Enum): user = 'user' -class TerminalExitStatus(BaseModel): - exitCode: Annotated[Optional[int], Field(ge=0)] = None - signal: Optional[str] = None +class SessionUpdate8(BaseModel): + currentModeId: Annotated[ + str, Field(description='Unique identifier for a Session Mode.') + ] + sessionUpdate: Literal['current_mode_update'] -class TerminalOutputRequest(BaseModel): +class SetSessionModeRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + modeId: Annotated[str, Field(description='The ID of the mode to set.')] sessionId: Annotated[ - str, + str, Field(description='The ID of the session to set the mode for.') + ] + + +class SetSessionModeResponse(BaseModel): + meta: Optional[Any] = None + + +class TerminalExitStatus(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + exitCode: Annotated[ + Optional[int], + Field( + description='The process exit code (may be null if terminated by signal).', + ge=0, + ), + ] = None + signal: Annotated[ + Optional[str], Field( - description='A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from("sess_abc123def456"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)' + description='The signal that terminated the process (may be null if exited normally).' ), + ] = None + + +class TerminalOutputRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + sessionId: Annotated[str, Field(description='The session ID for this request.')] + terminalId: Annotated[ + str, Field(description='The ID of the terminal to get output from.') ] - terminalId: str class TerminalOutputResponse(BaseModel): - exitStatus: Optional[TerminalExitStatus] = None - output: str - truncated: bool + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + exitStatus: Annotated[ + Optional[TerminalExitStatus], + Field(description='Exit status if the command has completed.'), + ] = None + output: Annotated[str, Field(description='The terminal output captured so far.')] + truncated: Annotated[ + bool, Field(description='Whether the output was truncated due to byte limits.') + ] class TextResourceContents(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None mimeType: Optional[str] = None text: str uri: str class ToolCallContent2(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None newText: Annotated[str, Field(description='The new content after modification.')] oldText: Annotated[ Optional[str], Field(description='The original content (None for new files).') @@ -171,6 +341,10 @@ class ToolCallContent3(BaseModel): class ToolCallLocation(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None line: Annotated[ Optional[int], Field(description='Optional line number within the file.', ge=0) ] = None @@ -178,30 +352,63 @@ class ToolCallLocation(BaseModel): class WaitForTerminalExitRequest(BaseModel): - sessionId: Annotated[ - str, - Field( - description='A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from("sess_abc123def456"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)' - ), - ] - terminalId: str + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + sessionId: Annotated[str, Field(description='The session ID for this request.')] + terminalId: Annotated[str, Field(description='The ID of the terminal to wait for.')] class WaitForTerminalExitResponse(BaseModel): - exitCode: Annotated[Optional[int], Field(ge=0)] = None - signal: Optional[str] = None + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + exitCode: Annotated[ + Optional[int], + Field( + description='The process exit code (may be null if terminated by signal).', + ge=0, + ), + ] = None + signal: Annotated[ + Optional[str], + Field( + description='The signal that terminated the process (may be null if exited normally).' + ), + ] = None class WriteTextFileRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[str, Field(description='The text content to write to the file.')] path: Annotated[str, Field(description='Absolute path to the file to write.')] sessionId: Annotated[str, Field(description='The session ID for this request.')] +class WriteTextFileResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + + class AgentCapabilities(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None loadSession: Annotated[ Optional[bool], Field(description='Whether the agent supports `session/load`.') ] = False + mcpCapabilities: Annotated[ + Optional[McpCapabilities], + Field(description='MCP capabilities supported by the agent.'), + ] = {'http': False, 'sse': False} promptCapabilities: Annotated[ Optional[PromptCapabilities], Field(description='Prompt capabilities supported by the agent.'), @@ -209,18 +416,30 @@ class AgentCapabilities(BaseModel): class Annotations(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None audience: Optional[List[Role]] = None lastModified: Optional[str] = None priority: Optional[float] = None class AudioContent(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None data: str mimeType: str class AuthMethod(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None description: Annotated[ Optional[str], Field( @@ -236,6 +455,10 @@ class AuthMethod(BaseModel): class AvailableCommand(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None description: Annotated[ str, Field(description='Human-readable description of what the command does.') ] @@ -250,12 +473,20 @@ class AvailableCommand(BaseModel): class CancelNotification(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None sessionId: Annotated[ str, Field(description='The ID of the session to cancel operations for.') ] class ClientCapabilities(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None fs: Annotated[ Optional[FileSystemCapability], Field( @@ -264,28 +495,25 @@ class ClientCapabilities(BaseModel): ] = {'readTextFile': False, 'writeTextFile': False} terminal: Annotated[ Optional[bool], - Field( - description='**UNSTABLE**\n\nThis capability is not part of the spec yet, and may be removed or changed at any point.' - ), + Field(description='Whether the Client support all `terminal/*` methods.'), ] = False -class ClientNotification(RootModel[CancelNotification]): - root: Annotated[ - CancelNotification, - Field( - description="All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Agent`] trait instead.\n\nNotifications do not expect a response." - ), - ] - - class ContentBlock1(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None text: str type: Literal['text'] class ContentBlock2(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None data: str mimeType: str @@ -294,6 +522,10 @@ class ContentBlock2(BaseModel): class ContentBlock3(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None data: str mimeType: str @@ -301,6 +533,10 @@ class ContentBlock3(BaseModel): class ContentBlock4(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None description: Optional[str] = None mimeType: Optional[str] = None @@ -312,20 +548,37 @@ class ContentBlock4(BaseModel): class CreateTerminalRequest(BaseModel): - args: Optional[List[str]] = None - command: str - cwd: Optional[str] = None - env: Optional[List[EnvVariable]] = None - outputByteLimit: Annotated[Optional[int], Field(ge=0)] = None - sessionId: Annotated[ - str, + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + args: Annotated[ + Optional[List[str]], Field(description='Array of command arguments.') + ] = None + command: Annotated[str, Field(description='The command to execute.')] + cwd: Annotated[ + Optional[str], + Field(description='Working directory for the command (absolute path).'), + ] = None + env: Annotated[ + Optional[List[EnvVariable]], + Field(description='Environment variables for the command.'), + ] = None + outputByteLimit: Annotated[ + Optional[int], Field( - description='A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from("sess_abc123def456"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)' + description='Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.', + ge=0, ), - ] + ] = None + sessionId: Annotated[str, Field(description='The session ID for this request.')] class ImageContent(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None data: str mimeType: str @@ -333,6 +586,10 @@ class ImageContent(BaseModel): class InitializeRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None clientCapabilities: Annotated[ Optional[ClientCapabilities], Field(description='Capabilities supported by the client.'), @@ -348,11 +605,16 @@ class InitializeRequest(BaseModel): class InitializeResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None agentCapabilities: Annotated[ Optional[AgentCapabilities], Field(description='Capabilities supported by the agent.'), ] = { 'loadSession': False, + 'mcpCapabilities': {'http': False, 'sse': False}, 'promptCapabilities': { 'audio': False, 'embeddedContext': False, @@ -373,35 +635,33 @@ class InitializeResponse(BaseModel): ] -class KillTerminalRequest(BaseModel): - sessionId: Annotated[ - str, - Field( - description='A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from("sess_abc123def456"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)' - ), - ] - terminalId: str +class KillTerminalCommandRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + sessionId: Annotated[str, Field(description='The session ID for this request.')] + terminalId: Annotated[str, Field(description='The ID of the terminal to kill.')] class LoadSessionRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None cwd: Annotated[str, Field(description='The working directory for this session.')] mcpServers: Annotated[ - List[McpServer], + List[Union[McpServer1, McpServer2, McpServer3]], Field(description='List of MCP servers to connect to for this session.'), ] sessionId: Annotated[str, Field(description='The ID of the session to load.')] -class NewSessionResponse(BaseModel): - sessionId: Annotated[ - str, - Field( - description='Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation.' - ), - ] - - class PermissionOption(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None kind: Annotated[ str, Field(description='Hint about the nature of this permission option.') ] @@ -414,6 +674,10 @@ class PermissionOption(BaseModel): class PlanEntry(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[ str, Field( @@ -430,37 +694,45 @@ class PlanEntry(BaseModel): class PromptResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None stopReason: Annotated[ str, Field(description='Indicates why the agent stopped processing the turn.') ] class ReadTextFileRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None limit: Annotated[ - Optional[int], - Field(description='Optional maximum number of lines to read.', ge=0), + Optional[int], Field(description='Maximum number of lines to read.', ge=0) ] = None line: Annotated[ Optional[int], - Field( - description='Optional line number to start reading from (1-based).', ge=0 - ), + Field(description='Line number to start reading from (1-based).', ge=0), ] = None path: Annotated[str, Field(description='Absolute path to the file to read.')] sessionId: Annotated[str, Field(description='The session ID for this request.')] class ReleaseTerminalRequest(BaseModel): - sessionId: Annotated[ - str, - Field( - description='A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\n# Example\n\n```\nuse agent_client_protocol::SessionId;\nuse std::sync::Arc;\n\nlet session_id = SessionId(Arc::from("sess_abc123def456"));\n```\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)' - ), - ] - terminalId: str + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + sessionId: Annotated[str, Field(description='The session ID for this request.')] + terminalId: Annotated[str, Field(description='The ID of the terminal to release.')] class ResourceLink(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None description: Optional[str] = None mimeType: Optional[str] = None @@ -470,7 +742,35 @@ class ResourceLink(BaseModel): uri: str +class SessionMode(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + description: Optional[str] = None + id: Annotated[str, Field(description='Unique identifier for a Session Mode.')] + name: str + + +class SessionModeState(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + availableModes: Annotated[ + List[SessionMode], + Field(description='The set of modes that the Agent can operate in'), + ] + currentModeId: Annotated[ + str, Field(description='The current mode the Agent is in.') + ] + + class SessionUpdate6(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None entries: Annotated[ List[PlanEntry], Field( @@ -486,11 +786,19 @@ class SessionUpdate7(BaseModel): class TextContent(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None text: str class ContentBlock5(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None resource: Annotated[ Union[TextResourceContents, BlobResourceContents], @@ -500,6 +808,10 @@ class ContentBlock5(BaseModel): class EmbeddedResource(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None annotations: Optional[Annotations] = None resource: Annotated[ Union[TextResourceContents, BlobResourceContents], @@ -507,7 +819,43 @@ class EmbeddedResource(BaseModel): ] +class LoadSessionResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + modes: Annotated[ + Optional[SessionModeState], + Field( + description='Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)' + ), + ] = None + + +class NewSessionResponse(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None + modes: Annotated[ + Optional[SessionModeState], + Field( + description='Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)' + ), + ] = None + sessionId: Annotated[ + str, + Field( + description='Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation.' + ), + ] + + class Plan(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None entries: Annotated[ List[PlanEntry], Field( @@ -517,6 +865,10 @@ class Plan(BaseModel): class PromptRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None prompt: Annotated[ List[ Union[ @@ -583,6 +935,10 @@ class ToolCallContent1(BaseModel): class ToolCallUpdate(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[ Optional[List[Union[ToolCallContent1, ToolCallContent2, ToolCallContent3]]], Field(description='Replace the content collection.'), @@ -610,6 +966,10 @@ class ToolCallUpdate(BaseModel): class RequestPermissionRequest(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None options: Annotated[ List[PermissionOption], Field(description='Available permission options for the user to choose from.'), @@ -622,6 +982,10 @@ class RequestPermissionRequest(BaseModel): class SessionUpdate4(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[ Optional[List[Union[ToolCallContent1, ToolCallContent2, ToolCallContent3]]], Field(description='Content produced by the tool call.'), @@ -659,6 +1023,10 @@ class SessionUpdate4(BaseModel): class SessionUpdate5(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[ Optional[List[Union[ToolCallContent1, ToolCallContent2, ToolCallContent3]]], Field(description='Replace the content collection.'), @@ -687,6 +1055,10 @@ class SessionUpdate5(BaseModel): class ToolCall(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None content: Annotated[ Optional[List[Union[ToolCallContent1, ToolCallContent2, ToolCallContent3]]], Field(description='Content produced by the tool call.'), @@ -723,6 +1095,10 @@ class ToolCall(BaseModel): class SessionNotification(BaseModel): + field_meta: Annotated[ + Optional[Any], + Field(alias='_meta', description='Extension point for implementations'), + ] = None sessionId: Annotated[ str, Field(description='The ID of the session this update pertains to.') ] @@ -735,25 +1111,15 @@ class SessionNotification(BaseModel): SessionUpdate5, SessionUpdate6, SessionUpdate7, + SessionUpdate8, ], Field(description='The actual update content.'), ] -class AgentNotification(RootModel[SessionNotification]): - root: Annotated[ - SessionNotification, - Field( - description="All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly - use the notification methods on the [`Client`] trait instead.\n\nNotifications do not expect a response." - ), - ] - - class Model( RootModel[ Union[ - ClientNotification, - AgentNotification, Union[ WriteTextFileRequest, ReadTextFileRequest, @@ -762,31 +1128,44 @@ class Model( TerminalOutputRequest, ReleaseTerminalRequest, WaitForTerminalExitRequest, - KillTerminalRequest, + KillTerminalCommandRequest, + Any, ], - Optional[ - Union[ - ReadTextFileResponse, - RequestPermissionResponse, - CreateTerminalResponse, - TerminalOutputResponse, - WaitForTerminalExitResponse, - ] + Union[ + WriteTextFileResponse, + ReadTextFileResponse, + RequestPermissionResponse, + CreateTerminalResponse, + TerminalOutputResponse, + ReleaseTerminalResponse, + WaitForTerminalExitResponse, + KillTerminalCommandResponse, + Any, ], + Union[CancelNotification, Any], Union[ InitializeRequest, AuthenticateRequest, NewSessionRequest, LoadSessionRequest, + SetSessionModeRequest, PromptRequest, + Any, + ], + Union[ + InitializeResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + SetSessionModeResponse, + PromptResponse, + Any, ], - Optional[Union[InitializeResponse, NewSessionResponse, PromptResponse]], + Union[SessionNotification, Any], ] ] ): root: Union[ - ClientNotification, - AgentNotification, Union[ WriteTextFileRequest, ReadTextFileRequest, @@ -795,23 +1174,38 @@ class Model( TerminalOutputRequest, ReleaseTerminalRequest, WaitForTerminalExitRequest, - KillTerminalRequest, + KillTerminalCommandRequest, + Any, ], - Optional[ - Union[ - ReadTextFileResponse, - RequestPermissionResponse, - CreateTerminalResponse, - TerminalOutputResponse, - WaitForTerminalExitResponse, - ] + Union[ + WriteTextFileResponse, + ReadTextFileResponse, + RequestPermissionResponse, + CreateTerminalResponse, + TerminalOutputResponse, + ReleaseTerminalResponse, + WaitForTerminalExitResponse, + KillTerminalCommandResponse, + Any, ], + Union[CancelNotification, Any], Union[ InitializeRequest, AuthenticateRequest, NewSessionRequest, LoadSessionRequest, + SetSessionModeRequest, PromptRequest, + Any, + ], + Union[ + InitializeResponse, + AuthenticateResponse, + NewSessionResponse, + LoadSessionResponse, + SetSessionModeResponse, + PromptResponse, + Any, ], - Optional[Union[InitializeResponse, NewSessionResponse, PromptResponse]], + Union[SessionNotification, Any], ] diff --git a/src/acp/stdio.py b/src/acp/stdio.py index dd2dd3e..a0c1011 100644 --- a/src/acp/stdio.py +++ b/src/acp/stdio.py @@ -1,7 +1,12 @@ from __future__ import annotations import asyncio +import contextlib +import logging +import platform import sys +from asyncio import transports as aio_transports +from typing import cast class _WritePipeProtocol(asyncio.BaseProtocol): @@ -26,16 +31,67 @@ async def _drain_helper(self) -> None: await self._drain_waiter -async def stdio_streams() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: - """ - Create asyncio StreamReader/StreamWriter from the current process's - stdin/stdout without blocking the event loop. +def _start_stdin_feeder(loop: asyncio.AbstractEventLoop, reader: asyncio.StreamReader) -> None: + # Feed stdin from a background thread line-by-line + def blocking_read() -> None: + try: + while True: + data = sys.stdin.buffer.readline() + if not data: + break + loop.call_soon_threadsafe(reader.feed_data, data) + finally: + loop.call_soon_threadsafe(reader.feed_eof) + + import threading + + threading.Thread(target=blocking_read, daemon=True).start() + + +class _StdoutTransport(asyncio.BaseTransport): + def __init__(self) -> None: + self._is_closing = False + + def write(self, data: bytes) -> None: # type: ignore[override] + if self._is_closing: + return + try: + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + except Exception: + logging.exception("Error writing to stdout") + + def can_write_eof(self) -> bool: # type: ignore[override] + return False + + def is_closing(self) -> bool: # type: ignore[override] + return self._is_closing + + def close(self) -> None: # type: ignore[override] + self._is_closing = True + with contextlib.suppress(Exception): + sys.stdout.flush() + + def abort(self) -> None: # type: ignore[override] + self.close() + + def get_extra_info(self, name: str, default=None): # type: ignore[override] + return default - This uses low-level pipe adapters. Note: on Windows, this requires - running in an environment that supports asynchronous pipes. - """ - loop = asyncio.get_running_loop() +async def _windows_stdio_streams(loop: asyncio.AbstractEventLoop) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + reader = asyncio.StreamReader() + _ = asyncio.StreamReaderProtocol(reader) + + _start_stdin_feeder(loop, reader) + + write_protocol = _WritePipeProtocol() + transport = _StdoutTransport() + writer = asyncio.StreamWriter(cast(aio_transports.WriteTransport, transport), write_protocol, None, loop) + return reader, writer + + +async def _posix_stdio_streams(loop: asyncio.AbstractEventLoop) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: # Reader from stdin reader = asyncio.StreamReader() reader_protocol = asyncio.StreamReaderProtocol(reader) @@ -45,5 +101,12 @@ async def stdio_streams() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: write_protocol = _WritePipeProtocol() transport, _ = await loop.connect_write_pipe(lambda: write_protocol, sys.stdout) writer = asyncio.StreamWriter(transport, write_protocol, None, loop) - return reader, writer + + +async def stdio_streams() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Create stdio asyncio streams; on Windows use a thread feeder + custom stdout transport.""" + loop = asyncio.get_running_loop() + if platform.system() == "Windows": + return await _windows_stdio_streams(loop) + return await _posix_stdio_streams(loop) diff --git a/tests/test_rpc.py b/tests/test_rpc.py index cdf355a..09aa8ad 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import json import pytest @@ -21,6 +22,7 @@ RequestPermissionRequest, RequestPermissionResponse, SessionNotification, + SetSessionModeRequest, WriteTextFileRequest, ) from acp.schema import ContentBlock1, SessionUpdate1, SessionUpdate2 @@ -78,6 +80,8 @@ def __init__(self) -> None: self.permission_outcomes: list[dict] = [] self.files: dict[str, str] = {} self.notifications: list[SessionNotification] = [] + self.ext_calls: list[tuple[str, dict]] = [] + self.ext_notes: list[tuple[str, dict]] = [] async def requestPermission(self, params: RequestPermissionRequest) -> RequestPermissionResponse: outcome = self.permission_outcomes.pop() if self.permission_outcomes else {"outcome": "cancelled"} @@ -94,21 +98,28 @@ async def sessionUpdate(self, params: SessionNotification) -> None: self.notifications.append(params) # Optional terminal methods (not implemented in this test client) - async def createTerminal(self, params) -> None: + async def createTerminal(self, params) -> None: # pragma: no cover - placeholder pass - async def terminalOutput(self, params) -> None: + async def terminalOutput(self, params) -> None: # pragma: no cover - placeholder pass - async def releaseTerminal(self, params) -> None: + async def releaseTerminal(self, params) -> None: # pragma: no cover - placeholder pass - async def waitForTerminalExit(self, params) -> None: + async def waitForTerminalExit(self, params) -> None: # pragma: no cover - placeholder pass - async def killTerminal(self, params) -> None: + async def killTerminal(self, params) -> None: # pragma: no cover - placeholder pass + async def extMethod(self, method: str, params: dict) -> dict: + self.ext_calls.append((method, params)) + return {"ok": True, "method": method} + + async def extNotification(self, method: str, params: dict) -> None: + self.ext_notes.append((method, params)) + class TestAgent(Agent): __test__ = False # prevent pytest from collecting this class @@ -116,6 +127,8 @@ class TestAgent(Agent): def __init__(self) -> None: self.prompts: list[PromptRequest] = [] self.cancellations: list[str] = [] + self.ext_calls: list[tuple[str, dict]] = [] + self.ext_notes: list[tuple[str, dict]] = [] async def initialize(self, params: InitializeRequest) -> InitializeResponse: # Avoid serializer warnings by omitting defaults @@ -137,6 +150,16 @@ async def prompt(self, params: PromptRequest) -> PromptResponse: async def cancel(self, params: CancelNotification) -> None: self.cancellations.append(params.sessionId) + async def setSessionMode(self, params): + return {} + + async def extMethod(self, method: str, params: dict) -> dict: + self.ext_calls.append((method, params)) + return {"ok": True, "method": method} + + async def extNotification(self, method: str, params: dict) -> None: + self.ext_notes.append((method, params)) + # ------------------------ Tests -------------------------- @@ -252,3 +275,84 @@ async def read_one(i: int): results = await asyncio.gather(*(read_one(i) for i in range(5))) for i, res in enumerate(results): assert res.content == f"Content {i}" + + +@pytest.mark.asyncio +async def test_invalid_params_results_in_error_response(): + async with _Server() as s: + # Only start agent-side (server) so we can inject raw request from client socket + agent = TestAgent() + _server_conn = AgentSideConnection(lambda _conn: agent, s.server_writer, s.server_reader) + + # Send initialize with wrong param type (protocolVersion should be int) + req = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "oops"}} + s.client_writer.write((json.dumps(req) + "\n").encode()) + await s.client_writer.drain() + + # Read response + line = await asyncio.wait_for(s.client_reader.readline(), timeout=1) + resp = json.loads(line) + assert resp["id"] == 1 + assert "error" in resp + assert resp["error"]["code"] == -32602 # invalid params + + +@pytest.mark.asyncio +async def test_method_not_found_results_in_error_response(): + async with _Server() as s: + agent = TestAgent() + _server_conn = AgentSideConnection(lambda _conn: agent, s.server_writer, s.server_reader) + + req = {"jsonrpc": "2.0", "id": 2, "method": "unknown/method", "params": {}} + s.client_writer.write((json.dumps(req) + "\n").encode()) + await s.client_writer.drain() + + line = await asyncio.wait_for(s.client_reader.readline(), timeout=1) + resp = json.loads(line) + assert resp["id"] == 2 + assert resp["error"]["code"] == -32601 # method not found + + +@pytest.mark.asyncio +async def test_set_session_mode_and_extensions(): + async with _Server() as s: + agent = TestAgent() + client = TestClient() + agent_conn = ClientSideConnection(lambda _conn: client, s.client_writer, s.client_reader) + _client_conn = AgentSideConnection(lambda _conn: agent, s.server_writer, s.server_reader) + + # setSessionMode + resp = await agent_conn.setSessionMode(SetSessionModeRequest(sessionId="sess", modeId="yolo")) + # Either empty object or typed response depending on implementation + assert resp is None or resp.__class__.__name__ == "SetSessionModeResponse" + + # extMethod + res = await agent_conn.extMethod("ping", {"x": 1}) + assert res.get("ok") is True + + # extNotification + await agent_conn.extNotification("note", {"y": 2}) + # allow dispatch + await asyncio.sleep(0.05) + assert agent.ext_notes and agent.ext_notes[-1][0] == "note" + + +@pytest.mark.asyncio +async def test_ignore_invalid_messages(): + async with _Server() as s: + agent = TestAgent() + _server_conn = AgentSideConnection(lambda _conn: agent, s.server_writer, s.server_reader) + + # Message without id and method + msg1 = {"jsonrpc": "2.0"} + s.client_writer.write((json.dumps(msg1) + "\n").encode()) + await s.client_writer.drain() + + # Message without jsonrpc and without id/method + msg2 = {"foo": "bar"} + s.client_writer.write((json.dumps(msg2) + "\n").encode()) + await s.client_writer.drain() + + # Should not receive any response lines + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(s.client_reader.readline(), timeout=0.1) diff --git a/uv.lock b/uv.lock index 57cf42d..48d77b3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <4.0" [[package]] name = "agent-client-protocol" -version = "0.0.1" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, @@ -21,6 +21,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "python-dotenv" }, { name = "ruff" }, { name = "tox-uv" }, { name = "ty" }, @@ -40,6 +41,7 @@ dev = [ { name = "pre-commit", specifier = ">=2.20.0" }, { name = "pytest", specifier = ">=7.2.0" }, { name = "pytest-asyncio", specifier = ">=0.21.0" }, + { name = "python-dotenv", specifier = ">=1.1.1" }, { name = "ruff", specifier = ">=0.11.5" }, { name = "tox-uv", specifier = ">=1.11.3" }, { name = "ty", specifier = ">=0.0.1a16" },