Codex 的上层 Surface 不直接调用“运行模型”“执行命令”之类的 Session 内部函数,而是提交 Op。但
Op 不是 RPC method 的机械镜像,也不是所有动作都会启动一个新 Turn。它更像 Core 的命令代数:把
用户输入、审批回答、Thread 管理、Realtime 和生命周期控制放在一组可排序的内部请求中。
理解 Op 的关键,是同时看 Submission 信封和 submission_loop 分派,而不是只读 enum 名称。
Submission 承担关联,Op 承担意图
Submission 包含:
| 字段 | 作用 |
|---|---|
id | 关联该请求触发的 Event;Turn-start 请求也会被 App Server 投影成公开 Turn ID |
op | Core 要执行的操作 |
client_user_message_id | UserInput 的客户端消息身份 |
trace | 跨 Surface 传播的 W3C trace |
parent_turn_id | 多 Agent/派生调用中的父 Turn 关联 |
同一个 Op 值放进不同 Submission,可以拥有不同的追踪与父子关系。反过来,不能把 Submission ID 当作 所有内部工具 Call 的 ID;工具、审批和 ResponseItem 有各自标识。
当前 Op 可以按所有者分成五组
Turn 创建或模型工作
UserInput:普通用户输入,可能启动 RegularTask,也可能 steer 当前 Turn;InterAgentCommunication:把 Agent 间消息按正常 submission 生命周期记录,必要时触发 Turn;Compact:启动 CompactTask;Review:启动 Review Task;RunUserShellCommand:执行用户!cmd,用 Turn 事件报告生命周期。
回答一个在途等待点
ExecApproval、PatchApproval;UserInputAnswer、RequestPermissionsResponse;DynamicToolResponse;ResolveElicitation;ApproveGuardianDeniedAction。
它们通常根据 request/call ID 唤醒 Store 中的等待者,而不是发起第二次模型请求。若等待点已不存在, handler 要么忽略并记录,要么发错误,不能把迟到回答套到另一个 Call。
Thread 级状态操作
ThreadSettings:只更新持久 Thread settings,不启动 Turn;ThreadRollback:删除内存/持久历史中的最后 N 个 user turn;明确不撤销工作区文件;SetThreadMemoryMode:修改未来 memory generation eligibility;RefreshMcpServers、ReloadUserConfig:刷新 Runtime 投影。
控制与关闭
Interrupt:中止当前任务,但保留后台终端;CleanBackgroundTerminals:单独终止长时间运行的后台 shell;Shutdown:关闭整个 Session。
把 Interrupt 和 CleanBackgroundTerminals 分开,是为了让用户中止一次模型 Turn 时,不误杀明确希望保留 的 dev server。
Realtime 会话
Start、Audio、Text、Speech、Close、ListVoices 共享 Thread 的 submission 排序入口,但交给 Realtime Conversation Manager 处理。它们的事件族也独立于普通 AgentMessage。
non_exhaustive 是演进合同
Op 标记为 non_exhaustive。外部 crate 的 match 必须保留兜底,Core submission loop 也有忽略未知 Op
的分支。这允许协议增加 variant,而不要求旧消费者立即理解所有新动作。
代价是“未知操作”不一定自动生成错误事件;做协议桥接的 Surface 应在自己的版本边界先验证 method 与 参数,不能依赖 Core 兜底替它报告所有兼容问题。
submission_loop 是单入口,不是单任务执行器
Loop 顺序从 Receiver 取 Submission,再按 Op 调 handler。这个顺序保证 ThreadSettings 与紧随其后的 UserInput 能按调用顺序进入分派。但 handler 可能 spawn 后台 Task,所以“分派顺序”不等于“所有业务 工作直到完成都串行”。活动 Turn 约束由 Session task control 维护。
async def submission_loop(session, queue):
saw_shutdown = False
async for submission in queue:
with trace_span(submission):
should_exit = await dispatch(session, submission)
if should_exit:
saw_shutdown = True
break
if not saw_shutdown:
await teardown_after_all_senders_closed(session)
Channel 关闭也是终止路径。即使没有显式 Shutdown,loop 仍关闭 Runtime、发 session-end lifecycle,并 shutdown LiveThread persistence,避免“Sender 都没了,资源却永远活着”。
UserInput 的真实语义是 steer-or-start
UserInput 载荷不仅有文本/图片等 items,还包含:
- final output JSON Schema;
- Responses API client metadata;
- 按 opaque source key 组织的 additional context;
- 必须先应用的 ThreadSettingsOverrides。
Handler 先更新 Thread settings 与 schema,创建 TurnContext,然后尝试把输入 steer 进活动 Turn。只有
返回 NoActiveTurn 时才把输入转成模型 Item 并 spawn RegularTask。活动 Turn 若是 Review/Compact,
则返回 ActiveTurnNotSteerable,而不是悄悄新开并发 Turn。
async def handle_user_input(session, submission):
op = expect_user_input(submission.op)
await session.apply_thread_settings(op.thread_settings)
turn = await session.new_turn_context(output_schema=op.schema)
result = await session.steer_input(
items=op.items,
expected_turn_id=None,
metadata=op.responses_metadata,
)
if result.ok:
return
if result.error == NoActiveTurn:
model_input = build_regular_task_input(op, submission)
await session.spawn_task(RegularTask(model_input), turn)
return
await session.emit_error(submission.id, result.error)
因此,客户端连续发送两个 UserInput 时,第二个可能成为同 Turn steering,而非稳定地创建第二个 Turn。
App Server 的 turn/start/turn/steer 会进一步施加更明确的公开 API 语义,见 2.13。
ThreadSettings 为什么也走同一队列
如果设置更新绕过 Submission Queue,set model=A 和紧接的 UserInput 可能因任务调度先后而让新 Turn
仍使用旧模型。Op::ThreadSettings 明确注释为通过同一队列维持 caller order;应用成功发
ThreadSettingsApplied,约束失败发 Error。
这是一种命令排序,而不是大锁:真正的 SessionConfiguration 更新仍在 handler 内持有必要的状态锁。
回答型 Op 的所有权在等待 Store
执行审批、patch 审批、request_user_input、request_permissions 和 dynamic tool 都先把等待句柄以 ID 注册到 Session service。回答型 Op 查找并完成对应 waiter。核心不变式是“一次请求只被正确 ID 的一次 回答消费”。
审批 Op 带原始请求 ID,而 Submission 自己还有新的 ID。这两个 ID 不能交换:前者定位等待点,后者只 关联“这次回答命令”的处理事件。
Compact、Review、Rollback 不是 UserInput 参数
它们有独立 Op,是因为状态机与失败条件不同:
- Compact 运行专用 Task,限制 steering,成功后替换/追加摘要历史;
- Review 临时切入 Review mode,可能使用不同 instructions/model context;
- Rollback 要求
num_turns > 0、当前无活动 Turn、且存在 persistence,然后重放截断历史; - UserShell 不请求模型,直接启动 shell Task,但仍用 Exec 与 Turn 事件表达进度。
Rollback 的注释特别声明“不尝试恢复本地文件”。历史回退和工作区回退是两类操作,客户端需要自行 提供 git/undo 功能。
Shutdown 是唯一让正常 loop 退出的 Op
大多数分支返回 false,Shutdown handler 返回是否应该退出。Shutdown 过程中会中止任务、关闭后台资源、 发送 ShutdownComplete 等收尾。Loop 退出后,共享 termination future 才 resolve。
未知 Op 的兜底返回 false,防止旧 Runtime 因一个新 variant 直接终止整个 Thread。
失败矩阵
| Op/条件 | 处理 | 是否新建模型 Turn |
|---|---|---|
| UserInput,无活动 Turn | spawn RegularTask | 是 |
| UserInput,有可 steer Turn | 注入当前 Turn | 否 |
| UserInput,活动 Review/Compact | Error: not steerable | 否 |
| ThreadSettings 违反受管约束 | Error | 否 |
| 审批回答 ID 不存在/已消费 | 忽略或错误记录 | 否 |
| Rollback 为 0/Turn 活动/无持久历史 | ThreadRollbackFailed | 否 |
| Interrupt | 中止 task,保留后台 terminal | 否 |
| CleanBackgroundTerminals | 只清后台 terminal | 否 |
| Shutdown | teardown 并退出 loop | 否 |
| 未知未来 Op | 旧 loop 忽略 | 否 |
测试焦点
async def test_settings_and_input_preserve_order(thread):
await thread.submit(Op.ThreadSettings(model="new-model"))
sid = await thread.submit(Op.UserInput(items=[text("hello")]))
started = await wait_event(sid, "turn_started")
assert started.model == "new-model"
async def test_second_input_steers_active_turn(thread):
first = await start_blocked_turn(thread)
await thread.submit(Op.UserInput(items=[text("also inspect tests")]))
assert first.received_steering_input
async def test_channel_close_still_tears_down(session):
drop_all_submission_senders(session)
await session.loop_termination
assert session.persistence.is_shutdown
Op 协议真正提供的是“单 Thread 内可排序的意图入口”。至于该意图是否创建 Turn、唤醒工具、修改配置 或终止 Runtime,必须由具体 handler 和它拥有的状态来解释。
评论
登录后即可评论