Files
PolyLoop/research-wiki/scratch/2026-08-07-0003-辩论裁决稿.md
iomgaa 6fafd95d6c feat(tools): 落成 executor() 与派生分发器;公共数据类一律只收关键字参数
两个自行调研后决定的问题,各自的证据写进了 design doc:

一、handler 返回 str 而不是带截断计数的小结构(0008 决策三,文末新增一节)。三条实据:
两个真实消费者的执行函数今天就返回纯字符串(GovDoc 的 handler 是 Coroutine[..., str],
dissect 的环境 execute 是 -> str);dissect 的 observation_truncated_chars 唯一的生产写入点
硬编码 0 且全仓零读取点,存在的是名字不是需求;reference/pi 是唯一把截断做完整的,它记的是
totalBytes/outputBytes/maxBytes 这组绝对量而不是一个差值——现在补 truncated_chars 补的
大概率是错形状,正是 scope.md 说的「猜出来的接缝比没有接缝更难拆」。

二、新增 design 0009:src/polyloop/ 下每个数据类都加 kw_only=True,另加一条扫描测试守它。
实验室七个仓库 223 个 dataclass 里 kw_only 出现零次,但那是默认行为不是选择。真正的证据是
PolyGateway:它的 LLMResponse 前 11 个字段顺序被三个下游的测试替身按位置构造锁死,模块
docstring 写着「字段顺序即公共承诺」,还得专门写一条 test_eleven_legacy_fields_positional
守着,从此再也插不进字段。那个约束不是它选的是它继承的,而本库还没有下游装上。
扫描测试查的是构造签名不是那个装饰器参数——要守的承诺是「按位置构造不了」。

executor() 在派生那一刻全查一遍实现,缺一个就报错,不拖到分发时才炸。RegistryExecutor 是
具体类而不是闭包,因为 RunRequest 要用 isinstance 认它。CancelledError 不被那个
except Exception 接住(它继承 BaseException),有测试守着。
2026-08-10 01:19:15 -04:00

64 KiB
Raw Permalink Blame History

design 0003 裁决稿:公共 API 分层、模块边界与接缝清单

三份方案分别以实验可控性、接入成本、可演化性为优先级独立产出,随后各被一位对抗审查者证伪。下面是综合裁决,可直接作为 research-wiki/design/0003 的素材。凡我在参考代码上核对过的事实都注明了出处;没核对的地方明说没核对。


一、分歧地图

1.1 三份方案一致的地方

这些结论是三个互不相同的优先级各自独立推出来的,而且三位对抗审查者一条都没打动。它们是本轮最硬的产出。

A1:不导出低阶循环,也不导出单步。 三份方案给的理由不是同一条,但指向同一处:

  • 0002 要求"这次运行结束了"这个标记由库在返回结果之前写下。调用方一旦握着终止权,这个标记要么没有确定的写入点,要么靠调用方记得调一个 finish()——而忘记调不报错。
  • CancelledError 必须能穿过模型调用与环境执行,in-flight 资源在 finally 释放。两步之间的 await 点如果落在调用方的栈上,库没有任何位置放那个 finally
  • 停止判定顺序是公共契约。交出循环,这个顺序就变成调用方 while 条件的一部分。

三份方案还各自举了同一批业界证据:smolagents 真的开了 step(),代价是把"构造哪种步对象、append 到哪、怎么数步数、怎么判停"整个泄漏给调用方,预算与停止语义跑到了库外;pydantic-ai 的 AgentRun.next 把私有模块 pydantic_ai._agent_graph 的节点类型写进了公开签名,issue #1964 抱怨"字段含义只能靠猜"Claude Agent SDK 在选型表里把想自己写循环的人直接劝去用更低层的 Client SDK。

A1:公共驱动入口恰好一层,两个具名动词。 三份方案都收敛到"跑一次"与"接着跑"两个协程,都拒绝"run 撞上已有日志就自动续跑",理由一致:自动续跑是一次不可见的选择,而它的错误方向是重复执行副作用。

A2:依赖图有一个汇点,接缝定义模块零运行期依赖。 三份方案的分层骨架实际上是同一个:值类型在最底,Protocol 定义在其上且只 import 值类型,中间是若干互不 import 的纯逻辑模块,运行入口在上,库自带实现在最上。三份都能写成 layers + independence + forbidden 三类 import-linter 契约。这个形状抄自 docagent-core 已经落地的那套契约(GovDoc-SaaS/pyproject.toml:62-85),而它成立的前提正是"所有跨层 Protocol 集中在一个模块里"。

A2:停止判定与预算结算收敛成一个无 IO 的纯逻辑模块,并用 import-linter 禁止它 import asyncio。 三份方案都这么做,理由也一致:CLAUDE.md §3 要求这块必须对抗审查,而审查对象散在三处这道闸就等于空转。

A3:接缝恰好五个,三个候选接缝全部判为伪接缝。 三份方案独立给出了同一份清单——模型调用、决策解释、动作执行、存储、事件投递——并且用同一条准入判据(举得出两个形态明显不同的真实实现)裁掉了同样三个候选:

  • 完成判定不设接缝。信号源全在库内或已在别的接缝的返回值上,再设一个入口等于同一件事有两个来源。
  • 观察投影不设接缝。M-07 要的两个量(模型原文与进历史文本、环境观察与是否合成)已经分别由决策解释与动作执行的返回结构体携带。再开一条能改观察的路径,两条路径必然分叉——docagent-core 的 hooks 文档承诺"替换"而代码做的是"追加"hooks.py:33-35loop.py:473-493),pi 的三份 entry→context 投影已经漂移到"同一条 entry 在正常回合可见、在压缩里不可见"(issue #6451)。
  • 上下文装配不设接缝。真正会变的是渲染格式,而渲染格式必须留在项目侧;归库的只有段顺序与注入槽位置,那是纯函数不是扩展点。

A3:预算是两个独立计数而不是一个标量,且两种耗尽必须撞出两个不同的停止原因。 三份都这么定。

其余一致项:存储端口强制注入、不做可选参数;事件流不加投递保证、审计引到轨迹与意图日志上;库内不设步级重试;消息形态钉死为 role/content 字符串对;call_id 可为 None 不可为空串;CancelledError 原样重抛、CANCELLED 只出现在结束记录里;不实现上下文压缩。

1.2 真正的分歧,以及分歧的根源

分歧一:有没有"定义"这个对象。

方案一与方案二有一个不可变、可并发复用的 AgentDefinition,加一个 per-run 的 RunRequest;方案三干脆取消了定义,只留一个 RunRequest 加两个模块级函数。

根源是可演化性与实验可控性的冲突。方案三的论证是:凡是能表达成"结构体上的一个字段"的东西就不要表达成签名或层次,因为加一个带默认值的字段是兼容变更、加一层是永久合同;取消定义之后"预算给默认值还是请求覆盖"这个问题连问都不用问。方案一与方案二的论证是 D-13:可复现性参数必须能从已装配好的库对象上读出来并聚合各接缝的参数,而 dissect 的 runner 明写"组件参数从真实对象上取,不接受外部传入"——手写传进来的快照记的是声明不是事实。

这个分歧被一条实测事实裁掉了,见第三节。

分歧二:环境执行是不是独立接缝。

方案一保留 EnvironmentPortexecute + check_done),库另带一个由 ToolRegistry 驱动的实现;方案二合成一个 ActionExecutor;方案三最激进,连环境概念都不要,dissect 写一个只注册一个工具的注册表,把 Episode.executeis_done 都塞进那个工具的 handler。

根源是可演化性与实验可控性的冲突。方案三的算账是:is_done 只有 dissect 一个真实实现,GovDoc 那边恒为"没有环境信号",一个真实现加一个空实现不构成接缝;合并之后库少一个 Protocol、少一套契约套件、少一份永久签名。方案一的算账是:合并之后"环境完成信号取不到算环境故障"这个判定移进了 dissect 的适配器,库无法断言它做对了,而做错的表现是运行安静地跑到预算耗尽。

