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.
@@ -0,0 +1,79 @@
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 unified_diff(path: str, before: str, after: str) -> str:
32
+ """Build a unified diff from full before/after file text.
33
+
34
+ Used by the ACP backend, whose diff tool-call content carries the complete
35
+ old and new file contents rather than an edit description.
36
+ """
37
+ return _unified(path, before, after)
38
+
39
+
40
+ def _text(value: Any) -> str:
41
+ """Coerce a tool-input field to text, treating a missing/null value as empty.
42
+
43
+ ``dict.get(key, "")`` returns ``None`` when the key is present but null, so
44
+ plain ``str(...)`` would yield the literal ``"None"``.
45
+ """
46
+ return "" if value is None else str(value)
47
+
48
+
49
+ def file_changes(tool_name: str, tool_input: dict[str, Any]) -> list[FileChange]:
50
+ """Return the file changes a tool call would make, if any."""
51
+ if tool_name not in _EDIT_TOOLS:
52
+ return []
53
+ path = tool_input.get("file_path") or tool_input.get("path")
54
+ if not path:
55
+ return []
56
+
57
+ if tool_name == "Write":
58
+ diff = _unified(path, "", _text(tool_input.get("content")))
59
+ elif tool_name == "Edit":
60
+ diff = _unified(
61
+ path,
62
+ _text(tool_input.get("old_string")),
63
+ _text(tool_input.get("new_string")),
64
+ )
65
+ else: # MultiEdit: edits may be malformed; tolerate anything non-dict.
66
+ edits = tool_input.get("edits")
67
+ if not isinstance(edits, list):
68
+ return []
69
+ diff = "".join(
70
+ _unified(
71
+ path,
72
+ _text(edit.get("old_string")),
73
+ _text(edit.get("new_string")),
74
+ )
75
+ for edit in edits
76
+ if isinstance(edit, dict)
77
+ )
78
+
79
+ 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,132 @@
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
+ # Set when the executor drops this session to free a capacity slot, so
45
+ # the session's own consumer can fail its task instead of completing it
46
+ # with whatever partial output it had buffered.
47
+ self.evicted = False
48
+
49
+ @property
50
+ def is_parked(self) -> bool:
51
+ """Whether the run is currently waiting for a permission decision."""
52
+ return (
53
+ self.last_request_id is not None and self.last_request_id in self._pending
54
+ )
55
+
56
+ def start(self, driver: Callable[[BackendSession], Awaitable[None]]) -> None:
57
+ async def runner() -> None:
58
+ try:
59
+ await driver(self)
60
+ except asyncio.CancelledError:
61
+ raise
62
+ except BaseException as exc: # noqa: BLE001 (relayed to consumer)
63
+ # put_nowait (the queue is unbounded) so a pending cancellation
64
+ # cannot stop the sentinel from reaching a blocked drain().
65
+ self._queue.put_nowait(_Error(exc))
66
+ finally:
67
+ self._queue.put_nowait(_DONE)
68
+
69
+ self._runner = asyncio.create_task(runner())
70
+ _RUNNERS.add(self._runner)
71
+ self._runner.add_done_callback(_RUNNERS.discard)
72
+
73
+ async def emit(self, event: BackendEvent) -> None:
74
+ await self._queue.put(event)
75
+
76
+ async def request_permission(
77
+ self, tool_name: str, tool_input: dict[str, Any], description: str = ""
78
+ ) -> PermissionDecision:
79
+ request_id = uuid4().hex
80
+ future: asyncio.Future[PermissionDecision] = (
81
+ asyncio.get_running_loop().create_future()
82
+ )
83
+ self._pending[request_id] = future
84
+ await self._queue.put(
85
+ PermissionRequest(request_id, tool_name, dict(tool_input), description)
86
+ )
87
+ return await future
88
+
89
+ def resolve(self, decision: PermissionDecision) -> None:
90
+ future = self._pending.pop(decision.request_id, None)
91
+ if future is None:
92
+ # Fail fast: a no-op would leave the driver parked and hang drain().
93
+ raise ValueError(f"no pending permission request {decision.request_id!r}")
94
+ if not future.done():
95
+ future.set_result(decision)
96
+
97
+ async def drain(self) -> AsyncIterator[BackendEvent]:
98
+ """Yield events until the run ends or a permission request is surfaced.
99
+
100
+ A permission request is yielded and then stops the iteration, leaving the
101
+ background task parked until ``resolve`` is called and ``drain`` resumes.
102
+ """
103
+ while True:
104
+ item = await self._queue.get()
105
+ if item is _DONE:
106
+ self.done = True
107
+ return
108
+ if isinstance(item, _Error):
109
+ self.done = True
110
+ raise item.exc
111
+ yield item
112
+ if isinstance(item, PermissionRequest):
113
+ self.last_request_id = item.request_id
114
+ return
115
+
116
+ def abort(self) -> None:
117
+ """Cancel the background runner without awaiting. Safe to call from a
118
+ cancelled context (e.g. an interrupted ``execute``)."""
119
+ if self._runner is not None and not self._runner.done():
120
+ self._runner.cancel()
121
+
122
+ async def close(self) -> None:
123
+ try:
124
+ if self._runner is not None and not self._runner.done():
125
+ self._runner.cancel()
126
+ with suppress(asyncio.CancelledError):
127
+ await self._runner
128
+ finally:
129
+ for future in self._pending.values():
130
+ if not future.done():
131
+ future.cancel()
132
+ self._pending.clear()
a2acode/card.py ADDED
@@ -0,0 +1,187 @@
1
+ """Agent card construction.
2
+
3
+ The card advertises the coding agent's abilities as discrete A2A skills so that
4
+ calling agents can route to it deliberately rather than treating it as an opaque
5
+ chat box. The name identifies which agent is fronted; the skills are the same
6
+ generation/refactor/debug/review/test/explain set regardless of backend.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from importlib.metadata import PackageNotFoundError
13
+ from importlib.metadata import version as _package_version
14
+ from pathlib import Path
15
+
16
+ from a2a.types import (
17
+ AgentCapabilities,
18
+ AgentCard,
19
+ AgentInterface,
20
+ AgentSkill,
21
+ SecurityRequirement,
22
+ )
23
+ from a2a.utils.constants import PROTOCOL_VERSION_CURRENT, TransportProtocol
24
+
25
+ # The id used for the bearer scheme in the card's security_schemes map and in
26
+ # each security requirement that references it.
27
+ BEARER_SCHEME = "bearer"
28
+
29
+ try:
30
+ # The agent card version tracks the package version, read from installed
31
+ # metadata so there is one source of truth (pyproject) and it cannot drift.
32
+ VERSION = _package_version("a2acode")
33
+ except PackageNotFoundError: # running from a source tree without an install
34
+ VERSION = "0.0.0"
35
+
36
+ SKILLS = [
37
+ AgentSkill(
38
+ id="code-generation",
39
+ name="Code generation",
40
+ description="Implement features, scaffold modules, and write new code "
41
+ "from a natural-language description.",
42
+ tags=["code", "generation"],
43
+ examples=["Add a /health endpoint that returns build info"],
44
+ ),
45
+ AgentSkill(
46
+ id="refactor",
47
+ name="Refactoring",
48
+ description="Restructure existing code without changing behavior: "
49
+ "extract functions, rename, split modules.",
50
+ tags=["code", "refactor"],
51
+ examples=["Split this 400-line file into cohesive modules"],
52
+ ),
53
+ AgentSkill(
54
+ id="debug",
55
+ name="Debugging",
56
+ description="Reproduce, locate, and fix defects, then verify the fix.",
57
+ tags=["code", "debug"],
58
+ examples=["The auth test fails intermittently; find and fix it"],
59
+ ),
60
+ AgentSkill(
61
+ id="review",
62
+ name="Code review",
63
+ description="Review a diff or file for correctness, edge cases, and "
64
+ "consistency with the surrounding code.",
65
+ tags=["code", "review"],
66
+ examples=["Review the changes on this branch"],
67
+ ),
68
+ AgentSkill(
69
+ id="test",
70
+ name="Testing",
71
+ description="Write or extend tests and run them to confirm they pass.",
72
+ tags=["code", "test"],
73
+ examples=["Add unit tests for the payment module"],
74
+ ),
75
+ AgentSkill(
76
+ id="explain",
77
+ name="Code explanation",
78
+ description="Explain how a codebase, file, or function works.",
79
+ tags=["code", "explain"],
80
+ examples=["Walk me through how request routing works here"],
81
+ ),
82
+ ]
83
+
84
+
85
+ def build_card(
86
+ url: str,
87
+ *,
88
+ name: str = "Claude Code",
89
+ description: str | None = None,
90
+ streaming: bool = True,
91
+ push_notifications: bool = True,
92
+ require_auth: bool = False,
93
+ ) -> AgentCard:
94
+ card = AgentCard(
95
+ name=name,
96
+ description=description
97
+ or "A coding agent on an A2A mesh: generation, refactoring, "
98
+ "debugging, review, testing, and explanation over a real project "
99
+ "workspace.",
100
+ version=VERSION,
101
+ capabilities=AgentCapabilities(
102
+ streaming=streaming,
103
+ push_notifications=push_notifications,
104
+ ),
105
+ # The server mounts both JSON-RPC and REST (HTTP+JSON) routes, so the
106
+ # card advertises both bindings; a caller picks whichever its client
107
+ # speaks instead of assuming JSON-RPC.
108
+ supported_interfaces=[
109
+ AgentInterface(
110
+ url=url,
111
+ protocol_binding=TransportProtocol.JSONRPC,
112
+ protocol_version=PROTOCOL_VERSION_CURRENT,
113
+ ),
114
+ AgentInterface(
115
+ url=url,
116
+ protocol_binding=TransportProtocol.HTTP_JSON,
117
+ protocol_version=PROTOCOL_VERSION_CURRENT,
118
+ ),
119
+ ],
120
+ skills=SKILLS,
121
+ default_input_modes=["text/plain"],
122
+ default_output_modes=["text/plain"],
123
+ )
124
+ if require_auth:
125
+ # Declare an HTTP bearer scheme and require it, so a caller learns from
126
+ # the card that it must present a token before sending a task. A2A keeps
127
+ # the credential at the HTTP layer; the card only advertises the
128
+ # requirement.
129
+ card.security_schemes[BEARER_SCHEME].http_auth_security_scheme.scheme = "bearer"
130
+ requirement = SecurityRequirement()
131
+ # Reference the bearer scheme with no extra scopes (an empty scope list).
132
+ requirement.schemes[BEARER_SCHEME].SetInParent()
133
+ card.security_requirements.append(requirement)
134
+ return card
135
+
136
+
137
+ def sign_card(
138
+ card: AgentCard,
139
+ *,
140
+ key: str | bytes,
141
+ kid: str,
142
+ alg: str = "ES256",
143
+ jku: str | None = None,
144
+ ) -> AgentCard:
145
+ """Attach a JWS signature to the agent card.
146
+
147
+ A2A signed Agent Cards let a receiving agent verify the card was issued by
148
+ the domain that owns the key (``kid``), rather than trusting whatever a
149
+ discovery endpoint happened to return. The signature covers the canonical
150
+ card minus the ``signatures`` field, so it can be appended in place.
151
+
152
+ ``key`` is the private signing material: a PEM string for asymmetric
153
+ algorithms (ES256, RS256) or a shared secret for HMAC (HS256). ``jku``, if
154
+ given, is the JWK Set URL a verifier can fetch the public key from.
155
+ """
156
+ from a2a.utils.signing import ProtectedHeader, create_agent_card_signer
157
+
158
+ header: ProtectedHeader = {"kid": kid, "alg": alg, "jku": jku, "typ": "JOSE"}
159
+ signer = create_agent_card_signer(signing_key=key, protected_header=header)
160
+ return signer(card)
161
+
162
+
163
+ def signer_from_key_file(
164
+ path: str, *, kid: str, alg: str = "ES256", jku: str | None = None
165
+ ) -> Callable[[AgentCard], AgentCard]:
166
+ """Build a card-signing function that reads its key from ``path``.
167
+
168
+ The file holds a PEM private key (asymmetric algorithms) or a raw shared
169
+ secret (HMAC). The key is read once, as bytes so a binary secret does not
170
+ trip a decode error, and the key/algorithm pair is validated up front by
171
+ signing a throwaway card, so a misconfigured key fails at startup rather
172
+ than on the first discovery request.
173
+ """
174
+ key = Path(path).read_bytes().strip()
175
+ if not key:
176
+ raise ValueError(f"signing key file is empty: {path}")
177
+
178
+ def signer(card: AgentCard) -> AgentCard:
179
+ return sign_card(card, key=key, kid=kid, alg=alg, jku=jku)
180
+
181
+ try:
182
+ signer(build_card("https://validation.invalid/"))
183
+ except Exception as e: # noqa: BLE001 - any failure here is a key/alg misconfig
184
+ raise ValueError(
185
+ f"cannot sign agent card with the given key and {alg}: {e}"
186
+ ) from e
187
+ return signer