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.
- pycodex/__init__.py +14 -14
- pycodex/agent.py +465 -499
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -510
- 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 +324 -253
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +76 -45
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +245 -983
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -176
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +1 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +58 -25
- 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 +7 -5
- 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 +41 -72
- 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/{image_utils.py → utils/image_utils.py} +8 -11
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +217 -163
- 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 +23 -23
- responses_server/messages_api.py +51 -53
- responses_server/payload_processors.py +25 -20
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +101 -98
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +36 -14
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +461 -375
- workspace_server/workspace.html +852 -228
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +137 -79
- 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 -560
- python_codex-0.2.7.dist-info/METADATA +0 -455
- python_codex-0.2.7.dist-info/RECORD +0 -93
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/utils/session_persist.py
CHANGED
|
@@ -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:
|
|
33
|
-
) ->
|
|
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:
|
|
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:
|
|
50
|
-
session_id:
|
|
51
|
-
cwd:
|
|
52
|
-
originator:
|
|
53
|
-
model_provider:
|
|
54
|
-
base_instructions:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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:
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
150
|
-
limit:
|
|
151
|
-
) ->
|
|
152
|
-
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]" = {}
|
|
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:
|
|
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
|
|
189
|
-
codex_home:
|
|
190
|
-
resume_index_text:
|
|
191
|
-
) ->
|
|
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
|
-
|
|
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:
|
|
212
|
-
thread_name:
|
|
213
|
-
) ->
|
|
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:
|
|
225
|
+
history: "typing.List[ConversationItem]" = []
|
|
218
226
|
saw_user_turn = False
|
|
219
|
-
tool_names_by_call_id:
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
287
|
-
) ->
|
|
288
|
-
turns:
|
|
289
|
-
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
|
|
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)
|
|
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
|
|
306
|
-
history:
|
|
307
|
-
) ->
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if isinstance(item,
|
|
317
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
378
|
-
) ->
|
|
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:
|
|
411
|
-
start_offset:
|
|
412
|
-
) ->
|
|
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:
|
|
462
|
-
text_parts:
|
|
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:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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:
|
|
488
|
-
payload:
|
|
489
|
-
tool_names_by_call_id:
|
|
490
|
-
include_user_messages:
|
|
491
|
-
) ->
|
|
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(
|
|
530
|
+
history.append(AssistantMessage.from_response_item(payload))
|
|
497
531
|
return
|
|
498
532
|
if include_user_messages and role == "user":
|
|
499
|
-
history.append(
|
|
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(
|
|
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
|
|
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
|
|
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() ->
|
|
646
|
+
def _timestamp_string() -> "str":
|
|
593
647
|
return datetime.now().astimezone().isoformat(timespec="milliseconds")
|