codexapi 0.10.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (31) hide show
  1. {codexapi-0.10.0 → codexapi-0.11.0}/PKG-INFO +12 -1
  2. codexapi-0.10.0/src/codexapi.egg-info/PKG-INFO → codexapi-0.11.0/README.md +11 -15
  3. {codexapi-0.10.0 → codexapi-0.11.0}/pyproject.toml +1 -1
  4. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/__init__.py +1 -1
  5. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/agents.py +371 -13
  6. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/cli.py +91 -0
  7. codexapi-0.10.0/README.md → codexapi-0.11.0/src/codexapi.egg-info/PKG-INFO +26 -0
  8. {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_agents.py +587 -0
  9. {codexapi-0.10.0 → codexapi-0.11.0}/LICENSE +0 -0
  10. {codexapi-0.10.0 → codexapi-0.11.0}/setup.cfg +0 -0
  11. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/__main__.py +0 -0
  12. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/agent.py +0 -0
  13. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/foreach.py +0 -0
  14. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/gh_integration.py +0 -0
  15. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/lead.py +0 -0
  16. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/pushover.py +0 -0
  17. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/ralph.py +0 -0
  18. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/rate_limits.py +0 -0
  19. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/science.py +0 -0
  20. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/task.py +0 -0
  21. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/taskfile.py +0 -0
  22. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/welfare.py +0 -0
  23. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/SOURCES.txt +0 -0
  24. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/dependency_links.txt +0 -0
  25. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/entry_points.txt +0 -0
  26. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/requires.txt +0 -0
  27. {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/top_level.txt +0 -0
  28. {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_agent_backend.py +0 -0
  29. {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_rate_limits.py +0 -0
  30. {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_science.py +0 -0
  31. {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_task_progress.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -203,6 +203,8 @@ Inspect and talk to agents:
203
203
  ```bash
204
204
  codexapi agent list
205
205
  codexapi agent show ci-fixer
206
+ codexapi agent status ci-fixer
207
+ codexapi agent status --actions ci-fixer
206
208
  codexapi agent read ci-fixer
207
209
  codexapi agent book ci-fixer
208
210
  codexapi agent send ci-fixer "Prefer the smallest safe fix."
@@ -212,10 +214,15 @@ codexapi agent wake --wait ci-fixer
212
214
  codexapi agent pause ci-fixer
213
215
  codexapi agent resume ci-fixer
214
216
  codexapi agent resume --wait ci-fixer
217
+ codexapi agent set-heartbeat ci-fixer 30
215
218
  codexapi agent cancel ci-fixer
216
219
  codexapi agent delete ci-fixer
217
220
  ```
218
221
 
222
+ `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or
223
+ `canceled` agent still triggers a one-off wake on the next tick so you can get
224
+ a reply without putting the agent back into continuous heartbeat mode.
225
+
219
226
  Create a child agent explicitly:
220
227
 
221
228
  ```bash
@@ -236,6 +243,10 @@ wrappers report inconsistent hostnames for the same machine.
236
243
 
237
244
  `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
238
245
  jump directly to the durable working memory file.
246
+ `codexapi agent status` reads the latest turn from the agent's rollout log and
247
+ shows recent commentary plus the final visible output. Pass `--actions` to
248
+ include the tool-action summary. If a wake is still in progress, it shows the
249
+ active turn so far.
239
250
 
240
251
  See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling
241
252
  details.
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: codexapi
3
- Version: 0.10.0
4
- Summary: Minimal Python API for running the Codex CLI.
5
- License: MIT
6
- Keywords: codex,agent,cli,openai
7
- Classifier: Programming Language :: Python :: 3
8
- Classifier: Operating System :: OS Independent
9
- Requires-Python: >=3.8
10
- Description-Content-Type: text/markdown
11
- License-File: LICENSE
12
- Requires-Dist: PyYAML>=6.0
13
- Requires-Dist: gh-task>=0.1.7
14
- Requires-Dist: tqdm>=4.64
15
-
16
1
  # CodexAPI
17
2
 
18
3
  Use Codex or Cursor agents from python as easily as calling a function, using your CLI auth instead of the API.
@@ -203,6 +188,8 @@ Inspect and talk to agents:
203
188
  ```bash
204
189
  codexapi agent list
205
190
  codexapi agent show ci-fixer
191
+ codexapi agent status ci-fixer
192
+ codexapi agent status --actions ci-fixer
206
193
  codexapi agent read ci-fixer
207
194
  codexapi agent book ci-fixer
208
195
  codexapi agent send ci-fixer "Prefer the smallest safe fix."
@@ -212,10 +199,15 @@ codexapi agent wake --wait ci-fixer
212
199
  codexapi agent pause ci-fixer
213
200
  codexapi agent resume ci-fixer
214
201
  codexapi agent resume --wait ci-fixer
202
+ codexapi agent set-heartbeat ci-fixer 30
215
203
  codexapi agent cancel ci-fixer
216
204
  codexapi agent delete ci-fixer
217
205
  ```
218
206
 
207
+ `codexapi agent resume` can reopen a `done` agent. Sending to a `done` or
208
+ `canceled` agent still triggers a one-off wake on the next tick so you can get
209
+ a reply without putting the agent back into continuous heartbeat mode.
210
+
219
211
  Create a child agent explicitly:
220
212
 
221
213
  ```bash
@@ -236,6 +228,10 @@ wrappers report inconsistent hostnames for the same machine.
236
228
 
237
229
  `codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
238
230
  jump directly to the durable working memory file.
231
+ `codexapi agent status` reads the latest turn from the agent's rollout log and
232
+ shows recent commentary plus the final visible output. Pass `--actions` to
233
+ include the tool-action summary. If a wake is still in progress, it shows the
234
+ active turn so far.
239
235
 
240
236
  See [docs/agent-v1.md](docs/agent-v1.md) for the filesystem model and scheduling
241
237
  details.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -27,4 +27,4 @@ __all__ = [
27
27
  "task_result",
28
28
  "lead",
29
29
  ]
30
- __version__ = "0.10.0"
30
+ __version__ = "0.11.0"
@@ -227,6 +227,48 @@ def show_agent(agent_ref, home=None):
227
227
  return snapshot
228
228
 
229
229
 
230
+ def status_agent(agent_ref, home=None, include_actions=False):
231
+ """Return detailed transcript status for the latest agent turn."""
232
+ home = _resolve_home(home)
233
+ child_map = _child_map(home)
234
+ agent_dir = resolve_agent_dir(agent_ref, home)
235
+ snapshot = _snapshot(agent_dir, child_map)
236
+ session = _read_session(agent_dir)
237
+ rollout_path = _resolve_rollout_path(
238
+ session.get("rollout_path"),
239
+ session.get("thread_id") or snapshot.get("thread_id") or "",
240
+ )
241
+ result = {
242
+ "id": snapshot["id"],
243
+ "name": snapshot["name"],
244
+ "agent_status": snapshot["status"],
245
+ "thread_id": session.get("thread_id") or snapshot.get("thread_id") or "",
246
+ "rollout_path": str(rollout_path) if rollout_path else "",
247
+ "turn_id": "",
248
+ "turn_state": "missing",
249
+ "started_at": "",
250
+ "ended_at": "",
251
+ "cwd": snapshot.get("cwd") or "",
252
+ "progress": [],
253
+ "tools": [],
254
+ "final_output": "",
255
+ "final_json": None,
256
+ }
257
+ if rollout_path is None or not rollout_path.exists():
258
+ return result
259
+ events = _rollout_events(rollout_path)
260
+ turn = _last_rollout_turn(events, include_actions)
261
+ if turn is None:
262
+ return result
263
+ run_lock_path = agent_dir / "hosts" / snapshot["hostname"] / "run.lock"
264
+ turn_state = "complete"
265
+ if not turn["ended_at"]:
266
+ turn_state = "active" if _run_lock_held(run_lock_path) else "interrupted"
267
+ result.update(turn)
268
+ result["turn_state"] = turn_state
269
+ return result
270
+
271
+
230
272
  def read_agent(agent_ref, limit=10, home=None):
231
273
  """Return recent user-visible communication for an agent."""
232
274
  agent_dir = resolve_agent_dir(agent_ref, home)
@@ -319,6 +361,51 @@ def control_agent(agent_ref, kind, author=None, home=None, hostname=None, now=No
319
361
  return _queue_command(agent_ref, kind, "", author, home, hostname, now)
320
362
 
321
363
 
364
+ def set_agent_heartbeat(agent_ref, heartbeat_minutes, home=None, now=None):
365
+ """Update an agent heartbeat interval."""
366
+ if heartbeat_minutes < 0:
367
+ raise ValueError("heartbeat_minutes must be >= 0")
368
+ home = _resolve_home(home)
369
+ now = now or utc_now()
370
+ agent_dir = resolve_agent_dir(agent_ref, home)
371
+ meta_path = agent_dir / "meta.json"
372
+ state_path = agent_dir / "state.json"
373
+ meta = _read_json(meta_path)
374
+ state = _read_json(state_path)
375
+ new_minutes = int(heartbeat_minutes)
376
+ old_minutes = int(meta.get("heartbeat_minutes") or 0)
377
+ changed = old_minutes != new_minutes
378
+ if changed:
379
+ meta["heartbeat_minutes"] = new_minutes
380
+ _write_json(meta_path, meta)
381
+ run_lock_path = agent_dir / "hosts" / meta["hostname"] / "run.lock"
382
+ running = _run_lock_held(run_lock_path)
383
+ rescheduled = False
384
+ if (
385
+ not running
386
+ and state.get("status") in ("ready", "error")
387
+ and not state.get("wake_requested_at")
388
+ and state.get("next_wake_at")
389
+ ):
390
+ state["next_wake_at"] = format_utc(
391
+ now + timedelta(minutes=new_minutes)
392
+ )
393
+ _write_json(state_path, state)
394
+ rescheduled = True
395
+ return {
396
+ "id": meta["id"],
397
+ "name": meta["name"],
398
+ "status": state.get("status") or "",
399
+ "old_heartbeat_minutes": old_minutes,
400
+ "heartbeat_minutes": new_minutes,
401
+ "changed": changed,
402
+ "running": running,
403
+ "rescheduled": rescheduled,
404
+ "applies_after_current_run": bool(running),
405
+ "next_wake_at": state.get("next_wake_at") or "",
406
+ }
407
+
408
+
322
409
  def delete_agent(agent_ref, force=False, home=None):
323
410
  """Delete one agent directory when it is safe to do so."""
324
411
  home = _resolve_home(home)
@@ -522,15 +609,16 @@ def _tick_agent(agent_dir, now, runner):
522
609
  _sync_state_from_session(state, session)
523
610
  _write_json(session_path, session)
524
611
  _write_json(agent_dir / "state.json", state)
525
- if state.get("status") not in ("ready", "error"):
612
+ terminal_status = _one_shot_terminal_status(state)
613
+ if state.get("status") not in ("ready", "error") and not terminal_status:
526
614
  return {"processed": bool(commands), "woken": False}
527
615
  if not _is_due(state, now):
528
616
  return {"processed": bool(commands), "woken": False}
529
- _wake_agent(agent_dir, meta, state, session, now, commands, runner)
617
+ _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status)
530
618
  return {"processed": True, "woken": True}
531
619
 
532
620
 
533
- def _wake_agent(agent_dir, meta, state, session, now, commands, runner):
621
+ def _wake_agent(agent_dir, meta, state, session, now, commands, runner, terminal_status=""):
534
622
  prompt = _build_wake_prompt(meta, state, session, now, commands, agent_dir)
535
623
  state["status"] = "running"
536
624
  state["last_wake_at"] = format_utc(now)
@@ -578,7 +666,10 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner):
578
666
  state["thread_id"] = session["thread_id"]
579
667
  state["wake_requested_at"] = ""
580
668
  state["activity"] = response["status"]
581
- if response["continue"]:
669
+ if terminal_status:
670
+ state["status"] = terminal_status
671
+ state["next_wake_at"] = ""
672
+ elif response["continue"]:
582
673
  state["status"] = "ready"
583
674
  state["next_wake_at"] = format_utc(
584
675
  ended + timedelta(minutes=meta["heartbeat_minutes"])
@@ -602,13 +693,16 @@ def _wake_agent(agent_dir, meta, state, session, now, commands, runner):
602
693
  Pushover().send(title, response["notify"])
603
694
  except Exception as exc:
604
695
  ended = utc_now()
605
- state["status"] = "error"
696
+ state["status"] = terminal_status or "error"
606
697
  state["last_error"] = _single_line(str(exc)) or exc.__class__.__name__
607
698
  state["activity"] = state["last_error"]
608
699
  state["wake_requested_at"] = ""
609
- state["next_wake_at"] = format_utc(
610
- ended + timedelta(minutes=meta["heartbeat_minutes"])
611
- )
700
+ if terminal_status:
701
+ state["next_wake_at"] = ""
702
+ else:
703
+ state["next_wake_at"] = format_utc(
704
+ ended + timedelta(minutes=meta["heartbeat_minutes"])
705
+ )
612
706
  _sync_state_from_session(state, session)
613
707
  _write_json(agent_dir / "hosts" / meta["hostname"] / "session.json", session)
614
708
  _write_json(agent_dir / "state.json", state)
@@ -767,11 +861,11 @@ def _apply_commands(meta, state, session, commands, now):
767
861
  state["activity"] = "Paused"
768
862
  changed = True
769
863
  elif kind == "resume":
770
- if state.get("status") == "paused":
864
+ if state.get("status") in ("paused", "done"):
771
865
  state["status"] = "ready"
772
- state["wake_requested_at"] = format_utc(now)
773
- state["activity"] = "Resumed"
774
- changed = True
866
+ state["wake_requested_at"] = format_utc(now)
867
+ state["activity"] = "Resumed"
868
+ changed = True
775
869
  elif kind == "cancel":
776
870
  state["status"] = "canceled"
777
871
  state["activity"] = "Canceled"
@@ -792,6 +886,8 @@ def _apply_commands(meta, state, session, commands, now):
792
886
 
793
887
  def _is_due(state, now):
794
888
  status = state.get("status")
889
+ if status in ("done", "canceled") and int(state.get("unread_message_count") or 0) > 0:
890
+ return True
795
891
  if status not in ("ready", "error"):
796
892
  return False
797
893
  if state.get("wake_requested_at"):
@@ -804,6 +900,16 @@ def _is_due(state, now):
804
900
  return False
805
901
 
806
902
 
903
+ def _one_shot_terminal_status(state):
904
+ """Return the terminal status when a one-off message wake should run."""
905
+ status = state.get("status") or ""
906
+ if status not in _TERMINAL_STATES:
907
+ return ""
908
+ if int(state.get("unread_message_count") or 0) < 1:
909
+ return ""
910
+ return status
911
+
912
+
807
913
  def _write_run(agent_dir, hostname, payload):
808
914
  runs_dir = agent_dir / "hosts" / hostname / "runs"
809
915
  filename = f"{payload['id']}.json"
@@ -1282,8 +1388,10 @@ def _resolve_rollout_path(known_path, thread_id):
1282
1388
  """Return the rollout file for a thread, preferring the cached session path."""
1283
1389
  if known_path:
1284
1390
  path = Path(known_path)
1285
- if path.exists() and thread_id in path.name:
1391
+ if path.exists() and (not thread_id or thread_id in path.name):
1286
1392
  return path
1393
+ if not thread_id:
1394
+ return None
1287
1395
  root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "sessions"
1288
1396
  if not root.exists():
1289
1397
  return None
@@ -1335,6 +1443,256 @@ def _extract_rollout_usage(path, started_at):
1335
1443
  return latest
1336
1444
 
1337
1445
 
1446
+ def _rollout_events(path):
1447
+ """Return parsed JSONL events from one rollout file."""
1448
+ events = []
1449
+ try:
1450
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
1451
+ for line in handle:
1452
+ line = line.strip()
1453
+ if not line:
1454
+ continue
1455
+ try:
1456
+ event = json.loads(line)
1457
+ except json.JSONDecodeError:
1458
+ continue
1459
+ if isinstance(event, dict):
1460
+ events.append(event)
1461
+ except OSError:
1462
+ return []
1463
+ return events
1464
+
1465
+
1466
+ def _last_rollout_turn(events, include_actions=False):
1467
+ """Return the latest task_started slice from rollout events."""
1468
+ turn_events = []
1469
+ for event in events:
1470
+ payload = event.get("payload") or {}
1471
+ if event.get("type") == "event_msg" and payload.get("type") == "task_started":
1472
+ turn_events = [event]
1473
+ continue
1474
+ if turn_events:
1475
+ turn_events.append(event)
1476
+ if not turn_events:
1477
+ return None
1478
+
1479
+ started = turn_events[0]
1480
+ started_payload = started.get("payload") or {}
1481
+ progress_events = []
1482
+ assistant_events = []
1483
+ tools = []
1484
+ tool_by_call_id = {}
1485
+ ended_at = ""
1486
+ task_complete_message = ""
1487
+
1488
+ for event in turn_events:
1489
+ payload = event.get("payload") or {}
1490
+ event_type = event.get("type")
1491
+ payload_type = payload.get("type")
1492
+ if event_type == "event_msg":
1493
+ if payload_type == "agent_message":
1494
+ item = {
1495
+ "text": str(payload.get("message") or "").strip(),
1496
+ "phase": payload.get("phase") or "",
1497
+ }
1498
+ if item["text"]:
1499
+ progress_events.append(item)
1500
+ elif payload_type == "task_complete":
1501
+ ended_at = event.get("timestamp") or ""
1502
+ task_complete_message = str(payload.get("last_agent_message") or "").strip()
1503
+ elif event_type == "response_item":
1504
+ if payload_type == "message" and payload.get("role") == "assistant":
1505
+ text = _response_message_text(payload)
1506
+ if text:
1507
+ assistant_events.append(
1508
+ {
1509
+ "text": text,
1510
+ "phase": payload.get("phase") or "",
1511
+ }
1512
+ )
1513
+ elif include_actions and payload_type in ("function_call", "custom_tool_call"):
1514
+ tool = _rollout_tool_call(payload)
1515
+ if tool is None:
1516
+ continue
1517
+ tools.append(tool)
1518
+ call_id = tool.get("call_id") or ""
1519
+ if call_id:
1520
+ tool_by_call_id[call_id] = tool
1521
+ elif include_actions and payload_type in ("function_call_output", "custom_tool_call_output"):
1522
+ tool = tool_by_call_id.get(payload.get("call_id") or "")
1523
+ if tool is not None:
1524
+ _apply_rollout_tool_output(tool, payload)
1525
+
1526
+ visible = progress_events or assistant_events
1527
+ progress = [item["text"] for item in visible]
1528
+ final_output = visible[-1]["text"] if visible else task_complete_message
1529
+ final_json = None
1530
+ if final_output:
1531
+ final_json = _parse_rollout_final_json(final_output)
1532
+ if progress and progress[-1] == final_output and (
1533
+ final_json is not None or (visible[-1].get("phase") or "") == "final_answer"
1534
+ ):
1535
+ progress = progress[:-1]
1536
+
1537
+ if include_actions:
1538
+ for tool in tools:
1539
+ tool["summary"] = _rollout_tool_summary(tool)
1540
+
1541
+ return {
1542
+ "turn_id": started_payload.get("turn_id") or "",
1543
+ "started_at": started.get("timestamp") or "",
1544
+ "ended_at": ended_at,
1545
+ "progress": progress,
1546
+ "tools": tools,
1547
+ "final_output": final_output,
1548
+ "final_json": final_json,
1549
+ }
1550
+
1551
+
1552
+ def _response_message_text(payload):
1553
+ """Return the text content from one assistant response message."""
1554
+ parts = []
1555
+ for item in payload.get("content") or []:
1556
+ if not isinstance(item, dict):
1557
+ continue
1558
+ text = item.get("text")
1559
+ if isinstance(text, str) and text.strip():
1560
+ parts.append(text.strip())
1561
+ return "\n".join(parts).strip()
1562
+
1563
+
1564
+ def _rollout_tool_call(payload):
1565
+ """Return a compact tool-call record for one rollout item."""
1566
+ name = payload.get("name") or ""
1567
+ kind = payload.get("type") or ""
1568
+ call_id = payload.get("call_id") or ""
1569
+ tool = {
1570
+ "call_id": call_id,
1571
+ "kind": kind,
1572
+ "name": name,
1573
+ "command": "",
1574
+ "files": [],
1575
+ "exit_code": None,
1576
+ "output": "",
1577
+ "summary": "",
1578
+ }
1579
+ if kind == "function_call":
1580
+ arguments = _parse_rollout_json(payload.get("arguments"))
1581
+ if isinstance(arguments, dict):
1582
+ tool["command"] = str(arguments.get("cmd") or "").strip()
1583
+ elif kind == "custom_tool_call" and name == "apply_patch":
1584
+ tool["files"] = _patch_targets(payload.get("input") or "")
1585
+ return tool
1586
+
1587
+
1588
+ def _apply_rollout_tool_output(tool, payload):
1589
+ """Fold tool output into a compact rollout tool record."""
1590
+ text, exit_code = _tool_output_details(payload.get("output"))
1591
+ if exit_code is not None:
1592
+ tool["exit_code"] = exit_code
1593
+ tool["output"] = _snippet(text.strip(), 400) if text else ""
1594
+ if tool["name"] == "apply_patch" and not tool["files"]:
1595
+ tool["files"] = _updated_files(text)
1596
+
1597
+
1598
+ def _parse_rollout_json(value):
1599
+ if isinstance(value, dict):
1600
+ return value
1601
+ if not isinstance(value, str) or not value.strip():
1602
+ return None
1603
+ try:
1604
+ return json.loads(value)
1605
+ except json.JSONDecodeError:
1606
+ return None
1607
+
1608
+
1609
+ def _parse_rollout_final_json(text):
1610
+ """Return the normalized final agent JSON when the text matches the contract."""
1611
+ try:
1612
+ return _parse_agent_response(text)
1613
+ except ValueError:
1614
+ return None
1615
+
1616
+
1617
+ def _tool_output_details(output):
1618
+ """Return normalized output text and exit code from a rollout tool result."""
1619
+ text = str(output or "")
1620
+ payload = _parse_rollout_json(text)
1621
+ exit_code = None
1622
+ if isinstance(payload, dict):
1623
+ metadata = payload.get("metadata") or {}
1624
+ exit_code = _usage_int(metadata.get("exit_code"))
1625
+ text = str(payload.get("output") or "")
1626
+ raw = text
1627
+ body = raw
1628
+ if "\nOutput:\n" in raw:
1629
+ body = raw.split("\nOutput:\n", 1)[1]
1630
+ elif raw.startswith("Output:\n"):
1631
+ body = raw.split("Output:\n", 1)[1]
1632
+ for line in raw.splitlines():
1633
+ if not line.startswith("Process exited with code "):
1634
+ continue
1635
+ tail = line.rsplit(" ", 1)[-1].strip()
1636
+ if tail.startswith("-"):
1637
+ tail = tail[1:]
1638
+ if tail.isdigit():
1639
+ exit_code = int(line.rsplit(" ", 1)[-1].strip())
1640
+ break
1641
+ return body.strip(), exit_code
1642
+
1643
+
1644
+ def _rollout_tool_summary(tool):
1645
+ """Return one readable summary line for a tool action."""
1646
+ name = tool.get("name") or ""
1647
+ exit_code = tool.get("exit_code")
1648
+ suffix = ""
1649
+ if exit_code is not None:
1650
+ suffix = f" (exit {exit_code})"
1651
+ if name == "exec_command":
1652
+ command = _single_line(_snippet(tool.get("command") or "", 140))
1653
+ if command:
1654
+ return f"Running command: {command}{suffix}"
1655
+ return f"Running command{suffix}"
1656
+ if name == "apply_patch":
1657
+ files = tool.get("files") or []
1658
+ if files:
1659
+ label = ", ".join(files[:3])
1660
+ if len(files) > 3:
1661
+ label += ", ..."
1662
+ return f"Editing files: {label}{suffix}"
1663
+ return f"Editing files{suffix}"
1664
+ if name:
1665
+ return f"{name}{suffix}"
1666
+ return f"tool{suffix}"
1667
+
1668
+
1669
+ def _patch_targets(text):
1670
+ """Return patch target files from an apply_patch input."""
1671
+ files = []
1672
+ for line in str(text or "").splitlines():
1673
+ for prefix in ("*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "):
1674
+ if not line.startswith(prefix):
1675
+ continue
1676
+ target = line[len(prefix) :].strip()
1677
+ if target and target not in files:
1678
+ files.append(target)
1679
+ return files
1680
+
1681
+
1682
+ def _updated_files(text):
1683
+ """Return file paths mentioned in apply_patch output."""
1684
+ files = []
1685
+ for line in str(text or "").splitlines():
1686
+ line = line.strip()
1687
+ if not line or line == "Success. Updated the following files:":
1688
+ continue
1689
+ if line.startswith(("M ", "A ", "D ")):
1690
+ target = line[2:].strip()
1691
+ if target and target not in files:
1692
+ files.append(target)
1693
+ return files
1694
+
1695
+
1338
1696
  def _cron_tag(home, hostname):
1339
1697
  key = sha1(str(home).encode("utf-8")).hexdigest()[:12]
1340
1698
  return f"codexapi-agent::{hostname}::{key}"