python-codex 0.1.2__tar.gz → 0.1.4__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 (104) hide show
  1. python_codex-0.1.4/.github/workflows/test.yml +82 -0
  2. {python_codex-0.1.2 → python_codex-0.1.4}/AGENTS.md +6 -0
  3. {python_codex-0.1.2 → python_codex-0.1.4}/PKG-INFO +32 -11
  4. {python_codex-0.1.2 → python_codex-0.1.4}/README.md +17 -2
  5. {python_codex-0.1.2 → python_codex-0.1.4}/README_ZH.md +12 -2
  6. {python_codex-0.1.2 → python_codex-0.1.4}/docs/ALIGNMENT.md +26 -0
  7. {python_codex-0.1.2 → python_codex-0.1.4}/docs/responses_server/README.md +1 -0
  8. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/__init__.py +5 -1
  9. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/agent.py +89 -51
  10. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/cli.py +152 -45
  11. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/collaboration.py +6 -7
  12. python_codex-0.1.4/pycodex/compat.py +99 -0
  13. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/context.py +110 -87
  14. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/doctor.py +40 -40
  15. python_codex-0.1.4/pycodex/model.py +872 -0
  16. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/portable.py +33 -33
  17. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/portable_server.py +22 -21
  18. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/models.json +30 -0
  19. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/protocol.py +84 -86
  20. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/runtime.py +36 -35
  21. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/runtime_services.py +69 -69
  22. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/agent_tool_schemas.py +0 -2
  23. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/apply_patch_tool.py +45 -46
  24. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/base_tool.py +35 -36
  25. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/close_agent_tool.py +2 -4
  26. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/code_mode_manager.py +61 -61
  27. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/exec_command_tool.py +5 -6
  28. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/exec_runtime.js +3 -3
  29. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/exec_tool.py +2 -4
  30. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/grep_files_tool.py +10 -11
  31. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/list_dir_tool.py +8 -9
  32. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/read_file_tool.py +13 -14
  33. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/request_permissions_tool.py +2 -4
  34. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/request_user_input_tool.py +13 -14
  35. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/resume_agent_tool.py +2 -4
  36. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/send_input_tool.py +8 -9
  37. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/shell_command_tool.py +5 -6
  38. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/shell_tool.py +5 -6
  39. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/spawn_agent_tool.py +4 -5
  40. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/unified_exec_manager.py +62 -61
  41. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/update_plan_tool.py +4 -5
  42. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/view_image_tool.py +4 -5
  43. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/wait_agent_tool.py +2 -4
  44. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/wait_tool.py +4 -5
  45. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/web_search_tool.py +1 -3
  46. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/write_stdin_tool.py +4 -5
  47. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/utils/__init__.py +4 -0
  48. python_codex-0.1.4/pycodex/utils/compactor.py +189 -0
  49. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/utils/dotenv.py +6 -6
  50. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/utils/get_env.py +37 -33
  51. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/utils/random_ids.py +1 -2
  52. python_codex-0.1.4/pycodex/utils/session_persist.py +483 -0
  53. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/utils/visualize.py +197 -83
  54. python_codex-0.1.4/pyproject.toml +50 -0
  55. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/app.py +32 -20
  56. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/config.py +17 -17
  57. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/payload_processors.py +26 -17
  58. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/server.py +11 -11
  59. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/session_store.py +10 -10
  60. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/stream_router.py +83 -64
  61. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/tools/custom_adapter.py +12 -12
  62. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/tools/web_search.py +33 -33
  63. {python_codex-0.1.2 → python_codex-0.1.4}/tests/compare_request_user_input_roundtrip.py +71 -71
  64. {python_codex-0.1.2 → python_codex-0.1.4}/tests/compare_steer_request_bodies.py +69 -69
  65. {python_codex-0.1.2 → python_codex-0.1.4}/tests/compare_tool_schemas.py +52 -51
  66. {python_codex-0.1.2 → python_codex-0.1.4}/tests/fake_responses_server.py +43 -42
  67. {python_codex-0.1.2 → python_codex-0.1.4}/tests/fakes.py +10 -10
  68. {python_codex-0.1.2 → python_codex-0.1.4}/tests/responses_server/fake_chat_completions_server.py +51 -42
  69. {python_codex-0.1.2 → python_codex-0.1.4}/tests/responses_server/test_server.py +109 -22
  70. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_agent.py +42 -12
  71. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_builtin_tools.py +34 -37
  72. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_cli.py +1168 -154
  73. python_codex-0.1.4/tests/test_compactor.py +64 -0
  74. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_context.py +22 -7
  75. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_doctor.py +8 -9
  76. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_fake_responses_server.py +7 -6
  77. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_model.py +298 -34
  78. {python_codex-0.1.2 → python_codex-0.1.4}/tests/test_portable.py +14 -14
  79. python_codex-0.1.2/.github/workflows/test.yml +0 -65
  80. python_codex-0.1.2/pycodex/model.py +0 -533
  81. python_codex-0.1.2/pyproject.toml +0 -41
  82. {python_codex-0.1.2 → python_codex-0.1.4}/.github/workflows/publish.yml +0 -0
  83. {python_codex-0.1.2 → python_codex-0.1.4}/.gitignore +0 -0
  84. {python_codex-0.1.2 → python_codex-0.1.4}/LICENSE +0 -0
  85. {python_codex-0.1.2 → python_codex-0.1.4}/docs/CONTEXT.md +0 -0
  86. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/collaboration_default.md +0 -0
  87. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/collaboration_plan.md +0 -0
  88. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/default_base_instructions.md +0 -0
  89. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/exec_tools.json +0 -0
  90. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  91. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  92. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  93. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  94. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  95. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  96. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  97. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  98. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/prompts/subagent_tools.json +0 -0
  99. {python_codex-0.1.2 → python_codex-0.1.4}/pycodex/tools/__init__.py +0 -0
  100. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/__init__.py +0 -0
  101. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/__main__.py +0 -0
  102. {python_codex-0.1.2 → python_codex-0.1.4}/responses_server/tools/__init__.py +0 -0
  103. {python_codex-0.1.2 → python_codex-0.1.4}/tests/TESTS.md +0 -0
  104. {python_codex-0.1.2 → python_codex-0.1.4}/tests/__init__.py +0 -0
