Skip to content

实时上下文与委派

追踪实时提示的选择、启动历史和目录取样、分节裁剪、模式指令差量与委派转录,解释何时生效、关闭后的尾声任务和压缩后的重新注入。

基于rust-v0.150.0
CodexRealtimeContextRust

实时上下文与委派 ​

给 thread/realtime/start 传入 prompt: null,为什么请求中仍有一段工作区背景?修改了实时 start 指令,下一次模型请求为什么还带着旧文本?用户结束语音后,普通 Codex 为什么又收到了一次输入?这些现象涉及不同的上下文管线,仅看“实时提示词”这个名称无法解释。

本文把提示、启动背景、模式指令和委派转录分别追到接收者,再展开历史取样、预算、状态差量和关闭时的尾声处理。读者需要了解 Rust 的 Option、借用、trait、async/await 与 channel。实时会话架构 提供连接和普通 Turn 的背景;BEM事件与增量状态 解释回答如何送回实时模型;Context变更语义 介绍普通模型上下文的差量更新。

这里不重新讲音频设备、BEM 标签分类或客户端时间线。读完后,应能判断一段文本究竟进入实时服务的 session instructions,还是普通模型的 developer/user 消息;也能用真实请求与测试解释“参数已改,但模型输入未按预期变化”。源码块中的中文注释为阅读补充。

1. 指令接收面 ​

实时会话中有两个模型接收面:实时模型负责持续交互,普通 Codex 模型执行被交接的工作。启动背景(startup context)、模式指令(mode instructions)、委派(delegation) 是三种不同内容,取样和生命周期也不同。

内容来源接收者与字段生成时机
实时基础提示配置、请求 prompt 或默认模板实时 session 的 instructions准备启动时选择
启动背景当前 history、近期 Thread 元数据、本机目录名拼入同一个 instructions 字符串启动时一次取样,或使用配置覆盖
initialItems客户端给出的带 role 文本项V3 session 的 initial_items新会话初始化
start/end 指令客户端、start 配置回退、默认模板普通模型的 developer 内容World State 判断需要通知时
委派输入和近期转录实时 HandoffRequested 与 API 转录缓存普通模型的 user 输入每次交接或可选的结束尾声

下图按最终接收者分开这两条路径。BACKEND_PROMPT 虽然名字带 backend,调用方把它交给实时模型。

  • instructions 和 initial_items 是同一实时会话的不同字段。
  • start/end 描述普通执行者如何处理语音转录,不改变实时连接的系统提示。
  • 委派是一次输入,模式是跨输入比较的状态;两者不能互相代替。

这些模板由 codex-prompts 在编译时嵌入,Core 负责决定何时使用:

源码文件:codex-rs/prompts/src/realtime.rs

相关函数/类型:BACKEND_PROMPT、START_INSTRUCTIONS、END_INSTRUCTIONS(L1–L3,摘录)

rust
// 三个常量编译时嵌入不同模板,真正的接收者由 Core 调用方决定。
pub const BACKEND_PROMPT: &str = include_str!("../templates/realtime/backend_prompt.md");
pub const END_INSTRUCTIONS: &str = include_str!("../templates/realtime/realtime_end.md");
pub const START_INSTRUCTIONS: &str = include_str!("../templates/realtime/realtime_start.md");

因此默认模板不是启动时从用户工作区搜索出来的文件。实时基础提示的模板中明确把接收者描述为“conversational surface”,执行工作由另一个接收面完成;这是理解命名最直接的线索。

源码文件:codex-rs/prompts/templates/realtime/backend_prompt.md

相关模板常量:BACKEND_PROMPT(L13–L18,摘录)

markdown
<!-- 这里只摘录接收者与执行者的关系,不把整份行为模板当作机制实现。 -->
## Interface and operating model

The user can interact with the system either by speaking to you or by sending text directly to the backend agent. The user can see the full interaction with the backend.

The backend handles execution and produces user-visible artifacts. You are the conversational surface of the same system.

自然语言模板表达行为要求,实际消息传递仍由 Rust 代码执行。不能仅根据模板里“总是委派”之类措辞,推断每条语音必然创建一个普通 Turn。

2. prompt 的三态 ​

2.1 请求中的空值 ​

公开入口是实验性的 thread/realtime/start。客户端需要接受实验 API,Thread 还必须启用默认关闭的 realtime_conversation feature。这里进入参数定义,先看最容易被普通 Option<String> 思路误读的 prompt:

源码文件:codex-rs/app-server-protocol/src/protocol/v2/realtime.rs

相关函数/类型:ThreadRealtimeStartParams(L241–L254,摘录)

rust
// 节选请求字段;双层 Option 区分字段缺失、显式 null 与字符串。
/// Developer instructions given to the backing Codex model when this realtime session starts.
#[ts(optional = nullable)]
pub realtime_start_instructions: Option<String>,
/// Developer instructions given to the backing Codex model when this realtime session ends.
#[ts(optional = nullable)]
pub realtime_end_instructions: Option<String>,
#[serde(
    default,
    deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
    serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
    skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub prompt: Option<Option<String>>,

外层 Option 表示字段是否出现,内层 Option 表示出现的值是否为 null。deserialize_double_option 将这个区别保留下来,不能在调用方先用一次 unwrap_or_default 把它们合并。

JSON 输入Core 接收到的值无有效配置覆盖时的选择
省略 promptNone使用默认模板
"prompt": nullSome(None)不使用基础提示
"prompt": ""Some(Some(""))基础提示为空字符串
"prompt": "自定义提示"Some(Some(...))原样使用该字符串

这张表只描述“基础提示”。后面还会单独选择启动背景,所以 null 并不意味着整个 instructions 一定为空。

2.2 配置优先级 ​

选择顺序由下面这个函数完整决定:

源码文件:codex-rs/core/src/realtime_prompt.rs

相关函数/类型:prepare_realtime_backend_prompt、current_user_first_name(L2–L33,摘录)

rust
// 先检查非空白配置,再解释请求的三态;默认分支最后执行。
const DEFAULT_USER_FIRST_NAME: &str = "there";
const USER_FIRST_NAME_PLACEHOLDER: &str = "{{ user_first_name }}";

pub(crate) fn prepare_realtime_backend_prompt(
    prompt: Option<Option<String>>,
    config_prompt: Option<String>,
) -> String {
    if let Some(config_prompt) = config_prompt
        && !config_prompt.trim().is_empty()
    {
        // trim 仅作非空判断,返回的仍是原配置字符串。
        return config_prompt;
    }

    match prompt {
        Some(Some(prompt)) => return prompt,
        Some(None) => return String::new(),
        None => {}
    }

    BACKEND_PROMPT
        .trim_end()
        .replace(USER_FIRST_NAME_PLACEHOLDER, &current_user_first_name())
}

fn current_user_first_name() -> String {
    [whoami::realname(), whoami::username()]
        .into_iter()
        .filter_map(|name| name.split_whitespace().next().map(str::to_string))
        .find(|name| !name.is_empty())
        .unwrap_or_else(|| DEFAULT_USER_FIRST_NAME.to_string())
}

非空白的配置覆盖优先于请求。 即使请求明确传了 null,只要 experimental_realtime_ws_backend_prompt 在 trim 后非空,函数仍先返回配置字符串。trim 只用于判断,返回值保留原始空白;请求里的空白字符串则直接作为有效字符串返回。

默认分支替换 {{ user_first_name }}:先取宿主 realname 的第一个非空词,再尝试 username,最后回退为 there。这只是字符串填充,不是客户端账户名查询;文章和测试不应硬编码开发者机器上得到的姓名。

上游单元测试分别覆盖配置压过请求、显式请求、null/空字符串和默认占位符替换。查看这些测试时,应把“空配置”和“空请求”的条件分开:前者被跳过,后者可以主动让基础提示为空。

2.3 与启动背景拼接 ​

build_realtime_session_config 接着处理启动背景,最后才组成 instructions:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:build_realtime_session_config(L1334–L1356,摘录)

rust
// 基础提示与启动背景各自选择,再组合成实时 session instructions。
let config = sess.get_config().await;
let prompt = prepare_realtime_backend_prompt(
    params.prompt.clone(),
    config.experimental_realtime_ws_backend_prompt.clone(),
);
let startup_context = if params.include_startup_context {
    match config.experimental_realtime_ws_startup_context.clone() {
        // Some 空字符串也会阻止自动扫描;不在这里 trim。
        Some(startup_context) => startup_context,
        None => {
            build_realtime_startup_context(sess.as_ref(), REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET)
                .await
                .unwrap_or_default()
        }
    }
} else {
    String::new()
};
let prompt = match (prompt.is_empty(), startup_context.is_empty()) {
    (true, true) => String::new(),
    (true, false) => startup_context,
    (false, true) => prompt,
    (false, false) => format!("{prompt}\n\n{startup_context}"),
};

这里有两处独立控制。include_startup_context == false 直接跳过覆盖值与自动构建;为 true 时,只要配置是 Some,就使用其原值,包含 Some("")。因此空 startup-context 覆盖可以关闭自动取样,而空白字符串仍会被当成非空内容参与拼接。

基础提示启动背景最终 instructions
空空空字符串
空非空只有启动背景
非空空只有基础提示
非空非空基础提示、两个换行、启动背景

App Server 对一般新会话默认启用启动背景;existingCall 默认关闭,并拒绝显式要求重新配置 prompt、initialItems、model 等选项。existingCall 可以接受面向普通 Codex 的 start/end 指令,因为它们属于本地模式状态,不是改写已有实时 session 的配置。

一次普通 WebSocket 启动的调用顺序如下。RPC 接受请求与 Core 发送 Started 是不同阶段。

  • 输入无效时,准备阶段可以直接产生 Realtime Error,不进入新的连接初始化。
  • 图把 prepare_realtime_start 和 handle_start_inner 两个阶段归入 handle_start 参与者;Started 由后一个阶段发送。
  • 历史与目录取样在建立本次连接之前发生,期间可能耗费存储或文件系统时间。
  • start/end 字符串在 manager 启动成功后保存;普通模型仍需经过自己的上下文构造才会看到它们。

3. 启动历史取样 ​

3.1 构建顺序 ​

启动背景的自动构建分三步读取:当前 Thread 的模型 history、近期 Thread 元数据、本机目录。它们顺序执行,没有在一个共享事务中取快照。

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_realtime_startup_context(L59–L79,摘录)

