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 | 作用 | 生效位置 |
|---|---|---|---|
| source | InternalContextSource | 约束并标识扩展来源 | 构造 fragment 时 |
| fragment | InternalModelContextFragment | 保存 source 与 body | render() 时 |
| message | ResponseItem::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
#[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
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
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
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、结束标记直接拼接。最终字符串形状是:
<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
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
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
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
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 的渲染、历史写入和可见性过滤:
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 marker | matcher 返回 false | source 字符集边界 | 所有 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::Message | parse_turn_item 返回 None | 模型消息与可见 TurnItem 的分离 | UI 所有 reducer 的后续行为 |
读者可以在 checkout 中进一步执行:
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. 边界练习
- 为什么
InternalContextSource需要在构造期限制字符集,而不是在body()中转义? - 一条
role: "user"的消息为什么可能被模型使用,却不会生成TurnItem::UserMessage?请指出parse_user_message的判定行。 - legacy
<goal_context>为什么仍能被识别,但新 goal steering 不应再生成它? - 如果要增加新的内部来源,应该先修改哪一层:source 校验规则、fragment matcher、还是
parse_turn_item?请用源码搜索结果说明理由。
回答这些问题时,至少应能复述 InternalContextSource → InternalModelContextFragment → ResponseItem 和 ResponseItem → contextual matcher → parse_turn_item 两条方向相反的链路。
