python-codex 0.1.3__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. {python_codex-0.1.3 → python_codex-0.1.5}/AGENTS.md +6 -0
  2. {python_codex-0.1.3 → python_codex-0.1.5}/PKG-INFO +18 -3
  3. {python_codex-0.1.3 → python_codex-0.1.5}/README.md +17 -2
  4. {python_codex-0.1.3 → python_codex-0.1.5}/README_ZH.md +12 -2
  5. {python_codex-0.1.3 → python_codex-0.1.5}/docs/ALIGNMENT.md +26 -0
  6. {python_codex-0.1.3 → python_codex-0.1.5}/docs/responses_server/README.md +1 -0
  7. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/agent.py +51 -11
  8. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/cli.py +109 -3
  9. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/context.py +23 -0
  10. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/model.py +362 -23
  11. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/models.json +30 -0
  12. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/apply_patch_tool.py +2 -2
  13. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/utils/__init__.py +4 -0
  14. python_codex-0.1.5/pycodex/utils/compactor.py +189 -0
  15. python_codex-0.1.5/pycodex/utils/session_persist.py +483 -0
  16. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/utils/visualize.py +120 -6
  17. {python_codex-0.1.3 → python_codex-0.1.5}/pyproject.toml +1 -1
  18. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/app.py +4 -1
  19. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/payload_processors.py +10 -1
  20. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/stream_router.py +25 -6
  21. {python_codex-0.1.3 → python_codex-0.1.5}/tests/responses_server/test_server.py +132 -0
  22. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_agent.py +30 -0
  23. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_builtin_tools.py +1 -4
  24. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_cli.py +1025 -12
  25. python_codex-0.1.5/tests/test_compactor.py +64 -0
  26. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_context.py +16 -0
  27. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_model.py +266 -3
  28. {python_codex-0.1.3 → python_codex-0.1.5}/.github/workflows/publish.yml +0 -0
  29. {python_codex-0.1.3 → python_codex-0.1.5}/.github/workflows/test.yml +0 -0
  30. {python_codex-0.1.3 → python_codex-0.1.5}/.gitignore +0 -0
  31. {python_codex-0.1.3 → python_codex-0.1.5}/LICENSE +0 -0
  32. {python_codex-0.1.3 → python_codex-0.1.5}/docs/CONTEXT.md +0 -0
  33. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/__init__.py +0 -0
  34. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/collaboration.py +0 -0
  35. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/compat.py +0 -0
  36. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/doctor.py +0 -0
  37. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/portable.py +0 -0
  38. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/portable_server.py +0 -0
  39. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/collaboration_default.md +0 -0
  40. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/collaboration_plan.md +0 -0
  41. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/default_base_instructions.md +0 -0
  42. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/exec_tools.json +0 -0
  43. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  44. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  45. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  46. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  47. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  48. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  49. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  50. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  51. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/prompts/subagent_tools.json +0 -0
  52. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/protocol.py +0 -0
  53. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/runtime.py +0 -0
  54. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/runtime_services.py +0 -0
  55. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/__init__.py +0 -0
  56. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/agent_tool_schemas.py +0 -0
  57. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/base_tool.py +0 -0
  58. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/close_agent_tool.py +0 -0
  59. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/code_mode_manager.py +0 -0
  60. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/exec_command_tool.py +0 -0
  61. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/exec_runtime.js +0 -0
  62. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/exec_tool.py +0 -0
  63. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/grep_files_tool.py +0 -0
  64. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/list_dir_tool.py +0 -0
  65. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/read_file_tool.py +0 -0
  66. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/request_permissions_tool.py +0 -0
  67. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/request_user_input_tool.py +0 -0
  68. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/resume_agent_tool.py +0 -0
  69. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/send_input_tool.py +0 -0
  70. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/shell_command_tool.py +0 -0
  71. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/shell_tool.py +0 -0
  72. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/spawn_agent_tool.py +0 -0
  73. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/unified_exec_manager.py +0 -0
  74. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/update_plan_tool.py +0 -0
  75. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/view_image_tool.py +0 -0
  76. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/wait_agent_tool.py +0 -0
  77. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/wait_tool.py +0 -0
  78. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/web_search_tool.py +0 -0
  79. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/tools/write_stdin_tool.py +0 -0
  80. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/utils/dotenv.py +0 -0
  81. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/utils/get_env.py +0 -0
  82. {python_codex-0.1.3 → python_codex-0.1.5}/pycodex/utils/random_ids.py +0 -0
  83. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/__init__.py +0 -0
  84. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/__main__.py +0 -0
  85. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/config.py +0 -0
  86. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/server.py +0 -0
  87. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/session_store.py +0 -0
  88. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/tools/__init__.py +0 -0
  89. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/tools/custom_adapter.py +0 -0
  90. {python_codex-0.1.3 → python_codex-0.1.5}/responses_server/tools/web_search.py +0 -0
  91. {python_codex-0.1.3 → python_codex-0.1.5}/tests/TESTS.md +0 -0
  92. {python_codex-0.1.3 → python_codex-0.1.5}/tests/__init__.py +0 -0
  93. {python_codex-0.1.3 → python_codex-0.1.5}/tests/compare_request_user_input_roundtrip.py +0 -0
  94. {python_codex-0.1.3 → python_codex-0.1.5}/tests/compare_steer_request_bodies.py +0 -0
  95. {python_codex-0.1.3 → python_codex-0.1.5}/tests/compare_tool_schemas.py +0 -0
  96. {python_codex-0.1.3 → python_codex-0.1.5}/tests/fake_responses_server.py +0 -0
  97. {python_codex-0.1.3 → python_codex-0.1.5}/tests/fakes.py +0 -0
  98. {python_codex-0.1.3 → python_codex-0.1.5}/tests/responses_server/fake_chat_completions_server.py +0 -0
  99. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_doctor.py +0 -0
  100. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_fake_responses_server.py +0 -0
  101. {python_codex-0.1.3 → python_codex-0.1.5}/tests/test_portable.py +0 -0
