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,340 @@
|
|
|
1
|
+
"""Agy CLI driver: stream-json on stdout, a temporary log file for the session ID, and the prompt in argv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._classify import Facts, keyword_code
|
|
15
|
+
from .._platform import prepare_command
|
|
16
|
+
from .._process import ProcessOutcome, run_process
|
|
17
|
+
from ..events import SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
|
|
18
|
+
from ..registry import EventHandler, SpawnHandler
|
|
19
|
+
from ..types import Diagnostic, Request, Result
|
|
20
|
+
from ._common import (
|
|
21
|
+
CliDriver,
|
|
22
|
+
Diagnostics,
|
|
23
|
+
EventBuffer,
|
|
24
|
+
as_int,
|
|
25
|
+
build_result,
|
|
26
|
+
deadline_for,
|
|
27
|
+
parse_json_object,
|
|
28
|
+
process_facts,
|
|
29
|
+
read_and_unlink,
|
|
30
|
+
request_failure,
|
|
31
|
+
tool_started,
|
|
32
|
+
)
|
|
33
|
+
from ._usage import TokenCounters
|
|
34
|
+
|
|
35
|
+
MAX_DIAGNOSTICS = 50
|
|
36
|
+
PROMPT_LIMIT_BYTES = 100 * 1024
|
|
37
|
+
WINDOWS_COMMAND_LINE_LIMIT_CHARS = 32767
|
|
38
|
+
_WINDOWS = os.name == "nt"
|
|
39
|
+
_UUID_PATTERN = r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
|
|
40
|
+
_CONVERSATION_ID_RE = re.compile(rf"(?:Created|Streaming) conversation {_UUID_PATTERN}")
|
|
41
|
+
_SUCCESS_STATUS = "SUCCESS"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _event_model(event: dict[str, Any]) -> str | None:
|
|
45
|
+
"""Return the model named by one event: its top-level ``model``, else its ``result.model``."""
|
|
46
|
+
model = event.get("model")
|
|
47
|
+
if isinstance(model, str) and model:
|
|
48
|
+
return model
|
|
49
|
+
result = event.get("result")
|
|
50
|
+
if isinstance(result, dict):
|
|
51
|
+
model = result.get("model")
|
|
52
|
+
if isinstance(model, str) and model:
|
|
53
|
+
return model
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prompt_error(prompt: str) -> Diagnostic | None:
|
|
58
|
+
"""Reject a prompt that cannot be passed in argv: NUL bytes, invalid UTF-8, or more than the size limit."""
|
|
59
|
+
if "\x00" in prompt:
|
|
60
|
+
return Diagnostic(code="invalid_prompt", message="prompt cannot contain NUL bytes")
|
|
61
|
+
try:
|
|
62
|
+
size = len(prompt.encode("utf-8"))
|
|
63
|
+
except UnicodeEncodeError as exc:
|
|
64
|
+
return Diagnostic(code="invalid_prompt", message=f"prompt is not valid UTF-8: {exc}")
|
|
65
|
+
if size > PROMPT_LIMIT_BYTES:
|
|
66
|
+
return Diagnostic(
|
|
67
|
+
code="invalid_prompt",
|
|
68
|
+
message=(
|
|
69
|
+
f"agy prompt is {size} bytes, "
|
|
70
|
+
f"above the 100 KiB ({PROMPT_LIMIT_BYTES} bytes) limit for the prompt argument"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _command_line_error(argv: list[str]) -> Diagnostic | None:
|
|
77
|
+
"""On Windows, reject an argv whose command line exceeds the CreateProcess limit."""
|
|
78
|
+
if not _WINDOWS:
|
|
79
|
+
return None
|
|
80
|
+
chars = len(subprocess.list2cmdline(prepare_command(list(argv))))
|
|
81
|
+
if chars <= WINDOWS_COMMAND_LINE_LIMIT_CHARS:
|
|
82
|
+
return None
|
|
83
|
+
return Diagnostic(
|
|
84
|
+
code="invalid_prompt",
|
|
85
|
+
message=(
|
|
86
|
+
f"agy command line is {chars} characters, "
|
|
87
|
+
f"above the Windows limit of {WINDOWS_COMMAND_LINE_LIMIT_CHARS} characters"
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AgyRun:
|
|
93
|
+
"""Parse one Agy run line by line with bounded state. Raw lines are never retained after the first JSON event.
|
|
94
|
+
|
|
95
|
+
Plain-text lines are the reply only for a run that emits no JSON events, so they are kept until the first event.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, sink: EventHandler, *, requested_session: str | None) -> None:
|
|
99
|
+
self.events = EventBuffer(sink)
|
|
100
|
+
self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
|
|
101
|
+
self.counters = TokenCounters()
|
|
102
|
+
self.requested_session = requested_session or ""
|
|
103
|
+
self.stream_session_id = ""
|
|
104
|
+
self.conversation_id = ""
|
|
105
|
+
self.tool_counter = 0
|
|
106
|
+
self.active_tool_id = ""
|
|
107
|
+
self.step_counter = 0
|
|
108
|
+
self.deltas: list[str] = []
|
|
109
|
+
self.final_response = ""
|
|
110
|
+
self.status = ""
|
|
111
|
+
self.model: str | None = None
|
|
112
|
+
self.saw_event = False
|
|
113
|
+
self.plain_lines: list[str] = []
|
|
114
|
+
|
|
115
|
+
async def feed(self, line: str) -> None:
|
|
116
|
+
stripped = line.strip()
|
|
117
|
+
match = _CONVERSATION_ID_RE.search(stripped)
|
|
118
|
+
if match and not self.stream_session_id:
|
|
119
|
+
self.stream_session_id = match.group(1)
|
|
120
|
+
self._identify(self.stream_session_id)
|
|
121
|
+
event = parse_json_object(stripped)
|
|
122
|
+
if event is None:
|
|
123
|
+
if stripped:
|
|
124
|
+
code = keyword_code(stripped)
|
|
125
|
+
self.diagnostics.add(stripped, marks=(code,) if code else ())
|
|
126
|
+
if match is None and not self.saw_event:
|
|
127
|
+
self.plain_lines.append(stripped)
|
|
128
|
+
else:
|
|
129
|
+
self.saw_event = True
|
|
130
|
+
self.plain_lines.clear()
|
|
131
|
+
model = _event_model(event)
|
|
132
|
+
if model:
|
|
133
|
+
self.model = model
|
|
134
|
+
self._dispatch(event)
|
|
135
|
+
await self.events.flush()
|
|
136
|
+
|
|
137
|
+
def _identify(self, value: Any) -> None:
|
|
138
|
+
if isinstance(value, str) and value and value != self.conversation_id:
|
|
139
|
+
self.conversation_id = value
|
|
140
|
+
self.events.emit(SessionIdentified(session_id=value))
|
|
141
|
+
|
|
142
|
+
def _usage_updated(self) -> None:
|
|
143
|
+
self.events.emit(UsageUpdated(usage=self.counters.usage()))
|
|
144
|
+
|
|
145
|
+
def _dispatch(self, event: dict[str, Any]) -> None:
|
|
146
|
+
self._identify(event.get("conversation_id"))
|
|
147
|
+
kind = event.get("event", "")
|
|
148
|
+
if kind == "step_update":
|
|
149
|
+
self._step_update(event.get("step_update"))
|
|
150
|
+
elif kind == "result":
|
|
151
|
+
self._result(event.get("result"))
|
|
152
|
+
|
|
153
|
+
def _step_update(self, update: Any) -> None:
|
|
154
|
+
if not isinstance(update, dict):
|
|
155
|
+
return
|
|
156
|
+
self._identify(update.get("conversation_id"))
|
|
157
|
+
step_type = update.get("step_type", "")
|
|
158
|
+
if step_type == "agent_response":
|
|
159
|
+
self._append_delta(update.get("text_delta"))
|
|
160
|
+
elif step_type == "tool":
|
|
161
|
+
self._tool_update(update)
|
|
162
|
+
|
|
163
|
+
usage = update.get("usage")
|
|
164
|
+
if update.get("state", "") == "DONE" and isinstance(usage, dict):
|
|
165
|
+
self._step_usage(update, usage)
|
|
166
|
+
|
|
167
|
+
def _append_delta(self, value: Any) -> None:
|
|
168
|
+
if isinstance(value, str) and value:
|
|
169
|
+
self.deltas.append(value)
|
|
170
|
+
self.events.emit(TextDelta(text=value, entity_id="msg-1"))
|
|
171
|
+
|
|
172
|
+
def _tool_update(self, update: dict[str, Any]) -> None:
|
|
173
|
+
tool_info = update.get("tool_info")
|
|
174
|
+
if not isinstance(tool_info, dict):
|
|
175
|
+
tool_info = {}
|
|
176
|
+
state = update.get("state", "")
|
|
177
|
+
if state == "ACTIVE":
|
|
178
|
+
self.tool_counter += 1
|
|
179
|
+
self.active_tool_id = f"tool-{self.tool_counter}"
|
|
180
|
+
tool_name = str(update.get("tool_name", "") or tool_info.get("name", ""))
|
|
181
|
+
self.events.emit(
|
|
182
|
+
tool_started(self.active_tool_id, "agy", tool_name, tool_input=tool_info.get("parameters"))
|
|
183
|
+
)
|
|
184
|
+
elif state == "DONE":
|
|
185
|
+
self.events.emit(
|
|
186
|
+
ToolCompleted(status="completed", entity_id=self.active_tool_id or f"tool-{self.tool_counter or 1}")
|
|
187
|
+
)
|
|
188
|
+
self.active_tool_id = ""
|
|
189
|
+
|
|
190
|
+
def _step_usage(self, update: dict[str, Any], usage: dict[str, Any]) -> None:
|
|
191
|
+
raw_in = as_int(usage.get("input_tokens"))
|
|
192
|
+
raw_out = as_int(usage.get("output_tokens"))
|
|
193
|
+
cache_read = as_int(usage.get("cache_read_tokens"))
|
|
194
|
+
cache_write = as_int(usage.get("cache_write_tokens"))
|
|
195
|
+
if not any(v is not None for v in (raw_in, raw_out, cache_read, cache_write)):
|
|
196
|
+
return
|
|
197
|
+
self.step_counter += 1
|
|
198
|
+
step_index = update.get("step_index")
|
|
199
|
+
step_id = f"step_{step_index}" if step_index is not None else f"step_{self.step_counter}"
|
|
200
|
+
self.counters.record_response(
|
|
201
|
+
step_id, input=raw_in, output=raw_out, cache_read=cache_read, cache_write=cache_write
|
|
202
|
+
)
|
|
203
|
+
window = as_int(
|
|
204
|
+
usage.get("model_context_window")
|
|
205
|
+
or usage.get("context_window_size")
|
|
206
|
+
or update.get("model_context_window")
|
|
207
|
+
or update.get("context_window_size")
|
|
208
|
+
)
|
|
209
|
+
if raw_in is not None and raw_out is not None:
|
|
210
|
+
used = raw_in + raw_out - (as_int(usage.get("thinking_tokens")) or 0)
|
|
211
|
+
if used >= 0:
|
|
212
|
+
self.counters.record_context(used=used)
|
|
213
|
+
if window is not None:
|
|
214
|
+
self.counters.record_context(window=window)
|
|
215
|
+
self._usage_updated()
|
|
216
|
+
|
|
217
|
+
def _result(self, result: Any) -> None:
|
|
218
|
+
if not isinstance(result, dict):
|
|
219
|
+
return
|
|
220
|
+
self._identify(result.get("conversation_id"))
|
|
221
|
+
response = result.get("response")
|
|
222
|
+
if isinstance(response, str) and response:
|
|
223
|
+
self.final_response = response
|
|
224
|
+
status = result.get("status")
|
|
225
|
+
if isinstance(status, str):
|
|
226
|
+
self.status = status
|
|
227
|
+
usage = result.get("usage")
|
|
228
|
+
if not isinstance(usage, dict):
|
|
229
|
+
return
|
|
230
|
+
raw_in = as_int(usage.get("input_tokens"))
|
|
231
|
+
raw_out = as_int(usage.get("output_tokens"))
|
|
232
|
+
cache_read = as_int(usage.get("cache_read_tokens"))
|
|
233
|
+
cache_write = as_int(usage.get("cache_write_tokens"))
|
|
234
|
+
totals = any(v is not None for v in (raw_in, raw_out, cache_read, cache_write))
|
|
235
|
+
if totals:
|
|
236
|
+
self.counters.record_totals(input=raw_in, output=raw_out, cache_read=cache_read, cache_write=cache_write)
|
|
237
|
+
window = as_int(usage.get("model_context_window") or usage.get("context_window_size"))
|
|
238
|
+
if window is not None:
|
|
239
|
+
self.counters.record_context(window=window)
|
|
240
|
+
if totals or window is not None:
|
|
241
|
+
self._usage_updated()
|
|
242
|
+
|
|
243
|
+
def finish(self, process: ProcessOutcome, cancel: asyncio.Event, log_text: str) -> Result:
|
|
244
|
+
"""Map the parsed stream, the log file text, and the transport outcome to a result."""
|
|
245
|
+
match = _CONVERSATION_ID_RE.search(log_text)
|
|
246
|
+
session_id = (
|
|
247
|
+
match.group(1) if match else (self.stream_session_id or self.conversation_id or self.requested_session)
|
|
248
|
+
)
|
|
249
|
+
if not self.saw_event:
|
|
250
|
+
text = "\n".join(self.plain_lines).strip()
|
|
251
|
+
else:
|
|
252
|
+
text = (self.final_response or "".join(self.deltas)).strip()
|
|
253
|
+
|
|
254
|
+
facts: Facts = process_facts(process, cancel)
|
|
255
|
+
facts.text = text
|
|
256
|
+
facts.session_id = session_id or None
|
|
257
|
+
if self.status and self.status != _SUCCESS_STATUS:
|
|
258
|
+
facts.cli_error = Diagnostic(code="execution_error", message=f"agy reported status {self.status}")
|
|
259
|
+
# Diagnostics classify the run only when there is no agent output, or when the process exited nonzero.
|
|
260
|
+
if not text or facts.returncode:
|
|
261
|
+
facts.diagnostics = self.diagnostics.text()
|
|
262
|
+
return build_result(facts, model=self.model, usage=self.counters.usage())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class AgyDriver(CliDriver):
|
|
266
|
+
"""Runs ``agy --print`` with the prompt in argv, in ``Request.cwd``. The driver never adds a permission flag."""
|
|
267
|
+
|
|
268
|
+
name = "agy"
|
|
269
|
+
executable = "agy"
|
|
270
|
+
owned_flags = (
|
|
271
|
+
"--output-format",
|
|
272
|
+
"--log-file",
|
|
273
|
+
"--conversation",
|
|
274
|
+
"--new-project",
|
|
275
|
+
"--print",
|
|
276
|
+
"-p",
|
|
277
|
+
"--prompt",
|
|
278
|
+
"--continue",
|
|
279
|
+
"-c",
|
|
280
|
+
)
|
|
281
|
+
portable_flags = {"model": ("--model",)}
|
|
282
|
+
help_markers = ("--output-format", "stream-json", "--log-file")
|
|
283
|
+
|
|
284
|
+
def validate(self, req: Request) -> Diagnostic | None:
|
|
285
|
+
return _prompt_error(req.prompt)
|
|
286
|
+
|
|
287
|
+
def _argv(self, executable: Path, req: Request, log_path: Path) -> list[str]:
|
|
288
|
+
argv = [os.fspath(executable), "--output-format", "stream-json", "--log-file", os.fspath(log_path)]
|
|
289
|
+
if req.session_id:
|
|
290
|
+
argv.extend(["--conversation", req.session_id])
|
|
291
|
+
else:
|
|
292
|
+
argv.append("--new-project")
|
|
293
|
+
argv.extend(self.portable_argv(req))
|
|
294
|
+
argv.extend(req.args)
|
|
295
|
+
# The prompt comes last so no caller arg can take it as a flag value.
|
|
296
|
+
argv.extend(["--print", req.prompt])
|
|
297
|
+
return argv
|
|
298
|
+
|
|
299
|
+
async def run(
|
|
300
|
+
self,
|
|
301
|
+
req: Request,
|
|
302
|
+
*,
|
|
303
|
+
on_event: EventHandler,
|
|
304
|
+
cancel: asyncio.Event,
|
|
305
|
+
on_spawn: SpawnHandler | None,
|
|
306
|
+
) -> Result:
|
|
307
|
+
prepared = self.preflight(req, cancel)
|
|
308
|
+
if isinstance(prepared, Result):
|
|
309
|
+
return prepared
|
|
310
|
+
cwd, executable = prepared
|
|
311
|
+
fd, name = tempfile.mkstemp(prefix="coding-agent-cli-", suffix=".log")
|
|
312
|
+
os.close(fd)
|
|
313
|
+
log_path = Path(name)
|
|
314
|
+
self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
|
|
315
|
+
run = AgyRun(on_event, requested_session=req.session_id)
|
|
316
|
+
try:
|
|
317
|
+
argv = self._argv(executable, req, log_path)
|
|
318
|
+
error = _command_line_error(argv)
|
|
319
|
+
if error is not None:
|
|
320
|
+
return request_failure(req, error, cancel)
|
|
321
|
+
process = await run_process(
|
|
322
|
+
argv,
|
|
323
|
+
None,
|
|
324
|
+
dict(req.env),
|
|
325
|
+
os.fspath(cwd),
|
|
326
|
+
deadline_for(req),
|
|
327
|
+
cancel,
|
|
328
|
+
run.feed,
|
|
329
|
+
on_spawn=on_spawn,
|
|
330
|
+
)
|
|
331
|
+
log_text = read_and_unlink(log_path, errors="ignore")
|
|
332
|
+
finally:
|
|
333
|
+
with contextlib.suppress(OSError):
|
|
334
|
+
log_path.unlink()
|
|
335
|
+
result = run.finish(process, cancel, log_text)
|
|
336
|
+
self.log.info("run done outcome=%s", result.outcome)
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
__all__ = ["MAX_DIAGNOSTICS", "PROMPT_LIMIT_BYTES", "AgyDriver", "AgyRun"]
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Claude Code CLI driver using print-mode stream-json output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .._classify import http_status_code, keyword_code
|
|
12
|
+
from .._process import ProcessOutcome, run_process
|
|
13
|
+
from ..events import SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
|
|
14
|
+
from ..registry import EventHandler, SpawnHandler
|
|
15
|
+
from ..types import Diagnostic, Request, Result
|
|
16
|
+
from ._common import (
|
|
17
|
+
NO_USAGE,
|
|
18
|
+
CliDriver,
|
|
19
|
+
Diagnostics,
|
|
20
|
+
EventBuffer,
|
|
21
|
+
as_int,
|
|
22
|
+
build_result,
|
|
23
|
+
deadline_for,
|
|
24
|
+
parse_json_object,
|
|
25
|
+
process_facts,
|
|
26
|
+
read_usage,
|
|
27
|
+
tool_started,
|
|
28
|
+
)
|
|
29
|
+
from ._usage import TokenCounters
|
|
30
|
+
|
|
31
|
+
MAX_DIAGNOSTICS = 50
|
|
32
|
+
_RESULT_FIELDS = ("result", "session_id", "is_error", "subtype", "api_error_status", "modelUsage")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _value_text(value: Any) -> str:
|
|
36
|
+
if value is None:
|
|
37
|
+
return ""
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
return value
|
|
40
|
+
try:
|
|
41
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _unwrap(event: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
inner = event.get("event")
|
|
48
|
+
return inner if event.get("type") == "stream_event" and isinstance(inner, dict) else event
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _http_status(value: Any) -> int | None:
|
|
52
|
+
if isinstance(value, str) and value.isdigit():
|
|
53
|
+
return int(value)
|
|
54
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _result_failed(final: dict[str, Any]) -> bool:
|
|
58
|
+
return bool(final.get("is_error")) or final.get("subtype") != "success"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _result_error_text(final: dict[str, Any]) -> str:
|
|
62
|
+
parts = [_value_text(final.get("result"))]
|
|
63
|
+
status = _value_text(final.get("api_error_status"))
|
|
64
|
+
if status:
|
|
65
|
+
parts.append(f"api_error_status={status}")
|
|
66
|
+
return "\n".join(part for part in parts if part).strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ClaudeRun:
|
|
70
|
+
"""Parse one Claude run line by line. Raw lines are never retained.
|
|
71
|
+
|
|
72
|
+
Diagnostics keep the first ``MAX_DIAGNOSTICS`` lines, plus the first line for each CLI error code
|
|
73
|
+
beyond that bound, so the classification matches an unbounded run.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, sink: EventHandler) -> None:
|
|
77
|
+
self.events = EventBuffer(sink)
|
|
78
|
+
self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
|
|
79
|
+
self.counters = TokenCounters()
|
|
80
|
+
self.final: dict[str, Any] | None = None
|
|
81
|
+
self.session_id = ""
|
|
82
|
+
self.active_model = ""
|
|
83
|
+
self.response_counter = 0
|
|
84
|
+
self.message_counter = 0
|
|
85
|
+
self.tool_counter = 0
|
|
86
|
+
self.tool_entities: dict[str, str] = {}
|
|
87
|
+
|
|
88
|
+
async def feed(self, line: str) -> None:
|
|
89
|
+
stripped = line.strip()
|
|
90
|
+
event = parse_json_object(stripped)
|
|
91
|
+
if event is None:
|
|
92
|
+
if stripped:
|
|
93
|
+
self._keep_diagnostic(stripped)
|
|
94
|
+
else:
|
|
95
|
+
self._record(event)
|
|
96
|
+
await self.events.flush()
|
|
97
|
+
|
|
98
|
+
def finish(self, process: ProcessOutcome, cancel: asyncio.Event, requested_session: str | None) -> Result:
|
|
99
|
+
"""Map the parsed stream and the transport outcome to a result."""
|
|
100
|
+
facts = process_facts(process, cancel)
|
|
101
|
+
final = self.final
|
|
102
|
+
if final is not None and _result_failed(final):
|
|
103
|
+
code = http_status_code(_http_status(final.get("api_error_status"))) or "execution_error"
|
|
104
|
+
facts.cli_error = Diagnostic(code=code, message=_result_error_text(final))
|
|
105
|
+
elif final is not None:
|
|
106
|
+
facts.text = _value_text(final.get("result"))
|
|
107
|
+
facts.session_id = self.session_id or requested_session or ""
|
|
108
|
+
facts.diagnostics = self.diagnostics.text()
|
|
109
|
+
return build_result(facts, model=self._model(), usage=self.counters.usage())
|
|
110
|
+
|
|
111
|
+
def _keep_diagnostic(self, line: str) -> None:
|
|
112
|
+
code = keyword_code(line)
|
|
113
|
+
self.diagnostics.add(line, marks=(code,) if code else ())
|
|
114
|
+
|
|
115
|
+
def _record(self, raw: dict[str, Any]) -> None:
|
|
116
|
+
event = _unwrap(raw)
|
|
117
|
+
if event.get("type") == "result":
|
|
118
|
+
self.final = {key: event.get(key) for key in _RESULT_FIELDS}
|
|
119
|
+
self._identify(raw.get("session_id"))
|
|
120
|
+
|
|
121
|
+
kind = event.get("type")
|
|
122
|
+
if kind == "message_start":
|
|
123
|
+
self.message_counter += 1
|
|
124
|
+
elif kind == "content_block_delta":
|
|
125
|
+
self._text_delta(event)
|
|
126
|
+
elif kind == "content_block_start":
|
|
127
|
+
self._start_tool(event)
|
|
128
|
+
elif kind == "user":
|
|
129
|
+
self._tool_results(event)
|
|
130
|
+
elif kind == "assistant":
|
|
131
|
+
self._assistant(event)
|
|
132
|
+
elif kind == "result":
|
|
133
|
+
self._result(event)
|
|
134
|
+
|
|
135
|
+
def _identify(self, value: Any) -> None:
|
|
136
|
+
if isinstance(value, str) and value and value != self.session_id:
|
|
137
|
+
self.session_id = value
|
|
138
|
+
self.events.emit(SessionIdentified(session_id=value))
|
|
139
|
+
|
|
140
|
+
def _message_entity(self) -> str:
|
|
141
|
+
return f"msg-{self.message_counter or 1}"
|
|
142
|
+
|
|
143
|
+
def _usage_updated(self) -> None:
|
|
144
|
+
self.events.emit(UsageUpdated(usage=self.counters.usage()))
|
|
145
|
+
|
|
146
|
+
def _text_delta(self, event: dict[str, Any]) -> None:
|
|
147
|
+
delta = event.get("delta")
|
|
148
|
+
if not isinstance(delta, dict) or delta.get("type") != "text_delta":
|
|
149
|
+
return
|
|
150
|
+
text = delta.get("text")
|
|
151
|
+
if isinstance(text, str) and text:
|
|
152
|
+
self.events.emit(TextDelta(text=text, entity_id=self._message_entity()))
|
|
153
|
+
|
|
154
|
+
def _start_tool(self, event: dict[str, Any]) -> None:
|
|
155
|
+
block = event.get("content_block")
|
|
156
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
157
|
+
return
|
|
158
|
+
self.tool_counter += 1
|
|
159
|
+
entity_id = f"tool-{self.tool_counter}"
|
|
160
|
+
tool_use_id = block.get("id")
|
|
161
|
+
if isinstance(tool_use_id, str) and tool_use_id:
|
|
162
|
+
self.tool_entities[tool_use_id] = entity_id
|
|
163
|
+
self.events.emit(tool_started(entity_id, "claude", str(block.get("name", "")), tool_input=block.get("input")))
|
|
164
|
+
|
|
165
|
+
def _tool_results(self, event: dict[str, Any]) -> None:
|
|
166
|
+
message = event.get("message")
|
|
167
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
168
|
+
if not isinstance(content, list):
|
|
169
|
+
return
|
|
170
|
+
for block in content:
|
|
171
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
172
|
+
continue
|
|
173
|
+
tool_use_id = block.get("tool_use_id")
|
|
174
|
+
entity_id = self.tool_entities.pop(tool_use_id, None) if isinstance(tool_use_id, str) else None
|
|
175
|
+
if entity_id is None:
|
|
176
|
+
continue
|
|
177
|
+
status = "error" if block.get("is_error") is True else "completed"
|
|
178
|
+
self.events.emit(ToolCompleted(status=status, entity_id=entity_id))
|
|
179
|
+
|
|
180
|
+
def _assistant(self, event: dict[str, Any]) -> None:
|
|
181
|
+
message = event.get("message")
|
|
182
|
+
if not isinstance(message, dict):
|
|
183
|
+
return
|
|
184
|
+
model = message.get("model")
|
|
185
|
+
if isinstance(model, str) and model:
|
|
186
|
+
self.active_model = model
|
|
187
|
+
usage = message.get("usage")
|
|
188
|
+
if not isinstance(usage, dict):
|
|
189
|
+
return
|
|
190
|
+
tok = read_usage(usage)
|
|
191
|
+
if not tok.observed:
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
msg_id = message.get("id")
|
|
195
|
+
response_id = str(msg_id) if msg_id else f"msg-{self.response_counter + 1}"
|
|
196
|
+
self.response_counter += 1
|
|
197
|
+
self.counters.record_response(
|
|
198
|
+
response_id,
|
|
199
|
+
input=tok.input,
|
|
200
|
+
output=tok.output,
|
|
201
|
+
cache_read=tok.cache_read,
|
|
202
|
+
cache_write=tok.cache_write,
|
|
203
|
+
)
|
|
204
|
+
self._usage_updated()
|
|
205
|
+
if tok.input is not None:
|
|
206
|
+
self.counters.record_context(used=tok.input + (tok.output or 0))
|
|
207
|
+
self._usage_updated()
|
|
208
|
+
|
|
209
|
+
def _context_capacity(self, event: dict[str, Any]) -> int | None:
|
|
210
|
+
model_usage = event.get("modelUsage")
|
|
211
|
+
if not isinstance(model_usage, dict):
|
|
212
|
+
return None
|
|
213
|
+
target_model = self.active_model
|
|
214
|
+
if not target_model and len(model_usage) == 1:
|
|
215
|
+
target_model = next(iter(model_usage))
|
|
216
|
+
if not target_model or target_model not in model_usage:
|
|
217
|
+
return None
|
|
218
|
+
stats = model_usage[target_model]
|
|
219
|
+
if not isinstance(stats, dict):
|
|
220
|
+
return None
|
|
221
|
+
cap = as_int(stats.get("contextWindow"))
|
|
222
|
+
return cap if cap and cap > 0 else None
|
|
223
|
+
|
|
224
|
+
def _result(self, event: dict[str, Any]) -> None:
|
|
225
|
+
capacity = self._context_capacity(event)
|
|
226
|
+
|
|
227
|
+
usage = event.get("usage")
|
|
228
|
+
tok = read_usage(usage) if isinstance(usage, dict) else NO_USAGE
|
|
229
|
+
if tok.observed:
|
|
230
|
+
self.counters.record_totals(
|
|
231
|
+
input=tok.input,
|
|
232
|
+
output=tok.output,
|
|
233
|
+
cache_read=tok.cache_read,
|
|
234
|
+
cache_write=tok.cache_write,
|
|
235
|
+
)
|
|
236
|
+
self._usage_updated()
|
|
237
|
+
if capacity is not None:
|
|
238
|
+
self.counters.record_context(window=capacity)
|
|
239
|
+
self._usage_updated()
|
|
240
|
+
|
|
241
|
+
def _model(self) -> str | None:
|
|
242
|
+
final = self.final
|
|
243
|
+
if final is not None:
|
|
244
|
+
model_usage = final.get("modelUsage")
|
|
245
|
+
if isinstance(model_usage, dict) and model_usage:
|
|
246
|
+
if self.active_model and self.active_model in model_usage:
|
|
247
|
+
return self.active_model
|
|
248
|
+
first = next(iter(model_usage))
|
|
249
|
+
if isinstance(first, str) and first:
|
|
250
|
+
return first
|
|
251
|
+
return self.active_model or None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ClaudeDriver(CliDriver):
|
|
255
|
+
"""Runs ``claude -p`` with the prompt on stdin. The driver never adds a permission flag."""
|
|
256
|
+
|
|
257
|
+
name = "claude"
|
|
258
|
+
executable = "claude"
|
|
259
|
+
owned_flags = ("-p", "--output-format", "--include-partial-messages", "--resume")
|
|
260
|
+
portable_flags = {
|
|
261
|
+
"system_prompt": ("--system-prompt",),
|
|
262
|
+
"model": ("--model",),
|
|
263
|
+
"reasoning": ("--effort",),
|
|
264
|
+
}
|
|
265
|
+
help_markers = ("--output-format", "stream-json", "--include-partial-messages")
|
|
266
|
+
|
|
267
|
+
def _argv(self, executable: Path, req: Request) -> list[str]:
|
|
268
|
+
argv = [os.fspath(executable), "-p", "--output-format", "stream-json", "--include-partial-messages"]
|
|
269
|
+
if req.session_id:
|
|
270
|
+
argv.extend(["--resume", req.session_id])
|
|
271
|
+
argv.extend(self.portable_argv(req))
|
|
272
|
+
argv.extend(req.args)
|
|
273
|
+
return argv
|
|
274
|
+
|
|
275
|
+
async def run(
|
|
276
|
+
self,
|
|
277
|
+
req: Request,
|
|
278
|
+
*,
|
|
279
|
+
on_event: EventHandler,
|
|
280
|
+
cancel: asyncio.Event,
|
|
281
|
+
on_spawn: SpawnHandler | None,
|
|
282
|
+
) -> Result:
|
|
283
|
+
prepared = self.preflight(req, cancel)
|
|
284
|
+
if isinstance(prepared, Result):
|
|
285
|
+
return prepared
|
|
286
|
+
cwd, executable = prepared
|
|
287
|
+
self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
|
|
288
|
+
run = ClaudeRun(on_event)
|
|
289
|
+
process = await run_process(
|
|
290
|
+
self._argv(executable, req),
|
|
291
|
+
req.prompt,
|
|
292
|
+
dict(req.env),
|
|
293
|
+
os.fspath(cwd),
|
|
294
|
+
deadline_for(req),
|
|
295
|
+
cancel,
|
|
296
|
+
run.feed,
|
|
297
|
+
on_spawn=on_spawn,
|
|
298
|
+
)
|
|
299
|
+
result = run.finish(process, cancel, req.session_id)
|
|
300
|
+
self.log.info("run done outcome=%s", result.outcome)
|
|
301
|
+
return result
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
__all__ = ["MAX_DIAGNOSTICS", "ClaudeDriver", "ClaudeRun"]
|