turn_context 与提示组装
职责
在每一轮 API 调用前,把「历史消息 + 本轮用户输入 + 网关侧附加上下文 + 压缩标记」装配成发给 provider 的 api_messages。TurnContext 是这轮产物的容器,build_turn_context 是装配入口。它还负责把网关收集的笔记(notes)注入到多模态内容里。
关键文件
class TurnContext:242-268— 本轮上下文数据类def build_turn_context:268-400— 装配入口compose_user_api_content:44-79— 用户消息内容组装substitute_api_content:79-101— 占位符替换consume_gateway_turn_context_notes:124-164— 消费网关侧附加 notes 并注入多模态内容reanchor_current_turn_user_idx:164-210— 重定位本轮用户消息索引(压缩后修正)压缩进度估计:190-242—_compression_made_progress/_should_run_preflight_estimateprompt_builder— 系统提示组装(本页引用)
数据流
- 主循环每轮调用
build_turn_context:268,得到一个TurnContext。 compose_user_api_content(agent/turn_context.py:44) 把用户原始输入拼成 API 内容。- 若网关侧累积了 notes(来自附件、上下文提示),
consume_gateway_turn_context_notes(agent/turn_context.py:124)取出并经append_notes_to_multimodal_content注入。 substitute_api_content(agent/turn_context.py:79)替换占位符;drop_stale_api_content清理过期内容。- 若上一轮发生过压缩,
reanchor_current_turn_user_idx(agent/turn_context.py:164)修正本轮用户消息在历史中的位置。 - 装配好的
api_messages进入API 调用重试子循环:1227。
TurnContext 是普通 dataclass,把 prologue 算出来的值捆成一份(关键字段见 agent/turn_context.py:242-268):user_message 是消毒后的本轮消息;original_user_message 保留原始文本用于记忆与日志查询(nudge 不会注入进去);conversation_history 可能被 preflight 压缩置 None——压缩会开新会话,旧 history 不能再用;active_system_prompt 是本轮缓存的系统提示,也可能被压缩重建。
网关 notes 的消费逻辑见 agent/turn_context.py:124-145:
def consume_gateway_turn_context_notes(agent: Any) -> str:
"""Pop the gateway's per-turn must-deliver notes off the agent (one-shot).
... so the composed system prompt stays byte-stable turn-over-turn.
... this consumes them so a cached agent can never replay a stale note."""
notes = getattr(agent, "_gateway_turn_context_notes", "") or ""
if hasattr(agent, "_gateway_turn_context_notes"):
try:
agent._gateway_turn_context_notes = ""
except Exception:
pass
return notes if isinstance(notes, str) else ""取完立即清零——这是个一次性 pop,不是 peek。即便 agent 被缓存复用,上一轮没消费完的 notes 也不会泄漏到下一轮。
设计动机
为什么把 prologue 抽成 build_turn_context?原来所有「每轮一次」的准备(stdio 守卫、retry 计数器重置、user message 消毒、todo/nudge 注入、系统提示构建、preflight 压缩、pre_llm_call 钩子、外部记忆预取、崩溃恢复持久化)都内联在 run_conversation 开头,接近 130 行。抽成函数后,主循环只剩「循环 + 重试」,读主循环时不必被准备逻辑打断;prologue 还可以单独测试,subagent 路径也能复用。
为什么 notes 走用户消息旁路而不是塞进 system prompt?系统提示在多轮之间需要 byte 稳定——一旦改变,前面缓存的 prefix 就失效,provider 侧的 prompt caching 直接报废。网关把易变内容(首次自我介绍、语音通道切换、自动 reset 的临时提示)从 system prompt 里挪出来,通过用户消息的 api_content sidecar 投递,既让每轮都能带上新事实,又保住了缓存命中率。
_compression_made_progress 用 5% 作为 material 阈值,是为了防多轮压缩空转:某轮把 220 条消息压成 220 条但 token 从 288k 降到 183k,按行数判断会误报「压不动了」然后触发 auto-reset。
边界与失败
- 多模态消息丢失 notes:
compose_user_api_content对非字符串内容返回None,用户这轮发图片时 sidecar 形式的 notes 会静默丢失。append_notes_to_multimodal_content兜底把 notes 作为 text part 直接 append,代价是这部分内容会被持久化进 transcript——网关侧必须只放真正需要落盘的内容。 - 压缩后用户消息索引漂移:压缩把旧消息合成 summary,本轮用户消息在
messages里的位置会变。reanchor_current_turn_user_idx重新扫一遍 list 找到下标;不重定位会让工具调用结果回填找错位置。 - 少量但超大消息:
_should_run_preflight_estimate旧版只看消息条数,几条巨型 base64 图片永远不会触发压缩,直接撞 hard overflow(#27405)。新版加了 char-based 估算分支,大消息也能命中阈值。 - stale notes 残留:
consume_gateway_turn_context_notes用 try/except 兜住写入失败——即便 agent 属性是只读 property,也不会因清零失败而中断装配。
小结
turn_context 是「每轮的快照工厂」:把动态上下文固化为一个可发送的结构。它和上下文压缩协同——压缩改写历史后,turn_context 负责把指针重新对齐。