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/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