From 1d043bb64e0a2a8f70b2575991ebcb44c1556735 Mon Sep 17 00:00:00 2001 From: "moviepilot-codex[bot]" Date: Mon, 21 Sep 2026 08:59:33 +0800 Subject: [PATCH] fix(agent): isolate memory by user --- app/agent/defaults/CURRENT_PERSONA.md | 5 +- app/agent/middleware/memory.py | 297 +++++++++++++++++++------- app/agent/orchestrator.py | 6 +- app/agent/policy/registry.py | 2 +- app/agent/runtime.py | 32 ++- app/agent/tools/base.py | 42 +++- app/agent/tools/impl/persona.py | 11 +- app/foundation/identity.py | 16 ++ docs/agent.md | 6 +- docs/mcp-api.md | 2 +- tests/test_agent_file_tools.py | 36 ++++ tests/test_agent_memory.py | 102 +++++++++ tests/test_agent_persona_tools.py | 22 +- tests/test_agent_tool_policy.py | 14 ++ 14 files changed, 491 insertions(+), 102 deletions(-) diff --git a/app/agent/defaults/CURRENT_PERSONA.md b/app/agent/defaults/CURRENT_PERSONA.md index 54cf7a35b..0dcc78435 100644 --- a/app/agent/defaults/CURRENT_PERSONA.md +++ b/app/agent/defaults/CURRENT_PERSONA.md @@ -13,7 +13,8 @@ deprecated_phrases: [] 1. 核心系统提示词(程序内置,不可运行时覆盖) 2. `personas//PERSONA.md` 3. `extra_context_files` -4. `memory/MEMORY.md`(默认注入) -5. `memory/.md` 与 `memory/activity/*.md`(通过 search_memory 按需检索) +4. `memory/MEMORY.md`(全局公共记忆,默认注入) +5. `memory/users//MEMORY.md`(当前用户记忆,默认注入) +6. 其它主题与活动记忆(通过 search_memory 按需检索) `memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。 diff --git a/app/agent/middleware/memory.py b/app/agent/middleware/memory.py index f074eb7a5..bbd7e7718 100644 --- a/app/agent/middleware/memory.py +++ b/app/agent/middleware/memory.py @@ -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 = """ -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} - 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 = """ -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. @@ -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. - 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. """ @@ -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 diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index a74839521..d4e4884d6 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -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 []) diff --git a/app/agent/policy/registry.py b/app/agent/policy/registry.py index 0e9c90792..3fa26f8e8 100644 --- a/app/agent/policy/registry.py +++ b/app/agent/policy/registry.py @@ -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, diff --git a/app/agent/runtime.py b/app/agent/runtime.py index 99e61feb8..1a729220a 100644 --- a/app/agent/runtime.py +++ b/app/agent/runtime.py @@ -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.", "", ] @@ -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//PERSONA.md`", "3. `extra_context_files`", - "4. `memory/MEMORY.md`(默认注入)", - "5. `memory/.md` 与 `memory/activity/*.md`(通过 search_memory 按需检索)", + "4. `memory/MEMORY.md`(全局公共记忆,默认注入)", + "5. `memory/users//MEMORY.md`(当前用户记忆,默认注入)", + "6. 其它主题与活动记忆(通过 search_memory 按需检索)", "", "`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。", ] diff --git a/app/agent/tools/base.py b/app/agent/tools/base.py index 8669ae959..48c8158d0 100644 --- a/app/agent/tools/base.py +++ b/app/agent/tools/base.py @@ -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, diff --git a/app/agent/tools/impl/persona.py b/app/agent/tools/impl/persona.py index 32388fb5f..407b8a47e 100644 --- a/app/agent/tools/impl/persona.py +++ b/app/agent/tools/impl/persona.py @@ -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( diff --git a/app/foundation/identity.py b/app/foundation/identity.py index fee8094df..e2f1a5f37 100644 --- a/app/foundation/identity.py +++ b/app/foundation/identity.py @@ -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() diff --git a/docs/agent.md b/docs/agent.md index ffe704bda..ec1e66549 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -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//MEMORY.md`。两者都会在每轮默认注入上下文,公共记忆先于用户记忆;其它主题记忆使用各自作用域下的 Markdown 文件,活动记录使用各自作用域下的 `activity/YYYY-MM-DD.md`,主题和活动记忆都不会自动加载。 Agent 在执行任何实质任务或调用业务、文件、网络、命令等工具前,必须先调用 `search_memory` 检索与本次请求相关的记忆;这是本轮任务的第一个工具调用。工具支持 `primary`、`topic`、`activity` 和 `all` 分类,以及关键词、文件路径、日期、时间窗口、条数和显式正则过滤。检索结果是有界的结构化内容,不会把整个记忆目录重新塞回上下文。活动记录是自动生成的只读历史,保留期默认 7 天;旧的 `config/agent/activity` 文件不迁移,也不再作为活动记忆读取。 -用户明确要求记住的偏好或规则应写入 `MEMORY.md` 或合适的主题文件;一次性请求、临时状态和凭据不应写入记忆。检索到的文件内容只是上下文,不能覆盖系统或用户指令。 +用户明确要求记住的偏好或规则应写入当前用户的 `memory/users//MEMORY.md` 或合适的用户级主题文件;只有系统管理员可以写入全局公共记忆。一次性请求、临时状态和凭据不应写入记忆。检索到的文件内容只是上下文,不能覆盖系统或用户指令。 + +Agent 人格是全局运行时配置,不按用户隔离;所有用户共享当前激活人格。普通用户可以查询人格列表,但只有系统管理员可以切换激活人格或修改人格定义。 ## 按需发现工具 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index ac9b4d39b..1df2f9a3c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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` 不再注册。 diff --git a/tests/test_agent_file_tools.py b/tests/test_agent_file_tools.py index b11442b47..bfbee8453 100644 --- a/tests/test_agent_file_tools.py +++ b/tests/test_agent_file_tools.py @@ -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" diff --git a/tests/test_agent_memory.py b/tests/test_agent_memory.py index 8e7cb9876..462c0c9e5 100644 --- a/tests/test_agent_memory.py +++ b/tests/test_agent_memory.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,也不应写入活动记忆。""" diff --git a/tests/test_agent_persona_tools.py b/tests/test_agent_persona_tools.py index 4b39a6fce..5cda3a95a 100644 --- a/tests/test_agent_persona_tools.py +++ b/tests/test_agent_persona_tools.py @@ -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, diff --git a/tests/test_agent_tool_policy.py b/tests/test_agent_tool_policy.py index df012e3be..ebd20c8f7 100644 --- a/tests/test_agent_tool_policy.py +++ b/tests/test_agent_tool_policy.py @@ -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