From c9f10973ffd15ce9f0c0fba829c5bda5e37df113 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 10 Aug 2026 00:01:24 -0400 Subject: [PATCH] =?UTF-8?q?feat(types,ports):=20=E8=90=BD=E6=88=90?= =?UTF-8?q?=E5=85=AC=E5=85=B1=E5=80=BC=E7=B1=BB=E5=9E=8B=E4=B8=8E=E4=BA=94?= =?UTF-8?q?=E4=B8=AA=E6=8E=A5=E7=BC=9D=E7=9A=84=20Protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 ⑤ 阶段第一块,按 0006 与 0007 落地。从依赖图的底部开始写: 这两个模块定错了上面全得跟着改。 types 装 22 个公共值类型、枚举与持久化记录。StepCompleted 带一条构造期校验—— result_id 为空当且仅当 action_outcome 也为空,两者一空一有值是结构上说不通的, 放行它等于把「有动作结果却没有意图」这档日志损坏当成合法完整步接受。 校验用显式异常不用 assert,因为 -O 会把断言整条剥掉,下游用 -O 跑的那天守卫就静默消失。 ports 装五个 Protocol 与四个只在调用往返之间存在的壳。每个接缝多一个同步 parameters(),参数快照靠它聚合——原先没有这个方法,那个快照根本调不动。 RunLog 五个字段全带默认值,所以「读一个从没写过的运行标识返回空日志」就是 RunLog()。 零业务假设那条测试第二次抓到我:StopReason 的 docstring 里写了下游的实验词汇, 另有四处直接点名下游项目。库源码点名下游本身就是 §1.1 禁的——三个下游领域互不相交, 一个业务词进来就是替其中一个做了另外两个不需要的假设。已改成中性说法加 design doc 指向。 import-linter 从此不再是平凡的绿:Analyzed 15 files, 7 dependencies, ports → types 那几条边真的被检查了一遍,之前是 0 dependencies。 Co-Authored-By: Claude Opus 5 (1M context) --- src/polyloop/ports/__init__.py | 270 ++++++++++++++++++++- src/polyloop/types/__init__.py | 419 +++++++++++++++++++++++++++++++++ 2 files changed, 685 insertions(+), 4 deletions(-) diff --git a/src/polyloop/ports/__init__.py b/src/polyloop/ports/__init__.py index 06b2101..ee70dc4 100644 --- a/src/polyloop/ports/__init__.py +++ b/src/polyloop/ports/__init__.py @@ -1,7 +1,269 @@ -"""全部 Protocol 与它们的入参 / 返回结构体。 +"""五个 Protocol,以及只在一次调用往返之间存在的入参 / 返回壳。 -用 `typing.Protocol` 而不是抽象基类:下游的对象往往已经是它自己的类、还要同时满足项目 -自己更宽的接口,只有结构化子类型能让同一个对象同时满足库的窄视图和项目的宽视图。 +读者是写适配器的人和 `tests/contract/`。用 `typing.Protocol` 而不是抽象基类:下游的对象 +往往已经是它自己的类、还要同时满足项目自己更宽的接口,只有结构化子类型能让同一个对象同时 +满足库的窄视图和项目的宽视图。 -**签名上不许出现第三方类型**——出现了,那个包的 major 就是我们的 major。 +**签名上不许出现第三方类型**——出现了,那个包的 major 就是我们的 major。这一条由 +`tests/unit/test_import_purity.py` 断言。 + +**什么住这里、什么住 `types`,判据是「它是不是一个值」**:值类型与持久化记录住 `types`, +只为一次调用打包入参或返回的壳住这里。判据不能写成「会不会被写进日志」——消息不出现在任何 +一条日志记录里,照那条会判进这里,而上下文住 `types` 且字段就是消息序列,于是 `types` 反向 +依赖本模块,分层契约当场违规。展开见 +`research-wiki/design/0006-public-names-and-signatures.md` 决策四。 + +每个方法的行为契约在 `research-wiki/design/0007-seam-behaviour.md`,机器形式在 +`tests/contract/`。这里的 docstring 只写「读这段代码的人不知道就会写错什么」。 """ + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from polyloop.types import ( + ActionOutcome, + Intent, + Message, + ModelCallResult, + ModelReply, + RunFinished, + RunStarted, + StepCompleted, +) + +# --------------------------------------------------------------------------- +# 只在一次调用往返之间存在的壳 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ModelCall: + """交给模型调用接缝的一次调用。""" + + messages: tuple[Message, ...] + #: **本次运行内的第几次模型调用**,从 0 递增。它不是「同一个目标的第几次独立重做」—— + #: 后者是项目自己的 metadata,库不理解、原样透传在 `binding` 里。两者撞过名,这里换个 + #: 词就是为了不再撞。 + call_index: int + run_id: str + #: 库预分配的结果 ID。 + result_id: str + #: 项目自己的标识,库不解释,原样透传。用字符串映射而不是不透明对象,是为了它能逐字段 + #: 进参数快照——不透明对象是公共签名上一个永久的洞,洞里的东西快照看不见、契约测试也 + #: 看不见。 + binding: Mapping[str, str] + + +@dataclass(frozen=True, slots=True) +class ToolCall: + """一次工具调用的名字与参数。""" + + name: str + arguments: Mapping[str, object] + + +@dataclass(frozen=True, slots=True) +class Action: + """决策解释出的动作。""" + + #: 这一步的动作在轨迹里长什么样,由实现方决定内容,库原样填进步记录的 `action`。 + #: **不叫 `trace`**:那个词通常指整条执行轨迹,用它指单步的一个字符串会和「逐步轨迹」撞。 + text: str + #: 工具型动作才有;代码执行型的为 None。 + tool_call: ToolCall | None + + +@dataclass(frozen=True, slots=True) +class FinalAnswer: + """模型给出的最终回答。这一支不碰环境。""" + + text: str + + +@dataclass(frozen=True, slots=True) +class InvalidDecision: + """解释不出有效动作。""" + + #: **这段文本就是回喂给模型的那段观察**,不是从一个固定串里取。不同的解析失败要给不同 + #: 的对症说明——压成一句会改掉模型收到的纠错信息,它的纠错行为也就跟着变。 + explanation: str + + +#: 一次模型回复的三种解释结果。 +Decision = Action | FinalAnswer | InvalidDecision + + +@dataclass(frozen=True, slots=True) +class ParsedReply: + """决策解释接缝的返回。""" + + #: 这一步回填进历史的那段 assistant 文本。**它可以与模型原文不同**——解释器有权改写它, + #: 比如把第一个代码围栏之后的内容整段丢掉(模型常在代码块后面编造执行结果)。库这边只有 + #: 模型原文,照它回填,模型下一轮会看见自己编的那段。 + history_text: str + decision: Decision + + +@dataclass(frozen=True, slots=True) +class RunLog: + """一份读回来的完整日志。恢复判定读它。 + + 五个字段全部有默认值,所以「读一个从没写过的运行标识」返回的空日志就是 `RunLog()`。 + """ + + started: RunStarted | None = None + intents: tuple[Intent, ...] = () + model_results: tuple[ModelCallResult, ...] = () + steps: tuple[StepCompleted, ...] = () + finished: RunFinished | None = None + + +@dataclass(frozen=True, slots=True) +class Event: + """从事件出口发出去的一条事件。 + + **字段还没定。** 事件集与具名回调清单要独立成一份 design doc;在那之前这个类型只有名字, + `EventSink.emit` 的签名不会因为它定下来而改变。 + """ + + +# --------------------------------------------------------------------------- +# 五个接缝 +# --------------------------------------------------------------------------- + + +@runtime_checkable +class ModelClient(Protocol): + """模型调用接缝。挂在定义上。 + + **签名里不出现重试次数、退避时长、限流配额**——出现即意味着库在治理一次模型调用,而那 + 归下面那一层的共用库(`CLAUDE.md` §1.5)。 + + 失败以异常表达,不以「返回一个内容为空的正常回复」表达:后者让库没有任何办法把基础设施 + 故障和「模型真的回了空字符串」分开,而这两者在分析里属于完全不同的类别。 + """ + + async def call(self, call: ModelCall) -> ModelReply: ... + + def parameters(self) -> Mapping[str, str]: + """上报这个实现的可复现参数,供参数快照聚合。 + + **同步、不许做 I/O。** 快照要能在装配之后立刻算出来,一个会发网络请求的实现会让 + 「构造廉价」这条承诺失效,也会让快照的取值依赖当时网络通不通。 + """ + ... + + +@runtime_checkable +class DecisionParser(Protocol): + """决策解释接缝。挂在定义上。 + + **`parse` 是同步的**,因为解释一次模型回复是纯计算、没有等待点。写成协程会诱导实现方 + 在里面做 I/O,而这个接缝一旦做起 I/O,「恢复时重新解释被打断的那一步」就不再安全。 + + **不许抛异常**:对任何输入都要返回一个 `ParsedReply`,解释不出来就走 `InvalidDecision` + 那一支。「无法解释」本来就是正常路径的一部分——模型输出不合格式是每天都在发生的事。 + 真抛了库也不接管:接住就得给它编一个停止原因,而编出来的原因会把「解释器有 bug」伪装成 + 「这次运行以某某原因结束」,然后进下游的统计。 + """ + + def parse(self, reply: ModelReply) -> ParsedReply: ... + + def parameters(self) -> Mapping[str, str]: ... + + +@runtime_checkable +class ActionExecutor(Protocol): + """动作执行接缝。**挂在请求上**,因为它每次运行都不同。 + + **动作本身报错算「已执行」**,不算环境故障:代码抛异常、命令返回非零都是正常观察,要 + 原样回喂让模型自己纠正。判成环境故障会让一次运行在模型本来能自我纠正的地方直接终止, + 而轨迹上看不出它本可以继续。分界线是环境还能不能接着服务。 + """ + + async def execute(self, action: Action) -> ActionOutcome: ... + + def parameters(self) -> Mapping[str, str]: + """上报这个实现的可复现参数。 + + **这个接缝也必须上报,理由和别的一样硬。** 它可能是一个已经开好的会话,而会话是不是 + 有状态会改变跨步语义——换一个会话续跑而快照不比对,前几步的副作用留在旧会话里、 + 后几步在新会话上执行,全程零报错。 + """ + ... + + +@runtime_checkable +class RunStore(Protocol): + """存储接缝。挂在定义上。 + + **端口不持有「当前运行」的隐式状态**:运行标识住在每一条记录里。一个有隐式当前运行的 + 实现会在并发下把 A 的意图写进 B 的日志,而那种错在单线程测试里永远不出现。 + + **写入粒度是契约的一部分**:两条意图各自单独落地;动作结果与步记录由 `write_step_completed` + 一次原子落地,要么都可见、要么都不可见。实现还必须保证**前缀持久性**——第 k 次写入被确认 + 持久时,第 1 到 k-1 次也已经持久。 + + 不设「这个 ID 的结果存在吗」这类存在性查询:它可以由 `read_log` 推出来,端口少一个方法 + 就是少一份永久合同。 + """ + + async def write_run_started(self, record: RunStarted) -> None: ... + + async def write_intent(self, record: Intent) -> None: ... + + async def write_model_call_result(self, record: ModelCallResult) -> None: ... + + async def write_step_completed(self, record: StepCompleted) -> None: ... + + async def read_log(self, run_id: str) -> RunLog: + """读回整份日志。 + + **读一个从没写过的运行标识时返回空日志,不抛异常。** 驱动入口开工前要判断「这个标识 + 是不是已经有日志了」,靠的就是这条。抛异常的话那个判断就得写成捕获异常,而用捕获 + 异常做流程控制会把真正的存储故障一起吞掉——于是「存储连不上」会被读成「这是一次全新 + 的运行」,然后覆盖式地重跑一遍。 + + 它也是「每个方法只收一个记录对象」那条规则的例外:读的时候还没有记录对象可传。 + """ + ... + + async def write_run_finished(self, record: RunFinished) -> None: ... + + def parameters(self) -> Mapping[str, str]: ... + + +@runtime_checkable +class EventSink(Protocol): + """事件出口。挂在定义上。 + + **允许抛异常。** 投递失败由库捕获、记日志、把失败计数加一,然后继续跑——事件是观察通道, + 不是控制通道,一次运行不该因为进度回写的数据库连不上就终止。所以一个在后端不可用时抛 + 异常的实现是合规的。 + + 库**不会**把投递失败转成一条事件从同一个出口再发一次:那会自我喂食,一个持续失败的出口 + 会让失败处理路径变成递归。 + """ + + async def emit(self, event: Event) -> None: ... + + def parameters(self) -> Mapping[str, str]: ... + + +__all__ = [ + "Action", + "ActionExecutor", + "Decision", + "DecisionParser", + "Event", + "EventSink", + "FinalAnswer", + "InvalidDecision", + "ModelCall", + "ModelClient", + "ParsedReply", + "RunLog", + "RunStore", + "ToolCall", +] diff --git a/src/polyloop/types/__init__.py b/src/polyloop/types/__init__.py index 42ec1a2..6678ef3 100644 --- a/src/polyloop/types/__init__.py +++ b/src/polyloop/types/__init__.py @@ -5,4 +5,423 @@ **这个模块的字段只增不删不改名,新增字段必带默认值**,持久化结构的 schema 变更走显式 版本。理由见 `CLAUDE.md` §1.3 与 §1.4。 + +**凡是被这里的结构引用的类型,也必须住在这里。** 这个模块是依赖图的汇点,所有箭头指向它、 +不许从它出去;一个住在 `ports` 的字段类型会让整条分层契约当场违规。判据见 +`research-wiki/design/0006-public-names-and-signatures.md` 决策四。 + +字段各自的口径不在这里复述,见 `research-wiki/design/0004` 决策四与 `0005` 决策一、三。 """ + +from collections.abc import Mapping +from dataclasses import dataclass +from enum import StrEnum + +#: 持久化结构的当前 schema 版本。 +#: +#: 它只作为**构造**时的默认值:库写一条新记录时不必每处手填。反序列化是另一回事—— +#: `polyloop.serialization` 读到一份没有版本字段的载荷时直接失败,不走这个默认值。 +#: 两者分开的理由是它们回答的问题不同:构造时「当前版本是多少」库自己知道;读取时 +#: 「这条记录是哪个版本写的」只有载荷知道,靠默认值补齐会把「这是旧版本」和「这条没写 +#: 版本」压成同一个答案。 +CURRENT_SCHEMA_VERSION = 1 + + +# --------------------------------------------------------------------------- +# 消息与上下文 +# --------------------------------------------------------------------------- + + +class Role(StrEnum): + """一条消息的角色。 + + 第一版三个取值。**观察以 `USER` 回填进历史**,这是下游今天的做法;模型 API 原生的 + 工具调用与工具结果消息将来靠加取值承载,加取值是兼容变更。 + """ + + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + + +@dataclass(frozen=True, slots=True) +class TextBlock: + """一段文本内容。它的规模度量是准确的字符数。""" + + text: str + + +#: 消息内容的一个块。 +#: +#: 现在只有一个成员,看起来多余。它不是预留结构而是选对类型:将来加图片块时,把它变成 +#: 联合类型对逐块处理的代码是兼容变更,而把 `Message.content` 从 `str` 改成联合类型是 +#: 破坏性变更。 +ContentBlock = TextBlock + + +@dataclass(frozen=True, slots=True) +class Message: + """一条消息:一个角色加一个内容块序列。""" + + role: Role + content: tuple[ContentBlock, ...] + + +@dataclass(frozen=True, slots=True) +class Context: + """已渲染好的消息序列,按变化频率分两段。 + + 段的顺序不是风格:模型供应商按前缀缓存计费,把逐次变化的东西排到前面会让缓存静默失效, + 而多付的幅度随注入内容的规模变化——于是缓存伪影会精确地伪装成下游的实验效应。 + """ + + #: 这次运行从头到尾不变的段:角色说明、示例演示、能力描述。 + run_level: tuple[Message, ...] + #: 这一个目标特有的段:题面、当前任务描述。 + goal_level: tuple[Message, ...] + + +@dataclass(frozen=True, slots=True) +class Injection: + """一条要贴进上下文的 Skill 条目。 + + 库只负责贴和记录贴了什么,不负责生成、评测、挑选。 + """ + + #: 这条条目的标识,原样进轨迹。正文不进快照,只有它进——所以「这次贴了哪几条」事后 + #: 查得到,查不到的只是正文。 + entry_id: str + content: str + + +# --------------------------------------------------------------------------- +# 模型回复 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class ModelReply: + """一次模型调用的返回。""" + + #: 与账目之间的连接键。**可以是 None,绝不能是空串**——空串是个「看起来合法」的键, + #: 连表时静默匹配不上,而 None 至少能被显式筛出来。它为 None 的合法含义只有一个: + #: 调用在记账之前就失败了。 + call_id: str | None + #: 模型的可见回复。库自己数它的字符数,不从任何用量对象取。 + content: str + #: 模型的推理段。同上。 + thinking: str + + +# --------------------------------------------------------------------------- +# 动作结果 +# --------------------------------------------------------------------------- + + +class ActionStatus(StrEnum): + """一次动作结算的状态。 + + 触发条件定在 `research-wiki/design/0007-seam-behaviour.md` 决策一,判据是**环境还能不能 + 接着服务**:动作本身报错(代码抛异常、命令返回非零)算 `EXECUTED`,那是正常观察,要 + 原样回喂让模型自己纠正;只有环境自己坏了才是 `ENV_ERROR`。 + """ + + EXECUTED = "executed" + NOT_EXECUTED = "not_executed" + ENV_ERROR = "env_error" + + +@dataclass(frozen=True, slots=True) +class ActionOutcome: + """动作执行接缝的返回。""" + + status: ActionStatus + #: 回填进历史的那段观察文本,不是环境返回的原文。 + observation: str + observation_is_synthetic: bool + #: 环境说目标达成了。**这不是「这次运行结束了没有」**——那个问题的答案是停止原因。 + #: 名字里带 `env_reported` 是因为另一条完成通路(工具注册表上的完成标记)是 agent + #: 自报的,两者可信度不同,混起来等于让 agent 单方面宣布自己成功。 + env_reported_completion: bool + observation_truncated_chars: int + + +# --------------------------------------------------------------------------- +# 重放策略与预算 +# --------------------------------------------------------------------------- + + +class ReplayPolicy(StrEnum): + """一个工具对「我幂等吗」这个问题的回答。 + + **「重放」指的是恢复时把那个动作再执行一次**,不是把上次的结果填回去——上次的结果正是 + 那个「状态未知」里未知的东西,它可能根本没被写下来。 + """ + + #: 有不可重复的副作用(写文件、跑 shell、调外部 API)。状态未知时绝不重放。 + NEVER = "never" + #: 幂等,重复执行一次也无害(读文件、grep)。 + SAFE = "safe" + + +@dataclass(frozen=True, slots=True) +class Budget: + """一次运行的四个上限,各项必填无默认。 + + 前两个是**计数器**,各自耗尽时撞出两个不同的停止原因;后两个是**阈值**。四个都是调用方 + 按次设定的上限,所以住在同一个类型里。 + """ + + #: 追加进轨迹的步记录条数上限。继任下游现有字段名。 + max_steps: int + #: 动作执行接缝返回「已执行」的次数上限。无效的工具调用不计入这个数,但计入步数—— + #: 两个计数混成一个就防不住「模型一直调用不存在的工具」这种不收敛。 + max_actions: int + #: 连续解析不出有效决策的次数上限。继任下游现有字段名。 + max_consecutive_parse_failures: int + #: 单步装配出的提示词字符数上限。继任下游现有字段名,与步记录的 `prompt_chars` 同源。 + max_prompt_chars: int + + +# --------------------------------------------------------------------------- +# 停止原因 +# --------------------------------------------------------------------------- + + +class StopReason(StrEnum): + """一次运行为什么停下来。 + + **前六个取值逐字继任某个下游现有的枚举,一个字母都不改。** 它们已经写进那边的数据表, + 并被一批事先定死、事后不许改的统计规则按字符串匹配——改一个字母,那些规则会静默查到 + 零行。哪个下游、为什么不能改,见 `research-wiki/design/0004` 决策二。 + + `TASK_COMPLETED` 与 `AGENT_FINISHED` 的区别要分清:前者是动作执行之后目标达成(环境 + 报告完成,或执行的是被标了完成标记的工具),后者是模型给出最终回答、那时环境根本没被 + 碰过。两者可信度完全不同,一个有环境侧证据,一个是 agent 自报。 + """ + + TASK_COMPLETED = "task_completed" + STEP_BUDGET = "step_budget" + PARSE_FAILED_REPEATEDLY = "parse_failed_repeatedly" + CONTEXT_OVERFLOW = "context_overflow" + ENV_ERROR = "env_error" + LLM_ERROR = "llm_error" + AGENT_FINISHED = "agent_finished" + ACTION_BUDGET = "action_budget" + CANCELLED = "cancelled" + RESUME_STATE_UNKNOWN = "resume_state_unknown" + + +# --------------------------------------------------------------------------- +# 步记录与运行结果 +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class StepRecord: + """逐步轨迹的一步。 + + **前十三个字段的名字逐字继任某个下游现有的结构。** 那边的迁移验收标准是「轨迹与迁移前 + 逐字段可比」,改名就要在那边做一次字段映射,而那个映射本身是一处会漂移的地方。 + + **名字继任不等于口径继任**:`observation` 存的是回填进历史的那段文本,不是环境返回的 + 原文(`research-wiki/design/0005` 决策三)。 + + 后五个新增字段全部带默认值,所以一条只有那十三个字段的旧轨迹能被直接构造成 `StepRecord`。 + **这条路不经过 `polyloop.serialization`**——那个模块读到没有版本字段的载荷一律直接失败。 + 读本库写的记录走 `serialization`,读迁移前的历史文件由下游自己构造。 + """ + + step_idx: int + #: 解析之前的模型输出,解析器可能已经截断过。它正是回填进历史的那段 assistant 文本。 + raw_output: str + content_chars: int + thinking_chars: int + #: 这一步的动作在轨迹里长什么样,由决策解释接缝决定内容,库原样填。 + action: str | None + parse_ok: bool + #: 无效决策时回喂给模型的那段文本。 + parse_error: str | None + observation: str + observation_is_synthetic: bool + observation_truncated_chars: int + prompt_chars: int + #: 与账目之间的连接键,可为 None,**绝不为空串**。 + call_id: str | None + #: 模型 + 解释 + 执行的整步墙钟毫秒。刻意不与模型调用延迟同名——同名不同义的两列早晚 + #: 会被分析时读混,而读混的后果是把环境的慢算到模型头上。 + step_wall_ms: int + tool_name: str | None = None + #: 序列化之后的工具参数。 + tool_arguments: str | None = None + action_status: ActionStatus | None = None + env_reported_completion: bool = False + schema_version: int = CURRENT_SCHEMA_VERSION + + +@dataclass(frozen=True, slots=True) +class RunResult: + """一次运行的结果。""" + + run_id: str + stop_reason: StopReason + final_answer: str | None + steps: tuple[StepRecord, ...] + schema_version: int = CURRENT_SCHEMA_VERSION + #: 事件投递失败的次数。放在返回值上而不是只记日志,因为日志没人看。 + event_delivery_failures: int = 0 + + +@dataclass(frozen=True, slots=True) +class SyntheticObservations: + """库自己合成、回填给模型看的那几段观察。 + + 它们挂在定义上而不是请求上,因为跨运行不变,而且属于「模型看得见的东西」——那种东西 + 必须能进参数快照。**不做成模板或可格式化字符串**:一旦允许按情况拼接,库就在替调用方 + 决定模型看见什么。 + """ + + #: 工具不存在或参数不合法时喂回模型的那段。 + action_rejected: str + #: 环境故障时喂回模型的那段。 + env_failed: str + #: 模型调用失败那一步喂回模型的那段。这一步没有决策也没有动作结果,库手上没有别的来源。 + model_call_failed: str + + +# --------------------------------------------------------------------------- +# 日志记录 +# --------------------------------------------------------------------------- + + +class IntentKind(StrEnum): + """一条意图记的是哪一种执行。""" + + MODEL_CALL = "model_call" + ACTION = "action" + + +@dataclass(frozen=True, slots=True) +class RunStarted: + """一次运行开始了。 + + 它携带合并后的参数快照(定义级 + 请求级)。续跑时读回来与当前装配现算的快照逐字段比对, + 任何一项不一致直接报错——没有它,用同一个运行标识换一份定义续跑,前几步与后几步会来自 + 两个不同的配置而全程零报错。 + """ + + run_id: str + parameter_snapshot: Mapping[str, str] + schema_version: int = CURRENT_SCHEMA_VERSION + + +@dataclass(frozen=True, slots=True) +class Intent: + """要做一件有副作用的事了。 + + **两种意图合成一个类型,用 `kind` 区分**,而不是两个类。它们字段完全相同,分成两个类 + 之后恢复逻辑要把同一段「有意图没结果」的判定写两遍,而那段判定是高危代码——同一个判断 + 写在两处,改的时候必然有一处漏掉。代价是类型检查器不再帮忙区分两种意图,这个补在 + `tests/contract/` 里。 + """ + + run_id: str + kind: IntentKind + #: 这一步的模型调用序号。动作意图也带同一个值——一步之内只有一次模型调用,两条意图 + #: 属于同一步。 + call_index: int + #: 预分配的结果 ID。恢复时按它精确地问「这个 ID 的结果条目在不在」,而不是模糊匹配。 + result_id: str + replay_policy: ReplayPolicy + + +@dataclass(frozen=True, slots=True) +class ModelCallResult: + """一次模型调用结算了。 + + `reply` 与 `failure` 恰好一个有值。**失败也必须落一条记录**:只写步不写结果的话,进程 + 在写完步、还没写运行结束时崩溃,恢复读到「意图有、结果无」会判为状态未知走重放策略, + 而这次调用的状态一点都不未知——它明确地失败过,失败这件事就记在同一份日志的步记录里。 + """ + + run_id: str + result_id: str + reply: ModelReply | None + #: 一段说明文本,不是异常对象——异常对象没法可靠地序列化成任何一种持久形态,而恢复 + #: 只需要知道「失败过」以及失败的大致形态。 + failure: str | None + + +@dataclass(frozen=True, slots=True) +class StepCompleted: + """一步走完了:动作结果与步记录一次原子落地。 + + 不原子的话,崩在两者之间会让那一步的历史文本永远丢失,而恢复判定会把它读成「执行完了, + 跳过」——恢复出来的消息序列比不中断跑完时少一轮,后面每一步都跟着偏。 + + **不变量:`result_id` 为空当且仅当 `action_outcome` 也为空。** 两者一空一有值是结构上 + 说不通的——`action_outcome` 有值意味着动作真的执行过,而执行之前必定写过动作意图、必定 + 有预分配的 ID。少了这个不变量,一条「有动作结果却没有意图」的记录会被当成合法的完整步 + 接受,而它正是四态表里那一档日志损坏。 + """ + + run_id: str + #: 动作意图预分配的那个 ID;这一步没有动作时为 None(解析失败、模型调用失败、最终回答 + #: 三档都在写动作意图之前就记步了)。 + result_id: str | None + action_outcome: ActionOutcome | None + step: StepRecord + + def __post_init__(self) -> None: + """构造期校验那条不变量。 + + 用显式异常而不是 `assert`:`python -O` 会把断言整条移除,而下游用 `-O` 跑的那天, + 这条守卫就静默消失了(`CLAUDE.md` §6)。 + """ + if (self.result_id is None) != (self.action_outcome is None): + raise ValueError( + "result_id 与 action_outcome 必须同时为空或同时有值:" + f"result_id={self.result_id!r}, action_outcome={self.action_outcome!r}" + ) + + +@dataclass(frozen=True, slots=True) +class RunFinished: + """一次运行结束了。 + + 这个标记由库在把结果交给调用方**之前**写下。让项目自己落盘的话,「跑完了、库返回了、 + 项目存的时候崩了」这种情况下,重启后日志显示最后一步有结果、没有结束标记,而项目那边 + 什么都没有——续跑会重复执行最后一步的副作用,不续跑就丢掉一次已经花完钱的运行。歧义来自 + 结果跨了两个存储。 + + 它只带结果不另带停止原因,因为 `RunResult` 里已经有了;两处放同一个值,迟早有一处被改。 + """ + + run_id: str + result: RunResult + + +__all__ = [ + "CURRENT_SCHEMA_VERSION", + "ActionOutcome", + "ActionStatus", + "Budget", + "ContentBlock", + "Context", + "Injection", + "Intent", + "IntentKind", + "Message", + "ModelCallResult", + "ModelReply", + "ReplayPolicy", + "Role", + "RunFinished", + "RunResult", + "RunStarted", + "StepCompleted", + "StepRecord", + "StopReason", + "SyntheticObservations", + "TextBlock", +]