Skip to content

InternalContext结构

从 InternalContextSource 的约束开始,追踪隐藏模型上下文如何渲染为 user fragment、进入历史并在可见 Turn 映射中被排除。

基于rust-v0.150.0
CodexRustContext

InternalContext结构 ​

在 模型上下文体系总览 中,prompt history 被描述为由多个上下文片段组成的模型输入。本文只追踪其中一种特殊片段:InternalModelContextFragment。它的名字容易让人误以为这是一个独立的模型请求对象,当前源码实际把它实现成一个带固定标记的 user 消息;“隐藏”不是模型协议的角色,而是后续可见事件映射对标记的识别结果。

本文默认读者了解 Rust 的 trait、Result、Box<dyn Trait> 和 ResponseItem。读完后,读者应能从 InternalContextSource::new 追踪到 ContextualUserFragment::into,解释 source 校验和 legacy 兼容,并能判断一条 internal context 为什么进入模型历史却不出现在可见 TurnItem 中。本文不展开所有上下文片段的字段语义、history 的写时复制或 compact 算法;这些边界分别由 模型上下文体系总览 和后续专题负责。

1. 片段边界 ​

InternalModelContextFragment 有三个不同层次,不能把它们合并成“一个隐藏字符串”:

层次当前 owner作用生效位置
sourceInternalContextSource约束并标识扩展来源构造 fragment 时
fragmentInternalModelContextFragment保存 source 与 bodyrender() 时
messageResponseItem::Message以 role: "user" 进入上下文历史history/prompt 消费时

类图强调一个约束方向:fragment 持有已经验证过的 source,而不是持有裸 String。错误对象只在构造失败时出现,不会成为 fragment 的运行时字段。

图中 DROP 不是删除历史项。parse_turn_item 返回 None,表示这个消息不投影为用户可见的 TurnItem;历史和模型 prompt 是否保留它,仍由上下文记录与 prompt 规范化流程决定。

2. Source约束 ​

2.1 值对象 ​

源码位置:codex-rs/core/src/context/internal_model_context.rs :: InternalContextSource、InvalidInternalContextSource

rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InternalContextSource(String);

impl InternalContextSource {
    pub fn new(source: impl Into<String>) -> Result<Self, InvalidInternalContextSource> {
        let source = source.into();
        if is_valid_source(&source) {
            Ok(Self(source))
        } else {
            Err(InvalidInternalContextSource { source })
        }
    }

    pub fn from_static(source: &'static str) -> Self {
        Self::new(source)
            .unwrap_or_else(|_| panic!("invalid static internal context source: {source}"))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidInternalContextSource {
    source: String,
}

impl fmt::Display for InvalidInternalContextSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let source = &self.source;
        write!(
            f,
            "invalid internal model context source {source:?}; expected [a-z][a-z0-9_]*"
        )
    }
}

InternalContextSource 是一个小值对象,但它承担了序列化边界:source 会被直接放进 source="..." 属性,因此构造函数先把任意 Into<String> 收窄为受约束值,再允许 fragment 使用。错误类型保留原始字符串,调用方可以把拒绝原因传递给配置或扩展层,而不是在渲染阶段才发现格式损坏。

2.2 字符集规则 ​

源码位置:codex-rs/core/src/context/internal_model_context.rs :: is_valid_source

rust
fn is_valid_source(source: &str) -> bool {
    let mut chars = source.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    first.is_ascii_lowercase()
        && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}

这不是通用 XML 属性转义器。规则是“首字符为小写 ASCII 字母,后续只允许小写字母、数字和下划线”,所以空字符串、大写首字母、连字符、空格和非 ASCII 字符都会在构造期失败。body 没有走同一个规则,因为 body 是内容而不是 source 属性;它由上游模板或扩展负责生成,本文不把 source 校验误写成 body 安全过滤。

3. Fragment协议 ​

3.1 通用trait ​

源码位置:codex-rs/context-fragments/src/fragment.rs :: ContextualUserFragment

rust
pub trait ContextualUserFragment {
    fn role(&self) -> &'static str;

    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 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}")
    }

    fn into(self) -> ResponseItem
    where
        Self: Sized,
    {
        ResponseItem::Message {
            id: None,
            role: self.role().to_string(),
            content: vec![ContentItem::InputText {
                text: self.render(),
            }],
            phase: None,
            internal_chat_message_metadata_passthrough: None,
        }
    }
}