@@ -9,12 +9,16 @@
9
9
  - 会话协议先收敛到 4 类 item:`UserMessage`、`AssistantMessage`、`ToolCall`、`ToolResult`;只有这层稳定后再扩 richer event model。
10
10
  - 优先保持主循环可测试、可替换:模型侧通过 `ModelClient` 协议接入;测试专用的 `ScriptedModelClient` 放在 `tests/fakes.py`,不要放进运行时包。
11
11
  - `ResponsesModelClient` 直接复用 `~/.codex/config.toml` 的 provider 配置;当前已验证这里的 responses provider 需要 `stream = true`,否则会返回 `400` 和 `Stream must be set to true`。
12
+ - 现在 `ResponsesModelClient` 默认会对流式断连做 provider 级自动重试(`stream_max_retries` 默认 5);写 CLI/REPL 测试时如果断言“先向用户报错,再靠下一句 `go on` 继续”,必须在测试 provider 配置里显式设 `stream_max_retries = 0`,否则测试可能一直等不到预期错误而卡住。
12
13
  - `responses_server` compat 层应透传请求里的 `model`;不要再做 “取 downstream /models 第一个 id 并强制覆盖请求模型” 这种兜底兼容。
13
14
  - 对 `model_provider = "vllm"`,`responses_server` 仍然走 `/v1/chat/completions` compat 路径,但要保留 reasoning:把 chat chunk 里的 `reasoning` / `reasoning_content` 翻回 Responses `reasoning` item,并把历史里的 Responses `reasoning` item 回放成下游 assistant message 的 `reasoning` 字段。
14
15
  - `responses_server` 的 provider-specific chat payload 定制统一放在 `responses_server/payload_processors.py`:使用 `CompatServerConfig.model_provider` 选择 `provider_name -> proc_fn(outcomming_request)` 映射,并且只在真正发出 downstream `/v1/chat/completions` 前 post-process;`StreamRouter` 内部继续保留 canonical payload,避免 tool hydration / mock web_search follow-up 被 provider 改写污染。
15
16
  - `pycodex` 默认是最小交互 CLI;无 prompt 时进入 REPL,并通过 `AgentRuntime` 跑外层提交循环。当前会显示最小事件流、assistant 流式输出、简单 title/history(`/title`, `/history`),并默认注册一组与原版一一对应的本地工具子集。
16
17
  - 交互 CLI 的事件流展示优先表达用户可感知的阶段(例如工具开始/完成、模型回看工具结果),不要直接把内部 `iteration` 计数暴露成主要状态文案;`iterations` 应继续保留在 `TurnResult` 等程序化结果里。
17
18
  - prompt/context 相关逻辑统一放在 `pycodex/context.py`:`AgentLoop` 只维护真实会话历史;每轮请求前由 `ContextManager` 注入 base instructions、developer message、`AGENTS.md` 指令和 `<environment_context>`,且这些注入项不写回 history。
19
+ - 对需要 model-specific prompt 的本地 model slug,直接在 vendored `pycodex/prompts/models.json` 补条目;当前 `step-3.5-flash` / `step-3.5-flash-2603` 已按这个方式接入。
20
+ - 交互 REPL 的 context 用量提示也应尽量贴近上游语义:展示“剩余 context 百分比”而不是原始 token 数;计算时按上游同款 `BASELINE_TOKENS=12000` 做归一化,并在模型元数据只有 `context_window` 时默认按 `95%` effective window 处理。只要当前模型能解析出 context window,初始 prompt 就先显示 `100%`,等首个 usage 回来后再刷新成真实值。
21
+ - 对交互 REPL 的 context 指示器,`model_context_window` 的取值优先级也要贴近上游:先吃 `config.toml` / profile 里的 `model_context_window` override,再回退到 vendored `models.json` 的 `context_window`;effective percent 继续沿用模型元数据,没有时默认 `95%`。
18
22
  - `AgentLoop` 的 turn-loop 语义要跟上游 `codex-rs/core/src/codex.rs` 一致:按 follow-up / tool handoff 自然收敛,不要加固定 12 轮之类的 hard cap,也不要保留本地专用的 iteration-limit 参数。
