Files
MoviePilot/app/agent/tools/catalog.py

190 lines
6.1 KiB
Python

"""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 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 = args_schema.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:
"""绑定一次目录构造中精确工具实例的身份记录。"""
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:
"""同一图构造与严格执行共享的本地工具事实源。"""
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 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 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",
]