@@ -0,0 +1,82 @@
1
+ name: test
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ pull_request:
6
+ push:
7
+ branches:
8
+ - main
9
+
10
+ jobs:
11
+ pytest-modern:
12
+ name: pytest (Python ${{ matrix.python-version }})
13
+ runs-on: ubuntu-22.04
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version:
18
+ - "3.7"
19
+ - "3.8"
20
+ - "3.10"
21
+ steps:
22
+ - name: Check out repository
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+
30
+ - name: Set up uv
31
+ uses: astral-sh/setup-uv@v5
32
+
33
+ - name: Sync dependencies
34
+ run: uv sync --dev
35
+
36
+ - name: Run pytest
37
+ run: uv run pytest
38
+
39
+ pytest-py36:
40
+ name: pytest (Python 3.6)
41
+ runs-on: ubuntu-22.04
42
+ container:
43
+ image: python:3.6.15-slim-bullseye
44
+ steps:
45
+ - name: Install system dependencies
46
+ run: |
47
+ apt-get update
48
+ apt-get install -y --no-install-recommends git nodejs npm
49
+
50
+ - name: Check out repository
51
+ uses: actions/checkout@v4
52
+
53
+ - name: Mark workspace as safe for git
54
+ run: git config --global --add safe.directory "$PWD"
55
+
56
+ - name: Show runtime versions
57
+ run: |
58
+ python --version
59
+ pip --version
60
+ git --version
61
+ node --version
62
+
63
+ - name: Install Python 3.6 compatibility dependencies
64
+ run: |
65
+ python -m pip install -i https://pypi.org/simple \
66
+ "dataclasses>=0.8" \
67
+ "typing_extensions>=4.1.1,<4.2" \
68
+ "importlib_metadata>=4.8.3,<5" \
69
+ "tomli>=1.2.3,<2" \
70
+ "requests>=2.27.1" \
71
+ "prompt-toolkit>=3.0.36,<3.1" \
72
+ "loguru>=0.7.3,<1" \
73
+ "cryptography>=40.0.2,<41" \
74
+ "fastapi>=0.83,<0.84" \
75
+ "uvicorn>=0.16,<0.17" \
76
+ "pytest>=6.2.5,<7" \
77
+ "pytest-asyncio>=0.16,<0.17"
78
+
79
+ - name: Run Python 3.6 pytest
80
+ env:
81
+ PYTHONPATH: ${{ github.workspace }}
82
+ run: python -m pytest -c /dev/null
@@ -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,16 +1,22 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
- Requires-Python: >=3.10
7
- Requires-Dist: cryptography>=3.4
8
- Requires-Dist: fastapi>=0.115
6
+ Requires-Python: >=3.6.2
7
+ Requires-Dist: cryptography<41,>=40.0.2; python_version < '3.7'
8
+ Requires-Dist: cryptography>=40.0.2; python_version >= '3.7'
9
+ Requires-Dist: dataclasses>=0.8; python_version < '3.7'
10
+ Requires-Dist: fastapi<0.84,>=0.83.0; python_version < '3.7'
11
+ Requires-Dist: fastapi>=0.83.0; python_version >= '3.7'
12
+ Requires-Dist: importlib-metadata>=4.8.3; python_version < '3.8'
9
13
  Requires-Dist: loguru>=0.7.3