19
23
  - `README.md` 和 `docs/` 属于对齐工作的一部分:只要实现状态、对齐结论或使用方式发生实质变化,就应及时更新,不要让文档滞后于当前代码。
20
24
  - 新工具必须继承 `BaseTool`,然后通过 `ToolRegistry.register(tool_instance)` 接入;不要再给 registry 传散装 name/description/handler 参数。
@@ -42,3 +46,5 @@
42
46
  - `--put` 的 CLI UX 现在约定为“先打印打包文件清单和上传目标,再打印结果”,并且最后一行保留为可直接执行的 `pycodex --call SECRET-CALLID@<host:port>` 一键启动命令;后续如果再改输出,尽量保留这个末行语义。
43
47
  - `--put` 现在不是“只上传就结束”:上传成功后还会立刻跑一次真实的 `--call` 下载/解包 round-trip 测试,只有这个测试也成功才算整个 `--put` 成功。排障时如果上传成功但 CLI 仍退出非零,要继续看 call 路径而不只看 put 端。
44
48
  - `--put` 现在会先做 `/healthz` preflight,再开始扫描/压缩目录;如果用户报“卡死”,先看目标 `host:port` 是否真有 storage server 在监听。默认打包策略也已经切到白名单:只带 `config.toml`、`.env`、`AGENTS.md`、`AGENTS.override.md`、`skills/**`,以及 config 里相对引用的 `model_instructions_file`,所以像 `sessions/` 这类运行态目录不会再靠黑名单排除。
49
+ - 对接真实 `~/.codex/sessions/.../rollout-*.jsonl` 时,不要假设它一定是严格的一行一个 JSON object:本机样本可能包含 pretty-printed 多行对象,且文件尾部偶尔带未完成记录。恢复历史时用 concatenated-JSON 方式读取,并容忍尾部残缺。
50
+ - `pycodex` 本地 session 保存现在也按上游思路走:新 session 一开始就分配稳定的 uuidv7 thread/session id,并把历史增量追加到 `CODEX_HOME/sessions/.../rollout-*.jsonl`;`/resume` 列表应只展示至少有真实 user message 的 rollout,避免空白新 session 污染恢复列表。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -72,7 +72,7 @@ Intentionally not included yet:
72
72
 
73
73
  - TUI / streaming incremental rendering
74
74
  - MCP / connectors / sandbox / approvals
75
- - memory / compact / hooks / review mode
75
+ - memory / compact / review mode
76
76
  - a full production OpenAI adapter surface
77
77
 
78
78
  All of those can be layered on later. For now, the project is focused on
@@ -174,9 +174,24 @@ Current behavior:
174
174
  - interactive mode shows a compact event stream for user-visible phases such as
175
175
  tool execution and model follow-up after tool results
176
176
  - assistant text is printed from streaming deltas directly
177
- - interactive mode supports `/history`, `/title`, and `/model`
177
+ - interactive mode supports `/history`, `/title`, `/model`, `/resume`, and `/compact`
178
178
  - `/model <name>` switches the model used by later turns in the current
179
179
  interactive session; `/model` shows the current model and available choices
180
+ - `/resume` with no argument lists the currently resumable sessions by their
181
+ first user-message preview; `/resume 1` resumes the first listed session
182
+ - `/resume <number>` replaces the in-memory history with the selected recorded
183
+ Codex rollout from `CODEX_HOME/sessions`
184
+ - `/compact` synthesizes a local handoff summary, replaces the in-memory
185
+ conversation history with the compacted view, and appends a compacted-history
186
+ entry to the rollout so later `/resume` sees the same state
187
+ - new sessions are now recorded under `CODEX_HOME/sessions/.../rollout-*.jsonl`
188
+ with a stable session/thread id and per-item append+flush semantics so
189
+ `/resume` reads back the same rollout format
190
+ - if `TURN_HOOK.md` exists in the workspace root and is non-empty, each
191
+ completed turn also forks the just-finished history into a temporary,
192
+ non-persisted follow-up session and submits the file contents as the next
193
+ user instruction; this is intended for side-effect follow-ups such as
194
+ Feishu notifications
180
195
  - steer is enabled by default in interactive mode: normal input goes into the
181
196
  runtime steer path, the current request stops at the next safe boundary, and
182
197
  later steer text is appended to the next model request's `input` in order;
@@ -51,7 +51,7 @@ Intentionally not included yet:
51
51
 
52
52
  - TUI / streaming incremental rendering
53
53
  - MCP / connectors / sandbox / approvals
54
- - memory / compact / hooks / review mode
54
+ - memory / compact / review mode
55
55
  - a full production OpenAI adapter surface
56
56
 
57
57
  All of those can be layered on later. For now, the project is focused on
@@ -153,9 +153,24 @@ Current behavior:
153
153
  - interactive mode shows a compact event stream for user-visible phases such as
154
154
  tool execution and model follow-up after tool results
155
155
  - assistant text is printed from streaming deltas directly
156
- - interactive mode supports `/history`, `/title`, and `/model`
156
+ - interactive mode supports `/history`, `/title`, `/model`, `/resume`, and `/compact`
157
157
  - `/model <name>` switches the model used by later turns in the current
