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 +9 -0
- a2claude/backends/__init__.py +47 -0
- a2claude/backends/base.py +96 -0
- a2claude/backends/claude.py +125 -0
- a2claude/backends/diff.py +70 -0
- a2claude/backends/echo.py +47 -0
- a2claude/backends/session.py +128 -0
- a2claude/card.py +93 -0
- a2claude/cli.py +173 -0
- a2claude/executor.py +313 -0
- a2claude/py.typed +0 -0
- a2claude/server.py +62 -0
- a2claude-0.1.0.dist-info/METADATA +188 -0
- a2claude-0.1.0.dist-info/RECORD +16 -0
- a2claude-0.1.0.dist-info/WHEEL +4 -0
- a2claude-0.1.0.dist-info/entry_points.txt +3 -0
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
|
+
)
|