10
- Requires-Dist: prompt-toolkit>=3.0
11
- Requires-Dist: requests>=2.31
12
- Requires-Dist: tomli>=2.0; python_version < '3.11'
13
- Requires-Dist: uvicorn>=0.32
14
+ Requires-Dist: prompt-toolkit>=3.0.36
15
+ Requires-Dist: requests>=2.27.1
16
+ Requires-Dist: tomli<2,>=1.2.3; python_version < '3.11'
17
+ Requires-Dist: typing-extensions>=4.1.1; python_version < '3.8'
18
+ Requires-Dist: uvicorn<0.17,>=0.16.0; python_version < '3.7'
19
+ Requires-Dist: uvicorn>=0.16.0; python_version >= '3.7'
14
20
  Description-Content-Type: text/markdown
15
21
 
16
22
  # pycodex
@@ -66,7 +72,7 @@ Intentionally not included yet:
66
72
 
67
73
  - TUI / streaming incremental rendering
68
74
  - MCP / connectors / sandbox / approvals
69
- - memory / compact / hooks / review mode
75
+ - memory / compact / review mode
70
76
  - a full production OpenAI adapter surface
71
77
 
72
78
  All of those can be layered on later. For now, the project is focused on
@@ -168,9 +174,24 @@ Current behavior:
168
174
  - interactive mode shows a compact event stream for user-visible phases such as
169
175
  tool execution and model follow-up after tool results
170
176
  - assistant text is printed from streaming deltas directly
171
- - interactive mode supports `/history`, `/title`, and `/model`
177
+ - interactive mode supports `/history`, `/title`, `/model`, `/resume`, and `/compact`
172
178
  - `/model <name>` switches the model used by later turns in the current
173
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
174
195
  - steer is enabled by default in interactive mode: normal input goes into the
175
196
  runtime steer path, the current request stops at the next safe boundary, and
