一条 Responses 流怎样变成下一步行动
沿 rust-v0.144.6 的 transport mapper、ResponseEvent loop、OutputItemDone 与 drain_in_flight,追踪一条流怎样更新 live history、尝试追加 rollout、形成工具结果和下一次 sampling。
第 11 章已经把两层业务循环分开:RegularTask 决定整个 task 是否还要重入,run_turn 决定一次 sampling 之后是否继续。本章缩到更里面,只追一条 ResponseStream。问题也更具体:一条 wire event 什么时候只是屏幕上的增量,什么时候才进入 conversation history,工具结果又在什么时刻成为下一次 request 的输入。
这条边界不能靠事件名猜。output_item.added 听起来像“已经添加”,实际只建立流式状态;response.completed 听起来像“turn 完成”,实际只结束一次 provider response。真正改变 conversation state 和控制流的是 event loop 对完整 item、工具 future、usage 以及 pending input 的组合处理。这里还要再分一层:live history 更新与 rollout append 有先后关系,却不是一次原子提交。
一条 stream 先经过两次映射
先看整条纵向路径。HTTP SSE 和 Responses WebSocket 都先把 wire envelope 解成 codex_api::ResponseEvent。core 的 map_response_events 再把同一个 enum 送进 core ResponseStream,同时补 telemetry、provider error mapping 和 LastResponse 旁路;它还没有执行工具,也没有写 conversation history。行动发生在 try_run_sampling_request 的 event loop 里。
两个 transport 共用 process_responses_event 处理已经反序列化的 envelope。成功匹配时它产出 typed event;unknown kind、字段不足的 delta 或无法解析的 added/done item 会落到 Ok(None)。response.completed 还要再分两种情况:response 字段存在但 payload 无法解析时返回 stream error;response 字段缺失时,该 match arm 不返回事件,最后同样落到 Ok(None)。server model、verification、moderation、safety buffering、rate limit 等 metadata 还可能在调用共享 mapper 之前由 transport 外层单独发出,所以 Ok(None) 只表示“这次共享映射没有主事件”,不等于 envelope 绝对没有任何下游效果。
flowchart TB
accTitle: Responses 事件到下一步行动
accDescr: HTTP SSE 或 Responses WebSocket 先映射为 ResponseEvent;added 建立 active state,delta event 进入显式 gate 并由 matching active 或 diff state 决定是否投影;done 先更新 live history、再尝试追加 rollout 并排队工具,drain 只记录成功工具输出,future Err 在 debug build panic、在 release build 记录错误后继续,正常返回后 run_turn 才按 follow-up 与 pending input 构造下一次请求
HTTP[HTTP SSE] -->|SSE decode| API[codex_api ResponseEvent]
WS[Responses WebSocket] -->|JSON decode| API
API -->|map_response_events| STREAM[core ResponseStream]
STREAM -->|next event| LOOP[try_run_sampling_request event loop]
LOOP -->|OutputItemAdded| ACTIVE[active item and diff consumer]
LOOP -->|delta ResponseEvent| GATE{matching active or diff state}
ACTIVE -->|matching active or diff state| GATE
GATE -->|matched| DELTA[delta consumer]
DELTA -->|EventMsg| PROJECTION[client projection]
LOOP -->|OutputItemDone| DONE[complete item handler]
DONE -->|record completed item| RECORDER[Session conversation recorder]
RECORDER -->|first| LIVE[live conversation history]
LIVE -->|then await if live thread exists| APPEND[rollout append attempt]
APPEND -->|success| ROLLOUT[rollout store]
APPEND -->|error log only| NO_ROLLBACK[live history is not rolled back]
DONE -->|tool call| FUTURE[in flight tool future]
LOOP -->|Completed usage| TERMINAL[provider terminal response info]
TERMINAL -->|after event loop| DRAIN[drain gate]
LOOP -->|mailbox preemption| DRAIN
DRAIN -->|await next future| FUTURE
FUTURE -->|success ResponseInputItem| TOOL_OUTPUT[successful tool output]
FUTURE -->|Err| TOOL_ERROR{debug assertions}
TOOL_OUTPUT -->|record tool output| RECORDER
TOOL_OUTPUT -->|continue draining| DRAIN
TOOL_ERROR -->|debug assertions| PANIC[panic]
TOOL_ERROR -->|release log| DRAIN
DRAIN -->|all futures settled| RESULT[SamplingRequestResult]
RESULT -->|needs_follow_up| RUN_TURN[run_turn control]
RUN_TURN -->|merge pending input| NEXT_REQUEST[next sampling request]
ResponseEvent 本身把生命周期、item、delta 和 server metadata 放在同一个 enum 里。它是 transport 与 core consumer 的 typed boundary,不是 durable event log。后面的消费账本会逐项说明每个 variant 到底落在哪里。
core mapper 对 OutputItemDone 和 Completed 有额外观测:前者被收进 inference trace 的 item 列表,后者写 telemetry、trace 与 LastResponse。其余成功事件仍原样进入 core channel。这个 mapper 没有把 ResponseEvent 转成 action;它只是交接并观测 transport 结果。
try_run_sampling_request 打开一次 stream,建立 in_flight、needs_follow_up、last_agent_message、active_item 等局部状态,然后逐条等待 stream.next()。因此下图里的 active、future 和 result 都是一轮 sampling 的局部控制状态,不是新的 Session 或用户 turn。
OutputItemAdded 只建立流式状态
OutputItemAdded 到达时,event loop 先补可能缺失的 item id。若 item 是 custom tool call,它可以为参数增量建立 active_tool_argument_diff_consumer;普通 function call 则清掉这个 consumer。随后只有能转换成非工具 TurnItem 的 message、reasoning 等 item 才会进入 active_item。
Plan mode 为什么会推迟 started 事件
started projection 还要再看流式分支。在允许直接 streaming 的普通路径中,非 plan-mode assistant item 直接调用 emit_turn_item_started。plan mode 的 assistant added 绕过这条普通 started 分支,先把 TurnItem::AgentMessage 放进 pending_agent_message_items;同时 seed assistant parser、保留 plan-mode parsed delta,并在结尾继续写入 active_item 与 streaming flag。
“先 pending”不等于本次 added 处理绝不会发 started。seeded parser 结果会在同一 OutputItemAdded 分支内传给 emit_streamed_assistant_text_delta。若 parser 形成 visible normal segment,handle_plan_segments 会先调用 maybe_emit_pending_agent_message_start;该 helper 从 pending map 取出 item、调用 emit_turn_item_started,再记录 started id,随后才发送 content delta。若只有 proposed-plan segment、空白前缀或尚未能排除 tag prefix,agent-message start 继续保持 deferred。
这里的 started 是流式生命周期投影。代码没有调用 record_completed_response_item 或 record_conversation_items,也没有创建 tool_future。即使 added envelope 已经带了一段初始 assistant text,那段内容也只是用于 seed 当前 parser 与 started item。OutputItemAdded 不执行工具,不更新 live history,也不触发 rollout append。
这个区分也解释了为什么不能把 LastResponse.items_added 的名字反推成 added 事件库存。core client 实际只在 OutputItemDone 分支向这个 vector push 完整 item;OutputItemAdded 只用来计算 TTFT(time to first token,首个输出到达耗时)并继续转发。
delta 只更新当前投影
delta 的消费者都受当前 active 状态约束,且每类边界不同:
- **
OutputTextDelta:**需要active_item,还要确认该 item 正在直接流给客户端。assistant message 会先经过 stream parser,再形成 agent-message delta;其他兼容 item 形成AgentMessageContentDelta。没有 active item 时走诊断路径,不会凭 delta 补造一条 history item。 - **
ToolCallInputDelta:**只交给已建立的 argument diff consumer。显式call_id与 active call 不匹配时跳过;event 自带的item_id在这条消费路径中没有参与 recorded-item 关联。consumer 可能返回一个EventMsg,也可能什么都不发。 - Reasoning summary:
ReasoningSummaryDelta与ReasoningSummaryPartAdded服务普通 summary streaming;sequential-cutoff 模式直接跳过它们。ReasoningSummaryDone只在 sequential-cutoff 模式消费,并且要求 active id 与 event id 一致。 - **
ReasoningContentDelta:**形成 raw reasoning projection,同样要求 active item 正在向客户端 streaming。
所以 text、reasoning、tool-input delta 都是 UI / active-item 增量,不是已记录的 conversation item。这里的 EventMsg 允许 core 客户端及时显示当前状态,但 conversation history 仍等待完整的 OutputItemDone。app-server 怎样把 core event 投成 wire notification、TUI 怎样合并 started/delta/completed,分别留到第 30、31 章。
OutputItemDone 才处理完整 item
OutputItemDone 先关闭参数 diff consumer,取走 active_item,刷新尚未发完的 assistant text segment。接下来先检查 plan-mode assistant 旁路;未被它消费的完整 item 才进入下文的通用 handler。
Plan mode 的 done 旁路
当 plan_mode_state 存在且完整 item 是 assistant message 时,handle_assistant_item_done_in_plan_mode 先调用 plan-item helper。这个 helper 会拼接 assistant output text,尝试用 extract_proposed_plan_text 提取 proposed-plan block;只有 block 存在时,才按需调用 plan_item_state.start,再用 complete_with_text 完成 plan item。没有 proposed-plan block 时,这两步都不会发生。
plan item 的条件分支结束后,assistant done helper 仍调用 finalize_non_tool_response_item,再经 emit_turn_item_in_plan_mode 投影 agent message 的 started/completed 语义。它随后调用 record_completed_response_item_with_finalized_facts 进入 Session recording path,并更新 last_agent_message。helper 返回 true 后,event loop 直接 continue,不会进入通用 handle_output_item_done。
只有未被这个专用分支消费的普通路径,才把完整 ResponseItem 交给 handle_output_item_done,在这里发生 record/action 分叉。
通用 handler 里的 Ok(None) 只表示 ToolRouter 没有构造出 tool call,不保证 response item 能转成 TurnItem。finalize_non_tool_response_item 返回 Option<FinalizedTurnItem>:只有 Some(FinalizedTurnItem) 才会在缺少先前 active projection 时补发 started,并随后发 completed。返回 None 时没有 started/completed lifecycle。
无论这个 Option 是 Some 还是 None,分支随后都会调用 record_completed_response_item_with_finalized_facts,所以完整 ResponseItem 仍进入 conversation;区别只是 None 没有 finalized facts 和客户端 TurnItem lifecycle。当前 converter 只尝试处理 Message、Reasoning 与 WebSearchCall,且 parse_turn_item 仍可能返回 None;stream 中意外出现的 tool output 与其他 variants 直接返回 None。
工具 call 的 done 路径先调用 record_completed_response_item 记录完整 call item,再创建 tool_future。event loop 把 future 推入 in_flight,并用 OR 聚合 needs_follow_up。RespondToModel 这类不能进入正常工具执行的结果会记录原 call 和一条合成 output,同样要求 follow-up,但不会产生 future。注册表怎样识别 call、handler 如何执行、审批与 sandbox 在哪里包围它,统一交给第 14 章:工具规格、注册表与执行入口。
完整 item 的 recording helper 最终调用 Session 的 conversation recorder。这里还会处理 mailbox delivery phase、memory citation 等 finalized facts,但它的首要语义很清楚:传入的是已完成 item,不是 earlier delta。
Session 的 record_conversation_items 先在 state lock 内更新 live history,释放锁后调用并 await persist_rollout_response_items,最后才发送 raw-response event。如果 Session 有 live thread,persistence helper 会继续 await live_thread.append_items(...);没有 live thread 就不追加。append 失败只写一条 error log,persist_rollout_items 不把错误返回给 caller,后续 raw-response event 仍会发送。
因此这不是 live history 与 rollout 的原子提交。append 失败时,已经更新的 live history 不会回滚,caller 也收不到 persistence error;能确定的是“先更新 live、再等待一次 append 尝试”,不能写成“同一批 item 已经同时持久化成功”。客户端 started/delta 仍不会逆向写回这条 recording path。
event loop 接到 OutputItemResult 后,先排队 future、更新 last message、OR 合并 follow-up。然后还有一个容易漏掉的出口:commentary message 或 reasoning done 后,只要 mailbox 已有消息,就可能触发 mailbox preemption,提前形成 SamplingRequestResult。这条路径发生在 ResponseEvent::Completed 之前。
drain_in_flight 把工具结果送回下一次 request
工具 call done 以后,future 只是进入 FuturesOrdered。即便 event loop 已因 Completed 或 mailbox preemption 得到一个暂定 outcome,函数也不会立刻把 SamplingRequestResult 交回 run_turn。loop 外还有统一的 drain_in_flight gate。
drain_in_flight 逐个等待 future,并显式匹配结果。只有 Ok(response_input) 才得到 ResponseInputItem:它被转换成 response item,再交给 record_conversation_items。后者先把成功工具 output 放进 live history,再等待 rollout append 尝试结束;append 失败只记日志,不会让 drain 失败,也不会撤销 live item。
Err(err) 分支不构造 ResponseInputItem,也不进入 live-history/rollout recording path;接下来的控制流取决于 error_or_panic 的 build mode。debug build 在 cfg!(debug_assertions) 为 true 时直接 panic,当前 drain 到此停止。release build 走 error! 记录诊断并返回,match arm 结束后 while loop 才能继续等待后面的 future。失败 future 之后是否应合成模型可见结果、升级为 sampling error 或采取其他恢复策略,属于第 13 章:错误、重试与恢复。future 内部怎样执行工具仍留给第 14 章。
drain 正常返回后,core 才发送延迟的 token count、检查 cancellation、发送 turn diff,并返回先前保存的 outcome。debug build 若在失败 future 上 panic,不会到达这段返回路径。因此“provider 已 completed”与“sampling 函数已经返回”之间可能隔着真实工具执行;下一次 request 能看到成功完成的工具结果,靠的是先写 live history、再完成 drain 的顺序,不以 rollout append 成功为前提。
Completed 只结束一次 provider response
ResponseEvent::Completed 里的 token_usage 和 end_turn 都是 Option,provider 可以缺少任意一个。event loop 先 flush assistant segments,再把 token_usage.as_ref() 原样传给 record_token_usage_info;这一层不会把 None 当成 stream error。RateLimits 则在单独的 event 分支更新 rate-limit state。两者都把 TokenCount 延迟到工具 drain 之后,避免客户端在需要用户交互的工具仍暂停时看到误导性的进度事件。
只有 end_turn: Some(false) 会在 Completed 分支里强制把 needs_follow_up 设为 true。Some(true) 和 None 都不改写它;前面 tool call / RespondToModel 已经置起的 follow-up 也不会被 Completed 清掉。随后产生的是 SamplingRequestResult { needs_follow_up, last_agent_message },不是 TurnComplete。
core client mapper 还会在 Completed 上记录 response id、usage telemetry、inference trace 和 LastResponse。这些是 terminal response information,不是 user-visible task terminalization。第 11 章已经证明,TurnComplete 要等 run_turn、RegularTask pending check 和 Session task lifecycle 全部收束。
更关键的是,所有 sampling return 都不要求先看到 Completed。commentary / reasoning done 后的 mailbox preemption 可以在 Completed 之前形成成功 outcome;函数仍会 drain 已排队工具,再返回 needs_follow_up=true。因此不能把 Completed 当成 sampling result 的唯一来源,更不能把它当成整个 turn 的终点。
ResponseEvent 消费账本
下面的表只描述 rust-v0.144.6 core 消费边界。“不适用”表示该 variant 不拥有这一列的状态变化,不表示整个系统永远不会在随后阶段产生对应事件。
server metadata 不是一类可以合并成 no-op 的 event。它们分别拥有 warning、verification、moderation、buffering 和 reasoning-accounting 投影,但都不直接进入 conversation recording path,也不创建 tool future。
ResponseEvent | UI / EventMsg | conversation recording path | tool future | usage / rate limit | needs_follow_up |
|---|---|---|---|---|---|
Created | 不适用,event loop no-op | 不适用 | 不适用 | 不适用 | 不改变 |
SafetyBuffering | EventMsg::SafetyBuffering | 不适用 | 不适用 | 不适用 | 不改变 |
OutputItemAdded | plan assistant 先入 pending;visible normal segment 可在同一 added 内发 started | 不适用 | 不创建 | 不适用 | 不改变 |
OutputItemDone | 非工具分支仅 Some(FinalizedTurnItem) 发 started / completed;工具 call 的 lifecycle 由随后执行的 handler / future 路径拥有;plan assistant 走专用投影 | 无 TurnItem 时仍记录完整 ResponseItem;先更新 live,再尝试 append rollout | 仅可执行 tool call 创建 | 不适用 | tool / RespondToModel 置 true |
ServerModel | 模型不匹配时可发 warning | 不适用 | 不适用 | 不适用 | 不改变 |
ModelVerifications | 首次调用 emit_model_verification | 不适用 | 不适用 | 不适用 | 不改变 |
TurnModerationMetadata | 调用 emit_turn_moderation_metadata | 不适用 | 不适用 | 不适用 | 不改变 |
ServerReasoningIncluded | 调用 set_server_reasoning_included,不发 item event | 不适用 | 不适用 | 更新 Session reasoning accounting flag | 不改变 |
Completed | 不直接发 TurnComplete;loop 外可发 token count / diff | 不适用 | 不创建;返回前 drain 已有 future | token_usage=Some 时记录;None 可缺失 | 仅 Some(false) 强制 true;已有 action 保持 true |
OutputTextDelta | agent-message text/content delta | 不适用 | 不适用 | 不适用 | 不改变 |
ToolCallInputDelta | diff consumer 可产出参数增量 EventMsg | 不适用 | 不创建 | 不适用 | 不改变 |
ReasoningSummaryDelta | 非 sequential 模式发 reasoning content delta | 不适用 | 不适用 | 不适用 | 不改变 |
ReasoningSummaryDone | sequential 模式发 section break / reasoning content delta | 不适用 | 不适用 | 不适用 | 不改变 |
ReasoningContentDelta | raw reasoning content delta | 不适用 | 不适用 | 不适用 | 不改变 |
ReasoningSummaryPartAdded | 非 sequential 模式发 reasoning section break | 不适用 | 不适用 | 不适用 | 不改变 |
RateLimits | 延迟到 drain 后随 TokenCount 投影 | 不适用 | 不适用 | 更新 rate-limit state | 不改变 |
ModelsEtag | 不适用;触发 models manager refresh | 不适用 | 不适用 | 不适用 | 不改变 |
这张表也说明 needs_follow_up 不是任何一个 event 的 durable record。它是 event loop 局部变量:tool done、RespondToModel、end_turn=false 或 mailbox preemption 都能影响最终结果。它既不会作为一条 ResponseItem 写进 history,也不能单独代表下一次 request 的全部输入。
固定版本实验:同一个 call_id 进入第二次请求
固定 checkout 是 rust-v0.144.6,commit 是 5d1fbf26c43abc65a203928b2e31561cb039e06d。目标 fixture 开头有 skip_if_no_network!。这个宏本身检查 sandbox_network_env_var() accessor,accessor 再指向 core spawn 里的常量;固定版本中常量字面值才是 CODEX_SANDBOX_NETWORK_DISABLED。命令需要让变量存在时直接失败,不能让一个提前 return Ok(()) 的 fixture 被误报成绿灯:
set -euo pipefail
: "${ARCHIVE_CODEX_RS:?先执行第二部导读的 archive 准备脚本}"
test -z "${CODEX_SANDBOX_NETWORK_DISABLED+x}"
cd "$ARCHIVE_CODEX_RS"
CODEX_TEST_ENVIRONMENT=local \
just --set rust_min_stack 16777216 test --locked \
-p codex-core --test all current_time_tool_returns_the_latest_time
只有这段 precondition 通过,下面的 request 断言才有资格作为实验结果。
本机实测的关键结果整理为:
PASS codex-core::all suite::current_time_reminder::current_time_tool_returns_the_latest_time
Summary: 1 test run, 1 passed
nextest 跳过了多少无关 test 只是 suite inventory 快照。测试文件增删、cfg 或 target 变化都可能改变它;行为证据来自 fixture 对两次 request 的断言。
fixture 把 CALL_ID 固定为 current-time。第一次 request 暴露 clock.curr_time 工具,第一条 provider response 返回同一 call_id 的 function call;done 路径把它变成 tool future。future 结束后,output 先写回 history。第二次 request 随后携带同一 call_id 的 function-call output:It is 2026-06-17 17:35:15 UTC.。
| 观察点 | 固定 fixture 中的值 |
|---|---|
| 第一次 request | 暴露 clock.curr_time;对应 response 产生 function call call_id=current-time |
| drain 后的输出 | FunctionCallOutput(call_id=current-time, text=It is 2026-06-17 17:35:15 UTC.) |
| 第二次 request | function_call_output_text("current-time") 等于上述 output |
这段时间文本是 deterministic fixture output。TestTimeProvider 从固定 Unix timestamp 开始,每次读取按 60 秒递增;SECOND_REMINDER 被写死为上述字符串。fixture 直接证明的是 tool output 的 call-id 配对和它出现在第二次 request;结合前文 drain_in_flight -> record_conversation_items -> clone_history 的源码,可以定位 live-history 回填路径。它没有读取 rollout JSONL,不能单独证明磁盘持久化,也不能推出生产环境使用同一时钟、同一日期或固定一分钟步进。
负边界:错误集合留给第 13 章
正常 action 主线到这里结束。第 13 章接手的 unresolved error set 包括 unknown/缺字段事件、malformed envelope、response.failed / response.incomplete、SSE/WS 中断与 premature EOF;本章不在主线里展开它们的恢复语义。
展开 mapper 与 transport 的边界校准
这条链不是全程 fail-fast。合法但 unknown stream event 在共享 mapper 中 trace 后返回 Ok(None);字段不足的已知 delta 也可能落到 Ok(None)。无法反序列化的 OutputItemAdded / OutputItemDone item 只写 debug 并跳过。更外层的 SSE envelope JSON 解析失败和 WebSocket JSON 解析失败也都是 debug 后继续读取。
但“parse failure 都忽略”同样不成立。response.completed 的 response 字段存在、payload 却无法解析时会成为 stream error;若 response 字段本身缺失,则不会进入解析,最终返回 Ok(None)。response.failed 与 response.incomplete 也会进入 error path。
transport 对这个 error 的处理不同。SSE 先把 mapper error 存进 response_error 并继续读取;若后面没有先遇到 Completed、stream 随后 EOF,EOF 分支才发出保存的 error。WebSocket 遇到相同 mapper error 时立即结束当前读取。这里只划出差异,不在本章整理恢复策略。
第 10 至 12 章已经解释 request 构造、两层 loop、正常 ResponseEvent 消费与 tool output 回填。第 13 章不再把这些机制写成未解释项,它接收的是跨层失败如何被分类、计数和终止:
- 跨层错误分类:区分
response.failed/response.incomplete、malformed Completed、transport error、idle timeout、premature EOF 和 core event-loop EOF; - retry attempt 计数所有权:说清 auth refresh、provider error mapping、invalid image sanitation 与 Responses WebSocket fallback 各自由谁占用和递增 attempt;
- 重试耗尽与 fallback 终止条件:解释哪些错误进入 retry / backoff,哪些结束当前 sampling,fallback 在什么条件下停止;
- 恢复矩阵:按错误层、可重试性、side effect、durable output 和用户可见结果整理恢复动作,包括 in-flight tool future 失败与 cancellation。
这些问题统一交给第 13 章:错误、重试与恢复。本章已经证明正常 action 路径的顺序,不能顺手把每个 error 分支写成同一种 terminal behavior。
从第 11 章接过来,向第 13 章交出去
第 10 章 构造 request 并打开 transport;第 11 章 把 core ResponseStream 与两层 loop 交给本文。本文补齐中间缺口:
- added 与 delta 只维护 active/client projection;
- done 才记录完整 item,并在 tool call 时排队 future;
- sampling outcome 产生后仍要
drain_in_flight,成功工具 output 先进入 live history,再等待 best-effort rollout append;失败 future 在 debug assertions build 直接 panic,release build 写诊断后继续 drain; - Completed 记录 provider terminal information 和 usage,不直接产生
TurnComplete; needs_follow_up是聚合控制结果,不是 durable record。
最后一步仍由 run_turn 完成。它把 sampling 返回的 model needs_follow_up 与 input queue 的 has_pending_input 做 OR;下一轮开始时,再按 delivery 条件 drain pending input、记录进 history,然后从最新 history 构造 request。因此下一次 sampling 看到的既可能有工具 output,也可能有 pending input,不能从某一个 ResponseEvent 单独推出完整输入。
下一章沿本文的 unresolved error set 继续。工具 registry、handler 与 execution policy 属于第 14 章;core EventMsg 怎样成为 app-server protocol notification 属于第 30 章;TUI 怎样把 item lifecycle 与 delta 投成稳定界面属于第 31 章。这些客户端细节不会改变本文固定下来的 live-record/action 顺序。