158
158
  interactive session; `/model` shows the current model and available choices
159
+ - `/resume` with no argument lists the currently resumable sessions by their
160
+ first user-message preview; `/resume 1` resumes the first listed session
161
+ - `/resume <number>` replaces the in-memory history with the selected recorded
162
+ Codex rollout from `CODEX_HOME/sessions`
163
+ - `/compact` synthesizes a local handoff summary, replaces the in-memory
164
+ conversation history with the compacted view, and appends a compacted-history
165
+ entry to the rollout so later `/resume` sees the same state
166
+ - new sessions are now recorded under `CODEX_HOME/sessions/.../rollout-*.jsonl`
167
+ with a stable session/thread id and per-item append+flush semantics so
168
+ `/resume` reads back the same rollout format
169
+ - if `TURN_HOOK.md` exists in the workspace root and is non-empty, each
170
+ completed turn also forks the just-finished history into a temporary,
171
+ non-persisted follow-up session and submits the file contents as the next
172
+ user instruction; this is intended for side-effect follow-ups such as
173
+ Feishu notifications
159
174
  - steer is enabled by default in interactive mode: normal input goes into the
160
175
  runtime steer path, the current request stops at the next safe boundary, and
161
176
  later steer text is appended to the next model request's `input` in order;
@@ -47,7 +47,7 @@ uv run pycodex
47
47
 
48
48
  - TUI / 流式增量渲染
49
49
  - MCP / connectors / sandbox / approvals
50
- - memory / compact / hooks / review mode
50
+ - memory / compact / review mode
51
51
  - 真实 OpenAI 适配器
52
52
 
53
53
  这些都可以后续继续往上叠,但当前项目先把最核心的“工具增强推理主循环”钉住。
@@ -129,8 +129,18 @@ pycodex doctor
129
129
  - 交互模式下支持 `/exit` 和 `/quit`
130
130
  - 交互模式下会显示简洁阶段事件流,例如工具执行状态和模型回看工具结果
131
131
  - assistant 文本会按流式 delta 直接打印
132
- - 交互模式下支持 `/history`、`/title` 和 `/model`
132
+ - 交互模式下支持 `/history`、`/title`、`/model` 和 `/resume`
133
133
  - `/model <name>` 会切换当前交互会话后续请求使用的模型;`/model` 会显示当前模型和可选模型
134
+ - `/resume` 不带参数时会按首条用户消息预览列出当前可恢复的 session;`/resume 1`
135
+ 会恢复列表里的第 1 个 session
136
+ - `/resume <数字>` 会从 `CODEX_HOME/sessions` 读取选中的已记录 Codex rollout,
137
+ 并直接替换当前内存里的会话 history
138
+ - 新 session 现在会自动保存到 `CODEX_HOME/sessions/.../rollout-*.jsonl`,
139
+ 使用稳定的 session/thread id,并按 item 级别 append + flush,和 `/resume`
140
+ 读取的 rollout 格式保持一致
141
+ - 如果 workspace 根目录存在非空的 `TURN_HOOK.md`,每个已完成 turn 之后都会把
142
+ 刚结束的 history fork 成一个不落盘的临时 follow-up 会话,并把文件内容作为下一条
143
+ user 指令提交;适合做 Feishu 通知这类副作用收尾动作
134
144
  - 交互模式默认支持 steer:普通输入会走 runtime 的 steer 路径,当前请求会在下一个安全边界尽快停下,后续 steer 文本会按顺序并入下一次模型请求的 `input`;如需明确排队可用 `/queue <message>`,会打印 `[steer] queued: ...`,随后等该 turn 真正开始时再打印 `[steer] inserted: ...`
135
145
  - 当前默认注册一组与原版 Codex 一一对应的本地工具子集:`shell`、`shell_command`、`exec_command`、`write_stdin`、`exec`、`wait`、`web_search`、`update_plan`、`request_user_input`、`request_permissions`、`spawn_agent`、`send_input`、`resume_agent`、`wait_agent`、`close_agent`、`apply_patch`、`grep_files`、`read_file`、`list_dir`、`view_image`
136
146
  - `--vllm-endpoint http://host:port` 会自动拉起一个本地 `responses_server` compat 层;当 path 为空时会内部补 `/v1`,继续把 `/responses` 请求转到下游 `/v1/chat/completions`。当前对 `model_provider = "vllm"` 已补上 reasoning 兼容:会把 chat chunk 里的 `reasoning` / `reasoning_content` 翻回 Responses `reasoning` item,并把历史里的 `reasoning` item 回放成下游 assistant message 的 `reasoning` 字段;同时会向 vLLM 请求 streaming usage,并在最终 `response.completed.response.usage` 中回传
@@ -549,3 +549,29 @@ Those are the next alignment target after the prompt/context pass.
549
549
  - 因此,下一次请求体里的 `input` 现在可以把多个 steer 文本按顺序并到 history 尾部,并且继续沿用同一个 `turn_id`;这一点已经明显比旧版 `cancel + 单条新 turn` 更接近 upstream
