雨天小六

读懂 Codex(2.11):Compact、Review、UserShell 等非普通 Task 的入口

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#软件架构#Compact#Review

Codex 的 ActiveTurn 不只承载普通聊天。手动 Compact、代码 Review 和 !command 都复用 SessionTask 的 启动、中止、指标与终态外壳,但它们不走相同的模型循环;Rollback 甚至不是 Task,而是受保护的历史 重建操作。

把它们统称为“特殊命令”会掩盖最重要的差别:谁调用模型、谁创建子 Session、谁修改历史、谁可以嵌入 活动 Turn,以及每条路径应发哪些生命周期事件。

SessionTask 是共享外壳,不是统一算法

SessionTask trait 只要求:

  • kind():ActiveTurn/telemetry 识别;
  • span_name():全生命周期 trace;
  • run(context, input, cancellation):具体工作;
  • 可选 abort():协作取消后的专用清理。

Regular、Compact、Review、UserShell 都进入 start_task,因此共享:ActiveTurn 独占、AgentControl execution guard、CancellationToken、rollout flush、on_task_finished、TurnComplete/Aborted 和 idle lifecycle。

但“共享外壳”不代表共享 TurnStarted 逻辑。RegularTask 和 standalone UserShell 在各自 run 中显式发送 TurnStarted;Review 当前代码依赖共享完成外壳,却尚未发送对称的 parent TurnStarted,源码留有 TODO。

SessionTask 共享 start finish abort 外壳,并分叉到 Regular、Compact、Review 和 UserShell 不同运行语义
图 2.11-1:生命周期框架相同,模型、历史和事件语义仍由每种 Task 自己定义。

CompactTask:用专用算法替换上下文

Op::Compact 创建默认 TurnContext,以空输入启动 CompactTask。它被标记为 TaskKind::Compact,因此 活动期间拒绝 steering。

Compact 分派顺序是:

  1. TokenBudget Feature 启用 → token-budget manual compact;
  2. Provider 支持 remote compact:
    • RemoteCompactionV2 启用 → remote v2;
    • 否则 → remote legacy;
  3. 其他 Provider → local compact,用 config.compact_prompt 或默认 summarization prompt。
async def compact_task(session, turn):
    if turn.features.token_budget:
        return await run_token_budget_manual_compact(session, turn)
    if provider_supports_remote_compact(turn.provider):
        if turn.features.remote_compaction_v2:
            return await run_remote_v2(session, turn)
        return await run_remote(session, turn)
    prompt = turn.config.compact_prompt or DEFAULT_SUMMARIZATION_PROMPT
    return await run_local_compact(session, turn, synthetic_user_input(prompt))

Compaction 的产物是替换或追加后的历史摘要和 ContextCompacted 等事件,不是普通 assistant final answer。 CompactTask 只把 TurnAborted 向外抛给统一中止终态;其他 compact 实现错误通常已经在自身路径发出事件, Task 最终返回 Ok(None) 收口。

Regular Turn 中因 context limit 触发的是 inline auto compact,不另开用户 Turn;手动 Op::Compact 才是 这里的独立特殊 Task。

Review:父 Task 内再启动隔离的子 Codex

Review handler 先解析 target/diff request,再构造 review-specific TurnContext:

  • model 可由 review_model 覆盖;
  • 禁用 WebSearch、Goals;
  • multi-agent version 设为 Disabled;
  • 继承必要的环境、shell policy、permission profile 和 skill snapshot;
  • 用合成 review prompt 作为 Task 输入;
  • 发 EnteredReviewMode Item,让 UI 切换展示。

ReviewTask 的 run 不直接调用父 Session 的 run_turn,而是启动一次性 Review sub-Codex。子配置进一步:

  • base instructions 固定为 REVIEW_PROMPT;
  • approval policy 固定 Never;
  • 禁 web search、Collab 与 MultiAgentV2;
  • source 标记 SubAgentSource::Review;
  • 可使用 review model。

这形成权限与上下文隔离:Reviewer 不能在检查代码时再派生 Agent、弹审批或随意搜索网络。

父 Session 的 ReviewTask 创建受限子 Codex、过滤转发事件、解析 ReviewOutput 并写回父历史
图 2.11-2:Review 是父 Turn 托管的一次性子会话,不是给普通 Prompt 加一句“请审查”。

Review 事件为什么要过滤

父 Task 读取 child SessionIo.rx_event

  • 普通工具/状态事件可转发到父 Turn;
  • assistant AgentMessage 暂存,只在下一条消息到来时转发前一条;
  • assistant 的 ItemCompleted 与 text delta 被抑制,避免旧兼容事件提前显示非结构化终稿;
  • TurnComplete 的 last_agent_message 被解析为 ReviewOutput;
  • TurnAborted/Channel close 视为 interrupted。

解析先尝试整段 JSON,再提取第一个 {...} 区间;仍失败则把原文本放入 overall_explanation,保证 Reviewer 格式漂移不会让父 Turn 无输出。

