feat(agent): unify memory retrieval and activity history

This commit is contained in:
jxxghp
2026-09-16 18:24:30 +08:00
parent 207e1112dc
commit 3eb339b8e0
24 changed files with 1497 additions and 1669 deletions

View File

@@ -505,8 +505,8 @@ class StreamingHandler:
if tool_name in {"read_skill", "skill"}:
return "skill", tool_kwargs.get("name")
if tool_name == "query_activity_log":
return "activity_log", tool_kwargs.get("keyword") or tool_kwargs.get("date")
if tool_name == "search_memory":
return "memory", tool_kwargs.get("query") or tool_kwargs.get("category")
if tool_name == "subagent_task":
return "subagent", StreamingHandler._extract_subagent_targets(tool_kwargs)
if tool_name == "task":
@@ -671,8 +671,8 @@ class StreamingHandler:
return f"查询了 {count} 次数据"
if category == "skill":
return f"查询了 {count} 个技能说明"
if category == "activity_log":
return f"查询{count}活动日志"
if category == "memory":
return f"检索{count}记忆"
if category == "action":
return f"执行了 {count} 次操作"
if category == "interaction":

View File

@@ -13,7 +13,7 @@ deprecated_phrases: []
1. 核心系统提示词(程序内置,不可运行时覆盖)
2. `personas/<active_persona>/PERSONA.md`
3. `extra_context_files`
4. `memory/*.md`
5. `activity/*.md`
4. `memory/MEMORY.md`(默认注入)
5. `memory/<topic>.md``memory/activity/*.md`(通过 search_memory 按需检索)
`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。

View File

@@ -1,771 +0,0 @@
"""
活动日志中间件 - 自动记录 Agent 每次交互的操作摘要。
按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中,
每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要,
系统提示词只注入稳定的检索规则,完整日志由工具按需查询。
"""
import asyncio
import json
import os
import re
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, Any, NotRequired, Optional, TypedDict
import anyio
from anyio import Path as AsyncPath
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
ModelResponse,
PrivateStateAttr, # noqa
ResponseT,
ToolCallRequest,
)
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import StructuredTool
from langgraph.runtime import Runtime
from pydantic import BaseModel, Field
from app.agent.middleware.utils import append_to_system_message
from app.agent.policy.sanitizer import (
sanitize_for_host,
summarize_error,
summarize_result,
)
from app.agent.tools.tags import ToolTag
from app.runtime.log import logger
from app.runtime.tasks import TaskRegistry, get_task_registry
# 活动日志保留天数
DEFAULT_RETENTION_DAYS = 7
# 注入系统提示词时索引的天数
PROMPT_LOAD_DAYS = 3
# 工具默认查询的天数
DEFAULT_QUERY_DAYS = 7
# 工具单次返回的最大条数
DEFAULT_QUERY_LIMIT = 20
MAX_QUERY_LIMIT = 50
# 每日日志文件最大大小 (256KB)
MAX_LOG_FILE_SIZE = 256 * 1024
# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token
MAX_CONTEXT_FOR_SUMMARY = 4000
SUMMARY_SKIP_MARKER = "SKIP"
QUERY_ACTIVITY_LOG_TOOL_NAME = "query_activity_log"
QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION = (
"Query recent MoviePilot Agent activity logs on demand. Use this when the user asks what was done before, "
"asks to continue a previous task, or explicitly references recent agent activity. Supports keyword, date, "
"recent-day window, limit, and optional regex filters. If a keyword search returns no results, retry with "
"a shorter keyword, a larger days window, or no keyword to inspect recent entries."
)
# LLM 总结的提示词
SUMMARY_PROMPT = """请判断以下 AI 助手与用户的对话是否值得写入 MoviePilot 活动日志。
如果本轮只是问候、寒暄、感谢、确认、闲聊、没有实际任务、没有工具动作、任务没有推进、纯粹的格式纠正或无意义空转请只输出SKIP
如果值得记录,请输出一条中文单行活动摘要,要求:
- 40 到 160 个汉字左右,信息密度高,不要写成泛泛一句话。
- 只输出摘要正文不要标题、编号、Markdown、JSON 或解释。
- 尽量包含:用户目标、关键对象(影片/剧集/站点/路径/任务/设置)、助手采取的关键动作或工具、结果状态、失败原因或下一步。
- 如果有明确 ID、路径、站点名、任务状态、成功/失败数量,请保留关键值。
- 不要记录 API Key、Cookie、Token、密码等敏感信息如出现请写成“敏感信息已省略”。
推荐格式示例:
用户要求整理 `/downloads/Show`助手识别为《示例剧》TMDB 12345并提交 transfer_file 整理,结果成功。
用户排查下载失败,助手查询 qBittorrent 任务和站点状态,发现 tracker 超时,建议更换站点或重试。
对话记录:
{conversation}"""
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
def _write_activity_log_exclusive(path: Path, content: str) -> bool:
"""同步独占创建日志文件;调用方必须在线程池中执行本函数。"""
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
return False
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(content)
return True
class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。"""
keyword: Optional[str] = Field(
None,
description=(
"Optional plain-text keyword to filter activity summaries. Use short title, path, site, task, "
"or status fragments; omit it to inspect latest entries."
),
)
use_regex: Optional[bool] = Field(
False,
description=(
"Whether to treat keyword as a regular expression. Defaults to false; enable only for "
"alternative or pattern matching."
),
)
date: Optional[str] = Field(
None,
description="Optional exact date in YYYY-MM-DD format. If omitted, recent days are searched.",
)
days: Optional[int] = Field(
DEFAULT_QUERY_DAYS,
description="Number of recent days to search when date is not specified.",
)
limit: Optional[int] = Field(
DEFAULT_QUERY_LIMIT,
description="Maximum number of activity entries to return.",
)
def _coerce_query_limit(limit: Optional[int]) -> int:
"""规范化活动日志查询条数。"""
if limit is None:
return DEFAULT_QUERY_LIMIT
try:
value = int(limit)
except (TypeError, ValueError):
return DEFAULT_QUERY_LIMIT
return min(max(value, 1), MAX_QUERY_LIMIT)
def _build_log_path(activity_dir: str, date_str: str) -> Path:
"""构建指定日期的活动日志路径。"""
return Path(activity_dir) / f"{date_str}.md"
def _iter_recent_dates(days: int) -> list[str]:
"""返回从今天开始向前的日期字符串列表。"""
normalized_days = max(1, int(days or 1))
today = datetime.now().date()
return [
(today - timedelta(days=index)).strftime("%Y-%m-%d")
for index in range(normalized_days)
]
def _parse_activity_entries(date_str: str, content: str) -> list[dict[str, str]]:
"""从单日活动日志 Markdown 中解析活动条目。"""
entries: list[dict[str, str]] = []
for line in content.splitlines():
match = ACTIVITY_ENTRY_PATTERN.match(line.strip())
if not match:
continue
entries.append(
{
"date": date_str,
"time": match.group("time"),
"summary": match.group("summary").strip(),
}
)
return entries
def _activity_summary_matches_keyword(
summary: str,
keyword: str,
regex_pattern: Optional[re.Pattern[str]],
) -> bool:
"""判断活动摘要是否命中普通关键词或正则表达式。"""
if regex_pattern:
return bool(regex_pattern.search(summary))
return keyword.lower() in summary.lower()
def load_activity_log_index(activity_dir: str, days: int = PROMPT_LOAD_DAYS) -> dict[str, str]:
"""加载近期活动日志索引,不返回完整日志正文。"""
index: dict[str, str] = {}
for date_str in _iter_recent_dates(days):
log_path = _build_log_path(activity_dir, date_str)
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(
f"读取活动日志索引失败 {log_path}: {summarize_error(e)}"
)
continue
entry_count = len(_parse_activity_entries(date_str, content))
if entry_count:
index[date_str] = f"{entry_count} 条活动记录"
return index
def query_activity_logs(
activity_dir: str,
*,
keyword: Optional[str] = None,
use_regex: bool = False,
date: Optional[str] = None,
days: int = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
) -> dict[str, Any]:
"""
查询活动日志条目。
:param activity_dir: 活动日志目录
:param keyword: 可选关键词,按摘要文本过滤
:param use_regex: 是否将关键词按正则表达式匹配
:param date: 可选日期,格式为 ``YYYY-MM-DD``
:param days: 未指定日期时向前查询的天数
:param limit: 返回条数上限
:return: 查询结果载荷
"""
normalized_limit = _coerce_query_limit(limit)
normalized_keyword = str(keyword or "").strip()
normalized_use_regex = bool(use_regex)
regex_pattern: Optional[re.Pattern[str]] = None
if normalized_keyword and normalized_use_regex:
try:
regex_pattern = re.compile(normalized_keyword, re.IGNORECASE)
except re.error as err:
return {
"success": False,
"message": f"无效的活动日志正则表达式: {err}",
"activity_dir": activity_dir,
"keyword": normalized_keyword,
"use_regex": normalized_use_regex,
"date": date,
"days": days if not date else None,
"searched_dates": [],
"total_count": 0,
"returned_count": 0,
"truncated": False,
"entries": [],
}
date_candidates = [date] if date else _iter_recent_dates(days)
entries: list[dict[str, str]] = []
searched_dates: list[str] = []
for date_str in date_candidates:
if not date_str:
continue
searched_dates.append(date_str)
log_path = _build_log_path(activity_dir, date_str)
if not log_path.is_file():
continue
try:
content = log_path.read_text(encoding="utf-8", errors="replace")
except Exception as e:
logger.warning(f"读取活动日志失败 {log_path}: {summarize_error(e)}")
continue
for entry in _parse_activity_entries(date_str, content):
if normalized_keyword and not _activity_summary_matches_keyword(
entry["summary"], normalized_keyword, regex_pattern
):
continue
entries.append(entry)
entries.sort(key=lambda item: (item["date"], item["time"]), reverse=True)
total_count = len(entries)
return {
"success": True,
"activity_dir": activity_dir,
"keyword": normalized_keyword or None,
"use_regex": normalized_use_regex,
"date": date,
"days": days if not date else None,
"searched_dates": searched_dates,
"total_count": total_count,
"returned_count": min(total_count, normalized_limit),
"truncated": total_count > normalized_limit,
"entries": entries[:normalized_limit],
}
class _ActivityLogToolProvider:
"""活动日志工具的查询实现。"""
def __init__(self, *, activity_dir: str) -> None:
"""初始化活动日志查询目录。"""
self._activity_dir = activity_dir
async def query_activity_log(
self,
keyword: Optional[str] = None,
use_regex: Optional[bool] = False,
date: Optional[str] = None,
days: Optional[int] = DEFAULT_QUERY_DAYS,
limit: Optional[int] = DEFAULT_QUERY_LIMIT,
) -> str:
"""查询活动日志并返回 JSON 字符串。"""
logged_args = sanitize_for_host(
{
"keyword": keyword,
"use_regex": use_regex,
"date": date,
"days": days,
"limit": limit,
}
)
logger.info(f"查询活动日志: args={logged_args}")
try:
payload = query_activity_logs(
self._activity_dir,
keyword=keyword,
use_regex=bool(use_regex),
date=date,
days=days or DEFAULT_QUERY_DAYS,
limit=limit,
)
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as err:
error_summary = summarize_error(err)
logger.error(f"查询活动日志失败: {error_summary}")
return json.dumps(
{
"success": False,
"message": f"查询活动日志时发生错误: {error_summary}",
},
ensure_ascii=False,
)
class ActivityLogState(AgentState):
"""ActivityLogMiddleware 的状态模型。"""
activity_log_contents: NotRequired[Annotated[dict[str, str], PrivateStateAttr]]
"""将日期字符串映射到日志索引摘要的字典。标记为私有,不包含在最终代理状态中。"""
class ActivityLogStateUpdate(TypedDict):
"""ActivityLogMiddleware 的状态更新。"""
activity_log_contents: dict[str, str]
def _extract_last_round(messages: list) -> Optional[list]:
"""从完整消息列表中提取最后一轮交互。
从最后一条 HumanMessage 到消息末尾即为本轮交互。
参数:
messages: Agent 执行后的完整消息列表。
返回:
本轮交互的消息子列表,如果无有效交互则返回 None。
"""
if not messages:
return None
# 找到最后一条用户消息的索引
last_human_idx = None
for i in range(len(messages) - 1, -1, -1):
if isinstance(messages[i], HumanMessage) and messages[i].content:
last_human_idx = i
break
if last_human_idx is None:
return None
round_messages = messages[last_human_idx:]
# 检查是否为系统心跳消息
user_msg = round_messages[0]
user_content = (
user_msg.content if isinstance(user_msg.content, str) else str(user_msg.content)
)
if user_content.strip().startswith("[System Heartbeat]"):
return None
return round_messages
def _format_conversation_for_summary(round_messages: list) -> str:
"""将本轮对话消息格式化为文本,供 LLM 总结。
参数:
round_messages: 本轮交互的消息列表。
返回:
格式化后的对话文本。
"""
lines = []
total_len = 0
for msg in round_messages:
if isinstance(msg, HumanMessage):
content = msg.content if isinstance(msg.content, str) else str(msg.content)
line = f"用户: {content}"
elif isinstance(msg, AIMessage):
if hasattr(msg, "tool_calls") and msg.tool_calls:
tool_names = [
tc["name"]
for tc in msg.tool_calls
if isinstance(tc, dict) and "name" in tc
]
line = f"助手调用工具: {', '.join(tool_names)}"
elif msg.content:
content = (
msg.content if isinstance(msg.content, str) else str(msg.content)
)
line = f"助手: {content}"
else:
continue
elif isinstance(msg, ToolMessage):
content = msg.content if isinstance(msg.content, str) else str(msg.content)
# 工具返回可能很长,截断
if len(content) > 200:
content = content[:200] + "..."
line = f"工具返回: {content}"
else:
continue
# 控制总长度
if total_len + len(line) > MAX_CONTEXT_FOR_SUMMARY:
lines.append("...(后续对话省略)")
break
lines.append(line)
total_len += len(line)
return "\n".join(lines)
def _should_skip_activity_summary(round_messages: list) -> bool:
"""判断本轮交互是否无需生成活动日志。"""
if not round_messages:
return True
has_tool_activity = any(
isinstance(msg, ToolMessage)
or (isinstance(msg, AIMessage) and bool(getattr(msg, "tool_calls", None)))
for msg in round_messages
)
if has_tool_activity:
return False
return True
async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
"""调用 LLM 对对话文本生成活动摘要。
参数:
conversation_text: 格式化后的对话文本。
返回:
LLM 生成的摘要字符串,失败时返回 None。
"""
try:
from app.agent.llm.helper import LLMHelper
llm = await LLMHelper.get_llm(streaming=False)
prompt = SUMMARY_PROMPT.format(conversation=conversation_text)
response = await llm.ainvoke(prompt)
summary = LLMHelper.extract_text_content(response.content).strip()
# 清理模型可能输出的前缀(如 "摘要:" "总结:"
summary = re.sub(r"^(摘要|总结|活动记录)[:]\s*", "", summary)
if summary.strip().upper() == SUMMARY_SKIP_MARKER:
return None
return summary if summary else None
except Exception as e:
logger.debug(f"LLM 活动摘要生成失败: {summarize_error(e)}")
return None
ACTIVITY_LOG_SYSTEM_PROMPT = """<activity_log>
<activity_log_guidelines>
Activity log contents and indexes are not included in the default context.
Use `query_activity_log` only when the user references previous work, asks to continue a prior task, or recent activity is clearly relevant.
Activity logs are read-only and retained for {retention_days} days; use MEMORY.md for durable preferences.
</activity_log_guidelines>
</activity_log>
"""
class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, ResponseT]): # noqa
"""自动记录 Agent 活动日志并注入稳定检索规则的中间件。
- abefore_agent: 加载近几天的活动日志索引
- awrap_model_call: 将固定的活动日志检索规则注入系统提示词
- aafter_agent: 从本次对话中提取摘要并追加到当日日志文件
参数:
activity_dir: 活动日志存储目录路径。
retention_days: 日志保留天数(默认 7 天)。
prompt_load_days: 注入系统提示词时索引的天数(默认 3 天)。
"""
state_schema = ActivityLogState
def __init__(
self,
*,
activity_dir: str,
retention_days: int = DEFAULT_RETENTION_DAYS,
prompt_load_days: int = PROMPT_LOAD_DAYS,
stream_handler: Optional[Any] = None,
task_registry: Optional[TaskRegistry] = None,
) -> None:
"""初始化活动日志中间件,并绑定宿主后台任务 owner。"""
self.activity_dir = activity_dir
self.retention_days = retention_days
self.prompt_load_days = prompt_load_days
self.stream_handler = stream_handler
self._task_registry = task_registry or get_task_registry()
self._background_tasks: set[asyncio.Task[None]] = set()
self._tool_provider = _ActivityLogToolProvider(activity_dir=activity_dir)
self.tools = [
StructuredTool.from_function(
coroutine=self._tool_provider.query_activity_log,
name=QUERY_ACTIVITY_LOG_TOOL_NAME,
description=QUERY_ACTIVITY_LOG_TOOL_DESCRIPTION,
args_schema=QueryActivityLogInput,
tags=[ToolTag.Read, ToolTag.System],
)
]
def _get_log_path(self, date_str: str) -> AsyncPath:
"""获取指定日期的日志文件路径。"""
return AsyncPath(self.activity_dir) / f"{date_str}.md"
def _format_activity_log(self, _contents: dict[str, str]) -> str:
"""生成不受活动日志内容变化影响的系统提示词。"""
return ACTIVITY_LOG_SYSTEM_PROMPT.format(
retention_days=self.retention_days,
)
async def _load_recent_logs(self) -> dict[str, str]:
"""加载近几天的活动日志索引。"""
return load_activity_log_index(
activity_dir=self.activity_dir,
days=self.prompt_load_days,
)
async def _append_activity(self, summary: str) -> None:
"""将一条活动记录追加到当日日志文件。"""
today_str = datetime.now().strftime("%Y-%m-%d")
now_str = datetime.now().strftime("%H:%M")
log_path = self._get_log_path(today_str)
# 确保目录存在
dir_path = AsyncPath(self.activity_dir)
if not await dir_path.exists():
await dir_path.mkdir(parents=True, exist_ok=True)
# 检查文件大小
if await log_path.exists():
stat = await log_path.stat()
if stat.st_size >= MAX_LOG_FILE_SIZE:
logger.warning(
"Activity log %s exceeds size limit (%d bytes), skipping append",
today_str,
stat.st_size,
)
return
# 追加记录
entry = f"- **{now_str}** {summary}\n"
try:
if await log_path.exists():
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
else:
header = f"# {today_str} 活动日志\n\n"
created = await anyio.to_thread.run_sync(
_write_activity_log_exclusive,
Path(log_path),
header + entry,
)
if not created:
async with await anyio.open_file(
log_path,
mode="a",
encoding="utf-8",
) as stream:
await stream.write(entry)
logger.debug(f"Activity logged: {summarize_result(summary, max_chars=80)}")
except Exception as e:
logger.warning(f"Failed to append activity log: {summarize_error(e)}")
async def _cleanup_old_logs(self) -> None:
"""清理超过保留天数的旧日志文件。"""
dir_path = AsyncPath(self.activity_dir)
if not await dir_path.exists():
return
cutoff_date = datetime.now().date() - timedelta(days=self.retention_days)
date_pattern = re.compile(r"^(\d{4}-\d{2}-\d{2})\.md$")
try:
async for path in dir_path.iterdir():
if not await path.is_file():
continue
match = date_pattern.match(path.name)
if not match:
continue
try:
file_date = datetime.strptime(match.group(1), "%Y-%m-%d").date()
if file_date < cutoff_date:
await path.unlink()
logger.debug(f"Cleaned up old activity log: {path.name}")
except ValueError:
continue
except Exception as e:
logger.warning(
f"Failed to cleanup old activity logs: {summarize_error(e)}"
)
def _schedule_activity_recording(self, messages: list) -> None:
"""提交后台活动记录任务,不阻塞当前 Agent 会话结束。"""
task = self._task_registry.create(
self._record_activity(messages),
owner="agent.activity_log.record",
)
self._background_tasks.add(task)
task.add_done_callback(self._on_activity_recording_done)
def _on_activity_recording_done(self, task: asyncio.Task[None]) -> None:
"""清理已完成的后台任务并记录未捕获异常。"""
self._background_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.debug("活动日志后台记录任务已取消")
except Exception as err:
logger.warning(f"活动日志后台记录任务失败: {summarize_error(err)}")
async def _record_activity(self, messages: list) -> None:
"""在后台生成本轮活动摘要并写入活动日志。"""
try:
# 提取本轮交互
round_messages = _extract_last_round(messages)
if not round_messages:
return
if _should_skip_activity_summary(round_messages):
return
# 格式化对话文本
conversation_text = _format_conversation_for_summary(round_messages)
if not conversation_text:
return
# 调用 LLM 生成摘要
summary = await _summarize_with_llm(conversation_text)
if summary:
await self._append_activity(summary)
except Exception as e:
logger.warning(f"Failed to record activity: {summarize_error(e)}")
async def abefore_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[ActivityLogStateUpdate]:
"""在 Agent 执行前加载近期活动日志。"""
contents = await self._load_recent_logs()
# 趁机清理旧日志(低频操作,不影响性能)
await self._cleanup_old_logs()
return ActivityLogStateUpdate(activity_log_contents=contents)
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]:
"""将活动日志注入系统消息。"""
contents = request.state.get("activity_log_contents", {}) # noqa
activity_log_prompt = self._format_activity_log(contents)
new_system_message = append_to_system_message(
request.system_message, activity_log_prompt
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
"""异步包装模型调用,注入活动日志到系统提示词。"""
modified_request = self.modify_request(request)
return await handler(modified_request)
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[Any]],
) -> Any:
"""在活动日志查询工具执行时输出当前模式对应的执行信息。"""
tool = request.tool
tool_name = getattr(tool, "name", None)
if tool_name != QUERY_ACTIVITY_LOG_TOOL_NAME:
return await handler(request)
tool_call = request.tool_call or {}
tool_args = tool_call.get("args") or {}
if not isinstance(tool_args, dict):
tool_args = {}
logged_args = sanitize_for_host(tool_args)
if not isinstance(logged_args, dict):
logged_args = {}
logger.info(
f"开始执行活动日志查询工具: keyword={logged_args.get('keyword') or '-'}, "
f"date={logged_args.get('date') or '-'}"
)
tool_call_id = ""
if self.stream_handler and getattr(self.stream_handler, "is_streaming", False):
display_args = json.dumps(logged_args, ensure_ascii=False, default=str)
tool_call_id = self.stream_handler.report_tool_call(
tool_name=QUERY_ACTIVITY_LOG_TOOL_NAME,
tool_message=f"查询活动日志,主要参数:{display_args}",
tool_kwargs=tool_args,
)
try:
result = await handler(request)
except Exception as err:
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "error")
logger.error(
f"活动日志查询工具执行失败: error={summarize_error(err)}"
)
raise
if tool_call_id:
finish_tool_call = getattr(self.stream_handler, "tool_call_finished", None)
if callable(finish_tool_call):
finish_tool_call(tool_call_id, "done")
logger.info("活动日志查询工具执行完成")
return result
async def aafter_agent(
self, state: ActivityLogState, runtime: Runtime
) -> Optional[dict[str, Any]]:
"""Agent 执行完毕后,异步提交活动日志记录任务。"""
try:
messages = state.get("messages", [])
if not messages:
return None
self._schedule_activity_recording(list(messages))
except Exception as e:
logger.warning(f"Failed to record activity: {summarize_error(e)}")
return None
__all__ = [
"ActivityLogMiddleware",
"QUERY_ACTIVITY_LOG_TOOL_NAME",
"load_activity_log_index",
"query_activity_logs",
]

File diff suppressed because it is too large Load Diff

View File

@@ -97,11 +97,16 @@ class VisionMiddleware(AgentMiddleware): # type: ignore[misc]
return
if pending:
raise ValueError("工具回复尚未完整,不能发送图片观察")
output.append(HumanMessage(
output.append(VisionMiddleware._build_observation_message(observations))
observations.clear()
@staticmethod
def _build_observation_message(observations: list[dict[str, Any]]) -> HumanMessage:
"""把工具文字与图片组装成模型可见的临时 HumanMessage。"""
return HumanMessage(
content=list(observations),
additional_kwargs={TOOL_OBSERVATION_MARKER: True},
))
observations.clear()
)
def project_request(self, request: ModelRequest, *, force_text: bool = False) -> ModelRequest:
"""构造独立出站副本;同一请求反复投影不会改变原图或累积临时用户消息。"""

View File

@@ -28,7 +28,6 @@ from app.agent.llm.helper import LLMHelper
from app.agent.llm.tools import ServerToolRegistry
from app.agent.mcp import agent_mcp_manager
from app.agent.memory import MemoryManager, memory_manager
from app.agent.middleware.activity import ActivityLogMiddleware
from app.agent.middleware.config import RuntimeConfigMiddleware
from app.agent.middleware.invocation import InvocationMiddleware
from app.agent.middleware.jobs import (
@@ -1868,14 +1867,16 @@ class MoviePilotAgent:
stream_handler=self.stream_handler,
)
skill_tools = list(getattr(skills_middleware, "tools", []) or [])
activity_log_middleware = None
activity_log_tools = []
if self.has_message_context:
activity_log_middleware = ActivityLogMiddleware(
activity_dir=str(agent_runtime_manager.activity_dir),
memory_middleware = MemoryMiddleware(
memory_dir=str(agent_runtime_manager.memory_dir),
activity_dir=(
str(agent_runtime_manager.activity_dir)
if self.has_message_context
else None
),
stream_handler=self.stream_handler,
)
activity_log_tools = list(getattr(activity_log_middleware, "tools", []) or [])
memory_tools = list(getattr(memory_middleware, "tools", []) or [])
policy_context = self._build_policy_context()
subagent_middlewares, subagent_task_tools = create_subagent_middlewares(
model=non_streaming_model,
@@ -1891,7 +1892,7 @@ class MoviePilotAgent:
invocation_repository = getattr(self._data, "invocations", None)
invocation_middlewares = [InvocationMiddleware(policy_context, invocation_repository, tools)] if invocation_repository else []
internal_tools = [
*skill_tools, *activity_log_tools, *subagent_task_tools, *plan_middleware.tools,
*skill_tools, *memory_tools, *subagent_task_tools, *plan_middleware.tools,
*output_middleware.tools,
*(tool for middleware in invocation_middlewares for tool in middleware.tools),
]
@@ -1953,10 +1954,8 @@ class MoviePilotAgent:
RuntimeConfigMiddleware(),
# 计划独立保存,最终请求压缩仍计入其系统上下文预算。
plan_middleware,
# 记忆管理
MemoryMiddleware(memory_dir=str(agent_runtime_manager.memory_dir)),
# 活动日志依赖记忆上下文,并应在最终请求压缩前完成读取与记录。
*([activity_log_middleware] if activity_log_middleware else []),
# 记忆、按需检索与活动记录统一由一个中间件管理
memory_middleware,
# 错误工具调用修复
PatchToolCallsMiddleware(),
# 子代理委派
@@ -1989,7 +1988,7 @@ class MoviePilotAgent:
tools=[
*tools,
*skill_tools,
*activity_log_tools,
*memory_tools,
*server_tools,
],
system_prompt=system_prompt,

View File

@@ -250,7 +250,8 @@ class AgentRuntimeManager:
self.memory_dir = self.agent_root_dir / MEMORY_DIR
self.skills_dir = self.agent_root_dir / SKILLS_DIR
self.jobs_dir = self.agent_root_dir / JOBS_DIR
self.activity_dir = self.agent_root_dir / ACTIVITY_DIR
# 活动记忆属于统一 memory 域;旧的 agent/activity 目录不再读取或迁移。
self.activity_dir = self.memory_dir / ACTIVITY_DIR
self.subagents_dir = self.runtime_dir / SUBAGENTS_DIR
self.bundled_defaults_dir = bundled_defaults_dir or (Path(__file__).parent / "defaults")
self._cache_lock = threading.Lock()
@@ -880,8 +881,8 @@ class AgentRuntimeManager:
"1. 核心系统提示词(程序内置,不可运行时覆盖)",
"2. `personas/<active_persona>/PERSONA.md`",
"3. `extra_context_files`",
"4. `memory/*.md`",
"5. `activity/*.md`",
"4. `memory/MEMORY.md`(默认注入)",
"5. `memory/<topic>.md` 与 `memory/activity/*.md`(通过 search_memory 按需检索)",
"",
"`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。",
]

View File

@@ -86,6 +86,7 @@ class MoviePilotToolFactory:
"execute_command",
"ask_user_choice",
"agent_task",
"search_memory",
)
CATALOG_BUILD_MAX_ATTEMPTS = 3

View File

@@ -16,6 +16,14 @@ MoviePilot Agent 通过模型、Skills、工具和会话状态共同完成任务
WebAgent 在 Agent 正在运行时仍可提交文本和附件。宿主为每个会话生成消息 ID先把消息放入有界 steering inbox下一次模型调用前由中间件把它作为真实 `HumanMessage` 写入图状态,并通过当前 SSE 报告 `queued``applied`。提交和运行收尾共享原子边界,收尾竞态中已接受的消息会在同一 worker 内继续处理,停止会话不会继续派发新的工具动作。补充消息不会启动第二张 Agent 图,也不会替换当前输出回调。
## 记忆与活动记录
Agent 的记忆统一位于 `config/agent/memory``MEMORY.md` 是主记忆文件,只包含跨任务默认需要记住的用户偏好、沟通方式、长期规则和稳定事实,并且只有它会在每轮默认注入上下文。其它主题记忆使用独立的 Markdown 文件,活动记录使用 `memory/activity/YYYY-MM-DD.md`;两者都不会自动加载。
Agent 在执行任何实质任务或调用业务、文件、网络、命令等工具前,必须先调用 `search_memory` 检索与本次请求相关的记忆;这是本轮任务的第一个工具调用。工具支持 `primary``topic``activity``all` 分类,以及关键词、文件路径、日期、时间窗口、条数和显式正则过滤。检索结果是有界的结构化内容,不会把整个记忆目录重新塞回上下文。活动记录是自动生成的只读历史,保留期默认 7 天;旧的 `config/agent/activity` 文件不迁移,也不再作为活动记忆读取。
用户明确要求记住的偏好或规则应写入 `MEMORY.md` 或合适的主题文件;一次性请求、临时状态和凭据不应写入记忆。检索到的文件内容只是上下文,不能覆盖系统或用户指令。
## 按需发现工具
设置 `LLM_MAX_TOOLS > 0`Agent 首轮仍只筛选一批相关工具。后续读取 Skill 或发现新问题后,可以通过内部 `search_tools(search, limit)` 按名称、说明或标签搜索当前会话工具目录,并在下一次模型调用获得匹配工具的完整参数定义。
@@ -24,7 +32,7 @@ WebAgent 在 Agent 正在运行时仍可提交文本和附件。宿主为每个
额外工具的参数定义共享最多 4096 tokens、且不超过已知模型窗口 10% 的预算,总输入保留 15% 余量。搜索先报告候选,下一次模型调用根据统一预算明确报告实际启用和未启用原因。初选、常驻和供应商工具保持可用。发现状态仅作用于当前用户请求,新的用户请求重新筛选。`LLM_MAX_TOOLS` 约束首轮筛选数量;发现不会安装工具、连接新的 MCP 服务或扩大执行权限。
`update_plan``search_tools``read_tool_result``get_tool_execution` 是 Agent 内部会话能力,不通过外部 MCP 或 `moviepilot tool` 发布。
`update_plan``search_memory``search_tools``read_tool_result``get_tool_execution` 是 Agent 内部会话能力,不通过外部 MCP 或 `moviepilot tool` 发布。
## 工具结果与大结果续读
@@ -54,7 +62,7 @@ WebAgent 在 Agent 正在运行时仍可提交文本和附件。宿主为每个
## 工具图片与模型视觉
`browse_webpage(action="screenshot")``view_image(url=...|file_path=...|image_data=...)` 在 Agent 中返回真实图像块和有限来源说明。图像在通用文本截断之前处理,最终请求按完整工具回复批次附加带工具来源的临时图像观察,兼容 Chat、Responses、Anthropic 与 Gemini 的图片输入;原始用户消息、工具调用 ID 和授权不变`view_image` 的远程 URL 只允许通过公网安全校验的 HTTP(S) 地址,本地路径继续遵守 Agent 文件访问边界,`image_data` 支持纯 Base64、data URL 和原始字节。
`browse_webpage(action="screenshot")``view_image(url=...|file_path=...|image_data=...)` 在 Agent 中返回真实图像块和有限来源说明。图像在通用文本截断之前处理,最终请求按完整工具回复批次`ToolMessage` 中的 `image_url` 块组装成带 `TOOL_OBSERVATION_MARKER` 的临时 `HumanMessage(content=[text, image_url, ...])`,再发送给支持图片的模型;原始用户消息、工具调用 ID 和授权不变。视觉中间件只修改出站副本,不把临时图片观察写回持久会话历史`view_image` 的远程 URL 只允许通过公网安全校验的 HTTP(S) 地址,本地路径继续遵守 Agent 文件访问边界,`image_data` 支持纯 Base64、data URL 和原始字节。
每次模型请求都检查实际模型资料和 `LLM_SUPPORT_IMAGE_INPUT` 开关。已知纯文本模型会得到明确的“未接收工具图片”说明。服务明确拒绝图片时,只对该次模型调用做一次文字回退,随后本轮沿用文字观察,不重复执行图片工具或其他工具;认证、限流等错误保持原来的错误语义。临时观察不会写入会话历史。

View File

@@ -76,7 +76,7 @@
- 宿主能力execute_command, edit_file, apply_patch, write_file, read_file
- 诊断能力query_doctor_report
此外渠道工具、Skill 工具、活动日志、子 Agent 和插件/MCP 动态工具继续按运行时条件注入。
此外渠道工具、Skill 工具、统一记忆(含活动记录)、子 Agent 和插件/MCP 动态工具继续按运行时条件注入;活动记录不再维护独立的活动日志中间件
### 3.4 最终目录规模

View File

@@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| Event Contract | 53 | 均已有 payload model但当前全部是 diagnostic enforcement |
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`61 个文件超过 1,000 行11 个超过 2,000 行 |
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`65 个超过 150 行21 个超过 250 行 |
| 全量 mypy 历史债务 | 9,329 / 507 文件 | Application 模块目录整理后的现状基线canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
| 全量 mypy 历史债务 | 9,320 / 506 文件 | 删除独立活动日志中间件后的现状基线canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
| Ruff 历史诊断 | 517 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率固定基线 | Application 80.00%Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |

View File

@@ -49,7 +49,6 @@ _META_NOISE_MODULES = frozenset({
"callback",
"prompt",
"memory.py",
"activity_log.py",
"message.py",
"event.py",
"chain",

View File

@@ -155,11 +155,6 @@
"type-arg": 7,
"type-var": 1
},
"app/agent/middleware/activity.py": {
"misc": 3,
"type-arg": 5,
"valid-type": 1
},
"app/agent/middleware/config.py": {
"misc": 1
},

View File

@@ -1,520 +0,0 @@
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from app.agent.middleware.activity import (
QUERY_ACTIVITY_LOG_TOOL_NAME,
ActivityLogMiddleware,
_summarize_with_llm,
load_activity_log_index,
query_activity_logs,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.tags import ToolTag
from app.runtime.tasks import TaskRegistry
def _write_activity_log(activity_dir, date_str: str, lines: list[str]) -> None:
"""写入测试用活动日志。"""
activity_dir.mkdir(parents=True, exist_ok=True)
body = "\n".join(lines)
(activity_dir / f"{date_str}.md").write_text(
f"# {date_str} 活动日志\n\n{body}\n",
encoding="utf-8",
)
async def _wait_activity_log_tasks(middleware: ActivityLogMiddleware) -> None:
"""等待活动日志后台任务完成,避免测试与后台写入竞态。"""
tasks = list(middleware._background_tasks)
if tasks:
await asyncio.gather(*tasks)
def test_activity_log_index_counts_entries_without_body(tmp_path):
"""活动日志索引只应包含条目数量,不暴露完整摘要正文。"""
date_str = datetime.now().strftime("%Y-%m-%d")
_write_activity_log(
tmp_path,
date_str,
[
"- **10:00** 帮用户整理了电影文件",
"- **11:00** 查询了下载任务状态",
],
)
index = load_activity_log_index(str(tmp_path), days=1)
assert index == {date_str: "2 条活动记录"}
assert "整理了电影文件" not in json.dumps(index, ensure_ascii=False)
def test_activity_log_prompt_is_stable_and_excludes_log_index(tmp_path):
"""ActivityLogMiddleware 的系统提示词不应随活动日志索引变化。"""
date_str = datetime.now().strftime("%Y-%m-%d")
_write_activity_log(
tmp_path,
date_str,
["- **10:00** 这是一条不应默认进入上下文的活动正文"],
)
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None))
request = SimpleNamespace(
state=state_update,
system_message=SystemMessage(content="SYSTEM"),
override=lambda **kwargs: SimpleNamespace(
state=state_update,
system_message=kwargs.get("system_message", SystemMessage(content="SYSTEM")),
),
)
modified = middleware.modify_request(request)
system_text = str(modified.system_message.content)
stable_prompt = middleware._format_activity_log({"2099-12-31": "999 条活动记录"})
assert "1 条活动记录" not in system_text
assert date_str not in system_text
assert "这是一条不应默认进入上下文的活动正文" not in system_text
assert "query_activity_log" in system_text
assert middleware._format_activity_log(state_update["activity_log_contents"]) == stable_prompt
def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
"""复用 Agent 图时,活动日志索引仍应在每轮执行前刷新。"""
date_str = datetime.now().strftime("%Y-%m-%d")
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path), prompt_load_days=1)
state = {"activity_log_contents": {"old": "旧索引"}}
_write_activity_log(
tmp_path,
date_str,
["- **10:00** 新增活动记录"],
)
state_update = asyncio.run(middleware.abefore_agent(state, runtime=None))
assert state_update == {"activity_log_contents": {date_str: "1 条活动记录"}}
def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
"""无实际任务的寒暄不应调用 LLM也不应写入活动日志。"""
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
summarize_mock = AsyncMock(return_value="不应写入")
append_mock = AsyncMock()
with (
patch(
"app.agent.middleware.activity._summarize_with_llm",
new=summarize_mock,
),
patch.object(middleware, "_append_activity", new=append_mock),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="你好"),
AIMessage(content="你好,有什么可以帮你?"),
],
},
runtime=None,
)
await _wait_activity_log_tasks(middleware)
return summarize_mock, append_mock
summarize_mock, append_mock = asyncio.run(_run_test())
summarize_mock.assert_not_awaited()
append_mock.assert_not_awaited()
assert not list(tmp_path.glob("*.md"))
def test_summarize_with_llm_ignores_skip_marker():
"""LLM 返回 SKIP 时应视为无需记录活动日志。"""
llm = SimpleNamespace(ainvoke=AsyncMock(return_value=SimpleNamespace(content="SKIP")))
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 你好"))
assert summary is None
llm.ainvoke.assert_awaited_once()
def test_summarize_with_llm_extracts_text_blocks():
"""活动摘要应兼容 LLM 返回的结构化文本块。"""
llm = SimpleNamespace(
ainvoke=AsyncMock(
return_value=SimpleNamespace(
content=[
{"type": "reasoning", "text": "内部推理"},
{"type": "text", "text": "摘要:用户完成了文件工具排查。"},
]
)
)
)
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 排查文件工具"))
assert summary == "用户完成了文件工具排查。"
llm.ainvoke.assert_awaited_once()
def test_activity_log_records_detailed_summary(tmp_path):
"""有实际工具动作的交互应写入较完整的活动摘要。"""
summary = "用户要求整理 `/downloads/Show`,助手调用 transfer_file 识别并转移剧集,结果成功写入目标媒体库。"
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
with patch(
"app.agent.middleware.activity._summarize_with_llm",
new=AsyncMock(return_value=summary),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="帮我整理 /downloads/Show"),
AIMessage(
content="",
tool_calls=[
{
"name": "transfer_file",
"args": {"path": "/downloads/Show"},
"id": "call_1",
}
],
),
ToolMessage(
content='{"success": true, "target": "/media/Show"}',
tool_call_id="call_1",
),
],
},
runtime=None,
)
await _wait_activity_log_tasks(middleware)
asyncio.run(_run_test())
log_files = list(tmp_path.glob("*.md"))
assert len(log_files) == 1
content = log_files[0].read_text(encoding="utf-8")
assert summary in content
assert "- **" in content
def test_activity_log_after_agent_does_not_wait_for_summary(tmp_path):
"""活动日志摘要生成应在后台执行,不阻塞当前 Agent 会话结束。"""
async def _slow_summarize(_conversation_text: str) -> str:
"""模拟较慢的活动摘要生成。"""
await asyncio.sleep(0.05)
return "用户要求检查下载任务,助手调用工具完成检查。"
async def _run_test():
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
append_mock = AsyncMock()
with (
patch(
"app.agent.middleware.activity._summarize_with_llm",
side_effect=_slow_summarize,
) as summarize_mock,
patch.object(middleware, "_append_activity", new=append_mock),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="帮我检查下载任务"),
AIMessage(
content="",
tool_calls=[
{
"name": "moviepilot_api",
"args": {"operation_id": "scheduler.list"},
"id": "call_1",
}
],
),
ToolMessage(
content='{"success": true}',
tool_call_id="call_1",
),
],
},
runtime=None,
)
called_before_wait = summarize_mock.await_count
pending_before_wait = len(middleware._background_tasks)
await _wait_activity_log_tasks(middleware)
return called_before_wait, pending_before_wait, summarize_mock, append_mock
called_before_wait, pending_before_wait, summarize_mock, append_mock = asyncio.run(_run_test())
assert called_before_wait == 0
assert pending_before_wait == 1
summarize_mock.assert_awaited_once()
append_mock.assert_awaited_once_with("用户要求检查下载任务,助手调用工具完成检查。")
def test_activity_log_background_task_follows_host_shutdown(tmp_path):
"""活动摘要任务必须登记 owner并随宿主关停取消和收敛。"""
async def _run_test():
"""启动阻塞摘要后关闭登记器,返回 owner 与最终任务状态。"""
registry = TaskRegistry()
started = asyncio.Event()
cancelled = asyncio.Event()
async def _blocked_record(_messages: list) -> None:
"""保持记录任务运行,直到宿主关停发出取消。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
task_registry=registry,
)
with patch.object(middleware, "_record_activity", side_effect=_blocked_record):
middleware._schedule_activity_recording([])
await started.wait()
owners = tuple(record.owner for record in registry.records)
converged = await registry.shutdown(timeout_seconds=1.0)
await asyncio.sleep(0)
return owners, converged, cancelled.is_set(), middleware._background_tasks
owners, converged, cancelled, background_tasks = asyncio.run(_run_test())
assert owners == ("agent.activity_log.record",)
assert converged is True
assert cancelled is True
assert background_tasks == set()
def test_query_activity_logs_filters_by_keyword_and_date(tmp_path):
"""活动日志查询应支持日期和关键词过滤。"""
_write_activity_log(
tmp_path,
"2026-06-18",
[
"- **10:00** 帮用户整理了电影 A",
"- **10:30** 查询了站点状态",
],
)
_write_activity_log(
tmp_path,
"2026-06-17",
["- **09:00** 帮用户整理了电影 B"],
)
payload = query_activity_logs(
str(tmp_path),
keyword="整理",
date="2026-06-18",
limit=10,
)
assert payload["success"] is True
assert payload["total_count"] == 1
assert payload["entries"][0]["date"] == "2026-06-18"
assert payload["entries"][0]["time"] == "10:00"
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
def test_query_activity_logs_supports_optional_regex(tmp_path):
"""活动日志查询应在显式开启时支持正则匹配。"""
_write_activity_log(
tmp_path,
"2026-06-18",
[
"- **10:00** 帮用户整理了剧集 A",
"- **10:30** 查询了站点状态",
],
)
payload = query_activity_logs(
str(tmp_path),
keyword="整理|站点",
use_regex=True,
date="2026-06-18",
limit=10,
)
assert payload["success"] is True
assert payload["use_regex"] is True
assert payload["total_count"] == 2
def test_query_activity_logs_reports_invalid_regex(tmp_path):
"""活动日志查询遇到无效正则时应返回结构化错误。"""
payload = query_activity_logs(
str(tmp_path),
keyword="[",
use_regex=True,
date="2026-06-18",
)
assert payload["success"] is False
assert "无效的活动日志正则表达式" in payload["message"]
assert payload["entries"] == []
def test_activity_log_middleware_exposes_query_tool(tmp_path):
"""ActivityLogMiddleware 应以中间件工具形式暴露活动日志查询。"""
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
assert [tool.name for tool in middleware.tools] == [QUERY_ACTIVITY_LOG_TOOL_NAME]
assert ToolTag.Read in middleware.tools[0].tags
assert ToolTag.System in middleware.tools[0].tags
assert "recent MoviePilot Agent activity logs" in middleware.tools[0].description
def test_activity_log_middleware_query_tool_returns_json_payload(tmp_path):
"""query_activity_log 中间件工具应返回结构化 JSON 查询结果。"""
_write_activity_log(
tmp_path,
"2026-06-18",
["- **10:00** 帮用户整理了电影 A"],
)
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
tool = middleware.tools[0]
result = asyncio.run(tool.ainvoke({"keyword": "整理", "date": "2026-06-18", "limit": 5}))
payload = json.loads(result)
assert payload["success"] is True
assert payload["returned_count"] == 1
assert payload["entries"][0]["summary"] == "帮用户整理了电影 A"
def test_activity_log_tool_call_reports_streaming_execution(tmp_path):
"""query_activity_log 工具执行时应使用统一的工具显示策略。"""
async def _run_test():
calls = []
stream_handler = SimpleNamespace(
is_streaming=True,
report_tool_call=lambda **kwargs: calls.append(kwargs),
)
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME),
tool_call={
"args": {
"keyword": "整理",
"date": "2026-06-18",
}
},
)
async def _fake_handler(_request):
"""返回模拟工具结果。"""
return "ok"
result = await middleware.awrap_tool_call(request, _fake_handler)
return result, calls
result, calls = asyncio.run(_run_test())
assert result == "ok"
assert calls == [
{
"tool_name": QUERY_ACTIVITY_LOG_TOOL_NAME,
"tool_message": '查询活动日志,主要参数:{"keyword": "整理", "date": "2026-06-18"}',
"tool_kwargs": {
"keyword": "整理",
"date": "2026-06-18",
},
}
]
def test_activity_log_middleware_sanitizes_its_own_logs(tmp_path):
"""活动日志中间件读取参数和异常写日志时必须脱敏。"""
async def _run_test():
secret_marker = "activity-secret-marker-6825"
stream_handler = SimpleNamespace(
is_streaming=True,
report_tool_call=MagicMock(),
)
middleware = ActivityLogMiddleware(
activity_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME),
tool_call={"args": {"keyword": f"token={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch("app.agent.middleware.activity.logger", mock_logger):
try:
await middleware.awrap_tool_call(request, _failing_handler)
except RuntimeError:
pass
else:
raise AssertionError("middleware should re-raise handler errors")
return secret_marker, mock_logger
secret_marker, mock_logger = asyncio.run(_run_test())
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
def test_activity_log_provider_error_does_not_echo_secret(tmp_path):
"""活动日志 provider 内部异常不能进入日志或模型错误结果。"""
secret_marker = "activity-provider-secret-3584"
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
mock_logger = MagicMock()
with (
patch(
"app.agent.middleware.activity.query_activity_logs",
side_effect=RuntimeError(f"OPENAI_API_KEY={secret_marker}"),
),
patch("app.agent.middleware.activity.logger", mock_logger),
):
result = asyncio.run(middleware._tool_provider.query_activity_log(keyword="visible"))
assert secret_marker not in result
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in result
assert "***" in str(mock_logger.method_calls)
def test_factory_does_not_register_activity_log_tool():
"""活动日志查询工具应由中间件注册,不应进入全局工具工厂。"""
with patch(
"app.agent.tools.factory._get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="activity-session",
user_id="10001",
)
tool_names = {tool.name for tool in tools}
assert QUERY_ACTIVITY_LOG_TOOL_NAME not in tool_names

View File

@@ -7,8 +7,8 @@ from langchain_core.messages import AIMessage, HumanMessage
from app.agent.contracts import ReplyMode
from app.agent.manager import AgentManager
from app.agent.memory import memory_manager
from app.agent.middleware.activity import QUERY_ACTIVITY_LOG_TOOL_NAME
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
from app.agent.middleware.memory import SEARCH_MEMORY_TOOL_NAME
from app.agent.middleware.output import READ_TOOL_RESULT_NAME, ToolOutputMiddleware
from app.agent.middleware.plan import PLAN_TOOL_NAME, PlanMiddleware
from app.agent.middleware.selection import TOOL_DISCOVERY_NAME, ToolSelectorMiddleware
@@ -79,9 +79,9 @@ def _fake_skills_middleware(tool=None):
return SimpleNamespace(name="skills", tools=[] if tool is None else [tool])
def _fake_activity_log_middleware(tool=None):
"""构造带 tools 属性的 ActivityLogMiddleware 测试替身。"""
return SimpleNamespace(name="activity", tools=[] if tool is None else [tool])
def _fake_memory_middleware(tool=None):
"""构造带 tools 属性的统一 MemoryMiddleware 测试替身。"""
return SimpleNamespace(name="memory", tools=[] if tool is None else [tool])
def _capture_tool_selector(captured, **kwargs):
@@ -421,8 +421,8 @@ class TestAgentBackgroundOutput:
has_audio_input=True,
)
async def test_create_agent_excludes_activity_log_for_heartbeat_session(self):
"""心跳任务保留计划上下文,但不注入渠道活动日志"""
async def test_create_agent_disables_activity_recording_for_heartbeat_session(self):
"""心跳任务保留统一记忆能力,但不启用渠道活动记录"""
agent = MoviePilotAgent(
session_id=f"{HEARTBEAT_SESSION_PREFIX}test__",
user_id="system",
@@ -446,10 +446,6 @@ class TestAgentBackgroundOutput:
patch("app.agent.orchestrator.JobsMiddleware", side_effect=lambda *args, **kwargs: "jobs"),
patch("app.agent.orchestrator.RuntimeConfigMiddleware", side_effect=lambda *args, **kwargs: "runtime"),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch("app.agent.orchestrator.SummarizationMiddleware", side_effect=lambda *args, **kwargs: "summary"),
patch("app.agent.orchestrator.PatchToolCallsMiddleware", side_effect=lambda *args, **kwargs: "patch"),
patch("app.agent.orchestrator.UsageMiddleware", side_effect=lambda *args, **kwargs: "usage"),
@@ -503,10 +499,6 @@ class TestAgentBackgroundOutput:
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
@@ -527,8 +519,8 @@ class TestAgentBackgroundOutput:
assert SKILL_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_excludes_activity_log_without_message_context(self):
"""无渠道信息的后台捕获任务不应注入活动日志"""
async def test_create_agent_disables_activity_recording_without_message_context(self):
"""无渠道信息的后台捕获任务不应启用活动记录"""
agent = MoviePilotAgent(
session_id="background-capture-session",
user_id="system",
@@ -556,10 +548,6 @@ class TestAgentBackgroundOutput:
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
@@ -598,20 +586,20 @@ class TestAgentBackgroundOutput:
assert "send_message" not in always_include
def test_activity_log_tool_is_not_registered_by_tool_factory(self):
"""活动日志查询工具不应再由全局工具工厂保留"""
activity_log_tool = SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME)
def test_memory_tool_is_always_included_by_tool_selector(self):
"""记忆检索工具应作为常驻候选,但不由全局工具工厂重复注册"""
memory_tool = SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME)
always_include = MoviePilotToolFactory.get_tool_selector_always_include_names(
[activity_log_tool]
[memory_tool]
)
assert QUERY_ACTIVITY_LOG_TOOL_NAME not in always_include
assert SEARCH_MEMORY_TOOL_NAME in always_include
async def test_create_agent_registers_activity_log_tool_from_middleware(self):
"""ActivityLogMiddleware 暴露的工具应进入 Agent 工具和筛选候选。"""
async def test_create_agent_registers_memory_tool_from_middleware(self):
"""MemoryMiddleware 暴露的工具应进入 Agent 工具和筛选候选。"""
captured = {}
activity_tool = SimpleNamespace(name=QUERY_ACTIVITY_LOG_TOOL_NAME)
memory_tool = SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME)
agent = MoviePilotAgent(
session_id="normal-session",
user_id="system",
@@ -643,12 +631,9 @@ class TestAgentBackgroundOutput:
"app.agent.orchestrator.RuntimeConfigMiddleware",
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(
activity_tool
),
"app.agent.orchestrator.MemoryMiddleware",
side_effect=lambda *args, **kwargs: _fake_memory_middleware(memory_tool),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
@@ -665,9 +650,9 @@ class TestAgentBackgroundOutput:
):
created = await agent._create_agent(streaming=False)
assert activity_tool in created["tools"]
assert activity_tool in captured["selection_tools"]
assert QUERY_ACTIVITY_LOG_TOOL_NAME in captured["always_include"]
assert memory_tool in created["tools"]
assert memory_tool in captured["selection_tools"]
assert SEARCH_MEMORY_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_always_includes_subagent_tools(self):
@@ -709,10 +694,6 @@ class TestAgentBackgroundOutput:
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
@@ -732,8 +713,8 @@ class TestAgentBackgroundOutput:
assert SUBAGENT_CONTROL_TOOL_NAME in captured["always_include"]
_assert_internal_tool_registration(created, captured)
async def test_create_agent_keeps_activity_log_for_normal_session(self):
"""普通渠道会话在计划和记忆上下文后保留活动日志"""
async def test_create_agent_uses_one_memory_middleware_for_normal_session(self):
"""普通渠道会话用同一个中间件提供记忆检索和活动记录"""
agent = MoviePilotAgent(
session_id="normal-session",
user_id="system",
@@ -762,10 +743,6 @@ class TestAgentBackgroundOutput:
side_effect=lambda *args, **kwargs: "runtime",
),
patch("app.agent.orchestrator.MemoryMiddleware", side_effect=lambda *args, **kwargs: "memory"),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
side_effect=lambda *args, **kwargs: _fake_activity_log_middleware(),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",
side_effect=lambda *args, **kwargs: "summary",
@@ -788,7 +765,6 @@ class TestAgentBackgroundOutput:
"runtime",
"PlanMiddleware",
"memory",
"activity",
"patch",
"FinalRequestCompactionMiddleware",
"VisionMiddleware",

View File

@@ -482,11 +482,11 @@ async def test_graph_rejects_mcp_and_skill_name_collisions(
args_schema={"type": "object", "properties": {}},
_agent_tool_source="middleware:skills",
)
activity_tool = SimpleNamespace(
name="query_activity_log",
description="activity log",
memory_tool = SimpleNamespace(
name="search_memory",
description="memory search",
args_schema={"type": "object", "properties": {}},
_agent_tool_source="middleware:activity_log",
_agent_tool_source="middleware:memory",
)
subagent_task_tool = SimpleNamespace(
name="task",
@@ -578,10 +578,6 @@ async def test_graph_rejects_mcp_and_skill_name_collisions(
"app.agent.orchestrator.SkillsMiddleware",
return_value=SimpleNamespace(name="skills", tools=[skill_tool]),
),
patch(
"app.agent.orchestrator.ActivityLogMiddleware",
return_value=SimpleNamespace(name="activity", tools=[activity_tool]),
),
patch(
"app.agent.orchestrator.JobsMiddleware",
return_value=SimpleNamespace(name="jobs"),
@@ -592,7 +588,7 @@ async def test_graph_rejects_mcp_and_skill_name_collisions(
),
patch(
"app.agent.orchestrator.MemoryMiddleware",
return_value=SimpleNamespace(name="memory"),
return_value=SimpleNamespace(name="memory", tools=[memory_tool]),
),
patch(
"app.agent.orchestrator.SummarizationMiddleware",

422
tests/test_agent_memory.py Normal file
View File

@@ -0,0 +1,422 @@
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from app.agent.middleware.memory import (
SEARCH_MEMORY_TOOL_NAME,
MemoryMiddleware,
_summarize_with_llm,
query_memory_files,
)
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.tags import ToolTag
from app.runtime.tasks import TaskRegistry
def _write_activity_log(activity_dir, date_str: str, lines: list[str]) -> None:
"""写入测试用活动记忆文件。"""
activity_dir.mkdir(parents=True, exist_ok=True)
body = "\n".join(lines)
(activity_dir / f"{date_str}.md").write_text(
f"# {date_str} 活动记忆\n\n{body}\n",
encoding="utf-8",
)
async def _wait_memory_tasks(middleware: MemoryMiddleware) -> None:
"""等待活动记忆后台任务完成,避免测试与后台写入竞态。"""
tasks = list(middleware._background_tasks)
if tasks:
await asyncio.gather(*tasks)
def test_memory_loads_only_primary_file(tmp_path):
"""每轮默认上下文只能装载 MEMORY.md不应装载主题或活动正文。"""
primary = tmp_path / "MEMORY.md"
primary.write_text("用户偏好:简洁回复。", encoding="utf-8")
(tmp_path / "MEDIA_RULES.md").write_text("主题记忆:优先 Remux。", encoding="utf-8")
_write_activity_log(
tmp_path / "activity",
datetime.now().strftime("%Y-%m-%d"),
["- **10:00** 活动正文不应自动进入上下文"],
)
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
activity_dir=str(tmp_path / "activity"),
)
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None, config=None))
request = SimpleNamespace(
state=state_update,
system_message=SystemMessage(content="SYSTEM"),
override=lambda **kwargs: SimpleNamespace(
state=state_update,
system_message=kwargs["system_message"],
),
)
modified = middleware.modify_request(request)
system_text = str(modified.system_message.content)
assert state_update["memory_contents"] == {str(primary): "用户偏好:简洁回复。"}
assert "用户偏好:简洁回复" in system_text
assert "主题记忆:优先 Remux" not in system_text
assert "活动正文不应自动进入上下文" not in system_text
assert "search_memory" in system_text
assert "first tool call" in system_text
def test_memory_onboarding_still_requires_search_before_task(tmp_path):
"""主记忆为空时也必须要求 Agent 在执行任务前检索其它记忆。"""
(tmp_path / "MEDIA_RULES.md").write_text("主题记忆:偏好 HEVC。", encoding="utf-8")
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
state_update = asyncio.run(middleware.abefore_agent({}, runtime=None, config=None))
prompt = middleware._format_agent_memory(
state_update["memory_contents"],
memory_empty=state_update["memory_empty"],
)
assert "primary memory file is empty" in prompt
assert "first tool call" in prompt
assert "search_memory" in prompt
assert "偏好 HEVC" not in prompt
def test_query_memory_files_searches_topic_and_activity_categories(tmp_path):
"""统一检索工具应能按分类、关键词和日期读取主题与活动记忆。"""
(tmp_path / "MEDIA_RULES.md").write_text(
"# 媒体规则\n优先 Remux字幕使用简体中文。\n",
encoding="utf-8",
)
activity_dir = tmp_path / "activity"
_write_activity_log(
activity_dir,
"2026-06-18",
[
"- **10:00** 帮用户整理了电影 A",
"- **10:30** 查询了站点状态",
],
)
topic_payload = query_memory_files(
str(tmp_path),
activity_dir=str(activity_dir),
query="Remux",
category="topic",
limit=10,
)
activity_payload = query_memory_files(
str(tmp_path),
activity_dir=str(activity_dir),
query="整理",
category="activity",
date="2026-06-18",
limit=10,
)
assert topic_payload["success"] is True
assert topic_payload["entries"][0]["category"] == "topic"
assert topic_payload["entries"][0]["text"] == "优先 Remux字幕使用简体中文。"
assert activity_payload["success"] is True
assert activity_payload["entries"][0]["category"] == "activity"
assert activity_payload["entries"][0]["summary"] == "帮用户整理了电影 A"
assert activity_payload["entries"][0]["date"] == "2026-06-18"
def test_query_memory_files_supports_regex_and_bounds_results(tmp_path):
"""记忆检索应支持显式正则,并对返回条数做有界处理。"""
(tmp_path / "one.md").write_text("整理电影 A\n查询站点\n", encoding="utf-8")
(tmp_path / "two.md").write_text("整理电影 B\n", encoding="utf-8")
payload = query_memory_files(
str(tmp_path),
query="整理|站点",
category="topic",
use_regex=True,
limit=1,
)
invalid = query_memory_files(
str(tmp_path),
query="[",
category="topic",
use_regex=True,
)
assert payload["success"] is True
assert payload["total_count"] == 3
assert payload["returned_count"] == 1
assert payload["truncated"] is True
assert invalid["success"] is False
assert "无效的记忆检索正则表达式" in invalid["message"]
def test_memory_middleware_exposes_search_tool(tmp_path):
"""统一记忆中间件应通过一个只读系统工具提供按需检索。"""
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
assert [tool.name for tool in middleware.tools] == [SEARCH_MEMORY_TOOL_NAME]
assert ToolTag.Read in middleware.tools[0].tags
assert ToolTag.System in middleware.tools[0].tags
assert middleware.tools[0]._agent_tool_source == "middleware:memory"
assert "topic memory" in middleware.tools[0].description
def test_memory_search_tool_returns_json_payload(tmp_path):
"""search_memory 工具应返回结构化 JSON而不是将文件正文隐式注入上下文。"""
(tmp_path / "MEDIA_RULES.md").write_text("优先 Remux。\n", encoding="utf-8")
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
result = asyncio.run(
middleware.tools[0].ainvoke({"query": "Remux", "category": "topic", "limit": 5})
)
payload = json.loads(result)
assert payload["success"] is True
assert payload["returned_count"] == 1
assert payload["entries"][0]["text"] == "优先 Remux。"
def test_memory_search_tool_reports_streaming_execution(tmp_path):
"""search_memory 工具执行时应复用统一的工具显示策略。"""
async def _run_test():
calls = []
stream_handler = SimpleNamespace(
is_streaming=True,
report_tool_call=lambda **kwargs: calls.append(kwargs) or "tool-1",
tool_call_finished=MagicMock(),
)
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME),
tool_call={"args": {"query": "整理", "category": "activity"}},
)
async def _fake_handler(_request):
"""返回模拟工具结果。"""
return "ok"
result = await middleware.awrap_tool_call(request, _fake_handler)
return result, calls, stream_handler.tool_call_finished
result, calls, finished = asyncio.run(_run_test())
assert result == "ok"
assert calls == [
{
"tool_name": SEARCH_MEMORY_TOOL_NAME,
"tool_message": '检索记忆,主要参数:{"query": "整理", "category": "activity"}',
"tool_kwargs": {"query": "整理", "category": "activity"},
}
]
finished.assert_called_once_with("tool-1", "done")
def test_memory_middleware_sanitizes_its_own_logs(tmp_path):
"""记忆中间件读取参数和异常写日志时必须脱敏。"""
async def _run_test():
secret_marker = "memory-secret-marker-6825"
stream_handler = SimpleNamespace(is_streaming=True, report_tool_call=MagicMock())
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
stream_handler=stream_handler,
)
request = SimpleNamespace(
tool=SimpleNamespace(name=SEARCH_MEMORY_TOOL_NAME),
tool_call={"args": {"query": f"token={secret_marker}"}},
)
mock_logger = MagicMock()
async def _failing_handler(_request):
raise RuntimeError(f"Authorization: Bearer {secret_marker}")
with patch("app.agent.middleware.memory.logger", mock_logger):
try:
await middleware.awrap_tool_call(request, _failing_handler)
except RuntimeError:
pass
else:
raise AssertionError("middleware should re-raise handler errors")
return secret_marker, mock_logger
secret_marker, mock_logger = asyncio.run(_run_test())
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in str(mock_logger.method_calls)
def test_memory_provider_error_does_not_echo_secret(tmp_path):
"""记忆 provider 内部异常不能进入日志或模型错误结果。"""
secret_marker = "memory-provider-secret-3584"
middleware = MemoryMiddleware(memory_dir=str(tmp_path))
mock_logger = MagicMock()
with (
patch(
"app.agent.middleware.memory.query_memory_files",
side_effect=RuntimeError(f"OPENAI_API_KEY={secret_marker}"),
),
patch("app.agent.middleware.memory.logger", mock_logger),
):
result = asyncio.run(
middleware._tool_provider.search_memory(query="visible")
)
assert secret_marker not in result
assert secret_marker not in str(mock_logger.method_calls)
assert "***" in result
assert "***" in str(mock_logger.method_calls)
def test_activity_memory_records_under_unified_memory_directory(tmp_path):
"""活动摘要应写入 memory/activity而不是旧的 agent/activity 目录。"""
summary = "用户要求整理电影文件,助手调用 transfer_file 完成处理,结果成功。"
activity_dir = tmp_path / "activity"
async def _run_test():
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
activity_dir=str(activity_dir),
)
with patch(
"app.agent.middleware.memory._summarize_with_llm",
new=AsyncMock(return_value=summary),
):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="帮我整理电影"),
AIMessage(
content="",
tool_calls=[
{"name": "transfer_file", "args": {}, "id": "call_1"}
],
),
ToolMessage(content='{"success": true}', tool_call_id="call_1"),
]
},
runtime=None,
)
await _wait_memory_tasks(middleware)
asyncio.run(_run_test())
log_files = list(activity_dir.glob("*.md"))
assert len(log_files) == 1
assert summary in log_files[0].read_text(encoding="utf-8")
def test_activity_memory_skips_trivial_greeting_without_llm(tmp_path):
"""无实际任务的寒暄不应调用 LLM也不应写入活动记忆。"""
async def _run_test():
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
activity_dir=str(tmp_path / "activity"),
)
summarize_mock = AsyncMock(return_value="不应写入")
with patch("app.agent.middleware.memory._summarize_with_llm", new=summarize_mock):
await middleware.aafter_agent(
{
"messages": [
HumanMessage(content="你好"),
AIMessage(content="你好,有什么可以帮你?"),
]
},
runtime=None,
)
await _wait_memory_tasks(middleware)
return summarize_mock
summarize_mock = asyncio.run(_run_test())
summarize_mock.assert_not_awaited()
assert not list((tmp_path / "activity").glob("*.md"))
def test_activity_summary_background_task_follows_host_shutdown(tmp_path):
"""活动摘要任务必须登记统一 owner并随宿主关停取消和收敛。"""
async def _run_test():
registry = TaskRegistry()
started = asyncio.Event()
cancelled = asyncio.Event()
async def _blocked_record(_messages: list) -> None:
"""保持记录任务运行,直到宿主关停发出取消。"""
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelled.set()
raise
middleware = MemoryMiddleware(
memory_dir=str(tmp_path),
activity_dir=str(tmp_path / "activity"),
task_registry=registry,
)
with patch.object(middleware, "_record_activity", side_effect=_blocked_record):
middleware._schedule_activity_recording([])
await started.wait()
owners = tuple(record.owner for record in registry.records)
converged = await registry.shutdown(timeout_seconds=1.0)
await asyncio.sleep(0)
return owners, converged, cancelled.is_set(), middleware._background_tasks
owners, converged, cancelled, background_tasks = asyncio.run(_run_test())
assert owners == ("agent.memory.activity_record",)
assert converged is True
assert cancelled is True
assert background_tasks == set()
def test_summarize_with_llm_ignores_skip_marker():
"""LLM 返回 SKIP 时应视为无需记录活动记忆。"""
llm = SimpleNamespace(ainvoke=AsyncMock(return_value=SimpleNamespace(content="SKIP")))
with patch(
"app.agent.llm.LLMHelper.get_llm",
new=AsyncMock(return_value=llm),
):
summary = asyncio.run(_summarize_with_llm("用户: 你好"))
assert summary is None
llm.ainvoke.assert_awaited_once()
def test_activity_summary_hides_image_payload():
"""活动摘要输入只能保留图片占位符,不能把 Base64 写入活动记忆。"""
from app.agent.middleware.memory import _format_conversation_for_summary
content = [
{"type": "text", "text": "请看看图片"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,secret"}},
]
formatted = _format_conversation_for_summary([HumanMessage(content=content)])
assert "[图片]" in formatted
assert "secret" not in formatted
def test_factory_does_not_register_memory_tool():
"""记忆检索工具由统一中间件注册,不应进入全局工具工厂。"""
with patch(
"app.agent.tools.factory._get_plugin_agent_tools",
return_value=[],
):
tools = MoviePilotToolFactory.create_tools(
session_id="memory-session",
user_id="10001",
)
assert SEARCH_MEMORY_TOOL_NAME not in {tool.name for tool in tools}

View File

@@ -101,6 +101,21 @@ class TestAgentRuntimeConfig(unittest.TestCase):
self.assertFalse(obsolete_persona.exists())
self.assertFalse((self.agent_root / "memory" / "USER_PREFERENCES.md").exists())
def test_activity_memory_uses_nested_memory_directory_without_migration(self):
"""活动记忆使用新的统一目录,旧目录内容不迁移也不作为新目录内容。"""
old_activity = self.agent_root / "activity"
old_activity.mkdir(parents=True, exist_ok=True)
old_log = old_activity / "2026-06-18.md"
old_log.write_text("# 旧活动日志\n", encoding="utf-8")
manager = self._manager()
manager.ensure_layout()
self.assertEqual(manager.activity_dir, self.agent_root / "memory" / "activity")
self.assertTrue(manager.activity_dir.exists())
self.assertTrue(old_log.exists())
self.assertFalse((manager.activity_dir / old_log.name).exists())
def test_render_prompt_sections_uses_active_persona(self):
manager = self._manager()
runtime_config = manager.load_runtime_config()

View File

@@ -302,6 +302,7 @@ def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares(with_invoc
"execute_command",
"agent_task",
"read_skill",
"search_memory",
PLAN_TOOL_NAME,
READ_TOOL_RESULT_NAME,
*([GET_TOOL_EXECUTION_NAME] if with_invocations else []),
@@ -309,7 +310,7 @@ def test_streaming_agent_uses_non_streaming_llm_for_model_middlewares(with_invoc
]
assert tool_selector_middleware.selection_tools[: len(fake_tools)] == fake_tools
assert [getattr(tool, "name", None) for tool in tool_selector_middleware.selection_tools[len(fake_tools) :]] == [
"read_skill", PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME,
"read_skill", "search_memory", PLAN_TOOL_NAME, READ_TOOL_RESULT_NAME,
*([GET_TOOL_EXECUTION_NAME] if with_invocations else []), TOOL_DISCOVERY_NAME,
]
middlewares = captured["middleware"]

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from pydantic import BaseModel
from app.agent.middleware.activity import QueryActivityLogInput
from app.agent.middleware.memory import SearchMemoryInput
from app.agent.middleware.skills import SkillToolInput
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.factory import MoviePilotToolFactory
@@ -78,7 +78,7 @@ def test_agent_tool_schemas_do_not_expose_explanation_parameter() -> None:
]
middleware_schemas = [
SkillToolInput,
QueryActivityLogInput,
SearchMemoryInput,
]
for tool_class in tool_classes:

View File

@@ -8,7 +8,6 @@ from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
import app.agent.orchestrator as agent_module
from app.agent.middleware.activity import ActivityLogMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.policy import AgentPolicyMiddleware
from app.agent.middleware.summarization import FinalRequestCompactionMiddleware
@@ -850,8 +849,8 @@ def test_main_agent_registers_policy_middleware_as_outermost() -> None:
assert isinstance(captured["middleware"][0], AgentPolicyMiddleware)
def test_main_agent_preserves_activity_log_middleware_order() -> None:
"""策略层加入后,ActivityLog 仍应位于 Memory 后、摘要前。"""
def test_main_agent_preserves_memory_middleware_order() -> None:
"""策略层加入后,统一 MemoryMiddleware 仍位于摘要压缩之前。"""
agent = agent_module.MoviePilotAgent(
session_id="session-1",
user_id="user-1",
@@ -882,9 +881,6 @@ def test_main_agent_preserves_activity_log_middleware_order() -> None:
memory_index = next(
index for index, middleware in enumerate(middlewares) if isinstance(middleware, MemoryMiddleware)
)
activity_index = next(
index for index, middleware in enumerate(middlewares) if isinstance(middleware, ActivityLogMiddleware)
)
compaction_index = next(
index
for index, middleware in enumerate(middlewares)
@@ -892,5 +888,4 @@ def test_main_agent_preserves_activity_log_middleware_order() -> None:
)
assert policy_index == 0
assert activity_index == memory_index + 1
assert compaction_index > activity_index
assert compaction_index > memory_index

View File

@@ -362,15 +362,15 @@ class TestAgentToolStreaming:
tool_kwargs={"name": "moviepilot-api"},
)
handler.record_tool_call(
tool_name="query_activity_log",
tool_message="Query recent MoviePilot Agent activity logs",
tool_kwargs={"keyword": "整理"},
tool_name="search_memory",
tool_message="Search MoviePilot Agent memory",
tool_kwargs={"query": "整理", "category": "activity"},
)
return await handler.take()
buffered_message = asyncio.run(_run())
assert buffered_message == "处理中:\n\n(查询了 1 个技能说明,查询了 1 次活动日志\n\n"
assert buffered_message == "处理中:\n\n(查询了 1 个技能说明,检索了 1 次记忆\n\n"
def test_non_verbose_tool_summary_counts_subagent_batch_tasks(self):
"""校验批量子代理控制工具按子任务数统计。"""

View File

@@ -6,8 +6,12 @@ from io import BytesIO
from typing import Any, Optional
import pytest
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langgraph.checkpoint.memory import InMemorySaver
from PIL import Image
from pydantic import ValidationError
from pydantic import Field, ValidationError
from app.adapters.network.http import AsyncRequestUtils
from app.agent.middleware.vision import VisionMiddleware
@@ -17,6 +21,7 @@ from app.agent.tools.impl.view_image import (
ViewImageInput,
ViewImageTool,
)
from app.agent.tools.result import TOOL_OBSERVATION_MARKER
from app.application.security.url import SecurityUtils
@@ -66,6 +71,21 @@ def _tool() -> ViewImageTool:
return ViewImageTool(session_id="view-image-test", user_id="owner")
class _RecordingImageModel(FakeMessagesListChatModel):
"""记录真实 Agent 图发送给模型的消息,验证图片不是只停留在 formatter。"""
requests: list[list[Any]] = Field(default_factory=list)
def bind_tools(self, _tools, **_kwargs):
"""接受 LangGraph 的工具绑定并保留预设模型响应。"""
return self
async def _agenerate(self, messages, stop=None, run_manager=None, **kwargs):
"""保存模型出站消息后返回预设的工具调用或最终答复。"""
self.requests.append([message.model_copy(deep=True) for message in messages])
return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)
def test_input_requires_exactly_one_image_source() -> None:
"""输入模型必须拒绝缺少来源或同时提供两个来源。"""
with pytest.raises(ValidationError):
@@ -208,3 +228,58 @@ def test_image_tool_is_registered_for_agent_but_keeps_raw_data_out_of_generic_fo
tool_names = {tool_class.model_fields["name"].default for tool_class in MoviePilotToolFactory.BUILTIN_TOOL_CLASSES}
assert "view_image" in tool_names
assert IMAGE_MAX_BYTES == 768 * 1024
@pytest.mark.asyncio
async def test_view_image_reaches_multimodal_model_through_real_agent_graph() -> None:
"""真实 ToolNode 与 VisionMiddleware 应把 view_image 图块送进模型 HumanMessage。"""
encoded = base64.b64encode(_image_bytes()).decode("ascii")
tool = _tool()
model = _RecordingImageModel(
responses=[
AIMessage(
content="",
tool_calls=[
{
"id": "view-image-call",
"name": "view_image",
"args": {"image_data": encoded, "detail": "high"},
}
],
),
AIMessage(content="已收到图片。"),
]
)
graph = create_agent(
model=model,
tools=[tool],
middleware=[VisionMiddleware(supports_images=lambda _model: True)],
checkpointer=InMemorySaver(),
)
result = await graph.ainvoke(
{"messages": [HumanMessage(content="请看看这张图片")]},
{"configurable": {"thread_id": "view-image-integration"}},
)
assert result["messages"][-1].content == "已收到图片。"
assert len(model.requests) == 2
model_request = model.requests[-1]
tool_messages = [message for message in model_request if isinstance(message, ToolMessage)]
assert len(tool_messages) == 1
assert isinstance(tool_messages[0].content, list)
assert not any(block.get("type") == "image_url" for block in tool_messages[0].content)
observations = [
message
for message in model_request
if isinstance(message, HumanMessage)
and message.additional_kwargs.get(TOOL_OBSERVATION_MARKER) is True
]
assert len(observations) == 1
observation_blocks = observations[0].content
assert isinstance(observation_blocks, list)
image_blocks = [block for block in observation_blocks if block.get("type") == "image_url"]
assert len(image_blocks) == 1
assert image_blocks[0]["image_url"]["detail"] == "high"
assert image_blocks[0]["image_url"]["url"].startswith("data:image/png;base64,")

View File

@@ -81,7 +81,6 @@ RETIRED_CANONICAL_FILES = (
"app/runtime/native_dependencies.py",
"app/agent/runtime_loader.py",
"app/agent/llm/server_tools.py",
"app/agent/middleware/activity_log.py",
"app/agent/middleware/patch_tool_calls.py",
"app/agent/middleware/runtime_config.py",
"app/agent/middleware/tool_selection.py",