mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
feat(agent): unify memory retrieval and activity history
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。
|
||||
|
||||
@@ -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
@@ -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:
|
||||
"""构造独立出站副本;同一请求反复投影不会改变原图或累积临时用户消息。"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。",
|
||||
]
|
||||
|
||||
@@ -86,6 +86,7 @@ class MoviePilotToolFactory:
|
||||
"execute_command",
|
||||
"ask_user_choice",
|
||||
"agent_task",
|
||||
"search_memory",
|
||||
)
|
||||
|
||||
CATALOG_BUILD_MAX_ATTEMPTS = 3
|
||||
|
||||
@@ -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` 开关。已知纯文本模型会得到明确的“未接收工具图片”说明。服务明确拒绝图片时,只对该次模型调用做一次文字回退,随后本轮沿用文字观察,不重复执行图片工具或其他工具;认证、限流等错误保持原来的错误语义。临时观察不会写入会话历史。
|
||||
|
||||
|
||||
@@ -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 最终目录规模
|
||||
|
||||
|
||||
@@ -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 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ _META_NOISE_MODULES = frozenset({
|
||||
"callback",
|
||||
"prompt",
|
||||
"memory.py",
|
||||
"activity_log.py",
|
||||
"message.py",
|
||||
"event.py",
|
||||
"chain",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
422
tests/test_agent_memory.py
Normal 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}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""校验批量子代理控制工具按子任务数统计。"""
|
||||
|
||||
@@ -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,")
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user