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:
2026-08-10 01:19:15 -04:00
parent fa09e873c5
commit 6fafd95d6c
10 changed files with 1082 additions and 49 deletions
+185 -8
View File
@@ -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",