python-codex 0.2.6__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.
- pycodex/__init__.py +18 -14
- pycodex/agent.py +468 -462
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -436
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +329 -252
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +90 -52
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +235 -803
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -174
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +4 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +201 -0
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +13 -13
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +50 -66
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/utils/image_utils.py +76 -0
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +263 -161
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +25 -22
- responses_server/messages_api.py +96 -49
- responses_server/payload_processors.py +25 -19
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +196 -107
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +51 -13
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +470 -384
- workspace_server/workspace.html +859 -232
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +168 -100
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -553
- python_codex-0.2.6.dist-info/METADATA +0 -441
- python_codex-0.2.6.dist-info/RECORD +0 -91
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/utils/session_persist.py
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import mmap
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
5
|
+
import typing
|
|
6
|
+
from contextlib import closing
|
|
4
7
|
from datetime import datetime
|
|
5
8
|
from pathlib import Path
|
|
6
9
|
|
|
7
10
|
from ..protocol import (
|
|
8
11
|
AssistantMessage,
|
|
12
|
+
ContextMessage,
|
|
9
13
|
ConversationItem,
|
|
10
14
|
ReasoningItem,
|
|
11
15
|
ToolCall,
|
|
12
16
|
ToolResult,
|
|
13
17
|
UserMessage,
|
|
14
18
|
)
|
|
19
|
+
from .compactor import SUMMARY_PREFIX
|
|
20
|
+
from .event_helpers import shorten_title
|
|
15
21
|
from .get_env import get_package_version
|
|
16
|
-
from .visualize import shorten_title
|
|
17
|
-
import typing
|
|
18
22
|
|
|
19
23
|
SESSION_INDEX_FILENAME = "session_index.jsonl"
|
|
20
24
|
ROLLUP_SESSION_DIRNAMES = ("sessions", "archived_sessions")
|
|
@@ -23,11 +27,13 @@ UUID_PATTERN = re.compile(
|
|
|
23
27
|
re.IGNORECASE,
|
|
24
28
|
)
|
|
25
29
|
ROLLOUT_READ_CHUNK_SIZE = 1024 * 1024
|
|
30
|
+
COMPACTED_RECORD_MARKER = b',"type":"compacted","payload":'
|
|
31
|
+
ROLLOUT_RECORD_PREFIX = b'\n{"timestamp":"'
|
|
26
32
|
|
|
27
33
|
|
|
28
34
|
def resolve_codex_home(
|
|
29
|
-
config_path:
|
|
30
|
-
) ->
|
|
35
|
+
config_path: "typing.Union[str, None]" = None,
|
|
36
|
+
) -> "Path":
|
|
31
37
|
if config_path:
|
|
32
38
|
return Path(config_path).expanduser().resolve().parent
|
|
33
39
|
codex_home = os.environ.get("CODEX_HOME", "").strip()
|
|
@@ -37,45 +43,32 @@ def resolve_codex_home(
|
|
|
37
43
|
|
|
38
44
|
|
|
39
45
|
class SessionRolloutRecorder:
|
|
40
|
-
def __init__(self, rollout_path:
|
|
46
|
+
def __init__(self, rollout_path: "Path") -> "None":
|
|
41
47
|
self.rollout_path = rollout_path
|
|
48
|
+
self._session_meta: "typing.Union[typing.Dict[str, object], None]" = None
|
|
42
49
|
|
|
43
50
|
@classmethod
|
|
44
51
|
def create(
|
|
45
52
|
cls,
|
|
46
|
-
codex_home:
|
|
47
|
-
session_id:
|
|
48
|
-
cwd:
|
|
49
|
-
originator:
|
|
50
|
-
model_provider:
|
|
51
|
-
base_instructions:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
model_provider=model_provider,
|
|
59
|
-
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)
|
|
60
65
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
) -> 'SessionRolloutRecorder':
|
|
68
|
-
return cls(Path(rollout_path))
|
|
69
|
-
|
|
70
|
-
def write_session_meta(
|
|
71
|
-
self,
|
|
72
|
-
session_id: 'str',
|
|
73
|
-
cwd: 'Path',
|
|
74
|
-
originator: 'str',
|
|
75
|
-
model_provider: 'typing.Union[str, None]',
|
|
76
|
-
base_instructions: 'str',
|
|
77
|
-
) -> 'None':
|
|
78
|
-
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 = {
|
|
79
72
|
"id": session_id,
|
|
80
73
|
"timestamp": _timestamp_string(),
|
|
81
74
|
"cwd": str(cwd),
|
|
@@ -85,68 +78,92 @@ class SessionRolloutRecorder:
|
|
|
85
78
|
"model_provider": model_provider,
|
|
86
79
|
"base_instructions": {"text": base_instructions},
|
|
87
80
|
}
|
|
88
|
-
|
|
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())
|
|
89
89
|
|
|
90
90
|
def append_history_items(
|
|
91
91
|
self,
|
|
92
|
-
items:
|
|
93
|
-
|
|
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 = []
|
|
94
102
|
for item in items:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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", {
|
|
103
113
|
"type": "user_message",
|
|
104
114
|
"message": item.text,
|
|
105
115
|
"images": [],
|
|
106
116
|
"local_images": [],
|
|
107
117
|
"text_elements": [],
|
|
108
|
-
}
|
|
109
|
-
)
|
|
110
|
-
return
|
|
111
|
-
if isinstance(item, ToolResult):
|
|
112
|
-
self._append_line("response_item", item.serialize())
|
|
113
|
-
return
|
|
114
|
-
serialized = item.serialize()
|
|
115
|
-
if isinstance(serialized, dict):
|
|
116
|
-
self._append_line("response_item", serialized)
|
|
118
|
+
}
|
|
117
119
|
|
|
118
120
|
def append_compacted_history(
|
|
119
121
|
self,
|
|
120
|
-
history:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
serialized_items.append(serialized)
|
|
127
|
-
self._append_line(
|
|
128
|
-
"compacted",
|
|
129
|
-
{"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,
|
|
130
128
|
)
|
|
131
129
|
|
|
132
|
-
def
|
|
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
|
+
)
|
|
133
146
|
self.rollout_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
143
160
|
|
|
144
161
|
|
|
145
162
|
def list_resumable_sessions(
|
|
146
|
-
codex_home:
|
|
147
|
-
limit:
|
|
148
|
-
) ->
|
|
149
|
-
latest_rollouts_by_id:
|
|
163
|
+
codex_home: "Path",
|
|
164
|
+
limit: "int" = 20,
|
|
165
|
+
) -> "typing.Tuple[typing.Dict[str, str], ...]":
|
|
166
|
+
latest_rollouts_by_id: "typing.Dict[str, Path]" = {}
|
|
150
167
|
for dirname in ROLLUP_SESSION_DIRNAMES:
|
|
151
168
|
root = codex_home / dirname
|
|
152
169
|
if not root.exists():
|
|
@@ -165,7 +182,7 @@ def list_resumable_sessions(
|
|
|
165
182
|
key=lambda item: (item[1].stat().st_mtime, str(item[1])),
|
|
166
183
|
reverse=True,
|
|
167
184
|
)
|
|
168
|
-
sessions:
|
|
185
|
+
sessions: "typing.List[typing.Dict[str, str]]" = []
|
|
169
186
|
for thread_id, path in ordered_paths[:limit]:
|
|
170
187
|
thread_name = latest_names_by_id.get(thread_id, "")
|
|
171
188
|
preview = _extract_first_user_message_preview(path)
|
|
@@ -182,10 +199,10 @@ def list_resumable_sessions(
|
|
|
182
199
|
return tuple(sessions)
|
|
183
200
|
|
|
184
201
|
|
|
185
|
-
def
|
|
186
|
-
codex_home:
|
|
187
|
-
resume_index_text:
|
|
188
|
-
) ->
|
|
202
|
+
def select_resumable_session(
|
|
203
|
+
codex_home: "Path",
|
|
204
|
+
resume_index_text: "str",
|
|
205
|
+
) -> "typing.Dict[str, str]":
|
|
189
206
|
normalized_target = resume_index_text.strip()
|
|
190
207
|
if not normalized_target.isdigit():
|
|
191
208
|
raise ValueError("Usage: /resume <number>")
|
|
@@ -195,27 +212,34 @@ def load_resumed_session(
|
|
|
195
212
|
if resume_index < 1 or resume_index > len(sessions):
|
|
196
213
|
raise ValueError(f"Session not found: {normalized_target}")
|
|
197
214
|
|
|
198
|
-
|
|
199
|
-
thread_id = session["thread_id"]
|
|
200
|
-
rollout_path = Path(session["rollout_path"])
|
|
201
|
-
return load_resumed_session_path(
|
|
202
|
-
rollout_path,
|
|
203
|
-
thread_name=_latest_thread_names_by_id(codex_home).get(thread_id),
|
|
204
|
-
)
|
|
215
|
+
return sessions[resume_index - 1]
|
|
205
216
|
|
|
206
217
|
|
|
207
218
|
def load_resumed_session_path(
|
|
208
|
-
rollout_path:
|
|
209
|
-
thread_name:
|
|
210
|
-
) ->
|
|
211
|
-
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()
|
|
212
223
|
thread_id = _thread_id_from_rollout_path(rollout_path) or ""
|
|
213
224
|
session_id = thread_id
|
|
214
|
-
history:
|
|
225
|
+
history: "typing.List[ConversationItem]" = []
|
|
215
226
|
saw_user_turn = False
|
|
216
|
-
tool_names_by_call_id:
|
|
217
|
-
|
|
218
|
-
|
|
227
|
+
tool_names_by_call_id: "typing.Dict[str, str]" = {}
|
|
228
|
+
|
|
229
|
+
# A rollout is append-only, and a compacted entry replaces everything
|
|
230
|
+
# before it for the next request. Large tool outputs before the latest
|
|
231
|
+
# checkpoint are retained for audit, but do not need to be decoded while
|
|
232
|
+
# restoring the active conversation.
|
|
233
|
+
compacted_offset = _find_last_compacted_offset(rollout_path)
|
|
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
|
|
241
|
+
|
|
242
|
+
for entry in _iter_rollout_entries(rollout_path, entry_start_offset):
|
|
219
243
|
item_type = str(entry.get("type", "")).strip()
|
|
220
244
|
payload = entry.get("payload")
|
|
221
245
|
|
|
@@ -226,9 +250,11 @@ def load_resumed_session_path(
|
|
|
226
250
|
if item_type == "compacted" and isinstance(payload, dict):
|
|
227
251
|
replacement_history = payload.get("replacement_history")
|
|
228
252
|
if isinstance(replacement_history, list):
|
|
229
|
-
history = _deserialize_compacted_history(
|
|
253
|
+
history = _deserialize_compacted_history(
|
|
254
|
+
replacement_history, payload.get("message")
|
|
255
|
+
)
|
|
230
256
|
saw_user_turn = any(
|
|
231
|
-
isinstance(item, UserMessage) for item in history
|
|
257
|
+
isinstance(item, (UserMessage, ContextMessage)) for item in history
|
|
232
258
|
)
|
|
233
259
|
tool_names_by_call_id = {
|
|
234
260
|
item.call_id: item.name
|
|
@@ -243,7 +269,11 @@ def load_resumed_session_path(
|
|
|
243
269
|
saw_user_turn = True
|
|
244
270
|
continue
|
|
245
271
|
|
|
246
|
-
if
|
|
272
|
+
if (
|
|
273
|
+
item_type != "response_item"
|
|
274
|
+
or not saw_user_turn
|
|
275
|
+
or not isinstance(payload, dict)
|
|
276
|
+
):
|
|
247
277
|
continue
|
|
248
278
|
|
|
249
279
|
_append_deserialized_response_item(
|
|
@@ -256,12 +286,13 @@ def load_resumed_session_path(
|
|
|
256
286
|
if not history:
|
|
257
287
|
raise ValueError(f"No resumable history found in {rollout_path}")
|
|
258
288
|
|
|
259
|
-
history =
|
|
289
|
+
history = _drop_unmatched_tool_calls(history)
|
|
260
290
|
if not history:
|
|
261
291
|
raise ValueError(f"No resumable history found in {rollout_path}")
|
|
262
292
|
|
|
263
293
|
turns = conversation_history_to_turns(history)
|
|
264
|
-
|
|
294
|
+
first_prompt = next((prompt for prompt, _ in turns if prompt), "")
|
|
295
|
+
title = thread_name or shorten_title(first_prompt) or thread_id
|
|
265
296
|
return {
|
|
266
297
|
"session_id": session_id,
|
|
267
298
|
"thread_id": thread_id,
|
|
@@ -273,57 +304,46 @@ def load_resumed_session_path(
|
|
|
273
304
|
|
|
274
305
|
|
|
275
306
|
def conversation_history_to_turns(
|
|
276
|
-
history:
|
|
277
|
-
) ->
|
|
278
|
-
turns:
|
|
279
|
-
current_user_text:
|
|
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
|
|
280
311
|
current_assistant_text = ""
|
|
281
312
|
for item in history:
|
|
282
313
|
if isinstance(item, UserMessage):
|
|
283
|
-
if current_user_text is not None:
|
|
284
|
-
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))
|
|
285
316
|
current_user_text = item.text
|
|
286
317
|
current_assistant_text = ""
|
|
287
318
|
continue
|
|
288
|
-
if isinstance(item, AssistantMessage)
|
|
319
|
+
if isinstance(item, AssistantMessage):
|
|
289
320
|
current_assistant_text = item.text
|
|
290
|
-
if current_user_text is not None:
|
|
291
|
-
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))
|
|
292
323
|
return tuple(turns)
|
|
293
324
|
|
|
294
325
|
|
|
295
|
-
def
|
|
296
|
-
history:
|
|
297
|
-
) ->
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if isinstance(item,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if not pending_call_ids:
|
|
310
|
-
return history
|
|
311
|
-
|
|
312
|
-
trim_start = min(call_indexes[call_id] for call_id in pending_call_ids)
|
|
313
|
-
while trim_start > 0 and isinstance(
|
|
314
|
-
history[trim_start - 1],
|
|
315
|
-
(AssistantMessage, ReasoningItem, ToolCall),
|
|
316
|
-
):
|
|
317
|
-
trim_start -= 1
|
|
318
|
-
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
|
+
]
|
|
319
339
|
|
|
320
340
|
|
|
321
|
-
def _latest_thread_names_by_id(codex_home:
|
|
341
|
+
def _latest_thread_names_by_id(codex_home: "Path") -> "typing.Dict[str, str]":
|
|
322
342
|
index_path = codex_home / SESSION_INDEX_FILENAME
|
|
323
343
|
if not index_path.exists():
|
|
324
344
|
return {}
|
|
325
345
|
|
|
326
|
-
names_by_id:
|
|
346
|
+
names_by_id: "typing.Dict[str, str]" = {}
|
|
327
347
|
with index_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
328
348
|
for raw_line in handle:
|
|
329
349
|
line = raw_line.strip()
|
|
@@ -342,7 +362,7 @@ def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
|
|
|
342
362
|
return names_by_id
|
|
343
363
|
|
|
344
364
|
|
|
345
|
-
def _thread_id_from_rollout_path(path:
|
|
365
|
+
def _thread_id_from_rollout_path(path: "Path") -> "typing.Union[str, None]":
|
|
346
366
|
stem = path.stem
|
|
347
367
|
if len(stem) < 36:
|
|
348
368
|
return None
|
|
@@ -350,7 +370,9 @@ def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
|
|
|
350
370
|
return candidate if UUID_PATTERN.match(candidate) else None
|
|
351
371
|
|
|
352
372
|
|
|
353
|
-
def _extract_first_user_message_preview(
|
|
373
|
+
def _extract_first_user_message_preview(
|
|
374
|
+
rollout_path: "Path",
|
|
375
|
+
) -> "typing.Union[str, None]":
|
|
354
376
|
for entry in _iter_rollout_entries(rollout_path):
|
|
355
377
|
if entry.get("type") != "event_msg":
|
|
356
378
|
continue
|
|
@@ -363,13 +385,51 @@ def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[s
|
|
|
363
385
|
return None
|
|
364
386
|
|
|
365
387
|
|
|
366
|
-
def
|
|
388
|
+
def _find_last_compacted_offset(
|
|
389
|
+
rollout_path: "Path",
|
|
390
|
+
) -> "typing.Union[int, None]":
|
|
391
|
+
"""Find the latest recorder-format compact checkpoint."""
|
|
392
|
+
|
|
393
|
+
file_size = rollout_path.stat().st_size
|
|
394
|
+
if not file_size:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
with rollout_path.open("rb") as handle:
|
|
398
|
+
mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)
|
|
399
|
+
try:
|
|
400
|
+
marker_offset = mapped.rfind(COMPACTED_RECORD_MARKER)
|
|
401
|
+
if marker_offset < 0:
|
|
402
|
+
return None
|
|
403
|
+
record_start = mapped.rfind(
|
|
404
|
+
ROLLOUT_RECORD_PREFIX,
|
|
405
|
+
0,
|
|
406
|
+
marker_offset,
|
|
407
|
+
)
|
|
408
|
+
if record_start < 0:
|
|
409
|
+
return None
|
|
410
|
+
previous = record_start - 1
|
|
411
|
+
while previous >= 0 and mapped[previous] in b" \t\r\n":
|
|
412
|
+
previous -= 1
|
|
413
|
+
if previous >= 0 and mapped[previous] != ord("}"):
|
|
414
|
+
return None
|
|
415
|
+
return record_start + 1
|
|
416
|
+
finally:
|
|
417
|
+
mapped.close()
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _iter_rollout_entries(
|
|
422
|
+
rollout_path: "Path",
|
|
423
|
+
start_offset: "int" = 0,
|
|
424
|
+
) -> "typing.Iterable[typing.Dict[str, object]]":
|
|
367
425
|
decoder = json.JSONDecoder()
|
|
368
426
|
buffer = ""
|
|
369
427
|
start = 0
|
|
370
428
|
parsed_entries = 0
|
|
371
429
|
|
|
372
430
|
with rollout_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
431
|
+
if start_offset:
|
|
432
|
+
handle.seek(start_offset)
|
|
373
433
|
while True:
|
|
374
434
|
chunk = handle.read(ROLLOUT_READ_CHUNK_SIZE)
|
|
375
435
|
eof = not chunk
|
|
@@ -410,19 +470,33 @@ def _iter_rollout_entries(rollout_path: 'Path') -> 'typing.Iterable[typing.Dict[
|
|
|
410
470
|
raise ValueError(f"no rollout entries found in {rollout_path}")
|
|
411
471
|
|
|
412
472
|
|
|
413
|
-
def _extract_response_message_text(payload:
|
|
414
|
-
text_parts:
|
|
473
|
+
def _extract_response_message_text(payload: "typing.Dict[str, object]") -> "str":
|
|
474
|
+
text_parts: "typing.List[str]" = []
|
|
415
475
|
for item in payload.get("content") or []:
|
|
416
476
|
if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}:
|
|
417
477
|
text_parts.append(str(item.get("text", "")))
|
|
418
478
|
return "".join(text_parts)
|
|
419
479
|
|
|
420
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
|
+
|
|
421
494
|
def _deserialize_compacted_history(
|
|
422
|
-
replacement_history:
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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]" = {}
|
|
426
500
|
for payload in replacement_history:
|
|
427
501
|
if not isinstance(payload, dict):
|
|
428
502
|
continue
|
|
@@ -432,23 +506,35 @@ def _deserialize_compacted_history(
|
|
|
432
506
|
tool_names_by_call_id,
|
|
433
507
|
include_user_messages=True,
|
|
434
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
|
|
435
517
|
return history
|
|
436
518
|
|
|
437
519
|
|
|
438
520
|
def _append_deserialized_response_item(
|
|
439
|
-
history:
|
|
440
|
-
payload:
|
|
441
|
-
tool_names_by_call_id:
|
|
442
|
-
include_user_messages:
|
|
443
|
-
) ->
|
|
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":
|
|
444
526
|
response_item_type = str(payload.get("type", "")).strip()
|
|
445
527
|
if response_item_type == "message":
|
|
446
528
|
role = str(payload.get("role", "")).strip()
|
|
447
529
|
if role == "assistant":
|
|
448
|
-
history.append(AssistantMessage(
|
|
530
|
+
history.append(AssistantMessage.from_response_item(payload))
|
|
449
531
|
return
|
|
450
532
|
if include_user_messages and role == "user":
|
|
451
|
-
history.append(
|
|
533
|
+
history.append(
|
|
534
|
+
UserMessage(
|
|
535
|
+
text=_extract_response_message_text(payload), id=payload.get("id")
|
|
536
|
+
)
|
|
537
|
+
)
|
|
452
538
|
return
|
|
453
539
|
|
|
454
540
|
if response_item_type == "reasoning":
|
|
@@ -472,7 +558,16 @@ def _append_deserialized_response_item(
|
|
|
472
558
|
name = str(payload.get("name", "")).strip()
|
|
473
559
|
if not call_id or not name:
|
|
474
560
|
return
|
|
475
|
-
history.append(
|
|
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
|
+
)
|
|
476
571
|
tool_names_by_call_id[call_id] = name
|
|
477
572
|
return
|
|
478
573
|
|
|
@@ -487,6 +582,9 @@ def _append_deserialized_response_item(
|
|
|
487
582
|
name=name,
|
|
488
583
|
arguments=str(payload.get("input", "")),
|
|
489
584
|
tool_type="custom",
|
|
585
|
+
id=payload.get("id"),
|
|
586
|
+
namespace=payload.get("namespace"),
|
|
587
|
+
status=payload.get("status"),
|
|
490
588
|
)
|
|
491
589
|
)
|
|
492
590
|
tool_names_by_call_id[call_id] = name
|
|
@@ -505,7 +603,10 @@ def _append_deserialized_response_item(
|
|
|
505
603
|
):
|
|
506
604
|
content_items = tuple(dict(item) for item in raw_output)
|
|
507
605
|
output = json.dumps(raw_output, ensure_ascii=False)
|
|
508
|
-
elif
|
|
606
|
+
elif (
|
|
607
|
+
isinstance(raw_output, (dict, list, str, int, float, bool))
|
|
608
|
+
or raw_output is None
|
|
609
|
+
):
|
|
509
610
|
output = raw_output
|
|
510
611
|
else:
|
|
511
612
|
output = str(raw_output)
|
|
@@ -515,6 +616,7 @@ def _append_deserialized_response_item(
|
|
|
515
616
|
name=tool_names_by_call_id.get(call_id, ""),
|
|
516
617
|
output=output,
|
|
517
618
|
content_items=content_items,
|
|
619
|
+
id=payload.get("id"),
|
|
518
620
|
success=(
|
|
519
621
|
payload.get("success")
|
|
520
622
|
if isinstance(payload.get("success"), bool)
|
|
@@ -529,7 +631,7 @@ def _append_deserialized_response_item(
|
|
|
529
631
|
)
|
|
530
632
|
|
|
531
633
|
|
|
532
|
-
def
|
|
634
|
+
def rollout_path_for_session(codex_home: "Path", session_id: "str") -> "Path":
|
|
533
635
|
now = datetime.now().astimezone()
|
|
534
636
|
return (
|
|
535
637
|
codex_home
|
|
@@ -541,5 +643,5 @@ def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
|
|
|
541
643
|
)
|
|
542
644
|
|
|
543
645
|
|
|
544
|
-
def _timestamp_string() ->
|
|
646
|
+
def _timestamp_string() -> "str":
|
|
545
647
|
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|