176
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` 接口对齐(返回空结果)
@@ -1,3 +1,7 @@
1
+ from .compat import patch_asyncio
2
+
3
+ patch_asyncio()
4
+
1
5
  from .agent import AgentLoop
2
6
  from .context import ContextConfig, ContextManager
3
7
  from .model import (
@@ -60,7 +64,7 @@ from .tools import (
60
64
  WriteStdinTool,
61
65
  )
62
66
 
63
- def debug(stop: bool = False):
67
+ def debug(stop: 'bool' = False):
64
68
 
65
69
  import socket
66
70
 
@@ -1,8 +1,7 @@
1
- from __future__ import annotations
2
1
 
3
2
  import asyncio
4
3
  import json
5
- from collections.abc import Callable
4
+ from typing import Callable
6
5
 
7
6
  from .context import ContextManager
8
7
  from .model import ModelClient
@@ -19,10 +18,14 @@ from .protocol import (
19
18
  )
20
19
  from .tools import ToolContext, ToolRegistry
21
20
  from .utils import uuid7_string
21
+ import typing
22
+
23
+ if typing.TYPE_CHECKING:
24
+ from .utils.session_persist import SessionRolloutRecorder
22
25
 
23
26
 
24
27
  EventHandler = Callable[[AgentEvent], None]
25
- NOOP_EVENT_HANDLER: EventHandler = lambda _event: None
28
+ NOOP_EVENT_HANDLER: 'EventHandler' = lambda _event: None
26
29
 
27
30
 
28
31
  class TurnInterrupted(RuntimeError):
@@ -40,51 +43,66 @@ class AgentLoop:
40
43
 
41
44
  def __init__(
42
45
  self,
43
- model_client: ModelClient,
44
- tool_registry: ToolRegistry,
45
- context_manager: ContextManager | None = None,
46
- parallel_tool_calls: bool = True,
47
- event_handler: EventHandler = NOOP_EVENT_HANDLER,
48
- initial_history: tuple[ConversationItem, ...] = (),
49
- ) -> None:
46
+ model_client: 'ModelClient',
47
+ tool_registry: 'ToolRegistry',
48
+ context_manager: 'typing.Union[ContextManager, None]' = None,
49
+ parallel_tool_calls: 'bool' = True,
50
+ event_handler: 'EventHandler' = NOOP_EVENT_HANDLER,
51
+ initial_history: 'typing.Tuple[ConversationItem, ...]' = (),
52
+ rollout_recorder: 'typing.Union[SessionRolloutRecorder, None]' = None,
53
+ ) -> 'None':
50
54
  self._model_client = model_client
51
55
  self._tool_registry = tool_registry
52
56
  self._context_manager = context_manager or ContextManager()
53
57
  self._parallel_tool_calls = parallel_tool_calls
54
58
  self._event_handler = event_handler
55
- self._history: list[ConversationItem] = list(initial_history)
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
59
- def history(self) -> tuple[ConversationItem, ...]:
64
+ def history(self) -> 'typing.Tuple[ConversationItem, ...]':
60
65
  return tuple(self._history)
61
66
 
62
67
  def set_event_handler(
63
- self, event_handler: EventHandler = NOOP_EVENT_HANDLER
64
- ) -> None:
68
+ self, event_handler: 'EventHandler' = NOOP_EVENT_HANDLER
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
- turn_id: str,
70
- iteration: int,
71
- output_text: str | None = None,
72
- ) -> None:
86
+ turn_id: 'str',
87
+ iteration: 'int',
88
+ output_text: 'typing.Union[str, None]' = None,
89
+ ) -> 'None':
73
90
  if self.interrupt_asap:
74
91
  self.interrupt_asap = False
75
- payload: dict[str, object] = {"iteration": iteration}
92
+ payload: 'typing.Dict[str, object]' = {"iteration": iteration}
76
93
  if output_text is not None:
77
94
  payload["output_text"] = output_text
78
95
  self._emit("turn_interrupted", turn_id, **payload)
79
96
  raise TurnInterrupted("turn interrupted")
80
97
 
81
98
  async def run_turn(
82
- self, texts: list[str], turn_id: str | None = None
83
- ) -> TurnResult:
99
+ self, texts: 'typing.List[str]', turn_id: 'typing.Union[str, None]' = None
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",
@@ -93,10 +111,8 @@ class AgentLoop:
93
111
  user_texts=list(texts),
94
112
  )
95
113
 
96
- last_assistant_message: str | None = None
97
- final_response_items: tuple[
98
- AssistantMessage | ToolCall | ReasoningItem, ...
99
- ] = ()
114
+ last_assistant_message: 'typing.Union[str, None]' = None
115
+ final_response_items: 'typing.Tuple[\n typing.Union[typing.Union[AssistantMessage, ToolCall], ReasoningItem], ...\n]' = ()
100
116
 
101
117
  iteration = 0
102
118
  try:
@@ -132,13 +148,16 @@ class AgentLoop:
132
148
  item_count=len(response.items),
133
149
  )
134
150
 
135
- tool_calls: list[ToolCall] = []
151
+ tool_calls: 'typing.List[ToolCall]' = []
152
+ persisted_response_items: 'typing.List[ConversationItem]' = []
136
153
  for item in response.items:
137
154
  self._history.append(item)
155
+ persisted_response_items.append(item)
138
156
  if isinstance(item, AssistantMessage):
139
157
  last_assistant_message = item.text
140
158
  elif isinstance(item, ToolCall):
141
159
  tool_calls.append(item)
160
+ self._persist_history_items(persisted_response_items)
142
161
 
143
162
  if not tool_calls:
144
163
  self._raise_if_interrupt_requested(
@@ -162,7 +181,10 @@ class AgentLoop:
162
181
 
163
182
  tool_results = await self._execute_tool_batch(turn_id, tool_calls)
164
183
  self._history.extend(tool_results)
165
- 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)
166
188
  self._raise_if_interrupt_requested(
167
189
  turn_id,
168
190
  iteration,
@@ -182,11 +204,11 @@ class AgentLoop:
182
204
 
183
205
  async def _execute_tool_batch(
184
206
  self,
185
- turn_id: str,
186
- tool_calls: list[ToolCall],
187
- ) -> list[ToolResult]:
188
- results: list[ToolResult] = []
189
- parallel_batch: list[ToolCall] = []
207
+ turn_id: 'str',
208
+ tool_calls: 'typing.List[ToolCall]',
209
+ ) -> 'typing.List[ToolResult]':
210
+ results: 'typing.List[ToolResult]' = []
211
+ parallel_batch: 'typing.List[ToolCall]' = []
190
212
 
191
213
  for call in tool_calls:
192
214
  can_run_parallel = (
@@ -224,11 +246,16 @@ class AgentLoop:
224
246
 
225
247
  async def _run_single_tool(
226
248
  self,
227
- turn_id: str,
228
- call: ToolCall,
229
- prior_results: tuple[ToolResult, ...] = (),
230
- ) -> ToolResult:
231
- self._emit("tool_started", turn_id, tool_name=call.name, call_id=call.call_id)
249
+ turn_id: 'str',
250
+ call: 'ToolCall',
251
+ prior_results: 'typing.Tuple[ToolResult, ...]' = (),
252
+ ) -> 'ToolResult':
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)
232
259
  result = await self._tool_registry.execute(
233
260
  call,
234
261
  ToolContext(
@@ -237,32 +264,43 @@ class AgentLoop:
237
264
  collaboration_mode=self._context_manager.collaboration_mode,
238
265
  ),
239
266
  )
240
- payload: dict[str, object] = {
241
- "tool_name": call.name,
242
- "call_id": call.call_id,
243
- "is_error": result.is_error,
244
- "call": call,
245
- "result": result,
246
- }
267
+ payload["result"] = result
268
+ payload["is_error"] = result.is_error
247
269
  self._emit("tool_completed", turn_id, **payload)
248
270
  return result
249
271
 
250
- def _emit(self, kind: str, turn_id: str, **payload: object) -> None:
272
+ def _emit(self, kind: 'str', turn_id: 'str', **payload: 'object') -> 'None':
251
273
  self._event_handler(
252
274
  AgentEvent(kind=kind, turn_id=turn_id, payload=dict(payload))
253
275
  )
254
276
 
255
- def _handle_model_stream_event(self, turn_id: str, event: ModelStreamEvent) -> None:
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
+
289
+ def _handle_model_stream_event(self, turn_id: 'str', event: 'ModelStreamEvent') -> 'None':
256
290
  if event.kind == "assistant_delta":
257
291
  self._emit("assistant_delta", turn_id, **event.payload)
258
292
  elif event.kind == "tool_call":
259
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)
260
298
 
261
299
  def _build_follow_up_messages(
262
300
  self,
263
- tool_results: list[ToolResult],
264
- ) -> list[UserMessage]:
265
- follow_ups: list[UserMessage] = []
301
+ tool_results: 'typing.List[ToolResult]',
302
+ ) -> 'typing.List[UserMessage]':
303
+ follow_ups: 'typing.List[UserMessage]' = []
266
304
  for result in tool_results:
267
305
  statuses = None
268
306
  if (