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/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Three commands, enough to run the server and exercise it by hand:
|
|
4
|
+
|
|
5
|
+
a2acode serve start the A2A server
|
|
6
|
+
a2acode call TEXT send a message and print the streamed events
|
|
7
|
+
a2acode card fetch and print the agent card
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import shlex
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import typer
|
|
19
|
+
import uvicorn
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
add_completion=False,
|
|
23
|
+
help="Serve a coding agent (Claude Code and other ACP agents) over A2A.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Human-facing card names for the ACP agents shipped with a launch preset.
|
|
27
|
+
_AGENT_CARD_NAMES = {
|
|
28
|
+
"claude": "Claude Code",
|
|
29
|
+
"gemini": "Gemini CLI",
|
|
30
|
+
"codex": "Codex",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_permission_mode(value: str | None) -> None:
|
|
35
|
+
"""Reject an invalid --permission-mode at startup.
|
|
36
|
+
|
|
37
|
+
Without this the bad value flows into ClaudeAgentOptions and only surfaces as
|
|
38
|
+
a generic "Claude Code run failed" on the first request, after the server is
|
|
39
|
+
already up. The valid set is read from the SDK's own literal so it cannot
|
|
40
|
+
drift; the import stays lazy so the echo backend needs no SDK at hand.
|
|
41
|
+
|
|
42
|
+
Skip validation rather than block startup when the accepted set cannot be
|
|
43
|
+
determined: the SDK absent (echo without it installed), or PermissionMode no
|
|
44
|
+
longer a Literal so get_args returns an empty tuple. In both cases the SDK
|
|
45
|
+
itself still rejects a genuinely bad value at run time.
|
|
46
|
+
"""
|
|
47
|
+
if value is None:
|
|
48
|
+
return
|
|
49
|
+
from typing import get_args
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from claude_agent_sdk import PermissionMode
|
|
53
|
+
except ImportError:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
# Keep only string members, so a non-Literal form (e.g. a Union with
|
|
57
|
+
# non-string args) neither breaks the join below nor is matched against.
|
|
58
|
+
valid = [v for v in get_args(PermissionMode) if isinstance(v, str)]
|
|
59
|
+
if valid and value not in valid:
|
|
60
|
+
raise typer.BadParameter(
|
|
61
|
+
f"invalid --permission-mode {value!r}; expected one of {', '.join(valid)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _local_url(host: str, port: int) -> str:
|
|
66
|
+
shown = "localhost" if host in ("0.0.0.0", "::") else host
|
|
67
|
+
return f"http://{shown}:{port}/"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command()
|
|
71
|
+
def serve(
|
|
72
|
+
backend: str = typer.Option(
|
|
73
|
+
"acp", help="Backend: 'acp' (any ACP agent), 'claude' (Claude SDK), 'echo'."
|
|
74
|
+
),
|
|
75
|
+
agent: str = typer.Option(
|
|
76
|
+
"claude",
|
|
77
|
+
help="ACP agent the 'acp' backend fronts: 'claude', 'gemini', 'codex', "
|
|
78
|
+
"or any agent reachable via --agent-command.",
|
|
79
|
+
),
|
|
80
|
+
agent_command: str = typer.Option(
|
|
81
|
+
None,
|
|
82
|
+
help="Explicit launch command for an ACP agent, overriding --agent's "
|
|
83
|
+
"preset (e.g. 'npx -y @zed-industries/claude-agent-acp').",
|
|
84
|
+
),
|
|
85
|
+
cwd: str = typer.Option(".", help="Project directory the coding agent works in."),
|
|
86
|
+
host: str = typer.Option("127.0.0.1"),
|
|
87
|
+
port: int = typer.Option(9100),
|
|
88
|
+
permission_mode: str = typer.Option(
|
|
89
|
+
None,
|
|
90
|
+
help="Claude permission mode (e.g. acceptEdits). Omit to use defaults.",
|
|
91
|
+
),
|
|
92
|
+
max_budget_usd: float = typer.Option(
|
|
93
|
+
None, help="Hard cost ceiling per run, in USD."
|
|
94
|
+
),
|
|
95
|
+
sign_key: str = typer.Option(
|
|
96
|
+
None,
|
|
97
|
+
help="Path to a file holding the signing key (a PEM private key, or a "
|
|
98
|
+
"shared secret for HS256) used to sign the agent card so callers can "
|
|
99
|
+
"verify who issued it.",
|
|
100
|
+
),
|
|
101
|
+
sign_kid: str = typer.Option(
|
|
102
|
+
None, help="Key id (kid) recorded in the card signature."
|
|
103
|
+
),
|
|
104
|
+
sign_alg: str = typer.Option(
|
|
105
|
+
"ES256", help="JWS algorithm for the card signature (e.g. ES256, RS256)."
|
|
106
|
+
),
|
|
107
|
+
auth_token_file: str = typer.Option(
|
|
108
|
+
None,
|
|
109
|
+
help="Path to a file holding a bearer token. When set, callers must "
|
|
110
|
+
"send 'Authorization: Bearer <token>' and the card advertises it.",
|
|
111
|
+
),
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Start the A2A server."""
|
|
114
|
+
from pathlib import Path
|
|
115
|
+
|
|
116
|
+
from .backends import make_backend
|
|
117
|
+
from .server import build_app
|
|
118
|
+
|
|
119
|
+
kwargs: dict[str, object] = {}
|
|
120
|
+
card_name = None
|
|
121
|
+
if backend == "acp":
|
|
122
|
+
kwargs = {"agent": agent, "cwd": cwd}
|
|
123
|
+
if agent_command:
|
|
124
|
+
try:
|
|
125
|
+
parts = shlex.split(agent_command)
|
|
126
|
+
except ValueError as e:
|
|
127
|
+
raise typer.BadParameter(f"invalid --agent-command: {e}") from e
|
|
128
|
+
if not parts:
|
|
129
|
+
raise typer.BadParameter("--agent-command is empty or whitespace-only")
|
|
130
|
+
kwargs["command"], kwargs["args"] = parts[0], parts[1:]
|
|
131
|
+
# The identity of an arbitrary command is unknown, so don't advertise
|
|
132
|
+
# it under a preset name (which would default to "Claude Code").
|
|
133
|
+
card_name = "Coding Agent"
|
|
134
|
+
else:
|
|
135
|
+
card_name = _AGENT_CARD_NAMES.get(agent, agent.capitalize())
|
|
136
|
+
elif backend == "claude":
|
|
137
|
+
# Claude-only flag: validate it only on the Claude path so an ACP run
|
|
138
|
+
# isn't rejected for a flag it never uses.
|
|
139
|
+
_validate_permission_mode(permission_mode)
|
|
140
|
+
kwargs = {
|
|
141
|
+
"cwd": cwd,
|
|
142
|
+
"permission_mode": permission_mode,
|
|
143
|
+
"max_budget_usd": max_budget_usd,
|
|
144
|
+
}
|
|
145
|
+
drv = make_backend(backend, **kwargs)
|
|
146
|
+
|
|
147
|
+
card_signer = None
|
|
148
|
+
if sign_kid and not sign_key:
|
|
149
|
+
raise typer.BadParameter("--sign-key is required when --sign-kid is set")
|
|
150
|
+
if sign_key:
|
|
151
|
+
if not sign_kid:
|
|
152
|
+
raise typer.BadParameter("--sign-kid is required when --sign-key is set")
|
|
153
|
+
from .card import signer_from_key_file
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
card_signer = signer_from_key_file(sign_key, kid=sign_kid, alg=sign_alg)
|
|
157
|
+
except (OSError, ValueError) as e:
|
|
158
|
+
raise typer.BadParameter(f"invalid --sign-key: {e}") from e
|
|
159
|
+
|
|
160
|
+
auth_token = None
|
|
161
|
+
if auth_token_file:
|
|
162
|
+
try:
|
|
163
|
+
auth_token = Path(auth_token_file).read_text(encoding="utf-8").strip()
|
|
164
|
+
except (OSError, ValueError) as e:
|
|
165
|
+
raise typer.BadParameter(f"invalid --auth-token-file: {e}") from e
|
|
166
|
+
if not auth_token:
|
|
167
|
+
raise typer.BadParameter("--auth-token-file is empty")
|
|
168
|
+
|
|
169
|
+
asgi_app = build_app(
|
|
170
|
+
drv,
|
|
171
|
+
url=_local_url(host, port),
|
|
172
|
+
card_name=card_name,
|
|
173
|
+
card_signer=card_signer,
|
|
174
|
+
auth_token=auth_token,
|
|
175
|
+
)
|
|
176
|
+
label = f"{backend}:{agent}" if backend == "acp" else backend
|
|
177
|
+
typer.echo(f"a2acode: backend={label} card={_local_url(host, port)}")
|
|
178
|
+
uvicorn.run(asgi_app, host=host, port=port, log_level="info")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def call(
|
|
183
|
+
text: str = typer.Argument(..., help="Message to send to the agent."),
|
|
184
|
+
url: str = typer.Option("http://localhost:9100/", help="Server URL."),
|
|
185
|
+
context: str = typer.Option(None, help="contextId to continue a conversation."),
|
|
186
|
+
task: str = typer.Option(
|
|
187
|
+
None, help="taskId to answer an input-required prompt (e.g. 'allow')."
|
|
188
|
+
),
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Send a message and print the streamed task events."""
|
|
191
|
+
asyncio.run(_call(text, url, context, task))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def card(url: str = typer.Option("http://localhost:9100/")) -> None:
|
|
196
|
+
"""Fetch and print the agent card."""
|
|
197
|
+
base = url.rstrip("/")
|
|
198
|
+
resp = httpx.get(f"{base}/.well-known/agent-card.json", timeout=10)
|
|
199
|
+
resp.raise_for_status()
|
|
200
|
+
typer.echo(json.dumps(resp.json(), indent=2))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parts_text(parts) -> str:
|
|
204
|
+
return "".join(p.text for p in parts if p.text)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _state_name(state) -> str:
|
|
208
|
+
from a2a.types import TaskState
|
|
209
|
+
|
|
210
|
+
return TaskState.Name(state).removeprefix("TASK_STATE_").lower()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def _call(text: str, url: str, context: str | None, task: str | None) -> None:
|
|
214
|
+
from a2a.client import create_client
|
|
215
|
+
from a2a.client.client import ClientConfig
|
|
216
|
+
from a2a.types import Message, Part, Role, SendMessageRequest
|
|
217
|
+
|
|
218
|
+
message = Message(
|
|
219
|
+
message_id=uuid4().hex,
|
|
220
|
+
role=Role.ROLE_USER,
|
|
221
|
+
parts=[Part(text=text)],
|
|
222
|
+
)
|
|
223
|
+
if context:
|
|
224
|
+
message.context_id = context
|
|
225
|
+
if task:
|
|
226
|
+
message.task_id = task
|
|
227
|
+
|
|
228
|
+
ids = {"task": task or "", "context": context or ""}
|
|
229
|
+
streaming = False
|
|
230
|
+
timeout = httpx.Timeout(600.0, connect=10.0)
|
|
231
|
+
async with httpx.AsyncClient(timeout=timeout) as http:
|
|
232
|
+
client = await create_client(
|
|
233
|
+
url, ClientConfig(streaming=True, httpx_client=http)
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
request = SendMessageRequest(message=message)
|
|
237
|
+
async for event in client.send_message(request):
|
|
238
|
+
which = event.WhichOneof("payload")
|
|
239
|
+
if which == "task":
|
|
240
|
+
t = event.task
|
|
241
|
+
ids["task"], ids["context"] = t.id, t.context_id
|
|
242
|
+
typer.echo(f"task {t.id}")
|
|
243
|
+
typer.echo(f"context {t.context_id}\n")
|
|
244
|
+
elif which == "status_update":
|
|
245
|
+
s = event.status_update.status
|
|
246
|
+
line = _parts_text(s.message.parts) if s.message else ""
|
|
247
|
+
state = _state_name(s.state)
|
|
248
|
+
if streaming and state != "working":
|
|
249
|
+
typer.echo("")
|
|
250
|
+
streaming = False
|
|
251
|
+
if state == "working" and line:
|
|
252
|
+
typer.echo(f" · {line}")
|
|
253
|
+
elif state == "input_required":
|
|
254
|
+
_render_input_required(line, ids, url)
|
|
255
|
+
elif state != "working":
|
|
256
|
+
meta = _format_meta(s.message) if s.message else ""
|
|
257
|
+
typer.echo(f"[{state}] {meta}".rstrip())
|
|
258
|
+
elif which == "artifact_update":
|
|
259
|
+
streaming = True
|
|
260
|
+
parts = event.artifact_update.artifact.parts
|
|
261
|
+
typer.echo(_parts_text(parts), nl=False)
|
|
262
|
+
elif which == "message":
|
|
263
|
+
typer.echo(_parts_text(event.message.parts))
|
|
264
|
+
finally:
|
|
265
|
+
closer = client.close()
|
|
266
|
+
if closer is not None:
|
|
267
|
+
await closer
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _render_input_required(line: str, ids: dict[str, str], url: str) -> None:
|
|
271
|
+
typer.echo(f"[input-required] {line}")
|
|
272
|
+
follow = (
|
|
273
|
+
f'a2acode call "allow" --task {ids["task"]} '
|
|
274
|
+
f"--context {ids['context']} --url {url}"
|
|
275
|
+
)
|
|
276
|
+
typer.echo(f" reply: {follow}")
|
|
277
|
+
typer.echo(' (or "deny" to refuse)')
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _format_meta(msg) -> str:
|
|
281
|
+
from google.protobuf.json_format import MessageToDict
|
|
282
|
+
|
|
283
|
+
meta = MessageToDict(msg).get("metadata") if msg else None
|
|
284
|
+
if not meta:
|
|
285
|
+
return ""
|
|
286
|
+
bits = []
|
|
287
|
+
if "cost_usd" in meta:
|
|
288
|
+
bits.append(f"${meta['cost_usd']}")
|
|
289
|
+
if "num_turns" in meta:
|
|
290
|
+
bits.append(f"{meta['num_turns']} turns")
|
|
291
|
+
return " · ".join(bits)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
app()
|
a2acode/executor.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Protocol mapping.
|
|
2
|
+
|
|
3
|
+
Translates a backend's normalized event stream into A2A task lifecycle events:
|
|
4
|
+
|
|
5
|
+
text -> a streamed artifact (append / last_chunk)
|
|
6
|
+
tool use -> a working-state status update describing the action
|
|
7
|
+
file change -> a named artifact carrying the diff
|
|
8
|
+
permission request -> an input-required pause the caller answers
|
|
9
|
+
result -> run metadata on the completion message + continuity
|
|
10
|
+
|
|
11
|
+
A task that pauses on a permission request keeps its backend session alive in a
|
|
12
|
+
registry; the caller's follow-up message (same task id) carries the decision and
|
|
13
|
+
resumes the same session. Session ids are mapped to the A2A ``context_id`` so a
|
|
14
|
+
new task in the same context resumes the same Claude conversation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
25
|
+
from a2a.server.events import EventQueue
|
|
26
|
+
from a2a.server.tasks import TaskUpdater
|
|
27
|
+
from a2a.types import Part, Task, TaskState, TaskStatus
|
|
28
|
+
|
|
29
|
+
from .backends.base import (
|
|
30
|
+
Backend,
|
|
31
|
+
FileChange,
|
|
32
|
+
PermissionDecision,
|
|
33
|
+
PermissionRequest,
|
|
34
|
+
Result,
|
|
35
|
+
RunRequest,
|
|
36
|
+
TextDelta,
|
|
37
|
+
ToolUse,
|
|
38
|
+
)
|
|
39
|
+
from .backends.session import BackendSession
|
|
40
|
+
from .tracing import span
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
_ALLOW_WORDS = {"allow", "yes", "y", "approve", "ok", "accept", "grant"}
|
|
45
|
+
|
|
46
|
+
# Bound the in-memory maps so a long-running server cannot grow without limit
|
|
47
|
+
# (e.g. from many contexts, or tasks left paused on a permission and never
|
|
48
|
+
# answered). The continuity cache (_MAX_CONTEXTS) evicts its least-recently-used
|
|
49
|
+
# entry; the live-session map (_MAX_LIVE) evicts a parked session first, else the
|
|
50
|
+
# oldest entry.
|
|
51
|
+
_MAX_CONTEXTS = 4096
|
|
52
|
+
_MAX_LIVE = 256
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class _Stream:
|
|
57
|
+
"""Response-stream state for a task, persisted across permission pauses."""
|
|
58
|
+
|
|
59
|
+
artifact_id: str
|
|
60
|
+
chunks: list[str] = field(default_factory=list)
|
|
61
|
+
pending: str | None = None
|
|
62
|
+
sent_first: bool = False
|
|
63
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_prompt(context: RequestContext) -> str:
|
|
67
|
+
"""Assemble the prompt from every part of the incoming message.
|
|
68
|
+
|
|
69
|
+
Text parts are concatenated; file, URL, and structured-data parts are
|
|
70
|
+
surfaced to Claude as labelled references so an attachment is not silently
|
|
71
|
+
dropped on the floor.
|
|
72
|
+
"""
|
|
73
|
+
message = getattr(context, "message", None)
|
|
74
|
+
parts = getattr(message, "parts", None) if message is not None else None
|
|
75
|
+
if not parts:
|
|
76
|
+
return context.get_user_input() or ""
|
|
77
|
+
|
|
78
|
+
texts: list[str] = []
|
|
79
|
+
refs: list[str] = []
|
|
80
|
+
for part in parts:
|
|
81
|
+
which = part.WhichOneof("content")
|
|
82
|
+
if which == "text":
|
|
83
|
+
if part.text:
|
|
84
|
+
texts.append(part.text)
|
|
85
|
+
elif which == "url":
|
|
86
|
+
label = part.filename or part.url
|
|
87
|
+
refs.append(f"[attached {label} ({part.media_type or 'file'}): {part.url}]")
|
|
88
|
+
elif which == "raw":
|
|
89
|
+
name = part.filename or "unnamed"
|
|
90
|
+
kind = part.media_type or "application/octet-stream"
|
|
91
|
+
refs.append(f"[attached file {name} ({kind}, {len(part.raw)} bytes)]")
|
|
92
|
+
elif which == "data":
|
|
93
|
+
refs.append(f"[attached data ({part.media_type or 'application/json'})]")
|
|
94
|
+
|
|
95
|
+
prompt = "\n".join(texts)
|
|
96
|
+
if refs:
|
|
97
|
+
prompt = f"{prompt}\n\n" + "\n".join(refs)
|
|
98
|
+
return prompt.strip()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _describe_tool(event: ToolUse) -> str:
|
|
102
|
+
"""A short, human-readable line for a tool invocation."""
|
|
103
|
+
i = event.tool_input
|
|
104
|
+
if event.name == "Bash":
|
|
105
|
+
return f"$ {str(i.get('command', '')).strip()[:120]}"
|
|
106
|
+
path = i.get("file_path") or i.get("path") or i.get("pattern")
|
|
107
|
+
if path:
|
|
108
|
+
return f"{event.name} {path}"
|
|
109
|
+
return event.name
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ClaudeCodeExecutor(AgentExecutor):
|
|
113
|
+
def __init__(self, backend: Backend) -> None:
|
|
114
|
+
self._backend = backend
|
|
115
|
+
# context_id -> claude session id, for resuming a new task in a context.
|
|
116
|
+
self._session_ids: dict[str, str] = {}
|
|
117
|
+
# task_id -> live session, for resuming a task paused on a permission.
|
|
118
|
+
self._live: dict[str, BackendSession] = {}
|
|
119
|
+
# task_id -> response-stream state, kept across permission pauses.
|
|
120
|
+
self._streams: dict[str, _Stream] = {}
|
|
121
|
+
# Serializes capacity eviction with new-session registration so a burst
|
|
122
|
+
# of concurrent first-turn requests cannot each see a slot freed by one
|
|
123
|
+
# eviction and collectively overshoot _MAX_LIVE. Uncontended below
|
|
124
|
+
# capacity, where _evict_if_full returns without awaiting.
|
|
125
|
+
self._admit_lock = asyncio.Lock()
|
|
126
|
+
|
|
127
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
128
|
+
# span() drops None-valued attributes, so no fallbacks are needed; the
|
|
129
|
+
# ids are populated by the SDK before execute is called.
|
|
130
|
+
with span(
|
|
131
|
+
"a2acode.execute",
|
|
132
|
+
**{
|
|
133
|
+
"a2a.task_id": context.task_id,
|
|
134
|
+
"a2a.context_id": context.context_id,
|
|
135
|
+
"a2acode.backend": self._backend.name,
|
|
136
|
+
},
|
|
137
|
+
):
|
|
138
|
+
await self._execute(context, event_queue)
|
|
139
|
+
|
|
140
|
+
async def _execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
141
|
+
task_id, context_id = context.task_id, context.context_id
|
|
142
|
+
assert task_id is not None and context_id is not None
|
|
143
|
+
updater = TaskUpdater(event_queue, task_id, context_id)
|
|
144
|
+
session = self._live.get(task_id)
|
|
145
|
+
|
|
146
|
+
if session is None:
|
|
147
|
+
# The stream MUST open with a Task object before any status update.
|
|
148
|
+
await event_queue.enqueue_event(
|
|
149
|
+
Task(
|
|
150
|
+
id=task_id,
|
|
151
|
+
context_id=context_id,
|
|
152
|
+
status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
await updater.start_work()
|
|
156
|
+
request = RunRequest(
|
|
157
|
+
prompt=_build_prompt(context),
|
|
158
|
+
context_id=context_id,
|
|
159
|
+
resume=self._session_ids.get(context_id),
|
|
160
|
+
)
|
|
161
|
+
async with self._admit_lock:
|
|
162
|
+
await self._evict_if_full()
|
|
163
|
+
session = BackendSession()
|
|
164
|
+
session.start(lambda s: self._backend.drive(s, request))
|
|
165
|
+
self._live[task_id] = session
|
|
166
|
+
else:
|
|
167
|
+
# Follow-up to an input-required pause: the message is the decision.
|
|
168
|
+
# Guard against a concurrent message arriving while the task is still
|
|
169
|
+
# running; resolving and pumping a non-parked session would put two
|
|
170
|
+
# consumers on the same queue and lose events.
|
|
171
|
+
if not session.is_parked:
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"task {task_id} is already running and not awaiting input"
|
|
174
|
+
)
|
|
175
|
+
await updater.start_work()
|
|
176
|
+
session.resolve(self._decision(context, session))
|
|
177
|
+
|
|
178
|
+
await self._pump(updater, task_id, context_id, session)
|
|
179
|
+
|
|
180
|
+
async def _pump(
|
|
181
|
+
self,
|
|
182
|
+
updater: TaskUpdater,
|
|
183
|
+
task_id: str,
|
|
184
|
+
context_id: str,
|
|
185
|
+
session: BackendSession,
|
|
186
|
+
) -> None:
|
|
187
|
+
# One stream of artifacts/text per task, kept across permission pauses so
|
|
188
|
+
# the response stays a single artifact and the completion text is whole.
|
|
189
|
+
stream = self._streams.setdefault(task_id, _Stream(artifact_id=uuid4().hex))
|
|
190
|
+
|
|
191
|
+
async def flush(text: str, *, last: bool) -> None:
|
|
192
|
+
await updater.add_artifact(
|
|
193
|
+
[Part(text=text)],
|
|
194
|
+
artifact_id=stream.artifact_id,
|
|
195
|
+
name="response",
|
|
196
|
+
append=stream.sent_first,
|
|
197
|
+
last_chunk=last,
|
|
198
|
+
)
|
|
199
|
+
stream.sent_first = True
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
async for event in session.drain():
|
|
203
|
+
if isinstance(event, TextDelta):
|
|
204
|
+
if stream.pending is not None:
|
|
205
|
+
stream.chunks.append(stream.pending)
|
|
206
|
+
await flush(stream.pending, last=False)
|
|
207
|
+
stream.pending = event.text
|
|
208
|
+
elif isinstance(event, ToolUse):
|
|
209
|
+
await updater.update_status(
|
|
210
|
+
TaskState.TASK_STATE_WORKING,
|
|
211
|
+
message=updater.new_agent_message(
|
|
212
|
+
[Part(text=_describe_tool(event))]
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
elif isinstance(event, FileChange):
|
|
216
|
+
await updater.add_artifact(
|
|
217
|
+
[Part(text=event.diff, media_type="text/x-diff")],
|
|
218
|
+
name=event.path,
|
|
219
|
+
)
|
|
220
|
+
elif isinstance(event, PermissionRequest):
|
|
221
|
+
if stream.pending is not None:
|
|
222
|
+
stream.chunks.append(stream.pending)
|
|
223
|
+
await flush(stream.pending, last=False)
|
|
224
|
+
stream.pending = None
|
|
225
|
+
await self._request_input(updater, event)
|
|
226
|
+
elif isinstance(event, Result):
|
|
227
|
+
stream.metadata = self._result_metadata(event)
|
|
228
|
+
if event.session_id:
|
|
229
|
+
self._remember_session(context_id, event.session_id)
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
# Client disconnected / timed out: drop the session and its runner
|
|
232
|
+
# instead of leaking them. Synchronous cleanup since we are cancelled.
|
|
233
|
+
self._live.pop(task_id, None)
|
|
234
|
+
self._streams.pop(task_id, None)
|
|
235
|
+
session.abort()
|
|
236
|
+
raise
|
|
237
|
+
except Exception: # noqa: BLE001 (surface failure without leaking details)
|
|
238
|
+
logger.exception("backend run failed for task %s", task_id)
|
|
239
|
+
await self._discard(task_id, session)
|
|
240
|
+
await updater.failed(
|
|
241
|
+
message=updater.new_agent_message(
|
|
242
|
+
[Part(text="Claude Code run failed; see server logs.")]
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if not session.done:
|
|
248
|
+
# Paused on a permission request; keep the stream for the follow-up.
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
if session.evicted:
|
|
252
|
+
# The session was dropped to free a capacity slot while still
|
|
253
|
+
# running, so its drain ended on the cancellation sentinel rather
|
|
254
|
+
# than a real result. Fail the task instead of presenting the partial
|
|
255
|
+
# buffer as a completed run.
|
|
256
|
+
await updater.failed(
|
|
257
|
+
message=updater.new_agent_message(
|
|
258
|
+
[Part(text="Task evicted to free server capacity.")]
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if stream.pending is not None:
|
|
264
|
+
stream.chunks.append(stream.pending)
|
|
265
|
+
await flush(stream.pending, last=True)
|
|
266
|
+
elif stream.sent_first:
|
|
267
|
+
# No new text this turn, but earlier chunks went out, so close the
|
|
268
|
+
# artifact so it is not left without a final chunk.
|
|
269
|
+
await flush("", last=True)
|
|
270
|
+
|
|
271
|
+
await self._discard(task_id, session)
|
|
272
|
+
full_text = "".join(stream.chunks) or "(no text output)"
|
|
273
|
+
await updater.complete(
|
|
274
|
+
message=updater.new_agent_message(
|
|
275
|
+
[Part(text=full_text)], metadata=stream.metadata or None
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
280
|
+
task_id, context_id = context.task_id, context.context_id
|
|
281
|
+
assert task_id is not None and context_id is not None
|
|
282
|
+
self._streams.pop(task_id, None)
|
|
283
|
+
session = self._live.pop(task_id, None)
|
|
284
|
+
if session is not None:
|
|
285
|
+
await session.close()
|
|
286
|
+
updater = TaskUpdater(event_queue, task_id, context_id)
|
|
287
|
+
await updater.cancel()
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
async def _request_input(updater: TaskUpdater, event: PermissionRequest) -> None:
|
|
291
|
+
line = event.description or event.tool_name
|
|
292
|
+
await updater.requires_input(
|
|
293
|
+
message=updater.new_agent_message(
|
|
294
|
+
[Part(text=f"Permission requested for {event.tool_name}: {line}")],
|
|
295
|
+
metadata={
|
|
296
|
+
"a2acode_permission": {
|
|
297
|
+
"request_id": event.request_id,
|
|
298
|
+
"tool": event.tool_name,
|
|
299
|
+
"input": event.tool_input,
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _decision(
|
|
307
|
+
context: RequestContext, session: BackendSession
|
|
308
|
+
) -> PermissionDecision:
|
|
309
|
+
text = (context.get_user_input() or "").strip().lower()
|
|
310
|
+
allow = text in _ALLOW_WORDS or text.startswith("allow")
|
|
311
|
+
return PermissionDecision(
|
|
312
|
+
request_id=session.last_request_id or "",
|
|
313
|
+
allow=allow,
|
|
314
|
+
message="" if allow else "Denied by A2A caller",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _remember_session(self, context_id: str, session_id: str) -> None:
|
|
318
|
+
# Re-insert so the most recently used context moves to the end of the
|
|
319
|
+
# dict: a plain reassignment keeps an existing key in its original
|
|
320
|
+
# position, which would let an actively reused context be evicted before
|
|
321
|
+
# an idle, more-recently-created one. Pop-then-set makes eviction LRU.
|
|
322
|
+
self._session_ids.pop(context_id, None)
|
|
323
|
+
self._session_ids[context_id] = session_id
|
|
324
|
+
while len(self._session_ids) > _MAX_CONTEXTS:
|
|
325
|
+
oldest = next(iter(self._session_ids))
|
|
326
|
+
del self._session_ids[oldest]
|
|
327
|
+
|
|
328
|
+
async def _evict_if_full(self) -> None:
|
|
329
|
+
"""Make room when at capacity.
|
|
330
|
+
|
|
331
|
+
Prefer evicting a parked session (an input-required task the caller
|
|
332
|
+
abandoned without answering) over one still actively running: a parked
|
|
333
|
+
task's ``_pump`` has already returned, so dropping it just closes a
|
|
334
|
+
stalled session. Only when nothing is parked do we fall back to the
|
|
335
|
+
oldest entry, which is a running task; mark it evicted so its ``_pump``
|
|
336
|
+
fails the task rather than completing it with partial output.
|
|
337
|
+
"""
|
|
338
|
+
while len(self._live) >= _MAX_LIVE:
|
|
339
|
+
# Fall back lazily to the oldest entry: passing next(iter(...)) as the
|
|
340
|
+
# default would evaluate it even when a parked session is found.
|
|
341
|
+
victim = next((tid for tid, s in self._live.items() if s.is_parked), None)
|
|
342
|
+
if victim is None:
|
|
343
|
+
victim = next(iter(self._live))
|
|
344
|
+
logger.warning("evicting live task %s at capacity", victim)
|
|
345
|
+
session = self._live[victim]
|
|
346
|
+
session.evicted = True
|
|
347
|
+
await self._discard(victim, session)
|
|
348
|
+
|
|
349
|
+
async def _discard(self, task_id: str, session: BackendSession) -> None:
|
|
350
|
+
self._live.pop(task_id, None)
|
|
351
|
+
self._streams.pop(task_id, None)
|
|
352
|
+
await session.close()
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _result_metadata(event: Result) -> dict[str, object]:
|
|
356
|
+
meta: dict[str, object] = {}
|
|
357
|
+
if event.session_id is not None:
|
|
358
|
+
meta["claude_session_id"] = event.session_id
|
|
359
|
+
if event.cost_usd is not None:
|
|
360
|
+
meta["cost_usd"] = event.cost_usd
|
|
361
|
+
if event.num_turns is not None:
|
|
362
|
+
meta["num_turns"] = event.num_turns
|
|
363
|
+
if event.usage is not None:
|
|
364
|
+
meta["usage"] = event.usage
|
|
365
|
+
return meta
|
a2acode/py.typed
ADDED
|
File without changes
|