rust
// 三种来源顺序读取;history 克隆不与 ThreadStore 和文件扫描构成联合快照。
pub(crate) async fn build_realtime_startup_context(
    sess: &Session,
    budget_tokens: usize,
) -> Option<String> {
    let config = sess.get_config().await;
    let cwd = config.cwd.clone();
    let current_thread_section = {
        let history = sess.clone_history().await;
        build_current_thread_section(history.raw_items())
    };
    let recent_threads = load_recent_threads(sess).await;
    let recent_work_section = build_recent_work_section(&cwd, &recent_threads).await;
    let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir()).await;

    if current_thread_section.is_none()
        && recent_work_section.is_none()
        && workspace_section.is_none()
    {
        debug!("realtime startup context unavailable; skipping injection");
        return None;
    }
// ...

clone_history 取得的是当前内存历史,压缩后可能已经不是最初的全部对话。它不会在这里回读所有 rollout 来补齐被压缩的细节。近期 Thread 加载失败则降为一个空列表,其他部分仍可生成。

只有三种实际背景都缺失时,函数才返回 None;固定 Notes 文本不能独自使一个空上下文变成“有内容”。最后的 notes 说明取样来源,却没有再次读取 AGENTS、记忆摘要或项目文档的独立步骤。

3.2 消息过滤与归组 ​

“当前 Thread 的 turn”在这个算法里是按有效 user 文本切分的阅读单元,不是根据运行时 Turn ID 查询出的数据库记录:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_current_thread_section(L213–L271,摘录)

rust
// 按有效 user 文本分组,不从运行时 Turn ID 重建对话。
fn build_current_thread_section<'a>(
    items: impl IntoIterator<Item = &'a ResponseItem>,
) -> Option<String> {
    let mut turns = Vec::new();
    let mut current_user = Vec::new();
    let mut current_assistant = Vec::new();

    for item in items {
        match item {
            ResponseItem::Message { role, content, .. } if role == "user" => {
                // 任一已登记上下文项命中,就跳过这条 user 消息。
                if is_contextual_user_message_content(content) {
                    continue;
                }
                let Some(text) = content_items_to_text(content)
                    .map(|text| text.trim().to_string())
                    .filter(|text| !text.is_empty())
                else {
                    continue;
                };
                if !current_user.is_empty() || !current_assistant.is_empty() {
                    turns.push((take(&mut current_user), take(&mut current_assistant)));
                }
                current_user.push(text);
            }
            ResponseItem::Message { role, content, .. } if role == "assistant" => {
                let Some(text) = content_items_to_text(content)
                    .map(|text| text.trim().to_string())
                    .filter(|text| !text.is_empty())
                else {
                    continue;
                };
                // 没有前置 user 的 assistant 不创建独立组。
                if current_user.is_empty() && current_assistant.is_empty() {
                    continue;
                }
                current_assistant.push(text);
            }
            ResponseItem::AgentMessage {
                author, content, ..
            } => {
                let Some(text) = plaintext_agent_message_content(content) else {
                    continue;
                };
                if current_user.is_empty() && current_assistant.is_empty() {
                    continue;
                }
                current_assistant.push(format!("Agent message from {author}:\n{text}"));
            }
            _ => {}
        }
    }

    if !current_user.is_empty() || !current_assistant.is_empty() {
        turns.push((current_user, current_assistant));
    }

    if turns.is_empty() {
        return None;
    }
// ...

每遇到一个有效 user 消息,先结束之前积累的一组;后续 assistant 文本和可读的跨 Agent 消息继续挂到这组。工具调用和工具输出不参与这一段摘要;没有前置有效 user 的 assistant 文本会被忽略。

这里的过滤还有两个具体边界:

  1. content_items_to_text 只提取 InputText/OutputText,跳过图片和音频。纯图片 user 消息不会在本算法中创建新的分组,不能据此声称它重建了完整多模态 Turn。
  2. is_contextual_user_message_content 采用 any:只要一个 content 项匹配已登记的上下文片段,整条 user 消息就会被跳过。

匹配器登记了 UserInstructions、环境、Skills、shell、TurnAborted 等片段,但不是所有实现 ContextualUserFragment 的类型都会自动登记。当前列表里没有 RealtimeDelegation,所以不能把“过滤上下文消息”扩写成“所有委派文本都一定排除”。

源码文件:codex-rs/core/src/context/contextual_user_message.rs

相关函数/类型:CONTEXTUAL_USER_FRAGMENT_MATCHERS、is_contextual_user_fragment(L18–L45,摘录)

rust
// 消费者通过显式列表识别上下文;实现 trait 不会自动加入列表。
const CONTEXTUAL_USER_FRAGMENT_MATCHERS: &[fn(&str) -> bool] = &[
    UserInstructions::matches_text,
    EnvironmentsState::matches_text,
    AdditionalContextUserFragment::matches_text,
    codex_skills_extension::is_skill_prompt_fragment,
    UserShellCommand::matches_text,
    TurnAborted::matches_text,
    SubagentNotification::matches_text,
    InternalModelContextFragment::matches_text,
    RecommendedPluginsInstructions::matches_text,
    LegacyUnifiedExecProcessLimitWarning::matches_text,
    LegacyApplyPatchExecCommandWarning::matches_text,
    LegacyModelMismatchWarning::matches_text,
];

fn is_standard_contextual_user_text(text: &str) -> bool {
    CONTEXTUAL_USER_FRAGMENT_MATCHERS
        .iter()
        .any(|matches_text| matches_text(text))
}

pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
    let ContentItem::InputText { text } = content_item else {
        return false;
    };
    parse_hook_prompt_fragment(text).is_some() || is_standard_contextual_user_text(text)
}

这段明确的登记表值得与归组循环一起阅读。trait 描述如何渲染,matcher 列表描述某个消费者如何识别;增加一个新 fragment 类型,并不会自动改变启动历史的筛选结果。

3.3 最近回合优先 ​

归组完成后,从最新的一组往回选,单组最多分配 300 个估算 token,同时扣减 Current Thread 分节的剩余额度:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_current_thread_section(L272–L317,摘录)

rust
// 节选归组后的选择阶段:先最新组,再用剩余分节预算接纳更早的组。
// ...
let mut lines = vec![
        "Most recent user/assistant turns from this exact thread. Use them for continuity when responding.".to_string(),
    ];
    let mut remaining_budget =
        CURRENT_THREAD_SECTION_TOKEN_BUDGET.saturating_sub(approx_token_count(&lines.join("\n")));
    let mut retained_turn_count = 0;

    for (index, (user_messages, assistant_messages)) in turns.into_iter().rev().enumerate() {
        if remaining_budget == 0 {
            break;
        }

        let mut turn_lines = Vec::new();
        if index == 0 {
            turn_lines.push("### Latest turn".to_string());
        } else {
            turn_lines.push(format!("### Previous turn {index}"));
        }

        if !user_messages.is_empty() {
            turn_lines.push("User:".to_string());
            turn_lines.push(user_messages.join("\n\n"));
        }
        if !assistant_messages.is_empty() {
            turn_lines.push(String::new());
            turn_lines.push("Assistant:".to_string());
            turn_lines.push(assistant_messages.join("\n\n"));
        }

        // 大组先按本组额度截短,不会直接挡住后面的旧组。
        let turn_budget = REALTIME_TURN_TOKEN_BUDGET.min(remaining_budget);
        let turn_text = turn_lines.join("\n");
        let turn_text = truncate_realtime_text_to_token_budget(&turn_text, turn_budget);
        let turn_tokens = approx_token_count(&turn_text);
        if turn_tokens == 0 {
            continue;
        }

        lines.push(String::new());
        lines.push(turn_text);
        remaining_budget = remaining_budget.saturating_sub(turn_tokens);
        retained_turn_count += 1;
    }

    (retained_turn_count > 0).then(|| lines.join("\n"))
}

大回合会被截短后保留,随后继续尝试较旧回合;并非一遇到放不下的回合就停止。短回合可以保留很多组,所以“最近四轮”也不是实现约定。输出顺序为 Latest turn、Previous turn 1、Previous turn 2,与日常按时间正序显示聊天不同。

下图把过滤、归组和取样拆开。三个阶段各自丢弃的信息不同,不能只看最终字符串长度。

  • 上下文 user 消息与没有文本的消息在归组前就被过滤。
  • 倒序只用于启动背景,不会把 Session 的原始 history 反转。
  • 裁剪函数内部收紧预算以容纳标记;空结果跳过当前组,具体实现见第 6 节。

上游测试用四组短文本断言完整的新到旧顺序;另一组使用八个长回合,要求最新回合开头与结尾仍在,而最旧回合已经消失。测试验证的是这种文本取样方式,不证明背景保存了全部工具结果。

4. 近期工作分组 ​

4.1 元数据查询 ​

Recent Work 不是对历史 Thread 重新运行一次总结模型。输入是一页最多 40 条的 ThreadStore 元数据,按 UpdatedAt 倒序查询:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:load_recent_threads(L130–L158,摘录)

rust
// 只取最新一页;未配置 cwd/provider 过滤,失败不阻止其他背景生成。
async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
    match sess
        .services
        .thread_store
        .list_threads(ListThreadsParams {
            page_size: MAX_RECENT_THREADS,
            cursor: None,
            sort_key: ThreadSortKey::UpdatedAt,
            sort_direction: SortDirection::Desc,
            allowed_sources: Vec::new(),
            model_providers: None,
            cwd_filters: None,
            relation_filter: None,
            archived: false,
            section: None,
            project_id: None,
            search_term: None,
            use_state_db_only: false,
        })
        .await
    {
        Ok(page) => page.items,
        Err(err) => {
            warn!("failed to load realtime startup threads from thread store: {err}");
            Vec::new()
        }
    }
}

这里没有设置 cwd、provider、project 或 relation 过滤,且只读取第一页。因此背景可能包含其他目录的近期工作;某个项目没有进入这 40 条,就不会因为“它是当前项目”而被额外补查。archived 为 false,归档线程不在这一页中。

失败分支只记录 warning 并返回空 Vec,启动不因这一项缺失而失败。函数名里的 recent 是这次排序和分页的结果,不是所有历史工作的完整视图。

4.2 项目归组 ​

每条记录尝试用本地文件系统解析 Git 根目录;成功则按仓库根分组,失败或 cwd 无法表示为绝对路径时按原 cwd 分组。

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_recent_work_section(L159–L212,摘录)

