mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
187 lines
7.9 KiB
Python
187 lines
7.9 KiB
Python
"""
|
||
每会话学习计数、复盘任务 owner 和人工记忆/技能管理命令。
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import threading
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
|
||
from langchain_core.messages import BaseMessage, HumanMessage
|
||
|
||
from app.agent.learning.memory import MemoryStore
|
||
from app.agent.learning.review import ReviewLoop, ReviewSnapshot
|
||
from app.agent.learning.skills import SkillLibrary
|
||
from app.agent.learning.tools import LearningTools
|
||
from app.agent.tools.base import run_agent_blocking
|
||
from app.agent.tools.result import TOOL_OBSERVATION_MARKER
|
||
from app.application.messaging.interaction.agent import is_agent_learning_command
|
||
from app.runtime.log import logger
|
||
from app.runtime.tasks import get_task_registry
|
||
|
||
|
||
@dataclass
|
||
class ReviewRun:
|
||
"""
|
||
独立令牌避免旧任务结束回调清掉新任务,并给阻塞写入线程提供取消屏障。
|
||
|
||
:param cancelled: 提供给阻塞存储线程的取消事件;置位后不得继续提交后台写入
|
||
:param task: 执行本次复盘的异步任务;尚未启动时为 None
|
||
"""
|
||
|
||
cancelled: threading.Event = field(default_factory=threading.Event)
|
||
task: asyncio.Task[Any] | None = None
|
||
|
||
|
||
class LearningSession:
|
||
"""
|
||
计数跨图缓存重建保留,默认每十轮触发记忆复盘、每十次工具迭代触发技能复盘。
|
||
"""
|
||
|
||
def __init__(self, *, session_id: str, skill_root: Path, memory_root: Path,
|
||
public_roots: tuple[Path, ...], on_usage: Callable[[dict[str, Any]], None]) -> None:
|
||
"""
|
||
会话与用户目录由宿主解析,后台和前台永不共享可变存储实例。
|
||
"""
|
||
self.session_id, self.skill_root, self.memory_root = session_id, skill_root, memory_root
|
||
self.public_roots, self.on_usage = public_roots, on_usage
|
||
self.tools = LearningTools(SkillLibrary(skill_root, public_roots=public_roots), MemoryStore(memory_root))
|
||
self.turns = self.iterations = 0
|
||
self.memory_due = False
|
||
self.initialized = self.closed = False
|
||
self.snapshot: ReviewSnapshot | None = None
|
||
self.run: ReviewRun | None = None
|
||
self.last_actions: list[dict[str, Any]] = []
|
||
|
||
def seal(self) -> None:
|
||
"""
|
||
关停同步入口立即禁止新复盘及后续后台写入。
|
||
"""
|
||
self.closed = True
|
||
self.cancel()
|
||
|
||
def cancel(self) -> None:
|
||
"""
|
||
先封存储再取消模型请求;底层线程即使晚退也不能继续批次提交。
|
||
"""
|
||
if self.run:
|
||
self.run.cancelled.set()
|
||
if self.run.task and not self.run.task.done():
|
||
self.run.task.cancel()
|
||
|
||
async def wait_cancelled(self) -> bool:
|
||
"""
|
||
前台只等两秒;超时保留 owner,不能伪称后台已退出。
|
||
"""
|
||
run = self.run
|
||
self.cancel()
|
||
if run and run.task and not run.task.done():
|
||
await asyncio.wait({run.task}, timeout=2)
|
||
return run.task.done()
|
||
return True
|
||
|
||
async def begin(self, messages: list[BaseMessage]) -> None:
|
||
"""
|
||
先让旧复盘退出,再推进真实用户轮次;恢复会话用历史 human 条数初始化。
|
||
"""
|
||
await self.wait_cancelled()
|
||
if not self.initialized:
|
||
self.turns = max(0, sum(isinstance(message, HumanMessage) and not message.additional_kwargs.get(TOOL_OBSERVATION_MARKER)
|
||
for message in messages) - 1) % 10
|
||
self.initialized = True
|
||
self.turns = (self.turns + 1) % 10
|
||
self.memory_due = self.turns == 0
|
||
self.snapshot = None
|
||
self.tools.skills.reset_reads()
|
||
self.tools.memory.reset_turn()
|
||
|
||
def observe(self, request: Any, result: Any) -> None:
|
||
"""
|
||
只捕获最终请求和本次返回;工具迭代按模型回合计数,不按并行工具个数计数。
|
||
"""
|
||
self.snapshot = ReviewSnapshot.capture(request, result)
|
||
response = getattr(result, 'model_response', result)
|
||
for message in response.result:
|
||
calls = getattr(message, 'tool_calls', [])
|
||
if calls:
|
||
self.iterations += 1
|
||
if any(call['name'] == 'skill_manage' for call in calls):
|
||
self.iterations = 0
|
||
|
||
def finish(self, *, delivered: bool) -> None:
|
||
"""
|
||
仅在最终回复交付和持久化成功后触发,已有未退出任务时不堆叠复盘。
|
||
"""
|
||
if not delivered or self.closed or not self.snapshot or self.run:
|
||
return
|
||
skill_due = self.iterations >= 10
|
||
if not self.memory_due and not skill_due:
|
||
return
|
||
if skill_due:
|
||
self.iterations = 0
|
||
run = ReviewRun()
|
||
snapshot = self.snapshot
|
||
self.run = run
|
||
try:
|
||
run.task = get_task_registry().create(self._review(run, snapshot, skill_due), owner='agent.learning.review')
|
||
except RuntimeError:
|
||
self.run = None
|
||
return
|
||
|
||
def complete(_task: asyncio.Task[Any]) -> None:
|
||
"""
|
||
即使任务尚未启动就被取消,也按身份清除 owner,不留下永久占位。
|
||
"""
|
||
if self.run is run:
|
||
self.run = None
|
||
|
||
run.task.add_done_callback(complete)
|
||
|
||
async def _review(self, run: ReviewRun, snapshot: ReviewSnapshot, skill_due: bool) -> None:
|
||
"""
|
||
后台没有用户输出回调或父图 checkpointer,失败只记录类型且不重放业务操作。
|
||
"""
|
||
tools = LearningTools(
|
||
SkillLibrary(self.skill_root, public_roots=self.public_roots, background=True,
|
||
source={'session_id': self.session_id, 'message_ids': [m.id for m in snapshot.messages[-20:] if m.id]},
|
||
cancelled=run.cancelled.is_set),
|
||
MemoryStore(self.memory_root, background=True, cancelled=run.cancelled.is_set),
|
||
)
|
||
loop = ReviewLoop(snapshot, tools, review_memory=self.memory_due, review_skills=skill_due,
|
||
cancelled=run.cancelled.is_set, on_usage=self.on_usage)
|
||
try:
|
||
self.last_actions = await loop.run()
|
||
logger.info('Agent 后台复盘结束:会话=%s,实际维护回执=%d,输入token=%d',
|
||
self.session_id, len(self.last_actions), loop.consumed)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as error:
|
||
logger.warning('Agent 后台复盘未完成:%s', type(error).__name__)
|
||
finally:
|
||
self.last_actions = list(loop.actions)
|
||
|
||
async def command(self, text: str) -> str | None:
|
||
"""
|
||
仅识别宿主传入的最新用户原文;模型工具无法自行审批提案或接管技能。
|
||
"""
|
||
match = re.fullmatch(r'/memory\s+(pending|approve|discard)(?:\s+([a-f0-9]{32}))?\s*', text)
|
||
skill = re.fullmatch(r'/skills\s+(adopt|pin|unpin|restore)\s+([a-z0-9][a-z0-9_-]{0,63})\s*', text)
|
||
if not match and not skill:
|
||
return ('学习命令:/memory pending、/memory approve ID、/memory discard ID;/skills adopt NAME、/skills pin NAME、/skills unpin NAME、/skills restore NAME。'
|
||
if is_agent_learning_command(text) else None)
|
||
try:
|
||
if skill:
|
||
result = await run_agent_blocking('default', self.tools.skills.control, skill[2], skill[1])
|
||
else:
|
||
assert match is not None
|
||
if match[1] == 'pending':
|
||
result = await run_agent_blocking('default', self.tools.memory.pending, match[2])
|
||
else:
|
||
result = await run_agent_blocking('default', self.tools.memory.resolve, match[2] or '', approve=match[1] == 'approve')
|
||
except (OSError, ValueError) as error:
|
||
result = dict(success=False, error=str(error))
|
||
return json.dumps(result, ensure_ascii=False, indent=2)
|