python-codex 0.2.7__py3-none-any.whl → 0.3.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.
Files changed (84) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,20 +2,23 @@ import json
2
2
  import mmap
3
3
  import os
4
4
  import re
5
+ import typing
6
+ from contextlib import closing
5
7
  from datetime import datetime
6
8
  from pathlib import Path
7
9
 
8
10
  from ..protocol import (
9
11
  AssistantMessage,
12
+ ContextMessage,
10
13
  ConversationItem,
11
14
  ReasoningItem,
12
15
  ToolCall,
13
16
  ToolResult,
14
17
  UserMessage,
15
18
  )
19
+ from .compactor import SUMMARY_PREFIX
20
+ from .event_helpers import shorten_title
16
21
  from .get_env import get_package_version
17
- from .visualize import shorten_title
18
- import typing
19
22
 
20
23
  SESSION_INDEX_FILENAME = "session_index.jsonl"
21
24
  ROLLUP_SESSION_DIRNAMES = ("sessions", "archived_sessions")
@@ -29,8 +32,8 @@ ROLLOUT_RECORD_PREFIX = b'\n{"timestamp":"'
29
32
 
30
33
 
31
34
  def resolve_codex_home(
32
- config_path: 'typing.Union[str, None]' = None,
33
- ) -> 'Path':
35
+ config_path: "typing.Union[str, None]" = None,
36
+ ) -> "Path":
34
37
  if config_path:
35
38
  return Path(config_path).expanduser().resolve().parent
36
39
  codex_home = os.environ.get("CODEX_HOME", "").strip()