rust
// 在已取得的记录内按 Git 根或 cwd 分组;当前组优先,随后按更新时间。
async fn build_recent_work_section(
    cwd: &AbsolutePathBuf,
    recent_threads: &[StoredThread],
) -> Option<String> {
    let mut groups: HashMap<PathBuf, Vec<&StoredThread>> = HashMap::new();
    for entry in recent_threads {
        let group = match AbsolutePathBuf::from_absolute_path(entry.cwd.as_path()) {
            Ok(entry_cwd) => resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &entry_cwd)
                .await
                .map(AbsolutePathBuf::into_path_buf)
                .unwrap_or_else(|| entry.cwd.clone()),
            Err(_) => entry.cwd.clone(),
        };
        groups.entry(group).or_default().push(entry);
    }

    let current_group = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), cwd)
        .await
        .map(AbsolutePathBuf::into_path_buf)
        .unwrap_or_else(|| cwd.clone().into_path_buf());
    let mut groups = groups.into_iter().collect::<Vec<_>>();
    groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| {
        let left_latest = left_entries
            .iter()
            .map(|entry| entry.updated_at)
            .max()
            .unwrap_or_else(Utc::now);
        let right_latest = right_entries
            .iter()
            .map(|entry| entry.updated_at)
            .max()
            .unwrap_or_else(Utc::now);
        (
            *left_group != current_group,
            Reverse(left_latest),
            left_group.as_os_str(),
        )
            .cmp(&(
                *right_group != current_group,
                Reverse(right_latest),
                right_group.as_os_str(),
            ))
    });

    let mut sections = Vec::new();
    // 最多选择八个组,不为落在本页之外的项目追加查询。
    for (group, mut entries) in groups.into_iter().take(MAX_RECENT_WORK_GROUPS) {
        entries.sort_by_key(|entry| Reverse(entry.updated_at));
        if let Some(section) = format_thread_group(&current_group, &group, entries).await {
            sections.push(section);
        }
    }
    (!sections.is_empty()).then(|| sections.join("\n\n"))
}

排序元组的首项是“是否不等于当前组”,所以进入候选集合的当前组排在前面;再按组内最新更新时间倒序,最后用路径稳定排序。随后最多输出八个组,并在组内按更新时间选择用户请求。

源码调用的是 resolve_root_git_project_for_trust,但这里使用的是它返回的根路径;不能只根据函数名,把整段背景构建理解成完成了一次独立权限审批。

4.3 请求摘录 ​

组内实际提取的是 first_user_message。以下循环决定去重、数量和长度:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:format_thread_group(L532–L569,摘录)

rust
// 节选元数据头部之后的请求摘录;去重基于 cwd 和完整规范化文本。
// ...
let mut seen = HashSet::new();
    let max_asks = if group == current_group {
        MAX_CURRENT_CWD_ASKS
    } else {
        MAX_OTHER_CWD_ASKS
    };

    for entry in entries {
        let Some(first_user_message) = entry.first_user_message.as_deref() else {
            continue;
        };
        let ask = first_user_message
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ");
        let dedupe_key = format!("{}:{ask}", entry.cwd.display());
        if ask.is_empty() || !seen.insert(dedupe_key) {
            continue;
        }
        // 这里按字符裁剪;后续整个分节还会按估算 token 裁剪。
        let ask = if ask.chars().count() > MAX_ASK_CHARS {
            format!(
                "{}...",
                ask.chars()
                    .take(MAX_ASK_CHARS.saturating_sub(3))
                    .collect::<String>()
            )
        } else {
            ask
        };
        lines.push(format!("- {}: {ask}", entry.cwd.display()));
        if seen.len() == max_asks {
            break;
        }
    }

    (lines.len() > 5).then(|| lines.join("\n"))
}

去重键由原 cwd 与空白折叠后的完整请求组成。同一 Git 仓库的两个子目录即使有相同请求,仍可能保留两条;这与“按请求文本全局去重”不同。当前项目组最多八条,其他组最多五条。数量在字符截短之前去重,显示相同的两个截短请求也未必是同一个键。

MAX_ASK_CHARS = 240 约束的是 Unicode 标量字符数;超长时保留前 237 个字符加三个英文句点。它不是 240 字节,也不是 240 token。Recent Work 分节后面还会整体裁剪,因此这只是组内第一层约束。

组标题还记录会话数、最近活动和最新记录的 branch。最终 lines.len() > 5 是基于已组装行数的保留条件,带 branch 的组即使没有有效 ask,也可能留下元数据;不能把这个条件解释为“至少找到一条用户请求”。

5. 本机目录地图 ​

5.1 三个扫描根 ​

目录地图最多取三个根:cwd、不同于 cwd 的 Git 根、与前两者都不同的用户主目录。

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_workspace_section_with_user_root(L343–L367,摘录)

rust
// 节选扫描根与可用性判断;文本拼装随后记录路径和各树条目。
async fn build_workspace_section_with_user_root(
    cwd: &AbsolutePathBuf,
    user_root: Option<PathBuf>,
) -> Option<String> {
    let cwd_path = cwd.as_path();
    let git_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), cwd).await;
    let cwd_tree = render_tree(cwd_path);
    let git_root_tree = git_root
        .as_ref()
        .filter(|git_root| git_root.as_path() != cwd_path)
        .and_then(|git_root| render_tree(git_root.as_path()));
    let user_root_tree = user_root
        .as_ref()
        .filter(|user_root| user_root.as_path() != cwd_path)
        .filter(|user_root| {
            git_root
                .as_ref()
                .is_none_or(|git_root| git_root.as_path() != user_root.as_path())
        })
        .and_then(|user_root| render_tree(user_root));

    if cwd_tree.is_none() && git_root.is_none() && user_root_tree.is_none() {
        return None;
    }
// ...

home_dir() 和 std::fs 读取宿主本机目录,Git 根解析也使用 LOCAL_FS。连接远程执行环境不代表这段代码自动改为扫描远端文件系统。若配置 cwd 在本机不存在,cwd 树可以缺失,其他背景仍可能存在。

这一部分记录路径、目录名和目录树,不读取普通文件正文。相同根不重复扫描;但彼此不同且嵌套的根仍可能展示重叠子树。Git 根已解析到而树为空时,函数也可以输出根路径信息。

5.2 深度与宽度 ​

collect_tree_lines 限制递归深度与每层实际输出的条目:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:render_tree、collect_tree_lines(L402–L444,摘录)

rust
// 深度限制与每层 take 约束展示结果;单个目录失败只跳过该子树。
fn render_tree(root: &Path) -> Option<Vec<String>> {
    if !root.is_dir() {
        return None;
    }

    let mut lines = Vec::new();
    collect_tree_lines(root, /*depth*/ 0, &mut lines);
    (!lines.is_empty()).then_some(lines)
}

fn collect_tree_lines(dir: &Path, depth: usize, lines: &mut Vec<String>) {
    if depth >= TREE_MAX_DEPTH {
        return;
    }

    let entries = match read_sorted_entries(dir) {
        Ok(entries) => entries,
        Err(_) => return,
    };
    let total_entries = entries.len();

    for entry in entries.into_iter().take(DIR_ENTRY_LIMIT) {
        let Ok(file_type) = entry.file_type() else {
            continue;
        };
        let name = file_name_string(&entry.path());
        let indent = "  ".repeat(depth);
        // 只有目录才递归,不读取普通文件正文。
        let suffix = if file_type.is_dir() { "/" } else { "" };
        lines.push(format!("{indent}- {name}{suffix}"));
        if file_type.is_dir() {
            collect_tree_lines(&entry.path(), depth + 1, lines);
        }
    }

    if total_entries > DIR_ENTRY_LIMIT {
        lines.push(format!(
            "{}- ... {} more entries",
            "  ".repeat(depth),
            total_entries - DIR_ENTRY_LIMIT
        ));
    }
}

从 depth 0 开始,depth 达到 2 就返回,所以输出根下条目及子目录中的下一层。每次最多展示 20 个条目;只有 file_type 判断为目录才递归。某一目录读取失败时,这条子树直接消失,不会让整次启动报错。

但“最多输出 20 项”不等于“最多扫描 20 项”。排序函数先把该目录的全部可读取条目收集起来:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:read_sorted_entries、is_noisy_name(L445–L469,摘录)

rust
// 所有可读取条目先被收集并排序,上层随后才限制输出数量。
fn read_sorted_entries(dir: &Path) -> io::Result<Vec<DirEntry>> {
    let mut entries = std::fs::read_dir(dir)?
        .filter_map(Result::ok)
        .filter(|entry| !is_noisy_name(&entry.file_name()))
        .collect::<Vec<_>>();
    entries.sort_by(|left, right| {
        let left_is_dir = left
            .file_type()
            .map(|file_type| file_type.is_dir())
            .unwrap_or(false);
        let right_is_dir = right
            .file_type()
            .map(|file_type| file_type.is_dir())
            .unwrap_or(false);
        (!left_is_dir, file_name_string(&left.path()))
            .cmp(&(!right_is_dir, file_name_string(&right.path())))
    });
    Ok(entries)
}

fn is_noisy_name(name: &OsStr) -> bool {
    let name = name.to_string_lossy();
    name.starts_with('.') || NOISY_DIR_NAMES.iter().any(|noisy| *noisy == name)
}

它先过滤点开头的名字以及 target、node_modules、build 等噪声名,再按“目录优先、名称排序”整理,最后上层才 take(20)。因此宽目录仍可能有明显的枚举、分配与排序开销;复杂度取决于被枚举条目数,不能从展示上限推导固定 I/O 成本。

这些 std::fs 调用位于 async 构建函数调用链中,并未在这里转交 spawn_blocking。源码限制了输出的形状,没有提供统一的扫描超时或全链路耗时上限。

测试使用临时目录创建 docs、README.md 和独立 user root,断言目录名出现、隐藏的 .zshrc 不出现;空目录且没有 user root 时则返回 None。它们验证输出选择,不代表所有文件系统故障或远程路径都已覆盖。

6. 分节预算 ​

6.1 预算的层次 ​

自动背景有四个固定分节预算。它们与 initialItems、模式指令、委派字段的限制彼此独立:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:CURRENT_THREAD_SECTION_TOKEN_BUDGET、REALTIME_TURN_TOKEN_BUDGET(L33–L45,摘录)

rust
// 这些常量属于不同层次,分节之和不是最终 session instructions 的总上限。
const CURRENT_THREAD_SECTION_TOKEN_BUDGET: usize = 1_200;
const RECENT_WORK_SECTION_TOKEN_BUDGET: usize = 2_200;
const WORKSPACE_SECTION_TOKEN_BUDGET: usize = 1_600;
const NOTES_SECTION_TOKEN_BUDGET: usize = 300;
pub(crate) const REALTIME_TURN_TOKEN_BUDGET: usize = 300;
const MAX_RECENT_THREADS: usize = 40;
const MAX_RECENT_WORK_GROUPS: usize = 8;
const MAX_CURRENT_CWD_ASKS: usize = 8;
const MAX_OTHER_CWD_ASKS: usize = 5;
const MAX_ASK_CHARS: usize = 240;
const TREE_MAX_DEPTH: usize = 2;
const DIR_ENTRY_LIMIT: usize = 20;
const APPROX_BYTES_PER_TOKEN: usize = 4;
范围限制单位与执行位置
Current Thread1200约 4 字节/token;先逐组取样,再分节裁剪
单组当前对话300估算 token;不是固定回合数
Recent Work2200分节裁剪
Machine / Workspace Map1600分节裁剪
Notes300分节裁剪
initialItems128 项、总计 8192各项 text 的估算 token 求和,独立校验
客户端 start/end每个 8192校验客户端参数字符串
委派 input / transcript_delta各 4096转义后的 UTF-8 字节,见第 10 节