分歧三:模型响应类型是自己定义还是 re-export PolyGateway 的。

方案二 re-export,理由是不重复字段、下游按位构造零改动,而且 docagent-core 已经这么做并把理由写在注释里。方案一与方案三自己定义,理由是第三方类型进公共签名会把 PolyGateway 的 major 变成 PolyLoop 的 major,而"只增不删不改名"这份承诺是 PolyLoop 给三个下游的、不是 PolyGateway 给的。

根源是接入成本与可演化性的冲突。

分歧四:工具参数 schema 用 pydantic 模型还是普通 JSON Schema 字典。

方案一与方案二显式认下 pydantic 为公共依赖并用 forbidden 契约把爆炸半径钉在一个模块;方案三反向选择,说 pydantic 的 major 会同时击穿三个下游,pi 就因为 typebox 0.34→1.x 出过一次破坏性变更、理由正是 schema 库的类型出现在公共 API 表面。

分歧五:模型调用接缝挂在定义上还是 per-run。

D-19 要求模型调用能透传库不理解的 per-run 绑定(dissect 要绑 ledger、round_idx、phase、item_id、attempt_idx 五维)。方案一选"接缝实例本身是 per-run 参数",方案二选"接缝留在定义上,per-run 绑定走一个 object 类型的不透明字段",方案三选 per-run 且把绑定闭包进去。

分歧六:库带多少电池。

方案一带得最全(PolyGateway 适配器、Jsonl 存储、内存存储、工具分发环境、Null 事件出口);方案二带一半;方案三只带一个内存存储,并用一条更强的 forbidden 契约禁止整个 polyloop 包 import polygateway。


二、被证伪的东西

判据是能不能指出一个能写成测试、且当前设计下必然失败的具体场景。我对最关键的几条做了参考代码核对。

2.1 成立的致命指控

(1)意图日志加副作用结果,不足以支撑恢复。 三份方案的存储端口都只写"意图"与"两次副作用的结果",没有任何位置写每一步的派生状态(进历史的文本、解析判别位、连续失败计数、两个预算计数)。于是 resume 只能拿存储里的模型响应重跑一遍 DecisionInterpreter 来重建历史,这把"解释器必须确定且永不升级"变成一条从未声明的隐性契约。攻击一给的场景可直接写成测试:跑 6 步后杀进程,改一条解释器的容错规则(GovDoc 的 pes_overrides.py:382-398 就是这么演进的),resume 之后 RunResult.steps 与一口气跑完不等价,而 R-08 正好要求它们等价。方案二被指出的更基础:它的 RunStore 连"按 id 读回结果"的方法都没有,resume 在类型上就跑不通。

(2)resume 没有运行身份校验,而 dissect 今天有。 我核对过:reference/dissect/harness/record/store.py:343 定义了 verify_same_runrunner.py:202 在任何写入之前调它。三份方案里库接管恢复责任之后,这道守卫全部丢失——用同一个 RunKey 但换一份定义(换模型、换预算、换段)调 resume,库会从第 5 步接着用新模型跑下去,返回的快照只反映新模型而前 5 步是旧模型产的,全程零报错。这正是 CLAUDE.md §1.4 点名的那类损坏。

(3)两个预算计数的命名会静默打掉 dissect 的预注册崩坏判据。 这条我逐行核对了。reference/dissect/harness/agent/loop.py:180for step_idx in range(self._config.max_steps),解析失败走 continue 但占掉一个 step_idx(注释原文:"这一步照样计入预算:它确实消耗了一次模型调用"),循环跑满返回 StopReason.STEP_BUDGET,落盘值 'step_budget'memory.py)。而 reference/dissect/harness/record/checks_coverage.py:237SELECT count(*) FROM rollouts WHERE stop_reason = 'step_budget',按占比告警。方案二与方案三把"总迭代"绑给了另一个名字(ITERATION_BUDGET / MODEL_CALL_BUDGET),于是迁移后这条 SQL 永远查到 0 行,一条预注册的判据静默熄火,而且不会有任何地方报错。同理,checks_coverage.py:308 写着 WHERE stop_reason IN ('llm_error', 'env_error'),方案三把 LLM_ERROR 改名成 MODEL_ERROR 会打掉这条。

(4)无效决策的观察不能用一条固定的合成文本。 dissect 的 parser 对五种解析失败各产出一条对症说明(无代码块、空的未闭合块、闭合围栏后跟了别的内容、first_only 下第一个块为空、concat_all 下全空),loop.py:329parsed.error 原样当观察回喂。方案三规定这条观察取自 per-run 的一条固定串,等于把五条对症纠错压成一句泛泛的提示,实验刺激被改写,而唯一的硬验收口径是"轨迹与迁移前逐字段可比"。

5)结构化动作怎么落进步记录的 action 列没有答案。 dissect 今天 Step.action 就是那段可执行的 Python 源码本身,而 memory.py 明写轨迹文件是反思模型的唯一输入界面。如果决策被规定成 ToolCall(name, arguments),库往 action 里填什么都会改掉这一列——填 json.dumps(arguments) 就让反思模型读到的从"一段代码"变成"一段包着代码的 JSON"。

(6)取消路径上那段 shield 算法自相矛盾。 await asyncio.shield(fut) 在一个已经收到取消的协程里会立刻抛 CancelledError,内层写入还没做完;"写完立刻重抛"做不到,"shield 内部的存储失败只发一条事件"也无从触发,而 emit 本身又是一个 await,与"那段代码里没有第二个 await"直接冲突。这是 asyncio 语义问题,不是口味问题。

7EventSink 投递失败转成 sink_failure 事件再从同一个 sink 发出去,没有递归护栏。 一个持续失败的 sink 会让失败处理路径自我喂食。GovDoc 的进度钩子每步回写业务数据库,数据库真挂掉时正是这个形态,而那恰恰是"观察者失败不能打断循环"这条规则存在的唯一理由。

8ModelPort 移到 per-run 会让 D-13 在 dissect 侧硬报错。 dissect 的 runner 在第一个 rollout 之前 build_snapshot(components={"agent": self._agent.snapshot_params}),取不到模型串与 scope 直接 raise。若模型客户端挂在每题一个的 RunRequest 上,定义级快照里就结构性地不可能有模型串。

9run() 撞上已有日志的行为、以及日志里已有结束记录时 resume 的行为,三份方案都没定义。 dissect 的续跑路径会用完全相同的六维主键第二次调 run(runner.py:319-333 把没有结果行的尝试重新排进 pending),而 GovDoc 每个审核点都套 wait_for,超时后写下一条 cancelled 的结束记录、下一次调度还要接着做这个点。两个实现者会在这里做出相反的选择,两种选择都不报错。

(10)子集要同时收窄提示词与分发,但方案二的装配签名拿不到工具 schema。 装配函数看不见执行器,两次 run 换不同工具子集会产出逐字节相同的消息,模型在 summarize 阶段照样看得见 Bash,而"summarize 绝对禁止 Bash"是生产硬要求。

11)模型调用的重放策略没有定义。 0002 第三条要求模型调用与动作执行用同一套保护,第四条的重放策略却只由工具声明,而模型调用没有工具。模型调用后到结果落盘之间是一步之内最长的 await 窗口,崩溃概率正比于窗口长度,这一档没定义等于恢复能力在实践中大半不生效。

12)动作通道不匹配会静默烧光预算。 一个产出工具调用的解释器配上一个只吃代码载荷的环境,每步返回 rejected 直到预算耗尽,轨迹长度与停止原因都与"真的做了 40 步没做完"一模一样。这条是五条里最弱的一条——任何一次冒烟测试都会撞到——但它属于"没有失败现场"那一类,值得一道构造期守卫。

2.2 不成立或被高估的指控

