mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-09 23:30:13 +08:00
95 lines
5.8 KiB
Python
95 lines
5.8 KiB
Python
"""宿主绑定身份的学习工具;后台派发器不执行父图的任何业务工具。"""
|
||
|
||
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)
|