前三种背景之一存在时,构建器把它们与 Notes 分别交给 format_section。它先从本分节额度扣除标题开销:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:format_section(L470–L490,摘录)

rust
// 先为标题预留额度,再裁剪 body;空结果可以让整个分节缺席。
fn format_section(title: &str, body: Option<String>, budget_tokens: usize) -> Option<String> {
    let body = body?;
    let body = body.trim();
    if body.is_empty() {
        return None;
    }

    let heading = format!("## {title}\n");
    let body_budget = budget_tokens.saturating_sub(approx_token_count(&heading));
    if body_budget == 0 {
        return None;
    }

    let body = truncate_realtime_text_to_token_budget(body, body_budget);
    if body.is_empty() {
        return None;
    }

    Some(format!("{heading}{body}"))
}

标题过大导致 body_budget 为零、body 为空,或裁剪后没有内容,这个分节可以被省略。不同分节不会互相借用余量:当前 Thread 很短,不意味着 Recent Work 的预算会自动扩大。

6.2 截断标记开销 ​

通用截断器会选择两端内容并添加标记。只对原内容预算一次,拼出标记后仍可能超额,因此实时上下文有一层收紧循环:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:truncate_realtime_text_to_token_budget(L318–L342,摘录)

rust
// 重新计算含截断标记的结果长度,逐次收紧内容额度直到符合原目标。
pub(crate) fn truncate_realtime_text_to_token_budget(text: &str, budget_tokens: usize) -> String {
    let mut truncation_budget = budget_tokens;
    loop {
        let candidate = truncate_text(text, TruncationPolicy::Tokens(truncation_budget));
        let candidate_tokens = approx_token_count(&candidate);
        if candidate_tokens <= budget_tokens {
            break candidate;
        }

        // The shared truncator adds its marker after choosing preserved
        // content, so tighten the content budget until the rendered turn
        // itself fits the per-turn cap.
        let excess_tokens = candidate_tokens.saturating_sub(budget_tokens);
        let next_budget = truncation_budget.saturating_sub(excess_tokens.max(1));
        // 即使只剩标记仍超额,也要返回空串,不能无限循环。
        if next_budget == 0 {
            let candidate = truncate_text(text, TruncationPolicy::Tokens(0));
            if approx_token_count(&candidate) <= budget_tokens {
                break candidate;
            }
            break String::new();
        }
        truncation_budget = next_budget;
    }
}

循环每次估算候选结果的实际字节数,再从内容额度扣掉超出的 token;至少收紧 1,避免没有进展。若额度已经压到零而标记仍放不下,返回空串。算法保留开头和结尾,故一个很长的请求仍可保留末尾限定条件,而中段被截掉。

这里的 token 是 text.len().div_ceil(4),不是模型 tokenizer 的计数;多字节中文和 ASCII 的字符数也不能直接互换。读源码时应先确认被比较的是字符串字节、字符还是估算 token。

上游 current_thread_turn_truncation_preserves_start_and_end 用带 start/middle/end 的长文本,断言 start 和 end 存在、middle 消失并带截断标记。它既验证保留策略,也说明输出不再是完整原文。

6.3 拼接后的边界 ​

build_realtime_startup_context 的 budget_tokens 参数只出现在诊断日志中。四个分节之后,它直接添加头部、空行与外层标签,没有再按这个参数裁剪整段:

源码文件:codex-rs/core/src/realtime_context.rs

相关函数/类型:build_realtime_startup_context、format_startup_context_blob(L116–L128, L491–L494,摘录)

rust
// budget_tokens 在这里只参与日志;外层标签拼接不再做总裁剪。
// ...
let context = format_startup_context_blob(&parts.join("\n\n"));
    debug!(
        approx_tokens = approx_token_count(&context),
        requested_budget_tokens = budget_tokens,
        bytes = context.len(),
        has_current_thread_section,
        has_recent_work_section,
        has_workspace_section,
        "built realtime startup context"
    );
    info!("realtime startup context: {context}");
    Some(context)
}

// ...

fn format_startup_context_blob(body: &str) -> String {
    format!("{STARTUP_CONTEXT_OPEN_TAG}\n{body}\n{STARTUP_CONTEXT_CLOSE_TAG}")
}

因此 1200 + 2200 + 1600 + 300 = 5300 是分节额度之和,不能写成完整 session instructions 的硬上限。背景还有固定 header/tag 开销;基础 prompt 在它之外;配置直接覆盖的 startup context 也不经过这些分节算法。

这两个测试专门约束“各节裁剪、外层不再裁剪”的行为:

源码文件:codex-rs/core/src/realtime_context_tests.rs

相关函数/类型:startup_context_blob_is_wrapped_in_tags_without_final_truncation、fixed_section_budgets_apply_per_section_without_total_blob_truncation(L194–L246,摘录)

rust
// 测试故意保留完整外层标签和全部分节,不把分节预算误当成最终总截断。
#[test]
fn startup_context_blob_is_wrapped_in_tags_without_final_truncation() {
    let body = "Startup context from Codex.\n## Current Thread\nhello";
    let wrapped = format_startup_context_blob(body);

    assert_eq!(
        wrapped,
        "<startup_context>\nStartup context from Codex.\n## Current Thread\nhello\n</startup_context>"
    );
}

#[test]
fn fixed_section_budgets_apply_per_section_without_total_blob_truncation() {
    let body = [
        STARTUP_CONTEXT_HEADER.to_string(),
        format_section(
            "Current Thread",
            Some("current thread ".repeat(2_000)),
            CURRENT_THREAD_SECTION_TOKEN_BUDGET,
        )
        .expect("current thread section"),
        format_section(
            "Recent Work",
            Some("recent work ".repeat(3_000)),
            RECENT_WORK_SECTION_TOKEN_BUDGET,
        )
        .expect("recent work section"),
        format_section(
            "Machine / Workspace Map",
            Some("workspace map ".repeat(2_500)),
            WORKSPACE_SECTION_TOKEN_BUDGET,
        )
        .expect("workspace section"),
        format_section(
            "Notes",
            Some("notes ".repeat(500)),
            NOTES_SECTION_TOKEN_BUDGET,
        )
        .expect("notes section"),
    ]
    .join("\n\n");

    let wrapped = format_startup_context_blob(&body);

    assert!(wrapped.starts_with("<startup_context>\n"));
    assert!(wrapped.ends_with("\n</startup_context>"));
    assert!(wrapped.contains("tokens truncated"));
    assert!(wrapped.contains("## Current Thread"));
    assert!(wrapped.contains("## Recent Work"));
    assert!(wrapped.contains("## Machine / Workspace Map"));
    assert!(wrapped.contains("## Notes"));
}

其中构造了四段超长内容,要求截断标记和四个分节标题都保留。它没有断言最终结果小于某个统一总值。另一个 socket 测试对自己构造的请求断言长度不超过 20,500 字节,那只是特定 fixture 的结果,不能覆盖任意配置 prompt 或 startup-context 覆盖值。

7. 初始消息 ​

7.1 保留角色 ​

initialItems 是完整的带 role 文本项。它们不会先混入启动摘要再让模型猜角色:

源码文件:codex-rs/app-server-protocol/src/protocol/v2/realtime.rs

相关函数/类型:ThreadRealtimeInitialItem(L266–L274,摘录)

rust
// 初始化项显式携带 role,原始顺序在映射时保留。
/// EXPERIMENTAL - role-bearing text item included when a realtime V3 session starts.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadRealtimeInitialItem {
    pub role: ConversationTextRole,
    pub text: String,
}

App Server 把它们映射为 ConversationTextParams,Core 校验后放进 RealtimeSessionConfig.initial_items。V3 在构造 session JSON 时,根据 role 选择内容类型:

源码文件:codex-rs/codex-api/src/endpoint/realtime_websocket/methods_frameless_bidi.rs

相关函数/类型:session_json(L76–L98,摘录)

rust
// 节选 session_json 的 initial_items 分支:assistant 使用 output_text,其余两种角色使用 input_text。
if !initial_items.is_empty() {
    session["initial_items"] = Value::Array(
        initial_items
            .into_iter()
            .map(|item| {
                let content_type = match item.role {
                    ConversationTextRole::User | ConversationTextRole::Developer => {
                        "input_text"
                    }
                    ConversationTextRole::Assistant => "output_text",
                };
                json!({
                    "type": "message",
                    "role": item.role,
                    "content": [{
                        "type": content_type,
                        "text": item.text,
                    }],
                })
            })
            .collect(),
    );
}

User 与 Developer 对应 input_text,Assistant 对应 output_text。空列表不发送 initial_items 字段。列表本身保持输入顺序,不做历史归组、去重或摘要。

7.2 校验时机 ​

版本、项数和文本预算在连接建立前校验:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:build_realtime_session_config(L1357–L1381,摘录)

rust
// 节选 initialItems 校验;单项额度和合计额度必须分别检查。
if version != RealtimeWsVersion::V3 && !params.initial_items.is_empty() {
    return Err(CodexErr::InvalidRequest(
        "initial realtime items require realtime v3".to_string(),
    ));
}
if params.initial_items.len() > REALTIME_INITIAL_ITEMS_MAX_COUNT {
    return Err(CodexErr::InvalidRequest(format!(
        "initial realtime items must contain no more than {REALTIME_INITIAL_ITEMS_MAX_COUNT} items"
    )));
}
let mut total_initial_item_tokens: usize = 0;
for item in &params.initial_items {
    let item_tokens = approx_token_count(&item.text);
    if item_tokens > REALTIME_INITIAL_ITEMS_MAX_TOKENS {
        return Err(CodexErr::InvalidRequest(format!(
            "each initial realtime item must not exceed {REALTIME_INITIAL_ITEMS_MAX_TOKENS} estimated tokens"
        )));
    }
    total_initial_item_tokens = total_initial_item_tokens.saturating_add(item_tokens);
}
if total_initial_item_tokens > REALTIME_INITIAL_ITEMS_MAX_TOKENS {
    return Err(CodexErr::InvalidRequest(format!(
        "initial realtime items must not exceed {REALTIME_INITIAL_ITEMS_MAX_TOKENS} estimated tokens in total"
    )));
}

每项与总量都按 text 估算,不把 JSON 字段、role 名或整个 instructions 加进这个总数。两个各自未超额的项仍可能使合计超额,所以不能删掉最后的 aggregate 检查。

