Skip to content

Runtime提醒体系

从时间、预算、策略和中断源码追踪动态提醒的所有者、注入时机、持久化屏障与失败边界。

基于rust-v0.150.0
CodexRustContextReminder

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 sectionfull context 构造steady-state diff 不重复发送
已提交策略Session 与 execpolicyno-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。

rust
#[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。

rust
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 loop
  • codex-rs/core/src/session/time_reminder.rs :: maybe_record_current_time_reminder
rust
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。

rust
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 :: TokenBudgetContext
  • codex-rs/core/src/session/mod.rs :: full-context developer sections
rust
// 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。

rust
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_delivered
  • codex-rs/core/src/session/rollout_budget.rs :: maybe_record_reminder
rust
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_message
  • codex-rs/core/src/context/network_rule_saved.rs :: NetworkRuleSaved::body
rust
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_abort
  • codex-rs/core/src/context/turn_aborted.rs :: TurnAborted
rust
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_contexts
  • codex-rs/core/src/context/hook_additional_context.rs :: HookAdditionalContext
rust
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_reminder
  • codex-rs/core/src/context/guardian_followup_review_reminder.rs :: body
rust
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_reminderCodexErr::Fatal,本次 inference 不继续
rollout 提醒重复mark_reminder_delivered 调用顺序插入前取消会刻意重试
预算单位报 fatalRolloutBudget::record_usageprovider units 非 finite 或为负,不重试
full context 每步重复TokenBudgetContext 构造处误把 metadata 当 steady-state diff
策略提醒出现但未新开 Turninject_no_new_turn这是预期行为
客户端先收到中断事件却读不到 markerflush_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 元数据,再沿以下路径复核:

  1. 时间:run_turn → maybe_record_current_time_reminder → take_reminder_due → record_conversation_items。
  2. rollout:record_usage → pending_reminder → maybe_record_reminder → mark_reminder_delivered。
  3. 中断:handle_task_abort → marker flush_rollout → TurnAbortedEvent → terminal flush。
  4. Hook/Guardian:附加上下文入口 → ContextualUserFragment::into → 对应 Session history → 下一次 sampling。

如果你修改了 interval、threshold、window rollover 或取消顺序,应同时更新对应状态对象和测试断言;只改 片段文本而不改消费者,通常不会改变提醒何时出现。

可以用下面的只读搜索把练习落到源码上:

bash
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,以及取消发生在交付确认前会产生什么结果。