退出 Review 时,父 Session 写入合成 user message 表达 review 结束语义,发 ExitedReviewMode Item,并把 结构化 review 渲染成 assistant history。中止也写明确的 interrupted guidance。最后显式 materialize rollout,因为 Review 可能发生在第一个普通 Turn 之前。

UserShell:不经过模型的 Turn

Op::RunUserShellCommand 表示用户显式输入的 !cmd。它执行默认 shell 语法,可以含 pipe、redirect 等, 并使用 ExecCommand/CommandExecution Item 流式报告。

入口先检查当前是否有 ActiveTurn:

模式调度生命周期历史写法
StandaloneTurnspawn UserShellCommandTask自己发 TurnStarted,共享外壳发 TurnCompleterecord_conversation_items + materialize
ActiveTurnAuxiliary独立 Tokio task,复用活动 TurnContext/Cancellation不发第二组 Started/Completeinject_no_new_turn
async def run_user_shell(session, command):
    active = await session.active_turn_context_and_token()
    if active:
        spawn(execute_shell(command, active.context, active.token, mode="auxiliary"))
    else:
        turn = await session.new_default_turn()
        await session.spawn_task(turn, [], UserShellTask(command))

Auxiliary 模式若再发 TurnStarted/Complete,会让一个公开 Turn 出现嵌套终态,App Server 无法正确维护 TurnState,所以源码明确禁止。

UserShell 的执行边界

它选择 TurnEnvironment,建立 exec env,处理 managed proxy env、runtime PATH prepend 与可选 shell snapshot, 再调用通用 exec runtime。Timeout 固定为一小时。输出形成 CommandExecution Started/Delta/Completed,失败 也形成 status=Failed 的 Item,而不是请求模型解释。

UserShell 是“用户直接执行”,不等同于模型 shell tool:审批语义、source 标记和模型 follow-up 都不同。

Rollback:历史重建,不是 SessionTask

Op::ThreadRollback 不进入 ActiveTurn Task 外壳。Handler 先检查:

  • num_turns >= 1
  • 当前没有 ActiveTurn;
  • Session 有 LiveThread persistence。

之后 flush 现有 writer、从 Store 加载历史,把 ThreadRolledBack(num_turns) marker 接到重放序列,调用 apply_rollout_reconstruction 重建内存状态,重新 arm rollout budget reminder、重算 token usage,再持久化 rollback marker 并发事件。

async def rollback(session, n):
    require(n >= 1)
    require(session.active_turn is None)
    live = require_persistence(session)
    await live.flush()
    stored = await live.load_history()
    marker = ThreadRolledBack(num_turns=n)
    await session.reconstruct(stored.items + [marker])
    await session.recompute_token_usage()
    await session.persist_and_emit(marker)

它明确不恢复本地文件变更。历史告诉模型“忘掉最后 N 个用户 Turn”,工作区仍保持当前磁盘状态;若需要 撤销 patch,必须由 Git 或编辑器提供另一条操作。

其他非模型 Op 为什么不做 Task

ThreadSettings、SetThreadMemoryMode、RefreshMcpServers、ReloadUserConfig 是短时 Thread 状态操作;审批回答 是唤醒 waiter;CleanBackgroundTerminals 是资源管理。把它们包装成 ActiveTurn 会制造无意义 Started/ Complete,并阻止真正用户 Turn,所以它们直接在 submission handler 中执行。

选择是否使用 SessionTask 的判断标准是:是否需要独占一段可中止、可计时并具有 Turn 终态的用户可见 工作,而不是“代码是否异步”。

失败矩阵

路径失败终态/恢复
Manual Compact 被 InterruptTurnAborted历史保留中止标记
Compact provider 错误实现层发错误,Task 收口不伪造摘要
Review request 解析失败Error,不 spawn Task无 Review mode
Review child 非 JSON 输出fallback overall_explanation正常退出 Review
Review child channel 提前关闭interrupted写退出提示
Auxiliary UserShell 失败Failed CommandExecution主 Turn 可继续
Standalone UserShell 无环境Error + TurnComplete无模型调用
Rollback 时有活动 TurnThreadRollbackFailed原历史不变
Rollback marker flush 失败已重建,发 Warningwriter 后续重试

测试重点

async def test_standalone_shell_has_one_lifecycle(thread):
    events = await run_shell(thread, "pwd")
    assert count(events, "turn_started") == 1
    assert count(events, "turn_complete") == 1


async def test_aux_shell_does_not_nest_turn(thread):
    await start_blocked_regular_turn(thread)
    events = await run_shell(thread, "git status")
    assert count(events, "turn_started") == 0
    assert events.contains("item_completed")


async def test_review_child_is_restricted(review_probe):
    child = await review_probe.spawn()
    assert child.approval_policy == "never"
    assert "web_search" not in child.tools
    assert "spawn_agent" not in child.tools


async def test_rollback_does_not_touch_files(session, file):
    before = file.read_bytes()
    await session.rollback(1)
    assert file.read_bytes() == before

特殊 Task 的设计没有把所有命令强行塞进普通 Agent Loop,而是复用必要的生命周期骨架,再为压缩、隔离 审查、直接 shell 和历史重建保留不同的状态语义。

评论


← 返回文章列表