a2claude 0.1.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.
a2claude/__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"]
@@ -0,0 +1,47 @@
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
+ ``claude`` is imported lazily so the echo backend works without the Claude
39
+ Agent SDK's runtime dependencies present.
40
+ """
41
+ if name == "echo":
42
+ return EchoBackend()
43
+ if name == "claude":
44
+ from .claude import ClaudeBackend
45
+
46
+ return ClaudeBackend(**kwargs)
47
+ raise ValueError(f"unknown backend: {name!r} (expected 'echo' or 'claude')")
@@ -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 — are 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)
@@ -0,0 +1,70 @@
1
+ """Synthesize unified diffs from Claude Code's file-editing tool calls.
2
+
3
+ The tool input describes the intended change, so the diff is the proposed edit
4
+ rather than a post-hoc comparison of disk state — which is what a caller wants
5
+ to see streamed while the work is still in progress.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ from typing import Any
12
+
13
+ from .base import FileChange
14
+
15
+ _EDIT_TOOLS = {"Write", "Edit", "MultiEdit"}
16
+
17
+
18
+ def _unified(path: str, before: str, after: str) -> str:
19
+ before_lines = before.splitlines(keepends=True) if before else []
20
+ after_lines = after.splitlines(keepends=True) if after else []
21
+ diff = "".join(
22
+ difflib.unified_diff(
23
+ before_lines, after_lines, fromfile=f"a/{path}", tofile=f"b/{path}"
24
+ )
25
+ )
26
+ if diff and not diff.endswith("\n"):
27
+ diff += "\n"
28
+ return diff
29
+
30
+
31
+ def _text(value: Any) -> str:
32
+ """Coerce a tool-input field to text, treating a missing/null value as empty.
33
+
34
+ ``dict.get(key, "")`` returns ``None`` when the key is present but null, so
35
+ plain ``str(...)`` would yield the literal ``"None"``.
36
+ """
37
+ return "" if value is None else str(value)
38
+
39
+
40
+ def file_changes(tool_name: str, tool_input: dict[str, Any]) -> list[FileChange]:
41
+ """Return the file changes a tool call would make, if any."""
42
+ if tool_name not in _EDIT_TOOLS:
43
+ return []
44
+ path = tool_input.get("file_path") or tool_input.get("path")
45
+ if not path:
46
+ return []
47
+
48
+ if tool_name == "Write":
49
+ diff = _unified(path, "", _text(tool_input.get("content")))
50
+ elif tool_name == "Edit":
51
+ diff = _unified(
52
+ path,
53
+ _text(tool_input.get("old_string")),
54
+ _text(tool_input.get("new_string")),
55
+ )
56
+ else: # MultiEdit — edits may be malformed; tolerate anything non-dict.
57
+ edits = tool_input.get("edits")
58
+ if not isinstance(edits, list):
59
+ return []
60
+ diff = "".join(
61
+ _unified(
62
+ path,
63
+ _text(edit.get("old_string")),
64
+ _text(edit.get("new_string")),
65
+ )
66
+ for edit in edits
67
+ if isinstance(edit, dict)
68
+ )
69
+
70
+ return [FileChange(path=str(path), diff=diff)] if diff else []
@@ -0,0 +1,47 @@
1
+ """Echo backend.
2
+
3
+ Needs no API key and no Claude install. It exists so the server, the protocol
4
+ mapping, and the CLI can be exercised end to end offline. It emits the same
5
+ event shapes a real run produces, and when the prompt contains ``sudo`` it asks
6
+ for permission first — which lets the full ``input-required`` round trip be
7
+ verified without a live Claude session.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .base import Result, RunRequest, TextDelta, ToolUse
13
+ from .session import BackendSession
14
+
15
+
16
+ class EchoBackend:
17
+ name = "echo"
18
+
19
+ async def drive(self, session: BackendSession, request: RunRequest) -> None:
20
+ await session.emit(
21
+ ToolUse(
22
+ name="Echo",
23
+ tool_input={"prompt": request.prompt},
24
+ tool_use_id="echo-1",
25
+ )
26
+ )
27
+
28
+ if "sudo" in request.prompt.lower():
29
+ decision = await session.request_permission(
30
+ "Bash", {"command": request.prompt}, f"$ {request.prompt}"
31
+ )
32
+ if not decision.allow:
33
+ await session.emit(TextDelta("permission denied; nothing run"))
34
+ await session.emit(self._result(request))
35
+ return
36
+
37
+ for word in request.prompt.split():
38
+ await session.emit(TextDelta(word + " "))
39
+ await session.emit(self._result(request))
40
+
41
+ @staticmethod
42
+ def _result(request: RunRequest) -> Result:
43
+ return Result(
44
+ session_id=request.context_id or "echo-session",
45
+ cost_usd=0.0,
46
+ num_turns=1,
47
+ )
@@ -0,0 +1,128 @@
1
+ """Session machinery shared by all backends.
2
+
3
+ A backend's ``drive`` coroutine runs in a background task and pushes events onto
4
+ the session. The consumer (the executor) reads them with ``drain``, which stops
5
+ either when the run finishes or when it surfaces a permission request — at which
6
+ point the background task is parked inside ``request_permission`` waiting for a
7
+ decision. A later ``resolve`` un-parks it. This decoupling is what allows the
8
+ A2A ``input-required`` round trip to span two separate ``execute`` calls while
9
+ the Claude session stays alive in between.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from collections.abc import AsyncIterator, Awaitable, Callable
16
+ from contextlib import suppress
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+ from uuid import uuid4
20
+
21
+ from .base import BackendEvent, PermissionDecision, PermissionRequest
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class _Error:
26
+ exc: BaseException
27
+
28
+
29
+ _DONE = object()
30
+
31
+ # The event loop holds only a weak reference to tasks created with
32
+ # create_task; keep a strong one so a runner can't be garbage-collected
33
+ # mid-flight (e.g. after its session is dropped on a client disconnect).
34
+ _RUNNERS: set[asyncio.Task[None]] = set()
35
+
36
+
37
+ class BackendSession:
38
+ def __init__(self) -> None:
39
+ self._queue: asyncio.Queue[Any] = asyncio.Queue()
40
+ self._pending: dict[str, asyncio.Future[PermissionDecision]] = {}
41
+ self._runner: asyncio.Task[None] | None = None
42
+ self.last_request_id: str | None = None
43
+ self.done = False
44
+
45
+ @property
46
+ def is_parked(self) -> bool:
47
+ """Whether the run is currently waiting for a permission decision."""
48
+ return (
49
+ self.last_request_id is not None and self.last_request_id in self._pending
50
+ )
51
+
52
+ def start(self, driver: Callable[[BackendSession], Awaitable[None]]) -> None:
53
+ async def runner() -> None:
54
+ try:
55
+ await driver(self)
56
+ except asyncio.CancelledError:
57
+ raise
58
+ except BaseException as exc: # noqa: BLE001 — relayed to consumer
59
+ # put_nowait (the queue is unbounded) so a pending cancellation
60
+ # cannot stop the sentinel from reaching a blocked drain().
61
+ self._queue.put_nowait(_Error(exc))
62
+ finally:
63
+ self._queue.put_nowait(_DONE)
64
+
65
+ self._runner = asyncio.create_task(runner())
66
+ _RUNNERS.add(self._runner)
67
+ self._runner.add_done_callback(_RUNNERS.discard)
68
+
69
+ async def emit(self, event: BackendEvent) -> None:
70
+ await self._queue.put(event)
71
+
72
+ async def request_permission(
73
+ self, tool_name: str, tool_input: dict[str, Any], description: str = ""
74
+ ) -> PermissionDecision:
75
+ request_id = uuid4().hex
76
+ future: asyncio.Future[PermissionDecision] = (
77
+ asyncio.get_running_loop().create_future()
78
+ )
79
+ self._pending[request_id] = future
80
+ await self._queue.put(
81
+ PermissionRequest(request_id, tool_name, dict(tool_input), description)
82
+ )
83
+ return await future
84
+
85
+ def resolve(self, decision: PermissionDecision) -> None:
86
+ future = self._pending.pop(decision.request_id, None)
87
+ if future is None:
88
+ # Fail fast: a no-op would leave the driver parked and hang drain().
89
+ raise ValueError(f"no pending permission request {decision.request_id!r}")
90
+ if not future.done():
91
+ future.set_result(decision)
92
+
93
+ async def drain(self) -> AsyncIterator[BackendEvent]:
94
+ """Yield events until the run ends or a permission request is surfaced.
95
+
96
+ A permission request is yielded and then stops the iteration, leaving the
97
+ background task parked until ``resolve`` is called and ``drain`` resumes.
98
+ """
99
+ while True:
100
+ item = await self._queue.get()
101
+ if item is _DONE:
102
+ self.done = True
103
+ return
104
+ if isinstance(item, _Error):
105
+ self.done = True
106
+ raise item.exc
107
+ yield item
108
+ if isinstance(item, PermissionRequest):
109
+ self.last_request_id = item.request_id
110
+ return
111
+
112
+ def abort(self) -> None:
113
+ """Cancel the background runner without awaiting — safe to call from a
114
+ cancelled context (e.g. an interrupted ``execute``)."""
115
+ if self._runner is not None and not self._runner.done():
116
+ self._runner.cancel()
117
+
118
+ async def close(self) -> None:
119
+ try:
120
+ if self._runner is not None and not self._runner.done():
121
+ self._runner.cancel()
122
+ with suppress(asyncio.CancelledError):
123
+ await self._runner
124
+ finally:
125
+ for future in self._pending.values():
126
+ if not future.done():
127
+ future.cancel()
128
+ self._pending.clear()
a2claude/card.py ADDED
@@ -0,0 +1,93 @@
1
+ """Agent card construction.
2
+
3
+ The card advertises Claude Code's coding abilities as discrete A2A skills so
4
+ that calling agents can route to it deliberately rather than treating it as an
5
+ opaque chat box.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill
11
+ from a2a.utils.constants import PROTOCOL_VERSION_CURRENT, TransportProtocol
12
+
13
+ VERSION = "0.1.0"
14
+
15
+ SKILLS = [
16
+ AgentSkill(
17
+ id="code-generation",
18
+ name="Code generation",
19
+ description="Implement features, scaffold modules, and write new code "
20
+ "from a natural-language description.",
21
+ tags=["code", "generation"],
22
+ examples=["Add a /health endpoint that returns build info"],
23
+ ),
24
+ AgentSkill(
25
+ id="refactor",
26
+ name="Refactoring",
27
+ description="Restructure existing code without changing behavior — "
28
+ "extract functions, rename, split modules.",
29
+ tags=["code", "refactor"],
30
+ examples=["Split this 400-line file into cohesive modules"],
31
+ ),
32
+ AgentSkill(
33
+ id="debug",
34
+ name="Debugging",
35
+ description="Reproduce, locate, and fix defects, then verify the fix.",
36
+ tags=["code", "debug"],
37
+ examples=["The auth test fails intermittently — find and fix it"],
38
+ ),
39
+ AgentSkill(
40
+ id="review",
41
+ name="Code review",
42
+ description="Review a diff or file for correctness, edge cases, and "
43
+ "consistency with the surrounding code.",
44
+ tags=["code", "review"],
45
+ examples=["Review the changes on this branch"],
46
+ ),
47
+ AgentSkill(
48
+ id="test",
49
+ name="Testing",
50
+ description="Write or extend tests and run them to confirm they pass.",
51
+ tags=["code", "test"],
52
+ examples=["Add unit tests for the payment module"],
53
+ ),
54
+ AgentSkill(
55
+ id="explain",
56
+ name="Code explanation",
57
+ description="Explain how a codebase, file, or function works.",
58
+ tags=["code", "explain"],
59
+ examples=["Walk me through how request routing works here"],
60
+ ),
61
+ ]
62
+
63
+
64
+ def build_card(
65
+ url: str,
66
+ *,
67
+ name: str = "Claude Code",
68
+ description: str | None = None,
69
+ streaming: bool = True,
70
+ push_notifications: bool = True,
71
+ ) -> AgentCard:
72
+ return AgentCard(
73
+ name=name,
74
+ description=description
75
+ or "Claude Code as an A2A agent — generation, refactoring, "
76
+ "debugging, review, testing, and explanation over a real project "
77
+ "workspace.",
78
+ version=VERSION,
79
+ capabilities=AgentCapabilities(
80
+ streaming=streaming,
81
+ push_notifications=push_notifications,
82
+ ),
83
+ supported_interfaces=[
84
+ AgentInterface(
85
+ url=url,
86
+ protocol_binding=TransportProtocol.JSONRPC,
87
+ protocol_version=PROTOCOL_VERSION_CURRENT,
88
+ )
89
+ ],
90
+ skills=SKILLS,
91
+ default_input_modes=["text/plain"],
92
+ default_output_modes=["text/plain"],
93
+ )