Runtime提醒体系
Codex 里的“提醒”不是一个统一的消息类型。当前时间提醒由 CurrentTimeReminderState 决定是否在本次 sampling 前追加;rollout 预算提醒由 root-thread 共享账本计算,并按 Thread 记录交付;窗口预算信息是 full context 元数据;网络规则保存和中断标记则是已经发生的状态变化,需要让历史或客户端看到。把这些 都称作 reminder,会看不出谁拥有状态、什么时候生效,以及失败后是否重试。
本文面向已经读过 ContextHistory读写 和 Context变更语义 的读者。本文只讨论动态提醒如何从运行时入口 进入模型请求、会话历史或客户端事件,不展开 token 窗口算法、完整 compaction 协议和审批决策本身。 读完后,可以从一次采样前的调用追到提醒状态、历史写入、模型消费者和终止事件,并能区分“尚未到期”、 “已经交付”和“写入失败后应重试”三种现象。
1. 提醒分类
先按写入路径分类,而不是按结构体名称分类。
| 类别 | 状态所有者 | 主要载体 | 生效时机 | 失败/重试语义 |
|---|---|---|---|---|
| 时间窗口 | Session 状态 | developer 历史条目 | sampling 前 | 读取时间失败则停止本次 inference |
| rollout 预算 | root-thread 预算 | <rollout_budget> 历史条目 | sampling 前 | 插入后才标记已交付 |
| 窗口元数据 | Session/窗口状态 | full context developer section | full context 构造 | steady-state diff 不重复发送 |
| 已提交策略 | Session 与 execpolicy | no-new-turn 历史条目 | 策略保存后 | 先验证 host,再写入 |
| 中断边界 | Task/rollout | <turn_aborted> + TurnAbortedEvent | 取消流程 | marker 先 flush,终止事件后 flush |
| 事件附加上下文 | Hook/Guardian 会话 | developer 历史条目 | hook 或 review fork 产生时 | 由相应会话消费者读取 |
图中的“历史 developer 条目”不是 WorldState diff。它们会成为后续 clone_history().for_prompt(...) 的输入;相反,窗口元数据由 full context 组装,不应在稳定 diff 中反复追加。
2. 时间窗口
CurrentTimeReminderState 保存上次交付时间、上次窗口 ID,以及是否出现过用户或工具输出边界。 它属于 Session 状态,而 CurrentTimeReminder 只负责把时间格式化为 developer 片段。
源码位置:codex-rs/core/src/session/time_reminder.rs :: CurrentTimeReminderState。
#[derive(Default)]
pub(crate) struct CurrentTimeReminderState {
last_delivery_time: Option<DateTime<Utc>>,
last_window_id: Option<String>,
pending_user_or_tool_output_boundary: bool,
}
impl CurrentTimeReminderState {
pub(super) fn note_recorded_items(&mut self, items: &[ResponseItem]) {
if items.iter().any(|item| {
is_user_turn_boundary(item)
|| matches!(
item,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
)
}) {
self.pending_user_or_tool_output_boundary = true;
}
}
}note_recorded_items 不立即写提醒,只记录“下一次 inference 可以观察到边界”。因此工具输出已经进入 历史,但提醒仍在下一次模型请求前统一判断。
源码位置:codex-rs/core/src/session/time_reminder.rs :: take_reminder_due。
fn take_reminder_due(
&mut self,
window_id: &str,
current_time: DateTime<Utc>,
interval_seconds: u64,
delivery_mode: CurrentTimeReminderDeliveryMode,
) -> bool {
let is_new_window = self.last_window_id.as_deref() != Some(window_id);
// Consume the boundary for this inference even if the interval suppresses delivery.
let follows_user_or_tool_output =
std::mem::take(&mut self.pending_user_or_tool_output_boundary);
if delivery_mode == CurrentTimeReminderDeliveryMode::AfterUserOrToolOutput
&& !is_new_window
&& !follows_user_or_tool_output
{
return false;
}
let reminder_is_due = is_new_window
|| interval_seconds == 0
|| self.last_delivery_time.is_none_or(|last_delivery_time| {
current_time
.signed_duration_since(last_delivery_time)
.num_seconds()
>= i64::try_from(interval_seconds).unwrap_or(i64::MAX)
});
if reminder_is_due {
self.last_delivery_time = Some(current_time);
self.last_window_id = Some(window_id.to_string());
}
reminder_is_due
}这里的边界是:AfterUserOrToolOutput 消费的是“边界资格”,不是“提醒已发送”。如果时间间隔尚未到, 函数仍会通过 take 清掉资格;下一次没有新的用户或工具输出时不会补发。
3. 采样入口
时间提醒和 rollout 预算提醒都在 run_turn 的循环中、构造 sampling request 之前运行,但它们读取的 状态不同。时间提醒读取 Session 的时间提供者和窗口状态;rollout 预算读取共享账本。
相关源码:
codex-rs/core/src/session/turn.rs :: sampling loopcodex-rs/core/src/session/time_reminder.rs :: maybe_record_current_time_reminder
let window_id = sess.current_window_id().await;
super::rollout_budget::maybe_record_reminder(
sess.as_ref(),
turn_context.as_ref(),
&window_id,
)
.await;
// Capture once so context, advertised tools, and tool calls share one request view.
let step_context = match next_step_context.take() {
Some(step_context) => step_context,
None if pending_input.is_empty() => {
sess.capture_step_context(Arc::clone(&turn_context), &cancellation_token)
.await?
}
None => {
let pending_user_input = turn_user_input(&pending_input);
let (required_servers, _) = required_mcp_servers_for_input(
&sess,
turn_context.as_ref(),
&pending_user_input,
)
.or_cancel(&cancellation_token)
.await?;
sess.capture_step_context_with_required_mcp_servers(
Arc::clone(&turn_context),
&cancellation_token,
&required_servers,
)
.await?
}
};
let sampling_request_result: CodexResult<_> = async {
super::time_reminder::maybe_record_current_time_reminder(
sess.as_ref(),
turn_context.as_ref(),
&window_id,
)
.await?;
world_state = sess
.record_step_world_state_if_changed(&world_state, step_context.as_ref())
.await?;
let sampling_request_input: Vec<ResponseItem> = sess
.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities);上面的截取停在输入构造处;sampling_request_input 随后交给 run_sampling_request。关键顺序是提醒写入和 WorldState 记录都早于 clone_history,因此本次请求就能消费新条目。
时间提醒的实际写入是 record_conversation_items,不是直接修改本次请求的临时数组。
源码位置:codex-rs/core/src/session/time_reminder.rs :: maybe_record_current_time_reminder。
let response_item =
ContextualUserFragment::into(crate::context::CurrentTimeReminder::new(current_time));
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;所以一次提醒会留在历史中,后续请求可以继续看到它;窗口变化或 compaction 后,新的窗口 ID 又会使提醒 重新到期。
4. 预算元数据
TokenBudgetContext 与“剩余预算提醒”是两个不同层次。前者描述 Thread、agent path 和 context window 身份,标记为 full-context metadata;后者是跨阈值后的增量 developer 条目。
相关源码:
codex-rs/core/src/context/token_budget_context.rs :: TokenBudgetContextcodex-rs/core/src/session/mod.rs :: full-context developer sections
// This is full-context metadata. Steady-state context diffs should not re-emit it.
if turn_context.config.features.enabled(Feature::TokenBudget)
&& turn_context.model_context_window().is_some()
{
let mcp_result = self
.services
.mcp_runtime
.latest_call_tool(
"notes",
"thread_hint",
/*arguments*/ None,
Some(serde_json::json!({
"threadId": self.thread_id().to_string(),
})),
)
.await
.ok()
.and_then(|result| {
let text = result
.content
.iter()
.filter_map(|content| {
content.get("text").and_then(serde_json::Value::as_str)
})
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n");
(!text.is_empty()).then_some(text)
});
developer_sections.push(
crate::context::TokenBudgetContext::new(
self.thread_id(),
session_source
.get_agent_path()
.unwrap_or_else(codex_protocol::AgentPath::root),
turn_context
.config
.token_budget
.as_ref()
.map(|config| config.mode)
.unwrap_or_default(),
auto_compact_window_ids.first_window_id,
auto_compact_window_ids.previous_window_id,
auto_compact_window_ids.window_id,
mcp_result,
).render(),
);
}notes/thread_hint 只作为可选文本合并进 full context;它不会因此变成一个对模型暴露的 MCP 工具。 稳定 diff 只应表达窗口状态变化,不能每一步重复发身份元数据。
5. 共享预算
rollout 预算由一个 root-thread session tree 共享。账本保存加权使用量和每个 Thread 最近交付到哪个 window_id + reminder_index,因此子 Thread 消耗的 provider units 也会影响父树的剩余量。
源码位置:codex-rs/core/src/rollout_budget.rs :: RolloutBudget。
struct RolloutBudgetState {
config: RolloutBudgetConfig,
weighted_tokens_used: f64,
/// Last reminder delivered to each thread, so every thread observes crossed thresholds.
deliveries: HashMap<ThreadId, ThreadBudgetDelivery>,
}
pub(crate) fn record_usage(&self, usage: &TokenUsage) -> CodexResult<bool> {
let Some(mut state) = self.lock() else {
return Ok(false);
};
let units = if let Some(units) = usage.codex_rollout_budget_units.as_ref() {
let units = units.as_f64().unwrap_or(f64::NAN);
if !units.is_finite() || units < 0.0 {
return Err(CodexErr::Fatal(
"response.completed usage.codex_rollout_budget_units must be finite and non-negative"
.to_string(),
));
}
units
} else {
usage.output_tokens.max(0) as f64 * state.config.sampling_token_weight
+ usage.non_cached_input() as f64 * state.config.prefill_token_weight
};
state.weighted_tokens_used += units;
Ok(state.weighted_tokens_used >= state.config.limit_tokens as f64)
}provider 提供 codex_rollout_budget_units 时优先使用它;缺失时才按 output 与非缓存 input 权重计算。 负数、NaN 或无穷值不是“忽略这次用量”,而是 fatal,测试也明确要求不重试。
6. 阈值交付
账本只负责判断 reminder 是否 pending;真正写入由 sampling loop 调用 maybe_record_reminder 完成。
相关源码:
codex-rs/core/src/rollout_budget.rs :: pending_reminder, mark_reminder_deliveredcodex-rs/core/src/session/rollout_budget.rs :: maybe_record_reminder
pub(super) async fn maybe_record_reminder(
sess: &Session,
turn_context: &TurnContext,
window_id: &str,
) {
let budget = sess.services.agent_control.rollout_budget();
let Some(reminder) = budget.pending_reminder(sess.thread_id(), window_id) else {
return;
};
let response_item = ContextualUserFragment::into(crate::context::RolloutBudgetContext {
remaining_tokens: reminder.remaining_tokens,
});
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
budget.mark_reminder_delivered(sess.thread_id(), window_id, reminder);
}这里的顺序是一个不变量:历史插入返回后才标记交付。取消发生在插入之前时,deliveries 没有前进, 下一次 sampling 仍会看到同一阈值;rollback 则通过 rearm_reminder 删除该 Thread 的交付游标,但不退还 已经使用的预算。
7. 策略保存
网络策略的提醒不是审批请求。审批成功后,record_network_policy_amendment_message 先把 amendment 转换成 NetworkRuleSaved,再调用 inject_no_new_turn。这条消息说明“规则已经写入 execpolicy”,不会 额外开启一个 Turn。
相关源码:
codex-rs/core/src/session/mod.rs :: record_network_policy_amendment_messagecodex-rs/core/src/context/network_rule_saved.rs :: NetworkRuleSaved::body
pub(crate) async fn record_network_policy_amendment_message(
&self,
sub_id: &str,
amendment: &NetworkPolicyAmendment,
) {
let message: ResponseItem = ContextualUserFragment::into(NetworkRuleSaved::new(amendment));
let turn_context = self.turn_context_for_sub_id(sub_id).await;
self.inject_no_new_turn(vec![message], turn_context.as_deref())
.await;
}调用前的审批路径还会比较 amendment host 与批准 host;不一致的 amendment 被拒绝,不能用“保存提醒” 伪造批准结果。命令前缀保存采用同一类 developer 历史消息,具体权限快照见 权限与Sandbox指令,本文只保留其“已提交策略更新”定位。
8. 中断屏障
中断有两个消费者:模型读取历史中的 <turn_aborted>,客户端读取 TurnAbortedEvent。handle_task_abort 先取消任务、等待短暂 graceful window,并执行任务自己的 abort;只有 Interrupted 原因且配置允许时才 写 marker。
相关源码:
codex-rs/core/src/tasks/mod.rs :: handle_task_abortcodex-rs/core/src/context/turn_aborted.rs :: TurnAborted
if reason == TurnAbortReason::Interrupted
&& let Some(marker) = interrupted_turn_history_marker(
InterruptedTurnHistoryMarker::from_config_and_version(
task.turn_context.config.as_ref(),
task.turn_context.multi_agent_version,
),
)
{
self.record_conversation_items(
task.turn_context.as_ref(),
std::slice::from_ref(&marker),
)
.await;
// Ensure the marker is durably visible before emitting TurnAborted: some clients
// synchronously re-read the rollout on receipt of the abort event.
if let Err(err) = self.flush_rollout().await {
warn!("failed to flush interrupted-turn marker before emitting TurnAborted: {err}");
}
}
let event = EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(task.turn_context.sub_id.clone()),
reason,
started_at,
completed_at,
duration_ms,
});
self.send_event(task.turn_context.as_ref(), event).await;
self.services
.guardian_rejection_circuit_breaker
.lock()
.await
.clear_turn(&task.turn_context.sub_id);
// Regular items were flushed before this terminal event was appended; buffering
// thread writers may not flush it without another explicit barrier.
if let Err(err) = self.flush_rollout().await {
warn!("failed to flush rollout after emitting terminal turn event: {err}");
}V1 使用 user-role <turn_aborted>,V2 使用 developer-role 的同一 guidance 文本;配置关闭时 marker 不 生成。无论载体如何选择,顺序都不变:marker 持久化屏障 → TurnAbortedEvent → 终止事件屏障。
9. 附加上下文
Hook 的 additionalContext 和 Guardian 的 follow-up reminder 都是 developer 历史条目,但它们的所有者 不同。Hook runtime 将多个字符串转换成 HookAdditionalContext,记录后由下一次模型请求读取;Guardian 则向独立 review session 注入一条固定提醒,告诉审查模型如何使用既有 review。
相关源码:
codex-rs/core/src/hook_runtime.rs :: record_additional_contextscodex-rs/core/src/context/hook_additional_context.rs :: HookAdditionalContext
pub(crate) async fn record_additional_contexts(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
additional_contexts: Vec<String>,
) {
let developer_messages = additional_context_messages(additional_contexts);
if developer_messages.is_empty() {
return;
}
sess.record_conversation_items(turn_context, developer_messages.as_slice())
.await;
}
fn additional_context_messages(additional_contexts: Vec<String>) -> Vec<ResponseItem> {
additional_contexts
.into_iter()
.map(HookAdditionalContext::new)
.map(ContextualUserFragment::into)
.collect()
}Hook 测试写入一个 PostToolUse hook,返回 hookSpecificOutput.additionalContext,然后断言后续请求包含 该文本。它证明的是 JSON 到 developer 历史的转换,不证明 hook 文本本身正确或模型一定遵循它。
相关源码:
codex-rs/core/src/guardian/review_session.rs :: append_guardian_followup_remindercodex-rs/core/src/context/guardian_followup_review_reminder.rs :: body
async fn append_guardian_followup_reminder(review_session: &GuardianReviewSession) {
let reminder: ResponseItem = ContextualUserFragment::into(GuardianFollowupReviewReminder);
review_session
.session
.inject_no_new_turn(vec![reminder], /*current_turn_context*/ None)
.await;
}Guardian reminder 不修改主 Session 的 WorldState;它属于 review fork 的历史,并由 Guardian review 的 后续 sampling 消费。把它当成普通用户消息会错误推断其权限和生命周期。
10. 失败边界
这些机制的失败含义不同,排障时应先判断载体。
| 现象 | 先查哪里 | 代码含义 |
|---|---|---|
| 时间提醒缺失 | take_reminder_due 的 window、interval、boundary | 可能是已消费边界或间隔未到 |
| 时间读取报错 | maybe_record_current_time_reminder | CodexErr::Fatal,本次 inference 不继续 |
| rollout 提醒重复 | mark_reminder_delivered 调用顺序 | 插入前取消会刻意重试 |
| 预算单位报 fatal | RolloutBudget::record_usage | provider units 非 finite 或为负,不重试 |
| full context 每步重复 | TokenBudgetContext 构造处 | 误把 metadata 当 steady-state diff |
| 策略提醒出现但未新开 Turn | inject_no_new_turn | 这是预期行为 |
| 客户端先收到中断事件却读不到 marker | flush_rollout 屏障 | 违反持久化顺序或存储异常 |
11. 提醒测试
时间提醒测试用外部时间提供者推进时间,断言首次请求、间隔未到请求和间隔到期请求分别包含哪些提醒; current_time_reminders_can_follow_only_user_or_tool_outputs 还构造了没有新用户/工具输出的 continuation, 证明 AfterUserOrToolOutput 不会无限重复。current_time_reminder_is_refreshed_after_compaction 证明 新窗口会强制刷新;这些测试不证明系统时钟永远可读,也不证明模型理解日期。
rollout 预算测试给出 provider units 或本地加权 token 两种输入,断言剩余值和阈值消息; invalid_provider_rollout_budget_units_fail_without_retry 输入 -1.0,断言 fatal error 且 Responses API 只收到一次请求。restates_the_current_remainder_after_compaction 和 rollback 测试分别证明新窗口会重述 余量、rollback 会 rearm 但不退还用量;它们不证明多进程外部账本的一致性。
中断测试 abort_regular_task_emits_marker_before_turn_aborted 和 abort_gracefully_emits_marker_before_turn_aborted 读取事件通道,先断言 RawResponseItem,再断言 TurnAborted,且没有额外事件;集成测试 interrupt_persists_turn_aborted_marker_in_next_request 进一步检查下一次 Responses 请求包含 <turn_aborted>。这证明顺序和持久化可见性,不证明被中断的 shell 进程一定已经退出,因为源码明确允许它们在后台继续运行。
Hook 测试从脚本输出 additionalContext,检查后续模型请求包含该文本;网络审批测试先验证规则文件,再 检查请求中出现 Allowed network rule saved in execpolicy,证明保存消息和策略落盘相互对应。
12. 路径练习
遇到“模型没有看到提醒”时,不要先搜索所有 Reminder 字符串。先按本文的分类判断它是历史条目还是 full context 元数据,再沿以下路径复核:
- 时间:
run_turn→maybe_record_current_time_reminder→take_reminder_due→record_conversation_items。 - rollout:
record_usage→pending_reminder→maybe_record_reminder→mark_reminder_delivered。 - 中断:
handle_task_abort→ markerflush_rollout→TurnAbortedEvent→ terminal flush。 - Hook/Guardian:附加上下文入口 →
ContextualUserFragment::into→ 对应 Session history → 下一次 sampling。
如果你修改了 interval、threshold、window rollover 或取消顺序,应同时更新对应状态对象和测试断言;只改 片段文本而不改消费者,通常不会改变提醒何时出现。
可以用下面的只读搜索把练习落到源码上:
rg -n "take_reminder_due|pending_reminder|mark_reminder_delivered|handle_task_abort" \
codex-rs/core/src/session codex-rs/core/src/tasks/mod.rs codex-rs/core/src/rollout_budget.rs然后对照 core/src/context/current_time_reminder.rs、core/src/rollout_budget.rs 和 core/src/session/time_reminder.rs 中的实现与测试,确认自己能指出 提醒何时写入、何时只构造 metadata,以及取消发生在交付确认前会产生什么结果。