例如边界测试用 "x".repeat(8192 * 4 + 1) 触发单项拒绝;再用两个 "x".repeat(8192 * 2 + 1) 触发总量拒绝。另有 129 项和非 V3 输入的拒绝测试。失败通过 Realtime Error 事件报告,不能把 RPC 返回空对象当作会话配置已成功发送的证明。

注意执行顺序:模式指令的参数长度校验先于背景构建;initialItems 的检查在背景拼接之后。因此一个最终被 initialItems 检查拒绝的请求,仍可能已经做过本机背景取样。

8. 模式指令 ​

8.1 谁拥有文本 ​

与发往实时 API 的 prompt 不同,start/end 字符串保存在 manager 的独立槽位中:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:RealtimeModeInstructions、mode_instructions、start(L128–L137, L502–L504, L528–L544,摘录)

rust
// mode 文本单独保存,运行状态取走以后仍可用于后续 end 指令。
pub(crate) struct RealtimeConversationManager {
    state: Mutex<Option<ConversationState>>,
    mode_instructions: Mutex<Option<RealtimeModeInstructions>>,
}

#[derive(Clone, Debug)]
pub(crate) struct RealtimeModeInstructions {
    pub(crate) start: Option<String>,
    pub(crate) end: Option<String>,
}

// ...

pub(crate) async fn mode_instructions(&self) -> Option<RealtimeModeInstructions> {
    self.mode_instructions.lock().await.clone()
}

// ...

async fn start(
    &self,
    start: RealtimeStart,
    mode_instructions: RealtimeModeInstructions,
) -> CodexResult<RealtimeStartOutput> {
    let previous_state = {
        let mut guard = self.state.lock().await;
        guard.take()
    };
    if let Some(state) = previous_state {
        stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await;
    }

    let output = self.start_inner(start).await?;
    // 只有新的 start_inner 成功,才替换 mode 文本。
    *self.mode_instructions.lock().await = Some(mode_instructions);
    Ok(output)
}

start 先结束旧状态、创建新运行状态,成功后才替换 mode_instructions。shutdown 取走的是运行状态,不会顺手清空这份指令文本;后续普通 Turn 还需要它来生成自定义 end 说明。

这也解释了为什么成功“存入参数”与普通模型已经“收到参数”不同:mode slot 只是来源,实际发出的内容由 World State 比较决定。

8.2 片段与角色 ​

默认 start 指令使用一个无字段类型,实现 ContextualUserFragment。尽管 trait 名字含 User,它的 role 明确为 developer:

源码文件:codex-rs/core/src/context/realtime_start_instructions.rs

相关函数/类型:RealtimeStartInstructions(L7–L33,摘录)

rust
// 默认 start 是 developer 片段,trait 名称不能代替实际 role。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RealtimeStartInstructions;

impl ContextualUserFragment for RealtimeStartInstructions {
    fn content_kind(&self) -> ContentItemKind {
        ContentItemKind("realtime_conversation.start_instructions".to_string())
    }

    fn role(&self) -> &'static str {
        "developer"
    }

    fn markers(&self) -> (&'static str, &'static str) {
        Self::type_markers()
    }

    fn type_markers() -> (&'static str, &'static str) {
        (
            REALTIME_CONVERSATION_OPEN_TAG,
            REALTIME_CONVERSATION_CLOSE_TAG,
        )
    }

    fn body(&self) -> String {
        format!("\n{}\n", START_INSTRUCTIONS.trim())
    }
}

模板要求普通执行者把实时输入视为可能有识别误差的转录,并简洁返回执行结果:

源码文件:codex-rs/prompts/templates/realtime/realtime_start.md

相关模板常量:START_INSTRUCTIONS(L1–L9,摘录)

markdown
<!-- 默认模板告知普通执行者转录输入与中间交互者的关系。 -->
Realtime conversation started.

You are operating as a backend executor behind an intermediary. The user does not talk to you directly. Any response you produce will be consumed by the intermediary and may be summarized before the user sees it.

When invoked, you receive the latest conversation transcript and any relevant mode or metadata. The intermediary may invoke you even when backend help is not actually needed. Use the transcript to decide whether you should do work. If backend help is unnecessary, avoid verbose responses that add user-visible latency.

When user text is routed from realtime, treat it as a transcript. It may be unpunctuated or contain recognition errors.

- Keep responses concise and action-oriented. Your updates should help the intermediary respond to the user.

这个模板面向普通 Codex 模型。它不会自动把某条实时文本转换为工具调用;是否提交工作仍由委派路径和普通 Turn 逻辑控制。

客户端自定义 start 则用一个持有 String 的片段,body 保留调用者提供的文本:

源码文件:codex-rs/core/src/context/realtime_start_with_instructions.rs

相关函数/类型:RealtimeStartWithInstructions(L6–L42,摘录)

rust
// 自定义正文原样保存;空字符串与 None 的默认回退语义不同。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealtimeStartWithInstructions {
    instructions: String,
}

impl RealtimeStartWithInstructions {
    pub(crate) fn new(instructions: impl Into<String>) -> Self {
        Self {
            instructions: instructions.into(),
        }
    }
}

impl ContextualUserFragment for RealtimeStartWithInstructions {
    fn content_kind(&self) -> ContentItemKind {
        ContentItemKind("realtime_conversation.custom_start_instructions".to_string())
    }

    fn role(&self) -> &'static str {
        "developer"
    }

    fn markers(&self) -> (&'static str, &'static str) {
        Self::type_markers()
    }

    fn type_markers() -> (&'static str, &'static str) {
        (
            REALTIME_CONVERSATION_OPEN_TAG,
            REALTIME_CONVERSATION_CLOSE_TAG,
        )
    }

    fn body(&self) -> String {
        format!("\n{}\n", self.instructions)
    }
}

自定义字符串与默认模板共用 realtime_conversation 标记,但 content_kind 不同。显式空字符串仍是 Some,不会回退默认模板;结果只剩带换行的标记片段。

end 片段保存 Option<String>,没有自定义值时读取默认 END_INSTRUCTIONS:

源码文件:codex-rs/core/src/context/realtime_end_instructions.rs

相关函数/类型:RealtimeEndInstructions(L7–L51,摘录)

rust
// 已有自定义值就使用它,否则使用默认 END_INSTRUCTIONS。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RealtimeEndInstructions {
    instructions: Option<String>,
}

impl RealtimeEndInstructions {
    pub(crate) fn new() -> Self {
        Self { instructions: None }
    }

    pub(crate) fn with_instructions(instructions: impl Into<String>) -> Self {
        Self {
            instructions: Some(instructions.into()),
        }
    }
}

impl ContextualUserFragment for RealtimeEndInstructions {
    fn content_kind(&self) -> ContentItemKind {
        ContentItemKind("realtime_conversation.end_instructions".to_string())
    }

    fn role(&self) -> &'static str {
        "developer"
    }

    fn markers(&self) -> (&'static str, &'static str) {
        Self::type_markers()
    }

    fn type_markers() -> (&'static str, &'static str) {
        (
            REALTIME_CONVERSATION_OPEN_TAG,
            REALTIME_CONVERSATION_CLOSE_TAG,
        )
    }

    fn body(&self) -> String {
        let instructions = self
            .instructions
            .as_deref()
            .unwrap_or_else(|| END_INSTRUCTIONS.trim());
        format!("\n{instructions}\n")
    }
}

默认 end 模板的职责是恢复普通文字输入的解释方式:

源码文件:codex-rs/prompts/templates/realtime/realtime_end.md

相关模板常量:END_INSTRUCTIONS(L1–L3,摘录)

markdown
<!-- 这是给普通模型的状态说明,不是结束任务的程序调用。 -->
Realtime conversation ended.

Subsequent user input will return to typed text rather than transcript-style text. Do not assume recognition errors or missing punctuation once realtime has ended. Resume normal chat behavior.

它是模型上下文中的状态说明,不是一个取消音频任务或中断普通 Turn 的控制命令。控制路径结束资源,模式片段告知模型,两者在不同位置执行。

8.3 渲染与消息合并 ​

公共 trait 定义 role、分类、标记、body 与渲染入口。其核心实现如下:

源码文件:codex-rs/context-fragments/src/fragment.rs

相关函数/类型:ContextualUserFragment(L64–L120,摘录)

rust
// 标记、空白和 role 都由实现者控制;渲染结果保留内容分类。
pub trait ContextualUserFragment {
    fn role(&self) -> &'static str;

    /// Returns a stable `<feature>.<name>` classification, using `generic` for shared fragments.
    fn content_kind(&self) -> ContentItemKind;

    /// Whether this fragment must be recorded as its own response item.
    fn requires_separate_message(&self) -> bool {
        false
    }

    fn markers(&self) -> (&'static str, &'static str);

    fn body(&self) -> String;

    fn type_markers() -> (&'static str, &'static str)
    where
        Self: Sized;

    fn matches_text(text: &str) -> bool
    where
        Self: Sized,
    {
        let (start_marker, end_marker) = Self::type_markers();
        matches_marked_text(start_marker, end_marker, text)
    }

    fn render(&self) -> String {
        let (start_marker, end_marker) = self.markers();
        let body = self.body();
        if start_marker.is_empty() && end_marker.is_empty() {
            return body;
        }

        format!("{start_marker}{body}{end_marker}")
    }

    /// Renders the role, model-visible content, and classification together.
    // 把 role 与带分类的内容绑定,供下一层合并。
    fn render_fragment(&self) -> RenderedFragment {
        RenderedFragment::new(
            self.role(),
            AnnotatedContent::input_text(self.render(), self.content_kind()),
        )
    }

    fn into(self) -> ResponseItem
    where
        Self: Sized,
    {
        ResponseItem::from(self.render_fragment())
    }

    fn into_boxed_response_item(self: Box<Self>) -> ResponseItem {
        ResponseItem::from(self.render_fragment())
    }
}

render 只拼接标记与 body,不额外补换行;每个实现者必须自行决定空白。render_fragment 将 role 和带分类的内容绑在一起,避免构造 Message 时只保留文本、丢掉内容类别。

普通上下文更新会把相邻且允许合并的同角色 fragment 合成一条 ResponseItem,content 中仍可有多个 InputText。完整调用链是 render_diff → merge_contextual_fragments → record_conversation_items → history → sampling input,不是每个 fragment 都独立占据一个网络 Message。

源码文件:codex-rs/core/src/context_manager/updates.rs

相关函数/类型:merge_contextual_fragments(L32–L60,摘录)