550
550
  - 通过 `tests/compare_steer_request_bodies.py` 的 fake/proxy capture 对比,当前 steer 首轮/次轮 request body 在忽略 `prompt_cache_key` 后已与本机 installed `codex-cli 0.115.0` 对齐;这里比较的是“默认 steer”路径,因此脚本会先去掉本机用户配置里的顶层 `service_tier`,避免把本地 fast-mode 设置误记成 steer 差异。同一 steer turn 的 follow-up request 仍需继续带 `workspaces`
551
551
  - 仍未完全一致的点主要是内部控制流:本地实现仍是在 runtime 层结束一次 `run_turn(...)` 再启动下一次;upstream 则更倾向于在同一个 active turn 里继续 follow-up
552
+
553
+ ## timeout / interrupt 对齐现状
554
+
555
+ - `pycodex` 现在已经补上最小的 provider 级 stream retry:`ResponsesProviderConfig`
556
+ 支持 `stream_max_retries` / `stream_idle_timeout_ms`,默认值对齐 upstream 的
557
+ `5` 次重试和 `300_000 ms` SSE idle timeout;代码在 `pycodex/model.py`
558
+ - 当前实现会把 `response.failed`、stream 在 `response.completed` 前断开、以及
559
+ `requests` 侧的读流异常统一视为 retryable stream error,并在
560
+ `ResponsesModelClient.complete(...)` 里按 backoff 重试;重试前会向外发
561
+ `ModelStreamEvent(kind="stream_error")`,CLI 会显示 `[status] Reconnecting...`
562
+ - 这一点已经明显更接近 upstream 在 `run_sampling_request(...)` 里对
563
+ `CodexErr::Stream(...)` / `CodexErr::Timeout` 的 backoff retry + `StreamError`
564
+ 前端通知语义;对应参考仍在 `core/src/model_provider_info.rs`、
565
+ `codex-api/src/sse/responses.rs`、`core/src/codex.rs`
566
+ - 还没对齐的点主要有两类:
567
+ - retry 目前是在 Python `ResponsesModelClient` 内部完成,不像 upstream 那样放在更外层的
568
+ sampling loop,并复用统一的 `CodexErr::is_retryable()` 分类
569
+ - 还没有 upstream 的 WebSocket transport / HTTP fallback,因此也没有
570
+ “超过 WS retry 预算后切到 HTTP” 的那层行为
571
+ - 中断语义也还不一样:upstream 普通“steer/user input”优先走
572
+ `inject_input(...)` -> `pending_input`,只有显式 interrupt 才会真正取消当前 task;
573
+ 取消时会通过 `CancellationToken` 中止活跃请求/工具,终止 unified-exec 进程,并把
574
+ `<turn_aborted>` marker 持久化到 history
575
+ - `pycodex` 当前 steer 仍主要靠 `AgentLoop.interrupt_asap` 在 loop 边界抛
576
+ `TurnInterrupted`;它不会主动打断正在阻塞的模型流读取或正在运行的 tool,也不会写
577
+ `<turn_aborted>` marker,因此 interrupt 语义仍明显弱于 upstream
@@ -25,6 +25,7 @@
25
25
  - vLLM chat-completions `reasoning` / `reasoning_content` -> Responses `reasoning` item 适配
26
26
  - vLLM 历史 `reasoning` item -> assistant message `reasoning` 字段回放
27
27
  - vLLM streaming `usage` -> final `response.completed.response.usage`
28
+ - 下游 chat stream 如果半路断开,会转成上游可解析的 `response.failed` 事件,而不是直接截断 HTTP body
28
29
  - 普通 function tools
29
30
  - custom tools 的 function-wrapper 兼容适配
30
31
  - mock `web_search` 接口对齐(返回空结果)
@@ -20,6 +20,9 @@ from .tools import ToolContext, ToolRegistry
20
20
  from .utils import uuid7_string
21
21
  import typing
22
22
 
23
+ if typing.TYPE_CHECKING:
24
+ from .utils.session_persist import SessionRolloutRecorder
25
+
23
26
 
24
27
  EventHandler = Callable[[AgentEvent], None]
25
28
  NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
@@ -46,6 +49,7 @@ class AgentLoop:
46
49
  parallel_tool_calls: 'bool' = True,
47
50
  event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
48
51
  initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
52
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
49
53
  ) -> 'None':
50
54
  self._model_client = model_client
51
55
  self._tool_registry = tool_registry
@@ -53,6 +57,7 @@ class AgentLoop:
53
57
  self._parallel_tool_calls = parallel_tool_calls
54
58
  self._event_handler = event_handler
55
59
  self._history: 'typing.List[ConversationItem]' = list(initial_history)
60
+ self._rollout_recorder = rollout_recorder
56
61
  self.interrupt_asap = False
57
62
 
58
63
  @property
@@ -64,6 +69,18 @@ class AgentLoop:
64
69
  ) -> 'None':
65
70
  self._event_handler = event_handler
66
71
 
