Files
MoviePilot/app/agent/learning/session.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

187 lines
7.9 KiB
Python
Raw Permalink 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.
"""
每会话学习计数、复盘任务 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)