playmaker-cli 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.
@@ -0,0 +1,387 @@
1
+ """Codex CLI handler.
2
+
3
+ Empirically (codex 0.125.0):
4
+ - oneshot: `codex exec [--json] [--skip-git-repo-check] [--cd <DIR>] "<PROMPT>"`
5
+ - json output: `--json` (JSONL event stream on stdout)
6
+ - last assistant text: `--output-last-message <FILE>` writes it cleanly
7
+ - session file: ~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO-ts>-<thread_id>.jsonl
8
+
9
+ stdout event types observed: thread.started, turn.started, item.completed, turn.completed
10
+ session-file event types: session_meta, turn_context, event_msg, response_item
11
+ The final assistant message is in `task_complete.payload.last_agent_message`
12
+ inside the rollout file, OR via stdout `item.completed` events with
13
+ `item.type == "agent_message"`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import shutil
20
+ import subprocess
21
+ import tempfile
22
+ import time
23
+ from pathlib import Path
24
+
25
+ from playmaker.agents.base import DispatchResult, SessionStartedCallback, Turn
26
+
27
+ CODEX_SESSIONS_ROOT = Path("~/.codex/sessions").expanduser()
28
+
29
+
30
+ class CodexHandler:
31
+ name = "codex"
32
+
33
+ def is_available(self) -> bool:
34
+ return shutil.which("codex") is not None
35
+
36
+ def dispatch(
37
+ self,
38
+ prompt: str,
39
+ cwd: Path,
40
+ files: list[Path] | None = None,
41
+ on_session_started: SessionStartedCallback | None = None,
42
+ model: str | None = None,
43
+ ) -> DispatchResult:
44
+ full_prompt = self._build_prompt(prompt, files or [])
45
+
46
+ with tempfile.NamedTemporaryFile(
47
+ "w+", suffix=".txt", delete=False, prefix="codex-last-"
48
+ ) as tmp:
49
+ last_msg_path = Path(tmp.name)
50
+
51
+ cmd = [
52
+ "codex",
53
+ "exec",
54
+ "--json",
55
+ "--skip-git-repo-check",
56
+ "--cd",
57
+ str(cwd),
58
+ "-o",
59
+ str(last_msg_path),
60
+ ]
61
+ if model:
62
+ cmd += ["-m", model]
63
+ cmd.append(full_prompt)
64
+ t0 = time.monotonic()
65
+ proc = subprocess.Popen(
66
+ cmd,
67
+ stdout=subprocess.PIPE,
68
+ stderr=subprocess.PIPE,
69
+ text=True,
70
+ bufsize=1, # line-buffered
71
+ )
72
+ thread_id, last_message_streamed, error_text, stdout_buf = self._consume_stream(
73
+ proc, on_session_started
74
+ )
75
+ stderr_buf = proc.stderr.read() if proc.stderr else ""
76
+ proc.wait()
77
+ duration = time.monotonic() - t0
78
+
79
+ last_message = ""
80
+ if last_msg_path.exists():
81
+ last_message = last_msg_path.read_text(encoding="utf-8").strip()
82
+ last_msg_path.unlink(missing_ok=True)
83
+ if not last_message:
84
+ last_message = last_message_streamed
85
+
86
+ # Codex reports invalid-model / auth / server failures via a `turn.failed`
87
+ # or `error` stream event while still exiting 0 and writing an EMPTY
88
+ # last-message file — without this the dispatch would look "done" with no
89
+ # output. Surface it as a failure so the coach reroutes instead of
90
+ # silently getting nothing back. (A non-empty message means the turn
91
+ # recovered, so only raise when we truly have no answer.)
92
+ if error_text and not last_message:
93
+ raise RuntimeError(f"codex turn failed: {error_text}")
94
+
95
+ # Codex emits a non-fatal "failed to record rollout items" warning when the
96
+ # agent shuts down; the answer is still written. Gate on the message we
97
+ # actually hold, not on the file — it has been consumed and unlinked above.
98
+ if proc.returncode != 0 and not last_message:
99
+ raise RuntimeError(
100
+ f"codex failed (exit {proc.returncode}): "
101
+ f"{stderr_buf.strip() or error_text or stdout_buf[:500]}"
102
+ )
103
+
104
+ if thread_id is None:
105
+ raise RuntimeError(
106
+ f"codex stdout missing thread.started event:\n{stdout_buf[:500]}"
107
+ )
108
+
109
+ return DispatchResult(
110
+ agent_session_id=thread_id,
111
+ cwd=str(cwd),
112
+ session_file=self.find_session_file(thread_id, cwd),
113
+ initial_output=last_message,
114
+ cost_usd=None, # Codex JSON does not expose USD cost
115
+ duration_seconds=duration,
116
+ exit_code=proc.returncode,
117
+ )
118
+
119
+ @staticmethod
120
+ def _consume_stream(
121
+ proc: subprocess.Popen,
122
+ on_session_started: SessionStartedCallback | None,
123
+ ) -> tuple[str | None, str, str, str]:
124
+ """Read stdout line-by-line, fire callback on thread.started, capture
125
+ the latest agent_message item and any failure event.
126
+
127
+ Returns (thread_id, last_message, error_text, raw_stdout). `error_text`
128
+ is the message from a `turn.failed`/`error`/`item.type == "error"` event
129
+ (invalid model, auth, upstream 4xx/5xx) — codex emits these while still
130
+ exiting 0, so callers must inspect it, not just the return code.
131
+ """
132
+ thread_id: str | None = None
133
+ last_message = ""
134
+ error_text = ""
135
+ buf_parts: list[str] = []
136
+ assert proc.stdout is not None
137
+ for raw in proc.stdout:
138
+ buf_parts.append(raw)
139
+ line = raw.strip()
140
+ if not line:
141
+ continue
142
+ try:
143
+ obj = json.loads(line)
144
+ except json.JSONDecodeError:
145
+ continue
146
+ etype = obj.get("type")
147
+ if thread_id is None and etype == "thread.started":
148
+ tid = obj.get("thread_id")
149
+ if isinstance(tid, str) and tid:
150
+ thread_id = tid
151
+ if on_session_started is not None:
152
+ try:
153
+ on_session_started(tid)
154
+ except Exception:
155
+ # callback failures must not break dispatch
156
+ pass
157
+ elif etype == "item.completed":
158
+ item = obj.get("item") or {}
159
+ if item.get("type") == "agent_message" and item.get("text"):
160
+ last_message = item["text"]
161
+ elif etype in ("error", "turn.failed"):
162
+ error_text = CodexHandler._extract_error(obj) or error_text
163
+ return thread_id, last_message, error_text, "".join(buf_parts)
164
+
165
+ @staticmethod
166
+ def _extract_error(obj: dict) -> str:
167
+ """Pull a human-readable message out of a codex error/turn.failed event.
168
+
169
+ The message field often nests a JSON string
170
+ ({"error":{"message": "..."}}); unwrap it to the innermost message.
171
+ """
172
+ raw = ""
173
+ err = obj.get("error")
174
+ if isinstance(err, dict):
175
+ raw = err.get("message") or err.get("type") or ""
176
+ elif isinstance(err, str):
177
+ raw = err
178
+ if not raw:
179
+ raw = obj.get("message") or ""
180
+ candidate = raw.strip()
181
+ for _ in range(3):
182
+ if not (candidate.startswith("{") and candidate.endswith("}")):
183
+ break
184
+ try:
185
+ inner = json.loads(candidate)
186
+ except (json.JSONDecodeError, ValueError):
187
+ break
188
+ nested = inner.get("error") if isinstance(inner, dict) else None
189
+ if isinstance(nested, dict) and nested.get("message"):
190
+ candidate = str(nested["message"]).strip()
191
+ elif isinstance(inner, dict) and inner.get("message"):
192
+ candidate = str(inner["message"]).strip()
193
+ else:
194
+ break
195
+ return candidate
196
+
197
+ def resume(
198
+ self,
199
+ prompt: str,
200
+ cwd: Path,
201
+ agent_session_id: str,
202
+ files: list[Path] | None = None,
203
+ on_session_started: SessionStartedCallback | None = None,
204
+ model: str | None = None,
205
+ ) -> DispatchResult:
206
+ full_prompt = self._build_prompt(prompt, files or [])
207
+
208
+ with tempfile.NamedTemporaryFile(
209
+ "w+", suffix=".txt", delete=False, prefix="codex-last-"
210
+ ) as tmp:
211
+ last_msg_path = Path(tmp.name)
212
+
213
+ # `codex exec resume` has no --cd; cwd flows through subprocess.
214
+ cmd = [
215
+ "codex",
216
+ "exec",
217
+ "resume",
218
+ "--json",
219
+ "--skip-git-repo-check",
220
+ "-o",
221
+ str(last_msg_path),
222
+ ]
223
+ if model:
224
+ cmd += ["-m", model]
225
+ cmd += [agent_session_id, full_prompt]
226
+ t0 = time.monotonic()
227
+ proc = subprocess.Popen(
228
+ cmd,
229
+ cwd=str(cwd),
230
+ stdout=subprocess.PIPE,
231
+ stderr=subprocess.PIPE,
232
+ text=True,
233
+ bufsize=1,
234
+ )
235
+ thread_id, last_message_streamed, error_text, stdout_buf = self._consume_stream(
236
+ proc, on_session_started
237
+ )
238
+ stderr_buf = proc.stderr.read() if proc.stderr else ""
239
+ proc.wait()
240
+ duration = time.monotonic() - t0
241
+
242
+ last_message = ""
243
+ if last_msg_path.exists():
244
+ last_message = last_msg_path.read_text(encoding="utf-8").strip()
245
+ last_msg_path.unlink(missing_ok=True)
246
+ if not last_message:
247
+ last_message = last_message_streamed
248
+
249
+ # See dispatch(): codex reports failures via a stream event while exiting 0.
250
+ if error_text and not last_message:
251
+ raise RuntimeError(f"codex resume turn failed: {error_text}")
252
+
253
+ if proc.returncode != 0 and not last_message:
254
+ raise RuntimeError(
255
+ f"codex resume failed (exit {proc.returncode}): "
256
+ f"{stderr_buf.strip() or error_text or stdout_buf[:500]}"
257
+ )
258
+
259
+ # On resume, codex re-emits thread.started with the SAME thread_id.
260
+ # If the event was missed (older codex builds, transport hiccup), fall
261
+ # back to the input id and fire the callback so the contract holds.
262
+ effective_id = thread_id or agent_session_id
263
+ if thread_id is None and on_session_started is not None:
264
+ try:
265
+ on_session_started(effective_id)
266
+ except Exception:
267
+ pass
268
+
269
+ return DispatchResult(
270
+ agent_session_id=effective_id,
271
+ cwd=str(cwd),
272
+ session_file=self.find_session_file(effective_id, cwd),
273
+ initial_output=last_message,
274
+ cost_usd=None,
275
+ duration_seconds=duration,
276
+ exit_code=proc.returncode,
277
+ )
278
+
279
+ def find_session_file(self, agent_session_id: str, cwd: Path) -> Path | None:
280
+ # filename: rollout-<ISO timestamp>-<thread_id>.jsonl
281
+ matches = sorted(
282
+ CODEX_SESSIONS_ROOT.rglob(f"rollout-*-{agent_session_id}.jsonl"),
283
+ key=lambda p: p.stat().st_mtime,
284
+ reverse=True,
285
+ )
286
+ return matches[0] if matches else None
287
+
288
+ def parse_session_file(self, path: Path) -> list[Turn]:
289
+ """Read Codex rollout jsonl and yield user/assistant/tool turns.
290
+
291
+ Codex line shape: {timestamp, type, payload}. Relevant types:
292
+ - response_item with payload.type == "message" → user/assistant text
293
+ - response_item with payload.type == "function_call" → tool_call
294
+ - response_item with payload.type == "function_call_output" → tool_result
295
+ - event_msg with payload.type == "task_complete" → carries the
296
+ last_agent_message (already produced by some 'message' item)
297
+
298
+ Reasoning items are ignored by default — they're verbose and the
299
+ coach reads them only when actively debugging.
300
+ """
301
+ from datetime import datetime
302
+
303
+ turns: list[Turn] = []
304
+ if not path.exists():
305
+ return turns
306
+ with path.open("r", encoding="utf-8") as fh:
307
+ for raw in fh:
308
+ raw = raw.strip()
309
+ if not raw:
310
+ continue
311
+ try:
312
+ obj = json.loads(raw)
313
+ except json.JSONDecodeError:
314
+ continue
315
+ if obj.get("type") != "response_item":
316
+ continue
317
+ payload = obj.get("payload") or {}
318
+ ptype = payload.get("type")
319
+
320
+ ts = None
321
+ ts_raw = obj.get("timestamp")
322
+ if isinstance(ts_raw, str):
323
+ try:
324
+ ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
325
+ except ValueError:
326
+ pass
327
+
328
+ if ptype == "message":
329
+ role = payload.get("role") or "user"
330
+ if role == "developer":
331
+ # System scaffolding from Codex (skills/permissions); skip.
332
+ continue
333
+ content_blocks = payload.get("content") or []
334
+ texts: list[str] = []
335
+ for block in content_blocks:
336
+ if not isinstance(block, dict):
337
+ continue
338
+ text = block.get("text") or ""
339
+ if text:
340
+ texts.append(text)
341
+ turns.append(
342
+ Turn(
343
+ role=role,
344
+ content="\n".join(texts),
345
+ timestamp=ts,
346
+ )
347
+ )
348
+ elif ptype == "function_call":
349
+ turns.append(
350
+ Turn(
351
+ role="assistant",
352
+ content="",
353
+ tool_calls=[
354
+ {
355
+ "id": payload.get("call_id") or payload.get("id"),
356
+ "name": payload.get("name"),
357
+ "input": payload.get("arguments"),
358
+ }
359
+ ],
360
+ timestamp=ts,
361
+ )
362
+ )
363
+ elif ptype == "function_call_output":
364
+ output = payload.get("output")
365
+ turns.append(
366
+ Turn(
367
+ role="tool",
368
+ content="",
369
+ tool_results=[
370
+ {
371
+ "tool_use_id": payload.get("call_id"),
372
+ "content": output
373
+ if isinstance(output, str)
374
+ else json.dumps(output) if output else "",
375
+ }
376
+ ],
377
+ timestamp=ts,
378
+ )
379
+ )
380
+ return turns
381
+
382
+ @staticmethod
383
+ def _build_prompt(prompt: str, files: list[Path]) -> str:
384
+ if not files:
385
+ return prompt
386
+ refs = "\n".join(f"@{p}" for p in files)
387
+ return f"{prompt}\n\n{refs}"