mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-14 01:39:45 +08:00
212 lines
7.3 KiB
Python
212 lines
7.3 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.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 = 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 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",
|
||
]
|