"pi 的低阶 agentLoop 在整个 monorepo 里除测试外零调用点"——只对一半。 我核对过:reference/pi/packages/agent/src/agent.ts:10 import 了 runAgentLoop:414 调用它。零调用点的只是那个流式包装 agentLoop。这条业界证据被三份方案里的两份各用了一次,实际强度打折。但"不导出低阶循环"这个结论不依赖它——它站在 R-05 与 G-06 两条硬约束上。

"三层 / 五个接缝 / 依赖契约这套骨架本身错了"——三位攻击者都没有主张,而且都明确说站得住。 三份 verdict 全是 needs_major_revision,理由全是"骨架上少了东西"或"该冻结的细节留白了",没有一条要求换骨架。三个不同优先级的方案作者加三个独立攻击者共六轮,没有一轮动摇"单入口 + 五接缝 + 三伪接缝"的形状。这是本轮最强的背书。

"完成判定在记步之后、预算结算之前"没被攻击过。 三份方案都这么定,三位攻击者都没碰。它是 D-03 的直接继任,判据是可写成测试的(用一个在第 N 步返回 done 的执行器替身造场景,不 mock 内部实现)。

"事件流不加投递保证"没被攻击过。 三份方案都把审计引到轨迹与意图日志上,三位攻击者都接受。

"存储端口强制注入 + 一个显式命名的无恢复实现"没被攻击过。 只有一条相关观察:dissect 若长期停在内存实现上,0002 那套步级恢复对唯一的硬消费者一条都不生效。这是真的,但它是电池层的选择问题而不是接口错误,见第五节。


三、推荐方案

名字:单定义双动词内核加电池层。骨架取三份方案的交集,四处分歧按下面的理由各选一边,攻击暴露的十二个洞全部在 0003 里补上。

3.1 分层

九个模块,四层公开面加一层内部纯逻辑。

模块 是否公开 装什么
polyloop.types 公开 公共值类型、枚举、四种持久化记录
polyloop.ports 公开 五个 Protocol 与它们的入参/返回结构体
polyloop.tools 公开 ToolSpec / ToolRegistry,以及由它派生的动作执行器
polyloop.serialization 公开 记录的 encode/decode 与 schema major 校验
polyloop._assembly 内部 段序、注入槽、规模度量
polyloop._stopping 内部 停止判定与预算结算
polyloop._recovery 内部 四态判定与运行身份校验
polyloop.session 公开 AgentDefinition / RunRequest / run / resume
polyloop.stores / polyloop.adapters 公开,可选装配 库自带的存储实现与 PolyGateway 模型适配器

polyloop.types 给谁用:所有人。dissect 的 runner 读 StepStopReason 把 Rollout 头拼回轨迹文件;dissect 的对账器读 Step.call_idGovDoc 的编排层读 stop_reason 决定这个审核点算不算可挽救;写任何一个适配器的人都要构造这里的结构体。它是"字段只增不删不改名"保护的那份合同本身,也是依赖图的汇点——谁都能 import 它,它谁也不 import。

polyloop.ports 给谁用:写适配器的人,以及 tests/contract/。它是新适配器的准入标准所在。用 typing.Protocol 而不是 ABC,理由是 dissect 的 Episode 已经是它自己的类、还要同时满足 ScoredEpisode 这个更宽的视图,只有结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图;要求它继承库的基类就是反向耦合。它必须零第三方依赖并且不 import 任何实现模块,否则 independence 那条契约根本写不出来——实现方想用另一个子包定义的端口就会长出反向边。

polyloop.tools 给谁用:GovDoc 按阶段派生窄工具集;CHSAnalyzer 这类"有工具、要 JSON 工具调用"的项目直接用;dissect 用一个空注册表。注册、模型可见 schema 生成、存在性与参数校验、分发、重放策略、完成标记六件事由同一个 ToolRegistry 实例驱动,住在同一个模块里,independence 契约保证没有第二处持表。它是不可变值对象,subset(names) 返回新实例——不能是全局单例,因为 GovDoc 三个阶段要在同一进程里同时持有三份窄集合,而"依赖注入不从全局偷取"也禁止进程级注册表。

polyloop.session 给谁用:每个消费者的驱动代码。它是唯一持有 run 生命周期、存储写入与取消退出路径的地方。

内部三个模块用下划线开头且不进 polyloop/__init__.py。Python 拦不住谁去 import 它们,下划线是唯一的机器信号。pi 的 AgentRunner 与 pydantic-ai 的 _agent_graph 都证明了"docstring 写着 experimental"这种约束在下游看不到实现的库里必然被当成公共 API 用——OpenAI 的 AgentRunner 甚至被写进了 __all__ 同时 docstring 写着"not part of the public API"。

3.2 定义与请求怎么切

采纳"定义 + 请求"两个类型,切点在跨运行不变的能力每次运行都变的数据之间。

AgentDefinitionfrozen,可并发复用)
    model: ModelPort
    interpreter: DecisionInterpreter
    store: RunStore
    events: EventSink
    texts: LibraryTexts          # 库合成观察的文本、观察包装模板
    params_view: Mapping[str, JSONValue]   # 聚合三个端口各自的 params

RunRequestfrozen,构造廉价:无 I/O、无校验网络、无哈希计算)
    key: RunKey                  # 不透明字符串,库不解析
    budget: Budget               # 无默认值,四项全必填
    executor: ActionExecutor     # 环境句柄或由 ToolRegistry 派生的分发器
    tools: ToolRegistry          # 本次可见的(子)集合
    run_segments / item_segments: tuple[ChatMessage, ...]
    skills: tuple[InjectedEntry, ...]
    model_binding: Mapping[str, str]      # 库不解释,原样透传给每次模型调用
    model_call_replay: ReplayPolicy       # 必填,无默认
    tool_docs_style: ToolDocsStyle        # 必填,无默认
    finalize_timeout_s: float             # 必填,无默认

为什么保留定义对象。 D-13 要求可复现性参数能从已装配好的库对象上读出来,而 dissect 的 runner 在第一个 rollout 之前就要拿到模型串与 scope,拿不到直接 raise。模型客户端一旦挂到每题一个的请求上,定义级快照里结构性地不可能有模型串——这是攻击一证伪方案一的那条。同时,共用一个底层 PolyGateway 客户端、限流桶不被拆,只有在客户端住在一个可复用对象上时才是结构性保证;挂在 per-run 参数上就退化成靠调用方自觉。

为什么 per-run 绑定用 Mapping[str, str] 而不是不透明 object 方案二用 object 直通,代价是公共签名上一个永久的 Any 洞,而洞里的东西永远进不了参数快照。我核对了 dissect 真正要绑的五维(reference/dissect/harness/llm/gateway.py:330-355):ledger 的 Raises 段写着"ledger 不是三个合法取值之一"(即一个取值有限的枚举),其余是 int 与 str|None。这五个全部能表达成字符串,所以不需要开洞。代价是 dissect 的适配器要做一次 Ledger(binding["ledger"])int(binding["round_idx"]) 的还原——那是适配器该干的活,而收益是这五维能原样进 run 快照。

为什么预算只有请求这一处、不设"定义给默认值加请求覆盖"。 两处取值意味着"这次到底跑的什么设置"要对照两个地方才答得出来,而覆盖发生时快照记哪一个还要另外规定。dissect 每题重传的是同一个 frozen Budget 对象,不存在漂移;GovDoc 三个阶段共用一份定义、各传一份预算,不会长出三份除预算外完全相同的定义。"定义给默认值"这条唯一的支持理由是方便,举不出失败场景。

为什么没有 open_run 这个上下文管理器。 它不 yield 任何有用的东西,一个消费者都点不出来,却要白养一份永久合同和一套契约套件。runresumeAgentDefinition 上的两个协程方法。

3.3 五个接缝