@@ -40,45 +43,32 @@ def resolve_codex_home(
40
43
 
41
44
 
42
45
  class SessionRolloutRecorder:
43
- def __init__(self, rollout_path: 'Path') -> 'None':
46
+ def __init__(self, rollout_path: "Path") -> "None":
44
47
  self.rollout_path = rollout_path
48
+ self._session_meta: "typing.Union[typing.Dict[str, object], None]" = None
45
49
 
46
50
  @classmethod
47
51
  def create(
48
52
  cls,
49
- codex_home: 'Path',
50
- session_id: 'str',
51
- cwd: 'Path',
52
- originator: 'str',
53
- model_provider: 'typing.Union[str, None]',
54
- base_instructions: 'str',
55
- ) -> 'SessionRolloutRecorder':
56
- recorder = cls(_rollout_path_for_session(codex_home, session_id))
57
- recorder.write_session_meta(
58
- session_id=session_id,
59
- cwd=cwd,
60
- originator=originator,
61
- model_provider=model_provider,
62
- base_instructions=base_instructions,
53
+ codex_home: "Path",
54
+ session_id: "str",
55
+ cwd: "Path",
56
+ originator: "str",
57
+ model_provider: "typing.Union[str, None]",
58
+ base_instructions: "str",
59
+ session_file_path: "typing.Union[str, Path, None]" = None,
60
+ ) -> "SessionRolloutRecorder":
61
+ path = (
62
+ Path(session_file_path)
63
+ if session_file_path is not None
64
+ else rollout_path_for_session(codex_home, session_id)
63
65
  )
64
- return recorder
65
-
66
- @classmethod
67
- def resume(
68
- cls,
69
- rollout_path: 'typing.Union[str, Path]',
70
- ) -> 'SessionRolloutRecorder':
71
- return cls(Path(rollout_path))
72
-
73
- def write_session_meta(
74
- self,
75
- session_id: 'str',
76
- cwd: 'Path',
77
- originator: 'str',
78
- model_provider: 'typing.Union[str, None]',
79
- base_instructions: 'str',
80
- ) -> 'None':
81
- payload = {
66
+ recorder = cls(path.expanduser().resolve())
67
+ if recorder.rollout_path.exists():
68
+ raise FileExistsError(
69
+ "session file already exists: {0}".format(recorder.rollout_path)
70
+ )
71
+ recorder._session_meta = {
82
72
  "id": session_id,
83
73
  "timestamp": _timestamp_string(),
84
74
  "cwd": str(cwd),
@@ -88,68 +78,92 @@ class SessionRolloutRecorder:
88
78
  "model_provider": model_provider,
89
79
  "base_instructions": {"text": base_instructions},
90
80
  }
91
- self._append_line("session_meta", payload)
81
+ return recorder
82
+
83
+ @classmethod
84
+ def resume(
85
+ cls,
86
+ rollout_path: "typing.Union[str, Path]",
87
+ ) -> "SessionRolloutRecorder":
88
+ return cls(Path(rollout_path).expanduser().resolve())
92
89
 
93
90
  def append_history_items(
94
91
  self,
95
- items: 'typing.Iterable[ConversationItem]',
96
- ) -> 'None':
92
+ items: "typing.Iterable[ConversationItem]",
93
+ initial_history: "typing.Iterable[ConversationItem]" = (),
94
+ ) -> "None":
95
+ self._append_records(self._history_records(items), initial_history)
96
+
97
+ @staticmethod
98
+ def _history_records(
99
+ items: "typing.Iterable[ConversationItem]",
100
+ ) -> "typing.Iterable[typing.Tuple[str, typing.Dict[str, object]]]":
101
+ history = []
97
102
  for item in items:
98
- self.append_history_item(item)
99
-
100
- def append_history_item(self, item: 'ConversationItem') -> 'None':
101
- if isinstance(item, UserMessage):
102
- self._append_line("response_item", item.serialize())
103
- self._append_line(
104
- "event_msg",
105
- {
103
+ history.append(item)
104
+ if isinstance(item, ContextMessage) and item.role == "user":
105
+ # Preserve a fork's compact summary as context when replayed.
106
+ yield "compacted", _compacted_payload(history)
107
+ continue
108
+ serialized = item.serialize()
109
+ if isinstance(serialized, dict):
110
+ yield "response_item", serialized
111
+ if isinstance(item, UserMessage):
112
+ yield "event_msg", {
106
113
  "type": "user_message",
107
114
  "message": item.text,
108
115
  "images": [],
109
116
  "local_images": [],
110
117
  "text_elements": [],
111
- },
112
- )
113
- return
114
- if isinstance(item, ToolResult):
115
- self._append_line("response_item", item.serialize())
116
- return
117
- serialized = item.serialize()
118
- if isinstance(serialized, dict):
119
- self._append_line("response_item", serialized)
118
+ }
120
119
 
121
120
  def append_compacted_history(
122
121
  self,
123
- history: 'typing.Iterable[ConversationItem]',
124
- ) -> 'None':
125
- serialized_items = []
126
- for item in history:
127
- serialized = item.serialize()
128
- if isinstance(serialized, dict):
129
- serialized_items.append(serialized)
130
- self._append_line(
131
- "compacted",
132
- {"replacement_history": serialized_items},
122
+ history: "typing.Iterable[ConversationItem]",
123
+ initial_history: "typing.Iterable[ConversationItem]" = (),
124
+ ) -> "None":
125
+ self._append_records(
126
+ [("compacted", _compacted_payload(history))],
127
+ initial_history,
133
128
  )
134
129
 
135
- def _append_line(self, item_type: 'str', payload: 'typing.Dict[str, object]') -> 'None':
130
+ def _append_records(
131
+ self,
132
+ records: "typing.Iterable[typing.Tuple[str, typing.Dict[str, object]]]",
133
+ initial_history: "typing.Iterable[ConversationItem]" = (),
134
+ ) -> "None":
135
+ records = list(records)
136
+ if not records:
137
+ return
138
+ mode = "a"
139
+ if self._session_meta is not None:
140
+ mode = "x"
141
+ records = (
142
+ [("session_meta", self._session_meta)]
143
+ + list(self._history_records(initial_history))
144
+ + records
145
+ )
136
146
  self.rollout_path.parent.mkdir(parents=True, exist_ok=True)
137
- line = {
138
- "timestamp": _timestamp_string(),
139
- "type": item_type,
140
- "payload": payload,
141
- }
142
- with self.rollout_path.open("a", encoding="utf-8") as handle:
143
- handle.write(json.dumps(line, ensure_ascii=False, separators=(",", ":")))
144
- handle.write("\n")
145
- handle.flush()
147
+ with self.rollout_path.open(mode, encoding="utf-8") as handle:
148
+ for item_type, payload in records:
149
+ line = {
150
+ "timestamp": _timestamp_string(),
151
+ "type": item_type,
152
+ "payload": payload,
153
+ }
154
+ handle.write(
155
+ json.dumps(line, ensure_ascii=False, separators=(",", ":"))
156
+ )
157
+ handle.write("\n")
158
+ handle.flush()
159
+ self._session_meta = None
146
160
 
147
161
 
148
162
  def list_resumable_sessions(
149
- codex_home: 'Path',
150
- limit: 'int' = 20,
151
- ) -> 'typing.Tuple[typing.Dict[str, str], ...]':
152
- latest_rollouts_by_id: 'typing.Dict[str, Path]' = {}
163
+ codex_home: "Path",
164
+ limit: "int" = 20,
165
+ ) -> "typing.Tuple[typing.Dict[str, str], ...]":
166
+ latest_rollouts_by_id: "typing.Dict[str, Path]" = {}
153
167
  for dirname in ROLLUP_SESSION_DIRNAMES:
154
168
  root = codex_home / dirname
155
169
  if not root.exists():
@@ -168,7 +182,7 @@ def list_resumable_sessions(
168
182
  key=lambda item: (item[1].stat().st_mtime, str(item[1])),
169
183
  reverse=True,
170
184
  )
171
- sessions: 'typing.List[typing.Dict[str, str]]' = []
185
+ sessions: "typing.List[typing.Dict[str, str]]" = []
172
186
  for thread_id, path in ordered_paths[:limit]:
173
187
  thread_name = latest_names_by_id.get(thread_id, "")
174
188
  preview = _extract_first_user_message_preview(path)
@@ -185,10 +199,10 @@ def list_resumable_sessions(
185
199
  return tuple(sessions)
186
200
 
187
201
 
188
- def load_resumed_session(
189
- codex_home: 'Path',
190
- resume_index_text: 'str',
191
- ) -> 'typing.Dict[str, object]':
202
+ def select_resumable_session(
203
+ codex_home: "Path",
204
+ resume_index_text: "str",
205
+ ) -> "typing.Dict[str, str]":
192
206
  normalized_target = resume_index_text.strip()
193
207
  if not normalized_target.isdigit():
194
208
  raise ValueError("Usage: /resume <number>")
@@ -198,25 +212,19 @@ def load_resumed_session(
198
212
  if resume_index < 1 or resume_index > len(sessions):
199
213
  raise ValueError(f"Session not found: {normalized_target}")
200
214
 
201
- session = sessions[resume_index - 1]
202
- thread_id = session["thread_id"]
203
- rollout_path = Path(session["rollout_path"])
204
- return load_resumed_session_path(
205
- rollout_path,
206
- thread_name=_latest_thread_names_by_id(codex_home).get(thread_id),
207
- )
215
+ return sessions[resume_index - 1]
208
216
 
209
217
 
210
218
  def load_resumed_session_path(
211
- rollout_path: 'typing.Union[str, Path]',
212
- thread_name: 'typing.Union[str, None]' = None,
213
- ) -> 'typing.Dict[str, object]':
214
- rollout_path = Path(rollout_path)
219
+ rollout_path: "typing.Union[str, Path]",
220
+ thread_name: "typing.Union[str, None]" = None,
221
+ ) -> "typing.Dict[str, object]":
222
+ rollout_path = Path(rollout_path).expanduser().resolve()
215
223
  thread_id = _thread_id_from_rollout_path(rollout_path) or ""
216
224
  session_id = thread_id
217
- history: 'typing.List[ConversationItem]' = []
225
+ history: "typing.List[ConversationItem]" = []
218
226
  saw_user_turn = False
219
- tool_names_by_call_id: 'typing.Dict[str, str]' = {}
227
+ tool_names_by_call_id: "typing.Dict[str, str]" = {}
220
228
 
221
229
  # A rollout is append-only, and a compacted entry replaces everything
222
230
  # before it for the next request. Large tool outputs before the latest
@@ -224,6 +232,12 @@ def load_resumed_session_path(
224
232
  # restoring the active conversation.
225
233
  compacted_offset = _find_last_compacted_offset(rollout_path)
226
234
  entry_start_offset = compacted_offset if compacted_offset is not None else 0
235
+ if compacted_offset is not None:
236
+ with closing(_iter_rollout_entries(rollout_path)) as entries:
237
+ first_entry = next(entries)
238
+ metadata = first_entry.get("payload")
239
+ if first_entry.get("type") == "session_meta" and isinstance(metadata, dict):
240
+ session_id = str(metadata.get("id", "")).strip() or session_id
227
241
 
228
242
  for entry in _iter_rollout_entries(rollout_path, entry_start_offset):
229
243
  item_type = str(entry.get("type", "")).strip()
@@ -236,9 +250,11 @@ def load_resumed_session_path(
236
250
  if item_type == "compacted" and isinstance(payload, dict):
237
251
  replacement_history = payload.get("replacement_history")
238
252
  if isinstance(replacement_history, list):
239
- history = _deserialize_compacted_history(replacement_history)
253
+ history = _deserialize_compacted_history(
254
+ replacement_history, payload.get("message")
255
+ )
240
256
  saw_user_turn = any(
241
- isinstance(item, UserMessage) for item in history
257
+ isinstance(item, (UserMessage, ContextMessage)) for item in history
242
258
  )
243
259
  tool_names_by_call_id = {
244
260
  item.call_id: item.name
@@ -253,7 +269,11 @@ def load_resumed_session_path(
253
269
  saw_user_turn = True
254
270
  continue
255
271
 
256
- if item_type != "response_item" or not saw_user_turn or not isinstance(payload, dict):
272
+ if (
273
+ item_type != "response_item"
274
+ or not saw_user_turn
275
+ or not isinstance(payload, dict)
276
+ ):
257
277
  continue
258
278
 
259
279
  _append_deserialized_response_item(
@@ -266,12 +286,13 @@ def load_resumed_session_path(
266
286
  if not history:
267
287
  raise ValueError(f"No resumable history found in {rollout_path}")
268
288
 
269
- history = _trim_incomplete_tool_call_tail(history)
289
+ history = _drop_unmatched_tool_calls(history)
270
290
  if not history:
271
291
  raise ValueError(f"No resumable history found in {rollout_path}")
272
292
 
273
293
  turns = conversation_history_to_turns(history)
274
- title = thread_name or (shorten_title(turns[0][0]) if turns else thread_id)
294
+ first_prompt = next((prompt for prompt, _ in turns if prompt), "")
295
+ title = thread_name or shorten_title(first_prompt) or thread_id
275
296
  return {
276
297
  "session_id": session_id,
277
298
  "thread_id": thread_id,
@@ -283,57 +304,46 @@ def load_resumed_session_path(
283
304
 
284
305
 
285
306
  def conversation_history_to_turns(
286
- history: 'typing.Iterable[ConversationItem]',
287
- ) -> 'typing.Tuple[typing.Tuple[str, str], ...]':
288
- turns: 'typing.List[typing.Tuple[str, str]]' = []
289
- current_user_text: 'typing.Union[str, None]' = None
307
+ history: "typing.Iterable[ConversationItem]",
308
+ ) -> "typing.Tuple[typing.Tuple[str, str], ...]":
309
+ turns: "typing.List[typing.Tuple[str, str]]" = []
310
+ current_user_text: "typing.Union[str, None]" = None
290
311
  current_assistant_text = ""
291
312
  for item in history:
292
313
  if isinstance(item, UserMessage):
293
- if current_user_text is not None:
294
- turns.append((current_user_text, current_assistant_text))
314
+ if current_user_text is not None or current_assistant_text:
315
+ turns.append((current_user_text or "", current_assistant_text))
295
316
  current_user_text = item.text
296
317
  current_assistant_text = ""
297
318
  continue
298
- if isinstance(item, AssistantMessage) and current_user_text is not None:
319
+ if isinstance(item, AssistantMessage):
299
320
  current_assistant_text = item.text
300
- if current_user_text is not None:
301
- turns.append((current_user_text, current_assistant_text))
321
+ if current_user_text is not None or current_assistant_text:
322
+ turns.append((current_user_text or "", current_assistant_text))
302
323
  return tuple(turns)
303
324
 
304
325
 
305
- def _trim_incomplete_tool_call_tail(
306
- history: 'typing.List[ConversationItem]',
307
- ) -> 'typing.List[ConversationItem]':
308
- pending_call_ids: 'typing.Set[str]' = set()
309
- call_indexes: 'typing.Dict[str, int]' = {}
310
-
311
- for index, item in enumerate(history):
312
- if isinstance(item, ToolCall):
313
- pending_call_ids.add(item.call_id)
314
- call_indexes[item.call_id] = index
315
- continue
316
- if isinstance(item, ToolResult):
317
- pending_call_ids.discard(item.call_id)
318
-
319
- if not pending_call_ids:
320
- return history
321
-
322
- trim_start = min(call_indexes[call_id] for call_id in pending_call_ids)
323
- while trim_start > 0 and isinstance(
324
- history[trim_start - 1],
325
- (AssistantMessage, ReasoningItem, ToolCall),
326
- ):
327
- trim_start -= 1
328
- return history[:trim_start]
326
+ def _drop_unmatched_tool_calls(
327
+ history: "typing.List[ConversationItem]",
328
+ ) -> "typing.List[ConversationItem]":
329
+ completed_call_ids = {
330
+ item.call_id for item in history if isinstance(item, ToolResult)
331
+ }
332
+ # An interrupted call can precede later saved turns in an append-only rollout.
333
+ # Omit only calls without results; subsequent history is still resumable.
334
+ return [
335
+ item
336
+ for item in history
337
+ if not isinstance(item, ToolCall) or item.call_id in completed_call_ids
338
+ ]
329
339
 
330
340
 
331
- def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
341
+ def _latest_thread_names_by_id(codex_home: "Path") -> "typing.Dict[str, str]":
332
342
  index_path = codex_home / SESSION_INDEX_FILENAME
333
343
  if not index_path.exists():
334
344
  return {}
335
345
 
336
- names_by_id: 'typing.Dict[str, str]' = {}
346
+ names_by_id: "typing.Dict[str, str]" = {}
337
347
  with index_path.open("r", encoding="utf-8", errors="replace") as handle:
338
348
  for raw_line in handle:
339
349
  line = raw_line.strip()
@@ -352,7 +362,7 @@ def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
352
362
  return names_by_id
353
363
 
354
364
 
355
- def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
365
+ def _thread_id_from_rollout_path(path: "Path") -> "typing.Union[str, None]":
356
366
  stem = path.stem
357
367
  if len(stem) < 36:
358
368
  return None
@@ -360,7 +370,9 @@ def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
360
370
  return candidate if UUID_PATTERN.match(candidate) else None
361
371
 
362
372
 
363
- def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[str, None]':
373
+ def _extract_first_user_message_preview(
374
+ rollout_path: "Path",
375
+ ) -> "typing.Union[str, None]":
364
376
  for entry in _iter_rollout_entries(rollout_path):
365
377
  if entry.get("type") != "event_msg":
366
378
  continue
@@ -374,8 +386,8 @@ def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[s
374
386
 
375
387
 
376
388
  def _find_last_compacted_offset(
377
- rollout_path: 'Path',
378
- ) -> 'typing.Union[int, None]':
389
+ rollout_path: "Path",
390
+ ) -> "typing.Union[int, None]":
379
391
  """Find the latest recorder-format compact checkpoint."""
380
392
 
381
393
  file_size = rollout_path.stat().st_size
@@ -407,9 +419,9 @@ def _find_last_compacted_offset(
407
419
 
408
420
 
409
421
  def _iter_rollout_entries(
410
- rollout_path: 'Path',
411
- start_offset: 'int' = 0,
412
- ) -> 'typing.Iterable[typing.Dict[str, object]]':
422
+ rollout_path: "Path",
423
+ start_offset: "int" = 0,
424
+ ) -> "typing.Iterable[typing.Dict[str, object]]":
413
425
  decoder = json.JSONDecoder()
414
426
  buffer = ""
415
427
  start = 0
@@ -458,19 +470,33 @@ def _iter_rollout_entries(
458
470
  raise ValueError(f"no rollout entries found in {rollout_path}")
459
471
 
460
472
 
461
- def _extract_response_message_text(payload: 'typing.Dict[str, object]') -> 'str':
462
- text_parts: 'typing.List[str]' = []
473
+ def _extract_response_message_text(payload: "typing.Dict[str, object]") -> "str":
474
+ text_parts: "typing.List[str]" = []
463
475
  for item in payload.get("content") or []:
464
476
  if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}:
465
477
  text_parts.append(str(item.get("text", "")))
466
478
  return "".join(text_parts)
467
479
 
468
480
 
481
+ def _compacted_payload(
482
+ history: "typing.Iterable[ConversationItem]",
483
+ ) -> "typing.Dict[str, object]":
484
+ payload: "typing.Dict[str, object]" = {"replacement_history": []}
485
+ for item in history:
486
+ serialized = item.serialize()
487
+ if isinstance(serialized, dict):
488
+ payload["replacement_history"].append(serialized)
489
+ if isinstance(item, ContextMessage) and item.role == "user":
490
+ payload["message"] = item.text
491
+ return payload
492
+
493
+
469
494
  def _deserialize_compacted_history(
470
- replacement_history: 'typing.Iterable[object]',
471
- ) -> 'typing.List[ConversationItem]':
472
- history: 'typing.List[ConversationItem]' = []
473
- tool_names_by_call_id: 'typing.Dict[str, str]' = {}
495
+ replacement_history: "typing.Iterable[object]",
496
+ summary_message: "typing.Union[str, None]" = None,
497
+ ) -> "typing.List[ConversationItem]":
498
+ history: "typing.List[ConversationItem]" = []
499
+ tool_names_by_call_id: "typing.Dict[str, str]" = {}
474
500
  for payload in replacement_history:
475
501
  if not isinstance(payload, dict):
476
502
  continue
@@ -480,23 +506,35 @@ def _deserialize_compacted_history(
480
506
  tool_names_by_call_id,
481
507
  include_user_messages=True,
482
508
  )
509
+ for index in range(len(history) - 1, -1, -1):
510
+ item = history[index]
511
+ if isinstance(item, UserMessage) and (
512
+ item.text == summary_message
513
+ or (summary_message is None and item.text.startswith(SUMMARY_PREFIX + "\n"))
514
+ ):
515
+ history[index] = ContextMessage(text=item.text, id=item.id)
516
+ break
483
517
  return history
484
518
 
485
519
 
486
520
  def _append_deserialized_response_item(
487
- history: 'typing.List[ConversationItem]',
488
- payload: 'typing.Dict[str, object]',
489
- tool_names_by_call_id: 'typing.Dict[str, str]',
490
- include_user_messages: 'bool',
491
- ) -> 'None':
521
+ history: "typing.List[ConversationItem]",
522
+ payload: "typing.Dict[str, object]",
523
+ tool_names_by_call_id: "typing.Dict[str, str]",
524
+ include_user_messages: "bool",
525
+ ) -> "None":
492
526
  response_item_type = str(payload.get("type", "")).strip()
493
527
  if response_item_type == "message":
494
528
  role = str(payload.get("role", "")).strip()
495
529
  if role == "assistant":
496
- history.append(AssistantMessage(text=_extract_response_message_text(payload)))
530
+ history.append(AssistantMessage.from_response_item(payload))
497
531
  return
498
532
  if include_user_messages and role == "user":
499
- history.append(UserMessage(text=_extract_response_message_text(payload)))
533
+ history.append(
534
+ UserMessage(
535
+ text=_extract_response_message_text(payload), id=payload.get("id")
536
+ )
537
+ )
500
538
  return
501
539
 
502
540
  if response_item_type == "reasoning":
@@ -520,7 +558,16 @@ def _append_deserialized_response_item(
520
558
  name = str(payload.get("name", "")).strip()
521
559
  if not call_id or not name:
522
560
  return
523
- history.append(ToolCall(call_id=call_id, name=name, arguments=arguments))
561
+ history.append(
562
+ ToolCall(
563
+ call_id=call_id,
564
+ name=name,
565
+ arguments=arguments,
566
+ id=payload.get("id"),
567
+ raw_arguments=raw_arguments if isinstance(raw_arguments, str) else None,
568
+ namespace=payload.get("namespace"),
569
+ )
570
+ )
524
571
  tool_names_by_call_id[call_id] = name
525
572
  return
526
573
 
@@ -535,6 +582,9 @@ def _append_deserialized_response_item(
535
582
  name=name,
536
583
  arguments=str(payload.get("input", "")),
537
584
  tool_type="custom",
585
+ id=payload.get("id"),
586
+ namespace=payload.get("namespace"),
587
+ status=payload.get("status"),
538
588
  )
539
589
  )
540
590
  tool_names_by_call_id[call_id] = name
@@ -553,7 +603,10 @@ def _append_deserialized_response_item(
553
603
  ):
554
604
  content_items = tuple(dict(item) for item in raw_output)
555
605
  output = json.dumps(raw_output, ensure_ascii=False)
556
- elif isinstance(raw_output, (dict, list, str, int, float, bool)) or raw_output is None:
606
+ elif (
607
+ isinstance(raw_output, (dict, list, str, int, float, bool))
608
+ or raw_output is None
609
+ ):
557
610
  output = raw_output
558
611
  else:
559
612
  output = str(raw_output)
@@ -563,6 +616,7 @@ def _append_deserialized_response_item(
563
616
  name=tool_names_by_call_id.get(call_id, ""),
564
617
  output=output,
565
618
  content_items=content_items,
619
+ id=payload.get("id"),
566
620
  success=(
567
621
  payload.get("success")
568
622
  if isinstance(payload.get("success"), bool)
@@ -577,7 +631,7 @@ def _append_deserialized_response_item(
577
631
  )
578
632
 
579
633
 
580
- def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
634
+ def rollout_path_for_session(codex_home: "Path", session_id: "str") -> "Path":
581
635
  now = datetime.now().astimezone()
582
636
  return (
583
637
  codex_home
@@ -589,5 +643,5 @@ def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
589
643
  )
590
644
 
591
645
 
592
- def _timestamp_string() -> 'str':
646
+ def _timestamp_string() -> "str":
593
647
  return datetime.now().astimezone().isoformat(timespec="milliseconds")