File size: 6,001 Bytes
ad88834 21e8d36 ad88834 21e8d36 ad88834 21e8d36 ad88834 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | import asyncio
import inspect
import json
import re
import typing
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, create_model
import observability
class ToolInput(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class NoInput(ToolInput):
pass
class TextInput(ToolInput):
text: str = Field(min_length=1, description="用户当前消息的完整原文")
def original_text_input(text: str):
return create_model(
"CurrentMessageInput", __base__=TextInput,
text=(Literal[text], Field(description="本轮用户消息的完整原文,保持字词和标点")),
)
@dataclass(frozen=True)
class Tool:
name: str
description: str
inputs: type[BaseModel]
handler: Callable[..., Awaitable[Any]]
def definition(self):
return {
"name": self.name, "description": self.description,
"parameters": self.inputs.model_json_schema(),
}
# 大模型接口对工具名的限制。
TOOL_NAME = re.compile(r"[A-Za-z0-9_-]{1,64}")
# 工具方法里叫这个名字的参数由框架传入当前这一轮,不交给大模型填写。
TURN_PARAMETER = "turn"
@dataclass(frozen=True)
class ToolSpec:
"""用 @tool 声明的工具:发给大模型的定义和对应的方法。"""
name: str
description: str
inputs: type[BaseModel]
function: Callable[..., Any]
takes_turn: bool
def tool(description: str, *, name: str | None = None):
"""把游戏 Agent 的方法声明成工具。description 原样发给大模型,name 不写时用方法名。
参数 turn 由框架传入当前这一轮;其余参数由大模型填写,必须写类型注解,有默认值的是可选参数,
取值范围和说明写成 Annotated[int, Field(ge=1, le=20, description="用户猜的数")]。
方法可以是同步或 async 的,返回能转成 JSON 的字典。
"""
if not isinstance(description, str):
raise TypeError('@tool 要带上工具说明,写成 @tool("说明")')
def decorate(function):
function.__tool__ = tool_spec(function, description, name)
return function
return decorate
def tool_spec(function, description: str, name: str | None) -> ToolSpec:
"""检查方法的签名,生成工具定义;写错的地方在定义类时就报错。"""
where = function.__qualname__
name = function.__name__ if name is None else name
if not TOOL_NAME.fullmatch(name):
raise ValueError(f"{where} 的工具名 {name!r} 不合法:只能用字母、数字、下划线和短横线,最长 64 个字符")
if not description.strip():
raise ValueError(f"{where} 的工具说明不能为空")
hints = typing.get_type_hints(function, include_extras=True)
fields, takes_turn = {}, False
# 第一个参数是 self。
for parameter in list(inspect.signature(function).parameters.values())[1:]:
if parameter.name == TURN_PARAMETER:
takes_turn = True
continue
if parameter.kind not in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY):
raise TypeError(f"{where} 的参数 {parameter.name} 要写成普通参数,不能用 *args、**kwargs 或仅限位置的参数")
if parameter.name not in hints:
raise TypeError(f"{where} 的参数 {parameter.name} 缺少类型注解")
default = ... if parameter.default is parameter.empty else parameter.default
fields[parameter.name] = (hints[parameter.name], default)
try:
inputs = create_model(f"{name}_input", __base__=ToolInput, **fields)
inputs.model_json_schema()
except Exception as error:
raise TypeError(f"{where} 的参数类型生成不了工具定义:{error}") from error
return ToolSpec(name, description, inputs, function, takes_turn)
def collect_tools(cls: type) -> tuple[ToolSpec, ...]:
"""按定义顺序收集类上用 @tool 声明的方法;子类重写同名方法时以子类为准。"""
found = {}
for klass in reversed(cls.__mro__):
for attribute, value in vars(klass).items():
found[attribute] = getattr(value, "__tool__", None)
specs = tuple(spec for spec in found.values() if isinstance(spec, ToolSpec))
owners = {}
for spec in specs:
if spec.name in owners:
raise ValueError(f"{cls.__name__} 的工具名 {spec.name} 重复:"
f"{owners[spec.name]} 和 {spec.function.__qualname__}")
owners[spec.name] = spec.function.__qualname__
return specs
class ToolRegistry:
def __init__(self, tools: list[Tool], *, terminal=()):
self.tools = {tool.name: tool for tool in tools}
if len(self.tools) != len(tools):
raise ValueError("工具名称不能重复")
self.calls: list[dict] = []
self.lock = asyncio.Lock()
self.terminal = set(terminal)
self.completed = asyncio.Event()
def definitions(self):
return [tool.definition() for tool in self.tools.values()]
async def call(self, name: str, arguments: dict):
async with self.lock:
if self.completed.is_set():
raise ValueError("当前工具调用轮次已经结束")
with observability.tool_call(name, arguments) as outcome:
tool = self.tools[name]
inputs = tool.inputs.model_validate(arguments)
result = await tool.handler(**inputs.model_dump())
# 在工具执行位置验证返回值可以通过模型接口传输。
json.dumps(result, ensure_ascii=False, allow_nan=False)
outcome["result"] = result
self.calls.append({"name": name, "arguments": arguments, "result": result})
if name in self.terminal:
self.completed.set()
return result
|