feat(tools): 落成工具规格与注册表,四者同源由同一个实例驱动

ToolSpec 五个字段加构造期校验(空名字、非映射 parameters),parameters 深拷贝一份存下来
——不拷贝的话调用方在别处改那个字典会连带改掉模型看见的 schema,而那次修改没有任何地方
记录得到。

ToolRegistry 是不可变值对象:注册顺序保留(工具清单要贴进提示词,而 restrict_to 收到的
常常是 set,照集合顺序输出会让同一份配置在不同进程里渲染出不同提示词);相等按内容判
不按身份判,供 RunRequest 那条一致性校验用;restrict_to 遇到不认识的名字直接报错,
不静默丢弃。

validate 校验的是 JSON Schema 的一个子集(必填键、additionalProperties: false 时的多余键、
顶层 type),子集边界写在 docstring 里。完整校验只能靠第三方库,而公共签名上不许出现
第三方类型。剩下那部分由工具自己报错,那是一条正常观察。

executor() 没写,缺口见 design/0008(待确认,要过人类门)。
This commit is contained in:
2026-08-10 00:45:53 -04:00
parent 77ffad9dc4
commit 30895cd306
2 changed files with 586 additions and 7 deletions
+270 -7
View File
@@ -1,11 +1,274 @@
"""工具规格与注册表,以及由注册表派生的动作执行器
"""工具规格与注册表。
一个模块持有关于工具的全部六件事:注册、模型可见 schema 的生成、存在性与参数校验、分发、
重放策略声明、完成标记。
读者是给库注册工具的人,以及循环里要问「这个工具声明了什么」的三个纯逻辑模块。
**六件必须同源,不许把其中任何一件挪出去。** 挪出去的那份清单会跟注册表漂移,而漂移的
表现是「模型明明提交了,运行却没停」——它看起来像模型不听话,不像配置错了。
**注册、模型可见 schema 的生成、存在性与参数校验、分发——四者由同一个注册表实例驱动**
`research-wiki/explanation/scope.md` 第 68 行那条要求)。不同源就会漂移:模型看见一个已经
删掉的工具,或者校验放行了一个分发时找不到的名字。重放策略与完成标记同住这里,理由一样
——它们都是「关于某个工具的一条事实」,分开存就会跟工具清单漂移。
注册表是不可变值对象,取子集返回新实例。不能是进程级单例:同一进程里可能同时持有多份
不同的窄集合。
注册表是**不可变值对象**,取子集返回新实例,不是进程级单例:同一进程里可能同时持有多份
不同的窄集合`research-wiki/design/0003-public-api-shape.md` 决策二)
**`executor()` 还没落地。** `0006` 定的 `ToolSpec` 五个字段里没有一处装工具本身的实现,
所以注册表拿到一次合法调用之后无处分发。这个洞由
`research-wiki/design/0008-tool-handlers.md` 处理,它要过 `CLAUDE.md` §2 那道人类门;在它被
确认之前这个方法不写,也不写一个「先占位、以后再改」的版本——那种版本会让下游以为分发
已经能用了。
"""
import copy
from collections.abc import Collection, Iterable, Mapping, Sequence
from dataclasses import dataclass
from polyloop.ports import ToolCall
from polyloop.types import ReplayPolicy
class ToolValidationError(ValueError):
"""一次工具调用不合法:工具不存在,或者参数不合它声明的 schema。
**是异常不是布尔返回值**:返回布尔的校验函数迟早有一处调用方忘了看返回值,而那处不会
报错(`research-wiki/design/0006-public-names-and-signatures.md` 决策六)。
它继承 `ValueError` 而不是直接继承 `Exception`,是为了让「只想拦住一切参数错误」的调用
方不必先 import 这个名字。**但库自己捕获它时一律用这个窄名字**——在分发那条路径上捕获
`ValueError` 会把工具实现内部抛的普通 `ValueError`(参数解析时极常见)一并吞掉,然后把
它判成「工具无效」,于是模型能无限重试同一个坏工具直到上界耗尽
`research-wiki/design/0003-public-api-shape.md` 决策四)。
"""
@dataclass(frozen=True, slots=True)
class ToolSpec:
"""一个工具的全部声明。
`parameters` 是一份普通的 JSON Schema 字典,不是任何第三方库的模型对象——签名上一旦出现
第三方类型,那个包的 major 就是我们的 major。
构造时 `parameters` 会被深拷贝一份存下来。调用方传进来的那个字典之后再被改,注册表看见
的仍是注册那一刻的形状;不拷贝的话,「模型看见的 schema」和「校验用的 schema」会随调用方
在别处的一次修改一起变,而那次修改没有任何地方记录得到。
"""
name: str
description: str
parameters: Mapping[str, object]
#: 状态未知时要不要重放这个工具。默认 `NEVER`:工具可能有几十个,逐个强制声明会让接入
#: 成本高到有人去写一个批量填 `SAFE` 的辅助函数,那比默认值更糟。默认 `NEVER` 而声明漏了
#: 只是多停一次、有人会看见;默认 `SAFE` 而声明漏了会静默重复副作用。
replay_policy: ReplayPolicy = ReplayPolicy.NEVER
#: 这个工具一旦被成功执行,就代表这次运行的目标达成了。
#:
#: **它和 `ActionOutcome.env_reported_completion` 是两条不同的完成通路,可信度不同**:
#: 这一条是 agent 自报(它调了一个提交型工具来宣布做完,环境状态一点没变),那一条是环境
#: 侧信号。停止判定分别问这两处,不把其中一处折算成另一处。
completes_run: bool = False
def __post_init__(self) -> None:
"""校验并冻结构造入参。
用显式异常而不是 `assert``python -O` 会把断言整条移除(`CLAUDE.md` §6)。
"""
if not self.name:
raise ValueError("工具名不能为空串:空名字在提示词里不可见,模型永远调不到它")
if not isinstance(self.parameters, Mapping):
raise TypeError(f"parameters 必须是一份映射,收到 {type(self.parameters).__name__}")
object.__setattr__(self, "parameters", copy.deepcopy(dict(self.parameters)))
def _matches_one_json_type(value: object, type_name: str) -> bool:
"""一个取值符不符合 JSON Schema 里的一个类型名。
不认识的类型名一律放行。两个方向的错误代价不对称:放行一个其实不合法的调用,工具自己会
报错,那是一条正常观察、模型能自我纠正;拦下一个其实合法的调用,模型看见「参数不合法」
却怎么改都过不去,而库这边一条错误日志都没有。
"""
if type_name == "null":
return value is None
if type_name == "boolean":
return isinstance(value, bool)
if type_name == "integer":
# 布尔在 Python 里是整数的子类,而 JSON 里不是。不排掉的话 `True` 会被判成合法的整数。
return isinstance(value, int) and not isinstance(value, bool)
if type_name == "number":
return isinstance(value, int | float) and not isinstance(value, bool)
if type_name == "string":
return isinstance(value, str)
if type_name == "array":
return isinstance(value, list | tuple)
if type_name == "object":
return isinstance(value, Mapping)
return True
def _matches_json_type(value: object, declared: object) -> bool:
"""`type` 可以是一个类型名,也可以是一组类型名里的任意一个。"""
if isinstance(declared, str):
return _matches_one_json_type(value, declared)
if isinstance(declared, list | tuple):
return any(
isinstance(name, str) and _matches_one_json_type(value, name) for name in declared
)
return True
class ToolRegistry:
"""本次运行可见的那一组工具。
构造入参是一串规格,注册顺序被保留下来——`schema_for_model` 与 `names` 都按它输出。
**顺序必须是确定的**:工具清单要贴进提示词,而 `restrict_to` 收到的常常是一个集合,
照集合的迭代顺序输出会让同一份配置在不同进程里渲染出不同的提示词(Python 的字符串哈希
每进程随机),于是提示词的差异会精确地伪装成下游想测的效应。
**相等按「注册了哪些规格、什么顺序」判,不按对象身份判。** 构造 `RunRequest` 时要比对
「执行器持有的注册表」和「本次可见的注册表」是不是同一份,两份内容相同的注册表在模型
看见的 schema 与实际分发上完全一致,没有可失败的地方,按身份判会把它们错判成冲突。
"""
__slots__ = ("_by_name", "_specs")
def __init__(self, specs: Iterable[ToolSpec] = ()) -> None:
ordered: list[ToolSpec] = []
by_name: dict[str, ToolSpec] = {}
for spec in specs:
if not isinstance(spec, ToolSpec):
raise TypeError(f"注册表只收 ToolSpec,收到 {type(spec).__name__}")
if spec.name in by_name:
raise ValueError(
f"工具名重复:{spec.name!r}。重名的两份规格会让「模型看见的那一份」和"
"「分发时取到的那一份」取决于注册顺序,而那个顺序没有任何地方约束得住"
)
by_name[spec.name] = spec
ordered.append(spec)
self._specs: tuple[ToolSpec, ...] = tuple(ordered)
self._by_name: dict[str, ToolSpec] = by_name
def __eq__(self, other: object) -> bool:
if not isinstance(other, ToolRegistry):
return NotImplemented
return self._specs == other._specs
def __repr__(self) -> str:
return f"ToolRegistry({list(self.names())!r})"
def names(self) -> tuple[str, ...]:
"""按注册顺序列出工具名。
`RunRequest` 的参数快照要带上这一份清单,靠它把「这次运行模型能看见哪些工具」记进
运行开始那条记录。从 `schema_for_model()` 里把名字抠出来也能得到同一份清单,但那样
快照就跟着模型可见 schema 的形状走——将来裁剪那份 schema 会连带改掉快照,而快照的
变化会让续跑守卫在一次纯粹的渲染改动上报错。
"""
return tuple(spec.name for spec in self._specs)
def spec_for(self, name: str) -> ToolSpec | None:
"""查一个工具的规格,查不到返回 `None`。
**返回 `None` 而不抛异常**:问一个不在注册表里的名字是正常情形。没有工具的动作
(模型输出的是一整段代码,而不是一次工具调用)就问不出规格,那时取「绝不重放」——
当成可重放而其实不是,会重复执行副作用且静默;当成绝不重放而其实可以,只是多停一次
`research-wiki/design/0006-public-names-and-signatures.md` 决策六)。
这与 `validate` 抛异常不矛盾:那里问的是「这次调用合不合法」,不合法就是错误。
"""
return self._by_name.get(name)
def restrict_to(self, names: Collection[str]) -> "ToolRegistry":
"""按名字收窄,返回一个新注册表。原注册表不变。
**名字里有一个不在本注册表里就直接报错**,不静默丢弃。收窄清单基本上都是人手写的,
里面一个拼错的名字意味着模型看不见调用方以为已经开放的那个工具,而表现是模型在
「我没有这个能力」上绕圈,看起来像模型不行、不像清单写错了。
**叫 `restrict_to` 而不是 `subset`**:后者读起来像取一个属性,而它做的是「造一个新
实例」。
"""
wanted = set(names)
unknown = sorted(wanted - set(self._by_name))
if unknown:
raise ValueError(
f"要收窄到的工具不在注册表里:{unknown},本注册表有:{list(self.names())}"
)
return ToolRegistry(spec for spec in self._specs if spec.name in wanted)
def schema_for_model(self) -> Sequence[Mapping[str, object]]:
"""生成喂给模型的那份工具清单,按注册顺序。
**名字里带 `for_model`**,是因为这个模块里还有另一份 schema——`ToolSpec.parameters`
是校验用的完整 JSON Schema,而喂给模型的那份将来可能裁剪过。不加限定词的 `schema()`
在两者之间是歧义的。
每次调用现造一份新的普通字典与列表,所以调用方改它不会影响注册表,而且能直接
`json.dumps`。
"""
return [
{
"name": spec.name,
"description": spec.description,
"parameters": copy.deepcopy(dict(spec.parameters)),
}
for spec in self._specs
]
def validate(self, call: ToolCall) -> None:
"""校验一次工具调用,不合法就抛 `ToolValidationError`。
**校验的是 JSON Schema 的一个子集**:必填键在不在、声明了 `additionalProperties: false`
时有没有多出来的键、以及每个键的顶层 `type` 对不对。嵌套 schema、`enum`、数值区间、
字符串格式、`oneOf` 这些都不查。
子集不是偷懒,是因为完整的 JSON Schema 校验只能靠第三方库,而库的公共签名上不许出现
第三方类型(`CLAUDE.md` §1)。剩下那部分交给工具自己:一次参数不对的调用打到工具里
会抛异常,那是一条正常观察,原样回喂让模型自己纠正。所以这里只拦「一定错」的那几种,
拦不住的那些不会静默通过、只是换了个地方报。
"""
spec = self._by_name.get(call.name)
if spec is None:
raise ToolValidationError(
f"工具不存在:{call.name!r},本次可见的是 {list(self.names())}"
)
if not isinstance(call.arguments, Mapping):
raise ToolValidationError(
f"{call.name!r} 的参数必须是一份映射,收到 {type(call.arguments).__name__}"
)
schema = spec.parameters
raw_properties = schema.get("properties")
properties: Mapping[str, object] = (
raw_properties if isinstance(raw_properties, Mapping) else {}
)
raw_required = schema.get("required")
required: Sequence[object] = raw_required if isinstance(raw_required, list | tuple) else ()
missing = [key for key in required if isinstance(key, str) and key not in call.arguments]
if missing:
raise ToolValidationError(f"{call.name!r} 缺少必填参数:{missing}")
if schema.get("additionalProperties") is False:
unknown = sorted(key for key in call.arguments if key not in properties)
if unknown:
raise ToolValidationError(
f"{call.name!r} 收到未声明的参数:{unknown}"
f"它声明的参数是 {sorted(properties)}"
)
wrong_types: list[str] = []
for key, value in call.arguments.items():
declared = properties.get(key)
if not isinstance(declared, Mapping):
continue
declared_type = declared.get("type")
if declared_type is None:
continue
if not _matches_json_type(value, declared_type):
wrong_types.append(f"{key}(声明 {declared_type!r},收到 {type(value).__name__}")
if wrong_types:
raise ToolValidationError(f"{call.name!r} 的参数类型不对:{wrong_types}")
__all__ = [
"ToolRegistry",
"ToolSpec",
"ToolValidationError",
]