mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 13:28:32 +08:00
fix(agent): isolate memory by user
This commit is contained in:
@@ -13,7 +13,8 @@ deprecated_phrases: []
|
||||
1. 核心系统提示词(程序内置,不可运行时覆盖)
|
||||
2. `personas/<active_persona>/PERSONA.md`
|
||||
3. `extra_context_files`
|
||||
4. `memory/MEMORY.md`(默认注入)
|
||||
5. `memory/<topic>.md` 与 `memory/activity/*.md`(通过 search_memory 按需检索)
|
||||
4. `memory/MEMORY.md`(全局公共记忆,默认注入)
|
||||
5. `memory/users/<user-key>/MEMORY.md`(当前用户记忆,默认注入)
|
||||
6. 其它主题与活动记忆(通过 search_memory 按需检索)
|
||||
|
||||
`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
from collections.abc import Awaitable, Callable, Iterable, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
@@ -249,10 +249,16 @@ def _category_for_path(path: Path, memory_dir: Path, activity_dir: Optional[Path
|
||||
return "topic"
|
||||
|
||||
|
||||
def _iter_memory_files(memory_dir: str, activity_dir: Optional[str] = None) -> Iterator[Path]:
|
||||
def _iter_memory_files(
|
||||
memory_dir: str | Path,
|
||||
activity_dir: Optional[str | Path] = None,
|
||||
*,
|
||||
excluded_dirs: Optional[Iterable[str | Path]] = None,
|
||||
) -> Iterator[Path]:
|
||||
"""递归枚举允许检索的 Markdown 文件,并拒绝逃逸记忆根的链接。"""
|
||||
memory_root = _memory_root(memory_dir)
|
||||
activity_root = _memory_root(activity_dir) if activity_dir else None
|
||||
excluded_roots = tuple(_memory_root(path) for path in (excluded_dirs or ()))
|
||||
roots = [memory_root]
|
||||
if activity_root and activity_root not in roots and not _is_relative_to(activity_root, memory_root):
|
||||
roots.append(activity_root)
|
||||
@@ -270,6 +276,8 @@ def _iter_memory_files(memory_dir: str, activity_dir: Optional[str] = None) -> I
|
||||
resolved = candidate.resolve(strict=False)
|
||||
if candidate.suffix.casefold() != ".md" or not candidate.is_file() or resolved in seen:
|
||||
continue
|
||||
if any(_is_relative_to(resolved, root) for root in excluded_roots):
|
||||
continue
|
||||
allowed = _is_relative_to(resolved, memory_root)
|
||||
if activity_root:
|
||||
allowed = allowed or _is_relative_to(resolved, activity_root)
|
||||
@@ -283,8 +291,10 @@ def _iter_memory_files(memory_dir: str, activity_dir: Optional[str] = None) -> I
|
||||
|
||||
def _resolve_memory_file(
|
||||
file_path: str,
|
||||
memory_dir: str,
|
||||
activity_dir: Optional[str],
|
||||
memory_dir: str | Path,
|
||||
activity_dir: Optional[str | Path],
|
||||
*,
|
||||
excluded_dirs: Optional[Iterable[str | Path]] = None,
|
||||
) -> Optional[Path]:
|
||||
"""解析工具传入的记忆路径,并限制在记忆域内。"""
|
||||
memory_root = _memory_root(memory_dir)
|
||||
@@ -295,6 +305,8 @@ def _resolve_memory_file(
|
||||
resolved = candidate.resolve(strict=False)
|
||||
if resolved.suffix.casefold() != ".md":
|
||||
return None
|
||||
if any(_is_relative_to(resolved, _memory_root(path)) for path in (excluded_dirs or ())):
|
||||
return None
|
||||
if _is_relative_to(resolved, memory_root):
|
||||
return resolved
|
||||
if activity_root and _is_relative_to(resolved, activity_root):
|
||||
@@ -336,10 +348,44 @@ def _line_entry(path: Path, category: str, line_number: int, text: str) -> dict[
|
||||
}
|
||||
|
||||
|
||||
def _build_memory_scopes(
|
||||
memory_dir: str | Path,
|
||||
activity_dir: Optional[str | Path],
|
||||
user_memory_dir: Optional[str | Path],
|
||||
user_activity_dir: Optional[str | Path],
|
||||
) -> list[tuple[str, Path, Optional[Path], tuple[Path, ...]]]:
|
||||
"""构造全局公共记忆与当前用户记忆的检索作用域。"""
|
||||
global_root = _memory_root(memory_dir)
|
||||
global_activity_root = _memory_root(activity_dir) if activity_dir else None
|
||||
user_root = _memory_root(user_memory_dir) if user_memory_dir else None
|
||||
if user_root:
|
||||
current_user_activity_root = (
|
||||
_memory_root(user_activity_dir)
|
||||
if user_activity_dir
|
||||
else user_root / "activity"
|
||||
)
|
||||
else:
|
||||
current_user_activity_root = None
|
||||
|
||||
scopes: list[tuple[str, Path, Optional[Path], tuple[Path, ...]]] = [
|
||||
(
|
||||
"global",
|
||||
global_root,
|
||||
global_activity_root,
|
||||
(global_root / "users",),
|
||||
)
|
||||
]
|
||||
if user_root and user_root != global_root:
|
||||
scopes.append(("user", user_root, current_user_activity_root, ()))
|
||||
return scopes
|
||||
|
||||
|
||||
def query_memory_files(
|
||||
memory_dir: str,
|
||||
*,
|
||||
activity_dir: Optional[str] = None,
|
||||
user_memory_dir: Optional[str] = None,
|
||||
user_activity_dir: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
category: str = "all",
|
||||
file_path: Optional[str] = None,
|
||||
@@ -348,7 +394,7 @@ def query_memory_files(
|
||||
days: int = DEFAULT_SEARCH_DAYS,
|
||||
limit: Optional[int] = DEFAULT_SEARCH_LIMIT,
|
||||
) -> dict[str, Any]:
|
||||
"""在统一记忆域内按需检索主记忆、主题记忆和活动记忆。"""
|
||||
"""在全局公共记忆与当前用户记忆作用域内按需检索。"""
|
||||
normalized_category = (category or "all").strip().casefold()
|
||||
if normalized_category not in {"all", "primary", "topic", "activity"}:
|
||||
return {
|
||||
@@ -383,25 +429,48 @@ def query_memory_files(
|
||||
"entries": [],
|
||||
}
|
||||
|
||||
memory_root = _memory_root(memory_dir)
|
||||
activity_root = _memory_root(activity_dir) if activity_dir else None
|
||||
scopes = _build_memory_scopes(
|
||||
memory_dir,
|
||||
activity_dir,
|
||||
user_memory_dir,
|
||||
user_activity_dir,
|
||||
)
|
||||
if file_path:
|
||||
resolved = _resolve_memory_file(file_path, memory_dir, activity_dir)
|
||||
if resolved is None:
|
||||
scoped_files: list[tuple[Path, str, Path, Optional[Path]]] = []
|
||||
for scope_name, scope_root, scope_activity, excluded_dirs in scopes:
|
||||
resolved = _resolve_memory_file(
|
||||
file_path,
|
||||
scope_root,
|
||||
scope_activity,
|
||||
excluded_dirs=excluded_dirs,
|
||||
)
|
||||
if resolved is not None and resolved.is_file():
|
||||
scoped_files.append((resolved, scope_name, scope_root, scope_activity))
|
||||
break
|
||||
if not scoped_files:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "file_path 必须指向记忆域内的 Markdown 文件",
|
||||
"message": "file_path 必须指向当前可见记忆域内的 Markdown 文件",
|
||||
"entries": [],
|
||||
}
|
||||
files = [resolved] if resolved.is_file() else []
|
||||
else:
|
||||
files = list(_iter_memory_files(memory_dir, activity_dir))
|
||||
scoped_files = []
|
||||
for scope_name, scope_root, scope_activity, excluded_dirs in scopes:
|
||||
scoped_files.extend(
|
||||
(path, scope_name, scope_root, scope_activity)
|
||||
for path in _iter_memory_files(
|
||||
scope_root,
|
||||
scope_activity,
|
||||
excluded_dirs=excluded_dirs,
|
||||
)
|
||||
)
|
||||
|
||||
category_order = {"primary": 0, "topic": 1, "activity": 2}
|
||||
filtered_files: list[Path] = []
|
||||
scope_order = {"global": 0, "user": 1}
|
||||
filtered_files: list[tuple[Path, str, Path, Optional[Path]]] = []
|
||||
recent_dates = set(_iter_recent_dates(normalized_days))
|
||||
for path in files:
|
||||
file_category = _category_for_path(path, memory_root, activity_root)
|
||||
for path, scope_name, scope_root, scope_activity in scoped_files:
|
||||
file_category = _category_for_path(path, scope_root, scope_activity)
|
||||
if normalized_category != "all" and file_category != normalized_category:
|
||||
continue
|
||||
activity_date = _activity_date_from_path(path) if file_category == "activity" else None
|
||||
@@ -409,12 +478,15 @@ def query_memory_files(
|
||||
continue
|
||||
if not date and file_category == "activity" and activity_date not in recent_dates:
|
||||
continue
|
||||
filtered_files.append(path)
|
||||
def _file_sort_key(path: Path) -> tuple[int, int, str]:
|
||||
filtered_files.append((path, scope_name, scope_root, scope_activity))
|
||||
|
||||
def _file_sort_key(item: tuple[Path, str, Path, Optional[Path]]) -> tuple[int, int, int, str]:
|
||||
"""为目录清单提供稳定的分类与活动日期排序键。"""
|
||||
path, scope_name, scope_root, scope_activity = item
|
||||
activity_date = _activity_date_from_path(path)
|
||||
return (
|
||||
category_order[_category_for_path(path, memory_root, activity_root)],
|
||||
category_order[_category_for_path(path, scope_root, scope_activity)],
|
||||
scope_order[scope_name],
|
||||
-int(activity_date.replace("-", "")) if activity_date else 0,
|
||||
str(path).casefold(),
|
||||
)
|
||||
@@ -425,10 +497,11 @@ def query_memory_files(
|
||||
{
|
||||
"path": str(path),
|
||||
"name": path.name,
|
||||
"category": _category_for_path(path, memory_root, activity_root),
|
||||
"category": _category_for_path(path, scope_root, scope_activity),
|
||||
"scope": scope_name,
|
||||
"bytes": path.stat().st_size if path.exists() else 0,
|
||||
}
|
||||
for path in filtered_files
|
||||
for path, scope_name, scope_root, scope_activity in filtered_files
|
||||
]
|
||||
entries: list[dict[str, str]] = []
|
||||
total_count = 0
|
||||
@@ -443,12 +516,12 @@ def query_memory_files(
|
||||
return bool(regex_pattern.search(text))
|
||||
return normalized_query.casefold() in text.casefold()
|
||||
|
||||
for path in filtered_files:
|
||||
for path, scope_name, scope_root, scope_activity in filtered_files:
|
||||
content, reason = _read_search_file(path)
|
||||
if content is None:
|
||||
skipped_files.append({"path": str(path), "reason": reason or "unavailable"})
|
||||
continue
|
||||
file_category = _category_for_path(path, memory_root, activity_root)
|
||||
file_category = _category_for_path(path, scope_root, scope_activity)
|
||||
activity_date = _activity_date_from_path(path)
|
||||
if file_category == "activity" and activity_date:
|
||||
activity_entries = _parse_activity_entries(activity_date, content)
|
||||
@@ -459,6 +532,7 @@ def query_memory_files(
|
||||
{
|
||||
"path": str(path),
|
||||
"category": file_category,
|
||||
"scope": scope_name,
|
||||
**item,
|
||||
"text": item["summary"][:MAX_SEARCH_LINE_CHARS],
|
||||
},
|
||||
@@ -467,7 +541,14 @@ def query_memory_files(
|
||||
]
|
||||
else:
|
||||
line_entries = [
|
||||
(line_number, line, _line_entry(path, file_category, line_number, line))
|
||||
(
|
||||
line_number,
|
||||
line,
|
||||
{
|
||||
**_line_entry(path, file_category, line_number, line),
|
||||
"scope": scope_name,
|
||||
},
|
||||
)
|
||||
for line_number, line in enumerate(content.splitlines(), start=1)
|
||||
if line.strip()
|
||||
]
|
||||
@@ -490,8 +571,10 @@ def query_memory_files(
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"memory_dir": str(memory_root),
|
||||
"activity_dir": str(activity_root) if activity_root else None,
|
||||
"memory_dir": str(scopes[0][1]),
|
||||
"activity_dir": str(scopes[0][2]) if scopes[0][2] else None,
|
||||
"user_memory_dir": str(scopes[1][1]) if len(scopes) > 1 else None,
|
||||
"user_activity_dir": str(scopes[1][2]) if len(scopes) > 1 and scopes[1][2] else None,
|
||||
"query": normalized_query,
|
||||
"category": normalized_category,
|
||||
"file_path": str(file_path) if file_path else None,
|
||||
@@ -500,7 +583,7 @@ def query_memory_files(
|
||||
"days": None if date else normalized_days,
|
||||
"files": file_descriptions,
|
||||
"file_count": len(file_descriptions),
|
||||
"searched_files": [str(path) for path in filtered_files],
|
||||
"searched_files": [str(path) for path, _scope, _root, _activity in filtered_files],
|
||||
"total_count": total_count,
|
||||
"returned_count": len(entries),
|
||||
"truncated": total_count > len(entries),
|
||||
@@ -512,10 +595,19 @@ def query_memory_files(
|
||||
class _MemoryToolProvider:
|
||||
"""统一记忆检索工具的异步实现。"""
|
||||
|
||||
def __init__(self, *, memory_dir: str, activity_dir: Optional[str]) -> None:
|
||||
"""保存受限的记忆根路径,实际文件读取在线程池中执行。"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
memory_dir: str,
|
||||
activity_dir: Optional[str],
|
||||
user_memory_dir: Optional[str],
|
||||
user_activity_dir: Optional[str],
|
||||
) -> None:
|
||||
"""保存受限的记忆作用域,实际文件读取在线程池中执行。"""
|
||||
self._memory_dir = memory_dir
|
||||
self._activity_dir = activity_dir
|
||||
self._user_memory_dir = user_memory_dir
|
||||
self._user_activity_dir = user_activity_dir
|
||||
|
||||
async def search_memory(
|
||||
self,
|
||||
@@ -546,6 +638,8 @@ class _MemoryToolProvider:
|
||||
query_memory_files,
|
||||
self._memory_dir,
|
||||
activity_dir=self._activity_dir,
|
||||
user_memory_dir=self._user_memory_dir,
|
||||
user_activity_dir=self._user_activity_dir,
|
||||
query=query,
|
||||
category=category,
|
||||
file_path=file_path,
|
||||
@@ -661,19 +755,23 @@ async def _summarize_with_llm(conversation_text: str) -> Optional[str]:
|
||||
|
||||
|
||||
MEMORY_SYSTEM_PROMPT = """<agent_memory>
|
||||
Only the primary memory file was loaded from the memory directory: `{memory_dir}`.
|
||||
The loaded file is shown below. Other Markdown memory files are available only through the `search_memory` tool.
|
||||
The global public memory file and the current user's memory file were loaded from the memory directory.
|
||||
Global public memory: `{memory_file}`
|
||||
Current user memory: `{user_memory_file}`
|
||||
The loaded files are shown below. Other Markdown memory files are available only through the `search_memory` tool.
|
||||
|
||||
{agent_memory}
|
||||
</agent_memory>
|
||||
|
||||
<memory_guidelines>
|
||||
The memory directory is `{memory_dir}`. Use `write_file` or `edit_file` to maintain durable memory, and use `search_memory` to retrieve files that are not loaded above.
|
||||
The global memory directory is `{memory_dir}`. The current user's memory directory is `{user_memory_dir}`.
|
||||
Use `write_file` or `edit_file` to maintain durable memory, and use `search_memory` to retrieve files that are not loaded above.
|
||||
Global public memory is shared by all users and may only be written by a system administrator. For a user's preference, write only to the current user's `{user_memory_file}`. Do not write another user's memory.
|
||||
|
||||
**Memory categories:**
|
||||
- `primary`: `{memory_file}` / `MEMORY.md`, for preferences, communication style, durable rules, and cross-task facts that should be remembered by default.
|
||||
- `topic`: other focused Markdown files for specialized knowledge. They are never loaded automatically; call `search_memory(category="topic", ...)` when relevant.
|
||||
- `activity`: `memory/activity/YYYY-MM-DD.md`, automatically summarized task history. It is read-only history for retrieval, retained for {retention_days} days, and is not automatically loaded. Do not manually write task history into `MEMORY.md`.
|
||||
- `primary`: `{memory_file}` and the current user's `{user_memory_file}`, for public rules and user-specific preferences that should be remembered by default.
|
||||
- `topic`: other focused Markdown files in the global or current user's memory scope. They are never loaded automatically; call `search_memory(category="topic", ...)` when relevant.
|
||||
- `activity`: the global or current user's `activity/YYYY-MM-DD.md`, automatically summarized task history. It is read-only history for retrieval, retained for {retention_days} days, and is not automatically loaded. Do not manually write task history into a primary memory file.
|
||||
|
||||
**Required task-start memory retrieval:**
|
||||
- Before executing any substantive task or calling any business, file, web, command, or external tool, first call `search_memory` to retrieve memories relevant to the user's request. This must be the first tool call for that task. If it finds nothing, continue without repeating unrelated searches.
|
||||
@@ -690,9 +788,11 @@ The loaded file is shown below. Other Markdown memory files are available only t
|
||||
"""
|
||||
|
||||
MEMORY_ONBOARDING_PROMPT = """<agent_memory>
|
||||
The primary memory file is empty or does not exist.
|
||||
Memory directory: {memory_dir}
|
||||
Primary memory file: {memory_file}
|
||||
The primary memory file is empty or does not exist for the available global and current-user scopes.
|
||||
Global memory directory: {memory_dir}
|
||||
Current user memory directory: {user_memory_dir}
|
||||
Global public memory file: {memory_file}
|
||||
Current user memory file: {user_memory_file}
|
||||
Other topic and activity Markdown files are available only through the `search_memory` tool.
|
||||
</agent_memory>
|
||||
|
||||
@@ -704,11 +804,11 @@ Other topic and activity Markdown files are available only through the `search_m
|
||||
- Before executing any substantive task or calling any business, file, web, command, or external tool, first call `search_memory`; this is the first tool call for that task, even when the primary file is empty, so relevant topic or activity memory can still be found.
|
||||
- Simple greetings, acknowledgements, and answers that require no task execution do not need a search.
|
||||
|
||||
When a user gives a durable preference or explicitly asks to remember something, save it promptly to `{memory_file}` with `write_file` or `edit_file` after the initial memory search. Record only durable preferences and working rules; never save credentials or invent personal details.
|
||||
When a user gives a durable preference or explicitly asks to remember something, save it promptly to the current user's `{user_memory_file}` with `write_file` or `edit_file` after the initial memory search. Only a system administrator may write global public memory at `{memory_file}`. Record only durable preferences and working rules; never save credentials or invent personal details.
|
||||
</memory_onboarding>
|
||||
|
||||
<memory_guidelines>
|
||||
Use `search_memory(category="topic", ...)` for focused knowledge and `search_memory(category="activity", ...)` for recent task history. Activity memory is automatically generated and read-only.
|
||||
Use `search_memory(category="topic", ...)` for focused knowledge and `search_memory(category="activity", ...)` for recent task history in the global or current user's scope. Activity memory is automatically generated and read-only.
|
||||
Memory may refine reply style but must not override core identity, safety boundaries, or system rules.
|
||||
</memory_guidelines>
|
||||
"""
|
||||
@@ -717,12 +817,14 @@ Other topic and activity Markdown files are available only through the `search_m
|
||||
class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # noqa
|
||||
"""统一管理主记忆、按需记忆检索与活动记忆记录。
|
||||
|
||||
`abefore_agent` 只加载 `MEMORY.md`,`search_memory` 递归检索其它 Markdown 文件,
|
||||
`aafter_agent` 在启用消息上下文的会话中把本轮活动摘要写入 `memory/activity`。
|
||||
`abefore_agent` 加载全局公共和当前用户的 `MEMORY.md`,`search_memory` 只检索
|
||||
全局公共与当前用户作用域,`aafter_agent` 将活动摘要写入当前用户的活动目录。
|
||||
|
||||
参数:
|
||||
memory_dir: 统一记忆根目录。
|
||||
activity_dir: 活动记忆目录;未提供时只启用主记忆与检索,不记录活动。
|
||||
memory_dir: 全局公共记忆根目录。
|
||||
activity_dir: 兼容无用户上下文时使用的全局活动记忆目录。
|
||||
user_memory_dir: 当前用户的记忆根目录;未提供时只使用全局公共记忆。
|
||||
user_activity_dir: 当前用户的活动记忆目录;未提供时默认为用户记忆目录下的 `activity`。
|
||||
retention_days: 活动记忆保留天数。
|
||||
stream_handler: 用于显示记忆检索工具的流式执行状态。
|
||||
task_registry: 宿主后台任务登记器。
|
||||
@@ -735,6 +837,8 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
*,
|
||||
memory_dir: str,
|
||||
activity_dir: Optional[str] = None,
|
||||
user_memory_dir: Optional[str] = None,
|
||||
user_activity_dir: Optional[str] = None,
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||
stream_handler: Optional[Any] = None,
|
||||
task_registry: Optional[TaskRegistry] = None,
|
||||
@@ -742,7 +846,18 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
"""初始化统一记忆中间件与按需检索工具。"""
|
||||
self.memory_dir = str(Path(memory_dir))
|
||||
self.activity_dir = str(Path(activity_dir)) if activity_dir else None
|
||||
self.user_memory_dir = str(Path(user_memory_dir)) if user_memory_dir else None
|
||||
self.user_activity_dir = (
|
||||
str(Path(user_activity_dir))
|
||||
if user_activity_dir
|
||||
else str(Path(self.user_memory_dir) / "activity") if self.user_memory_dir else None
|
||||
)
|
||||
self.default_memory_file = str(Path(self.memory_dir) / DEFAULT_MEMORY_FILE)
|
||||
self.user_memory_file = (
|
||||
str(Path(self.user_memory_dir) / DEFAULT_MEMORY_FILE)
|
||||
if self.user_memory_dir
|
||||
else None
|
||||
)
|
||||
self.retention_days = retention_days
|
||||
self.stream_handler = stream_handler
|
||||
self._task_registry = task_registry or get_task_registry()
|
||||
@@ -750,6 +865,8 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
self._tool_provider = _MemoryToolProvider(
|
||||
memory_dir=self.memory_dir,
|
||||
activity_dir=self.activity_dir,
|
||||
user_memory_dir=self.user_memory_dir,
|
||||
user_activity_dir=self.user_activity_dir,
|
||||
)
|
||||
self.tools = [
|
||||
StructuredTool.from_function(
|
||||
@@ -777,52 +894,67 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
return MEMORY_ONBOARDING_PROMPT.format(
|
||||
memory_dir=self.memory_dir,
|
||||
memory_file=self.default_memory_file,
|
||||
user_memory_dir=self.user_memory_dir or "(未启用)",
|
||||
user_memory_file=self.user_memory_file or "(未启用)",
|
||||
)
|
||||
memory_body = "\n\n".join(
|
||||
f"### {Path(path).name}\n**Path:** `{path}`\n\n{content}"
|
||||
for path, content in sorted(contents.items())
|
||||
f"### {('全局公共记忆' if path == self.default_memory_file else '当前用户记忆' if path == self.user_memory_file else Path(path).name)}\n**Path:** `{path}`\n\n{content}"
|
||||
for path, content in sorted(
|
||||
contents.items(),
|
||||
key=lambda item: (
|
||||
0 if item[0] == self.default_memory_file else 1 if item[0] == self.user_memory_file else 2,
|
||||
item[0],
|
||||
),
|
||||
)
|
||||
if content.strip()
|
||||
)
|
||||
if not memory_body:
|
||||
return MEMORY_ONBOARDING_PROMPT.format(
|
||||
memory_dir=self.memory_dir,
|
||||
memory_file=self.default_memory_file,
|
||||
user_memory_dir=self.user_memory_dir or "(未启用)",
|
||||
user_memory_file=self.user_memory_file or "(未启用)",
|
||||
)
|
||||
return MEMORY_SYSTEM_PROMPT.format(
|
||||
agent_memory=memory_body,
|
||||
memory_dir=self.memory_dir,
|
||||
memory_file=self.default_memory_file,
|
||||
user_memory_dir=self.user_memory_dir or "(未启用)",
|
||||
user_memory_file=self.user_memory_file or "(未启用)",
|
||||
retention_days=self.retention_days,
|
||||
)
|
||||
|
||||
async def _load_primary_memory(self) -> dict[str, str]:
|
||||
"""只读取主记忆文件,拒绝把主题和活动文件自动装入上下文。"""
|
||||
file_path = AsyncPath(self.default_memory_file)
|
||||
if not await file_path.is_file():
|
||||
return {}
|
||||
try:
|
||||
stat = await file_path.stat()
|
||||
if stat.st_size > MAX_MEMORY_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping primary memory file %s: too large (%d bytes, max %d)",
|
||||
self.default_memory_file,
|
||||
stat.st_size,
|
||||
MAX_MEMORY_FILE_SIZE,
|
||||
)
|
||||
return {}
|
||||
return {
|
||||
self.default_memory_file: await file_path.read_text(
|
||||
"""加载全局与当前用户主记忆,不把主题和活动正文自动装入上下文。"""
|
||||
contents: dict[str, str] = {}
|
||||
memory_files = [self.default_memory_file]
|
||||
if self.user_memory_file:
|
||||
memory_files.append(self.user_memory_file)
|
||||
for memory_file in memory_files:
|
||||
file_path = AsyncPath(memory_file)
|
||||
if not await file_path.is_file():
|
||||
continue
|
||||
try:
|
||||
stat = await file_path.stat()
|
||||
if stat.st_size > MAX_MEMORY_FILE_SIZE:
|
||||
logger.warning(
|
||||
"Skipping primary memory file %s: too large (%d bytes, max %d)",
|
||||
memory_file,
|
||||
stat.st_size,
|
||||
MAX_MEMORY_FILE_SIZE,
|
||||
)
|
||||
continue
|
||||
contents[memory_file] = await file_path.read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
}
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to read primary memory file %s: %s",
|
||||
self.default_memory_file,
|
||||
summarize_error(error),
|
||||
)
|
||||
return {}
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
"Failed to read primary memory file %s: %s",
|
||||
memory_file,
|
||||
summarize_error(error),
|
||||
)
|
||||
return contents
|
||||
|
||||
async def abefore_agent( # noqa
|
||||
self,
|
||||
@@ -860,20 +992,26 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
"""异步包装模型调用,注入主记忆和统一记忆操作规则。"""
|
||||
return await handler(self.modify_request(request))
|
||||
|
||||
def _get_active_activity_dir(self) -> Optional[str]:
|
||||
"""返回当前会话应写入和清理的活动记忆目录。"""
|
||||
return self.user_activity_dir or self.activity_dir
|
||||
|
||||
def _get_activity_path(self, date_str: str) -> AsyncPath:
|
||||
"""获取指定日期的活动记忆文件路径。"""
|
||||
if not self.activity_dir:
|
||||
activity_dir = self._get_active_activity_dir()
|
||||
if not activity_dir:
|
||||
raise RuntimeError("activity memory is disabled")
|
||||
return AsyncPath(self.activity_dir) / f"{date_str}.md"
|
||||
return AsyncPath(activity_dir) / f"{date_str}.md"
|
||||
|
||||
async def _append_activity(self, summary: str) -> None:
|
||||
"""将活动摘要追加到统一记忆域的当日活动文件。"""
|
||||
if not self.activity_dir:
|
||||
"""将活动摘要追加到当前作用域的当日活动文件。"""
|
||||
activity_dir = self._get_active_activity_dir()
|
||||
if not activity_dir:
|
||||
return
|
||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||
now_str = datetime.now().strftime("%H:%M")
|
||||
log_path = self._get_activity_path(today_str)
|
||||
directory = AsyncPath(self.activity_dir)
|
||||
directory = AsyncPath(activity_dir)
|
||||
try:
|
||||
await directory.mkdir(parents=True, exist_ok=True)
|
||||
if await log_path.exists() and (await log_path.stat()).st_size >= MAX_LOG_FILE_SIZE:
|
||||
@@ -897,10 +1035,11 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
logger.warning("Failed to append activity memory: %s", summarize_error(error))
|
||||
|
||||
async def _cleanup_old_activity(self) -> None:
|
||||
"""清理统一活动记忆域中超过保留期的日期文件。"""
|
||||
if not self.activity_dir:
|
||||
"""清理当前作用域中超过保留期的活动日期文件。"""
|
||||
activity_dir = self._get_active_activity_dir()
|
||||
if not activity_dir:
|
||||
return
|
||||
directory = AsyncPath(self.activity_dir)
|
||||
directory = AsyncPath(activity_dir)
|
||||
if not await directory.exists():
|
||||
return
|
||||
cutoff_date = datetime.now().date() - timedelta(days=self.retention_days)
|
||||
@@ -923,7 +1062,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
|
||||
def _schedule_activity_recording(self, messages: list[Any]) -> None:
|
||||
"""登记后台活动摘要任务,不阻塞当前 Agent 会话结束。"""
|
||||
if not self.activity_dir:
|
||||
if not self._get_active_activity_dir():
|
||||
return
|
||||
task = self._task_registry.create(
|
||||
self._record_activity(messages),
|
||||
@@ -1005,7 +1144,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
|
||||
"""Agent 执行完毕后,异步登记本轮活动记忆摘要。"""
|
||||
del runtime
|
||||
messages = state.get("messages", [])
|
||||
if messages and self.activity_dir:
|
||||
if messages and self._get_active_activity_dir():
|
||||
self._schedule_activity_recording(list(messages))
|
||||
return None
|
||||
|
||||
|
||||
@@ -1867,13 +1867,17 @@ class MoviePilotAgent:
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
skill_tools = list(getattr(skills_middleware, "tools", []) or [])
|
||||
user_memory_dir = agent_runtime_manager.get_user_memory_dir(self.user_id)
|
||||
user_activity_dir = agent_runtime_manager.get_user_activity_dir(self.user_id)
|
||||
memory_middleware = MemoryMiddleware(
|
||||
memory_dir=str(agent_runtime_manager.memory_dir),
|
||||
activity_dir=(
|
||||
str(agent_runtime_manager.activity_dir)
|
||||
if self.has_message_context
|
||||
if self.has_message_context and user_memory_dir is None
|
||||
else None
|
||||
),
|
||||
user_memory_dir=str(user_memory_dir) if user_memory_dir else None,
|
||||
user_activity_dir=str(user_activity_dir) if user_activity_dir else None,
|
||||
stream_handler=self.stream_handler,
|
||||
)
|
||||
memory_tools = list(getattr(memory_middleware, "tools", []) or [])
|
||||
|
||||
@@ -118,7 +118,7 @@ class ToolPolicyRegistry:
|
||||
)
|
||||
return ActionPolicy(
|
||||
effect=(ActionEffect.REVERSIBLE_WRITE if action in {"switch", "update"} else ActionEffect.UNKNOWN),
|
||||
required_role=(PrincipalRole.SYSTEM_ADMIN if action == "update" else PrincipalRole.USER),
|
||||
required_role=PrincipalRole.SYSTEM_ADMIN,
|
||||
confirmation=ConfirmationMode.REQUIRED,
|
||||
recovery=RecoveryMode.BEFORE_STATE,
|
||||
result_sensitivity=ResultSensitivity.NORMAL,
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Iterable, Optional
|
||||
import yaml
|
||||
|
||||
from app.application.configuration import get_runtime_settings
|
||||
from app.foundation.identity import build_user_memory_key
|
||||
from app.runtime.log import logger
|
||||
|
||||
CURRENT_PERSONA_FILE = "CURRENT_PERSONA.md"
|
||||
@@ -22,6 +23,7 @@ MEMORY_DIR = "memory"
|
||||
SKILLS_DIR = "skills"
|
||||
JOBS_DIR = "jobs"
|
||||
ACTIVITY_DIR = "activity"
|
||||
USER_MEMORY_DIR = "users"
|
||||
PERSONAS_DIR = "personas"
|
||||
PERSONA_FILE = "PERSONA.md"
|
||||
SUBAGENTS_DIR = "subagents"
|
||||
@@ -184,6 +186,7 @@ class AgentRuntimeConfig:
|
||||
f"- Active persona: `{self.active_persona}`",
|
||||
f"- Active persona file: `personas/{self.persona.persona_id}/{PERSONA_FILE}`",
|
||||
"- Use `persona` with `action=list` before switching when the requested speaking style is unclear.",
|
||||
"- Only a system administrator may switch the global active persona or update persona definitions.",
|
||||
"- Subagent availability is exposed by the subagent task tools; do not rely on this runtime section as a catalog.",
|
||||
"</agent_runtime_config>",
|
||||
]
|
||||
@@ -248,6 +251,7 @@ class AgentRuntimeManager:
|
||||
self.agent_root_dir = agent_root_dir or _default_agent_root_dir()
|
||||
self.runtime_dir = self.agent_root_dir / SYSTEM_RUNTIME_DIR
|
||||
self.memory_dir = self.agent_root_dir / MEMORY_DIR
|
||||
self.user_memory_root = self.memory_dir / USER_MEMORY_DIR
|
||||
self.skills_dir = self.agent_root_dir / SKILLS_DIR
|
||||
self.jobs_dir = self.agent_root_dir / JOBS_DIR
|
||||
# 活动记忆属于统一 memory 域;旧的 agent/activity 目录不再读取或迁移。
|
||||
@@ -269,6 +273,7 @@ class AgentRuntimeManager:
|
||||
self.agent_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.user_memory_root.mkdir(parents=True, exist_ok=True)
|
||||
self.skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.jobs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.activity_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -308,6 +313,28 @@ class AgentRuntimeManager:
|
||||
self._cached_signature_checked_at = 0.0
|
||||
self._layout_ready = False
|
||||
|
||||
def get_user_memory_dir(self, user_id: Optional[str]) -> Optional[Path]:
|
||||
"""
|
||||
获取指定用户的记忆目录,不创建目录或暴露原始用户标识。
|
||||
|
||||
:param user_id: 由可信入口传入的用户 ID
|
||||
:return: 用户级记忆目录;系统内部用户或空标识返回 None
|
||||
"""
|
||||
user_key = build_user_memory_key(user_id)
|
||||
if user_key is None:
|
||||
return None
|
||||
return self.user_memory_root / user_key
|
||||
|
||||
def get_user_activity_dir(self, user_id: Optional[str]) -> Optional[Path]:
|
||||
"""
|
||||
获取指定用户的活动记忆目录。
|
||||
|
||||
:param user_id: 由可信入口传入的用户 ID
|
||||
:return: 用户级活动记忆目录;系统内部用户或空标识返回 None
|
||||
"""
|
||||
user_memory_dir = self.get_user_memory_dir(user_id)
|
||||
return user_memory_dir / ACTIVITY_DIR if user_memory_dir else None
|
||||
|
||||
def current_signature(self) -> tuple[tuple[str, int, int], ...]:
|
||||
"""返回当前运行时配置文件签名,供调用方判断缓存是否仍可复用。"""
|
||||
now = time.monotonic()
|
||||
@@ -881,8 +908,9 @@ class AgentRuntimeManager:
|
||||
"1. 核心系统提示词(程序内置,不可运行时覆盖)",
|
||||
"2. `personas/<active_persona>/PERSONA.md`",
|
||||
"3. `extra_context_files`",
|
||||
"4. `memory/MEMORY.md`(默认注入)",
|
||||
"5. `memory/<topic>.md` 与 `memory/activity/*.md`(通过 search_memory 按需检索)",
|
||||
"4. `memory/MEMORY.md`(全局公共记忆,默认注入)",
|
||||
"5. `memory/users/<user-key>/MEMORY.md`(当前用户记忆,默认注入)",
|
||||
"6. 其它主题与活动记忆(通过 search_memory 按需检索)",
|
||||
"",
|
||||
"`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.application.agent import AgentDataContext
|
||||
from app.application.messaging.channel.admin import matches_channel_admin
|
||||
from app.application.notification import get_notification_configs
|
||||
from app.chain.base import ChainBase
|
||||
from app.foundation.identity import build_user_memory_key
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.schemas.message import Message
|
||||
@@ -751,17 +752,46 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
|
||||
return resolved_path, None
|
||||
|
||||
allowed_roots = self._get_non_admin_local_file_roots()
|
||||
if any(
|
||||
if not any(
|
||||
self._is_path_relative_to(resolved_path, root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
|
||||
agent_root = allowed_roots[0]
|
||||
memory_root = agent_root / "memory"
|
||||
runtime_root = agent_root / "runtime"
|
||||
if self._is_path_relative_to(resolved_path, runtime_root) and operation != "读取":
|
||||
return (
|
||||
resolved_path,
|
||||
"抱歉,Agent 人格及运行时配置只有系统管理员才能修改。",
|
||||
)
|
||||
|
||||
if not self._is_path_relative_to(resolved_path, memory_root):
|
||||
return resolved_path, None
|
||||
|
||||
allowed_text = "、".join(str(root) for root in allowed_roots)
|
||||
return (
|
||||
resolved_path,
|
||||
f"抱歉,普通用户只能{operation}Agent配置目录内的文件或目录:{allowed_text}",
|
||||
)
|
||||
user_memory_root = memory_root / "users"
|
||||
if self._is_path_relative_to(resolved_path, user_memory_root):
|
||||
user_key = build_user_memory_key(self._user_id)
|
||||
own_memory_root = user_memory_root / user_key if user_key else None
|
||||
if not own_memory_root or not self._is_path_relative_to(resolved_path, own_memory_root):
|
||||
return (
|
||||
resolved_path,
|
||||
"抱歉,普通用户只能访问自己的用户级记忆,不能读取或修改其他用户的记忆。",
|
||||
)
|
||||
return resolved_path, None
|
||||
|
||||
if operation != "读取":
|
||||
return (
|
||||
resolved_path,
|
||||
"抱歉,全局公共记忆只有系统管理员才能写入;请将个人偏好写入当前用户的记忆目录。",
|
||||
)
|
||||
|
||||
return resolved_path, None
|
||||
|
||||
async def _check_local_storage_access(
|
||||
self,
|
||||
|
||||
@@ -83,9 +83,8 @@ class PersonaTool(MoviePilotTool):
|
||||
tags: list[str] = [ToolTag.Read, ToolTag.Write, ToolTag.Persona]
|
||||
description: str = (
|
||||
"Manage agent personas with one structured action. Use list to discover personas "
|
||||
"and the active state, switch when the user explicitly requests a speaking style, "
|
||||
"and update only for an explicit administrator request to create or rewrite a "
|
||||
"persona definition."
|
||||
"and the active state. Only a system administrator may switch the global active "
|
||||
"persona or create and rewrite a persona definition."
|
||||
)
|
||||
args_schema: Type[BaseModel] = PersonaInput
|
||||
|
||||
@@ -134,16 +133,16 @@ class PersonaTool(MoviePilotTool):
|
||||
try:
|
||||
if payload.action == "list":
|
||||
return self._list_personas(payload.query)
|
||||
if payload.action == "switch":
|
||||
return self._switch_persona(payload.persona_id)
|
||||
if not await self.is_admin_user():
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"message": "只有系统管理员才能更新人格定义。",
|
||||
"message": "只有系统管理员才能切换或更新全局人格设置。",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if payload.action == "switch":
|
||||
return self._switch_persona(payload.persona_id)
|
||||
return self._update_persona(payload)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
from typing import Optional, Union
|
||||
|
||||
# 后台任务会话使用的内部占位用户ID。
|
||||
@@ -25,3 +26,18 @@ def normalize_internal_user_id(
|
||||
if is_internal_user_id(userid):
|
||||
return None
|
||||
return userid
|
||||
|
||||
|
||||
def build_user_memory_key(userid: Optional[Union[str, int]]) -> Optional[str]:
|
||||
"""
|
||||
为用户记忆目录生成不可逆且稳定的隔离键。
|
||||
|
||||
用户 ID 可能来自消息渠道,不能直接拼接进文件路径;系统内部用户不拥有
|
||||
用户级记忆,返回 None 让调用方只使用公共记忆。
|
||||
"""
|
||||
if userid is None or is_internal_user_id(userid):
|
||||
return None
|
||||
normalized = str(userid).strip()
|
||||
if not normalized:
|
||||
return None
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -18,11 +18,13 @@ WebAgent 在 Agent 正在运行时仍可提交文本和附件。宿主为每个
|
||||
|
||||
## 记忆与活动记录
|
||||
|
||||
Agent 的记忆统一位于 `config/agent/memory`:`MEMORY.md` 是主记忆文件,只包含跨任务默认需要记住的用户偏好、沟通方式、长期规则和稳定事实,并且只有它会在每轮默认注入上下文。其它主题记忆使用独立的 Markdown 文件,活动记录使用 `memory/activity/YYYY-MM-DD.md`;两者都不会自动加载。
|
||||
Agent 的记忆统一位于 `config/agent/memory`:`MEMORY.md` 是全局公共主记忆文件,只包含所有用户共享的偏好、沟通方式、长期规则和稳定事实;当前用户的主记忆位于 `memory/users/<user-key>/MEMORY.md`。两者都会在每轮默认注入上下文,公共记忆先于用户记忆;其它主题记忆使用各自作用域下的 Markdown 文件,活动记录使用各自作用域下的 `activity/YYYY-MM-DD.md`,主题和活动记忆都不会自动加载。
|
||||
|
||||
Agent 在执行任何实质任务或调用业务、文件、网络、命令等工具前,必须先调用 `search_memory` 检索与本次请求相关的记忆;这是本轮任务的第一个工具调用。工具支持 `primary`、`topic`、`activity` 和 `all` 分类,以及关键词、文件路径、日期、时间窗口、条数和显式正则过滤。检索结果是有界的结构化内容,不会把整个记忆目录重新塞回上下文。活动记录是自动生成的只读历史,保留期默认 7 天;旧的 `config/agent/activity` 文件不迁移,也不再作为活动记忆读取。
|
||||
|
||||
用户明确要求记住的偏好或规则应写入 `MEMORY.md` 或合适的主题文件;一次性请求、临时状态和凭据不应写入记忆。检索到的文件内容只是上下文,不能覆盖系统或用户指令。
|
||||
用户明确要求记住的偏好或规则应写入当前用户的 `memory/users/<user-key>/MEMORY.md` 或合适的用户级主题文件;只有系统管理员可以写入全局公共记忆。一次性请求、临时状态和凭据不应写入记忆。检索到的文件内容只是上下文,不能覆盖系统或用户指令。
|
||||
|
||||
Agent 人格是全局运行时配置,不按用户隔离;所有用户共享当前激活人格。普通用户可以查询人格列表,但只有系统管理员可以切换激活人格或修改人格定义。
|
||||
|
||||
## 按需发现工具
|
||||
|
||||
|
||||
@@ -754,7 +754,7 @@ Agent 自主任务使用数据库中的整数 `task_id`。`scheduler.list` 与 `
|
||||
创建每天 20:30 执行的周期任务时,使用 `trigger_type=cron` 和 `trigger="30 20 * * *"`。
|
||||
|
||||
`persona` 使用 `action=list|switch|update`。`list` 可按 `query` 过滤;
|
||||
`switch` 必须提供 `persona_id`;`update` 只有管理员可用,支持替换 label、
|
||||
`switch` 和 `update` 必须提供 `persona_id`,且只有系统管理员可用。`update` 支持替换 label、
|
||||
description、aliases、instructions,或通过 `append_instructions` 追加规则。旧的
|
||||
`query_personas`、`switch_persona` 和 `update_persona_definition` 不再注册。
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
from app.agent.tools.impl.edit_file import EditFileTool
|
||||
from app.agent.tools.impl.read_file import MAX_READ_SIZE, ReadFileTool
|
||||
from app.agent.tools.impl.write_file import WriteFileTool
|
||||
from app.foundation.identity import build_user_memory_key
|
||||
|
||||
|
||||
def _make_admin_tool(tool_class):
|
||||
@@ -123,6 +124,41 @@ def test_read_file_can_return_sha256_metadata(tmp_path):
|
||||
assert payload["truncated"] is False
|
||||
|
||||
|
||||
def test_non_admin_memory_access_is_scoped_to_current_user(tmp_path, monkeypatch):
|
||||
"""普通用户只能写自己的记忆,公共记忆和其他用户记忆不可写读。"""
|
||||
config_path = tmp_path / "config"
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.base.get_runtime_setting",
|
||||
lambda name: config_path if name == "CONFIG_PATH" else None,
|
||||
)
|
||||
user_id = "10001"
|
||||
user_key = build_user_memory_key(user_id)
|
||||
assert user_key is not None
|
||||
own_memory = config_path / "agent" / "memory" / "users" / user_key / "MEMORY.md"
|
||||
global_memory = config_path / "agent" / "memory" / "MEMORY.md"
|
||||
other_memory = config_path / "agent" / "memory" / "users" / "other" / "MEMORY.md"
|
||||
global_memory.parent.mkdir(parents=True)
|
||||
global_memory.write_text("公共规则", encoding="utf-8")
|
||||
other_memory.parent.mkdir(parents=True)
|
||||
other_memory.write_text("其他用户规则", encoding="utf-8")
|
||||
tool = WriteFileTool(session_id="session-1", user_id=user_id)
|
||||
tool.set_agent_context({"is_admin": False})
|
||||
|
||||
own_result = asyncio.run(tool.run(str(own_memory), "我的偏好"))
|
||||
global_result = asyncio.run(tool.run(str(global_memory), "不应写入", overwrite=True))
|
||||
other_result = asyncio.run(tool.run(str(other_memory), "不应写入", overwrite=True))
|
||||
|
||||
assert "成功写入文件" in own_result
|
||||
assert own_memory.read_text(encoding="utf-8") == "我的偏好"
|
||||
assert "全局公共记忆" in global_result
|
||||
assert "其他用户的记忆" in other_result
|
||||
|
||||
read_tool = ReadFileTool(session_id="session-1", user_id=user_id)
|
||||
read_tool.set_agent_context({"is_admin": False})
|
||||
read_other_result = asyncio.run(read_tool.run(str(other_memory)))
|
||||
assert "其他用户的记忆" in read_other_result
|
||||
|
||||
|
||||
def test_read_file_rejects_invalid_line_ranges(tmp_path):
|
||||
"""非法行号必须明确失败,不能静默裁剪成另一段内容。"""
|
||||
file_path = tmp_path / "range.py"
|
||||
|
||||
@@ -69,6 +69,37 @@ def test_memory_loads_only_primary_file(tmp_path):
|
||||
assert "first tool call" in system_text
|
||||
|
||||
|
||||
def test_memory_loads_global_and_current_user_primary_files_only(tmp_path):
|
||||
"""默认上下文应按全局再当前用户顺序加载,且不触及其他用户记忆。"""
|
||||
global_memory = tmp_path / "MEMORY.md"
|
||||
global_memory.write_text("公共规则:使用简洁风格。", encoding="utf-8")
|
||||
user_memory_dir = tmp_path / "users" / "user-a"
|
||||
user_memory = user_memory_dir / "MEMORY.md"
|
||||
user_memory.parent.mkdir(parents=True)
|
||||
user_memory.write_text("用户偏好:优先中文回答。", encoding="utf-8")
|
||||
other_memory = tmp_path / "users" / "user-b" / "MEMORY.md"
|
||||
other_memory.parent.mkdir(parents=True)
|
||||
other_memory.write_text("其他用户的私密偏好。", encoding="utf-8")
|
||||
|
||||
middleware = MemoryMiddleware(
|
||||
memory_dir=str(tmp_path),
|
||||
user_memory_dir=str(user_memory_dir),
|
||||
)
|
||||
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 state_update["memory_contents"] == {
|
||||
str(global_memory): "公共规则:使用简洁风格。",
|
||||
str(user_memory): "用户偏好:优先中文回答。",
|
||||
}
|
||||
assert prompt.index("公共规则:使用简洁风格") < prompt.index("用户偏好:优先中文回答")
|
||||
assert "其他用户的私密偏好" not in prompt
|
||||
assert str(user_memory) in prompt
|
||||
|
||||
|
||||
def test_memory_onboarding_still_requires_search_before_task(tmp_path):
|
||||
"""主记忆为空时也必须要求 Agent 在执行任务前检索其它记忆。"""
|
||||
(tmp_path / "MEDIA_RULES.md").write_text("主题记忆:偏好 HEVC。", encoding="utf-8")
|
||||
@@ -126,6 +157,37 @@ def test_query_memory_files_searches_topic_and_activity_categories(tmp_path):
|
||||
assert activity_payload["entries"][0]["date"] == "2026-06-18"
|
||||
|
||||
|
||||
def test_query_memory_files_scopes_global_and_current_user_memory(tmp_path):
|
||||
"""检索只能看到公共记忆与当前用户记忆,不能跨用户读取。"""
|
||||
(tmp_path / "PUBLIC_RULES.md").write_text("公共主题:优先 Remux。\n", encoding="utf-8")
|
||||
user_memory_dir = tmp_path / "users" / "user-a"
|
||||
(user_memory_dir / "USER_RULES.md").parent.mkdir(parents=True)
|
||||
(user_memory_dir / "USER_RULES.md").write_text("当前用户主题:偏好 HEVC。\n", encoding="utf-8")
|
||||
other_memory = tmp_path / "users" / "user-b" / "USER_RULES.md"
|
||||
other_memory.parent.mkdir(parents=True)
|
||||
other_memory.write_text("其他用户主题:偏好 AV1。\n", encoding="utf-8")
|
||||
|
||||
payload = query_memory_files(
|
||||
str(tmp_path),
|
||||
user_memory_dir=str(user_memory_dir),
|
||||
query="偏好",
|
||||
category="topic",
|
||||
limit=10,
|
||||
)
|
||||
other_file = query_memory_files(
|
||||
str(tmp_path),
|
||||
user_memory_dir=str(user_memory_dir),
|
||||
file_path=str(other_memory),
|
||||
)
|
||||
|
||||
assert payload["success"] is True
|
||||
assert {entry["text"] for entry in payload["entries"]} == {
|
||||
"当前用户主题:偏好 HEVC。",
|
||||
}
|
||||
assert all(entry["scope"] == "user" for entry in payload["entries"])
|
||||
assert other_file["success"] is False
|
||||
|
||||
|
||||
def test_query_memory_files_supports_regex_and_bounds_results(tmp_path):
|
||||
"""记忆检索应支持显式正则,并对返回条数做有界处理。"""
|
||||
(tmp_path / "one.md").write_text("整理电影 A\n查询站点\n", encoding="utf-8")
|
||||
@@ -313,6 +375,46 @@ def test_activity_memory_records_under_unified_memory_directory(tmp_path):
|
||||
assert summary in log_files[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_activity_memory_records_under_current_user_directory(tmp_path):
|
||||
"""带用户上下文的活动摘要只能写入当前用户的活动目录。"""
|
||||
summary = "当前用户请求整理电影,助手完成了文件处理。"
|
||||
user_memory_dir = tmp_path / "users" / "user-a"
|
||||
user_activity_dir = user_memory_dir / "activity"
|
||||
|
||||
async def _run_test():
|
||||
middleware = MemoryMiddleware(
|
||||
memory_dir=str(tmp_path),
|
||||
user_memory_dir=str(user_memory_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(user_activity_dir.glob("*.md"))
|
||||
assert len(log_files) == 1
|
||||
assert summary in log_files[0].read_text(encoding="utf-8")
|
||||
assert not list((tmp_path / "activity").glob("*.md"))
|
||||
|
||||
|
||||
def test_activity_memory_skips_trivial_greeting_without_llm(tmp_path):
|
||||
"""无实际任务的寒暄不应调用 LLM,也不应写入活动记忆。"""
|
||||
|
||||
|
||||
@@ -65,13 +65,31 @@ async def test_persona_switch_updates_runtime_by_alias(
|
||||
runtime_manager,
|
||||
)
|
||||
|
||||
payload = json.loads(await _tool().run(action="switch", persona_id="讲解"))
|
||||
payload = json.loads(await _tool(is_admin=True).run(action="switch", persona_id="讲解"))
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["active_persona"] == "guide"
|
||||
assert runtime_manager.load_runtime_config().active_persona == "guide"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_persona_switch_requires_admin(
|
||||
monkeypatch,
|
||||
runtime_manager: AgentRuntimeManager,
|
||||
) -> None:
|
||||
"""普通用户不能切换所有用户共享的全局人格。"""
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.persona.agent_runtime_manager",
|
||||
runtime_manager,
|
||||
)
|
||||
|
||||
payload = json.loads(await _tool().run(action="switch", persona_id="讲解"))
|
||||
|
||||
assert payload["success"] is False
|
||||
assert "系统管理员" in payload["message"]
|
||||
assert runtime_manager.load_runtime_config().active_persona == "default"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_persona_update_changes_existing_definition(
|
||||
monkeypatch,
|
||||
@@ -139,7 +157,7 @@ async def test_persona_update_requires_admin(
|
||||
monkeypatch,
|
||||
runtime_manager: AgentRuntimeManager,
|
||||
) -> None:
|
||||
"""普通用户可以查询和切换,但不能更新人格定义。"""
|
||||
"""普通用户可以查询,但不能更新或切换全局人格定义。"""
|
||||
monkeypatch.setattr(
|
||||
"app.agent.tools.impl.persona.agent_runtime_manager",
|
||||
runtime_manager,
|
||||
|
||||
@@ -154,6 +154,11 @@ def test_registry_applies_safe_read_exceptions_and_defaults_to_shadow() -> None:
|
||||
arguments={"action": "list"},
|
||||
requires_admin=False,
|
||||
)
|
||||
persona_switch_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||||
tool_name="persona",
|
||||
arguments={"action": "switch", "persona_id": "concise"},
|
||||
requires_admin=False,
|
||||
)
|
||||
admin_safe_policy = DEFAULT_TOOL_POLICY_REGISTRY.resolve(
|
||||
tool_name="moviepilot_api",
|
||||
arguments={"operation_id": "scheduler.list"},
|
||||
@@ -173,6 +178,7 @@ def test_registry_applies_safe_read_exceptions_and_defaults_to_shadow() -> None:
|
||||
assert safe_policy.effect is ActionEffect.SAFE_READ
|
||||
assert safe_policy.result_sensitivity is ResultSensitivity.NORMAL
|
||||
assert safe_policy.migration_state is MigrationState.ENFORCED
|
||||
assert persona_switch_policy.required_role is PrincipalRole.SYSTEM_ADMIN
|
||||
|
||||
assert admin_safe_policy.effect is ActionEffect.SAFE_READ
|
||||
assert admin_safe_policy.required_role is PrincipalRole.SYSTEM_ADMIN
|
||||
@@ -886,6 +892,14 @@ def test_main_agent_preserves_memory_middleware_order() -> None:
|
||||
for index, middleware in enumerate(middlewares)
|
||||
if isinstance(middleware, FinalRequestCompactionMiddleware)
|
||||
)
|
||||
memory_middleware = middlewares[memory_index]
|
||||
|
||||
assert policy_index == 0
|
||||
assert compaction_index > memory_index
|
||||
assert memory_middleware.user_memory_dir == str(
|
||||
agent_module.agent_runtime_manager.get_user_memory_dir("user-1")
|
||||
)
|
||||
assert memory_middleware.user_activity_dir == str(
|
||||
agent_module.agent_runtime_manager.get_user_activity_dir("user-1")
|
||||
)
|
||||
assert memory_middleware.activity_dir is None
|
||||
|
||||
Reference in New Issue
Block a user