72
+ def replace_history(
73
+ self,
74
+ history: 'typing.Iterable[ConversationItem]',
75
+ ) -> 'None':
76
+ self._history = list(history)
77
+
78
+ def set_rollout_recorder(
79
+ self,
80
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]',
81
+ ) -> 'None':
82
+ self._rollout_recorder = rollout_recorder
83
+
67
84
  def _raise_if_interrupt_requested(
68
85
  self,
69
86
  turn_id: 'str',
@@ -83,8 +100,9 @@ class AgentLoop:
83
100
  ) -> 'TurnResult':
84
101
  turn_id = turn_id or uuid7_string()
85
102
  self.interrupt_asap = False
86
- for text in texts:
87
- self._history.append(UserMessage(text=text))
103
+ new_user_messages = [UserMessage(text=text) for text in texts]
104
+ self._history.extend(new_user_messages)
105
+ self._persist_history_items(new_user_messages)
88
106
 
89
107
  self._emit(
90
108
  "turn_started",
@@ -131,12 +149,15 @@ class AgentLoop:
131
149
  )
132
150
 
133
151
  tool_calls: 'typing.List[ToolCall]' = []
152
+ persisted_response_items: 'typing.List[ConversationItem]' = []
134
153
  for item in response.items:
135
154
  self._history.append(item)
155
+ persisted_response_items.append(item)
136
156
  if isinstance(item, AssistantMessage):
137
157
  last_assistant_message = item.text
138
158
  elif isinstance(item, ToolCall):
139
159
  tool_calls.append(item)
160
+ self._persist_history_items(persisted_response_items)
140
161
 
141
162
  if not tool_calls:
142
163
  self._raise_if_interrupt_requested(
@@ -160,7 +181,10 @@ class AgentLoop:
160
181
 
161
182
  tool_results = await self._execute_tool_batch(turn_id, tool_calls)
162
183
  self._history.extend(tool_results)
163
- self._history.extend(self._build_follow_up_messages(tool_results))
184
+ self._persist_history_items(tool_results)
185
+ follow_up_messages = self._build_follow_up_messages(tool_results)
186
+ self._history.extend(follow_up_messages)
187
+ self._persist_history_items(follow_up_messages)
164
188
  self._raise_if_interrupt_requested(
165
189
  turn_id,
166
190
  iteration,
@@ -226,7 +250,12 @@ class AgentLoop:
226
250
  call: 'ToolCall',
227
251
  prior_results: 'typing.Tuple[ToolResult, ...]' = (),
228
252
  ) -> 'ToolResult':
229
- self._emit("tool_started", turn_id, tool_name=call.name, call_id=call.call_id)
253
+ payload: 'typing.Dict[str, object]' = {
254
+ "tool_name": call.name,
255
+ "call_id": call.call_id,
256
+ "call": call,
257
+ }
258
+ self._emit("tool_started", turn_id, **payload)
230
259
  result = await self._tool_registry.execute(
231
260
  call,
232
261
  ToolContext(
@@ -235,13 +264,8 @@ class AgentLoop:
235
264
  collaboration_mode=self._context_manager.collaboration_mode,
236
265
  ),
237
266
  )
238
- payload: 'typing.Dict[str, object]' = {
239
- "tool_name": call.name,
240
- "call_id": call.call_id,
241
- "is_error": result.is_error,
242
- "call": call,
243
- "result": result,
244
- }
267
+ payload["result"] = result
268
+ payload["is_error"] = result.is_error
245
269
  self._emit("tool_completed", turn_id, **payload)
246
270
  return result
247
271
 
@@ -250,11 +274,27 @@ class AgentLoop:
250
274
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
251
275
  )
252
276
 
277
+ def _persist_history_items(
278
+ self,
279
+ items: 'typing.Iterable[ConversationItem]',
280
+ ) -> 'None':
281
+ recorder = self._rollout_recorder
282
+ if recorder is None:
283
+ return
284
+ try:
285
+ recorder.append_history_items(items)
286
+ except Exception: # pragma: no cover - persistence should not break turns
287
+ return
288
+
253
289
  def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
254
290
  if event.kind == "assistant_delta":
255
291
  self._emit("assistant_delta", turn_id, **event.payload)
256
292
  elif event.kind == "tool_call":
257
293
  self._emit("tool_called", turn_id, **event.payload)
294
+ elif event.kind == "token_count":
295
+ self._emit("token_count", turn_id, **event.payload)
296
+ elif event.kind == "stream_error":
297
+ self._emit("stream_error", turn_id, **event.payload)
258
298
 