rust
// 只有相邻、同角色且均允许合并的片段才进入同一个 Message。
pub(crate) fn merge_contextual_fragments(
    fragments: Vec<Box<dyn ContextualUserFragment>>,
) -> Vec<ResponseItem> {
    let mut messages: Vec<(&str, MessageGroup, Vec<RenderedFragment>)> =
        Vec::with_capacity(fragments.len());
    for fragment in fragments {
        let group = if fragment.requires_separate_message() {
            MessageGroup::Standalone
        } else {
            MessageGroup::Mergeable
        };
        let rendered = fragment.render_fragment();
        let role = rendered.role();
        match messages.last_mut() {
            Some((previous_role, previous_group, rendered_fragments))
                if *previous_role == role
                    && *previous_group == MessageGroup::Mergeable
                    && group == MessageGroup::Mergeable =>
            {
                rendered_fragments.push(rendered);
            }
            _ => messages.push((role, group, vec![rendered])),
        }
    }
    messages
        .into_iter()
        .filter_map(|(_, _, fragments)| build_rendered_message(fragments))
        .collect()
}

合并条件看相邻 role 与 requires_separate_message,不按全局 role 重新排序。阅读请求时可以寻找 developer 消息中的 realtime 标记,但不要用“消息总条数等于 fragment 个数”验证是否注入成功。

9. 世界状态差量 ​

9.1 布尔值的来源 ​

TurnContext.realtime_active 在创建普通 TurnContext 时读取运行状态,之后作为该 Turn 的快照使用:

源码文件:codex-rs/core/src/session/turn_context.rs

相关函数/类型:realtime_active(L956–L963,摘录)

rust
// 节选 TurnContext 构造收尾;取样后的布尔值随这份 Arc 传给各 step。
turn_context.code_mode_available = self.services.code_mode_service.is_available();
turn_context.extension_data.insert(trusted_plugin_roots);
turn_context.realtime_active = self.conversation.running_state().await.is_some();

if let Some(final_schema) = final_output_json_schema {
    turn_context.final_output_json_schema = final_schema;
}
let turn_context = Arc::new(turn_context);

每个 step 构建 World State 时使用这个布尔值,同时取得 manager 保存的 mode 文本。start 还允许从普通 Turn 配置回退:

源码文件:codex-rs/core/src/session/world_state.rs

相关函数/类型:build_world_state_for_step(L131–L144,摘录)

rust
// 节选实时 section 装配;布尔值来自 Turn,模式文本来自 manager 和 start 配置回退。
let realtime_mode_instructions = self.conversation.mode_instructions().await;
world_state.add_section(RealtimeState::new(
    turn_context.realtime_active,
    realtime_mode_instructions
        .as_ref()
        .and_then(|instructions| instructions.start.as_deref())
        .or(turn_context
            .config
            .experimental_realtime_start_instructions
            .as_deref()),
    realtime_mode_instructions
        .as_ref()
        .and_then(|instructions| instructions.end.as_deref()),
));

优先级因此与基础 prompt 不同:客户端 start 优先于 experimental_realtime_start_instructions,最后才是默认模板;end 只有客户端值与默认模板。这里的配置 start 回退也不能套用“客户端参数已经通过 8192 校验”的结论,因为参数校验检查的是 params.realtime_start_instructions。

运行中的普通 Turn 不会仅因实时连接打开或关闭,就通过这几行代码重写自己的 realtime_active。下一次创建 TurnContext 才重新取样;同一个 Turn 内的 step 仍用既有快照。结束尾声如果 steer 现有工作,也不能被理解为必然新建了一个实时状态为 false 的 Turn。

9.2 比较状态与正文 ​

RealtimeState 拥有当前可渲染的文本,但持久比较的 RealtimeSnapshot 只包含 active:

源码文件:codex-rs/core/src/context/world_state/realtime.rs

相关函数/类型:RealtimeState、RealtimeSnapshot、new(L10–L35,摘录)

rust
// 渲染所需字符串与用于判等的 snapshot 分开;只有 active 进入 snapshot。
/// The realtime conversation state currently visible to the model.
#[derive(Clone, Debug)]
pub(crate) struct RealtimeState {
    snapshot: RealtimeSnapshot,
    start_instructions: Option<String>,
    end_instructions: Option<String>,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub(crate) struct RealtimeSnapshot {
    active: bool,
}

impl RealtimeState {
    pub(crate) fn new(
        active: bool,
        start_instructions: Option<&str>,
        end_instructions: Option<&str>,
    ) -> Self {
        Self {
            snapshot: RealtimeSnapshot { active },
            start_instructions: start_instructions.map(str::to_string),
            end_instructions: end_instructions.map(str::to_string),
        }
    }
// ...

这一结构决定了差量的范围:同为 active,只改 start 字符串,两个 snapshot 仍相等。字符串存在于实时 state,不代表它参与了判等。

具体的进入与退出片段由以下逻辑选择:

源码文件:codex-rs/core/src/context/world_state/realtime.rs

相关函数/类型:render_start、render_transition、render_diff(L36–L93,摘录)

rust
// 已知 active 未变就不产生文本差量;缺失或未知状态只在当前 active 时补发 start。
// ...
fn render_start(&self) -> Box<dyn ContextualUserFragment> {
        match self.start_instructions.as_deref() {
            Some(instructions) => Box::new(RealtimeStartWithInstructions::new(instructions)),
            None => Box::new(RealtimeStartInstructions),
        }
    }

    fn render_transition(&self, previous_active: bool) -> Option<Box<dyn ContextualUserFragment>> {
        match (previous_active, self.snapshot.active) {
            (false, true) => Some(self.render_start()),
            (true, false) => Some(match self.end_instructions.as_deref() {
                Some(instructions) => {
                    Box::new(RealtimeEndInstructions::with_instructions(instructions))
                }
                None => Box::new(RealtimeEndInstructions::new()),
            }),
            (false, false) | (true, true) => None,
        }
    }
}

impl WorldStateSection for RealtimeState {
    const ID: &'static str = "realtime";
    type Snapshot = RealtimeSnapshot;

    fn snapshot(&self) -> Self::Snapshot {
        self.snapshot.clone()
    }

    fn matches_legacy_fragment(role: &str, text: &str) -> bool {
        role == "developer" && RealtimeStartInstructions::matches_text(text)
    }

    fn has_retained_fragment_matcher() -> bool {
        true
    }

    fn matches_retained_fragment(role: &str, text: &str) -> bool {
        Self::matches_legacy_fragment(role, text)
    }

    fn render_diff(
        &self,
        previous: PreviousSectionState<'_, Self::Snapshot>,
    ) -> Option<Box<dyn ContextualUserFragment>> {
        match previous {
            // 比较的 snapshot 只有 active,不比较 start/end 字符串。
            PreviousSectionState::Known(previous) if previous == &self.snapshot => None,
            PreviousSectionState::Known(previous) => self.render_transition(previous.active),
            PreviousSectionState::Absent | PreviousSectionState::Unknown
                if self.snapshot.active =>
            {
                Some(self.render_start())
            }
            PreviousSectionState::Absent | PreviousSectionState::Unknown => None,
        }
    }
}
先前知识当前 active输出
Known(false)true默认或自定义 start
Known(true)false默认或自定义 end
Known(true)true无差量,即使 start 文本改变
Known(false)false无差量
Absent / Unknowntrue重述 start
Absent / Unknownfalse无片段

下图表示模型上下文的比较状态,不是 socket 的连接状态机。Absent/Unknown 在本 section 上产生相同输出,合画为一个入口。

  • 从未知到 inactive 不补发 end,因为这时没有可靠的先前 active 状态。
  • 活跃到活跃不比较文本,所以重新创建实时会话也不等于一定刷新普通历史中的指令。
  • 历史是否保留片段会影响判定,下面需要继续读外围比较器。

9.3 保留历史与压缩 ​

WorldState::render_history_diff 先检查快照与当前保留历史。即使有已知 snapshot,如果该 section 要求保留的片段已不在 history,也按 Absent 处理:

源码文件:codex-rs/core/src/context/world_state/mod.rs

相关函数/类型:render_history_diff(L414–L435,摘录)

rust
// 保留片段缺失时,即使有 snapshot 也视为 Absent,避免模型丢掉必要模式说明。
pub(crate) fn render_history_diff<'a>(
    &self,
    previous: Option<&WorldStateSnapshot>,
    items: impl IntoIterator<Item = &'a ResponseItem> + Clone,
) -> Vec<Box<dyn ContextualUserFragment>> {
    self.render_with(|id, section| {
        if let Some(previous) = previous.and_then(|previous| previous.sections.get(id)) {
            if section.has_retained_fragment_matcher()
                && !has_retained_fragment(items.clone(), section)
            {
                PreviousSectionState::Absent
            } else {
                PreviousSectionState::Known(previous)
            }
        } else if has_legacy_fragment(items.clone(), section) {
            PreviousSectionState::Unknown
        } else {
            PreviousSectionState::Absent
        }
    })
}

Realtime section 的保留检查只接受 developer 内容中的 realtime_conversation 标记。它不逐字比较旧指令内容;默认 start、自定义 start 和 end 都使用同一标记对。没有 typed snapshot 但保留了匹配片段时,先得到 Unknown;snapshot 反序列化失败也降为 Unknown。

普通回合起点通过 ContextManager::update_world_state 得到片段和持久化补丁:

源码文件:codex-rs/core/src/context_manager/history.rs

相关函数/类型:update_world_state(L131–L149,摘录)

rust
// history 同时推进比较基线并返回待注入片段;它们不是同一份数据。
pub(crate) fn update_world_state(
    &mut self,
    world_state: &WorldState,
) -> (Vec<Box<dyn ContextualUserFragment>>, Option<WorldStateItem>) {
    let snapshot = world_state.snapshot();
    let fragments =
        world_state.render_history_diff(self.world_state_baseline.as_ref(), self.raw_items());
    let rollout_item = self.world_state_baseline.as_ref().map_or_else(
        || Some(WorldStateItem::full(snapshot.clone().into_object())),
        |previous| {
            snapshot
                .merge_patch_from(previous)
                .map(WorldStateItem::patch)
        },
    );
    self.world_state_baseline = Some(snapshot);
    (fragments, rollout_item)
}

这同时说明 baseline 与模型正文是两份数据。snapshot 记住已知状态,history 保留模型看到的说明;只恢复其中一份,并不能假定下次无需重新注入。

在一次模型采样之前,Core 先记录 world-state 更新,再从 history 取出真正送入请求的内容:

源码文件:codex-rs/core/src/session/turn.rs

相关函数/类型:run_turn(L367–L378,摘录)

rust
// 节选每次 sampling 前的顺序:先更新上下文,再从 history 构造请求输入。
world_state = sess
    .record_step_world_state_if_changed(&world_state, step_context.as_ref())
    .await?;

// Construct the input that we will send to the model.
let sampling_request_input: Vec<ResponseItem> = async {
    sess.clone_history()
        .await
        .for_prompt(&step_context.model_info.input_modalities)
}
.instrument(trace_span!("run_turn.prepare_sampling_request_input"))
.await;