ModelPort(挂定义) async call(self, call: ModelCall) -> ModelReply,加一个只读 paramsModelCall 带已装配好的消息序列、这次运行内的第几次调用、RunKey、库预分配的结果 ID、以及原样透传的 model_bindingModelReply 三个字段:call_id: str | None(绝不为空串)、textreasoning。失败以异常表达,库接住翻译成 llm_error 并记一条 call_id=None 的步。签名里不出现重试次数、退避时长、限流配额——出现即意味着库在治理一次模型调用。

两个真实实现形态不同:dissect 的 LedgerClient.chat 要按三本账记一条并自己按价格表算成本(PolyGateway 的 cost 字段恒为 None);GovDoc 要在调用外面套退避 20/40 秒并累加本次运行的 token。两者的差别是结构差别不是配置差别。

返回类型是库自己的 ModelReply 而不是 re-export polygateway.LLMResponse 三条理由。第一,re-export 会让接缝定义模块运行期依赖 PolyGateway,零依赖契约要开豁免,任何只想写测试替身的下游也得装上 PolyGateway;攻击二证明方案二的"import polyloop 后 sys.modules 里没有 polygateway"这条断言与它自己的再导出清单直接互斥。第二,PolyGateway 加一个字段就等于 PolyLoop 的公共类型变了一次而没有发过版。第三,LLMResponse 本身并不合身:dissect 的 memory.py docstring 明写它不透出 finish_reason,而曾经写成 getattr(response, "finish_reason", None) 是个 bug——"用默认值掩盖字段不存在"。代价照实认下:PolyGateway 将来透出 finish_reasonPolyLoop 要跟着发一版。

DecisionInterpreter(挂定义) async read(self, reply: ModelReply) -> Decision,加只读 params 与只读 produces: ActionKindDecision 三分支:

  • ActionDecision(action_text: str, tool_name: str | None, arguments: Mapping[str, JSONValue], history_text: str)
  • FinalAnswer(text: str, history_text: str)
  • InvalidDecision(reason: str, history_text: str)

action_text 是这一步的动作在轨迹里长什么样,由实现方决定,库原样填进 Step.action——dissect 传那段 Python 代码,GovDoc 传 json.dumps(arguments)。这一项是攻击三逼出来的,没有它 dissect 轨迹的 action 列会被改写,而那个文件是反思模型的唯一输入界面。

InvalidDecision.reason 就是回喂给模型的观察文本(observation_is_synthetic 为真),不是从 LibraryTexts 取一条固定串。这是攻击三的第二条:dissect 的 parser 对五种解析失败各有一条对症说明,压成一句会改掉实验刺激。

库不带任何默认实现——带了就等于替某一家定了动作语言。

ActionExecutor(挂请求) async execute(self, action: Action) -> ActionOutcome,加只读 tool_schemas 与只读 accepts: ActionKindActionOutcome 五个字段:status: ActionStatusEXECUTED / REJECTED / ENV_FAILURE)、observation: strobservation_is_synthetic: boolcompleted: bool | NoneNone 表示完成信号取不到)、truncated_chars: int

判别位放在返回结构体上而不是用异常类型编码,理由有具体失败场景:项目自定义工具里抛一个普通 ValueError(参数解析时极常见)如果被判成"工具无效"而不计有效步,模型就能无限重试同一个坏工具直到上界耗尽。ENV_FAILUREcompleted is None 都映射到 env_error——前者是执行侧坏了,后者是完成信号侧坏了,两个失败点不同所以两个字段。任何逃出 execute 的异常也被库接住翻译成 env_error,那是兜底不是主通道。

accepts / produces 两个只读标记在 run 开始时比对,不匹配抛 InvalidRequest。它守的是攻击一那条:解释器产出工具调用而环境只吃代码载荷时,运行会每步 rejected 直到预算耗尽,轨迹长度与停止原因和"真的做不完"一模一样。

库提供 ToolRegistry.executor():工具不存在与参数不合 schema 由它判定、不经过任何 handler,直接合成 REJECTED。dissect 的实现把一段 Python 交给已开好的容器会话,tool_schemas 为空,status 恒为 EXECUTED(代码报错是正常观察不是动作未成立),completed 来自问环境。

环境不另设接缝EnvironmentPortActionExecutor 是同一个东西,execute 与完成信号住在同一个返回值上。dissect 的 Episode 本来就同时有 executeis_done,拆开会让工具分发那条路径多出一个只能返回常量的空壳。但也不像方案三那样把环境整个塞进一个工具的 handler——那会让"完成信号取不到算环境故障"这个判定移出库外,库断言不了。

诚实登记一条迁移成本:dissect 的 Episode 与这个 Protocol 不是结构化子类型关系。我核对过 reference/dissect/harness/envs/protocol.py:那边是 execute(action: str) -> stris_done() -> bool,没有 params。dissect 必须为每个 episode 写一个包装类,这项成本 migrations/dissect.md 里没有登记。

RunStore(挂定义) 六个方法,全部带 RunKey,端口不持有"当前 run"的隐式状态——一个有隐式当前 run 的端口在并发下会把 A 的意图写进 B 的日志。

async append_run_start(key, RunStartRecord)      # 含合并后的 params_snapshot
async append_intent(key, IntentRecord)           # 模型调用意图 / 动作意图,带预分配 result_id
async append_result(key, result_id, ResultRecord)
async append_step(key, StepRecord)
async read_run(key) -> RunLog
async finish(key, RunEndRecord)                  # 含 stop_reason

这比 0002 冻结的三种记录多了两类:运行开始逐步结果。它们是攻击一与攻击二的两条致命指控的唯一解药,理由在 3.6 详述。has_result 这类按 ID 的存在性查询不设方法——它可以由 read_run 推出来,端口少一个方法就是少一份永久合同。

写入粒度的契约要写清楚:两条意图记录必须各自单独落地(意图必须在副作用之前就持久,这是 0002 的全部意义),而结果与步记录允许合并成一次写。于是一步是四次写、两个耐久屏障,而不是五次。

EventSink(挂定义) async emit(self, event: Event) -> None。投递失败由库捕获、logger.exception、并把 RunResult.event_sink_failures 加一,然后继续跑。失败不再转成事件从同一个 sink 发出去——那会自我喂食,一个持续失败的 sink 会把失败处理路径变成递归。失败次数回到返回值里比只记日志更容易被发现,而且这不是 try-except-pass,所以不触发 ruff S110、也不需要开豁免。

这是五个接缝里第二消费者证据最弱的一个:GovDoc 有两个形态不同的实现(进度回写与审计出口),dissect 大概率传 Null。它靠判据 (b) 过线——run 内部发生的事,调用方在 run 外面包不到。

3.4 依赖规则

八条契约,全部能写成 import-linter 或一个测试。

  1. layers,自高向低:polyloop.adapters : polyloop.stores > polyloop.session > polyloop.tools : polyloop._assembly : polyloop._stopping : polyloop._recovery : polyloop.serialization > polyloop.ports > polyloop.types
  2. independencetools_assembly_stopping_recoveryserialization 五者互不 import,不设豁免。它们之间的编织只能发生在 session 里。
  3. forbiddenpolyloop 全部子模块禁止 import dissectharnessdocagent_coregovdocchsanalyzer
  4. forbidden:除 polyloop.adapters 外的一切禁止 import polygateway
  5. forbiddenpolyloop.typespolyloop.ports 禁止 import 任何第三方包(逐个列出 pydanticjsonschemalogurupolygateway)。公共类型与接缝签名上不许出现第三方类型,否则那个包的 major 就是我们的 major。
  6. forbiddenpolyloop._stoppingpolyloop._assemblypolyloop._recovery 禁止 import asynciopathlib。这是"停止判定与预算结算收敛成一个无 IO、可纯函数测穷的模块"这条要求唯一写得成机器检查的形式,也是 §3 那道对抗审查闸能界定审查对象的前提。
  7. forbiddenpolyloop.ports 禁止 import 其余任何 polyloop 模块。layers 已覆盖,单列一条是为了让违规信息直接指向"接缝定义模块被污染了"而不是一条泛泛的分层报错。
  8. 非 import-linter:契约测试断言 import polyloop 之后 sys.modules 里没有 polygatewaypolyloop/__init__.py 只从 typesportssessiontoolsserialization 再导出;storesadapters 必须显式 import。理由是一个"顺手提供的默认模型客户端"会让每个 import polyloop 的进程都把网关连同它的 provider 目录一起拉起来——pi 的 streamFn 默认值就是这么把分层击穿的,改成必填后线上崩溃,最终落在一个全局可变默认上,三个版本才收敛。

