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 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')")
@@ -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
@@ -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)