Files
MoviePilot/app/agent/tools/catalog.py
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

260 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 本地工具目录的不可变快照与严格解析。
"""
import hashlib
import json
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any, Mapping, Optional
from pydantic import BaseModel
from app.agent.policy.contracts import ToolRevision
class ToolCatalogError(RuntimeError):
"""
工具目录无法建立可信当前视图时的稳定失败。
"""
class ToolIdentityAmbiguousError(ToolCatalogError):
"""
同一工具名对应多个实现,无法进行严格解析。
"""
def _stable_json(value: Any) -> str:
"""
生成工具身份摘要使用的稳定 JSON。
"""
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)
def _schema_digest(tool: Any) -> str:
"""
计算工具当前 Pydantic 参数契约摘要。
"""
args_schema = getattr(tool, "args_schema", None)
if isinstance(args_schema, type) and issubclass(args_schema, BaseModel):
schema_model = args_schema
schema = schema_model.model_json_schema()
elif isinstance(args_schema, Mapping):
schema = dict(args_schema)
else:
schema = {"type": "object", "properties": {}}
return hashlib.sha256(_stable_json(schema).encode("utf-8")).hexdigest()
def _implementation_identity(tool: Any) -> str:
"""
返回不依赖对象地址、可区分动态绑定的工具实现身份。
"""
tool_class = type(tool)
implementation = f"{tool_class.__module__}.{tool_class.__qualname__}"
binding = str(getattr(tool, "_agent_tool_binding", "") or "")
return f"{implementation}:{binding}" if binding else implementation
@dataclass(frozen=True)
class ToolCatalogEntry:
"""
绑定一次目录构造中精确工具实例的身份记录。
:param name: 用于严格解析和碰撞检测的工具名称
:param source: 工具来源类别,未显式声明时为 builtin
:param identity: 来源、实现身份和参数 schema 摘要组成的稳定身份
:param description_digest: 工具说明文本的 SHA-256 摘要,用于识别说明变更
:param schema_digest: 参数 JSON Schema 的 SHA-256 摘要
:param revision: 实现、工厂及插件修订信息
:param tool: 本次目录构造绑定的精确工具实例,不参与相等比较
"""
name: str
source: str
identity: str
description_digest: str
schema_digest: str
revision: ToolRevision
tool: Any = field(repr=False, compare=False)
@dataclass(frozen=True)
class ToolCatalogSnapshot:
"""
图构造、缓存签名和身份碰撞审计共享的本地工具事实源。
:param entries: 按构造顺序保存的不可变工具记录
:param plugin_revision: 构造目录时的插件工具修订号
:param factory_revision: 构造目录时的工具工厂修订标识
:ivar _by_name: 初始化时建立的只读名称索引,同名冲突项全部保留
"""
entries: tuple[ToolCatalogEntry, ...]
plugin_revision: int
factory_revision: str
_by_name: Mapping[str, tuple[ToolCatalogEntry, ...]] = field(
init=False,
repr=False,
compare=False,
)
def __post_init__(self) -> None:
"""
建立只读名称索引并保留所有冲突项。
"""
by_name: dict[str, list[ToolCatalogEntry]] = {}
for entry in self.entries:
by_name.setdefault(entry.name, []).append(entry)
object.__setattr__(
self,
"_by_name",
MappingProxyType({name: tuple(matches) for name, matches in by_name.items()}),
)
@classmethod
def from_tools(
cls,
tools: list[Any],
*,
plugin_revision: int,
factory_revision: str,
) -> "ToolCatalogSnapshot":
"""
从已完成上下文注入的精确实例建立不可变目录。
"""
entries = []
for tool in tools:
name = str(getattr(tool, "name", "") or "")
if not name:
raise ToolCatalogError("工具缺少稳定名称")
source = str(getattr(tool, "_agent_tool_source", "builtin"))
schema_digest = _schema_digest(tool)
description_digest = hashlib.sha256(str(getattr(tool, "description", "") or "").encode("utf-8")).hexdigest()
implementation = _implementation_identity(tool)
revision = ToolRevision(
implementation=implementation,
factory=factory_revision,
plugin=str(plugin_revision),
)
entries.append(
ToolCatalogEntry(
name=name,
source=source,
identity=f"{source}:{implementation}:{schema_digest}",
description_digest=description_digest,
schema_digest=schema_digest,
revision=revision,
tool=tool,
)
)
return cls(
entries=tuple(entries),
plugin_revision=plugin_revision,
factory_revision=factory_revision,
)
@property
def tools(self) -> list[Any]:
"""
按目录顺序返回精确工具实例。
"""
return [entry.tool for entry in self.entries]
@property
def collisions(self) -> Mapping[str, tuple[ToolCatalogEntry, ...]]:
"""
返回所有同名工具,不按注册顺序隐式选胜者。
"""
return MappingProxyType({name: entries for name, entries in self._by_name.items() if len(entries) > 1})
@property
def signature(self) -> tuple[Any, ...]:
"""
返回可参与 Agent 图缓存的完整目录签名。
"""
return (
self.factory_revision,
self.plugin_revision,
tuple(
(
entry.name,
entry.identity,
entry.description_digest,
entry.schema_digest,
)
for entry in self.entries
),
)
def audit_payload(self) -> dict[str, Any]:
"""
返回不含工具对象的目录快照,供评测比较实现、schema 和版本差异。
"""
signature_sha256 = hashlib.sha256(_stable_json(self.signature).encode("utf-8")).hexdigest()
entries = [
{
"name": entry.name,
"source": entry.source,
"identity": entry.identity,
"description_digest": entry.description_digest,
"schema_digest": entry.schema_digest,
"revision": {
"implementation": entry.revision.implementation,
"factory": entry.revision.factory,
"plugin": entry.revision.plugin,
},
}
for entry in self.entries
]
return {
"signature_sha256": signature_sha256,
"plugin_revision": self.plugin_revision,
"factory_revision": self.factory_revision,
"entries": entries,
}
def resolve_unique(self, name: str) -> Optional[ToolCatalogEntry]:
"""
严格解析当前唯一实现;重名时拒绝继承 first/last-wins。
"""
entries = self._by_name.get(name, ())
if len(entries) > 1:
raise ToolIdentityAmbiguousError("TOOL_IDENTITY_AMBIGUOUS")
return entries[0] if entries else None
def require_unique(self) -> "ToolCatalogSnapshot":
"""
拒绝任何同名身份,保证目录可直接交给执行图。
"""
if self.collisions:
names = ", ".join(sorted(self.collisions))
raise ToolIdentityAmbiguousError(f"TOOL_IDENTITY_AMBIGUOUS: {names}")
return self
def select(self, tools: list[Any]) -> "ToolCatalogSnapshot":
"""
为子图保留所选工具名的全部候选身份与 revision 语义。
"""
selected_names = {str(getattr(tool, "name", "") or "") for tool in tools}
return ToolCatalogSnapshot(
entries=tuple(entry for entry in self.entries if entry.name in selected_names),
plugin_revision=self.plugin_revision,
factory_revision=self.factory_revision,
)
__all__ = [
"ToolCatalogEntry",
"ToolCatalogError",
"ToolCatalogSnapshot",
"ToolIdentityAmbiguousError",
]