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.
- {codexapi-0.10.0 → codexapi-0.11.0}/PKG-INFO +12 -1
- codexapi-0.10.0/src/codexapi.egg-info/PKG-INFO → codexapi-0.11.0/README.md +11 -15
- {codexapi-0.10.0 → codexapi-0.11.0}/pyproject.toml +1 -1
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/__init__.py +1 -1
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/agents.py +371 -13
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/cli.py +91 -0
- codexapi-0.10.0/README.md → codexapi-0.11.0/src/codexapi.egg-info/PKG-INFO +26 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_agents.py +587 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/LICENSE +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/setup.cfg +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/__main__.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/agent.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/foreach.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/gh_integration.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/lead.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/pushover.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/ralph.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/rate_limits.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/science.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/task.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/taskfile.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi/welfare.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/SOURCES.txt +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/dependency_links.txt +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/entry_points.txt +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/requires.txt +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/src/codexapi.egg-info/top_level.txt +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_agent_backend.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_rate_limits.py +0 -0
- {codexapi-0.10.0 → codexapi-0.11.0}/tests/test_science.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
610
|
-
|
|
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")
|
|
864
|
+
if state.get("status") in ("paused", "done"):
|
|
771
865
|
state["status"] = "ready"
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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}"
|