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,333 @@
|
|
|
1
|
+
"""Codex CLI driver: JSONL on stdout, the final message in a temporary file, and an exact session ID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._classify import Facts, keyword_code
|
|
15
|
+
from .._process import ProcessOutcome, run_process
|
|
16
|
+
from ..events import MessageCompleted, MessageStarted, SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
|
|
17
|
+
from ..registry import EventHandler, SpawnHandler
|
|
18
|
+
from ..types import Diagnostic, Request, Result
|
|
19
|
+
from ._common import (
|
|
20
|
+
CliDriver,
|
|
21
|
+
Diagnostics,
|
|
22
|
+
EventBuffer,
|
|
23
|
+
as_int,
|
|
24
|
+
build_result,
|
|
25
|
+
deadline_for,
|
|
26
|
+
effective_env,
|
|
27
|
+
parse_json_object,
|
|
28
|
+
process_facts,
|
|
29
|
+
read_and_unlink,
|
|
30
|
+
tool_started,
|
|
31
|
+
tool_status,
|
|
32
|
+
)
|
|
33
|
+
from ._usage import TokenCounters
|
|
34
|
+
from .codex_rollout import CodexRolloutCollector
|
|
35
|
+
|
|
36
|
+
MAX_DIAGNOSTICS = 50
|
|
37
|
+
_SESSION_ID_STDOUT_RE = re.compile(
|
|
38
|
+
r"^\s*session id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s*$",
|
|
39
|
+
re.IGNORECASE | re.MULTILINE,
|
|
40
|
+
)
|
|
41
|
+
_TOML_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _toml_string(value: str) -> str:
|
|
45
|
+
"""Return ``value`` as a TOML basic string. Control characters and DEL use ``\\uXXXX`` escapes."""
|
|
46
|
+
parts: list[str] = []
|
|
47
|
+
for char in value:
|
|
48
|
+
if char in _TOML_ESCAPES:
|
|
49
|
+
parts.append(_TOML_ESCAPES[char])
|
|
50
|
+
elif ord(char) < 0x20 or ord(char) == 0x7F:
|
|
51
|
+
parts.append(f"\\u{ord(char):04X}")
|
|
52
|
+
else:
|
|
53
|
+
parts.append(char)
|
|
54
|
+
return f'"{"".join(parts)}"'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _session_id_line(text: str) -> str:
|
|
58
|
+
"""Return the ID of the first ``session id: <uuid>`` line in ``text``, or ""."""
|
|
59
|
+
match = _SESSION_ID_STDOUT_RE.search(text)
|
|
60
|
+
return match.group(1) if match else ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_session_id(thread_session_id: str, last_message: str, stdout_session_id: str) -> str:
|
|
64
|
+
"""Return the first exact session ID from the thread event, the last message, or stdout. No source returns ""."""
|
|
65
|
+
for value in (thread_session_id.strip(), _session_id_line(last_message), stdout_session_id):
|
|
66
|
+
if value:
|
|
67
|
+
return value
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cd_flag(args: tuple[str, ...]) -> str | None:
|
|
72
|
+
"""Return the first raw arg that sets the working directory: ``--cd``, ``--cd=...``, ``-C``, or ``-C...``."""
|
|
73
|
+
for token in args:
|
|
74
|
+
if token == "--cd" or token.startswith("--cd="):
|
|
75
|
+
return token
|
|
76
|
+
if token.startswith("-C") and not token.startswith("--"):
|
|
77
|
+
return token
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CodexRun:
|
|
82
|
+
"""Parse one Codex run line by line with bounded state. Raw lines are never retained.
|
|
83
|
+
|
|
84
|
+
The answer is the last-message file when it has text, else the concatenated ``agent_message``
|
|
85
|
+
items. Diagnostics keep the first ``MAX_DIAGNOSTICS`` non-JSON lines plus the first fatal-token line.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
sink: EventHandler,
|
|
91
|
+
*,
|
|
92
|
+
cwd: Path,
|
|
93
|
+
env: Mapping[str, str],
|
|
94
|
+
requested_session: str | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self.events = EventBuffer(sink)
|
|
97
|
+
self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
|
|
98
|
+
self.counters = TokenCounters()
|
|
99
|
+
self.requested_session = requested_session or ""
|
|
100
|
+
self.thread_session_id = ""
|
|
101
|
+
self.stdout_session_id = ""
|
|
102
|
+
self.agent_messages: list[str] = []
|
|
103
|
+
self.entity_counter = 0
|
|
104
|
+
self.raw_to_entity: dict[str, str] = {}
|
|
105
|
+
self.turn_counter = 0
|
|
106
|
+
self.collector = CodexRolloutCollector(
|
|
107
|
+
cwd=cwd,
|
|
108
|
+
env=env,
|
|
109
|
+
session_id=self.requested_session,
|
|
110
|
+
counters=self.counters,
|
|
111
|
+
on_change=self._usage_updated,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def feed(self, line: str) -> None:
|
|
115
|
+
stripped = line.strip()
|
|
116
|
+
if not self.stdout_session_id:
|
|
117
|
+
self.stdout_session_id = _session_id_line(stripped)
|
|
118
|
+
event = parse_json_object(stripped)
|
|
119
|
+
if event is None:
|
|
120
|
+
if stripped:
|
|
121
|
+
self._keep_diagnostic(stripped)
|
|
122
|
+
else:
|
|
123
|
+
self._on_event(event)
|
|
124
|
+
await self.events.flush()
|
|
125
|
+
|
|
126
|
+
def _keep_diagnostic(self, line: str) -> None:
|
|
127
|
+
code = keyword_code(line)
|
|
128
|
+
self.diagnostics.add(line, marks=(code,) if code else ())
|
|
129
|
+
|
|
130
|
+
def _usage_updated(self) -> None:
|
|
131
|
+
self.events.emit(UsageUpdated(usage=self.counters.usage()))
|
|
132
|
+
|
|
133
|
+
def _start_item(self, item: dict[str, Any], raw_id: str) -> None:
|
|
134
|
+
item_type = item.get("type", "")
|
|
135
|
+
if item_type == "agent_message":
|
|
136
|
+
self.entity_counter += 1
|
|
137
|
+
entity_id = f"msg-{self.entity_counter}"
|
|
138
|
+
if raw_id:
|
|
139
|
+
self.raw_to_entity[raw_id] = entity_id
|
|
140
|
+
self.events.emit(MessageStarted(entity_id=entity_id))
|
|
141
|
+
elif item_type == "tool_call":
|
|
142
|
+
self.entity_counter += 1
|
|
143
|
+
entity_id = f"tool-{self.entity_counter}"
|
|
144
|
+
if raw_id:
|
|
145
|
+
self.raw_to_entity[raw_id] = entity_id
|
|
146
|
+
self.events.emit(tool_started(entity_id, "codex", str(item.get("name", "")), tool_input=item.get("input")))
|
|
147
|
+
|
|
148
|
+
def _complete_item(self, item: dict[str, Any], raw_id: str) -> None:
|
|
149
|
+
item_type = item.get("type", "")
|
|
150
|
+
if item_type == "agent_message":
|
|
151
|
+
text = item.get("text")
|
|
152
|
+
if isinstance(text, str) and text:
|
|
153
|
+
self.agent_messages.append(text)
|
|
154
|
+
entity_id = self.raw_to_entity.get(raw_id) or f"msg-{self.entity_counter or 1}"
|
|
155
|
+
if isinstance(text, str) and text:
|
|
156
|
+
self.events.emit(TextDelta(text=text, entity_id=entity_id))
|
|
157
|
+
self.events.emit(MessageCompleted(entity_id=entity_id))
|
|
158
|
+
elif item_type == "tool_call":
|
|
159
|
+
entity_id = self.raw_to_entity.get(raw_id) or f"tool-{self.entity_counter or 1}"
|
|
160
|
+
self.events.emit(ToolCompleted(status=tool_status(item.get("status")), entity_id=entity_id))
|
|
161
|
+
|
|
162
|
+
def _on_event(self, parsed: dict[str, Any]) -> None:
|
|
163
|
+
event_type = parsed.get("type", "")
|
|
164
|
+
if event_type == "thread.started" and parsed.get("thread_id"):
|
|
165
|
+
thread_id = str(parsed.get("thread_id"))
|
|
166
|
+
if thread_id != self.thread_session_id:
|
|
167
|
+
self.thread_session_id = thread_id
|
|
168
|
+
self.events.emit(SessionIdentified(session_id=thread_id))
|
|
169
|
+
self.collector.set_session_id(thread_id)
|
|
170
|
+
self.collector.poll()
|
|
171
|
+
|
|
172
|
+
item = parsed.get("item")
|
|
173
|
+
if isinstance(item, dict):
|
|
174
|
+
raw_id = str(item.get("id", ""))
|
|
175
|
+
if event_type == "item.started":
|
|
176
|
+
self._start_item(item, raw_id)
|
|
177
|
+
elif event_type == "item.completed":
|
|
178
|
+
self._complete_item(item, raw_id)
|
|
179
|
+
|
|
180
|
+
if event_type == "turn.completed":
|
|
181
|
+
self.collector.poll()
|
|
182
|
+
self._record_turn_usage(parsed.get("usage"))
|
|
183
|
+
self.collector.poll()
|
|
184
|
+
elif event_type in ("thread.completed", "summary"):
|
|
185
|
+
self.collector.poll()
|
|
186
|
+
self._record_final_usage(parsed.get("usage"))
|
|
187
|
+
self.collector.poll()
|
|
188
|
+
|
|
189
|
+
def _record_turn_usage(self, u: Any) -> None:
|
|
190
|
+
if not isinstance(u, dict):
|
|
191
|
+
return
|
|
192
|
+
raw_in = as_int(u.get("input_tokens"))
|
|
193
|
+
cached_in = as_int(u.get("cached_input_tokens"))
|
|
194
|
+
cw = as_int(u.get("cache_write_input_tokens"))
|
|
195
|
+
raw_out = as_int(u.get("output_tokens"))
|
|
196
|
+
reasoning_out = as_int(u.get("reasoning_output_tokens"))
|
|
197
|
+
if not any(v is not None for v in (raw_in, cached_in, cw, raw_out, reasoning_out)):
|
|
198
|
+
return
|
|
199
|
+
# Codex input_tokens already includes cached_input_tokens.
|
|
200
|
+
self.turn_counter += 1
|
|
201
|
+
self.counters.record_response(
|
|
202
|
+
f"turn_{self.turn_counter}",
|
|
203
|
+
input=raw_in,
|
|
204
|
+
output=raw_out,
|
|
205
|
+
cache_read=cached_in,
|
|
206
|
+
cache_write=cw,
|
|
207
|
+
)
|
|
208
|
+
self._record_turn_context(raw_in, raw_out, reasoning_out)
|
|
209
|
+
self._usage_updated()
|
|
210
|
+
|
|
211
|
+
def _record_turn_context(self, norm_in: int | None, norm_out: int | None, reasoning_out: int | None) -> None:
|
|
212
|
+
"""Derive a context snapshot from turn usage unless the rollout already owns the context."""
|
|
213
|
+
if norm_in is None or norm_out is None:
|
|
214
|
+
return
|
|
215
|
+
used = norm_in + norm_out - (reasoning_out or 0)
|
|
216
|
+
if (
|
|
217
|
+
used >= 0
|
|
218
|
+
and not self.collector.is_compacted
|
|
219
|
+
and not self.collector.has_verified_context
|
|
220
|
+
and not self.collector.budget_exhausted
|
|
221
|
+
):
|
|
222
|
+
self.counters.record_context(used=used)
|
|
223
|
+
|
|
224
|
+
def _record_final_usage(self, u: Any) -> None:
|
|
225
|
+
if not isinstance(u, dict):
|
|
226
|
+
return
|
|
227
|
+
raw_in = as_int(u.get("input_tokens"))
|
|
228
|
+
cached_in = as_int(u.get("cached_input_tokens"))
|
|
229
|
+
cw = as_int(u.get("cache_write_input_tokens"))
|
|
230
|
+
raw_out = as_int(u.get("output_tokens"))
|
|
231
|
+
if any(v is not None for v in (raw_in, cached_in, cw, raw_out)) and not self.requested_session:
|
|
232
|
+
self.counters.record_totals(input=raw_in, output=raw_out, cache_read=cached_in, cache_write=cw)
|
|
233
|
+
self._usage_updated()
|
|
234
|
+
|
|
235
|
+
def finish(self, process: ProcessOutcome, cancel: asyncio.Event, last_message: str) -> Result:
|
|
236
|
+
"""Map the parsed stream, the last-message file text, and the transport outcome to a result."""
|
|
237
|
+
last_message_text = last_message.strip()
|
|
238
|
+
text = last_message_text or "".join(self.agent_messages).strip()
|
|
239
|
+
|
|
240
|
+
session_id = _resolve_session_id(self.thread_session_id, last_message, self.stdout_session_id)
|
|
241
|
+
if session_id:
|
|
242
|
+
self.collector.set_session_id(session_id)
|
|
243
|
+
self.collector.poll()
|
|
244
|
+
|
|
245
|
+
facts: Facts = process_facts(process, cancel)
|
|
246
|
+
facts.text = text
|
|
247
|
+
facts.session_id = session_id or None
|
|
248
|
+
# Diagnostics classify the run only when there is no agent output, or when the process exited nonzero.
|
|
249
|
+
if not text or facts.returncode:
|
|
250
|
+
facts.diagnostics = self.diagnostics.text()
|
|
251
|
+
return build_result(facts, model=self.collector.model_name, usage=self.counters.usage())
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class CodexDriver(CliDriver):
|
|
255
|
+
"""Runs ``codex exec`` with the prompt on stdin, in ``Request.cwd``.
|
|
256
|
+
|
|
257
|
+
The driver never adds an approval or sandbox flag.
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
name = "codex"
|
|
261
|
+
executable = "codex"
|
|
262
|
+
owned_flags = ("--json", "-o", "--output-last-message")
|
|
263
|
+
portable_flags = {
|
|
264
|
+
"model": ("--model", "-m"),
|
|
265
|
+
"reasoning": ("-c", "--config"),
|
|
266
|
+
}
|
|
267
|
+
help_args = ("exec", "--help")
|
|
268
|
+
help_markers = ("--json", "--output-last-message")
|
|
269
|
+
|
|
270
|
+
def validate(self, req: Request) -> Diagnostic | None:
|
|
271
|
+
token = _cd_flag(req.args)
|
|
272
|
+
if token is not None:
|
|
273
|
+
return Diagnostic(
|
|
274
|
+
code="invalid_args", message=f"args cannot set the working directory ({token!r}); use Request.cwd"
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def portable_argv(self, req: Request) -> list[str]:
|
|
279
|
+
argv: list[str] = []
|
|
280
|
+
if req.model is not None:
|
|
281
|
+
argv.extend(["--model", req.model])
|
|
282
|
+
if req.reasoning is not None:
|
|
283
|
+
argv.extend(["-c", f"model_reasoning_effort={_toml_string(req.reasoning)}"])
|
|
284
|
+
return argv
|
|
285
|
+
|
|
286
|
+
def _argv(self, executable: Path, req: Request, last_message_path: Path) -> list[str]:
|
|
287
|
+
argv = [os.fspath(executable), "exec", "--json", "-o", os.fspath(last_message_path)]
|
|
288
|
+
argv.extend(self.portable_argv(req))
|
|
289
|
+
argv.extend(req.args)
|
|
290
|
+
if req.session_id:
|
|
291
|
+
argv.extend(["resume", req.session_id])
|
|
292
|
+
# "-" makes codex exec read the prompt from stdin.
|
|
293
|
+
argv.append("-")
|
|
294
|
+
return argv
|
|
295
|
+
|
|
296
|
+
async def run(
|
|
297
|
+
self,
|
|
298
|
+
req: Request,
|
|
299
|
+
*,
|
|
300
|
+
on_event: EventHandler,
|
|
301
|
+
cancel: asyncio.Event,
|
|
302
|
+
on_spawn: SpawnHandler | None,
|
|
303
|
+
) -> Result:
|
|
304
|
+
prepared = self.preflight(req, cancel)
|
|
305
|
+
if isinstance(prepared, Result):
|
|
306
|
+
return prepared
|
|
307
|
+
cwd, executable = prepared
|
|
308
|
+
fd, name = tempfile.mkstemp(prefix="coding-agent-cli-", suffix=".txt")
|
|
309
|
+
os.close(fd)
|
|
310
|
+
last_message_path = Path(name)
|
|
311
|
+
self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
|
|
312
|
+
run = CodexRun(on_event, cwd=cwd, env=effective_env(req), requested_session=req.session_id)
|
|
313
|
+
try:
|
|
314
|
+
process = await run_process(
|
|
315
|
+
self._argv(executable, req, last_message_path),
|
|
316
|
+
req.prompt,
|
|
317
|
+
dict(req.env),
|
|
318
|
+
os.fspath(cwd),
|
|
319
|
+
deadline_for(req),
|
|
320
|
+
cancel,
|
|
321
|
+
run.feed,
|
|
322
|
+
on_spawn=on_spawn,
|
|
323
|
+
)
|
|
324
|
+
last_message = read_and_unlink(last_message_path, errors="replace")
|
|
325
|
+
finally:
|
|
326
|
+
with contextlib.suppress(OSError):
|
|
327
|
+
last_message_path.unlink()
|
|
328
|
+
result = run.finish(process, cancel, last_message)
|
|
329
|
+
self.log.info("run done outcome=%s", result.outcome)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
__all__ = ["MAX_DIAGNOSTICS", "CodexDriver", "CodexRun"]
|