Files
MoviePilot/app/agent/learning/tools.py
2026-10-01 16:45:38 +08:00

95 lines
5.8 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.
"""宿主绑定身份的学习工具;后台派发器不执行父图的任何业务工具。"""
import json
from pathlib import Path
from typing import Any
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, ConfigDict
from app.agent.learning.memory import MemoryStore
from app.agent.learning.schema import ManageSkillsInput, MemoryInput, ViewSkillInput
from app.agent.learning.skills import SkillLibrary
from app.agent.tools.base import run_agent_blocking
from app.agent.tools.tags import ToolTag
class ListSkillsInput(BaseModel): # type: ignore[misc]
"""技能枚举只接受当前宿主作用域,不允许传入任意目录。"""
model_config = ConfigDict(extra='forbid')
class LearningTools:
"""前台与复盘共用 schema,后台实例有独立读取记录和写入取消令牌。"""
def __init__(self, skills: SkillLibrary, memory: MemoryStore) -> None:
"""两个存储均由宿主按已认证用户构造。"""
self.skills, self.memory = skills, memory
definitions = (
('skills_list', self.skills_list, ListSkillsInput, ToolTag.Read,
'List public and current-user skills. Personal skills never grant API permissions; use read_skill for public API scopes.'),
('skill_view', self.skill_view, ViewSkillInput, ToolTag.Read,
'Read a complete skill or its support file. Before updating an existing file during background review, read it here in this review.'),
('skill_manage', self.skill_manage, ManageSkillsInput, ToolTag.Write,
'Maintain current-user skills with up to 20 atomic operations. create content=full SKILL.md; patch old_string/new_string or content; write_file file_path/file_content; remove_file; delete alone. Public skills are protected.'),
('memory', self.manage_memory, MemoryInput, ToolTag.Write,
"Curated memory: target='user' writes the current user's USER.md for cross-task preferences; target='memory' writes MEMORY.md for environment facts. add/replace/remove or atomic operations. old_text locates an entry; replace content replaces the WHOLE entry. If the target is empty or missing, use add or verify the target mapping. Limits: memory 2200, user 1375 chars. action=pending reads proposals. Background destructive writes require explicit /memory approve ID."),
)
self.tools = []
for name, function, schema, tag, description in definitions:
tool = StructuredTool.from_function(coroutine=function, name=name, description=description,
args_schema=schema, tags=[tag, ToolTag.System, ToolTag.Skill])
object.__setattr__(tool, '_agent_tool_source', 'middleware:learning')
self.tools.append(tool)
async def skills_list(self) -> str:
"""只返回有界路由目录,正文按需读取。"""
catalog = await run_agent_blocking('default', self.skills.catalog)
return json.dumps({'success': True, 'skills': [dict(name=item['name'], description=item['description'][:60], path=item['path'])
for item in catalog]}, ensure_ascii=False)
async def skill_view(self, **kwargs: Any) -> str:
"""返回完整读取结果后才登记写前读取;失败不伪造空文件。"""
args = ViewSkillInput(**kwargs)
return await self._execute(self.skills.view, args.name, args.file_path)
async def skill_manage(self, **kwargs: Any) -> str:
"""只通过有所有权、路径与事务保护的技能管理器修改。"""
return await self._execute(self.skills.manage, ManageSkillsInput(**kwargs))
async def manage_memory(self, **kwargs: Any) -> str:
"""记忆管理只操作绑定用户的两份稳定文件。"""
return await self._execute(self.memory.manage, MemoryInput(**kwargs))
@staticmethod
async def _execute(function: Any, *args: Any) -> str:
"""受控阻塞执行器保留取消后的线程所有权,给模型可纠正的失败回执。"""
try:
result = await run_agent_blocking('default', function, *args)
except (OSError, ValueError, RuntimeError) as error:
result = dict(success=False, error=str(error))
return json.dumps(result, ensure_ascii=False)
def read_file(self, path: str) -> dict[str, Any]:
"""后台 read_file 仅映射已发现技能文件,读取后同样登记精确版本。"""
target = Path(path).expanduser().resolve()
for metadata in self.skills.catalog():
directory = Path(metadata['path']).parent
if target.is_relative_to(directory):
return self.skills.view(metadata['name'], target.relative_to(directory).as_posix())
raise ValueError('后台复盘只能读取技能包内文件;使用 skills_list/skill_view 定位')
async def dispatch(self, name: str, arguments: dict[str, Any], *, review_memory: bool) -> str:
"""白名单只约束执行,广告 schema 与前台一致;不回调任何父工具实例。"""
if name == 'memory' and not review_memory:
return json.dumps(dict(success=False, error='本次仅触发技能复盘;memory 不可用'), ensure_ascii=False)
for tool in self.tools:
if tool.name == name:
return str(await tool.ainvoke(arguments))
if name == 'read_skill':
return await self.skill_view(name=arguments.get('name'), file_path=arguments.get('file_path') or 'SKILL.md')
if name == 'read_file':
return await self._execute(self.read_file, str(arguments.get('path') or arguments.get('file_path') or ''))
return json.dumps(dict(success=False, error='后台复盘不能执行此工具。请用 skills_list/skill_view 读取,skill_manage 维护技能,memory 保存稳定事实。'), ensure_ascii=False)