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
+197 -3
View File
@@ -9,12 +9,19 @@
"""
import json
from collections.abc import Mapping
import pytest
from polyloop.ports import ToolCall
from polyloop.tools import ToolRegistry, ToolSpec, ToolValidationError
from polyloop.types import ReplayPolicy
from polyloop.ports import Action, ToolCall
from polyloop.tools import (
RegistryExecutor,
ToolEnvironmentError,
ToolRegistry,
ToolSpec,
ToolValidationError,
)
from polyloop.types import ActionStatus, ReplayPolicy
pytestmark = pytest.mark.unit
@@ -405,3 +412,190 @@ def test_narrowing_hides_a_tool_from_the_model_and_from_validation_together() ->
assert narrowed.spec_for("write") is None
with pytest.raises(ToolValidationError):
narrowed.validate(ToolCall(name="write", arguments={}))
# ---------------------------------------------------------------------------
# 派生分发器
# ---------------------------------------------------------------------------
async def _echo(arguments: Mapping[str, object]) -> str:
return f"echo {arguments}"
def _tool_action(name: str, **arguments: object) -> Action:
return Action(text=f"{name}(...)", tool_call=ToolCall(name=name, arguments=arguments))
def test_deriving_an_executor_needs_every_tool_to_have_an_implementation() -> None:
"""缺实现在派生那一刻就报错,不等到分发时才发现。
分发时才发现的话,那是运行到第几步才炸,而前几步已经花了钱、留了轨迹,而且不同的运行会
在不同的步数上炸。
"""
registry = ToolRegistry([_spec("read", handler=_echo), _spec("write")])
with pytest.raises(ValueError, match="没有实现"):
registry.executor()
def test_a_registry_without_handlers_is_still_a_valid_registry() -> None:
"""项目自己写动作执行器时,工具清单照样进模型可见的 schema、照样被校验。
实现不在库这边,那时也不该调 `executor()`。必填实现的话,这种项目要给每个工具写一个
永远不会被调用的空壳。
"""
registry = ToolRegistry([_spec("read")])
assert registry.names() == ("read",)
registry.validate(ToolCall(name="read", arguments={}))
def test_an_executor_holds_the_registry_it_came_from() -> None:
"""运行请求靠 `isinstance` 认出它、再比对注册表,所以那份注册表必须拿得到。"""
registry = ToolRegistry([_spec("read", handler=_echo)])
executor = registry.executor()
assert isinstance(executor, RegistryExecutor)
assert executor.registry == registry
def test_the_executor_reports_the_tool_names_as_its_parameters() -> None:
"""工具集是「模型看得见的东西」的一部分,要能进参数快照。
换一组工具续跑而快照不比对,前几步与后几步的可选动作集就不一样了,而两段轨迹在文件里
看起来是同一次运行。
"""
registry = ToolRegistry([_spec("read", handler=_echo), _spec("write", handler=_echo)])
assert registry.executor().parameters() == {"tools": "read,write"}
async def test_a_successful_call_is_executed_with_the_handler_text() -> None:
registry = ToolRegistry([_spec("read", handler=_echo)])
outcome = await registry.executor().execute(_tool_action("read", path="a.txt"))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == "echo {'path': 'a.txt'}"
assert outcome.observation_is_synthetic is False
assert outcome.observation_truncated_chars == 0
async def test_the_env_completion_signal_is_always_false_on_this_path() -> None:
"""这条路径上根本没有环境可问,注册表派生的执行器手上只有一份工具清单。
走这条路的运行靠完成标记收尾,而那一档由停止判定去查注册表。填成真会凭空造出一条环境侧
证据,而两条完成通路的可信度本来就不同。
"""
registry = ToolRegistry([_spec("submit", handler=_echo, completes_run=True)])
outcome = await registry.executor().execute(_tool_action("submit"))
assert outcome.env_reported_completion is False
async def test_an_action_without_a_tool_call_is_not_executed() -> None:
"""一个只认工具调用的执行器收到一段代码,说明这次运行把两种动作语言配串了。
判成未执行而不是抛异常:抛异常会终止整次运行,而这一档留一条记录,事后能看见它发生
过几次。
"""
registry = ToolRegistry([_spec("read", handler=_echo)])
outcome = await registry.executor().execute(Action(text="print(1)", tool_call=None))
assert outcome.status is ActionStatus.NOT_EXECUTED
async def test_an_invalid_call_is_not_executed_and_never_reaches_the_handler() -> None:
"""工具不存在或参数不合法时直接合成「未执行」,不经过任何实现。"""
called = False
async def _never(arguments: Mapping[str, object]) -> str:
nonlocal called
called = True
return ""
registry = ToolRegistry([_spec("read", handler=_never)])
outcome = await registry.executor().execute(_tool_action("reed"))
assert outcome.status is ActionStatus.NOT_EXECUTED
assert called is False
async def test_a_plain_exception_from_a_tool_is_a_normal_observation() -> None:
"""工具里抛一个普通异常算「已执行」,原样回喂让模型自己纠正。
判成「工具无效、不计有效动作」的话,模型能无限重试同一个坏工具直到把步数上限耗尽。
观察带类名与文本、不带调用栈——模型要的是「哪里错了」,调用栈对它没用,还会把库内部的
路径喂进提示词。
"""
async def _boom(arguments: Mapping[str, object]) -> str:
raise ValueError("参数解析失败")
registry = ToolRegistry([_spec("read", handler=_boom)])
outcome = await registry.executor().execute(_tool_action("read"))
assert outcome.status is ActionStatus.EXECUTED
assert outcome.observation == "ValueError: 参数解析失败"
async def test_only_a_tool_environment_error_means_the_environment_broke() -> None:
"""分界线是环境还能不能接着服务,不是「有没有抛异常」。
没有这个口子的话,派生分发器永远产不出环境故障,于是后端挂掉时模型会一遍遍重试、把预算
烧光,而轨迹上表现成「预算耗尽」。
"""
async def _down(arguments: Mapping[str, object]) -> str:
raise ToolEnvironmentError("后端连不上")
registry = ToolRegistry([_spec("read", handler=_down)])
outcome = await registry.executor().execute(_tool_action("read"))
assert outcome.status is ActionStatus.ENV_ERROR
assert outcome.observation == "后端连不上"
async def test_cancellation_passes_straight_through() -> None:
"""`CancelledError` 不被那个 `except Exception` 接住——它继承的是 `BaseException`。
吞掉它的后果不是「取消失败」这么直白,是容器租约、连接和临时目录持续泄漏,而且一声
不吭。
"""
import asyncio
async def _cancelled(arguments: Mapping[str, object]) -> str:
raise asyncio.CancelledError
registry = ToolRegistry([_spec("read", handler=_cancelled)])
with pytest.raises(asyncio.CancelledError):
await registry.executor().execute(_tool_action("read"))
async def test_a_handler_that_returns_the_wrong_type_fails_loudly() -> None:
"""返回类型不对是实现的签名写错了,不是模型能应对的运行时状况。
它在第一次调用就必然发生,也就是在写这个工具的人第一次跑测试时,不会拖到生产的第 40 步。
放过去的话,一个非字符串会进到持久化的观察字段里。
"""
async def _wrong(arguments: Mapping[str, object]) -> str:
return {"not": "a string"} # type: ignore[return-value]
registry = ToolRegistry([_spec("read", handler=_wrong)])
with pytest.raises(TypeError, match="必须返回一段字符串观察"):
await registry.executor().execute(_tool_action("read"))
def test_a_handler_must_be_callable() -> None:
with pytest.raises(TypeError, match="handler"):
_spec("read", handler="not callable")