3.5 工具 schema 与提示词里的工具段

ToolSpec.parameters普通 JSON Schema 字典,不是 pydantic 模型。校验实现是库的内部依赖,随时可换,因为公共类型是一个 dict。理由是 pi 的 typebox 0.34→1.x 那次破坏性变更,成因正是 schema 库的类型出现在公共 API 表面;而我们的公共类型层要保持第三方无关(契约 5)。代价是工具作者要手写 JSON Schema,比写一个 pydantic 模型啰嗦——这是真实的接入成本,登记为将来可加的一个电池层辅助函数(tool_spec_from_pydantic(),住在 adapters 里,把模型转成 dict),本轮只登记不实现。

工具段落进提示词的方式:RunRequest.tools 是那个(子)注册表,session 从它渲染出一段文本,插进固定槽位;渲染样式是 per-run 必填字段且进快照。收窄因此结构性地同时作用于提示词与分发——两者从同一个对象派生。工具集为空时该段渲染为空,装配结果与根本没有这个槽位逐字节相同,由契约测试断言;dissect 不注册任何工具,所以库一个字都不会写进它的实验提示词。

段序钉死为:run 级段 → 工具段 → 注入槽 → 题级段。前后关系的依据是变化频率从低到高(context.py 的模块 docstring 与 dissect 实测:run 级段里混进题级变量,跨题稳定前缀会从 98.6% 掉到 11.9%)。工具段与注入槽都是 per-run 变化,两者的相对顺序证据不足,我按"实验扫的是注入内容而不是工具集"把工具段放在前面,这一条是约定不是论证。

_assembly 收的是已渲染好的字符串,所以它不 import toolsindependence 契约成立。

3.6 恢复:多出来的两类记录

这是本轮最重要的修正,它要动 0002 冻结的记录集合,属于人类门。

为什么要加"逐步结果"这一类记录。 只写意图与副作用结果的话,resume 手上没有前几步的进历史文本与观察,重建不出第 N 步的消息序列,只能拿存储里的模型响应重跑一遍 DecisionInterpreter。这引入一条从未声明的隐性契约——解释器必须确定且永不升级——而 GovDoc 的解释器正在持续加容错规则。写成测试:跑到第 N 步存盘、替换解释器实现、resume、断言 RunResult.steps 与一口气跑完等价(R-08 要求的等价性),当前设计下这条断言必挂而库不会报错。加上这类记录之后,resume 只读不重算,_assembly 的纯函数性质保证重放等价。

为什么要加"运行开始"这一类记录。 它携带合并后的 params_snapshot(定义级 + 请求级)。resume 读回来与当前装配比对,不一致直接 raise。这道守卫 dissect 今天有(store.py:343verify_same_runrunner.py:202 在任何写入之前调它),库接管恢复责任之后不能把它弄丢。没有它,用同一个 RunKey 但换一份定义 resume,前 5 步与后 35 步会来自两个不同的模型而全程零报错——这正是 CLAUDE.md §1.4 点名的、要到统计阶段才分不清哪些行是真的那类损坏。

runresume 的前置条件。

  • run(request)read_run(key) 返回任何记录 → 抛 RunAlreadyExists。这挡住"同一个 run 先跑 train 再跑 gate、后者把前者整个盖掉"那类事故(dissect 已经因为漏掉一维踩过,runner.py:435-438 记录了这次事故)。
  • resume(request):日志为空 → 抛 InvalidRequest;身份不符 → 抛 RunIdentityMismatch"无意图有结果" → 抛 CorruptRunLog,不修复也不带着它继续;已有结束记录且原因不是 cancelled原样返回存下来的那份 RunResult;已有结束记录且原因是 cancelled → 接着跑。

结束记录已存在时返回存档结果,顺带给了 GovDoc 一个免费的"这个阶段上次到底跑完没有"探针,而且它是幂等的。cancelled 可续、其余终态不可续这条区分的理由是:被 wait_for 打断的审核点下一次调度要接着做,而一次已经 task_completed 的运行被重新驱动会重复执行提交型工具的副作用。

模型调用的重放策略是 RunRequest 上一个必填字段。 模型调用到结果落盘之间是一步之内最长的 await 窗口,崩溃概率正比于窗口长度;给它默认 never,绝大多数崩溃都落在"状态未知"这一档,恢复能力在实践中几乎不生效;给它默认 safe,会静默双计费。必填强迫这次选择被看见。dissect 传 never(保住账目连接键),GovDoc 可以传 safe

工具的重放策略仍按 0002:由 ToolSpec 声明、可省略、省略取 never、执行时快照进动作意图记录,恢复时要求记录里的快照与当前声明都说 safe 才重放。

3.7 停止判定顺序(冻结)

每次迭代按这个顺序,_stopping 是它的唯一实现:

  • A 预算准入。 已追加步数 == max_stepsstep_budget;已执行动作数 == max_executed_actionsaction_budget。两者同时命中先报 step_budget
  • B 装配上下文并度量字符数。 超过 max_prompt_charscontext_overflow,不产生任何步记录、不写任何意图记录。命中时模型还没被调用、没花钱、没有 call_id 需要对账,这是唯一"真的一步都没走"的终止。
  • C 写模型调用意图;调 ModelPort 抛出 → 记一条 call_id=None 的步 → llm_error
  • D 解释决策。 InvalidDecision → 记步、连续计数加一;达阈值 → parse_failed_repeatedly;未达阈值 → 回 A跳过完成判定,因为这一步没碰环境,环境的完成信号不可能改变)。
  • E FinalAnswer → 记步 → agent_finished
  • F 写动作意图;执行;无条件记步。 任一有效决策把连续失败计数清零。
  • G 完成判定。 status == ENV_FAILUREcompleted is Noneenv_errorcompleted is Truetask_completed
  • H 回 A。

预算结算放在下一次迭代的开头而不是本次的结尾,与 dissect 的 for step_idx in range(max_steps) 逐次对齐,且天然满足 D-03:完成判定在 G、预算准入在 A,"第 40 步恰好做完"永远走不到 A。把它挪到 D 之后是这整套顺序里唯一有明确失败场景的错法——恰好用满预算完成的运行会被记成预算耗尽,两者轨迹长度一模一样,dissect 按停机原因分层的整批数据会失真。

step_budgetaction_budget 同时命中时报前者,理由是兼容性而不是原理:dissect 的预注册判据按 'step_budget' 的占比告警。这一条低置信度,唯一的硬要求是它被固定并被断言。

3.8 停止原因(10 个取值)

task_completedagent_finishedstep_budgetaction_budgetparse_failed_repeatedlycontext_overflowenv_errorllm_errorcancelledresume_state_unknown

dissect 已有的六个逐字保留字面值task_completed / step_budget / parse_failed_repeatedly / context_overflow / env_error / llm_error),因为它们已经写进了 rollouts 表并被两条预注册判据按字符串匹配(checks_coverage.py:237:308)。改名是零收益的破坏。

max_steps 数的是追加的 StepRecord 条数(含解析失败步、模型失败步、环境故障步),正好是 dissect 今天 max_steps 的语义;max_executed_actions 数的是 status == EXECUTED 的次数,正好是 GovDoc 要的"有效步"。于是"模型反复调不存在的工具烧光步数"与"真的做了五十步没做完"在轨迹上分得开,而 dissect 的语义一个字节都没变。

3.9 取消