trait 的关键点是两个转换方向:render() 只得到文本,into() 才把文本包进协议层 ResponseItem::Message。因此 InternalModelContextFragment::new 不会自动进入 history;goal 扩展必须显式调用 ContextualUserFragment::into。默认的 requires_separate_message() 在本文对象上没有被重写,不要据此推断所有 fragment 都一定独立成消息,真正的组合行为由各调用方决定。

3.2 Internal实现 ​

源码位置:codex-rs/core/src/context/internal_model_context.rs :: InternalModelContextFragment

rust
const CONTEXT_START_MARKER: &str = "<codex_internal_context";
const CONTEXT_END_MARKER: &str = "</codex_internal_context>";
const LEGACY_GOAL_CONTEXT_START_MARKER: &str = "<goal_context>";
const LEGACY_GOAL_CONTEXT_END_MARKER: &str = "</goal_context>";
const SOURCE_ATTR_START: &str = " source=\"";
const SOURCE_ATTR_END: &str = "\">";

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InternalModelContextFragment {
    source: InternalContextSource,
    body: String,
}

impl InternalModelContextFragment {
    pub fn new(source: InternalContextSource, body: impl Into<String>) -> Self {
        Self {
            source,
            body: body.into(),
        }
    }
}

impl ContextualUserFragment for InternalModelContextFragment {
    fn role(&self) -> &'static str {
        "user"
    }

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

    fn type_markers() -> (&'static str, &'static str) {
        (CONTEXT_START_MARKER, CONTEXT_END_MARKER)
    }

    fn body(&self) -> String {
        let source = self.source.as_str();
        let body = &self.body;
        format!(" source=\"{source}\">\n{body}\n")
    }
}

body() 故意返回属性和换行,而不是只返回业务 body:通用 trait 的 render() 会把开始标记、body、结束标记直接拼接。最终字符串形状是:

text
<codex_internal_context source="goal">
...
</codex_internal_context>

这解释了为什么 CONTEXT_START_MARKER 不包含 >:source 属性必须由具体 fragment 的 body 提供,否则 trait 的通用拼接无法把不同实现的属性布局统一起来。

4. 识别算法 ​

4.1 格式兼容 ​

源码位置:codex-rs/core/src/context/internal_model_context.rs :: matches_text

rust
fn matches_text(text: &str) -> bool {
    let trimmed = text.trim();
    if matches_legacy_goal_context(trimmed) {
        return true;
    }

    let Some(rest) = trimmed.strip_prefix(CONTEXT_START_MARKER) else {
        return false;
    };
    let Some(rest) = rest.strip_prefix(SOURCE_ATTR_START) else {
        return false;
    };
    let Some((source, body_and_close)) = rest.split_once(SOURCE_ATTR_END) else {
        return false;
    };

    is_valid_source(source) && body_and_close.ends_with(CONTEXT_END_MARKER)
}

fn matches_legacy_goal_context(text: &str) -> bool {
    text.starts_with(LEGACY_GOAL_CONTEXT_START_MARKER)
        && text.ends_with(LEGACY_GOAL_CONTEXT_END_MARKER)
}

这里的“严格”是结构严格,不是内容严格:算法要求开始标记、合法 source 属性、属性结束符和结束标记,但不解析 body 的 XML 结构,也不验证 body 是否为空。旧的 <goal_context>...</goal_context> 只在识别器中保留兼容路径;新建 fragment 始终使用 codex_internal_context,所以兼容旧历史不等于继续生成旧格式。

4.2 识别入口 ​

源码位置:codex-rs/core/src/context/contextual_user_message.rs :: CONTEXTUAL_USER_FRAGMENT_MATCHERS、is_contextual_user_fragment

rust
const CONTEXTUAL_USER_FRAGMENT_MATCHERS: &[fn(&str) -> bool] = &[
    UserInstructions::matches_text,
    EnvironmentsState::matches_text,
    AdditionalContextUserFragment::matches_text,
    SkillInstructions::matches_text,
    UserShellCommand::matches_text,
    TurnAborted::matches_text,
    SubagentNotification::matches_text,
    InternalModelContextFragment::matches_text,
    RecommendedPluginsInstructions::matches_text,
    LegacyUnifiedExecProcessLimitWarning::matches_text,
    LegacyApplyPatchExecCommandWarning::matches_text,
    LegacyModelMismatchWarning::matches_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()
        || CONTEXTUAL_USER_FRAGMENT_MATCHERS
            .iter()
            .any(|matches_text| matches_text(text))
}

