a2acode 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2acode/__init__.py +9 -0
- a2acode/auth.py +87 -0
- a2acode/backends/__init__.py +51 -0
- a2acode/backends/acp.py +291 -0
- a2acode/backends/base.py +96 -0
- a2acode/backends/claude.py +125 -0
- a2acode/backends/diff.py +79 -0
- a2acode/backends/echo.py +47 -0
- a2acode/backends/session.py +132 -0
- a2acode/card.py +187 -0
- a2acode/cli.py +295 -0
- a2acode/executor.py +365 -0
- a2acode/py.typed +0 -0
- a2acode/server.py +88 -0
- a2acode/tracing.py +41 -0
- a2acode-0.4.0.dist-info/METADATA +215 -0
- a2acode-0.4.0.dist-info/RECORD +19 -0
- a2acode-0.4.0.dist-info/WHEEL +4 -0
- a2acode-0.4.0.dist-info/entry_points.txt +3 -0
a2acode/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Run Claude Code as an A2A protocol agent server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .card import build_card
|
|
6
|
+
from .executor import ClaudeCodeExecutor
|
|
7
|
+
from .server import build_app
|
|
8
|
+
|
|
9
|
+
__all__ = ["build_app", "build_card", "ClaudeCodeExecutor"]
|
a2acode/auth.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Caller authentication.
|
|
2
|
+
|
|
3
|
+
A server that answers on behalf of other agents should be able to require a
|
|
4
|
+
credential. This is a pure-ASGI middleware (not ``BaseHTTPMiddleware``) so it
|
|
5
|
+
passes the request straight through to the inner app when authorized, leaving
|
|
6
|
+
streaming and server-sent events untouched; it only short-circuits with a 401
|
|
7
|
+
when a token is missing or wrong.
|
|
8
|
+
|
|
9
|
+
The agent card stays public: a caller fetches it to learn the auth scheme
|
|
10
|
+
*before* it has a credential, so discovery paths under ``/.well-known/`` are
|
|
11
|
+
exempt while the task endpoints are protected.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
|
|
20
|
+
Receive = Callable[[], Awaitable[dict]]
|
|
21
|
+
Send = Callable[[dict], Awaitable[None]]
|
|
22
|
+
ASGIApp = Callable[[dict, Receive, Send], Awaitable[None]]
|
|
23
|
+
|
|
24
|
+
_PUBLIC_PREFIXES = ("/.well-known/",)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BearerAuthMiddleware:
|
|
28
|
+
"""Require ``Authorization: Bearer <token>`` on non-discovery requests."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
app: ASGIApp,
|
|
33
|
+
*,
|
|
34
|
+
token: str,
|
|
35
|
+
public_prefixes: tuple[str, ...] = _PUBLIC_PREFIXES,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not token.strip():
|
|
38
|
+
raise ValueError("auth token must not be empty")
|
|
39
|
+
self.app = app
|
|
40
|
+
# Compare SHA-256 digests rather than the tokens themselves: the
|
|
41
|
+
# constant-time compare is then always over a fixed 32 bytes, so it
|
|
42
|
+
# cannot leak the token length, and the raw secret is not kept around.
|
|
43
|
+
self._token_digest = hashlib.sha256(token.encode("utf-8")).digest()
|
|
44
|
+
self._public = public_prefixes
|
|
45
|
+
|
|
46
|
+
async def __call__(self, scope: dict, receive: Receive, send: Send) -> None:
|
|
47
|
+
if scope["type"] != "http" or self._is_public(scope.get("path", "")):
|
|
48
|
+
await self.app(scope, receive, send)
|
|
49
|
+
return
|
|
50
|
+
if self._authorized(scope):
|
|
51
|
+
await self.app(scope, receive, send)
|
|
52
|
+
return
|
|
53
|
+
await self._reject(send)
|
|
54
|
+
|
|
55
|
+
def _is_public(self, path: str) -> bool:
|
|
56
|
+
return any(path.startswith(p) for p in self._public)
|
|
57
|
+
|
|
58
|
+
def _authorized(self, scope: dict) -> bool:
|
|
59
|
+
# Scan the headers list for the one we need instead of materializing a
|
|
60
|
+
# dict on every request.
|
|
61
|
+
raw = b""
|
|
62
|
+
for key, value in scope.get("headers") or []:
|
|
63
|
+
if key == b"authorization":
|
|
64
|
+
raw = value
|
|
65
|
+
break
|
|
66
|
+
# split(None, 1) tolerates extra whitespace between scheme and token.
|
|
67
|
+
parts = raw.split(None, 1)
|
|
68
|
+
if len(parts) != 2 or parts[0].lower() != b"bearer":
|
|
69
|
+
return False
|
|
70
|
+
presented = hashlib.sha256(parts[1].strip()).digest()
|
|
71
|
+
return hmac.compare_digest(presented, self._token_digest)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
async def _reject(send: Send) -> None:
|
|
75
|
+
body = b'{"error": "unauthorized"}'
|
|
76
|
+
await send(
|
|
77
|
+
{
|
|
78
|
+
"type": "http.response.start",
|
|
79
|
+
"status": 401,
|
|
80
|
+
"headers": [
|
|
81
|
+
(b"content-type", b"application/json"),
|
|
82
|
+
(b"www-authenticate", b"Bearer"),
|
|
83
|
+
(b"content-length", str(len(body)).encode()),
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Backends drive Claude Code and emit normalized events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import (
|
|
6
|
+
Backend,
|
|
7
|
+
BackendEvent,
|
|
8
|
+
FileChange,
|
|
9
|
+
PermissionDecision,
|
|
10
|
+
PermissionRequest,
|
|
11
|
+
Result,
|
|
12
|
+
RunRequest,
|
|
13
|
+
TextDelta,
|
|
14
|
+
ToolUse,
|
|
15
|
+
)
|
|
16
|
+
from .echo import EchoBackend
|
|
17
|
+
from .session import BackendSession
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Backend",
|
|
21
|
+
"BackendEvent",
|
|
22
|
+
"BackendSession",
|
|
23
|
+
"FileChange",
|
|
24
|
+
"PermissionDecision",
|
|
25
|
+
"PermissionRequest",
|
|
26
|
+
"Result",
|
|
27
|
+
"RunRequest",
|
|
28
|
+
"TextDelta",
|
|
29
|
+
"ToolUse",
|
|
30
|
+
"EchoBackend",
|
|
31
|
+
"make_backend",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_backend(name: str, **kwargs) -> Backend:
|
|
36
|
+
"""Construct a backend by name.
|
|
37
|
+
|
|
38
|
+
``acp`` and ``claude`` are imported lazily so the echo backend works without
|
|
39
|
+
their runtime dependencies (the ACP SDK / the Claude Agent SDK) present.
|
|
40
|
+
"""
|
|
41
|
+
if name == "echo":
|
|
42
|
+
return EchoBackend()
|
|
43
|
+
if name == "acp":
|
|
44
|
+
from .acp import ACPBackend
|
|
45
|
+
|
|
46
|
+
return ACPBackend(**kwargs)
|
|
47
|
+
if name == "claude":
|
|
48
|
+
from .claude import ClaudeBackend
|
|
49
|
+
|
|
50
|
+
return ClaudeBackend(**kwargs)
|
|
51
|
+
raise ValueError(f"unknown backend: {name!r} (expected 'acp', 'claude', or 'echo')")
|
a2acode/backends/acp.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""ACP backend.
|
|
2
|
+
|
|
3
|
+
Drives any agent that speaks Zed's Agent Client Protocol (ACP) — Claude Code,
|
|
4
|
+
Gemini CLI, Codex, OpenHands, ... — as a subprocess, and normalizes its
|
|
5
|
+
``session/update`` stream into backend events. This is the seam that makes the
|
|
6
|
+
server vendor-neutral: one ACP client backend instead of one SDK adapter per
|
|
7
|
+
agent. Swapping the underlying coding agent becomes a launch-command change, not
|
|
8
|
+
a new backend.
|
|
9
|
+
|
|
10
|
+
ACP maps almost one-to-one onto the backend event vocabulary:
|
|
11
|
+
|
|
12
|
+
agent_message_chunk -> TextDelta
|
|
13
|
+
tool_call / tool_call_update -> ToolUse (+ FileChange for diff content)
|
|
14
|
+
session/request_permission -> PermissionRequest (the input-required pause)
|
|
15
|
+
PromptResponse usage + cost -> Result
|
|
16
|
+
|
|
17
|
+
The permission round trip lands exactly on the session seam: the agent calls
|
|
18
|
+
back into the client's ``request_permission``, which awaits
|
|
19
|
+
``session.request_permission`` and parks until the A2A caller answers — the same
|
|
20
|
+
parked-across-two-execute-calls behavior the Claude backend gets through
|
|
21
|
+
``can_use_tool``.
|
|
22
|
+
|
|
23
|
+
``events_from_update`` and ``select_option`` are pure and side-effect free so the
|
|
24
|
+
protocol translation is unit-testable without launching an agent subprocess.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import os
|
|
31
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from acp import PROTOCOL_VERSION, Client, spawn_agent_process, text_block
|
|
36
|
+
from acp import schema as s
|
|
37
|
+
|
|
38
|
+
from .base import BackendEvent, FileChange, Result, RunRequest, TextDelta, ToolUse
|
|
39
|
+
from .diff import unified_diff
|
|
40
|
+
from .session import BackendSession
|
|
41
|
+
|
|
42
|
+
# How to launch each known ACP agent adapter as a subprocess. A preset is just a
|
|
43
|
+
# default command; pass an explicit ``command``/``args`` to drive any other ACP
|
|
44
|
+
# agent (or a pinned/locally installed adapter).
|
|
45
|
+
_AGENTS: dict[str, tuple[str, tuple[str, ...]]] = {
|
|
46
|
+
"claude": ("npx", ("-y", "@zed-industries/claude-agent-acp")),
|
|
47
|
+
"gemini": ("gemini", ("--experimental-acp",)),
|
|
48
|
+
"codex": ("codex-acp", ()),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def events_from_update(update: object) -> Iterator[BackendEvent]:
|
|
53
|
+
"""Map one ACP ``session/update`` to normalized backend events.
|
|
54
|
+
|
|
55
|
+
Pure and side-effect free so the translation can be unit tested without a
|
|
56
|
+
live agent subprocess. ``usage_update`` yields nothing here; cost/usage is
|
|
57
|
+
folded into the terminal ``Result`` by the backend.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(update, s.AgentMessageChunk):
|
|
60
|
+
text = getattr(update.content, "text", None)
|
|
61
|
+
if text:
|
|
62
|
+
yield TextDelta(text=text)
|
|
63
|
+
elif isinstance(update, s.ToolCallStart):
|
|
64
|
+
yield ToolUse(
|
|
65
|
+
name=update.title or (update.kind or "tool"),
|
|
66
|
+
tool_input=_as_dict(update.raw_input),
|
|
67
|
+
tool_use_id=update.tool_call_id,
|
|
68
|
+
)
|
|
69
|
+
yield from _file_changes(update.content)
|
|
70
|
+
elif isinstance(update, s.ToolCallProgress):
|
|
71
|
+
# A diff is often not ready when the tool call opens; later progress
|
|
72
|
+
# updates carry it. The ToolUse was already emitted on the start event.
|
|
73
|
+
yield from _file_changes(update.content)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def select_option(options: Sequence[s.PermissionOption], *, allow: bool) -> str | None:
|
|
77
|
+
"""Pick the option id that matches the caller's allow/deny decision.
|
|
78
|
+
|
|
79
|
+
ACP returns the binding choice as an ``optionId``; ``kind`` is only a UI
|
|
80
|
+
hint. Prefer a one-shot option (allow_once / reject_once) over a sticky one,
|
|
81
|
+
then fall back to any option of the right polarity. ``None`` means the agent
|
|
82
|
+
offered no option of that polarity.
|
|
83
|
+
"""
|
|
84
|
+
preferred = (
|
|
85
|
+
("allow_once", "allow_always") if allow else ("reject_once", "reject_always")
|
|
86
|
+
)
|
|
87
|
+
for kind in preferred:
|
|
88
|
+
for opt in options:
|
|
89
|
+
if opt.kind == kind:
|
|
90
|
+
return opt.option_id
|
|
91
|
+
prefix = "allow" if allow else "reject"
|
|
92
|
+
for opt in options:
|
|
93
|
+
if (opt.kind or "").startswith(prefix):
|
|
94
|
+
return opt.option_id
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
99
|
+
return dict(value) if isinstance(value, Mapping) else {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _file_changes(content: Sequence[object] | None) -> Iterator[FileChange]:
|
|
103
|
+
for item in content or []:
|
|
104
|
+
if isinstance(item, s.FileEditToolCallContent):
|
|
105
|
+
yield FileChange(
|
|
106
|
+
path=item.path,
|
|
107
|
+
diff=unified_diff(item.path, item.old_text or "", item.new_text or ""),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _BridgeClient(Client):
|
|
112
|
+
"""ACP client that forwards agent output onto a BackendSession.
|
|
113
|
+
|
|
114
|
+
The agent's notifications and permission requests arrive on the ACP
|
|
115
|
+
connection's reader task; this translates each onto the session queue, and
|
|
116
|
+
parks a permission request on ``session.request_permission`` until the A2A
|
|
117
|
+
caller answers.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, session: BackendSession, cwd: str = ".") -> None:
|
|
121
|
+
self._session = session
|
|
122
|
+
# Resolved workspace root: every fs read/write is confined under it so a
|
|
123
|
+
# buggy or hostile agent can't reach arbitrary files via the capability
|
|
124
|
+
# we advertise. ACP paths are absolute, but we still contain them.
|
|
125
|
+
self._cwd = Path(cwd).resolve()
|
|
126
|
+
self.cost_usd: float | None = None
|
|
127
|
+
|
|
128
|
+
def _safe_path(self, path: str) -> Path:
|
|
129
|
+
target = Path(path)
|
|
130
|
+
if not target.is_absolute():
|
|
131
|
+
target = self._cwd / target
|
|
132
|
+
target = target.resolve()
|
|
133
|
+
if not target.is_relative_to(self._cwd):
|
|
134
|
+
raise PermissionError(f"path escapes workspace {self._cwd}: {path!r}")
|
|
135
|
+
return target
|
|
136
|
+
|
|
137
|
+
async def session_update(self, session_id: str, update: Any, **_: Any) -> None:
|
|
138
|
+
if isinstance(update, s.UsageUpdate) and update.cost is not None:
|
|
139
|
+
self.cost_usd = update.cost.amount
|
|
140
|
+
for event in events_from_update(update):
|
|
141
|
+
await self._session.emit(event)
|
|
142
|
+
|
|
143
|
+
async def request_permission(
|
|
144
|
+
self,
|
|
145
|
+
options: list[s.PermissionOption],
|
|
146
|
+
session_id: str,
|
|
147
|
+
tool_call: s.ToolCallUpdate,
|
|
148
|
+
**_: Any,
|
|
149
|
+
) -> s.RequestPermissionResponse:
|
|
150
|
+
name = tool_call.title or (tool_call.kind or "tool")
|
|
151
|
+
decision = await self._session.request_permission(
|
|
152
|
+
name, _as_dict(tool_call.raw_input), name
|
|
153
|
+
)
|
|
154
|
+
option_id = select_option(options, allow=decision.allow)
|
|
155
|
+
if option_id is None:
|
|
156
|
+
# The agent offered no option of the requested polarity; cancelling
|
|
157
|
+
# is the only safe answer (selecting the wrong one could run a tool
|
|
158
|
+
# the caller denied).
|
|
159
|
+
return s.RequestPermissionResponse(
|
|
160
|
+
outcome=s.DeniedOutcome(outcome="cancelled")
|
|
161
|
+
)
|
|
162
|
+
return s.RequestPermissionResponse(
|
|
163
|
+
outcome=s.AllowedOutcome(outcome="selected", option_id=option_id)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def read_text_file(
|
|
167
|
+
self,
|
|
168
|
+
path: str,
|
|
169
|
+
session_id: str,
|
|
170
|
+
limit: int | None = None,
|
|
171
|
+
line: int | None = None,
|
|
172
|
+
**_: Any,
|
|
173
|
+
) -> s.ReadTextFileResponse:
|
|
174
|
+
# We advertise fs.readTextFile, so serve reads from disk. There are no
|
|
175
|
+
# unsaved editor buffers on a server; the file on disk is the truth.
|
|
176
|
+
target = self._safe_path(path)
|
|
177
|
+
if limit is not None and limit <= 0:
|
|
178
|
+
return s.ReadTextFileResponse(content="")
|
|
179
|
+
# A non-positive line number reads from the top.
|
|
180
|
+
start = (line - 1) if (line and line > 0) else 0
|
|
181
|
+
|
|
182
|
+
def _read() -> str:
|
|
183
|
+
if line is None and limit is None:
|
|
184
|
+
return target.read_text(encoding="utf-8")
|
|
185
|
+
# Stream so a small windowed read doesn't pull a huge file into
|
|
186
|
+
# memory just to slice a few lines out of it.
|
|
187
|
+
end = (start + limit) if limit is not None else None
|
|
188
|
+
out: list[str] = []
|
|
189
|
+
with target.open(encoding="utf-8") as f:
|
|
190
|
+
for i, text_line in enumerate(f):
|
|
191
|
+
if i >= start:
|
|
192
|
+
out.append(text_line)
|
|
193
|
+
if end is not None and i >= end - 1:
|
|
194
|
+
break
|
|
195
|
+
return "".join(out)
|
|
196
|
+
|
|
197
|
+
# Offloaded to a thread so the synchronous read can't stall the event
|
|
198
|
+
# loop the ACP connection runs on.
|
|
199
|
+
text = await asyncio.to_thread(_read)
|
|
200
|
+
return s.ReadTextFileResponse(content=text)
|
|
201
|
+
|
|
202
|
+
async def write_text_file(
|
|
203
|
+
self, content: str, path: str, session_id: str, **_: Any
|
|
204
|
+
) -> None:
|
|
205
|
+
target = self._safe_path(path)
|
|
206
|
+
|
|
207
|
+
def _write() -> None:
|
|
208
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
target.write_text(content, encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
# Offloaded so the blocking mkdir/write can't stall the event loop.
|
|
212
|
+
await asyncio.to_thread(_write)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ACPBackend:
|
|
217
|
+
name = "acp"
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
agent: str = "claude",
|
|
223
|
+
command: str | None = None,
|
|
224
|
+
args: Sequence[str] | None = None,
|
|
225
|
+
cwd: str | None = None,
|
|
226
|
+
env: Mapping[str, str] | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
if command is None:
|
|
229
|
+
preset = _AGENTS.get(agent)
|
|
230
|
+
if preset is None:
|
|
231
|
+
known = ", ".join(sorted(_AGENTS))
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"unknown ACP agent {agent!r} (known: {known}); "
|
|
234
|
+
"pass command=... to launch any other ACP agent"
|
|
235
|
+
)
|
|
236
|
+
command, default_args = preset
|
|
237
|
+
args = default_args if args is None else args
|
|
238
|
+
self.agent = agent
|
|
239
|
+
self.command = command
|
|
240
|
+
self.args = list(args or [])
|
|
241
|
+
self.cwd = os.path.abspath(cwd or os.getcwd())
|
|
242
|
+
# Overrides layered onto the server's own environment so the adapter
|
|
243
|
+
# still inherits PATH and any provider credentials (ANTHROPIC_API_KEY,
|
|
244
|
+
# GEMINI_API_KEY, ...) it needs to authenticate.
|
|
245
|
+
self.env = {**os.environ, **(env or {})}
|
|
246
|
+
|
|
247
|
+
async def drive(self, session: BackendSession, request: RunRequest) -> None:
|
|
248
|
+
# The ACP Client base declares terminal/* and ext_* with empty bodies as
|
|
249
|
+
# optional overrides; we advertise no terminal capability, so the agent
|
|
250
|
+
# never calls them. mypy reads the empty bodies as abstract, hence the
|
|
251
|
+
# scoped ignore.
|
|
252
|
+
client = _BridgeClient(session, self.cwd) # type: ignore[abstract]
|
|
253
|
+
async with spawn_agent_process(
|
|
254
|
+
client, self.command, *self.args, env=self.env, cwd=self.cwd
|
|
255
|
+
) as (conn, _process):
|
|
256
|
+
init = await conn.initialize(
|
|
257
|
+
protocol_version=PROTOCOL_VERSION,
|
|
258
|
+
client_capabilities=s.ClientCapabilities(
|
|
259
|
+
fs=s.FileSystemCapabilities(
|
|
260
|
+
read_text_file=True, write_text_file=True
|
|
261
|
+
)
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
session_id = await self._open_session(conn, init, request)
|
|
265
|
+
response = await conn.prompt(
|
|
266
|
+
prompt=[text_block(request.prompt)], session_id=session_id
|
|
267
|
+
)
|
|
268
|
+
usage = response.usage.model_dump() if response.usage else None
|
|
269
|
+
await session.emit(
|
|
270
|
+
Result(
|
|
271
|
+
session_id=session_id,
|
|
272
|
+
cost_usd=client.cost_usd,
|
|
273
|
+
num_turns=None,
|
|
274
|
+
usage=usage,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def _open_session(
|
|
279
|
+
self, conn: Any, init: s.InitializeResponse, request: RunRequest
|
|
280
|
+
) -> str:
|
|
281
|
+
can_load = bool(getattr(init.agent_capabilities, "load_session", False))
|
|
282
|
+
if request.resume and can_load:
|
|
283
|
+
await conn.load_session(
|
|
284
|
+
cwd=self.cwd, session_id=request.resume, mcp_servers=[]
|
|
285
|
+
)
|
|
286
|
+
return request.resume
|
|
287
|
+
# No resume, or the agent can't reload a session: start fresh. The
|
|
288
|
+
# executor learns the new session id from the Result and maps the A2A
|
|
289
|
+
# context onto it for the next turn.
|
|
290
|
+
response = await conn.new_session(cwd=self.cwd, mcp_servers=[])
|
|
291
|
+
return response.session_id
|
a2acode/backends/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Backend abstraction.
|
|
2
|
+
|
|
3
|
+
A backend drives Claude Code and yields a normalized stream of events. The
|
|
4
|
+
A2A layer never imports the Claude Agent SDK directly; it only consumes these
|
|
5
|
+
events. That keeps the protocol mapping in one place and lets us swap the
|
|
6
|
+
underlying driver (Agent SDK today, raw CLI later) without touching the server.
|
|
7
|
+
|
|
8
|
+
Backends implement ``drive(session, request)``: they push events onto the
|
|
9
|
+
session and, when a tool needs approval, call ``session.request_permission(...)``
|
|
10
|
+
which parks until the A2A caller responds. This is what lets a permission prompt
|
|
11
|
+
become an A2A ``input-required`` round trip rather than being silently skipped.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .session import BackendSession
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class TextDelta:
|
|
25
|
+
"""A chunk of assistant-authored text."""
|
|
26
|
+
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class ToolUse:
|
|
32
|
+
"""The agent decided to run a tool (Bash, Edit, Read, ...)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
tool_input: dict[str, Any]
|
|
36
|
+
tool_use_id: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class FileChange:
|
|
41
|
+
"""A file was written or edited during the run."""
|
|
42
|
+
|
|
43
|
+
path: str
|
|
44
|
+
diff: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class PermissionRequest:
|
|
49
|
+
"""A tool needs the caller's approval before it can run."""
|
|
50
|
+
|
|
51
|
+
request_id: str
|
|
52
|
+
tool_name: str
|
|
53
|
+
tool_input: dict[str, Any]
|
|
54
|
+
description: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class PermissionDecision:
|
|
59
|
+
"""The caller's answer to a PermissionRequest."""
|
|
60
|
+
|
|
61
|
+
request_id: str
|
|
62
|
+
allow: bool
|
|
63
|
+
message: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class Result:
|
|
68
|
+
"""Terminal event carrying run metadata."""
|
|
69
|
+
|
|
70
|
+
session_id: str | None = None
|
|
71
|
+
cost_usd: float | None = None
|
|
72
|
+
num_turns: int | None = None
|
|
73
|
+
usage: dict[str, Any] | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
BackendEvent = TextDelta | ToolUse | FileChange | PermissionRequest | Result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class RunRequest:
|
|
81
|
+
"""One turn of work handed to a backend."""
|
|
82
|
+
|
|
83
|
+
prompt: str
|
|
84
|
+
context_id: str | None = None
|
|
85
|
+
resume: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@runtime_checkable
|
|
89
|
+
class Backend(Protocol):
|
|
90
|
+
"""Anything that can drive Claude Code and emit normalized events."""
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
async def drive(self, session: BackendSession, request: RunRequest) -> None:
|
|
95
|
+
"""Run one turn, emitting events onto ``session`` until it returns."""
|
|
96
|
+
...
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Claude backend.
|
|
2
|
+
|
|
3
|
+
Drives Claude Code through the Claude Agent SDK's bidirectional client and
|
|
4
|
+
normalizes its typed message stream into backend events. Tool calls, file edits,
|
|
5
|
+
run cost, and the session id: everything the "text in, text out" wrappers
|
|
6
|
+
discard is preserved for the A2A layer to map onto the protocol.
|
|
7
|
+
|
|
8
|
+
Permission prompts are routed through ``can_use_tool`` into the session's
|
|
9
|
+
``request_permission``, so the caller approves or denies a tool over A2A instead
|
|
10
|
+
of the server skipping it.
|
|
11
|
+
|
|
12
|
+
Authentication follows whatever the Claude CLI is configured with. For a server
|
|
13
|
+
that answers on behalf of other agents that means an Anthropic API key (or
|
|
14
|
+
Bedrock/Vertex); subscription credentials are not permitted for third-party
|
|
15
|
+
serving.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
|
|
23
|
+
from claude_agent_sdk import (
|
|
24
|
+
AssistantMessage,
|
|
25
|
+
ClaudeAgentOptions,
|
|
26
|
+
ClaudeSDKClient,
|
|
27
|
+
PermissionMode,
|
|
28
|
+
PermissionResultAllow,
|
|
29
|
+
PermissionResultDeny,
|
|
30
|
+
ResultMessage,
|
|
31
|
+
SettingSource,
|
|
32
|
+
TextBlock,
|
|
33
|
+
ToolUseBlock,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .base import BackendEvent, Result, RunRequest, TextDelta, ToolUse
|
|
37
|
+
from .diff import file_changes
|
|
38
|
+
from .session import BackendSession
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def events_from_message(message: object) -> Iterator[BackendEvent]:
|
|
42
|
+
"""Map one Claude Agent SDK message to normalized backend events.
|
|
43
|
+
|
|
44
|
+
Pure and side-effect free so the translation can be unit tested without a
|
|
45
|
+
live Claude session.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(message, AssistantMessage):
|
|
48
|
+
for block in message.content:
|
|
49
|
+
if isinstance(block, TextBlock):
|
|
50
|
+
if block.text:
|
|
51
|
+
yield TextDelta(text=block.text)
|
|
52
|
+
elif isinstance(block, ToolUseBlock):
|
|
53
|
+
tool_input = dict(block.input or {})
|
|
54
|
+
yield ToolUse(block.name, tool_input, block.id)
|
|
55
|
+
yield from file_changes(block.name, tool_input)
|
|
56
|
+
elif isinstance(message, ResultMessage):
|
|
57
|
+
yield Result(
|
|
58
|
+
session_id=message.session_id,
|
|
59
|
+
cost_usd=message.total_cost_usd,
|
|
60
|
+
num_turns=message.num_turns,
|
|
61
|
+
usage=message.usage,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ClaudeBackend:
|
|
66
|
+
name = "claude"
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
cwd: str | None = None,
|
|
72
|
+
allowed_tools: list[str] | None = None,
|
|
73
|
+
permission_mode: PermissionMode | None = None,
|
|
74
|
+
model: str | None = None,
|
|
75
|
+
max_budget_usd: float | None = None,
|
|
76
|
+
setting_sources: list[SettingSource] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.cwd = os.path.abspath(cwd or os.getcwd())
|
|
79
|
+
self.allowed_tools = allowed_tools
|
|
80
|
+
self.permission_mode = permission_mode
|
|
81
|
+
self.model = model
|
|
82
|
+
self.max_budget_usd = max_budget_usd
|
|
83
|
+
# A server should not inherit a developer's personal tool allowlist:
|
|
84
|
+
# default to loading no settings so every tool routes through the A2A
|
|
85
|
+
# permission round trip. Pass e.g. ["project"] to opt back in.
|
|
86
|
+
self.setting_sources: list[SettingSource] = (
|
|
87
|
+
[] if setting_sources is None else setting_sources
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _options(self, request: RunRequest, can_use_tool) -> ClaudeAgentOptions:
|
|
91
|
+
options = ClaudeAgentOptions(
|
|
92
|
+
cwd=self.cwd,
|
|
93
|
+
can_use_tool=can_use_tool,
|
|
94
|
+
setting_sources=self.setting_sources,
|
|
95
|
+
)
|
|
96
|
+
if request.resume:
|
|
97
|
+
options.resume = request.resume
|
|
98
|
+
if self.allowed_tools:
|
|
99
|
+
options.allowed_tools = self.allowed_tools
|
|
100
|
+
if self.permission_mode:
|
|
101
|
+
options.permission_mode = self.permission_mode
|
|
102
|
+
if self.model:
|
|
103
|
+
options.model = self.model
|
|
104
|
+
if self.max_budget_usd is not None:
|
|
105
|
+
options.max_budget_usd = self.max_budget_usd
|
|
106
|
+
return options
|
|
107
|
+
|
|
108
|
+
async def drive(self, session: BackendSession, request: RunRequest) -> None:
|
|
109
|
+
async def can_use_tool(tool_name, tool_input, context):
|
|
110
|
+
description = getattr(context, "display_name", "") or tool_name
|
|
111
|
+
decision = await session.request_permission(
|
|
112
|
+
tool_name, dict(tool_input or {}), description
|
|
113
|
+
)
|
|
114
|
+
if decision.allow:
|
|
115
|
+
return PermissionResultAllow()
|
|
116
|
+
return PermissionResultDeny(
|
|
117
|
+
message=decision.message or "Denied by A2A caller"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
options = self._options(request, can_use_tool)
|
|
121
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
122
|
+
await client.query(request.prompt)
|
|
123
|
+
async for message in client.receive_response():
|
|
124
|
+
for event in events_from_message(message):
|
|
125
|
+
await session.emit(event)
|