因此测试应检查实际请求正文,而不是只断言 active 字段或片段构造函数的返回值。源码中的更新是追加需要告知模型的内容,不会删除整个旧对话再换一套指令。

三个压缩/恢复案例能把表格变成具体行为:

场景关键设置与断言解释
实时仍活跃,回合前或手动压缩只保留摘要压缩后的普通请求重新包含 start需要让模型重新知道活跃模式
实时已关闭,下一 Turn 首次请求已经发 end,再发生回合内压缩后续请求不再包含 realtime 标记inactive 已建立;全量重述不会反复发送 end
恢复 history 后重开实时,配置 start 文本改变但前后都是 active新请求保留旧指令且不含新指令snapshot 只比较 active,旧片段也仍在

最后一种情况的测试断言如下:

源码文件:codex-rs/core/tests/suite/compact_remote.rs

相关函数/类型:active_realtime_does_not_diff_changed_start_instructions_after_resume(L3755–L3770,摘录)

rust
// 测试恢复后显式重开实时会话;断言针对普通模型历史中的旧、新指令。
start_realtime_conversation(resumed.codex.as_ref()).await?;
resumed.submit_turn("USER_TWO").await?;

let requests = responses_mock.requests();
assert_eq!(requests.len(), 2, "expected one request per session");
assert_request_contains_custom_realtime_start(&requests[0], initial_instructions);
let resumed_body = requests[1].body_json().to_string();
assert!(
    resumed_body.contains(initial_instructions),
    "expected resumed history to retain the original realtime instructions"
);
assert!(
    !resumed_body.contains(changed_instructions),
    "did not expect an active-to-active instruction change to emit a diff"
);

测试在恢复后显式重新启动实时会话,不能据此声称 resume 会自动恢复音频连接。它证明的是:已有模型历史和 active-to-active 比较会阻止文本变更产生新的差量。

10. 委派上下文 ​

10.1 输入选择 ​

实时模型需要后台执行时,HandoffRequested 携带 input_transcript 和近期转录。Core 先选主输入,再组合两者:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:realtime_transcript_delta、realtime_text_from_handoff_request、realtime_delegation_from_handoff、wrap_realtime_delegation_input(L1647–L1682,摘录)

rust
// 先选主输入,再把近期转录放入独立字段;这里只判断空串,不 trim 主输入。
fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option<String> {
    realtime_transcript_delta(&handoff.active_transcript)
}

fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option<String> {
    let active_transcript = active_transcript
        .iter()
        .map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text))
        .collect::<Vec<_>>()
        .join("\n");
    (!active_transcript.is_empty()).then_some(active_transcript)
}

fn realtime_text_from_handoff_request(handoff: &RealtimeHandoffRequested) -> Option<String> {
    (!handoff.input_transcript.is_empty())
        .then_some(handoff.input_transcript.clone())
        .or_else(|| realtime_transcript_delta_from_handoff(handoff))
}

fn realtime_delegation_from_handoff(handoff: &RealtimeHandoffRequested) -> Option<String> {
    let input = realtime_text_from_handoff_request(handoff)?;
    Some(wrap_realtime_delegation_input(
        &input,
        realtime_transcript_delta_from_handoff(handoff).as_deref(),
        RealtimeDelegationSource::Handoff,
    ))
}

fn wrap_realtime_delegation_input(
    input: &str,
    transcript_delta: Option<&str>,
    source: RealtimeDelegationSource,
) -> String {
    RealtimeDelegation::new(input, transcript_delta, source).render()
}

主输入优先使用非空 input_transcript,否则回退到按 role: text 拼接的 active_transcript;两者都没有内容时,不提交委派。这里检查的是 is_empty,不是 trim 后为空,所以仅由空格组成的 input_transcript 仍会优先。

API 层在每次 handoff 时把近期转录从缓存取走。后续交接拿到的是新的尾部,而不是每次都把完整语音会话复制一遍;转录增量与终值的协调见 BEM事件与增量状态。

10.2 借用与标记 ​

RealtimeDelegation 借用输入字符串,在 render 时生成拥有所有权的输出字符串:

源码文件:codex-rs/core/src/context/realtime_delegation.rs

相关函数/类型:RealtimeDelegationSource、RealtimeDelegation(L4–L69,摘录)

rust
// 结构体借用输入;render 生成 String 后由普通 user 输入路径消费。
const MAX_REALTIME_DELEGATION_FIELD_BYTES: usize = 4 * 1024;
const TRUNCATION_MARKER: &str = "…";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeDelegationSource {
    Handoff,
    TranscriptTailFlush,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RealtimeDelegation<'a> {
    input: &'a str,
    transcript_delta: Option<&'a str>,
    source: RealtimeDelegationSource,
}

impl<'a> RealtimeDelegation<'a> {
    pub(crate) fn new(
        input: &'a str,
        transcript_delta: Option<&'a str>,
        source: RealtimeDelegationSource,
    ) -> Self {
        Self {
            input,
            transcript_delta,
            source,
        }
    }
}

impl ContextualUserFragment for RealtimeDelegation<'_> {
    fn content_kind(&self) -> ContentItemKind {
        ContentItemKind("realtime_conversation.delegation".to_string())
    }

    fn role(&self) -> &'static str {
        "user"
    }

    fn markers(&self) -> (&'static str, &'static str) {
        Self::type_markers()
    }

    fn type_markers() -> (&'static str, &'static str) {
        ("<realtime_delegation>", "</realtime_delegation>")
    }

    fn body(&self) -> String {
        let input = escape_xml_text_bounded(self.input, Retain::Start);
        let source = match self.source {
            RealtimeDelegationSource::Handoff => "",
            // 结束尾声通过 source 与正常 handoff 区分。
            RealtimeDelegationSource::TranscriptTailFlush => {
                "  <source>transcript_tail_flush</source>\n"
            }
        };
        if let Some(transcript_delta) = self.transcript_delta.filter(|text| !text.is_empty()) {
            let transcript_delta = escape_xml_text_bounded(transcript_delta, Retain::End);
            return format!(
                "\n{source}  <input>{input}</input>\n  <transcript_delta>{transcript_delta}</transcript_delta>\n"
            );
        }

        format!("\n{source}  <input>{input}</input>\n")
    }
}

普通 handoff 不额外写 source;结束尾声则带 <source>transcript_tail_flush</source>。input 与 transcript_delta 都位于文本节点中,外层标记描述输入来源,不是独立的权限或执行接口。

调用方使用 RealtimeDelegation::new(...).render() 得到 String,再放进 UserInput::Text 走 StartOrSteer。不能因为类型实现了 fragment trait,就假定这条路径必然调用 into_boxed_response_item、直接注入 developer 消息或触发工具执行。

10.3 转义后的裁剪 ​

每个字段限制 4 KiB,处理顺序是先转义,再按字节裁剪。input 保留开头,transcript_delta 保留结尾:

源码文件:codex-rs/core/src/context/realtime_delegation.rs

相关函数/类型:Retain、escape_xml_text_bounded、escape_xml_text(L70–L105,摘录)

rust
// 先转义再裁剪;主输入保留头,转录保留尾,输出额度包括省略号。
#[derive(Clone, Copy)]
enum Retain {
    Start,
    End,
}

fn escape_xml_text_bounded(input: &str, retain: Retain) -> String {
    let escaped = escape_xml_text(input);
    if escaped.len() <= MAX_REALTIME_DELEGATION_FIELD_BYTES {
        return escaped;
    }
    let retained_bytes = MAX_REALTIME_DELEGATION_FIELD_BYTES - TRUNCATION_MARKER.len();
    match retain {
        Retain::Start => {
            let mut end = retained_bytes;
            // 保证的是 UTF-8 边界,不是 XML 实体边界。
            while !escaped.is_char_boundary(end) {
                end -= 1;
            }
            format!("{}{TRUNCATION_MARKER}", &escaped[..end])
        }
        Retain::End => {
            let mut start = escaped.len() - retained_bytes;
            while !escaped.is_char_boundary(start) {
                start += 1;
            }
            format!("{TRUNCATION_MARKER}{}", &escaped[start..])
        }
    }
}