CancelledError 原样重抛,run 在取消时不返回 RunResultcancelled 是停止原因的一个取值,只出现在结束记录里和 resume 一个已取消运行时重建出来的结果里。结束记录那一次写的形状必须写到能照抄的程度:

fut = asyncio.ensure_future(store.finish(key, record))
try:
    await asyncio.shield(fut)
except asyncio.CancelledError:
    try:
        await asyncio.wait_for(asyncio.shield(fut), finalize_timeout_s)
    except (TimeoutError, asyncio.CancelledError):
        fut.cancel()
        logger.exception("结束记录写入超时,该运行恢复时会被判为状态未知")
    except Exception:
        logger.exception("结束记录写入失败,该运行恢复时会被判为状态未知")
    raise

裸的 await asyncio.shield(f) 在一个已经收到取消的协程里会立刻抛 CancelledError 而把写入 detach 掉,三份方案里有两份描述的正是这个错误形状。这一整段属于 §3 第三类必须对抗审查的产物,finalize_timeout_s 是 per-run 必填字段并进快照——取消延迟至多这个时长,这是一次显式的、看得见的代价。

cancelled 永不出现在返回值上这件事在类型上看不出来(两处用同一个枚举),读代码的人容易写出 if result.stop_reason is StopReason.CANCELLED 这种永假分支。只能靠 docstring 加一条契约测试守。

3.10 步记录

以 dissect 现有的十三个字段为下界,字段名与口径原样继任(我核对过 memory.py 的字段清单:step_idxraw_outputcontent_charsthinking_charsactionparse_okparse_errorobservationobservation_is_syntheticobservation_truncated_charsprompt_charscall_idstep_wall_ms)。三个口径不可改写:step_wall_ms 按整步计且刻意不与模型调用延迟同名,prompt_chars 按字符计,observation_truncated_chars 单独一列。

raw_output 保留原名与原义(进历史的文本,解析器可能已经截过),不改名成 history_text。改名会让"逐字段可比"这条硬验收要靠 dissect 侧做一次映射,而这个映射本身就是一处会漂移的地方。

新增四个带默认值的字段:tool_nametool_arguments_jsonaction_statusschema_versioncontent_charsthinking_chars 由库自己数 ModelReply 的字符,不从任何 usage 对象取——实测中转网关会用本地分词器补算并整体替换用量对象,把明细一起吃掉。

Step 是 frozen dataclass,字段全为 JSON 原语,枚举用 str-Enum 因而 asdict 之后仍是字符串。


四、否决的方案及理由

这一节直接进 design doc。

导出低阶循环,或导出单步(step(state) -> state、异步生成器、让调用方自己写 while)。 两步之间的 await 点会落在调用方的栈上,取消发生在步与步之间时库没有位置放 finally,容器租约与连接的释放没有归属。同时调用方握着终止权,"这次运行结束了"这个标记就没有确定的写入点,只能靠调用方记得调一个收尾方法,而忘记调不报错——正是那类没有失败现场的错误。除此之外它在本项目还举不出两个真实消费者:dissect 的 runner 从头到尾只要一个跑完的 Rollout,GovDoc 的介入锚点是工具边界。业界证据同向:smolagents 真的开了 step(),它的官方样例里 max_steps 变成了用户循环里的一个比较,预算与停止语义整个漏到库外;pydantic-ai 的 AgentRun.next 把私有模块的节点类型写进了公开签名。

在 run 之外再公开一个可手动驱动的会话对象(pi harness-v2 的 manual drive 形态)。 技术上成立——退出路径仍归库,pi 也证明了"automatic 与 manual 产出完全相同的持久化日志"这条断言写得出来。否掉它的理由是准入判据:两个消费者一个都举不出来,而每多导出一层就多一份永久合同和一套契约套件。pi 自己给出了代价的上界——它的低阶流式包装在整个 monorepo 里除测试外无人调用,却被新一代设计写成了"不许破坏"的硬约束。将来真要开,库内按"先写意图再执行"收敛出来的那份副作用清单就是它的天然粒度,不需要重构。

一个自己持有环境生命周期与可变历史的有状态门面。 门面一旦持有环境生命周期,dissect 就没有地方在会话关闭之前插入评分,而评分必须在关闭前完成,否则环境状态就没了、实验当场作废。门面若持有可变历史与步计数,dissect 那档"同一时刻用不同注入跑同一批题"的实验会让并发候选互相污染,而污染的表现是成绩变化不是报错。门面若把预算固化在构造期,GovDoc 三个阶段就要建三份除预算外完全相同的定义,一旦漂移没有任何机制能发现。

只暴露一个不透明门面(不提供参数视图)。 可复现性参数必须能从已装配好的库对象上读出来。dissect 的 runner 明写"组件参数从真实对象上取,不接受外部传入",因为手写传进来的快照记的是一份声明而不是事实,而不变量检查只验数据自洽、不验数据与现实一致——这类错没有任何机器兜得住。

取消定义对象,只留一个请求结构体。 它在"每次功能增长都是加一个带默认值的字段"这一点上确实最省,但它让 AgentDefinition.params_view 这个聚合点不存在,而 dissect 的 runner 要在第一个 rollout 之前拿到模型串与 scope,拿不到直接 raise。同样的问题也否掉了"把 ModelPort 移到 per-run"。

给模型调用接缝一个库不解释的不透明 payload(object 类型)来直通 per-run 绑定。 它在公共类型上开一个 Any 洞,洞里的东西永远进不了参数快照,而且契约测试对它不可见——只能断言库原样透传,断不了任何别的东西。改成 Mapping[str, str] 之后洞就没有了,代价只是适配器多一次还原。

re-export polygateway.LLMResponse 当模型接缝的返回类型。 它会让接缝定义模块运行期依赖 PolyGateway,零依赖契约要开豁免,任何只想写测试替身的下游也得装上它。更要紧的是 PolyGateway 加一个字段就等于 PolyLoop 的公共类型变了一次而没有发过版。而且它并不合身:dissect 已经登记过 LLMResponse 不透出 finish_reasoncost 字段恒为 None 要 dissect 自己按价格表算。

用 pydantic 模型做工具参数 schema。 它把 pydantic 变成公共 API 的一部分,pydantic 的 major 会同时击穿三个下游。pi 出过一次一模一样的破坏性变更(typebox 0.34→1.x),理由正是 schema 库的类型出现在公共 API 表面。

把完成判定、观察投影、上下文装配各设成一个接缝。 三个都举不出两个形态不同的真实实现。完成判定的信号源全在库内或已在动作执行的返回值上;观察投影要搬运的两个量已由决策解释与动作执行的返回结构体各自携带,再开一条能改观察的路径必然分叉(docagent-core 的文档说替换、代码做追加,pi 的三份投影已经漂移);上下文装配里真正可变的是渲染格式,而渲染格式归项目——能力位驱动的措辞调整是实验刺激的一部分,库若在内部按能力位拼自己的文本,那段文本就成了库写死的刺激并逃出项目的快照。设成接缝的代价是各白养一套契约套件加一份永久签名承诺。

把环境整个折进工具 handler(连 ActionExecutor 都不要)。 它更省,但"环境完成信号取不到算环境故障"这个判定会移进 dissect 的适配器,库无法断言那几行写对了;写错的表现是运行安静地跑到预算耗尽。同时它逼着 dissect 的代码动作变成 arguments["code"],进而改掉轨迹里 action 列的内容,而那个文件是反思模型的唯一输入界面。

库内做步级重试(模型调用失败后自己再走一步)。 库内禁止重建重试;一次调用之内的重试与换源归 PolyGateway,整次运行的重试归下游的 runner,中间那一层举不出两个消费者。docagent-core 的反例很具体:它那层重试的默认可重试异常集合漏掉了网关库自己的异常,于是在最需要它的场景下一声不吭地什么都不做,而这一点被它自己的测试记录了下来。预算口径一并定死:PolyGateway 内部换源重试不消耗任何预算,因为库数的是一次 ModelPort.call——口径必须是库自己能观测的量,否则换个后端口径就变了。

