"""工具规格与注册表。 读者是给库注册工具的人,以及循环里要问「这个工具声明了什么」的三个纯逻辑模块。 **注册、模型可见 schema 的生成、存在性与参数校验、分发——四者由同一个注册表实例驱动** (`research-wiki/explanation/scope.md` 界内清单里「工具的注册、模型可见 schema 生成、存在性 与参数校验、分发」那一条)。不同源就会漂移:模型看见一个已经删掉的工具,或者校验放行了 一个分发时找不到的名字。重放策略与完成标记同住这里,理由一样——它们都是「关于某个工具的 一条事实」,分开存就会跟工具清单漂移。 注册表是**不可变值对象**,取子集返回新实例,不是进程级单例:同一进程里可能同时持有多份 不同的窄集合(`research-wiki/design/0003-public-api-shape.md` 决策二)。 工具本身的实现挂在 `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 Action, ToolCall from polyloop.types import ActionOutcome, ActionStatus, 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` 决策四)。 """ 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: """把一份嵌套结构逐层变成改不动的形状:映射变只读视图,列表变元组。 工具的参数 schema 是第一个用它的地方,`polyloop.session` 的请求也用它冻自己那几个映射 字段——两处要防的是同一件事,所以共用这一个函数而不是各写一份。 只冻最外面一层不够。真正会发生的改法是从注册表里把规格取出来、往里伸一层去改 (`spec_for("read").parameters["properties"]["path"]["type"] = ...`),那一下同时改掉了 模型看见的 schema 和校验用的 schema,而这次修改没有任何地方记录得到——事后翻轨迹, 模型当时到底看见的是哪一份,查不出来。 """ if isinstance(value, Mapping): return MappingProxyType({key: _frozen(item) for key, item in value.items()}) if isinstance(value, list | tuple): return tuple(_frozen(item) for item in value) return value def _plain(value: object) -> object: """把冻过的形状变回普通字典与列表。 交给模型的那份 schema 要能直接 `json.dumps`,而只读视图与元组里只有元组能被序列化。 """ if isinstance(value, Mapping): return {key: _plain(item) for key, item in value.items()} if isinstance(value, tuple): return [_plain(item) for item in value] return value @dataclass(frozen=True, slots=True, kw_only=True) class ToolSpec: """一个工具的全部声明。 `parameters` 是一份普通的 JSON Schema 字典,不是任何第三方库的模型对象——签名上一旦出现 第三方类型,那个包的 major 就是我们的 major。 构造时 `parameters` 会被逐层冻成只读的形状存下来,两个方向都堵上:调用方传进来的那个 字典之后再被改,注册表看见的仍是注册那一刻的形状;从注册表里把规格取出来往里改,改不动。 """ 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 #: 这个工具的实现。**可以为空**:项目自己写动作执行器、只把工具清单交给库做 schema 展示 #: 与校验时,实现不在库这边。缺实现的代价由 `ToolRegistry.executor()` 兜——它在被调用的 #: 那一刻全查一遍,不等到分发时才发现。 #: #: **新字段追加在末尾**,这是一条规则不是这一次的判断:按位置传参的调用方那里,插在中间 #: 会静默改掉后面每一个参数的含义。本库的数据类都只收关键字参数 #: (`research-wiki/design/0009-keyword-only-public-types.md`),所以这条其实已经被堵死 #: 了两道,仍然照规则写是为了不留下一个「这次能不能插」的判断题。 handler: ToolHandler | None = None def __post_init__(self) -> None: """校验并冻结构造入参。 用显式异常而不是 `assert`:`python -O` 会把断言整条移除(`CLAUDE.md` §6)。 """ if not isinstance(self.name, str): raise TypeError(f"工具名必须是字符串,收到 {type(self.name).__name__}") if not self.name.strip(): raise ValueError("工具名不能是空串或纯空白:这种名字在提示词里不可见,模型永远调不到它") if not isinstance(self.description, str): 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))) 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` 会被判成合法的整数。 if isinstance(value, bool): return False if isinstance(value, int): return True # JSON Schema draft-06 起,小数部分为零的浮点数是合法的整数。模型写出 `1e2` 或者 # `3.0`,`json.loads` 给的就是 float——照「必须是 int」判会拒掉一次合法调用,而模型 # 怎么改都过不去。 return isinstance(value, float) and value.is_integer() 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 与实际分发上完全一致,没有可失败的地方,按身份判会把它们错判成冲突。 **不可哈希**,因为参数 schema 是映射。放进 `set` 或者拿它当字典键会抛 `TypeError`, 要按注册表分组的话用 `names()` 那份元组当键。 """ __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) # 只读视图而不是那个 dict 本身:两条查询路径(`names`/`schema_for_model` 走 `_specs`, # `spec_for`/`validate` 走这里)一旦有一条被就地改过,四者同源当场破掉。 self._by_name: Mapping[str, ToolSpec] = MappingProxyType(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": _plain(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}") # `patternProperties` 在场时,「哪些键是被声明过的」要靠正则匹配才答得出,而这里 # 不实现正则匹配那一档。跳过这项检查,不拿一份答不出的问题去拒调用——匹配到 pattern # 的键必然不在 `properties` 里,照下面那行判会把每一次合法调用都拒掉,而模型改名字 # 也绕不过去。 if schema.get("additionalProperties") is False and "patternProperties" not in schema: 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}") 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", ]