diff --git a/research-wiki/design/0010-context-assembly.md b/research-wiki/design/0010-context-assembly.md new file mode 100644 index 0000000..7859a62 --- /dev/null +++ b/research-wiki/design/0010-context-assembly.md @@ -0,0 +1,134 @@ +# Design 0010 · 上下文装配:段序、注入槽、观察回填 + +**日期** 2026-08-10 · **状态** 已接受(2026-08-10 项目负责人确认) + +**取代** `0006-public-names-and-signatures.md` 决策三里 `RunRequest.tool_section_template` 那一 +行——那个字段删掉,库不渲染工具段。理由见决策一。 + +**补充** `0003-public-api-shape.md` 决策五。那一条写着「归库的只有段顺序与注入槽的位置,那是 +纯函数不是扩展点」,但**那个顺序到底是什么,从没有一处写过**,`polyloop/_assembly/` 因此写不 +出来。本文把它写出来。 + +**触及** `../../src/polyloop/_assembly/`。 + +## 装配是什么 + +每一步调模型之前,库要把一堆片段拼成一个消息序列交给模型调用接缝。片段有四类:这次运行从头 +到尾不变的那段(角色说明、示例演示)、这次要贴进去的 Skill 条目、这一个目标特有的那段(题面)、 +以及前面每一步的「模型说了什么 + 环境返回了什么」。 + +**库不决定这些片段里写什么**,那是渲染格式,按 `../explanation/scope.md` 的排除条款留在项目侧 +——它是某个下游要扫的实验因子,库把它写死,那段文本就成了库定的实验刺激,而且逃出了项目自己 +的参数快照。**库决定的只有拼接顺序、槽位在哪、以及规模怎么量。** + +## 决策一:库不渲染工具段,`tool_section_template` 删掉 + +`0006` 决策三给 `RunRequest` 加了一个 `tool_section_template`,意思是「调用方给一个带占位符的 +模板,库把工具清单填进去」。这个字段没有消费者,而且照它做会弄坏一个下游。 + +**两个真实消费者都是项目侧自己拼的。** dissect 的工具清单是它提示词模板里的一个变量,位置在 +run 级前缀第 7 个消息块,而且被**伪装成示例演示里某一次命令执行的输出**(`Output:` 加一个代码 +围栏)——那是 ReAct 那套提示工程的常见做法,让模型觉得自己刚查过一遍工具文档。对它来说工具 +清单就是 run 级上下文的一部分,不是一个能单独拎出来的段。GovDoc 的循环只发两条消息、都是上层 +传进来的字符串,它包里那个 `render_tool_docs()` 全仓零生产调用方。 + +**留着这个字段的失败场景很具体。** 字段的存在本身就是一条指示:迁移的人看到「工具清单贴进 +提示词的格式模板」会去填它,填完之后那份提示词里有**两份**工具清单——一份在项目自己的模板里, +一份是库新插进去的。而那个下游的迁移验收标准是「轨迹与迁移前逐字段可比」,提示词一变数据就 +不可比了,**而且不会报错**,只表现成「新跑的这批分数有点不一样」,正好和实验想测的效应长得 +一样。 + +**参考框架 pi 的内核也不渲染。** 它把工具原样交给 provider 层走模型 API 的原生 tools 参数, +`packages/agent` 里没有任何把工具变成文本的代码。它的应用层 `packages/coding-agent` 确实有一段 +`Available tools:`,但那一段的每一行来自 `promptSnippet`——**一个和 `description` 分开的字段**, +没提供 snippet 的工具照样能调用、只是不出现在清单上,还有一条测试专门断言 `description` 不许 +漏进提示词。这说明一件事:**就算要渲染工具段,那段文字也不该是从校验用的 schema 生成的。** +`tool_section_template` 的设计恰恰是「库拿 `schema_for_model()` 填进你的模板」。 + +**「四者同源」不受影响。** 注册、模型可见 schema 生成、存在性与参数校验、分发仍然由同一个 +注册表实例驱动:`schema_for_model()` 这个方法还在,项目想自己渲染工具段就调它,拿到的和校验、 +分发用的是同一份。 + +**代价:哪天真有项目希望库来渲染,得把字段加回来。** 那时加一个带默认值的字段是兼容变更, +方向是便宜的那一边。 + +## 决策二:段序 + +``` +run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / 观察」交替 +``` + +**照变化频率从低到高排。** 模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默 +失效,而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。run 级 +片段一次运行内不变,注入与目标级片段一次运行内也不变但随运行变,历史每一步都在长。 + +**注入槽排在两段之间,不是最前也不是最后。** 这和唯一一个跑通了的下游逐字一致:它的装配是 +`run_prefix → 工件 → item_suffix`。排到最前会把最稳定的那段挤到后面去,白丢缓存;排到最后会 +让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置在题面之后读起来是反的。 + +**库不合成任何 system 消息。** 那个跑通了的下游全程没有 system 消息(它的模板只用 USER 与 +ASSISTANT 两种角色,ReAct 那套惯例如此),另一个有。库自己加一条的话,前者的提示词凭空多出 +一段。要 system 消息的项目把它放进自己的 run 级片段里。 + +## 决策三:注入槽——每条一条消息,正文原样,通道按名字排序 + +每一条 `Injection` 渲染成一条 `USER` 消息,内容就是它的 `content`,**前后不加任何标题、分隔符 +或换行**。 + +**为什么不把多条拼成一条。** 拼就要选一个分隔符,而分隔符是渲染格式,归项目侧。一条一条发则 +库一个字符都没有添。那个下游把注入的渲染形态明确标成一个实验因子、并且**至今没有实现**——它 +的 `_artifact_messages` 在收到非空输入时直接抛错,docstring 写着「空输入返回空列表,此时渲染 +出来的前缀与『根本没有这个槽位』逐字节相同」。库现在把这件事做完,做法必须是「不添任何东西」。 + +**空注入等同**:`injections` 为空时槽位产出零条消息,装配结果与「根本没有这个槽位」逐字节相同。 +这是 `0003` 点名的一条验收标准——某个下游第一阶段不注入任何东西,库要是悄悄多写一个空标题或 +一个换行,那一阶段的基线就和后续阶段不可比了。 + +**通道之间按通道名的字典序排,通道内按给定顺序。** `injections` 是一个映射,而映射的迭代顺序 +取决于调用方怎么构造它——用推导式从一个集合建出来的话,顺序每进程都可能不同(Python 的字符串 +哈希每进程随机),于是同一份配置在不同进程里渲染出不同的提示词。按名字排是确定的。 + +**角色一律 `USER`。** 那个没有 system 消息的下游决定了这是唯一对两家都安全的选择。给 `Injection` +加一个角色字段是在给一个还没有消费者的需求预留结构;真需要时加一个带默认值的字段是兼容变更。 + +## 决策四:历史轮次——模型原文一条,观察一条,观察套模板 + +每一个已完成的步渲染成两条消息: + +- `ASSISTANT`,内容是那一步的 `raw_output`。**这是解释器交回来的那段文本,不一定等于模型原文** + ——解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行 + 结果,留着的话下一轮它会把那段幻想当成真发生过的事)。库照 `raw_output` 回填,不自己截断。 +- `USER`,内容是 `observation_template.format(observation=step.observation)`。 + +**观察为什么要套模板。** 环境返回的是裸文本,模型需要一个边界才知道哪里是它自己说的、哪里是 +环境说的。那个边界长什么样归项目——一个下游用的是 ``Output:\n```\n{observation}\n```\n\n``。 + +**模板必须含 `{observation}` 占位符,不含就在装配时直接报错。** 不校验的话,一个写错的模板会让 +每一步的观察整个消失,而模型收到的是一段固定文本、看起来完全正常——它会以为每次执行都返回了 +同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。 + +**用 `str.format` 而不是别的替换方式**,因为那个下游今天用的就是它,迁移时模板字符串一个字都 +不用改。代价是模板里的其他花括号要转义,这一条写进那个字段的 docstring。 + +**没有动作的步照样有观察**:解析失败那一步回填的是解释器给的说明文本,模型调用失败那一步回填 +的是合成观察。所以这里不需要「这一步有没有观察」的分支。 + +## 决策五:规模度量逐块问,认不得的块类型直接失败 + +提示词规模 = 所有消息、所有内容块的规模度量之和,单位是**字符**不是 token。 + +**为什么是字符。** `0003` 决策六定了消息内容是内容块序列而不是裸字符串,理由之一是库不需要 +内容是字符串、只需要每个块能报出一个规模度量。文本块的度量是准确的字符数。用 token 要引一个 +分词器,那是第三方依赖,而且不同模型的分词器不同——一个跨模型可比的上限用 token 量反而不可比。 + +**认不得的块类型直接抛错,不当成 0。** 现在只有文本块,将来加图片块时如果漏了对应的度量, +当成 0 的后果是一个真的超限的提示词悄悄通过这一档,然后在模型那边报超限——那是一个来自网关 +的错误,看不出是本库的规模判定漏了一类块。 + +## 留给后续的 + +**模型原生的工具调用没有路。** `ModelCall` 上只有消息序列,没有 tools 字段,所以工具要让模型 +看见,唯一的路是变成项目自己拼进上下文的文字。两个已知消费者都不用原生工具调用(一个的动作 +协议是写在文本里的 JSON,一个是代码围栏),所以现在不缺。真出现要用原生工具调用的消费者时, +`ModelCall` 加一个 tools 字段是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数。这一条 +登记在 `../migrations/` 里。 diff --git a/research-wiki/migrations/govdoc-saas.md b/research-wiki/migrations/govdoc-saas.md index 9d8e3d0..bd61182 100644 --- a/research-wiki/migrations/govdoc-saas.md +++ b/research-wiki/migrations/govdoc-saas.md @@ -132,6 +132,13 @@ Agent 层补一层。 这些是 PolyLoop 现在答不上来的问题。答不上来不等于设计错了,但每一条都得有明确结论—— 哪怕结论是「不支持,理由是什么」。 +**模型原生的工具调用没有路。** PolyLoop 的模型调用入参上只有消息序列,没有 tools 字段, +所以工具要让模型看见,唯一的路是变成项目自己拼进上下文的文字 +(`../design/0010-context-assembly.md` 决策一与文末)。生产实现今天的动作协议是写在文本里的 +JSON,不用原生工具调用,所以现在不缺。哪天要换成原生工具调用,PolyLoop 的模型调用入参要加一 +个 tools 字段——那是兼容变更,但还要看下面那一层的共用库暴不暴露这个参数,以及原生工具调用的 +返回怎么进决策解释接缝那三个分支。**这条要在换协议之前定,不能边换边定。** + **审计出口与事件流的关系。** 生产实现的审计出口是一个「发一条带类型和载荷的事件」的接口, 而 PolyLoop 的方向是「观察走事件流、干预走具名回调」。事件流能不能覆盖审计的需求,取决于 事件里带不带原始响应——审计纪律要求原始输出和修复后的输出都留痕。事件集与回调清单要独立 diff --git a/src/polyloop/_assembly/__init__.py b/src/polyloop/_assembly/__init__.py index 9c88399..731d0b8 100644 --- a/src/polyloop/_assembly/__init__.py +++ b/src/polyloop/_assembly/__init__.py @@ -1,9 +1,177 @@ -"""段序、注入槽、规模度量。内部模块。 +"""上下文装配:段序、注入槽的位置、观察回填的形态、规模度量。 -渲染格式不在这里:那是调用方自己要改的东西。库一旦在内部按某个条件拼出自己的文本, -那段文本就成了库替调用方定的内容,而且不会出现在调用方的参数快照里。归这里的只有段的 -顺序与注入槽的位置,那是纯函数不是扩展点。 +**内部模块**(下划线开头,不进 `polyloop/__init__.py`)。纯逻辑,无 I/O、无事件循环——由 +`pyproject.toml` 的一条 import 契约断言(禁止 import `asyncio` 与 `pathlib`)。 -**纯逻辑,不碰 I/O 与事件循环。** 机器只拦得住 `asyncio` 与 `pathlib` 这两个最常见的 -入口,真正守住纯度的是这个模块的测试形态:不许用 fixture 起外部资源、不许有 `async def`。 +**库不决定片段里写什么。** 那是渲染格式,按 `research-wiki/explanation/scope.md` 的排除条款 +留在项目侧——有下游要系统地改动这些文本并观测它带来的差异,库把它写死,那段文本就成了库定 +的变量,而且逃出了项目自己的参数快照。库决定的只有拼接顺序、槽位在哪、规模怎么量,三样都在 +`research-wiki/design/0010-context-assembly.md`。 + +段序,照变化频率从低到高排(供应商按前缀缓存计费,逐次变化的东西排前面会让缓存静默失效): + + run 级片段 → 注入槽 → 目标级片段 → 逐步的「模型输出 / 观察」交替 + +**库不合成任何 system 消息。** 有一个下游全程没有 system 消息(它的模板只用 USER 与 ASSISTANT +两种角色),库自己加一条的话它的提示词凭空多出一段。要 system 消息的项目把它放进自己的 run 级 +片段里。 """ + +from collections.abc import Mapping, Sequence + +from polyloop.types import ( + ContentBlock, + Context, + Injection, + Message, + Role, + StepRecord, + TextBlock, +) + +#: 观察模板里那个占位符的名字。模板必须含它。 +OBSERVATION_PLACEHOLDER = "observation" + + +class AssemblyError(ValueError): + """装配不出消息序列:模板不合法,或者撞上认不得的内容块类型。""" + + +def _block_chars(block: ContentBlock) -> int: + """一个内容块的规模度量。 + + **认不得的块类型直接抛错,不当成 0。** 现在只有文本块,将来加图片块时如果漏了对应的 + 度量,当成 0 的后果是一个真的超限的提示词悄悄通过规模判定那一档,然后在模型那边报超限 + ——那是一个来自网关的错误,看不出是本库的规模判定漏了一类块。 + """ + if isinstance(block, TextBlock): + return len(block.text) + raise AssemblyError(f"不认得的内容块类型:{type(block).__name__},它没有规模度量") + + +def prompt_chars(messages: Sequence[Message]) -> int: + """装配出来的这一份提示词有多大,单位是字符。 + + **不是 token。** 用 token 要引一个分词器,那是第三方依赖,而且不同模型的分词器不同——一个 + 跨模型可比的上限用 token 量反而不可比。 + """ + return sum(_block_chars(block) for message in messages for block in message.content) + + +def _text(role: Role, text: str) -> Message: + return Message(role=role, content=(TextBlock(text=text),)) + + +def injection_messages(injections: Mapping[str, tuple[Injection, ...]]) -> tuple[Message, ...]: + """注入槽:每条注入一条 `USER` 消息,正文原样,前后不加任何标题、分隔符或换行。 + + **空注入等同**:没有注入时返回空元组,装配结果与「根本没有这个槽位」逐字节相同。某个 + 下游第一阶段不注入任何东西,库要是悄悄多写一个空标题或一个换行,那一阶段的基线就和后续 + 阶段不可比了。 + + **不把多条拼成一条**:拼就要选一个分隔符,而分隔符是渲染格式、归项目侧。一条一条发则库 + 一个字符都没有添。 + + **通道之间按通道名的字典序排。** 映射的迭代顺序取决于调用方怎么构造它——用推导式从一个 + 集合建出来的话,顺序每进程都可能不同(Python 的字符串哈希每进程随机),于是同一份配置在 + 不同进程里渲染出不同的提示词。通道内按给定顺序。 + + 角色一律 `USER`,因为有一个下游全程没有 system 消息。给注入加一个角色字段是在给一个还没有 + 消费者的需求预留结构。 + """ + return tuple( + _text(Role.USER, entry.content) + for channel in sorted(injections) + for entry in injections[channel] + ) + + +def injected_entry_ids(injections: Mapping[str, tuple[Injection, ...]]) -> tuple[str, ...]: + """这次贴了哪几条,按与 `injection_messages` 相同的顺序。 + + **正文不进快照也不进轨迹,只有条目标识进。** 注入内容可能很大,进快照会让快照变成一份 + 数据副本;而「这次贴了哪几条」事后要查得到。 + """ + return tuple(entry.entry_id for channel in sorted(injections) for entry in injections[channel]) + + +def history_messages(steps: Sequence[StepRecord], observation_template: str) -> tuple[Message, ...]: + """已完成的每一步渲染成两条消息:模型说了什么,环境返回了什么。 + + 第一条是 `ASSISTANT`,内容是 `raw_output`。**那不一定等于模型原文**——解释器有权改写它, + 比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行结果,留着的话下一轮 + 它会把那段幻想当成真发生过的事)。库照它回填,不自己截断。 + + 第二条是 `USER`,内容是观察套上模板。**没有动作的步照样有观察**:解析失败那一步回填的是 + 解释器给的说明文本,模型调用失败那一步回填的是合成观察。所以这里没有「这一步有没有观察」 + 的分支。 + """ + check_observation_template(observation_template) + messages: list[Message] = [] + for step in steps: + messages.append(_text(Role.ASSISTANT, step.raw_output)) + messages.append( + _text( + Role.USER, + observation_template.format(**{OBSERVATION_PLACEHOLDER: step.observation}), + ) + ) + return tuple(messages) + + +def check_observation_template(observation_template: str) -> None: + """模板必须含那个占位符,不含就直接报错。 + + 不校验的话,一个写错的模板会让每一步的观察整个消失,而模型收到的是一段固定文本、看起来 + 完全正常——它会以为每次执行都返回了同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的 + 观察。 + + 用 `str.format` 是因为某个下游今天用的就是它,迁移时模板字符串一个字都不用改。代价是模板 + 里的其他花括号要写成双份;下面那次试渲染会把没转义的花括号一并抓出来。 + """ + if not isinstance(observation_template, str): + raise AssemblyError(f"观察模板必须是字符串,收到 {type(observation_template).__name__}") + try: + rendered = observation_template.format(**{OBSERVATION_PLACEHOLDER: "\x00"}) + except (KeyError, IndexError, ValueError) as exc: + raise AssemblyError( + f"观察模板渲染不了:{exc}。模板里除 {{{OBSERVATION_PLACEHOLDER}}} 之外的花括号要写成双份" + ) from exc + if "\x00" not in rendered: + raise AssemblyError( + f"观察模板里没有 {{{OBSERVATION_PLACEHOLDER}}} 占位符," + "照它渲染的话每一步的观察会整个消失,而模型收到的是一段看起来完全正常的固定文本" + ) + + +def assemble( + *, + context: Context, + injections: Mapping[str, tuple[Injection, ...]], + steps: Sequence[StepRecord], + observation_template: str, +) -> tuple[Message, ...]: + """把四类片段拼成这一步要发给模型的消息序列。 + + 注入槽排在两段之间,不是最前也不是最后:排到最前会把最稳定的那段挤到后面去、白丢前缀 + 缓存;排到最后会让注入内容跑到题面后面,而它通常是在给「怎么做这道题」的额外指引,位置 + 在题面之后读起来是反的。 + """ + return ( + *context.run_level, + *injection_messages(injections), + *context.goal_level, + *history_messages(steps, observation_template), + ) + + +__all__ = [ + "OBSERVATION_PLACEHOLDER", + "AssemblyError", + "assemble", + "check_observation_template", + "history_messages", + "injected_entry_ids", + "injection_messages", + "prompt_chars", +] diff --git a/tests/unit/test_assembly.py b/tests/unit/test_assembly.py new file mode 100644 index 0000000..3c916f9 --- /dev/null +++ b/tests/unit/test_assembly.py @@ -0,0 +1,260 @@ +"""上下文装配的行为。 + +最要紧的一条是**空注入等同**:注入为空时,装配结果必须与「根本没有这个槽位」逐字节相同。 +某个下游第一阶段不注入任何东西,库要是悄悄多写一个空标题或一个换行,那一阶段的基线就和后续 +阶段不可比了——而这件事不会报错,只表现成「这批分数有点不一样」。 +""" + +import pytest + +from polyloop._assembly import ( + AssemblyError, + assemble, + check_observation_template, + history_messages, + injected_entry_ids, + injection_messages, + prompt_chars, +) +from polyloop.types import ( + ActionStatus, + Context, + Injection, + Message, + Role, + StepRecord, + TextBlock, +) + +pytestmark = pytest.mark.unit + +TEMPLATE = "Output:\n```\n{observation}\n```\n\n" + + +def _msg(role: Role, text: str) -> Message: + return Message(role=role, content=(TextBlock(text=text),)) + + +def _context() -> Context: + return Context( + run_level=(_msg(Role.USER, "你是一个助手"), _msg(Role.ASSISTANT, "好的")), + goal_level=(_msg(Role.USER, "任务:数到三"),), + ) + + +def _step(idx: int, *, raw_output: str = "1", observation: str = "ok") -> StepRecord: + return StepRecord( + step_idx=idx, + raw_output=raw_output, + content_chars=len(raw_output), + thinking_chars=0, + action="1", + parse_ok=True, + parse_error=None, + observation=observation, + observation_is_synthetic=False, + observation_truncated_chars=0, + prompt_chars=0, + call_id=None, + step_wall_ms=0, + action_status=ActionStatus.EXECUTED, + ) + + +# --------------------------------------------------------------------------- +# 段序 +# --------------------------------------------------------------------------- + + +def test_the_segments_come_in_order() -> None: + """run 级片段 → 注入槽 → 目标级片段 → 逐步交替。 + + 照变化频率从低到高排:供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默失效, + 而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游想测的效应。 + """ + messages = assemble( + context=_context(), + injections={"skill": (Injection(entry_id="e1", content="记得先看目录"),)}, + steps=[_step(0)], + observation_template=TEMPLATE, + ) + + assert [block.text for message in messages for block in message.content] == [ + "你是一个助手", + "好的", + "记得先看目录", + "任务:数到三", + "1", + "Output:\n```\nok\n```\n\n", + ] + + +def test_the_library_never_synthesises_a_system_message() -> None: + """有一个下游全程没有 system 消息,库自己加一条它的提示词会凭空多出一段。 + + 要 system 消息的项目把它放进自己的 run 级片段里。 + """ + messages = assemble(context=_context(), injections={}, steps=[], observation_template=TEMPLATE) + + assert all(message.role is not Role.SYSTEM for message in messages) + + +# --------------------------------------------------------------------------- +# 空注入等同 +# --------------------------------------------------------------------------- + + +def test_an_empty_injection_slot_leaves_nothing_behind() -> None: + """注入为空时,装配结果与「根本没有这个槽位」逐字节相同。 + + 这是 `design/0003` 点名的一条验收标准。库要是悄悄多写一个空标题或一个换行,不注入的那个 + 阶段就和后续阶段不可比了。 + """ + context = _context() + steps = [_step(0)] + + with_slot = assemble(context=context, injections={}, steps=steps, observation_template=TEMPLATE) + without_slot = (*context.run_level, *context.goal_level, *history_messages(steps, TEMPLATE)) + + assert with_slot == without_slot + + +def test_a_channel_with_no_entries_leaves_nothing_behind() -> None: + """一个空通道和「没有这个通道」也必须等同,否则按阶段建通道的项目会踩到。""" + assert injection_messages({"skill": (), "hint": ()}) == () + + +# --------------------------------------------------------------------------- +# 注入槽 +# --------------------------------------------------------------------------- + + +def test_each_injection_is_its_own_message_with_nothing_added() -> None: + """每条一条消息,正文原样,前后不加任何标题、分隔符或换行。 + + 拼成一条就要选一个分隔符,而分隔符是渲染格式、归项目侧。 + """ + messages = injection_messages( + {"skill": (Injection(entry_id="a", content="甲"), Injection(entry_id="b", content="乙"))} + ) + + assert messages == (_msg(Role.USER, "甲"), _msg(Role.USER, "乙")) + + +def test_channels_are_ordered_by_name_not_by_mapping_order() -> None: + """映射的迭代顺序取决于调用方怎么构造它,用集合推导建出来的话每进程都可能不同。 + + 照那个顺序渲染,同一份配置在不同进程里会渲染出不同的提示词。 + """ + entries = { + "zeta": (Injection(entry_id="z", content="Z"),), + "alpha": (Injection(entry_id="a", content="A"),), + } + + assert [block.text for m in injection_messages(entries) for block in m.content] == ["A", "Z"] + + +def test_entry_ids_come_back_in_the_same_order_as_the_messages() -> None: + """轨迹里记的是条目标识,正文不进——正文可能很大,而「这次贴了哪几条」事后要查得到。""" + entries = { + "zeta": (Injection(entry_id="z", content="Z"),), + "alpha": (Injection(entry_id="a", content="A"),), + } + + assert injected_entry_ids(entries) == ("a", "z") + + +# --------------------------------------------------------------------------- +# 历史轮次 +# --------------------------------------------------------------------------- + + +def test_each_step_becomes_two_messages() -> None: + messages = history_messages([_step(0), _step(1, raw_output="2", observation="ok2")], TEMPLATE) + + assert [(m.role, m.content[0].text) for m in messages] == [ + (Role.ASSISTANT, "1"), + (Role.USER, "Output:\n```\nok\n```\n\n"), + (Role.ASSISTANT, "2"), + (Role.USER, "Output:\n```\nok2\n```\n\n"), + ] + + +def test_the_assistant_turn_is_the_stored_raw_output_not_a_re_rendered_one() -> None: + """`raw_output` 是解释器交回来的那段文本,不一定等于模型原文。 + + 解释器有权改写它,比如把第一个代码围栏之后的内容整段丢掉——模型常在代码块后面编造执行 + 结果,留着的话下一轮它会把那段幻想当成真发生过的事。库照它回填,不自己截断。 + """ + step = _step(0, raw_output="```python\nprint(1)\n```") + + assert history_messages([step], TEMPLATE)[0].content[0].text == "```python\nprint(1)\n```" + + +def test_no_steps_means_no_history_messages() -> None: + assert history_messages([], TEMPLATE) == () + + +# --------------------------------------------------------------------------- +# 观察模板 +# --------------------------------------------------------------------------- + + +def test_a_template_without_the_placeholder_is_refused() -> None: + """照它渲染的话每一步的观察会整个消失,而模型收到的是一段看起来完全正常的固定文本。 + + 模型会以为每次执行都返回了同样的东西,然后开始瞎猜,而轨迹里那一列明明存着真的观察。 + """ + with pytest.raises(AssemblyError, match="占位符"): + check_observation_template("Output:\n```\n\n```") + + +def test_a_template_with_an_unescaped_brace_is_refused() -> None: + """模板里除占位符之外的花括号要写成双份,没转义的在这里就被抓出来。""" + with pytest.raises(AssemblyError, match="渲染不了"): + check_observation_template('{"result": {observation}, "extra": {oops}}') + + +def test_a_template_with_escaped_braces_is_accepted() -> None: + check_observation_template('{{"result": "{observation}"}}') + + +def test_the_template_is_checked_before_any_message_is_built() -> None: + """坏模板在装配入口就报错,不是走到第三步才发现。""" + with pytest.raises(AssemblyError): + assemble( + context=_context(), injections={}, steps=[_step(0)], observation_template="没有占位符" + ) + + +# --------------------------------------------------------------------------- +# 规模度量 +# --------------------------------------------------------------------------- + + +def test_prompt_chars_counts_every_block_of_every_message() -> None: + messages = ( + _msg(Role.USER, "12345"), + Message(role=Role.ASSISTANT, content=(TextBlock(text="ab"), TextBlock(text="cde"))), + ) + + assert prompt_chars(messages) == 10 + + +def test_an_empty_prompt_measures_zero() -> None: + assert prompt_chars(()) == 0 + + +def test_an_unknown_block_type_fails_instead_of_counting_zero() -> None: + """将来加图片块时如果漏了对应的度量,当成 0 的后果是一个真的超限的提示词悄悄通过。 + + 那时报错来自模型网关,看不出是本库的规模判定漏了一类块。 + """ + + class _Mystery: + pass + + messages = (Message(role=Role.USER, content=(_Mystery(),)),) # type: ignore[arg-type] + + with pytest.raises(AssemblyError, match="规模度量"): + prompt_chars(messages)