识别器只接受 ContentItem::InputText。如果同一个消息包含图片或音频,internal marker 不会因为“消息整体看起来像上下文”而自动通过;它必须在输入文本项上匹配。这个设计让 fragment 识别保持纯函数,也让调用方能在不构造完整 ResponseItem 的情况下测试边界。

状态图中的 LegacyAccepted 与 Accepted 都只表示“被识别为上下文片段”,并不表示它们拥有相同的写入格式:新 fragment 的写入路径仍由 render() 产生 codex_internal_context。

5. 可见性边界 ​

5.1 Turn映射 ​

源码位置:codex-rs/core/src/event_mapping.rs :: parse_user_message、parse_turn_item

rust
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
    if is_contextual_user_message_content(message) {
        return None;
    }

    let mut content: Vec<UserInput> = Vec::new();
    for content_item in message {
        match content_item {
            ContentItem::InputText { text } => content.push(UserInput::Text {
                text: text.clone(),
                text_elements: Vec::new(),
            }),
            ContentItem::InputImage { image_url, detail } => content.push(UserInput::Image {
                image_url: image_url.clone(),
                detail: *detail,
            }),
            ContentItem::InputAudio { audio_url } => content.push(UserInput::Audio {
                audio_url: audio_url.clone(),
            }),
            ContentItem::OutputText { text } => {
                warn!("Output text in user message: {}", text);
            }
        }
    }

    Some(UserMessageItem::new(&content))
}

pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
    match item {
        ResponseItem::Message { role, content, id, phase, .. } => match role.as_str() {
            "user" => parse_visible_hook_prompt_message(id.as_deref(), content)
                .map(TurnItem::HookPrompt)
                .or_else(|| parse_user_message(content).map(TurnItem::UserMessage)),
            "assistant" => Some(TurnItem::AgentMessage(parse_agent_message(
                id.as_deref(), content, phase.clone(),
            ))),
            "system" => None,
            _ => None,
        },
        _ => None,
    }
}

可见性由 parse_user_message 的第一行决定:只要消息的输入文本中有一个被识别的 contextual fragment,整条 user message 就不创建 UserMessageItem。这不是“只删除 marker 文本”;它是消息级拒绝,因此一条同时包含普通文本和 internal context 的 user message 也不会成为可见用户消息。Hook prompt 有独立的解析优先级,但 internal context 不会被当成 hook fragment。

5.2 两种消费者 ​

同一个 ResponseItem 可以有两个不同消费者:模型输入需要它,UI/Turn reducer 不需要它。不能根据“UI 没显示”推断 fragment 没进 history,也不能根据 role: user 推断它一定是用户键入。模型上下文体系总览 讨论了 history 与 prompt 的存储边界,本文补充的是它们与可见事件投影之间的判定点。

这条失败路径只针对静态 source:from_static("goal") 的失败意味着源码中的固定 source 违反约束,属于构建/发布时应被发现的编程错误;它与用户目标文本中包含 XML 字符不同,后者由 escape_xml_text 处理。

6. Goal注入 ​

源码位置:codex-rs/ext/goal/src/steering.rs :: goal_context_input_item、continuation_prompt

rust
pub(crate) fn continuation_steering_item(goal: &ThreadGoal) -> ResponseItem {
    goal_context_input_item(continuation_prompt(goal))
}

fn goal_context_input_item(prompt: String) -> ResponseItem {
    ContextualUserFragment::into(InternalModelContextFragment::new(
        InternalContextSource::from_static("goal"),
        prompt,
    ))
}

fn continuation_prompt(goal: &ThreadGoal) -> String {
    let objective = escape_xml_text(&goal.objective);
    let tokens_used = goal.tokens_used.to_string();
    let token_budget = goal
        .token_budget
        .map(|budget| budget.to_string())
        .unwrap_or_else(|| "none".to_string());
    let remaining_tokens = goal
        .token_budget
        .map(|budget| (budget - goal.tokens_used).max(0).to_string())
        .unwrap_or_else(|| "unbounded".to_string());

    CONTINUATION_PROMPT_TEMPLATE
        .render([
            ("objective", objective.as_str()),
            ("tokens_used", tokens_used.as_str()),
            ("token_budget", token_budget.as_str()),
            ("remaining_tokens", remaining_tokens.as_str()),
        ])
        .unwrap_or_else(|err| {
            panic!("embedded goals/continuation.md template failed to render: {err}")
        })
}

