mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。 按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩, CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。 修复空值、异步和供应方类型边界,保持插件与 API 兼容合同, 并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
133 lines
6.3 KiB
Python
133 lines
6.3 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):
|
||
"""
|
||
技能枚举只接受当前宿主作用域,不允许传入任意目录。
|
||
"""
|
||
|
||
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,
|
||
"列出公共和当前用户技能。"
|
||
"个人技能不授予 API 权限;"
|
||
"公共 API 范围须通过 read_skill 获取。"),
|
||
('skill_view', self.skill_view, ViewSkillInput, ToolTag.Read,
|
||
"读取完整技能或支持文件。"
|
||
"后台复盘修改已有文件前,必须在本次复盘中先通过此工具读取该文件。"),
|
||
('skill_manage', self.skill_manage, ManageSkillsInput, ToolTag.Write,
|
||
"以最多 20 个原子操作维护当前用户技能。"
|
||
"create 用 content 提交完整 SKILL.md;"
|
||
"patch 用 old_string/new_string 局部修改或 content 完整重写;"
|
||
"write_file 用 file_path/file_content 写入;"
|
||
"remove_file 删除支持文件;"
|
||
"delete 必须独占批次。"
|
||
"公共技能受到保护。"),
|
||
('memory', self.manage_memory, MemoryInput, ToolTag.Write,
|
||
"管理精选记忆:target='user' 将跨任务偏好写入当前用户 USER.md;"
|
||
"target='memory' 将环境事实写入 MEMORY.md。"
|
||
"支持 add/replace/remove 或原子操作批次。"
|
||
"old_text 定位整条记忆,replace 的 content 替换整条内容。"
|
||
"目标为空或不存在时应使用 add 或核对目标映射。"
|
||
"容量上限为 memory 2200 字符、user 1375 字符。"
|
||
"action=pending 读取提案;"
|
||
"后台破坏性写入须通过 /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)
|