coding-agent-cli 0.1.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.
- coding_agent_cli/__init__.py +47 -0
- coding_agent_cli/_args.py +66 -0
- coding_agent_cli/_classify.py +170 -0
- coding_agent_cli/_drivers/__init__.py +1 -0
- coding_agent_cli/_drivers/_common.py +330 -0
- coding_agent_cli/_drivers/_usage.py +77 -0
- coding_agent_cli/_drivers/agy.py +340 -0
- coding_agent_cli/_drivers/claude.py +304 -0
- coding_agent_cli/_drivers/codex.py +333 -0
- coding_agent_cli/_drivers/codex_rollout.py +439 -0
- coding_agent_cli/_drivers/pi.py +333 -0
- coding_agent_cli/_platform.py +171 -0
- coding_agent_cli/_process.py +338 -0
- coding_agent_cli/api.py +190 -0
- coding_agent_cli/events.py +109 -0
- coding_agent_cli/py.typed +0 -0
- coding_agent_cli/registry.py +67 -0
- coding_agent_cli/types.py +90 -0
- coding_agent_cli-0.1.0.dist-info/METADATA +217 -0
- coding_agent_cli-0.1.0.dist-info/RECORD +22 -0
- coding_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Asyncio subprocess transport for agent CLIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ._platform import kill_process_tree, prepare_command, process_group_kwargs, track_process_tree
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("coding_agent_cli.process")
|
|
16
|
+
|
|
17
|
+
TAIL_BYTES = 64 * 1024
|
|
18
|
+
LINE_LIMIT_BYTES = 1024 * 1024
|
|
19
|
+
EXIT_GRACE_S = 2.0
|
|
20
|
+
POLL_INTERVAL_S = 0.1
|
|
21
|
+
CLEANUP_S = 10.0
|
|
22
|
+
|
|
23
|
+
LineHandler = Callable[[str], Awaitable[None]]
|
|
24
|
+
SpawnHandler = Callable[[int], Awaitable[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class ProcessOutcome:
|
|
29
|
+
"""Exit status, newest output tail, and the failure that ended the run, if any."""
|
|
30
|
+
|
|
31
|
+
returncode: int | None = None
|
|
32
|
+
tail: str = ""
|
|
33
|
+
error: str = ""
|
|
34
|
+
error_class: str = ""
|
|
35
|
+
spawned: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OutputTail:
|
|
39
|
+
"""Newest lines within ``TAIL_BYTES``. The newest line is always kept, even when larger."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._lines: deque[tuple[str, int]] = deque()
|
|
43
|
+
self._size = 0
|
|
44
|
+
|
|
45
|
+
def append(self, line: str) -> None:
|
|
46
|
+
size = len(line.encode("utf-8")) + 1
|
|
47
|
+
self._lines.append((line, size))
|
|
48
|
+
self._size += size
|
|
49
|
+
while self._size > TAIL_BYTES and len(self._lines) > 1:
|
|
50
|
+
self._size -= self._lines.popleft()[1]
|
|
51
|
+
|
|
52
|
+
def text(self) -> str:
|
|
53
|
+
return "\n".join(line for line, _ in self._lines)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _write_stdin(stdin: asyncio.StreamWriter, payload: bytes) -> None:
|
|
57
|
+
stdin.write(payload)
|
|
58
|
+
await stdin.drain()
|
|
59
|
+
stdin.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _discard_until_eof(stdout: asyncio.StreamReader) -> None:
|
|
63
|
+
"""Drain the killed group's leftover output so asyncio can close the pipe transport."""
|
|
64
|
+
try:
|
|
65
|
+
while await asyncio.wait_for(stdout.read(64 * 1024), EXIT_GRACE_S):
|
|
66
|
+
pass
|
|
67
|
+
except (TimeoutError, OSError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _stdin_failure(writer: asyncio.Future[None] | None) -> BaseException | None:
|
|
72
|
+
if writer is None or not writer.done() or writer.cancelled():
|
|
73
|
+
return None
|
|
74
|
+
return writer.exception()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stdin_outcome(failure: BaseException) -> tuple[str, str]:
|
|
78
|
+
return f"stdin write failed: {type(failure).__name__}: {failure}", "stdin_failed"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _expired(deadline: float | None, now: float) -> bool:
|
|
82
|
+
return deadline is not None and now >= deadline
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _stop_reason(
|
|
86
|
+
now: float,
|
|
87
|
+
deadline: float | None,
|
|
88
|
+
cancel: asyncio.Event | None,
|
|
89
|
+
writer: asyncio.Future[None] | None,
|
|
90
|
+
outcome: ProcessOutcome,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Return the first stop reason in check order: cancel, deadline, stdin failure. Empty means keep going."""
|
|
93
|
+
if cancel is not None and cancel.is_set():
|
|
94
|
+
return "cancelled"
|
|
95
|
+
if _expired(deadline, now):
|
|
96
|
+
return "timeout"
|
|
97
|
+
failure = _stdin_failure(writer)
|
|
98
|
+
if failure is None:
|
|
99
|
+
return ""
|
|
100
|
+
outcome.error, outcome.error_class = _stdin_outcome(failure)
|
|
101
|
+
return "failed"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _note_exit(process: asyncio.subprocess.Process, exited_at: float | None, now: float) -> float | None:
|
|
105
|
+
"""Return when the leader was first seen exited, or ``exited_at`` unchanged while it still runs."""
|
|
106
|
+
if process.returncode is not None and exited_at is None:
|
|
107
|
+
return now
|
|
108
|
+
return exited_at
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _read_one(
|
|
112
|
+
stdout: asyncio.StreamReader,
|
|
113
|
+
tail: OutputTail,
|
|
114
|
+
on_line: LineHandler,
|
|
115
|
+
outcome: ProcessOutcome,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Read and handle one line. Return "" to keep looping, "eof" at end of output, or "failed"."""
|
|
118
|
+
try:
|
|
119
|
+
raw = await asyncio.wait_for(stdout.readline(), POLL_INTERVAL_S)
|
|
120
|
+
except TimeoutError:
|
|
121
|
+
return ""
|
|
122
|
+
except ValueError:
|
|
123
|
+
outcome.error, outcome.error_class = "output line exceeds 1 MiB", "execution_error"
|
|
124
|
+
return "failed"
|
|
125
|
+
if not raw:
|
|
126
|
+
return "eof"
|
|
127
|
+
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
128
|
+
tail.append(line)
|
|
129
|
+
try:
|
|
130
|
+
await on_line(line)
|
|
131
|
+
except Exception as exc: # noqa: BLE001 - a parser or callback failure fails the attempt
|
|
132
|
+
outcome.error, outcome.error_class = f"{type(exc).__name__}: {exc}", "execution_error"
|
|
133
|
+
return "failed"
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _pump(
|
|
138
|
+
process: asyncio.subprocess.Process,
|
|
139
|
+
writer: asyncio.Future[None] | None,
|
|
140
|
+
deadline: float | None,
|
|
141
|
+
cancel: asyncio.Event | None,
|
|
142
|
+
on_line: LineHandler,
|
|
143
|
+
tail: OutputTail,
|
|
144
|
+
outcome: ProcessOutcome,
|
|
145
|
+
) -> str:
|
|
146
|
+
"""Poll the child until a stop reason, leader exit plus grace, or EOF. Return the stop reason or ""."""
|
|
147
|
+
assert process.stdout is not None
|
|
148
|
+
exited_at: float | None = None
|
|
149
|
+
eof = False
|
|
150
|
+
while True:
|
|
151
|
+
now = time.monotonic()
|
|
152
|
+
stop = _stop_reason(now, deadline, cancel, writer, outcome)
|
|
153
|
+
if stop:
|
|
154
|
+
return stop
|
|
155
|
+
exited_at = _note_exit(process, exited_at, now)
|
|
156
|
+
if exited_at is not None and (eof or now - exited_at >= EXIT_GRACE_S):
|
|
157
|
+
if writer is not None:
|
|
158
|
+
return await _finish_stdin(writer, outcome)
|
|
159
|
+
return ""
|
|
160
|
+
if eof:
|
|
161
|
+
await asyncio.sleep(POLL_INTERVAL_S)
|
|
162
|
+
continue
|
|
163
|
+
step = await _read_one(process.stdout, tail, on_line, outcome)
|
|
164
|
+
if step == "failed":
|
|
165
|
+
return step
|
|
166
|
+
eof = step == "eof"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _watch(deadline: float | None, cancel: asyncio.Event | None) -> str:
|
|
170
|
+
"""Return "cancelled" or "timeout" when one fires, independent of the line handler."""
|
|
171
|
+
event = cancel if cancel is not None else asyncio.Event()
|
|
172
|
+
timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
173
|
+
try:
|
|
174
|
+
async with asyncio.timeout(timeout):
|
|
175
|
+
await event.wait()
|
|
176
|
+
except TimeoutError:
|
|
177
|
+
return "timeout"
|
|
178
|
+
return "cancelled"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _finish_stdin(writer: asyncio.Future[None], outcome: ProcessOutcome) -> str:
|
|
182
|
+
"""Give the stdin writer ``EXIT_GRACE_S`` to finish. Return "failed" when it failed, else ""."""
|
|
183
|
+
await asyncio.wait({writer}, timeout=EXIT_GRACE_S)
|
|
184
|
+
failure = _stdin_failure(writer)
|
|
185
|
+
if failure is None:
|
|
186
|
+
return ""
|
|
187
|
+
outcome.error, outcome.error_class = _stdin_outcome(failure)
|
|
188
|
+
return "failed"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _map_stop(outcome: ProcessOutcome, stop: str, name: str) -> None:
|
|
192
|
+
"""Set the final error from the stop reason, or from a nonzero exit when nothing stopped the run."""
|
|
193
|
+
if stop == "cancelled":
|
|
194
|
+
outcome.error, outcome.error_class = "command cancelled", "cancelled"
|
|
195
|
+
elif stop == "timeout":
|
|
196
|
+
outcome.error, outcome.error_class = f"{name} subprocess timed out", "timeout"
|
|
197
|
+
elif not stop and outcome.returncode:
|
|
198
|
+
outcome.error = f"command exited with status {outcome.returncode}"
|
|
199
|
+
outcome.error_class = "execution_error"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _spawn(
|
|
203
|
+
command: list[str],
|
|
204
|
+
payload: bytes | None,
|
|
205
|
+
env: Mapping[str, str] | None,
|
|
206
|
+
cwd: str | None,
|
|
207
|
+
) -> asyncio.subprocess.Process:
|
|
208
|
+
return await asyncio.create_subprocess_exec(
|
|
209
|
+
*command,
|
|
210
|
+
stdin=asyncio.subprocess.DEVNULL if payload is None else asyncio.subprocess.PIPE,
|
|
211
|
+
stdout=asyncio.subprocess.PIPE,
|
|
212
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
env=None if env is None else {**os.environ, **env},
|
|
215
|
+
limit=LINE_LIMIT_BYTES,
|
|
216
|
+
**process_group_kwargs(),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _supervise(
|
|
221
|
+
process: asyncio.subprocess.Process,
|
|
222
|
+
writer: asyncio.Future[None] | None,
|
|
223
|
+
deadline: float | None,
|
|
224
|
+
cancel: asyncio.Event | None,
|
|
225
|
+
on_line: LineHandler,
|
|
226
|
+
tail: OutputTail,
|
|
227
|
+
outcome: ProcessOutcome,
|
|
228
|
+
) -> str:
|
|
229
|
+
"""Run the pump while an independent watcher enforces cancel and the deadline."""
|
|
230
|
+
pump = asyncio.ensure_future(_pump(process, writer, deadline, cancel, on_line, tail, outcome))
|
|
231
|
+
watcher = asyncio.ensure_future(_watch(deadline, cancel))
|
|
232
|
+
try:
|
|
233
|
+
await asyncio.wait({pump, watcher}, return_when=asyncio.FIRST_COMPLETED)
|
|
234
|
+
if pump.done():
|
|
235
|
+
return pump.result()
|
|
236
|
+
return watcher.result()
|
|
237
|
+
finally:
|
|
238
|
+
for task in (pump, watcher):
|
|
239
|
+
if not task.done():
|
|
240
|
+
task.cancel()
|
|
241
|
+
await asyncio.gather(pump, watcher, return_exceptions=True)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _cleanup(process: asyncio.subprocess.Process, writer: asyncio.Future[None] | None) -> None:
|
|
245
|
+
"""Kill the tree, reap the leader, and drain output, all within ``CLEANUP_S``."""
|
|
246
|
+
assert process.stdout is not None
|
|
247
|
+
if writer is not None and not writer.done():
|
|
248
|
+
writer.cancel()
|
|
249
|
+
try:
|
|
250
|
+
async with asyncio.timeout(CLEANUP_S):
|
|
251
|
+
await kill_process_tree(process.pid)
|
|
252
|
+
if process.returncode is None:
|
|
253
|
+
try:
|
|
254
|
+
process.kill()
|
|
255
|
+
except ProcessLookupError:
|
|
256
|
+
pass
|
|
257
|
+
await process.wait()
|
|
258
|
+
if process.stdin is not None:
|
|
259
|
+
process.stdin.close()
|
|
260
|
+
await _discard_until_eof(process.stdout)
|
|
261
|
+
except TimeoutError:
|
|
262
|
+
log.warning("process cleanup exceeded %.1fs pid=%d", CLEANUP_S, process.pid)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _shielded(cleanup: asyncio.Future[None]) -> None:
|
|
266
|
+
"""Await ``cleanup`` to completion even when the caller is cancelled, then re-raise the cancellation."""
|
|
267
|
+
cancelled = False
|
|
268
|
+
while not cleanup.done():
|
|
269
|
+
try:
|
|
270
|
+
await asyncio.shield(cleanup)
|
|
271
|
+
except asyncio.CancelledError:
|
|
272
|
+
if cleanup.done():
|
|
273
|
+
break
|
|
274
|
+
cancelled = True
|
|
275
|
+
if cancelled:
|
|
276
|
+
raise asyncio.CancelledError
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def run_process(
|
|
280
|
+
argv: list[str],
|
|
281
|
+
stdin: str | None,
|
|
282
|
+
env: Mapping[str, str] | None,
|
|
283
|
+
cwd: str | None,
|
|
284
|
+
deadline: float | None,
|
|
285
|
+
cancel: asyncio.Event | None,
|
|
286
|
+
on_line: LineHandler,
|
|
287
|
+
*,
|
|
288
|
+
on_spawn: SpawnHandler | None = None,
|
|
289
|
+
) -> ProcessOutcome:
|
|
290
|
+
"""Run ``argv`` in a new process group and await ``on_line`` for each output line.
|
|
291
|
+
|
|
292
|
+
stderr is merged into stdout. ``stdin`` is UTF-8 encoded before spawn, written, then
|
|
293
|
+
closed. ``env`` overlays ``os.environ``. A ``cancel`` already set prevents the spawn.
|
|
294
|
+
``deadline`` is a ``time.monotonic`` value. Cancel and the deadline are enforced even
|
|
295
|
+
while ``on_line`` stalls. The process tree is killed on every exit path, and a cancelled
|
|
296
|
+
caller task gets ``CancelledError`` after cleanup. Exceptions from ``on_spawn`` propagate
|
|
297
|
+
after cleanup. A spawn ``OSError`` is returned as ``missing_cli`` or ``spawn_failed``.
|
|
298
|
+
"""
|
|
299
|
+
if cancel is not None and cancel.is_set():
|
|
300
|
+
return ProcessOutcome(error="command cancelled", error_class="cancelled")
|
|
301
|
+
try:
|
|
302
|
+
payload = None if stdin is None else stdin.encode("utf-8")
|
|
303
|
+
except UnicodeEncodeError as exc:
|
|
304
|
+
return ProcessOutcome(error=f"prompt is not valid UTF-8: {exc}", error_class="invalid_prompt")
|
|
305
|
+
|
|
306
|
+
name = os.path.basename(argv[0])
|
|
307
|
+
started_at = time.monotonic()
|
|
308
|
+
try:
|
|
309
|
+
process = await _spawn(prepare_command(list(argv)), payload, env, cwd)
|
|
310
|
+
except FileNotFoundError as exc:
|
|
311
|
+
return ProcessOutcome(error=f"{name} could not be started: {exc.strerror or exc}", error_class="missing_cli")
|
|
312
|
+
except OSError as exc:
|
|
313
|
+
return ProcessOutcome(error=f"{name} could not be started: {exc.strerror or exc}", error_class="spawn_failed")
|
|
314
|
+
track_process_tree(process.pid)
|
|
315
|
+
log.debug("spawned %s pid=%d", name, process.pid)
|
|
316
|
+
writer = None
|
|
317
|
+
if payload is not None:
|
|
318
|
+
assert process.stdin is not None
|
|
319
|
+
writer = asyncio.ensure_future(_write_stdin(process.stdin, payload))
|
|
320
|
+
tail = OutputTail()
|
|
321
|
+
outcome = ProcessOutcome(spawned=True)
|
|
322
|
+
stop = ""
|
|
323
|
+
try:
|
|
324
|
+
if on_spawn is not None:
|
|
325
|
+
await on_spawn(process.pid)
|
|
326
|
+
stop = await _supervise(process, writer, deadline, cancel, on_line, tail, outcome)
|
|
327
|
+
finally:
|
|
328
|
+
await _shielded(asyncio.ensure_future(_cleanup(process, writer)))
|
|
329
|
+
outcome.returncode = process.returncode
|
|
330
|
+
outcome.tail = tail.text()
|
|
331
|
+
|
|
332
|
+
_map_stop(outcome, stop, name)
|
|
333
|
+
if outcome.error_class:
|
|
334
|
+
log.info("%s subprocess ended class=%s after %.2fs", name, outcome.error_class, time.monotonic() - started_at)
|
|
335
|
+
return outcome
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = ["LineHandler", "OutputTail", "ProcessOutcome", "SpawnHandler", "run_process"]
|
coding_agent_cli/api.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Public entry points: ``run``, ``stream``, ``run_sync``, and ``check``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
from .events import Event, RunCompleted
|
|
12
|
+
from .registry import DriverRegistry, SpawnHandler
|
|
13
|
+
from .types import Capabilities, Request, Result
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("coding_agent_cli")
|
|
16
|
+
|
|
17
|
+
STREAM_QUEUE_SIZE = 256
|
|
18
|
+
|
|
19
|
+
_default_registry: DriverRegistry | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _registry(registry: DriverRegistry | None) -> DriverRegistry:
|
|
23
|
+
global _default_registry
|
|
24
|
+
if registry is not None:
|
|
25
|
+
return registry
|
|
26
|
+
if _default_registry is None:
|
|
27
|
+
_default_registry = DriverRegistry()
|
|
28
|
+
return _default_registry
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def run(
|
|
32
|
+
req: Request,
|
|
33
|
+
*,
|
|
34
|
+
on_event: Callable[[Event], Awaitable[None]] | None = None,
|
|
35
|
+
cancel: asyncio.Event | None = None,
|
|
36
|
+
on_spawn: SpawnHandler | None = None,
|
|
37
|
+
registry: DriverRegistry | None = None,
|
|
38
|
+
) -> Result:
|
|
39
|
+
"""Run ``req`` to completion and return its ``Result``.
|
|
40
|
+
|
|
41
|
+
``on_event`` receives each event with a strictly increasing ``sequence``, ending with
|
|
42
|
+
exactly one ``RunCompleted`` after the process tree is gone. An exception from
|
|
43
|
+
``on_event`` or ``on_spawn`` is raised after cleanup. Setting ``cancel`` ends the run
|
|
44
|
+
with a ``cancelled`` result; from another thread use ``loop.call_soon_threadsafe(cancel.set)``.
|
|
45
|
+
Raises ``KeyError`` for an unknown driver.
|
|
46
|
+
"""
|
|
47
|
+
driver = _registry(registry).get(req.driver)
|
|
48
|
+
sequence = 0
|
|
49
|
+
failures: list[BaseException] = []
|
|
50
|
+
|
|
51
|
+
async def emit(event: Event) -> None:
|
|
52
|
+
nonlocal sequence
|
|
53
|
+
if isinstance(event, RunCompleted):
|
|
54
|
+
return
|
|
55
|
+
sequence += 1
|
|
56
|
+
if on_event is None:
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
await on_event(dataclasses.replace(event, sequence=sequence))
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
failures.append(exc)
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
log.debug("run start driver=%s", req.driver)
|
|
65
|
+
result = await driver.run(req, on_event=emit, cancel=cancel or asyncio.Event(), on_spawn=on_spawn)
|
|
66
|
+
if failures:
|
|
67
|
+
raise failures[0]
|
|
68
|
+
log.debug("run done driver=%s outcome=%s", req.driver, result.outcome)
|
|
69
|
+
if on_event is not None:
|
|
70
|
+
await on_event(RunCompleted(result=result, sequence=sequence + 1))
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class EventStream:
|
|
75
|
+
"""Async iterator over one run's events, ending after ``RunCompleted``.
|
|
76
|
+
|
|
77
|
+
Use it as ``async with stream(req) as events: async for event in events: ...``.
|
|
78
|
+
Leaving the block or calling ``aclose()`` cancels the run and kills the process tree.
|
|
79
|
+
A bare ``async for`` without ``async with`` is safe only when it runs to the end.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
req: Request,
|
|
85
|
+
*,
|
|
86
|
+
cancel: asyncio.Event | None,
|
|
87
|
+
on_spawn: SpawnHandler | None,
|
|
88
|
+
registry: DriverRegistry | None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._req = req
|
|
91
|
+
self._cancel = cancel
|
|
92
|
+
self._on_spawn = on_spawn
|
|
93
|
+
self._registry = registry
|
|
94
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue(STREAM_QUEUE_SIZE)
|
|
95
|
+
self._task: asyncio.Task[Result] | None = None
|
|
96
|
+
self._finished = False
|
|
97
|
+
|
|
98
|
+
def _start(self) -> asyncio.Task[Result]:
|
|
99
|
+
if self._task is None:
|
|
100
|
+
self._task = asyncio.ensure_future(
|
|
101
|
+
run(
|
|
102
|
+
self._req,
|
|
103
|
+
on_event=self._queue.put,
|
|
104
|
+
cancel=self._cancel,
|
|
105
|
+
on_spawn=self._on_spawn,
|
|
106
|
+
registry=self._registry,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return self._task
|
|
110
|
+
|
|
111
|
+
def __aiter__(self) -> EventStream:
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
async def __anext__(self) -> Event:
|
|
115
|
+
if self._finished:
|
|
116
|
+
raise StopAsyncIteration
|
|
117
|
+
task = self._start()
|
|
118
|
+
while True:
|
|
119
|
+
if not self._queue.empty():
|
|
120
|
+
return self._queue.get_nowait()
|
|
121
|
+
if task.done():
|
|
122
|
+
self._finished = True
|
|
123
|
+
if not task.cancelled() and task.exception() is not None:
|
|
124
|
+
raise task.exception() # type: ignore[misc]
|
|
125
|
+
raise StopAsyncIteration
|
|
126
|
+
getter = asyncio.ensure_future(self._queue.get())
|
|
127
|
+
try:
|
|
128
|
+
await asyncio.wait({getter, task}, return_when=asyncio.FIRST_COMPLETED)
|
|
129
|
+
finally:
|
|
130
|
+
if not getter.done():
|
|
131
|
+
getter.cancel()
|
|
132
|
+
if getter.done() and not getter.cancelled():
|
|
133
|
+
return getter.result()
|
|
134
|
+
|
|
135
|
+
async def aclose(self) -> None:
|
|
136
|
+
"""Cancel the run if it is still going and wait for its cleanup."""
|
|
137
|
+
self._finished = True
|
|
138
|
+
task = self._task
|
|
139
|
+
if task is None:
|
|
140
|
+
return
|
|
141
|
+
if not task.done():
|
|
142
|
+
task.cancel()
|
|
143
|
+
try:
|
|
144
|
+
await task
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
current = asyncio.current_task()
|
|
147
|
+
if current is not None and current.cancelling():
|
|
148
|
+
raise
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
async def __aenter__(self) -> EventStream:
|
|
153
|
+
self._start()
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
async def __aexit__(
|
|
157
|
+
self,
|
|
158
|
+
exc_type: type[BaseException] | None,
|
|
159
|
+
exc: BaseException | None,
|
|
160
|
+
tb: TracebackType | None,
|
|
161
|
+
) -> None:
|
|
162
|
+
await self.aclose()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stream(
|
|
166
|
+
req: Request,
|
|
167
|
+
*,
|
|
168
|
+
cancel: asyncio.Event | None = None,
|
|
169
|
+
on_spawn: SpawnHandler | None = None,
|
|
170
|
+
registry: DriverRegistry | None = None,
|
|
171
|
+
) -> EventStream:
|
|
172
|
+
"""Return an ``EventStream`` for ``req``. The run starts on ``async with`` or the first ``__anext__``."""
|
|
173
|
+
return EventStream(req, cancel=cancel, on_spawn=on_spawn, registry=registry)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_sync(req: Request, *, registry: DriverRegistry | None = None) -> Result:
|
|
177
|
+
"""Run ``req`` with ``asyncio.run``. Raises ``RuntimeError`` inside a running event loop."""
|
|
178
|
+
try:
|
|
179
|
+
asyncio.get_running_loop()
|
|
180
|
+
except RuntimeError:
|
|
181
|
+
return asyncio.run(run(req, registry=registry))
|
|
182
|
+
raise RuntimeError("run_sync() cannot be called from a running event loop; await run() instead")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def check(req: Request, *, registry: DriverRegistry | None = None, refresh: bool = False) -> Capabilities:
|
|
186
|
+
"""Report whether the driver's CLI is available and supports structured streaming."""
|
|
187
|
+
return await _registry(registry).get(req.driver).check(req, refresh=refresh)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
__all__ = ["EventStream", "check", "run", "run_sync", "stream"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Typed run events. Consumers must ignore kinds they do not know."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, ClassVar, Literal
|
|
7
|
+
|
|
8
|
+
from .types import Result, Usage
|
|
9
|
+
|
|
10
|
+
SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class SessionIdentified:
|
|
15
|
+
kind: ClassVar[str] = "session.identified"
|
|
16
|
+
session_id: str
|
|
17
|
+
sequence: int = 0
|
|
18
|
+
entity_id: str | None = None
|
|
19
|
+
schema_version: int = SCHEMA_VERSION
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
23
|
+
class MessageStarted:
|
|
24
|
+
kind: ClassVar[str] = "assistant.message.started"
|
|
25
|
+
sequence: int = 0
|
|
26
|
+
entity_id: str | None = None
|
|
27
|
+
schema_version: int = SCHEMA_VERSION
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
31
|
+
class TextDelta:
|
|
32
|
+
kind: ClassVar[str] = "assistant.text.delta"
|
|
33
|
+
text: str
|
|
34
|
+
sequence: int = 0
|
|
35
|
+
entity_id: str | None = None
|
|
36
|
+
schema_version: int = SCHEMA_VERSION
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
40
|
+
class MessageCompleted:
|
|
41
|
+
kind: ClassVar[str] = "assistant.message.completed"
|
|
42
|
+
sequence: int = 0
|
|
43
|
+
entity_id: str | None = None
|
|
44
|
+
schema_version: int = SCHEMA_VERSION
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
48
|
+
class ToolStarted:
|
|
49
|
+
kind: ClassVar[str] = "tool.started"
|
|
50
|
+
tool: str
|
|
51
|
+
activity: Literal["command", "tool_call"]
|
|
52
|
+
input: Any = None
|
|
53
|
+
sequence: int = 0
|
|
54
|
+
entity_id: str | None = None
|
|
55
|
+
schema_version: int = SCHEMA_VERSION
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
59
|
+
class ToolCompleted:
|
|
60
|
+
kind: ClassVar[str] = "tool.completed"
|
|
61
|
+
status: Literal["completed", "error", "unknown"]
|
|
62
|
+
sequence: int = 0
|
|
63
|
+
entity_id: str | None = None
|
|
64
|
+
schema_version: int = SCHEMA_VERSION
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
68
|
+
class UsageUpdated:
|
|
69
|
+
"""A full replacement snapshot of usage so far, not an increment."""
|
|
70
|
+
|
|
71
|
+
kind: ClassVar[str] = "usage.updated"
|
|
72
|
+
usage: Usage
|
|
73
|
+
sequence: int = 0
|
|
74
|
+
entity_id: str | None = None
|
|
75
|
+
schema_version: int = SCHEMA_VERSION
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
79
|
+
class RunCompleted:
|
|
80
|
+
kind: ClassVar[str] = "run.completed"
|
|
81
|
+
result: Result
|
|
82
|
+
sequence: int = 0
|
|
83
|
+
entity_id: str | None = None
|
|
84
|
+
schema_version: int = SCHEMA_VERSION
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
type Event = (
|
|
88
|
+
SessionIdentified
|
|
89
|
+
| MessageStarted
|
|
90
|
+
| TextDelta
|
|
91
|
+
| MessageCompleted
|
|
92
|
+
| ToolStarted
|
|
93
|
+
| ToolCompleted
|
|
94
|
+
| UsageUpdated
|
|
95
|
+
| RunCompleted
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"SCHEMA_VERSION",
|
|
100
|
+
"Event",
|
|
101
|
+
"MessageCompleted",
|
|
102
|
+
"MessageStarted",
|
|
103
|
+
"RunCompleted",
|
|
104
|
+
"SessionIdentified",
|
|
105
|
+
"TextDelta",
|
|
106
|
+
"ToolCompleted",
|
|
107
|
+
"ToolStarted",
|
|
108
|
+
"UsageUpdated",
|
|
109
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Driver protocol and the instance-scoped driver registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from .events import Event
|
|
10
|
+
from .types import Capabilities, Request, Result
|
|
11
|
+
|
|
12
|
+
EventHandler = Callable[[Event], Awaitable[None]]
|
|
13
|
+
SpawnHandler = Callable[[int], Awaitable[None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class Driver(Protocol):
|
|
18
|
+
"""Runs one request through one CLI.
|
|
19
|
+
|
|
20
|
+
``run`` awaits ``on_event`` for each event it produces, leaving ``sequence`` to the API,
|
|
21
|
+
and never emits ``RunCompleted``. It kills the process tree before returning.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def check(self, req: Request, *, refresh: bool = False) -> Capabilities: ...
|
|
25
|
+
|
|
26
|
+
async def run(
|
|
27
|
+
self,
|
|
28
|
+
req: Request,
|
|
29
|
+
*,
|
|
30
|
+
on_event: EventHandler,
|
|
31
|
+
cancel: asyncio.Event,
|
|
32
|
+
on_spawn: SpawnHandler | None,
|
|
33
|
+
) -> Result: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _builtin_drivers() -> dict[str, Driver]:
|
|
37
|
+
# Imported here: the drivers import this module for the handler aliases.
|
|
38
|
+
from ._drivers.agy import AgyDriver
|
|
39
|
+
from ._drivers.claude import ClaudeDriver
|
|
40
|
+
from ._drivers.codex import CodexDriver
|
|
41
|
+
from ._drivers.pi import PiDriver
|
|
42
|
+
|
|
43
|
+
return {"claude": ClaudeDriver(), "pi": PiDriver(), "codex": CodexDriver(), "agy": AgyDriver()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DriverRegistry:
|
|
47
|
+
"""Maps driver names to drivers. Each instance is independent."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, *, builtins: bool = True) -> None:
|
|
50
|
+
self._drivers: dict[str, Driver] = _builtin_drivers() if builtins else {}
|
|
51
|
+
|
|
52
|
+
def register(self, name: str, driver: Driver, *, replace: bool = False) -> None:
|
|
53
|
+
if name in self._drivers and not replace:
|
|
54
|
+
raise ValueError(f"driver {name!r} is already registered")
|
|
55
|
+
self._drivers[name] = driver
|
|
56
|
+
|
|
57
|
+
def get(self, name: str) -> Driver:
|
|
58
|
+
try:
|
|
59
|
+
return self._drivers[name]
|
|
60
|
+
except KeyError:
|
|
61
|
+
raise KeyError(f"unknown driver {name!r}") from None
|
|
62
|
+
|
|
63
|
+
def names(self) -> tuple[str, ...]:
|
|
64
|
+
return tuple(sorted(self._drivers))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["Driver", "DriverRegistry", "EventHandler", "SpawnHandler"]
|