diff --git a/CLAUDE.md b/CLAUDE.md index 720386e..f851313 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -a2claude runs Claude Code as an [A2A](https://a2aprotocol.ai/) protocol agent server. Other agents discover it through its agent card and delegate coding work over the protocol; it drives a real Claude Code session and streams the structured work (tool calls, file diffs, cost, session continuity) back — not just flattened text in / text out. +a2claude serves a coding agent over the [A2A](https://a2aprotocol.ai/) protocol. Other agents discover it through its agent card and delegate coding work; it drives a real coding-agent session and streams the structured work (tool calls, file diffs, permission requests, cost, session continuity) back — not just flattened text in / text out. + +It is a **bridge between two interop standards**: it speaks Zed's [Agent Client Protocol](https://agentclientprotocol.com) (ACP) to the coding agent (Claude Code, Gemini CLI, Codex, OpenHands, ... — a launch-command choice) and A2A to the caller. The default `acp` backend makes the agent vendor-neutral; a `claude` backend (Claude Agent SDK, no subprocess) and an `echo` backend also ship. ## Commands @@ -30,7 +32,7 @@ uv run a2claude call "fix the failing test" ## Architecture -The core idea: a **backend** drives Claude Code and yields a normalized event stream; the **executor** is the only place that knows A2A. Backends never import the A2A SDK, and the executor never imports the Claude Agent SDK. This split lets a new driver (a raw-CLI backend) be added without touching the protocol mapping. +The core idea: a **backend** drives a coding agent and yields a normalized event stream; the **executor** is the only place that knows A2A. Backends never import the A2A SDK, and the executor never imports an agent SDK (neither the ACP nor the Claude SDK). This split lets a new driver be added without touching the protocol mapping. Data flows in one direction: @@ -39,10 +41,13 @@ CLI / A2A caller -> server.py Starlette app: JSON-RPC + REST routes, agent card, push, auth -> executor.py ClaudeCodeExecutor: maps backend events <-> A2A task lifecycle -> backends/ a backend emits normalized BackendEvents via a BackendSession - -> claude.py drives the Claude Agent SDK (ClaudeSDKClient) + -> acp.py drives any ACP agent as a subprocess (default; agent-neutral) + -> claude.py drives the Claude Agent SDK directly (ClaudeSDKClient) -> echo.py dependency-free mirror, for tests and offline wiring checks ``` +The `acp` backend is the headline: ACP's `session/update` stream, diff content, and `session/request_permission` map almost one-to-one onto the event vocabulary below, so vendor-neutrality is a launch-command choice rather than a backend per agent. ACP itself targets human-driven editors; a2claude's value is exposing an ACP agent to *remote A2A callers* with the permission round-trip and cost preserved. + ### The event vocabulary (`backends/base.py`) Every backend speaks the same five events, and the executor maps each onto an A2A surface: @@ -74,10 +79,10 @@ The server does **not** load the developer's personal Claude settings (`setting_ ## Conventions -- **Keep the layering intact.** If you reach for `claude_agent_sdk` outside `backends/claude.py`, or for `a2a.*` inside a backend, that's the wrong layer. -- **`events_from_message` and `diff.py` are pure and side-effect free** so the SDK-message translation is unit-testable without a live Claude session. Keep them that way. +- **Keep the layering intact.** If you reach for `acp`/`claude_agent_sdk` outside their own backend module, or for `a2a.*` inside a backend, that's the wrong layer. +- **The translation functions are pure and side-effect free** — `events_from_message` (claude), `events_from_update` + `select_option` (acp), and `diff.py` — so the protocol mapping is unit-testable without a live agent. Keep them that way; stateful concerns (cost capture, permission parking) live in the backend's `Client`/`drive`, not the translator. - Python 3.13+, full type hints, `from __future__ import annotations`. Ruff enforces `E, F, I, UP, B, SIM` at line length 88. -- The `claude` backend is imported lazily (`make_backend`) so `echo` works without the Agent SDK's runtime deps. New optional backends should follow the same lazy pattern. +- The `acp` and `claude` backends are imported lazily (`make_backend`) so `echo` works without their runtime deps. The Claude SDK is an optional extra (`a2claude[claude]`). New optional backends should follow the same lazy pattern. ## Reference material diff --git a/README.md b/README.md index 10311e3..36e4932 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # a2claude -Run Claude Code as an [A2A](https://a2aprotocol.ai/) agent server. Other agents call it over the protocol; it drives a real Claude Code session in your project and streams the work back as it happens. +Serve a coding agent over the [A2A](https://a2aprotocol.ai/) protocol. Other agents call it over A2A; it drives a real coding-agent session in your project — Claude Code, or any agent that speaks Zed's [Agent Client Protocol](https://agentclientprotocol.com) (ACP): Gemini CLI, Codex, OpenHands, and more — and streams the work back as it happens. [![CI](https://github.com/kanywst/a2claude/actions/workflows/ci.yml/badge.svg)](https://github.com/kanywst/a2claude/actions/workflows/ci.yml) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) @@ -11,17 +11,18 @@ Run Claude Code as an [A2A](https://a2aprotocol.ai/) agent server. Other agents ![a2claude streaming a task, then pausing on a permission prompt](assets/demo.gif) -Most adapters that put a coding agent behind A2A flatten everything to text in, text out. a2claude keeps the structure Claude Code produces: the tools it runs, the files it changes, what it costs, and how to continue on the next turn. +Most adapters that put a coding agent behind A2A flatten everything to text in, text out. a2claude keeps the structure the agent produces: the tools it runs, the files it changes, what it costs, the approvals it needs, and how to continue on the next turn. It bridges two Linux Foundation interop standards — **ACP** (how editors and clients talk to coding agents) on the agent side, **A2A** (how agents delegate to each other) on the caller side — so any ACP agent becomes a peer any A2A orchestrator can call. ## How it maps to A2A -| Claude Code produces | A2A surface it lands on | -| ------------------------ | -------------------------------------------------- | -| Assistant text | A streamed artifact (`append` / `last_chunk`) | -| A tool call (Bash, Edit) | A `working` status update for the action | -| A file edit | A named artifact carrying the diff | +| The coding agent produces | A2A surface it lands on | +| ------------------------- | -------------------------------------------------- | +| Assistant text | A streamed artifact (`append` / `last_chunk`) | +| A tool call (Bash, Edit) | A `working` status update for the action | +| A file edit (diff) | A named artifact carrying the diff | +| A permission request | An `input-required` pause the caller answers | | Run result | Cost, turns, and usage on the completion message | -| Session id | Mapped to the A2A `contextId` so follow-ups resume | +| Session id | Mapped to the A2A `contextId` so follow-ups resume | The mapping is all in `executor.py`. Backends only emit normalized events; they never touch the protocol. @@ -29,19 +30,19 @@ The mapping is all in `executor.py`. Backends only emit normalized events; they Anthropic now ships its own ways to run Claude Code beyond the terminal: Claude Code on the web, background agents, cloud-hosted Routines, and the Managed Agents API. These are the right choices when you want Anthropic to host the run and you live in their ecosystem, and they are typically tied to Anthropic infrastructure and a GitHub-centric flow. -a2claude solves a different problem: making Claude Code a first-class peer on a vendor-neutral [A2A](https://a2aprotocol.ai/) mesh. An orchestrator built on any framework discovers it through its agent card and delegates coding work to it the same way it would to any other A2A agent. The run happens on infrastructure you control, in a workspace you point it at. Reach for a2claude when: +a2claude solves a different problem: making any coding agent a first-class peer on a vendor-neutral [A2A](https://a2aprotocol.ai/) mesh. An orchestrator built on any framework discovers it through its agent card and delegates coding work the same way it would to any other A2A agent. The run happens on infrastructure you control, in a workspace you point it at. Reach for a2claude when: - another agent (not a human at a prompt) is the caller, and it speaks A2A; - you want the run on your own infrastructure and data boundary, not a vendor VM; -- you are wiring Claude Code into a multi-vendor agent system rather than standardizing on one vendor's hosted stack. +- you do not want to bet on one vendor's coding agent: ACP makes the backend a launch-command choice, so swapping Claude Code for Codex, Gemini CLI, or OpenHands does not touch the protocol surface your callers depend on. -The practical user is the platform team building that mesh, not the individual developer; the developer reaches it through whatever orchestrator the team puts in front of them. +ACP already standardizes the editor↔agent side and a dozen agents speak it; a2claude is the piece that exposes an ACP agent to *remote autonomous callers* over A2A, with permission round-trips and cost preserved as first-class protocol citizens — the part ACP leaves out because it assumes a human in an editor. The practical user is the platform team building that mesh, not the individual developer. ## Requirements - Python 3.13+ - [uv](https://docs.astral.sh/uv/) -- Claude Code CLI on `PATH` (only for the `claude` backend) +- An ACP agent adapter for the `acp` backend, launched as a subprocess. The `claude` preset uses `npx @zed-industries/claude-agent-acp` (needs Node and a Claude credential); `gemini` uses the Gemini CLI; or point `--agent-command` at any ACP agent. ## Quick start @@ -68,13 +69,20 @@ fix the failing test [completed] $0.0 · 1 turns ``` -Then point it at a real project: +Then point it at a real project. The default backend is `acp`, fronting Claude Code through its ACP adapter: ```bash -uv run a2claude serve --backend claude --cwd /path/to/project +uv run a2claude serve --cwd /path/to/project # acp + claude by default uv run a2claude call "add a /health endpoint" --url http://localhost:9100/ ``` +Swap the agent without touching anything else: + +```bash +uv run a2claude serve --agent gemini --cwd /path/to/project +uv run a2claude serve --agent-command "npx -y some-other-acp-agent" +``` + Continue the same conversation by passing the `context` from a previous turn: ```bash @@ -93,16 +101,17 @@ The agent card is served at `/.well-known/agent-card.json` and advertises Claude ## Backends -A backend turns a prompt into a stream of normalized events. Two ship today: +A backend turns a prompt into a stream of normalized events. Three ship today: +- `acp` (default): drives any agent that speaks Zed's Agent Client Protocol as a subprocess. `--agent claude|gemini|codex` selects a launch preset; `--agent-command` drives any other ACP agent. This is the vendor-neutral path. +- `claude`: drives Claude Code directly through the Claude Agent SDK, no subprocess. Install with `uv sync --extra claude`. Use it when you want the SDK-native path (e.g. `--max-budget-usd`) rather than ACP. - `echo`: no dependencies, mirrors the input. For wiring checks and tests. -- `claude`: drives Claude Code through the Claude Agent SDK. -The split keeps the A2A layer independent of how Claude Code is invoked, so a raw-CLI backend can be added later without touching the server or the protocol mapping. +The split keeps the A2A layer independent of how the agent is invoked: backends emit normalized events and never import `a2a.*`; the executor maps those events onto the protocol and never imports an agent SDK. Adding a backend never touches the server or the protocol mapping. ## Authentication -The `claude` backend uses whatever the Claude CLI is configured with. When the server answers on behalf of other agents, that has to be an Anthropic API key (or Bedrock / Vertex). Anthropic does not permit subscription credentials for third-party serving. Set a per-run cost ceiling with `--max-budget-usd`. +Each agent authenticates the way its own tooling does, inherited from the server's environment: the `acp` backend passes the environment through to the adapter subprocess (e.g. `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`), and the `claude` backend uses whatever the Claude CLI is configured with. When the server answers on behalf of other agents, a Claude credential has to be an Anthropic API key (or Bedrock / Vertex); Anthropic does not permit subscription credentials for third-party serving. The `claude` backend can cap per-run cost with `--max-budget-usd`. ## Signed agent cards @@ -137,9 +146,9 @@ uv run a2claude call "sudo reboot" uv run a2claude call "allow" --task --context ``` -`allow` (or `yes`, `approve`, `ok`) approves; anything else denies. The Claude session stays alive across the pause, so it resumes exactly where it stopped. +`allow` (or `yes`, `approve`, `ok`) approves; anything else denies. The agent session stays alive across the pause, so it resumes exactly where it stopped. Over ACP this is the agent's `session/request_permission` call answered from the A2A caller's reply; with the `claude` backend it routes through the Claude SDK's `can_use_tool`. -The server does not inherit your personal Claude settings, so it has no pre-approved tool allowlist; every tool that needs approval routes through the caller. Read-only actions Claude already treats as safe still run without a prompt. +Whatever the agent decides needs approval becomes an `input-required` pause rather than being silently skipped or auto-approved; the caller, not the server, holds the decision. Read-only actions the agent already treats as safe still run without a prompt. ## Long-running tasks diff --git a/pyproject.toml b/pyproject.toml index 19e962b..58a8cdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,25 @@ [project] name = "a2claude" version = "0.3.0" -description = "Run Claude Code as an A2A protocol agent server." +description = "Serve Claude Code and other ACP coding agents over the A2A protocol." readme = "README.md" requires-python = ">=3.13" license = "Apache-2.0" authors = [{ name = "kanywst" }] -keywords = ["a2a", "claude-code", "agent", "agent2agent"] +keywords = ["a2a", "acp", "claude-code", "agent", "agent2agent"] dependencies = [ "a2a-sdk[http-server,signing]>=1.1,<2", - "claude-agent-sdk>=0.2.101", + "agent-client-protocol>=0.10,<1", "uvicorn>=0.49", "httpx>=0.28", "typer>=0.26", ] [project.optional-dependencies] +# The Claude SDK backend (backends/claude.py): drives Claude Code directly +# through the Claude Agent SDK instead of over ACP. The ACP backend fronts +# Claude Code too, so this is only needed for the SDK-native path. +claude = ["claude-agent-sdk>=0.2.101"] # Distributed tracing. Pulls the A2A SDK's OpenTelemetry instrumentation plus # the API a2claude's own spans import directly (declared rather than relied on # transitively through the SDK). Install with `a2claude[telemetry]`. @@ -35,6 +39,9 @@ dev = [ "mypy>=2.1", # So the test suite can exercise the tracing path with a real exporter. "opentelemetry-sdk>=1.33", + # The Claude SDK backend is an optional extra; pull it in for dev so its + # tests (test_claude_backend.py) run. + "claude-agent-sdk>=0.2.101", ] [build-system] diff --git a/src/a2claude/backends/__init__.py b/src/a2claude/backends/__init__.py index 702ea58..163d339 100644 --- a/src/a2claude/backends/__init__.py +++ b/src/a2claude/backends/__init__.py @@ -35,13 +35,17 @@ def make_backend(name: str, **kwargs) -> Backend: """Construct a backend by name. - ``claude`` is imported lazily so the echo backend works without the Claude - Agent SDK's runtime dependencies present. + ``acp`` and ``claude`` are imported lazily so the echo backend works without + their runtime dependencies (the ACP SDK / the Claude Agent SDK) present. """ if name == "echo": return EchoBackend() + if name == "acp": + from .acp import ACPBackend + + return ACPBackend(**kwargs) if name == "claude": from .claude import ClaudeBackend return ClaudeBackend(**kwargs) - raise ValueError(f"unknown backend: {name!r} (expected 'echo' or 'claude')") + raise ValueError(f"unknown backend: {name!r} (expected 'acp', 'claude', or 'echo')") diff --git a/src/a2claude/backends/acp.py b/src/a2claude/backends/acp.py new file mode 100644 index 0000000..42e3f23 --- /dev/null +++ b/src/a2claude/backends/acp.py @@ -0,0 +1,291 @@ +"""ACP backend. + +Drives any agent that speaks Zed's Agent Client Protocol (ACP) — Claude Code, +Gemini CLI, Codex, OpenHands, ... — as a subprocess, and normalizes its +``session/update`` stream into backend events. This is the seam that makes the +server vendor-neutral: one ACP client backend instead of one SDK adapter per +agent. Swapping the underlying coding agent becomes a launch-command change, not +a new backend. + +ACP maps almost one-to-one onto the backend event vocabulary: + + agent_message_chunk -> TextDelta + tool_call / tool_call_update -> ToolUse (+ FileChange for diff content) + session/request_permission -> PermissionRequest (the input-required pause) + PromptResponse usage + cost -> Result + +The permission round trip lands exactly on the session seam: the agent calls +back into the client's ``request_permission``, which awaits +``session.request_permission`` and parks until the A2A caller answers — the same +parked-across-two-execute-calls behavior the Claude backend gets through +``can_use_tool``. + +``events_from_update`` and ``select_option`` are pure and side-effect free so the +protocol translation is unit-testable without launching an agent subprocess. +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import Iterator, Mapping, Sequence +from pathlib import Path +from typing import Any + +from acp import PROTOCOL_VERSION, Client, spawn_agent_process, text_block +from acp import schema as s + +from .base import BackendEvent, FileChange, Result, RunRequest, TextDelta, ToolUse +from .diff import unified_diff +from .session import BackendSession + +# How to launch each known ACP agent adapter as a subprocess. A preset is just a +# default command; pass an explicit ``command``/``args`` to drive any other ACP +# agent (or a pinned/locally installed adapter). +_AGENTS: dict[str, tuple[str, tuple[str, ...]]] = { + "claude": ("npx", ("-y", "@zed-industries/claude-agent-acp")), + "gemini": ("gemini", ("--experimental-acp",)), + "codex": ("codex-acp", ()), +} + + +def events_from_update(update: object) -> Iterator[BackendEvent]: + """Map one ACP ``session/update`` to normalized backend events. + + Pure and side-effect free so the translation can be unit tested without a + live agent subprocess. ``usage_update`` yields nothing here; cost/usage is + folded into the terminal ``Result`` by the backend. + """ + if isinstance(update, s.AgentMessageChunk): + text = getattr(update.content, "text", None) + if text: + yield TextDelta(text=text) + elif isinstance(update, s.ToolCallStart): + yield ToolUse( + name=update.title or (update.kind or "tool"), + tool_input=_as_dict(update.raw_input), + tool_use_id=update.tool_call_id, + ) + yield from _file_changes(update.content) + elif isinstance(update, s.ToolCallProgress): + # A diff is often not ready when the tool call opens; later progress + # updates carry it. The ToolUse was already emitted on the start event. + yield from _file_changes(update.content) + + +def select_option(options: Sequence[s.PermissionOption], *, allow: bool) -> str | None: + """Pick the option id that matches the caller's allow/deny decision. + + ACP returns the binding choice as an ``optionId``; ``kind`` is only a UI + hint. Prefer a one-shot option (allow_once / reject_once) over a sticky one, + then fall back to any option of the right polarity. ``None`` means the agent + offered no option of that polarity. + """ + preferred = ( + ("allow_once", "allow_always") if allow else ("reject_once", "reject_always") + ) + for kind in preferred: + for opt in options: + if opt.kind == kind: + return opt.option_id + prefix = "allow" if allow else "reject" + for opt in options: + if (opt.kind or "").startswith(prefix): + return opt.option_id + return None + + +def _as_dict(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, Mapping) else {} + + +def _file_changes(content: Sequence[object] | None) -> Iterator[FileChange]: + for item in content or []: + if isinstance(item, s.FileEditToolCallContent): + yield FileChange( + path=item.path, + diff=unified_diff(item.path, item.old_text or "", item.new_text or ""), + ) + + +class _BridgeClient(Client): + """ACP client that forwards agent output onto a BackendSession. + + The agent's notifications and permission requests arrive on the ACP + connection's reader task; this translates each onto the session queue, and + parks a permission request on ``session.request_permission`` until the A2A + caller answers. + """ + + def __init__(self, session: BackendSession, cwd: str = ".") -> None: + self._session = session + # Resolved workspace root: every fs read/write is confined under it so a + # buggy or hostile agent can't reach arbitrary files via the capability + # we advertise. ACP paths are absolute, but we still contain them. + self._cwd = Path(cwd).resolve() + self.cost_usd: float | None = None + + def _safe_path(self, path: str) -> Path: + target = Path(path) + if not target.is_absolute(): + target = self._cwd / target + target = target.resolve() + if not target.is_relative_to(self._cwd): + raise PermissionError(f"path escapes workspace {self._cwd}: {path!r}") + return target + + async def session_update(self, session_id: str, update: Any, **_: Any) -> None: + if isinstance(update, s.UsageUpdate) and update.cost is not None: + self.cost_usd = update.cost.amount + for event in events_from_update(update): + await self._session.emit(event) + + async def request_permission( + self, + options: list[s.PermissionOption], + session_id: str, + tool_call: s.ToolCallUpdate, + **_: Any, + ) -> s.RequestPermissionResponse: + name = tool_call.title or (tool_call.kind or "tool") + decision = await self._session.request_permission( + name, _as_dict(tool_call.raw_input), name + ) + option_id = select_option(options, allow=decision.allow) + if option_id is None: + # The agent offered no option of the requested polarity; cancelling + # is the only safe answer (selecting the wrong one could run a tool + # the caller denied). + return s.RequestPermissionResponse( + outcome=s.DeniedOutcome(outcome="cancelled") + ) + return s.RequestPermissionResponse( + outcome=s.AllowedOutcome(outcome="selected", option_id=option_id) + ) + + async def read_text_file( + self, + path: str, + session_id: str, + limit: int | None = None, + line: int | None = None, + **_: Any, + ) -> s.ReadTextFileResponse: + # We advertise fs.readTextFile, so serve reads from disk. There are no + # unsaved editor buffers on a server; the file on disk is the truth. + target = self._safe_path(path) + if limit is not None and limit <= 0: + return s.ReadTextFileResponse(content="") + # A non-positive line number reads from the top. + start = (line - 1) if (line and line > 0) else 0 + + def _read() -> str: + if line is None and limit is None: + return target.read_text(encoding="utf-8") + # Stream so a small windowed read doesn't pull a huge file into + # memory just to slice a few lines out of it. + end = (start + limit) if limit is not None else None + out: list[str] = [] + with target.open(encoding="utf-8") as f: + for i, text_line in enumerate(f): + if i >= start: + out.append(text_line) + if end is not None and i >= end - 1: + break + return "".join(out) + + # Offloaded to a thread so the synchronous read can't stall the event + # loop the ACP connection runs on. + text = await asyncio.to_thread(_read) + return s.ReadTextFileResponse(content=text) + + async def write_text_file( + self, content: str, path: str, session_id: str, **_: Any + ) -> None: + target = self._safe_path(path) + + def _write() -> None: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + # Offloaded so the blocking mkdir/write can't stall the event loop. + await asyncio.to_thread(_write) + return None + + +class ACPBackend: + name = "acp" + + def __init__( + self, + *, + agent: str = "claude", + command: str | None = None, + args: Sequence[str] | None = None, + cwd: str | None = None, + env: Mapping[str, str] | None = None, + ) -> None: + if command is None: + preset = _AGENTS.get(agent) + if preset is None: + known = ", ".join(sorted(_AGENTS)) + raise ValueError( + f"unknown ACP agent {agent!r} (known: {known}); " + "pass command=... to launch any other ACP agent" + ) + command, default_args = preset + args = default_args if args is None else args + self.agent = agent + self.command = command + self.args = list(args or []) + self.cwd = os.path.abspath(cwd or os.getcwd()) + # Overrides layered onto the server's own environment so the adapter + # still inherits PATH and any provider credentials (ANTHROPIC_API_KEY, + # GEMINI_API_KEY, ...) it needs to authenticate. + self.env = {**os.environ, **(env or {})} + + async def drive(self, session: BackendSession, request: RunRequest) -> None: + # The ACP Client base declares terminal/* and ext_* with empty bodies as + # optional overrides; we advertise no terminal capability, so the agent + # never calls them. mypy reads the empty bodies as abstract, hence the + # scoped ignore. + client = _BridgeClient(session, self.cwd) # type: ignore[abstract] + async with spawn_agent_process( + client, self.command, *self.args, env=self.env, cwd=self.cwd + ) as (conn, _process): + init = await conn.initialize( + protocol_version=PROTOCOL_VERSION, + client_capabilities=s.ClientCapabilities( + fs=s.FileSystemCapabilities( + read_text_file=True, write_text_file=True + ) + ), + ) + session_id = await self._open_session(conn, init, request) + response = await conn.prompt( + prompt=[text_block(request.prompt)], session_id=session_id + ) + usage = response.usage.model_dump() if response.usage else None + await session.emit( + Result( + session_id=session_id, + cost_usd=client.cost_usd, + num_turns=None, + usage=usage, + ) + ) + + async def _open_session( + self, conn: Any, init: s.InitializeResponse, request: RunRequest + ) -> str: + can_load = bool(getattr(init.agent_capabilities, "load_session", False)) + if request.resume and can_load: + await conn.load_session( + cwd=self.cwd, session_id=request.resume, mcp_servers=[] + ) + return request.resume + # No resume, or the agent can't reload a session: start fresh. The + # executor learns the new session id from the Result and maps the A2A + # context onto it for the next turn. + response = await conn.new_session(cwd=self.cwd, mcp_servers=[]) + return response.session_id diff --git a/src/a2claude/backends/diff.py b/src/a2claude/backends/diff.py index b11a91f..cfa1772 100644 --- a/src/a2claude/backends/diff.py +++ b/src/a2claude/backends/diff.py @@ -28,6 +28,15 @@ def _unified(path: str, before: str, after: str) -> str: return diff +def unified_diff(path: str, before: str, after: str) -> str: + """Build a unified diff from full before/after file text. + + Used by the ACP backend, whose diff tool-call content carries the complete + old and new file contents rather than an edit description. + """ + return _unified(path, before, after) + + def _text(value: Any) -> str: """Coerce a tool-input field to text, treating a missing/null value as empty. diff --git a/src/a2claude/card.py b/src/a2claude/card.py index e514512..adb0bbb 100644 --- a/src/a2claude/card.py +++ b/src/a2claude/card.py @@ -1,8 +1,9 @@ """Agent card construction. -The card advertises Claude Code's coding abilities as discrete A2A skills so -that calling agents can route to it deliberately rather than treating it as an -opaque chat box. +The card advertises the coding agent's abilities as discrete A2A skills so that +calling agents can route to it deliberately rather than treating it as an opaque +chat box. The name identifies which agent is fronted; the skills are the same +generation/refactor/debug/review/test/explain set regardless of backend. """ from __future__ import annotations @@ -93,7 +94,7 @@ def build_card( card = AgentCard( name=name, description=description - or "Claude Code as an A2A agent: generation, refactoring, " + or "A coding agent on an A2A mesh: generation, refactoring, " "debugging, review, testing, and explanation over a real project " "workspace.", version=VERSION, diff --git a/src/a2claude/cli.py b/src/a2claude/cli.py index 8ec44cb..8cd4985 100644 --- a/src/a2claude/cli.py +++ b/src/a2claude/cli.py @@ -11,13 +11,24 @@ import asyncio import json +import shlex from uuid import uuid4 import httpx import typer import uvicorn -app = typer.Typer(add_completion=False, help="Run Claude Code as an A2A server.") +app = typer.Typer( + add_completion=False, + help="Serve a coding agent (Claude Code and other ACP agents) over A2A.", +) + +# Human-facing card names for the ACP agents shipped with a launch preset. +_AGENT_CARD_NAMES = { + "claude": "Claude Code", + "gemini": "Gemini CLI", + "codex": "Codex", +} def _validate_permission_mode(value: str | None) -> None: @@ -58,8 +69,20 @@ def _local_url(host: str, port: int) -> str: @app.command() def serve( - backend: str = typer.Option("claude", help="Backend: 'claude' or 'echo'."), - cwd: str = typer.Option(".", help="Project directory Claude Code works in."), + backend: str = typer.Option( + "acp", help="Backend: 'acp' (any ACP agent), 'claude' (Claude SDK), 'echo'." + ), + agent: str = typer.Option( + "claude", + help="ACP agent the 'acp' backend fronts: 'claude', 'gemini', 'codex', " + "or any agent reachable via --agent-command.", + ), + agent_command: str = typer.Option( + None, + help="Explicit launch command for an ACP agent, overriding --agent's " + "preset (e.g. 'npx -y @zed-industries/claude-agent-acp').", + ), + cwd: str = typer.Option(".", help="Project directory the coding agent works in."), host: str = typer.Option("127.0.0.1"), port: int = typer.Option(9100), permission_mode: str = typer.Option( @@ -93,10 +116,27 @@ def serve( from .backends import make_backend from .server import build_app - _validate_permission_mode(permission_mode) - kwargs: dict[str, object] = {} - if backend == "claude": + card_name = None + if backend == "acp": + kwargs = {"agent": agent, "cwd": cwd} + if agent_command: + try: + parts = shlex.split(agent_command) + except ValueError as e: + raise typer.BadParameter(f"invalid --agent-command: {e}") from e + if not parts: + raise typer.BadParameter("--agent-command is empty or whitespace-only") + kwargs["command"], kwargs["args"] = parts[0], parts[1:] + # The identity of an arbitrary command is unknown, so don't advertise + # it under a preset name (which would default to "Claude Code"). + card_name = "Coding Agent" + else: + card_name = _AGENT_CARD_NAMES.get(agent, agent.capitalize()) + elif backend == "claude": + # Claude-only flag: validate it only on the Claude path so an ACP run + # isn't rejected for a flag it never uses. + _validate_permission_mode(permission_mode) kwargs = { "cwd": cwd, "permission_mode": permission_mode, @@ -129,10 +169,12 @@ def serve( asgi_app = build_app( drv, url=_local_url(host, port), + card_name=card_name, card_signer=card_signer, auth_token=auth_token, ) - typer.echo(f"a2claude: backend={backend} card={_local_url(host, port)}") + label = f"{backend}:{agent}" if backend == "acp" else backend + typer.echo(f"a2claude: backend={label} card={_local_url(host, port)}") uvicorn.run(asgi_app, host=host, port=port, log_level="info") diff --git a/src/a2claude/server.py b/src/a2claude/server.py index b46a838..82caf1f 100644 --- a/src/a2claude/server.py +++ b/src/a2claude/server.py @@ -38,11 +38,15 @@ def build_app( backend: Backend, *, url: str, + card_name: str | None = None, + card_description: str | None = None, card_signer: Callable[[AgentCard], AgentCard] | None = None, auth_token: str | None = None, ) -> Starlette: card = build_card( url, + name=card_name or "Claude Code", + description=card_description, streaming=True, push_notifications=True, require_auth=auth_token is not None, diff --git a/tests/test_acp.py b/tests/test_acp.py new file mode 100644 index 0000000..0b49a82 --- /dev/null +++ b/tests/test_acp.py @@ -0,0 +1,214 @@ +"""Unit tests for the ACP backend's pure mapping and permission selection. + +These exercise the protocol translation without launching an agent subprocess: +``events_from_update`` and ``select_option`` are pure, and ``_BridgeClient``'s +permission round trip is driven against a fake session. +""" + +from __future__ import annotations + +import pytest +from acp import schema as s +from acp import text_block, tool_diff_content + +from a2claude.backends.acp import ( + _BridgeClient, + events_from_update, + select_option, +) +from a2claude.backends.base import ( + FileChange, + PermissionDecision, + TextDelta, + ToolUse, +) + + +def _opts() -> list[s.PermissionOption]: + return [ + s.PermissionOption(option_id="a", name="Allow", kind="allow_once"), + s.PermissionOption(option_id="A", name="Always", kind="allow_always"), + s.PermissionOption(option_id="r", name="Reject", kind="reject_once"), + ] + + +def test_agent_message_chunk_maps_to_text_delta(): + update = s.AgentMessageChunk( + session_update="agent_message_chunk", content=text_block("hello") + ) + events = list(events_from_update(update)) + assert events == [TextDelta(text="hello")] + + +def test_empty_text_chunk_yields_nothing(): + update = s.AgentMessageChunk( + session_update="agent_message_chunk", content=text_block("") + ) + assert list(events_from_update(update)) == [] + + +def test_tool_call_start_with_diff_yields_tooluse_and_filechange(): + update = s.ToolCallStart( + session_update="tool_call", + tool_call_id="t1", + title="Write a.py", + kind="edit", + raw_input={"file_path": "a.py"}, + content=[tool_diff_content(path="a.py", new_text="x = 1\n", old_text=None)], + ) + events = list(events_from_update(update)) + + assert len(events) == 2 + assert isinstance(events[0], ToolUse) + assert events[0].name == "Write a.py" + assert events[0].tool_use_id == "t1" + assert events[0].tool_input == {"file_path": "a.py"} + assert isinstance(events[1], FileChange) + assert events[1].path == "a.py" + assert "+x = 1" in events[1].diff + + +def test_tool_call_progress_yields_only_filechange(): + update = s.ToolCallProgress( + session_update="tool_call_update", + tool_call_id="t1", + content=[ + tool_diff_content(path="a.py", new_text="y = 2\n", old_text="x = 1\n") + ], + ) + events = list(events_from_update(update)) + assert len(events) == 1 + assert isinstance(events[0], FileChange) + assert "-x = 1" in events[0].diff + assert "+y = 2" in events[0].diff + + +def test_usage_update_yields_nothing(): + update = s.UsageUpdate(session_update="usage_update", used=10, size=100) + assert list(events_from_update(update)) == [] + + +def test_non_mapping_raw_input_becomes_empty_dict(): + update = s.ToolCallStart( + session_update="tool_call", tool_call_id="t1", title="x", raw_input="not-a-dict" + ) + events = list(events_from_update(update)) + assert events[0].tool_input == {} + + +def test_select_option_prefers_one_shot(): + assert select_option(_opts(), allow=True) == "a" + assert select_option(_opts(), allow=False) == "r" + + +def test_select_option_falls_back_to_always_when_no_once(): + opts = [s.PermissionOption(option_id="A", name="Always", kind="allow_always")] + assert select_option(opts, allow=True) == "A" + assert select_option(opts, allow=False) is None + + +class _FakeSession: + def __init__(self, decision: PermissionDecision) -> None: + self._decision = decision + self.asked: tuple[str, dict, str] | None = None + + async def request_permission(self, name, tool_input, description): + self.asked = (name, tool_input, description) + return self._decision + + +@pytest.mark.asyncio +async def test_request_permission_allow_selects_allow_option(): + session = _FakeSession(PermissionDecision(request_id="x", allow=True)) + client = _BridgeClient(session) # type: ignore[arg-type] + tool_call = s.ToolCallUpdate(tool_call_id="t1", title="Run ls", kind="execute") + + resp = await client.request_permission(_opts(), "sess", tool_call) + + assert isinstance(resp.outcome, s.AllowedOutcome) + assert resp.outcome.option_id == "a" + assert session.asked == ("Run ls", {}, "Run ls") + + +@pytest.mark.asyncio +async def test_request_permission_deny_selects_reject_option(): + session = _FakeSession(PermissionDecision(request_id="x", allow=False)) + client = _BridgeClient(session) # type: ignore[arg-type] + tool_call = s.ToolCallUpdate(tool_call_id="t1", title="rm -rf", kind="execute") + + resp = await client.request_permission(_opts(), "sess", tool_call) + + assert isinstance(resp.outcome, s.AllowedOutcome) + assert resp.outcome.option_id == "r" + + +@pytest.mark.asyncio +async def test_request_permission_cancels_when_no_matching_option(): + session = _FakeSession(PermissionDecision(request_id="x", allow=False)) + client = _BridgeClient(session) # type: ignore[arg-type] + allow_only = [s.PermissionOption(option_id="a", name="Allow", kind="allow_once")] + tool_call = s.ToolCallUpdate(tool_call_id="t1", title="x") + + resp = await client.request_permission(allow_only, "sess", tool_call) + + assert isinstance(resp.outcome, s.DeniedOutcome) + + +@pytest.mark.asyncio +async def test_write_and_read_within_workspace(tmp_path): + session = _FakeSession(PermissionDecision(request_id="x", allow=True)) + client = _BridgeClient(session, str(tmp_path)) # type: ignore[arg-type] + target = tmp_path / "sub" / "a.txt" + + await client.write_text_file("hello\n", str(target), "sess") + assert target.read_text() == "hello\n" + resp = await client.read_text_file(str(target), "sess") + assert resp.content == "hello\n" + + +@pytest.mark.asyncio +async def test_read_outside_workspace_is_rejected(tmp_path): + session = _FakeSession(PermissionDecision(request_id="x", allow=True)) + workspace = tmp_path / "ws" + workspace.mkdir() + secret = tmp_path / "secret.txt" + secret.write_text("top secret\n") + client = _BridgeClient(session, str(workspace)) # type: ignore[arg-type] + + with pytest.raises(PermissionError): + await client.read_text_file(str(secret), "sess") + with pytest.raises(PermissionError): + await client.write_text_file("x", str(tmp_path / "escape.txt"), "sess") + + +@pytest.mark.asyncio +async def test_read_with_line_and_limit(tmp_path): + session = _FakeSession(PermissionDecision(request_id="x", allow=True)) + client = _BridgeClient(session, str(tmp_path)) # type: ignore[arg-type] + target = tmp_path / "a.txt" + target.write_text("l1\nl2\nl3\nl4\n") + + resp = await client.read_text_file(str(target), "sess", line=2, limit=2) + assert resp.content == "l2\nl3\n" + # A non-positive line number must not slice from the end. + resp = await client.read_text_file(str(target), "sess", line=0, limit=1) + assert resp.content == "l1\n" + # A negative limit must not slice from the end; it yields nothing. + resp = await client.read_text_file(str(target), "sess", line=1, limit=-1) + assert resp.content == "" + + +@pytest.mark.asyncio +async def test_session_update_captures_cost(): + session = _FakeSession(PermissionDecision(request_id="x", allow=True)) + client = _BridgeClient(session) # type: ignore[arg-type] + await client.session_update( + "sess", + s.UsageUpdate( + session_update="usage_update", + used=10, + size=100, + cost=s.Cost(amount=0.42, currency="USD"), + ), + ) + assert client.cost_usd == 0.42 diff --git a/tests/test_cli.py b/tests/test_cli.py index 95ec779..81d23b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,7 +3,7 @@ from __future__ import annotations import re -from unittest.mock import patch +from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -26,10 +26,11 @@ def _plain(output: str) -> str: @patch("a2claude.cli.uvicorn.run") -def test_serve_rejects_invalid_permission_mode(mock_run): - # Validation fails before uvicorn.run, so the server never starts. +def test_serve_rejects_invalid_permission_mode(mock_run: MagicMock) -> None: + # --permission-mode is a Claude-only flag, so it is validated on the claude + # path. A bad value fails before uvicorn.run, so the server never starts. result = runner.invoke( - app, ["serve", "--backend", "echo", "--permission-mode", "bogus"] + app, ["serve", "--backend", "claude", "--permission-mode", "bogus"] ) assert result.exit_code != 0 mock_run.assert_not_called() @@ -39,11 +40,36 @@ def test_serve_rejects_invalid_permission_mode(mock_run): @patch("a2claude.cli.uvicorn.run") -def test_serve_accepts_a_valid_permission_mode(mock_run): +def test_serve_accepts_a_valid_permission_mode(mock_run: MagicMock) -> None: # 'plan' is valid, so validation passes and execution proceeds to uvicorn.run # (mocked here so no socket is bound). result = runner.invoke( - app, ["serve", "--backend", "echo", "--permission-mode", "plan"] + app, ["serve", "--backend", "claude", "--permission-mode", "plan"] + ) + assert result.exit_code == 0 + mock_run.assert_called_once() + + +@patch("a2claude.cli.uvicorn.run") +def test_serve_ignores_permission_mode_off_the_claude_path( + mock_run: MagicMock, +) -> None: + # A non-Claude backend never uses --permission-mode, so an otherwise invalid + # value must not block startup: validation belongs to the claude path only. + result = runner.invoke( + app, ["serve", "--backend", "echo", "--permission-mode", "bogus"] ) assert result.exit_code == 0 mock_run.assert_called_once() + + +@patch("a2claude.cli.uvicorn.run") +def test_serve_rejects_malformed_agent_command(mock_run: MagicMock) -> None: + # An unmatched quote makes shlex.split raise; it must surface as a clean + # BadParameter, not a traceback, and the server must not start. + result = runner.invoke( + app, ["serve", "--backend", "acp", "--agent-command", "npx 'unterminated"] + ) + assert result.exit_code != 0 + mock_run.assert_not_called() + assert "agent-command" in _plain(result.output) diff --git a/uv.lock b/uv.lock index 38d1112..9b43139 100644 --- a/uv.lock +++ b/uv.lock @@ -44,13 +44,16 @@ version = "0.3.0" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server", "signing"] }, - { name = "claude-agent-sdk" }, + { name = "agent-client-protocol" }, { name = "httpx" }, { name = "typer" }, { name = "uvicorn" }, ] [package.optional-dependencies] +claude = [ + { name = "claude-agent-sdk" }, +] telemetry = [ { name = "a2a-sdk", extra = ["telemetry"] }, { name = "opentelemetry-api" }, @@ -58,6 +61,7 @@ telemetry = [ [package.dev-dependencies] dev = [ + { name = "claude-agent-sdk" }, { name = "mypy" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, @@ -69,16 +73,18 @@ dev = [ requires-dist = [ { name = "a2a-sdk", extras = ["http-server", "signing"], specifier = ">=1.1,<2" }, { name = "a2a-sdk", extras = ["telemetry"], marker = "extra == 'telemetry'", specifier = ">=1.1,<2" }, - { name = "claude-agent-sdk", specifier = ">=0.2.101" }, + { name = "agent-client-protocol", specifier = ">=0.10,<1" }, + { name = "claude-agent-sdk", marker = "extra == 'claude'", specifier = ">=0.2.101" }, { name = "httpx", specifier = ">=0.28" }, { name = "opentelemetry-api", marker = "extra == 'telemetry'", specifier = ">=1.33" }, { name = "typer", specifier = ">=0.26" }, { name = "uvicorn", specifier = ">=0.49" }, ] -provides-extras = ["telemetry"] +provides-extras = ["claude", "telemetry"] [package.metadata.requires-dev] dev = [ + { name = "claude-agent-sdk", specifier = ">=0.2.101" }, { name = "mypy", specifier = ">=2.1" }, { name = "opentelemetry-sdk", specifier = ">=1.33" }, { name = "pytest", specifier = ">=9" }, @@ -86,6 +92,18 @@ dev = [ { name = "ruff", specifier = ">=0.15" }, ] +[[package]] +name = "agent-client-protocol" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/a0/3b96cd8374725c69bc3dae9fcc2082f3f6cafec1be35d24d7af0f8c3265f/agent_client_protocol-0.10.1.tar.gz", hash = "sha256:355c65ca19f0568344aafc2c1552b7066a8fc491df23ab28e7e253c6c9a85a25", size = 81924, upload-time = "2026-05-24T18:46:44.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/18/d8c7ff337cf621ea79a84006a7252ff057bfb5767549bb102cc6649f4ec2/agent_client_protocol-0.10.1-py3-none-any.whl", hash = "sha256:a03d3198f4d772f2e0ec012c00ac1cce131b4710220a3dc9fae3c991d047c750", size = 65401, upload-time = "2026-05-24T18:46:43.202Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4"