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),有测试守着。
This commit is contained in:
@@ -104,7 +104,7 @@ class ResumeAction(StrEnum):
|
||||
FINISH_MODEL_CALL_FAILED = "finish_model_call_failed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ResumePlan:
|
||||
"""从一份日志读出来的续跑计划。"""
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from dataclasses import dataclass, replace
|
||||
from polyloop.types import ActionOutcome, ActionStatus, Budget, StopReason
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunCounters:
|
||||
"""一次运行走到此刻的三个计数。
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ from polyloop.types import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ModelCall:
|
||||
"""交给模型调用接缝的一次调用。"""
|
||||
|
||||
@@ -55,7 +55,7 @@ class ModelCall:
|
||||
binding: Mapping[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ToolCall:
|
||||
"""一次工具调用的名字与参数。"""
|
||||
|
||||
@@ -63,7 +63,7 @@ class ToolCall:
|
||||
arguments: Mapping[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Action:
|
||||
"""决策解释出的动作。"""
|
||||
|
||||
@@ -74,14 +74,14 @@ class Action:
|
||||
tool_call: ToolCall | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FinalAnswer:
|
||||
"""模型给出的最终回答。这一支不碰环境。"""
|
||||
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InvalidDecision:
|
||||
"""解释不出有效动作。"""
|
||||
|
||||
@@ -94,7 +94,7 @@ class InvalidDecision:
|
||||
Decision = Action | FinalAnswer | InvalidDecision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ParsedReply:
|
||||
"""决策解释接缝的返回。"""
|
||||
|
||||
@@ -105,7 +105,7 @@ class ParsedReply:
|
||||
decision: Decision
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunLog:
|
||||
"""一份读回来的完整日志。恢复判定读它。
|
||||
|
||||
@@ -119,7 +119,7 @@ class RunLog:
|
||||
finished: RunFinished | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Event:
|
||||
"""从事件出口发出去的一条事件。
|
||||
|
||||
|
||||
@@ -11,19 +11,18 @@
|
||||
注册表是**不可变值对象**,取子集返回新实例,不是进程级单例:同一进程里可能同时持有多份
|
||||
不同的窄集合(`research-wiki/design/0003-public-api-shape.md` 决策二)。
|
||||
|
||||
**`executor()` 还没落地。** `0006` 定的 `ToolSpec` 五个字段里没有一处装工具本身的实现,
|
||||
所以注册表拿到一次合法调用之后无处分发。这个洞由
|
||||
`research-wiki/design/0008-tool-handlers.md` 处理,它要过 `CLAUDE.md` §2 那道人类门;在它被
|
||||
确认之前这个方法不写,也不写一个「先占位、以后再改」的版本——那种版本会让下游以为分发
|
||||
已经能用了。
|
||||
工具本身的实现挂在 `ToolSpec.handler` 上,由 `executor()` 派生出的分发器调用。它为什么挂在
|
||||
规格上而不是另收一份「名字到实现」的映射、为什么可以为空、以及分发器怎么填动作结果的五个
|
||||
字段,见 `research-wiki/design/0008-tool-handlers.md`。
|
||||
"""
|
||||
|
||||
from collections.abc import Collection, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from polyloop.ports import ToolCall
|
||||
from polyloop.types import ReplayPolicy
|
||||
from polyloop.ports import Action, ToolCall
|
||||
from polyloop.types import ActionOutcome, ActionStatus, ReplayPolicy
|
||||
|
||||
|
||||
class ToolValidationError(ValueError):
|
||||
@@ -40,6 +39,35 @@ class ToolValidationError(ValueError):
|
||||
"""
|
||||
|
||||
|
||||
class ToolEnvironmentError(Exception):
|
||||
"""工具实现用它说「环境坏了」,不是「这次调用出错了」。
|
||||
|
||||
**只有这一种异常会被派生分发器判成环境故障**,别的异常一律算「已执行」加一条正常观察。
|
||||
分界线是环境还能不能接着服务:连不上后端是这一个,参数解析抛 `ValueError` 不是。
|
||||
|
||||
没有这个口子的话,派生分发器永远产不出环境故障,于是后端挂掉时模型会一遍遍重试、把预算
|
||||
烧光,而轨迹上表现成「预算耗尽」(`research-wiki/design/0008-tool-handlers.md` 决策四)。
|
||||
"""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolHandler(Protocol):
|
||||
"""一个工具的实现:收参数,返回一段给模型看的观察文本。
|
||||
|
||||
**是协程。** 已知的工具全都在做 I/O。允许同步实现就要在分发处判断返回值是不是可等待的,
|
||||
而那种隐式判断是 `CLAUDE.md` §6 明确不要的。
|
||||
|
||||
**只收参数,不收整个调用对象。** 实现知道自己叫什么;把整个调用递进去,实现就有机会按
|
||||
名字分支,那正好把「一个规格一个实现」这条结构拆掉。
|
||||
|
||||
**返回一段文本,不返回 `ActionOutcome`。** 返回完整结果的话,实现可以把环境侧完成信号
|
||||
填成真——于是一个 agent 侧的工具伪造出了一条环境侧证据,而两条完成通路的区分正是为了不让
|
||||
这件事发生。状态、完成位、截断计数由分发器填。
|
||||
"""
|
||||
|
||||
async def __call__(self, arguments: Mapping[str, object]) -> str: ...
|
||||
|
||||
|
||||
def _frozen(value: object) -> object:
|
||||
"""把一份 JSON Schema 逐层变成改不动的形状:映射变只读视图,列表变元组。
|
||||
|
||||
@@ -67,7 +95,7 @@ def _plain(value: object) -> object:
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ToolSpec:
|
||||
"""一个工具的全部声明。
|
||||
|
||||
@@ -91,6 +119,15 @@ class ToolSpec:
|
||||
#: 这一条是 agent 自报(它调了一个提交型工具来宣布做完,环境状态一点没变),那一条是环境
|
||||
#: 侧信号。停止判定分别问这两处,不把其中一处折算成另一处。
|
||||
completes_run: bool = False
|
||||
#: 这个工具的实现。**可以为空**:项目自己写动作执行器、只把工具清单交给库做 schema 展示
|
||||
#: 与校验时,实现不在库这边。缺实现的代价由 `ToolRegistry.executor()` 兜——它在被调用的
|
||||
#: 那一刻全查一遍,不等到分发时才发现。
|
||||
#:
|
||||
#: **新字段追加在末尾**,这是一条规则不是这一次的判断:按位置传参的调用方那里,插在中间
|
||||
#: 会静默改掉后面每一个参数的含义。本库的数据类都只收关键字参数
|
||||
#: (`research-wiki/design/0009-keyword-only-public-types.md`),所以这条其实已经被堵死
|
||||
#: 了两道,仍然照规则写是为了不留下一个「这次能不能插」的判断题。
|
||||
handler: ToolHandler | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""校验并冻结构造入参。
|
||||
@@ -105,6 +142,8 @@ class ToolSpec:
|
||||
raise TypeError(f"工具说明必须是字符串,收到 {type(self.description).__name__}")
|
||||
if not isinstance(self.parameters, Mapping):
|
||||
raise TypeError(f"parameters 必须是一份映射,收到 {type(self.parameters).__name__}")
|
||||
if self.handler is not None and not callable(self.handler):
|
||||
raise TypeError(f"handler 必须可调用,收到 {type(self.handler).__name__}")
|
||||
object.__setattr__(self, "parameters", _frozen(dict(self.parameters)))
|
||||
|
||||
|
||||
@@ -313,8 +352,146 @@ class ToolRegistry:
|
||||
if wrong_types:
|
||||
raise ToolValidationError(f"{call.name!r} 的参数类型不对:{wrong_types}")
|
||||
|
||||
def executor(self) -> "RegistryExecutor":
|
||||
"""派生出一个查这份注册表分发的动作执行器。
|
||||
|
||||
**每一份规格都必须有实现,缺一个就在这里报错**,不等到分发时才发现。分发时才发现的话,
|
||||
那是运行到第几步才炸,而前几步已经花了钱、留了轨迹,而且不同的运行会在不同的步数上
|
||||
炸。全查是一次遍历,工具数量是几十的量级,这个方法又只在装配时调用。
|
||||
|
||||
返回类型是具体类而不是 `ActionExecutor`,因为 `RunRequest` 要用 `isinstance` 认它。
|
||||
"""
|
||||
missing = [spec.name for spec in self._specs if spec.handler is None]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"这些工具没有实现,派生不出分发器:{missing}。"
|
||||
"项目自己写动作执行器时不必给实现,但那时也不该调用这个方法"
|
||||
)
|
||||
return RegistryExecutor(self)
|
||||
|
||||
|
||||
class RegistryExecutor:
|
||||
"""由一个注册表派生出来的动作执行器:查表、校验、分发。
|
||||
|
||||
**它是一个具体类而不是闭包或函数**,因为 `RunRequest` 构造时要判断「这个执行器是不是注册表
|
||||
派生的」——闭包和函数从外面看不出来源,具体类是唯一能被 `isinstance` 认出的形态。认出来
|
||||
之后它比对 `registry` 与本次可见的注册表,不一致直接报错;不是这个类的实例就一概放行,
|
||||
那是项目自己写执行器的情形,库无从判断也不该判断
|
||||
(`research-wiki/design/0006-public-names-and-signatures.md` 决策三)。
|
||||
|
||||
它满足 `polyloop.ports.ActionExecutor`,但**不显式继承那个 Protocol**:结构化子类型不需要
|
||||
继承,而继承会让这个模块 import 一个它只用来做名义基类的东西。
|
||||
"""
|
||||
|
||||
__slots__ = ("_registry",)
|
||||
|
||||
def __init__(self, registry: "ToolRegistry") -> None:
|
||||
self._registry = registry
|
||||
|
||||
@property
|
||||
def registry(self) -> "ToolRegistry":
|
||||
"""派生出这个执行器的那个注册表。`RunRequest` 的一致性校验读它。"""
|
||||
return self._registry
|
||||
|
||||
def parameters(self) -> Mapping[str, str]:
|
||||
"""上报可复现参数:这次分发认得哪些工具。
|
||||
|
||||
工具集是「模型看得见的东西」的一部分,换一组工具续跑而快照不比对,前几步与后几步的
|
||||
可选动作集就不一样了,而两段轨迹在文件里看起来是同一次运行。
|
||||
"""
|
||||
return {"tools": ",".join(self._registry.names())}
|
||||
|
||||
async def execute(self, action: Action) -> ActionOutcome:
|
||||
"""分发一次动作。五个字段各自怎么填,见 `design/0008` 决策四那张表。
|
||||
|
||||
**`CancelledError` 不捕获。** 下面那个 `except Exception` 接不到它——`CancelledError`
|
||||
继承的是 `BaseException`。这不是巧合可以依赖的细节,是 Python 3.8 起明确定下来的,
|
||||
取消要能穿过动作执行正是靠它(`CLAUDE.md` §1.6)。
|
||||
"""
|
||||
if action.tool_call is None:
|
||||
# 装配错了:一个只认工具调用的执行器收到了一段代码,说明这次运行把解释器和执行器
|
||||
# 配成了不同的动作语言。判成未执行而不是抛异常——抛异常会终止整次运行,而这一档
|
||||
# 留一条记录,事后能看见它发生过几次。
|
||||
return _rejected("这个执行器只分发工具调用,这次动作没有带工具调用")
|
||||
|
||||
try:
|
||||
self._registry.validate(action.tool_call)
|
||||
except ToolValidationError as exc:
|
||||
# 只捕获这个窄名字。捕获 ValueError 会把工具实现内部抛的普通 ValueError 一并吞掉
|
||||
# 然后判成「工具无效」,于是模型能无限重试同一个坏工具直到把步数上限耗尽。
|
||||
return _rejected(str(exc))
|
||||
|
||||
spec = self._registry.spec_for(action.tool_call.name)
|
||||
if spec is None or spec.handler is None:
|
||||
raise RuntimeError(
|
||||
f"{action.tool_call.name!r} 没有实现,而这个执行器是从注册表派生的。"
|
||||
"executor() 本该在派生那一刻就拦住它——撞到这里说明注册表在派生之后被换过"
|
||||
)
|
||||
|
||||
try:
|
||||
observation = await spec.handler(action.tool_call.arguments)
|
||||
except ToolEnvironmentError as exc:
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.ENV_ERROR,
|
||||
observation=str(exc),
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 见 docstring:这是「动作报错算已执行」那一档
|
||||
# 工具里抛一个普通异常是正常观察,原样回喂让模型自己纠正。判成「工具无效」的话,
|
||||
# 模型能无限重试同一个坏工具直到上界耗尽。不带调用栈:模型要的是「哪里错了」,
|
||||
# 调用栈对它没用,还会把库内部的路径喂进提示词。
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation=f"{type(exc).__name__}: {exc}",
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
if not isinstance(observation, str):
|
||||
# 返回类型不对是实现的签名写错了,不是模型能应对的运行时状况,而且它在第一次调用
|
||||
# 就必然发生——也就是在写这个工具的人第一次跑测试时,不会拖到生产的第 40 步。
|
||||
raise TypeError(
|
||||
f"{action.tool_call.name!r} 的实现返回了 {type(observation).__name__},"
|
||||
"工具实现必须返回一段字符串观察"
|
||||
)
|
||||
|
||||
# 环境侧完成信号恒为假:这条路径上根本没有环境可问,注册表派生的执行器手上只有一份
|
||||
# 工具清单。走这条路的运行靠 `completes_run` 收尾,而那一档由停止判定去查注册表。
|
||||
# 填成真会凭空造出一条环境侧证据。
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.EXECUTED,
|
||||
observation=observation,
|
||||
observation_is_synthetic=False,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
|
||||
def _rejected(explanation: str) -> ActionOutcome:
|
||||
"""动作没进入执行时的结果。
|
||||
|
||||
这段观察**不会进历史**:未执行与环境故障两档回填给模型的观察由库从
|
||||
`SyntheticObservations` 取(`research-wiki/design/0007-seam-behaviour.md` 决策二)。这里仍然
|
||||
认真填,是因为那段文本将来要靠事件流送出去做审计——进历史的东西会被模型看见、因而必须
|
||||
可复现,进审计的只被人看见。
|
||||
"""
|
||||
return ActionOutcome(
|
||||
status=ActionStatus.NOT_EXECUTED,
|
||||
observation=explanation,
|
||||
# 环境根本没产出过任何东西,这段文本是库自己写的。
|
||||
observation_is_synthetic=True,
|
||||
env_reported_completion=False,
|
||||
observation_truncated_chars=0,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RegistryExecutor",
|
||||
"ToolEnvironmentError",
|
||||
"ToolHandler",
|
||||
"ToolRegistry",
|
||||
"ToolSpec",
|
||||
"ToolValidationError",
|
||||
|
||||
@@ -44,7 +44,7 @@ class Role(StrEnum):
|
||||
ASSISTANT = "assistant"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TextBlock:
|
||||
"""一段文本内容。它的规模度量是准确的字符数。"""
|
||||
|
||||
@@ -59,7 +59,7 @@ class TextBlock:
|
||||
ContentBlock = TextBlock
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Message:
|
||||
"""一条消息:一个角色加一个内容块序列。"""
|
||||
|
||||
@@ -67,7 +67,7 @@ class Message:
|
||||
content: tuple[ContentBlock, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Context:
|
||||
"""已渲染好的消息序列,按变化频率分两段。
|
||||
|
||||
@@ -81,7 +81,7 @@ class Context:
|
||||
goal_level: tuple[Message, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Injection:
|
||||
"""一条要贴进上下文的 Skill 条目。
|
||||
|
||||
@@ -99,7 +99,7 @@ class Injection:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ModelReply:
|
||||
"""一次模型调用的返回。"""
|
||||
|
||||
@@ -131,7 +131,7 @@ class ActionStatus(StrEnum):
|
||||
ENV_ERROR = "env_error"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ActionOutcome:
|
||||
"""动作执行接缝的返回。"""
|
||||
|
||||
@@ -164,7 +164,7 @@ class ReplayPolicy(StrEnum):
|
||||
SAFE = "safe"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Budget:
|
||||
"""一次运行的四个上限,各项必填无默认。
|
||||
|
||||
@@ -217,7 +217,7 @@ class StopReason(StrEnum):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class StepRecord:
|
||||
"""逐步轨迹的一步。
|
||||
|
||||
@@ -259,7 +259,7 @@ class StepRecord:
|
||||
schema_version: int = CURRENT_SCHEMA_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunResult:
|
||||
"""一次运行的结果。"""
|
||||
|
||||
@@ -272,7 +272,7 @@ class RunResult:
|
||||
event_delivery_failures: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SyntheticObservations:
|
||||
"""库自己合成、回填给模型看的那几段观察。
|
||||
|
||||
@@ -301,7 +301,7 @@ class IntentKind(StrEnum):
|
||||
ACTION = "action"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunStarted:
|
||||
"""一次运行开始了。
|
||||
|
||||
@@ -315,7 +315,7 @@ class RunStarted:
|
||||
schema_version: int = CURRENT_SCHEMA_VERSION
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Intent:
|
||||
"""要做一件有副作用的事了。
|
||||
|
||||
@@ -335,7 +335,7 @@ class Intent:
|
||||
replay_policy: ReplayPolicy
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ModelCallResult:
|
||||
"""一次模型调用结算了。
|
||||
|
||||
@@ -352,7 +352,7 @@ class ModelCallResult:
|
||||
failure: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class StepCompleted:
|
||||
"""一步走完了:动作结果与步记录一次原子落地。
|
||||
|
||||
@@ -385,7 +385,7 @@ class StepCompleted:
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class RunFinished:
|
||||
"""一次运行结束了。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user