Goal 扩展没有手工拼接 XML,而是把目标字段先渲染进嵌入模板,再交给 fragment trait 生成协议消息。escape_xml_text 只处理 objective 的 &、<、>,它保护的是模板中的正文;source 的合法性由 from_static("goal") 在构造期保证。模板解析失败使用 panic,因为模板通过 include_str! 嵌入并被视为构建时资产;这条失败路径与运行时 goal 输入为空不是同一个问题。

7. 兼容与失败 ​

输入构造/识别结果对模型输入对可见事件
source="goal"构造成功、严格匹配可作为 user fragment 进入 prompt不生成 UserMessage
source="Goal"InternalContextSource::new 失败;手工文本也不会匹配不应被当作合法 internal fragment普通用户文本路径可能继续判断
source 为空构造失败不适用不适用
<goal_context>...</goal_context>识别器兼容可被上下文过滤不生成 UserMessage
<project_context>...</project_context>不是已注册 matcher按普通文本处理可能生成 UserMessage
fragment 与图片混合文本项仍可匹配,但消息级上下文判定成立由 history/prompt 处理整条消息不生成可见用户消息

这里最容易误判的是 legacy 分支:它只说明读取旧历史时仍能识别旧标记,不说明新代码会继续写入 <goal_context>。同样,parse_turn_item 返回 None 是投影层结果,不是对 source 或 body 的错误报告。

8. 源码验证 ​

以下测试分别覆盖 InternalContext 的渲染、历史写入和可见性过滤:

bash
just test -p codex-core detects_internal_model_context_fragment
just test -p codex-core rejects_invalid_internal_model_context_source
just test -p codex-core contextual_user_fragment_is_dyn_compatible
just test -p codex-core detects_legacy_goal_context_fragment
just test -p codex-core internal_model_context_does_not_parse_as_visible_turn_item
测试输入与动作断言覆盖范围未覆盖
detects_internal_model_context_fragment用 extension source 构造并 render文本形状正确且被 contextual matcher 接受新格式渲染与识别闭环history 持久化时序
rejects_invalid_internal_model_context_source手工输入大写 Extension markermatcher 返回 falsesource 字符集边界所有 XML 畸形 body
contextual_user_fragment_is_dyn_compatible将 fragment 装入 Box<dyn ContextualUserFragment>动态 trait 仍能 render 同样文本trait 对象消费契约多线程共享安全
detects_legacy_goal_context_fragment输入旧 <goal_context> 包裹文本兼容 matcher 返回 true旧历史读取兼容新写入路径是否使用旧格式
internal_model_context_does_not_parse_as_visible_turn_item把新 fragment 放进 ResponseItem::Messageparse_turn_item 返回 None模型消息与可见 TurnItem 的分离UI 所有 reducer 的后续行为

读者可以在 checkout 中进一步执行:

bash
rg -n "InternalModelContextFragment|InternalContextSource" codex-rs/core/src codex-rs/ext
rg -n "is_contextual_user_fragment|parse_turn_item" codex-rs/core/src
rg -n "continuation_steering_item|budget_limit_steering_item" codex-rs/ext/goal

这些搜索应分别落到构造者、识别器、可见性消费者和 goal 注入点;如果只搜到 marker 常量而没有找到 ContextualUserFragment::into,说明还没有走完“内容 → 协议项”的主线。

9. 边界练习 ​

  1. 为什么 InternalContextSource 需要在构造期限制字符集,而不是在 body() 中转义?
  2. 一条 role: "user" 的消息为什么可能被模型使用,却不会生成 TurnItem::UserMessage?请指出 parse_user_message 的判定行。
  3. legacy <goal_context> 为什么仍能被识别,但新 goal steering 不应再生成它?
  4. 如果要增加新的内部来源,应该先修改哪一层:source 校验规则、fragment matcher、还是 parse_turn_item?请用源码搜索结果说明理由。

回答这些问题时,至少应能复述 InternalContextSource → InternalModelContextFragment → ResponseItem 和 ResponseItem → contextual matcher → parse_turn_item 两条方向相反的链路。