给事件流加投递保证,让它承担 GovDoc 的审计需求。 一旦有投递保证,事件流就变成持久结构,此后每加一个事件类型都要走人类门改 schema 版本——而这正是把意图日志与事件流分成两条通道想避免的。审计改由轨迹与意图日志承担:模型原文的字符数与进历史文本在步记录里各占字段,本来就不可丢。

存储端口做成可选参数,不传就不具备恢复能力。 这是用默认参数掩盖关键逻辑:不传的人不会知道自己这次运行没有恢复能力。改成必填加一个显式命名的 EphemeralRunStore,"我不要恢复"就成了一次看得见的选择。

设一个默认 no-op 的上下文压缩接缝。 零消费者。留一个接缝就得同时回答"开启压缩后哪些保证不再成立"与"轨迹里怎么留下这一步发生过压缩",而这两个答案现在没有任何真实需求去校准。


五、代价

已知弱点,有对策但对策本身有成本:

每一步四次存储写、两个耐久屏障。对 GovDoc 的 Postgres 后端是每步两次不可合并的往返,50 步的长阶段上可感知。对策是允许结果与步记录合并成一次写,但两条意图记录不能合并——合并了 0002 就没有意义了。

RunRequest 上必填字段很多(预算四项、合成观察文本、观察模板、模型重放策略、工具文档样式、取消收尾时限、三个段序列、执行器、注册表)。最小可跑的请求相当臃肿,新接入者第一印象会是"这个库很难用"。这是 dissect 的实验纪律(不得存在影响行为又读不出来的默认值)外溢到另外两个消费者身上的成本。缓解只能靠文档给一个 dataclasses.replace 的模板派生范式。

accepts / produces 两个只读标记抬高了适配器门槛,而它们守的只是一次配置错误。它可能被评价为过度工程;我保留它的理由是那次配置错误没有失败现场。

没有对策、只能接受的:

在两步之间插入调用方代码这个能力真的没有了。调用方只能在接缝实现里插入,而接缝实现拿不到这一步的 StepRecord——那是库在接缝返回之后才组装的。"看着这一步的完整记录再决定下一步"目前只能靠事件流(可丢、旁观、不能改执行)。如果 dissect 将来要做"按上一步的记录动态换注入"这类实验,这会立刻变成阻塞。

消息形态钉死为 (role, content: str),多模态输入、原生 function-calling 的 tool_call / tool_result 消息、供应商的缓存断点标记全都表达不了。放宽它是破坏性变更。GovDoc-Editor 今天生产上跑的是 Claude Agent SDK 的原生工具调用路线,它重构后若仍走原生路线,这个决定就是错的。选它的理由只有一条:上下文规模上限要在装配之后、模型调用之前判定,判定要求库能数出字符数,而完全不透明的载荷会让 context_overflow 判不出来——那是 dissect 明令不许退化成静默截断的一档。

ModelReply 是库自定义而不是 re-export,所以 PolyGateway 每透出一个新事实(最典型的是 dissect 已登记的 finish_reason 缺口),PolyLoop 都要跟着发一版。那个缺口从"等 PolyGateway 一个 PR"变成"等两个 PR"。

EventSinkEvent 类型属于 0004,所以 0003 冻结了一个参数类型还不存在的公共签名,这个接缝的契约套件在 0004 之前写不了。如果 0004 发现事件需要携带同步返回值(比如让订阅者阻塞等待),emit 的签名就得改,而那是破坏性变更。

"模型调用一律走 PolyGateway"在这一层退化成一条约定。import-linter 保证得了库内不写重试限流、保证得了 polygateway 只出现在一个模块里,保证不了某个下游的 ModelPort 实现绕开它直连。这里不假装机器守得住。

停止原因枚举会长。按只增不删的规矩加取值是兼容变更,但对下游用 match 做穷尽处理的代码是软破坏——不会 ImportError,只会走进一个没写的分支。已经能预见的增长点有三个:最终输出校验失败、发生过压缩、被干预回调要求停止。只能靠 docstring 要求下游防御性处理。pydantic-ai 的版本政策把这条明写成了下游义务,值得抄。

取消路径上如果结束记录写入本身超时或失败,该运行恢复时被判为状态未知——而进程被 kill 留下的也是同一个形态。取消与崩溃在意图日志上分不开,除非结束记录写成功了。0002 的四态表在取消这条路径上有一个消除不掉的模糊地带。

resume 之所以不必重跑解释器,靠的是我们把步记录持久化了;但 _assembly 是纯函数这一条仍要求段渲染在两次运行之间一致。段是调用方交进来的字符串,库无法断言这一点。

usage 只报库能数的事实(模型调用次数、执行动作次数、步数、字符数)。token 与花费不在其中——库内不重建遥测且 ModelReply 不带 tokenGovDoc 要从自己的 ModelPort 实现里旁路汇总。这是 G-05 的明确不满足项。

第一版的测试体量很可能超过实现本身:五套接缝契约套件,加上 round-trip 恢复、并发隔离、停止顺序、空注入逐字节等同、身份校验、取消收尾这几类。README 的阶段清单现在没有为它留位置,这笔账要在阶段规划里认下。

migrations/dissect.md 需要补登记四项成本:每个 Episode 要写一个包装类(结构化子类型不成立,我核对过 envs/protocol.py);续跑路径要从 run 改成 resume;要选一个 RunStore 并给它一个目录;模型绑定要从关键字参数还原成 Mapping[str, str]


六、还没定的东西

Q1(必须先答,否则 0003 落不了笔):要不要把 0002 的意图日志记录集合从三种扩到五种,加上"运行开始"与"逐步结果" 选项:(a) 两类都加;(b) 只加"逐步结果",运行身份校验留给调用方;(c) 都不加,接受 resume 重跑解释器且不校验身份。 我的倾向是 (a)。(c) 的失败场景已经写得出测试(换一条解释器容错规则后 resume,轨迹与一口气跑完不等价而库不报错),(b) 会丢掉 dissect 今天已经有的 verify_same_run。这项改的是 0002 冻结的内容,所以必须过门。

Q2:对 scope.md 的两处显式不满足要不要接受? 两处是上下文压缩(S-10)与最终输出的 schema 校验加有界重问(S-06)。两者归属都不变(仍在界内),但本版不实现、不设接缝、不预留字段,用的是同一条论证:凑不齐两个真实消费者的东西只登记不实现。 选项:(a) 两项都只登记;(b) 实现其中之一;(c) 都实现。 我的倾向是 (a)。附带收益是"历史只追加"在本版无条件成立、可验证,而且完成判定只剩两条互斥入口(FinalAnswer 分支与动作结果上的 completed),它们不可能同一步同时成立,所以完全不需要优先级规则。注意这是用"界外的规则"去处理两个界内事项,属于类比推理不是直接引用,你有权不接受;不接受的话我需要先回答"开启压缩后哪些保证失效"和"轨迹里怎么留下压缩痕迹",而这两个问题现在没有答案。

Q3:要不要现在就把上面第五节末尾那四项 dissect 迁移成本写进 migrations/dissect.md 选项:(a) 现在写;(b) 等实现阶段。倾向 (a)——它们是 0003 的直接产物,等实现阶段等于让写代码的人重新发现一遍。

Q4accepts / produces 两个只读兼容标记要不要加? 选项:(a) 加,run 开始时比对不符即 raise;(b) 不加,接受配置错误表现为烧满预算;(c) 改成一个新的 ActionStatus 取值,由执行器在第一步报告不兼容。 倾向 (a)。(c) 依赖实现方正确报告,而不兼容恰恰是实现方最可能没考虑的情形。(b) 的失败形态与"真的做不完"在轨迹上完全一样。

Q5:工具段落在段序里的位置。 选项:(a) run 段之后、注入槽之前;(b) 注入槽之后、题级段之前。 倾向 (a),理由是实验扫的是注入内容而不是工具集,所以工具集更稳定。这条证据不足,是约定不是论证;唯一的硬要求是它被钉死并被契约测试断言。