259
299
  def _build_follow_up_messages(
260
300
  self,
@@ -20,7 +20,15 @@ from .portable import bootstrap_called_home, upload_codex_home
20
20
  from .protocol import AgentEvent
21
21
  from .runtime import AgentRuntime
22
22
  from .runtime_services import RuntimeEnvironment, create_runtime_environment
23
- from .utils import CliSessionView, load_codex_dotenv
23
+ from .utils import CliSessionView, load_codex_dotenv, uuid7_string
24
+ from .utils.compactor import compact_agent_loop
25
+ from .utils.session_persist import (
26
+ SessionRolloutRecorder,
27
+ conversation_history_to_turns,
28
+ list_resumable_sessions,
29
+ load_resumed_session,
30
+ resolve_codex_home,
31
+ )
24
32
  import typing
25
33
 
26
34
  EXIT_COMMANDS = {"/exit", "/quit"}
@@ -28,6 +36,8 @@ HISTORY_COMMAND = "/history"
28
36
  TITLE_COMMAND = "/title"
29
37
  MODEL_COMMAND = "/model"
30
38
  QUEUE_COMMAND = "/queue"
39
+ RESUME_COMMAND = "/resume"
40
+ COMPACT_COMMAND = "/compact"
31
41
  CliSessionMode = Literal["exec", "tui"]
32
42
  LOCAL_RESPONSES_SERVER_API_KEY_ENV = "PYCODEX_LOCAL_RESPONSES_SERVER_KEY"
33
43
  CLI_ORIGINATOR = "codex-tui"
@@ -284,6 +294,9 @@ def build_runtime(
284
294
  collaboration_mode=collaboration_mode,
285
295
  include_collaboration_instructions=use_tui_context,
286
296
  )
297
+ session_id = getattr(client, "_session_id", None) or uuid7_string()
298
+ if hasattr(client, "_session_id"):
299
+ client._session_id = session_id
287
300
  subagent_context_manager = ContextManager.from_codex_config(
288
301
  config_path,
289
302
  profile,
@@ -293,6 +306,14 @@ def build_runtime(
293
306
  runtime_environment = create_runtime_environment()
294
307
  runtime_environment.request_user_input_manager.set_handler(None)
295
308
  runtime_environment.request_permissions_manager.set_handler(None)
309
+ rollout_recorder = SessionRolloutRecorder.create(
310
+ resolve_codex_home(config_path),
311
+ session_id,
312
+ context_manager.cwd,
313
+ getattr(client, "_originator", CLI_ORIGINATOR),
314
+ getattr(getattr(client, "_config", None), "provider_name", None),
315
+ context_manager.resolve_base_instructions(),
316
+ )
296
317
 
297
318
  def make_subagent_runtime_builder(base_client):
298
319
  def build_subagent_runtime(
@@ -330,7 +351,10 @@ def build_runtime(
330
351
  )
331
352
  return AgentRuntime(
332
353
  AgentLoop(
333
- client, get_tools(runtime_environment, exec_mode=True), context_manager
354
+ client,
355
+ get_tools(runtime_environment, exec_mode=True),
356
+ context_manager,
357
+ rollout_recorder=rollout_recorder,
334
358
  ),
335
359
  runtime_environment=runtime_environment,
336
360
  )
@@ -498,10 +522,14 @@ async def prompt_request_permissions(
498
522
  async def run_interactive_session(
499
523
  runtime: 'AgentRuntime',
500
524
  json_mode: 'bool',
525
+ config_path: 'typing.Union[str, None]' = None,
501
526
  ) -> 'int':
502
527
  worker = asyncio.create_task(runtime.run_forever())
528
+ context_window_tokens = runtime._agent_loop._context_manager.resolve_model_context_window()
503
529
  view = CliSessionView()
530
+ view.set_context_window_tokens(context_window_tokens)
504
531
  model_client = runtime._agent_loop._model_client
532
+ codex_home = resolve_codex_home(config_path)
505
533
  runtime.set_event_handler(view.handle_event)
506
534
  pending_turn_tasks: 'typing.Set[asyncio.Task[None]]' = set()
507
535
  runtime_environment = runtime.runtime_environment
@@ -515,7 +543,7 @@ async def run_interactive_session(
515
543
  lambda payload: prompt_request_permissions(view, payload)
516
544
  )
517
545
  view.write_line("pycodex interactive mode. Type /exit to quit.")
518
- view.write_line("Extra commands: /history, /title, /model")
546
+ view.write_line("Extra commands: /history, /title, /model, /resume, /compact")
519
547
  try:
520
548
 
521
549
  def has_pending_turn_tasks() -> 'bool':
@@ -524,6 +552,39 @@ async def run_interactive_session(
524
552
  )
525
553
  return bool(pending_turn_tasks)
526
554
 
555
+ async def run_manual_compact() -> 'None':
556
+ agent_loop = runtime._agent_loop
557
+ if not agent_loop.history:
558
+ view.write_line("Nothing to compact.")
559
+ return
560
+
561
+ compact_turn_id = uuid7_string()
562
+
563
+ def handle_compact_stream_event(event) -> 'None':
564
+ if event.kind not in {"token_count", "stream_error"}:
565
+ return
566
+ view.handle_event(
567
+ AgentEvent(
568
+ kind=event.kind,
569
+ turn_id=compact_turn_id,
570
+ payload=dict(event.payload),
571
+ )
572
+ )
573
+
574
+ view.write_line("Compacting conversation history...")
575
+ compact_result = await compact_agent_loop(
576
+ agent_loop,
577
+ handle_compact_stream_event,
578
+ )
579
+ if compact_result is None:
580
+ view.write_line("Nothing to compact.")
581
+ return
582
+ view.load_session_history(
583
+ getattr(view, "_title", None),
584
+ conversation_history_to_turns(compact_result.history),
585
+ )
586
+ view.write_line(compact_result.display_text())
587
+
527
588
  async def wait_for_turn_result(future) -> 'None':
528
589
  try:
529
590
  result = await future
@@ -556,6 +617,50 @@ async def run_interactive_session(
556
617
  if prompt_text == TITLE_COMMAND:
557
618
  view.show_title()
558
619
  continue
620
+ if prompt_text == RESUME_COMMAND:
621
+ sessions = list_resumable_sessions(codex_home)
622
+ if not sessions:
623
+ view.write_line("No resumable sessions found.")
624
+ continue
625
+ view.write_line("Available sessions:")
626
+ for index, session in enumerate(sessions, start=1):
627
+ view.write_line(f"[{index}] {session['preview']}")
628
+ continue
629
+ if prompt_text.startswith(f"{RESUME_COMMAND} "):
630
+ if has_pending_turn_tasks():
631
+ view.write_line(
632
+ "Cannot resume while work is running or queued."
633
+ )
634
+ continue
635
+ resume_target = prompt_text[len(RESUME_COMMAND) :].strip()
636
+ try:
637
+ resumed = load_resumed_session(codex_home, resume_target)
638
+ runtime._agent_loop.replace_history(resumed["history"])
639
+ if hasattr(model_client, "_session_id"):
640
+ model_client._session_id = str(resumed["session_id"])
641
+ runtime._agent_loop.set_rollout_recorder(
642
+ SessionRolloutRecorder.resume(resumed["rollout_path"])
643
+ )
644
+ view.load_session_history(
645
+ str(resumed["title"]),
646
+ tuple(resumed["turns"]),
647
+ )
648
+ view.write_line(f"Resumed session: {resumed['title']}")
649
+ view.show_history()
650
+ except Exception as exc: # pragma: no cover - defensive surface
651
+ view.show_error(str(exc))
652
+ continue
653
+ if prompt_text == COMPACT_COMMAND:
654
+ if has_pending_turn_tasks():
655
+ view.write_line(
656
+ "Cannot compact while work is running or queued."
657
+ )
658
+ continue
659
+ try:
660
+ await run_manual_compact()
661
+ except Exception as exc: # pragma: no cover - defensive surface
662
+ view.show_error(str(exc))
663
+ continue
559
664
  if prompt_text.startswith(f"{QUEUE_COMMAND} "):
560
665
  queued_text = prompt_text[len(QUEUE_COMMAND) :].strip()
561
666
  if not queued_text:
@@ -663,6 +768,7 @@ async def run_cli(args: 'argparse.Namespace') -> 'int':
663
768
  return await run_interactive_session(
664
769
  runtime,
665
770
  args.json,
771
+ args.config,
666
772
  )
667
773
  else:
668
774
  prompt_text = resolve_prompt_text(args.prompt)
@@ -30,6 +30,7 @@ DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
30
30
  PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
31
31
  Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
32
32
  )
33
+ DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
33
34
  PERMISSIONS_SANDBOX_PROMPTS_PATH = (
34
35
  Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
35
36
  )
@@ -76,6 +77,7 @@ class ContextConfig:
76
77
  codex_home: 'typing.Union[Path, None]' = None
77
78
  project_doc_max_bytes: 'typing.Union[int, None]' = None
78
79
  model: 'typing.Union[str, None]' = None
80
+ model_context_window: 'typing.Union[int, None]' = None
79
81
  personality: 'typing.Union[str, None]' = None
80
82
  approval_policy: 'typing.Union[str, None]' = None
81
83
  sandbox_mode: 'typing.Union[str, None]' = None
@@ -117,6 +119,7 @@ class ContextConfig:
117
119
  codex_home=codex_home,
118
120
  project_doc_max_bytes=_normalize_int(selected.get("project_doc_max_bytes")),
119
121
  model=_normalize_text(selected.get("model")),
122
+ model_context_window=_normalize_int(selected.get("model_context_window")),
120
123
  personality=_normalize_text(selected.get("personality")),
121
124
  approval_policy=_normalize_text(selected.get("approval_policy")),
122
125
  sandbox_mode=_normalize_text(selected.get("sandbox_mode")),
@@ -240,6 +243,26 @@ class ContextManager:
240
243
  return resolved
241
244
  return self._default_base_instructions
242
245
 
246
+ def resolve_model_context_window(self) -> 'typing.Union[int, None]':
247
+ model_metadata = None
248
+ model_slug = self._config.model
249
+ if model_slug is not None:
250
+ model_metadata = _load_models_by_slug().get(model_slug)
251
+
252
+ context_window = self._config.model_context_window
253
+ if context_window is None and model_metadata is not None:
254
+ context_window = _normalize_int(model_metadata.get("context_window"))
255
+ if context_window is None:
256
+ return None
257
+ effective_percent = None
258
+ if model_metadata is not None:
259
+ effective_percent = _normalize_int(
260
+ model_metadata.get("effective_context_window_percent")
261
+ )
262
+ if effective_percent is None:
263
+ effective_percent = DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT
264
+ return context_window * max(effective_percent, 0) // 100
265
+
243
266
  def _resolve_model_instructions(self) -> 'typing.Union[str, None]':
244
267
  model_slug = self._config.model
245
268
  if model_slug is None: