Files
hunjing/backend/app/application/agent/python_skills.py
T
2026-08-21 16:00:13 +08:00

133 lines
4.3 KiB
Python
Raw 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.
"""受信任 Python Skill 的发现、匹配与 Run 级激活。"""
from __future__ import annotations
import importlib
import inspect
import pkgutil
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from types import ModuleType
from typing import Any
from app.application.agent.extensions import ExtensionRegistry
SkillCleanup = Callable[[], Awaitable[None] | None]
SkillSetup = Callable[[ExtensionRegistry], SkillCleanup | None]
SkillMatcher = Callable[[str, dict[str, Any]], bool]
@dataclass(frozen=True)
class PythonSkillDefinition:
"""部署期 Python Skill 契约。"""
name: str
description: str
triggers: tuple[str, ...]
instructions: str
setup: SkillSetup
version: str = "1"
enabled: bool = True
match: SkillMatcher | None = None
@dataclass
class PythonSkillActivation:
"""单个 Run 的 Skill 激活结果。"""
skills: tuple[PythonSkillDefinition, ...]
cleanups: list[SkillCleanup]
async def close(self) -> None:
"""按激活逆序释放 Skill 资源。"""
while self.cleanups:
result = self.cleanups.pop()()
if inspect.isawaitable(result):
await result
class PythonSkillCatalog:
"""只保存 Skill 描述与 setup,不持有 Run 状态。"""
def __init__(self, skills: Iterable[PythonSkillDefinition] = ()) -> None:
self._skills: dict[str, PythonSkillDefinition] = {}
for skill in skills:
if skill.name in self._skills:
raise ValueError(f"Python Skill 名称重复:{skill.name}")
self._skills[skill.name] = skill
@property
def skills(self) -> tuple[PythonSkillDefinition, ...]:
return tuple(self._skills.values())
def match(
self,
context_text: str,
*,
skill_refs: tuple[str, ...] = (),
metadata: dict[str, Any] | None = None,
) -> tuple[PythonSkillDefinition, ...]:
"""返回显式引用、关键词或自定义匹配器命中的 enabled Skills。"""
refs = set(skill_refs)
text = context_text.casefold()
match_metadata = metadata or {}
return tuple(
skill
for skill in self._skills.values()
if skill.enabled
and (
skill.name in refs
or any(trigger.casefold() in text for trigger in skill.triggers if trigger)
or (skill.match is not None and skill.match(context_text, match_metadata))
)
)
async def activate_python_skills(
registry: ExtensionRegistry,
skills: Iterable[PythonSkillDefinition],
) -> PythonSkillActivation:
"""把命中的 Skills 注册到当前 Run,并在部分失败时清理已激活项。"""
activation = PythonSkillActivation(tuple(skills), [])
try:
for skill in activation.skills:
cleanup = skill.setup(registry)
if cleanup is not None:
activation.cleanups.append(cleanup)
except Exception:
await activation.close()
raise
return activation
def discover_python_skills(packages: Iterable[str] = ()) -> PythonSkillCatalog:
"""从受信任 packages 发现定义,但不执行 Skill setup。"""
modules: list[ModuleType] = []
for package_name in dict.fromkeys(packages):
package = importlib.import_module(package_name)
package_path = getattr(package, "__path__", None)
if package_path is None:
raise TypeError(f"Python Skill 包必须是 package:{package_name}")
modules.extend(
importlib.import_module(module.name)
for module in pkgutil.iter_modules(package_path, f"{package.__name__}.")
if not module.name.rsplit(".", 1)[-1].startswith("_")
)
definitions: list[PythonSkillDefinition] = []
for module in sorted(modules, key=lambda item: item.__name__):
skill = getattr(module, "SKILL", None)
if not isinstance(skill, PythonSkillDefinition):
raise TypeError(f"Python Skill 缺少 SKILL 定义:{module.__name__}")
definitions.append(skill)
return PythonSkillCatalog(definitions)
__all__ = [
"PythonSkillActivation",
"PythonSkillCatalog",
"PythonSkillDefinition",
"activate_python_skills",
"discover_python_skills",
]