fn escape_xml_text(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

先把 & 换成 &amp;,随后转义尖括号,可以避免第二步生成的实体被再次转义。预算计算针对膨胀后的结果,所以 4 KiB 原始输入不保证能完整保留。省略号 … 占三个 UTF-8 字节,真正保留的内容额度为 4093 字节。

Retain::Start 适合保留主指令开头,Retain::End 让最新转录比早先寒暄优先留下。这是代码选择的保留策略,不能说裁剪后所有用户约束仍然完整。

字符边界判断只保证 UTF-8 合法,不保证 &amp; 这类 XML 实体不被切开。这里构造的是带标记的模型输入文本,并没有通过 XML 文档解析器验证。完整大字符串也在裁剪前已被转义分配,所以字段输出有界不等于中间分配只需 4 KiB。

上游测试用带大小比较和 && 的输入检查实体编码;再构造超过 8 KiB 的主输入与转录,断言主输入保留 start、舍弃 input-end,转录舍弃 transcript-start、保留 latest:

源码文件:codex-rs/core/src/realtime_conversation_tests.rs

相关函数/类型:bounds_realtime_delegation_fields_and_keeps_latest_transcript(L158–L174,摘录)

rust
// 用相反方向的首尾断言验证保留策略,而不只检查总长度下降。
#[test]
fn bounds_realtime_delegation_fields_and_keeps_latest_transcript() {
    let input = format!("start{}input-end", "x".repeat(8 * 1024));
    let transcript = format!("transcript-start{}latest", "y".repeat(8 * 1024));
    let rendered = wrap_realtime_delegation_input(
        &input,
        Some(&transcript),
        RealtimeDelegationSource::Handoff,
    );

    assert!(rendered.len() < 9 * 1024);
    assert!(rendered.contains("<input>start"));
    assert!(!rendered.contains("input-end"));
    assert!(!rendered.contains("transcript-start"));
    assert!(rendered.contains("latest</transcript_delta>"));
}

这些断言对应两种不同保留方向,而不是“只要总长变短就算通过”。测试不保证截断实体完整,也不验证模型会怎样理解留下的片段。

11. 结束后的尾声 ​

11.1 尚未委派的转录 ​

关闭实时会话时,API 缓存里可能还有最后几句尚未交给普通 Codex 的转录。flushTranscriptTailOnSessionEnd 默认为 false;为 true 才将它们作为额外输入提交。

输入任务退出后取走转录缓存,再调用尾声处理:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:spawn_realtime_input_task(L1783–L1798,摘录)

rust
// 输入循环退出后才取走尾部;TransportLost 先报告错误,再尝试尾声转换。
fn spawn_realtime_input_task(
    input: RealtimeInputTask,
    transcript_tail_flush: RealtimeTranscriptTailFlush,
) -> JoinHandle<()> {
    tokio::spawn(async move {
        let events_tx = input.events_tx.clone();
        let transcript_state = input.events.transcript_state();
        let exit = run_realtime_input_task(input, /*pending_outbound*/ None).await;
        if let RealtimeInputTaskExit::TransportLost { err, .. } = exit {
            report_realtime_transport_loss(&events_tx, err).await;
        }
        let transcript_tail = transcript_state.take_transcript_tail().await;
        flush_realtime_transcript_tail(&transcript_tail_flush, &transcript_tail).await;
    })
}

take_transcript_tail 是取走并清空,不是只读复制。V3 sideband 在自己的输入任务最终退出后也做相同处理,暂时断线并恢复连接不等于立刻提交结束尾声。

尾声转换的条件和输入内容由下面的函数决定:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:flush_realtime_transcript_tail(L1997–L2014,摘录)

rust
// 开关启用且转录可形成文本时才发送;send 错误不在本层重试。
async fn flush_realtime_transcript_tail(
    transcript_tail_flush: &RealtimeTranscriptTailFlush,
    transcript_tail: &[RealtimeTranscriptEntry],
) {
    if transcript_tail_flush.enabled
        && let Some(transcript_delta) = realtime_transcript_delta(transcript_tail)
    {
        let _ = transcript_tail_flush
            .tx
            .send(wrap_realtime_delegation_input(
                REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION,
                Some(&transcript_delta),
                RealtimeDelegationSource::TranscriptTailFlush,
            ))
            .await;
    }
}

它使用固定主输入,提醒普通执行者实时会话已结束,通常只需确认,除非剩余转录还要求继续工作;source 字段区分这条输入与正常 handoff。是否真的执行工作,仍由普通模型和运行时决定,字符串里的建议不是强制取消命令。

11.2 排空与提交顺序 ​

fanout 先排空已解析的事件,让已排队的 handoff 先路由,然后才读取容量为 1 的尾声通道:

源码文件:codex-rs/core/src/realtime_conversation.rs

相关函数/类型:handle_start_inner(L1574–L1575, L1588–L1609,摘录)

rust
// 节选 fanout 的排空、handoff 路由与 tail 接收;日志和事件封装辅助定义省略。
// Drain already-parsed events so a queued handoff is routed before the final tail.
while let Ok(event) = events_rx.recv().await {

// ...

let maybe_routed_text = match &event {
        RealtimeEvent::HandoffRequested(handoff) => {
            realtime_delegation_from_handoff(handoff)
        }
        _ => None,
    };
    if let Some(text) = maybe_routed_text {
        debug!(text = %text, "[realtime-text] realtime conversation text output");
        let sess_for_routed_text = Arc::clone(&sess_clone);
        sess_for_routed_text.route_realtime_text_input(text).await;
    }
    sess_clone
        .send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
            RealtimeConversationRealtimeEvent {
                payload: event.clone(),
            },
        )))
        .await;
}
if let Ok(text) = transcript_tail_rx.recv().await {
    sess_clone.route_realtime_text_input(text).await;
}

这样可避免直接先处理尾声、之后才处理已经形成的 handoff。当前实现只从 tail receiver 读取一次;输入任务在最后发送后释放通道,禁用或没有剩余转录时,接收端会通过通道关闭结束等待。

一次显式关闭的顺序如下。图中的普通工作可能被新建,也可能只是接受 steering。

  • shutdown 等待实时输入与 fanout 结束,不等待一个被尾声启动的普通 Turn 完成。
  • 图中的 tail 箭头表示入队;fanout 只有退出常规事件的接收循环后,才读取这个尾声通道。
  • 如果已有普通工作,尾声可能 steer 它;“语音结束”并不等于“所有普通执行任务取消”。
  • 当前 tail 发送错误被忽略,这里没有持久化重试队列或跨进程投递确认。

11.3 重复关闭的反证 ​

conversation_close_routes_only_remaining_transcript_tail_once 先发送一次正常 handoff,再追加两句尾部转录,然后执行两次关闭。其最终断言只允许两个普通模型请求:

源码文件:codex-rs/core/tests/suite/realtime_conversation.rs

相关函数/类型:conversation_close_routes_only_remaining_transcript_tail_once(L4535–L4544,摘录)

rust
// 前文已经发生第一次关闭;这里再次关闭并检查只产生正常委派和剩余尾声两个请求。
test.codex.submit(Op::RealtimeConversationClose).await?;
tokio::time::sleep(Duration::from_millis(200)).await;

let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(requests[0].message_input_texts("user").iter().any(|text| text
    == "<realtime_delegation>\n  <input>already handed off</input>\n  <transcript_delta>user: already handed off</transcript_delta>\n</realtime_delegation>"));
assert!(requests[1].message_input_texts("user").iter().any(|text| text
    == "<realtime_delegation>\n  <source>transcript_tail_flush</source>\n  <input>The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.</input>\n  <transcript_delta>assistant: remaining answer\nuser: remaining question</transcript_delta>\n</realtime_delegation>"));

第一个请求包含已委派的文本;第二个请求只追加尚未委派的 assistant/user 尾部。第二次关闭没有重新生成同一尾声。这证明该场景下缓存取走和状态清理有效,不等于任意异常重启、并发写入或网络故障都有 exactly-once 保证。

conversation_transport_close_tail_flush_is_opt_in 另用 false/true 两次设置验证自然断连:false 时没有普通请求,true 时出现带 transcript_tail_flush source 的委派。显式用户关闭与输入任务最终退出,都要结合这个开关理解。

12. 请求取证 ​

面对“上下文似乎没更新”,先确定是在检查哪一种请求。下表对应的原因可以沿本文源码逐项验证:

现象应检查的接收面关键原因
prompt 为 null 仍有文字实时 session instructions配置基础提示优先,或启动背景独立非空
实时背景包含其他项目Recent Work先取最多 40 条未归档 Thread,未按 cwd 过滤
输出条目不多但启动较慢本机目录取样先枚举并排序全部条目,再限制展示数量
instructions 超过 5300 估算 token完整实时 session 请求分节预算不包含所有外层开销和基础/覆盖提示
新 start 文本没出现普通 Responses 请求active 未变且旧片段还在,字符串不在 snapshot 内
关闭后暂时未看到 end普通 TurnContext 与 World State当前 Turn 的 active 是创建期快照,end 也不是即时 socket 命令
压缩后再次出现 start普通模型历史活跃模式需重新告知模型
语音关闭后仍有普通任务尾声与 StartOrSteer开启尾声提交,并未调用普通任务中断接口

可以从仓库根目录搜索实际选择与消费者:

bash
rg -n 'prepare_realtime_backend_prompt|build_realtime_session_config' \
  codex-rs/core/src/realtime_prompt.rs codex-rs/core/src/realtime_conversation.rs
rg -n 'build_current_thread_section|format_section|budget_tokens|collect_tree_lines' \
  codex-rs/core/src/realtime_context.rs
rg -n 'realtime_active|RealtimeState::new' \
  codex-rs/core/src/session/turn_context.rs codex-rs/core/src/session/world_state.rs
rg -n 'render_diff|render_history_diff|flush_realtime_transcript_tail' \
  codex-rs/core/src/context/world_state codex-rs/core/src/realtime_conversation.rs

前两组解释实时模型收到什么,后两组解释普通模型何时知道模式变化和剩余转录。路径不同,抓取一个请求不能代替检查另一个。

准备好源码仓库要求的 Rust 工具链后,在 codex-rs 目录运行下面几组定向测试:

bash
just test --locked -p codex-core --lib \
  -E 'test(realtime_context::tests::) | test(realtime_prompt::tests::) | test(context::world_state::realtime::tests::)'
just test --locked -p codex-core --test all \
  -E 'test(suite::realtime_initial_items::) | test(conversation_close_routes_only_remaining_transcript_tail_once) | test(conversation_transport_close_tail_flush_is_opt_in)'
just test --locked -p codex-core --test all \
  -E 'test(snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_start) | test(snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_realtime_end) | test(active_realtime_does_not_diff_changed_start_instructions_after_resume)'
just test --locked -p codex-app-server --test all \
  -E 'test(realtime_mode_uses_client_instructions_on_entry_and_exit) | test(realtime_start_can_skip_startup_context) | test(websocket_v3_passes_initial_items_through_session_start)'

单元测试覆盖选择、筛选、预算和比较表;集成测试通过本地 mock 捕获真实 session/Responses 请求。socket 测试包含禁网提前返回条件,运行时应区分真实执行与环境跳过。“remote compact”指调用远程压缩协议,测试服务本身是本地 mock,不能当作线上模型或语音设备实验。

其中 realtime_mode_uses_client_instructions_on_entry_and_exit 最适合练习跨接收面定位:先连续完成两个语音模式下的普通 Turn,再关闭实时会话并提交文字输入,检查三次 Responses 请求:

源码文件:codex-rs/app-server/tests/suite/v2/realtime_conversation.rs

相关函数/类型:realtime_mode_uses_client_instructions_on_entry_and_exit(L1779–L1814,摘录)

rust
// 实际 Responses 请求证明模式的接收者与生效时间,而不只依赖 Started/Closed 通知。
let requests = harness.main_loop_responses_requests().await?;
assert_eq!(requests.len(), 3);
assert!(response_request_contains_text(
    &requests[0],
    start_instructions
));
assert!(!response_request_contains_text(
    &requests[0],
    end_instructions
));
assert!(!response_request_contains_text(
    &requests[1],
    end_instructions
));
assert!(response_request_contains_text(
    &requests[2],
    end_instructions
));
assert!(response_request_contains_text(
    &requests[2],
    &format!("<realtime_conversation>\n{end_instructions}\n</realtime_conversation>"),
));

let start_message_count = requests[1]["input"]
    .as_array()
    .context("second voice Responses request should contain input")?
    .iter()
    .filter(|item| {
        item["role"] == "developer" && response_request_contains_text(item, start_instructions)
    })
    .count();
assert!(
    start_message_count <= 1,
    "realtime entry instructions should not be injected again on subsequent turns"
);

第一个请求含自定义 start,前两个请求都不含 end;第三个请求含包在 realtime_conversation 标签中的自定义 end。第二个请求里的 start developer 消息数量不超过一,约束了稳定状态不重复注入。这比仅检查 Started/Closed 通知,更直接回答普通模型到底看到了什么。

最后沿源码推演三个改动:将 prompt 的双层 Option 改成单层,会丢掉哪个输入区别;把 RealtimeSnapshot 加入 start 文本,会改变哪些 active-to-active 测试;把尾声的 StartOrSteer 提交移到 fanout 排空事件之前,会破坏哪一项顺序约定。需要继续追查普通 history 的比较基线时,阅读 Context变更语义;需要研究压缩请求和返回内容的替换边界时,接着阅读 远程Compact请求协议。