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/backends/diff.py
ADDED
|
@@ -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 []
|
a2acode/backends/echo.py
ADDED
|
@@ -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
|