Q6step_budgetaction_budget 同时命中时报哪个。 选项:(a) step_budget(b) action_budget。 倾向 (a),理由是 dissect 的预注册判据按 'step_budget' 的占比告警。这也是偏好不是失败场景——两个上界同时耗尽时两个原因描述的是同一件事。

Q7run / resumeAgentDefinition 上的方法,还是模块级函数 run(definition, request) 倾向方法:调用点上"哪份定义跑的这次"一眼可见,而且它把 AgentDefinition 定位在 session 层(它持有活端口、不是持久化类型),避免了"值类型 import 运行入口"的分层倒置。这一条我可以自己定,但它是公共 API 形状,按 §2 报给你。

Q8:契约套件要不要作为可 import 的公共子路径发布? pi./session/testing)与 Anthropicrun_session_store_conformance)都发布了,形式是"可注册到任意测试框架的数据",让下游自测适配器。 选项:(a) 先留在 tests/contract/;(b) 现在就发布。 倾向 (a)。现在没有任何下游要求过它,而它是一份额外的永久公开面;将来要发布是纯增量动作。

Q9Event 类型未定导致 EventSink 的契约套件在 0004 之前写不了,接受吗? 选项:(a) 0003 冻结这个接缝存在、挂在定义上、失败语义(吞 + 计数 + 日志)与"事件流可丢、不是持久 schema"这条定位,Event 与事件集留给 0004(b) 把 EventSink 整个推到 00040003 的 AgentDefinition 暂不带这个字段。 倾向 (a)。失败语义是停止语义的一部分(观察者失败绝不能打断循环),它现在就该被冻结;而 (b) 会让 AgentDefinition 在 0004 时新增一个字段,虽然是兼容变更但会让契约套件重写一次。


七、与业界惯例的对照

一致的地方。

主循环归库、不把单步作为首选入口,这是压倒性的行业共识。Claude Agent SDK 在选型表里把想自己写 tool loop 的人明确指向更低层的 Client SDKOpenAI Agents SDK 的文档直接写不支持逐 turn 迭代,内部虽已按单步分解(NextStepFinalOutput / NextStepHandoff / NextStepRunAgain / NextStepInterruption)却不放进公共 APILangGraph 的 PregelLoop.tick 住在私有模块里;Strands 只给 hooksAgno、AutoGen 的 AgentChat 层同理。

观察走被动事件流、干预走具名回调,也是共识,而且有的框架把它写成了硬分工:pi 的 Goals 原文是"Events observe execution and cannot change it. Hooks intercept execution and can change it"。AutoGen 的 InterventionHandler 甚至不允许用 None 表达"丢弃",必须返回一个专门的 DropMessage 标记类型来消除歧义——这一点值得我们在 0004 定回调返回值语义时抄。

停止原因是分类枚举而不是布尔,且分类依据是可恢复性。Strands 把 max_tokens 明确标为不可恢复并走单独的历史修复路径。

持久化结构带显式版本、读到未知 major 直接失败。OpenAI 的 RunState 已经走到 schema 1.15,每一版都必须留一句摘要,读到不支持的版本直接抛。反面教材是 AutoGen:它的状态模型里 version 字段,仍然留下了一句"The expected state format has changed since v0.4.9"的报错——光有版本号字段不够,还得有"读到未知版本直接失败"的规矩和一个统一的校验入口。

契约套件作为新适配器的准入标准,pi 与 Anthropic 都做了,而且都做成了"不依赖某个测试框架的数据结构"。Anthropic 的存储 conformance 还明说深相等才是契约、字节相等不是(因为 Postgres JSONB 会重排键)——这条细节值得在我们的 RunStore 契约里照抄。

"先写意图再执行、结果 ID 预先分配"不是我们发明的:pi 的 harness-v2 把它写成"效果之前写一条 intent 记录,写明将要发生什么以及它会产生哪些 id;效果之后把结果作为 entry 追加,id 就是那些 id",判据是"一个 intent 被兑现,当且仅当带着它预分配 id 的 entry 存在"。pydantic-graph 的 v1 也是同一套(先 snapshot_node 记待执行节点、再执行、再改状态)。

刻意不同的地方。

不提供任何形式的单步,连第二层都不给。 pydantic-ai 与 smolagents 都给了,代价各自可见:smolagents 的样例里 max_steps 变成了用户 while 条件里的一个比较;pydantic-ai 的 AgentRun.next 返回类型直接写着 _agent_graph.AgentNode,方法公开、状态类型私有。pi 走得最彻底——先把全部副作用收敛到一个注入的效果句柄上,让方法清单同时等于崩溃点清单和单步粒度,那是一份 3410 行的设计和一套还没实现完的运行时,远超本项目当前阶段。我们放弃这个能力换来的是三条硬保证(结束标记有确定写入点、取消有地方放 finally、停止判定顺序在库内)能真正交付。

我们自己做崩溃恢复,而 pydantic-ai v2 把它整个删了、推给 Temporal 这类持久执行引擎。 维护者在 issue #3697 里直接确认"we do not have current plans to work on this"。Strands 与 AutoGen 也都没有 run 中途的恢复(Strands 的 issue #859 正是工具执行中途断掉后历史里留下悬空 tool_use 导致模型 API 直接拒绝)。我们做的理由是具体的:dissect 的一次运行是论文数据,账已经记了、钱已经花了,崩溃丢掉轨迹但留下支出正是它最不能容忍的形态;而实验室里没有 Temporal 那一层可推。

不提供任何默认装配的模型实现。 我们带一个 PolyGateway 适配器,但它必须显式构造,import polyloop 之后 sys.modules 里不能有 polygateway。pi 的 streamFn 默认值把这条教训写得很完整:一个 ?? streamSimple 造成对 provider 目录的静态 import,把约 35 个 provider 打进每个 bundle 且无法 tree-shake;改成必填后线上崩溃;最终落在一个全局可变默认上,三个版本才收敛。

公共签名上不出现任何第三方类型。 主流做法相反——OpenAI Agents 用 pydantic、pi 用 typebox、docagent-core 直接 re-export PolyGateway 的响应类型。我们不这么做的理由是 pi 已经因为 typebox 的 major 出过一次破坏性变更,而"只增不删不改名"这份承诺是我们给三个下游的、不是那些第三方给的。代价是工具作者要手写 JSON Schema。

历史严格只追加,且库不提供任何改写入口。 smolagents 明确鼓励反向做法("Change the memory as you please",官方教程里调用方自己 append TaskStep、自己删两步以前的截图省 token)。我们禁止它,因为第 n 步的提示词正好是第 n-1 步加一段尾巴,这是 ReAct 吃得到供应商 prompt cache 的全部原因,而 dissect 实测过:run 级段里混进题级变量,跨题稳定前缀会从 98.6% 掉到 11.9%,多付的幅度随注入规模变化,于是缓存伪影会精确地伪装成因子效应。

停止判定顺序与"恰好用满预算完成"的结算是公共契约,配契约测试,而不是实现细节。 Claude Code 的 CHANGELOG 里有三条独立的、发布很久之后才发现的 bug 全部落在这一区:PreToolUse hook 超时曾被当成用户拒绝上报给模型、UserPromptSubmit 超时曾把整个 query 终结成错误、streaming 消息恰好在某个 turn 最后一次迭代到达时曾被并进那个正在结束的 turn 然后丢失。这类错误没有失败现场,把它留在实现里等于永远不会被发现。

取消是标准的 asyncio 取消,库不提供自己的取消令牌或 cancel() 方法。 LangGraph 新加的 RunControl.request_drain() 说明"优雅停机"和"取消"在实践中会分化成两个语义,但取消状态的权威在业务数据库(多租户、有租约),库没有资格也没有能力持有它;自己提供一个取消令牌就多了一处会漂移的状态。