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.
@@ -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"]