Refactor agent persona runtime layering

This commit is contained in:
jxxghp
2026-04-29 14:12:47 +08:00
parent 2c7fb5786c
commit 344280cd61
26 changed files with 1529 additions and 752 deletions

View File

@@ -21,10 +21,10 @@ from langgraph.checkpoint.memory import InMemorySaver
from app.agent.callback import StreamingHandler
from app.agent.memory import memory_manager
from app.agent.middleware.activity_log import ActivityLogMiddleware
from app.agent.middleware.hooks import AgentHooksMiddleware
from app.agent.middleware.jobs import JobsMiddleware
from app.agent.middleware.memory import MemoryMiddleware
from app.agent.middleware.patch_tool_calls import PatchToolCallsMiddleware
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
from app.agent.middleware.skills import SkillsMiddleware
from app.agent.middleware.usage import UsageMiddleware
from app.agent.prompt import prompt_manager
@@ -406,9 +406,9 @@ class MoviePilotAgent:
JobsMiddleware(
sources=[str(agent_runtime_manager.jobs_dir)],
),
# 结构化 hooks
AgentHooksMiddleware(),
# 记忆管理(仅扫描 memory 目录,避免与根层 persona/workflow 配置混写)
# 运行时人格与核心规则(动态加载,支持执行中切换人格)
RuntimeConfigMiddleware(),
# 记忆管理(仅扫描 memory 目录,避免与根层核心规则或人格定义混写)
MemoryMiddleware(memory_dir=str(agent_runtime_manager.memory_dir)),
# 活动日志
ActivityLogMiddleware(
@@ -1008,9 +1008,8 @@ class AgentManager:
@staticmethod
def _build_heartbeat_prompt() -> str:
"""使用统一 wake 模板源构建心跳任务提示词。"""
runtime_config = agent_runtime_manager.load_runtime_config()
return runtime_config.render_system_task_message("heartbeat")
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
return prompt_manager.render_system_task_message("heartbeat")
@staticmethod
def _build_retry_transfer_template_context(
@@ -1034,23 +1033,22 @@ class AgentManager:
history_ids: list[int],
) -> str:
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
runtime_config = agent_runtime_manager.load_runtime_config()
task_type, template_context = AgentManager._build_retry_transfer_template_context(
history_ids
)
return runtime_config.render_system_task_message(
return prompt_manager.render_system_task_message(
task_type,
template_context=template_context,
)
@staticmethod
def _build_manual_redo_template_context(history) -> dict[str, int | str]:
"""仅负责把整理历史对象映射成 SYSTEM_TASKS 需要的模板变量。"""
"""仅负责把整理历史对象映射成 System Tasks 需要的模板变量。"""
src_fileitem = history.src_fileitem or {}
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
source_path = source_path or history.src or ""
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
# 这里故意只做数据整形,具体行为定义全部交给 SYSTEM_TASKS
# 这里故意只做数据整形,具体行为定义全部交给内置 System Tasks YAML
return {
"history_id": history.id,
"current_status": "success" if history.status else "failed",
@@ -1203,8 +1201,7 @@ class AgentManager:
"""
构建手动 AI 整理提示词。
"""
runtime_config = agent_runtime_manager.load_runtime_config()
return runtime_config.render_system_task_message(
return prompt_manager.render_system_task_message(
"manual_transfer_redo",
template_context=AgentManager._build_manual_redo_template_context(history),
)

View File

@@ -0,0 +1,19 @@
---
version: 3
active_persona: default
extra_context_files: []
deprecated_phrases: []
---
# CURRENT_PERSONA
当前激活人格:`default`
运行时加载顺序固定如下:
1. 核心系统提示词(程序内置,不可运行时覆盖)
2. `personas/<active_persona>/PERSONA.md`
3. `extra_context_files`
4. `memory/*.md`
5. `activity/*.md`
`memory` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。

View File

@@ -0,0 +1,23 @@
---
version: 1
persona_id: concise
label: 极简
description: 更短、更硬朗,优先结论和动作,不主动展开背景解释。
aliases:
- 简洁
- 干脆
- 极简人格
---
# PERSONA
- Tone: terse, decisive, and highly compressed.
- Prefer the shortest complete answer that still moves the task forward.
- Default to one sentence when possible. Only use lists when they materially improve readability.
- Avoid extra context, caveats, or teaching unless the user explicitly asks for explanation.
- Keep transitions minimal and skip conversational softening.
## RESPONSE_FORMAT
- Lead with the conclusion or result.
- For option lists, keep each item very short.
- Do not repeat already-known context back to the user unless it is needed to disambiguate the action.

View File

@@ -0,0 +1,24 @@
---
version: 1
persona_id: default
label: 默认
description: 专业、克制、简洁,适合大多数日常媒体管理场景。
aliases:
- 专业
- 默认人格
---
# PERSONA
- Tone: professional, concise, restrained.
- Be direct. No unnecessary preamble, no repeating the user's words, no narrating internal reasoning.
- Do not flatter the user, praise the question, or add emotional cushioning.
- Do not use emojis, exclamation marks, cute language, or excessive apology.
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
- Use Markdown for structured data. Use `inline code` for media titles and paths.
## RESPONSE_FORMAT
- Keep confirmations short.
- For search or comparison results, prefer a brief list over a long paragraph.
- Skip filler phrases like "Let me help you", "Here are the results", or "I found...".
- When an error occurs, briefly state the blocker and the next best action.

View File

@@ -0,0 +1,22 @@
---
version: 1
persona_id: guide
label: 说明型
description: 在复杂问题上更愿意解释原因和步骤,但仍保持克制,不会无节制展开。
aliases:
- 讲解
- 解释型
- 教学
---
# PERSONA
- Tone: clear, structured, and mildly explanatory.
- When the task is simple, stay concise. When the task is complex or the user asks why/how, provide a short explanation with visible structure.
- Keep explanations practical and tied to the current decision, not generic theory.
- Remain restrained: do not become chatty, cute, or overly warm.
## RESPONSE_FORMAT
- For non-trivial tasks, prefer short sections or a compact numbered list.
- When describing tradeoffs, keep them concrete and action-oriented.
- End with the actual outcome or next step, not a generic summary.

View File

@@ -1,68 +0,0 @@
"""结构化 Agent hooks 中间件。"""
from collections.abc import Awaitable, Callable
from typing import Annotated, NotRequired, TypedDict
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
ContextT,
ModelRequest,
ModelResponse,
PrivateStateAttr, # noqa
ResponseT,
)
from langchain_core.runnables import RunnableConfig
from langgraph.runtime import Runtime
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import agent_runtime_manager
class HooksState(AgentState):
"""hooks 中间件状态。"""
hooks_prompt: NotRequired[Annotated[str, PrivateStateAttr]]
class HooksStateUpdate(TypedDict):
"""hooks 状态更新。"""
hooks_prompt: str
class AgentHooksMiddleware(AgentMiddleware[HooksState, ContextT, ResponseT]): # noqa
"""在固定生命周期点注入结构化 pre/in/post hooks。"""
state_schema = HooksState
async def abefore_agent( # noqa
self, state: HooksState, runtime: Runtime, config: RunnableConfig
) -> HooksStateUpdate | None:
if "hooks_prompt" in state:
return None
runtime_config = agent_runtime_manager.load_runtime_config()
return HooksStateUpdate(hooks_prompt=runtime_config.render_hooks_prompt())
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: # noqa
hooks_prompt = request.state.get("hooks_prompt", "") # noqa
if not hooks_prompt:
return request
new_system_message = append_to_system_message(
request.system_message, hooks_prompt
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
return await handler(self.modify_request(request))
__all__ = ["AgentHooksMiddleware"]

View File

@@ -57,8 +57,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
**Memory file organization:**
- All `.md` files in `{memory_dir}` are automatically loaded as memory.
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
- `MEMORY.md` is the default/primary memory file for general user preferences, communication style, and durable working rules.
- You may create additional `.md` files to organize knowledge by topic (e.g., `MEDIA_RULES.md`, `COMMUNICATION_PREFERENCES.md`, `DOWNLOAD_PREFERENCES.md`, `SITE_CONFIGS.md`, etc.).
- Keep each file focused on a specific domain or topic for better organization.
- Subdirectories are NOT scanned — only `.md` files directly in `{memory_dir}`.
@@ -78,11 +78,11 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
**When to update memories:**
- When the user explicitly asks you to remember something (e.g., "remember my email", "save this preference")
- When the user describes your role or how you should behave (e.g., "you are a web researcher", "always do X")
- When the user gives durable communication or reply-format preferences (e.g., "be more concise", "prefer tables", "use JSON when summarizing")
- When the user gives feedback on your work - capture what was wrong and how to improve
- When the user provides information required for tool use (e.g., slack channel ID, email addresses)
- When the user provides context useful for future tasks, such as how to use tools, or which actions to take in a particular situation
- When you discover new patterns or preferences (coding styles, conventions, workflows)
- When you discover new user-specific patterns or preferences (communication style, formatting, workflows)
**When to NOT update memories:**
- When the information is temporary or transient (e.g., "I'm running late", "I'm on my phone right now")
@@ -90,6 +90,8 @@ You can create, edit, or organize any `.md` files in this directory to manage yo
- When the information is a simple question that doesn't reveal lasting preferences (e.g., "What day is it?", "Can you explain X?")
- When the information is an acknowledgment or small talk (e.g., "Sounds good!", "Hello", "Thanks for that")
- When the information is stale or irrelevant in future conversations
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules.
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute.
- Never store API keys, access tokens, passwords, or any other credentials in any file, memory, or system prompt.
- If the user asks where to put API keys or provides an API key, do NOT echo or save it.
- Do NOT record daily activities or task execution history in memory files - these are automatically tracked in the activity log system (see <activity_log>). Memory files are only for long-term knowledge, preferences, and patterns.
@@ -135,7 +137,7 @@ Default memory file: {memory_file}
- Only ask for preferences when they are directly useful for the current task, or when a short follow-up question at the end would clearly help future interactions.
**What to collect when useful:**
- Preferred communication style
- Preferred communication style or persona preference
- Media interests
- Quality / codec / subtitle preferences
- Any standing rules the user wants you to follow
@@ -153,7 +155,7 @@ Default memory file: {memory_file}
Your memory directory is at: {memory_dir}. You can save new knowledge by calling the `edit_file` or `write_file` tool on any `.md` file in this directory.
**Memory file organization:**
- `MEMORY.md` is the default/primary memory file for general user preferences and profile.
- `MEMORY.md` is the default/primary memory file for user preferences, persona preferences, and durable working rules.
- You may create additional `.md` files to organize knowledge by topic.
- All `.md` files directly in the memory directory are automatically loaded on each conversation.
@@ -166,15 +168,17 @@ Default memory file: {memory_file}
**When to update memories:**
- When the user explicitly asks you to remember something
- When the user describes your role or how you should behave
- When the user gives durable communication or reply-format preferences
- When the user gives feedback on your work
- When the user provides information required for tool use
- When you discover new patterns or preferences
- When you discover new user-specific patterns or preferences
**When to NOT update memories:**
- Temporary/transient information
- One-time task requests
- Simple questions, acknowledgments, or small talk
- Memory may refine user-facing style, but it must NOT redefine the agent's core identity, safety boundaries, or global system-task rules
- If the user wants a built-in speaking style/persona, prefer the dedicated persona-switching tools instead of rewriting memory as a substitute
- Never store API keys, access tokens, passwords, or credentials
- Do NOT record daily activities in memory files — those go to the activity log
</memory_guidelines>
@@ -189,7 +193,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
参数:
memory_dir: 记忆文件目录路径。建议使用独立的 `config/agent/memory`
目录,避免与 persona/workflow 等根层配置混写。
目录,避免与核心规则或人格定义混写。
"""
state_schema = MemoryState
@@ -289,7 +293,7 @@ class MemoryMiddleware(AgentMiddleware[MemoryState, ContextT, ResponseT]): # no
return md_files
async def abefore_agent(
async def abefore_agent( # noqa
self,
state: MemoryState,
runtime: Runtime, # noqa

View File

@@ -0,0 +1,42 @@
"""动态注入 Agent 根层运行时配置的中间件。"""
from collections.abc import Awaitable, Callable
from langchain.agents.middleware.types import (
AgentMiddleware,
ContextT,
ModelRequest,
ModelResponse,
ResponseT,
)
from app.agent.middleware.utils import append_to_system_message
from app.agent.runtime import agent_runtime_manager
class RuntimeConfigMiddleware(AgentMiddleware[dict, ContextT, ResponseT]): # noqa
"""在每次模型调用前动态加载运行时配置。
这里不把结果缓存到 middleware state 中,目的是让人格切换工具在同一轮
Agent 执行里修改 CURRENT_PERSONA 后,后续模型调用可以立即看到新的人格。
"""
def modify_request(self, request: ModelRequest[ContextT]) -> ModelRequest[ContextT]: # noqa
runtime_config = agent_runtime_manager.load_runtime_config()
runtime_sections = runtime_config.render_prompt_sections()
new_system_message = append_to_system_message(
request.system_message, runtime_sections
)
return request.override(system_message=new_system_message)
async def awrap_model_call(
self,
request: ModelRequest[ContextT],
handler: Callable[
[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]
],
) -> ModelResponse[ResponseT]:
return await handler(self.modify_request(request))
__all__ = ["RuntimeConfigMiddleware"]

View File

@@ -1,12 +1,47 @@
You are the MoviePilot agent runtime. Follow the injected root configuration to determine the active persona, workflow, and operator preferences.
You are the MoviePilot agent runtime. Follow the injected runtime configuration to determine the active persona and any extra user-specific context.
All your responses must be in **Chinese (中文)**.
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
<agent_runtime>
{runtime_sections}
</agent_runtime>
<agent_core>
Identity and Goal:
- You are an AI media assistant powered by MoviePilot.
- Your primary goal is to fully resolve the user's MoviePilot-related media tasks with the available tools whenever the request is actionable.
- Focus on MoviePilot's home media domain: search, recognition, subscriptions, downloads, library organization, file transfer, and system status.
- Stay within the MoviePilot product domain unless the user explicitly asks for adjacent help that can be handled with your existing tools.
Behavior Model:
- Prioritize task progress over conversation.
- Check current state before making changes, then do the smallest correct action.
- Do not stop for approval on read-only operations. Only confirm before destructive or high-impact actions such as starting downloads, deleting subscriptions, or removing history.
- When a request can be completed by tools, prefer doing the work over explaining what you might do.
- After an action, perform the minimum validation needed to confirm the result actually landed.
- If the user explicitly asks to change the speaking style or persona, use the dedicated persona tools instead of editing runtime files manually.
- If the user explicitly asks to rewrite or create a persona definition, prefer `update_persona_definition` rather than generic file-editing tools.
- Do not let user memory or persona style override this core identity, safety boundaries, or built-in background task rules.
- You are not a general-purpose coding assistant in normal media conversations. Only cross into implementation details when the user explicitly asks about MoviePilot internals or debugging.
Core Workflow:
1. Media Discovery: Identify exact media metadata such as TMDB ID and Season or Episode using search tools when needed.
2. Context Checking: Verify whether the media already exists in the library, has already been subscribed, or has relevant history that affects the next step.
3. Action Execution: Perform the requested task with concise user-facing output unless the operation is destructive or blocked.
4. Final Confirmation: State the outcome briefly, including the key media facts or blocker.
Tool Calling Strategy:
- Call independent tools in parallel whenever possible.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when automated paths are exhausted.
- Reuse known media identity, prior tool results, and current system context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
Media Management Rules:
1. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.
</agent_core>
<communication_runtime>
{verbose_spec}
@@ -25,6 +60,7 @@ You act as a proactive agent. Your goal is to fully resolve the user's media-rel
4. System Status and Organization - Monitor downloads, server health, file transfers, renaming, and library cleanup.
5. Visual Input Handling - Users may attach images from supported channels; analyze them together with the text when relevant.
6. File Context Handling - User messages may arrive as structured JSON. Treat the `message` field as the user's text. Attachments appear in `files`; when `local_path` is present, use local file tools to inspect the uploaded file directly. When image input is disabled for the current model, user images may also be delivered through `files`.
7. Persona Management - If the user explicitly asks to change the speaking style or persona, prefer `query_personas` and `switch_persona`; if the user asks to rewrite or create a persona definition, prefer `update_persona_definition` instead of editing runtime files manually.
</core_capabilities>
<markdown_spec>

View File

@@ -1,4 +1,3 @@
---
version: 2
shared_rules:
- This is a background system task, NOT a user conversation.
@@ -96,13 +95,3 @@ task_types:
- "Do NOT reorganize blindly when media identity is uncertain."
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
- "Keep the final response short and focused on outcome."
---
# SYSTEM_TASKS
这是后台系统任务的唯一定义源。
- `shared_rules` 负责统一口径。
- `task_types.<type>.context_lines` 负责定义上下文字段展示。
- `task_types.<type>.steps` 负责定义任务执行步骤。
- `task_types.<type>.task_rules` 负责定义该任务独有的补充约束。
- 代码侧只负责触发任务并提供模板变量,不再保存具体行为提示词。

View File

@@ -1,13 +1,16 @@
"""提示词管理器"""
import socket
from dataclasses import dataclass, field
from pathlib import Path
from string import Formatter
from time import strftime
from typing import Dict
from typing import Any, Dict, Optional
import yaml
from app.core.config import settings
from app.log import logger
from app.agent.runtime import agent_runtime_manager
from app.schemas import (
ChannelCapability,
ChannelCapabilities,
@@ -16,6 +19,37 @@ from app.schemas import (
)
from app.utils.system import SystemUtils
SYSTEM_TASKS_FILE = "System Tasks.yaml"
SYSTEM_TASKS_SCHEMA_VERSION = 2
class PromptConfigError(ValueError):
"""程序内置提示词定义加载异常。"""
@dataclass
class SystemTaskTypeDefinition:
"""单个后台系统任务定义。"""
header: str
objective: str
context_title: Optional[str] = None
context_lines: list[str] = field(default_factory=list)
steps_title: Optional[str] = None
steps: list[str] = field(default_factory=list)
task_rules: list[str] = field(default_factory=list)
empty_result: Optional[str] = None
@dataclass
class SystemTasksDefinition:
"""程序内置后台系统任务定义。"""
path: Path
version: int
shared_rules: list[str]
task_types: dict[str, SystemTaskTypeDefinition]
class PromptManager:
"""
@@ -28,6 +62,8 @@ class PromptManager:
else:
self.prompts_dir = Path(prompts_dir)
self.prompts_cache: Dict[str, str] = {}
self._system_tasks_cache: Optional[SystemTasksDefinition] = None
self._system_tasks_signature: Optional[tuple[int, int]] = None
def load_prompt(self, prompt_name: str) -> str:
"""
@@ -60,11 +96,9 @@ class PromptManager:
:param prefer_voice_reply: 是否优先使用语音回复
:return: 提示词内容
"""
# 根层运行时配置由独立装配器负责,避免人格/工作流继续硬编码在单文件 prompt 中。
runtime_config = agent_runtime_manager.load_runtime_config()
runtime_sections = runtime_config.render_prompt_sections()
# 基础提示词只保留 MoviePilot 运行时和渠道能力相关约束。
# 根层运行时配置由 RuntimeConfigMiddleware 在每次模型调用前动态注入,
# 这样人格切换可以在同一轮 Agent 执行里立即生效。
base_prompt = self.load_prompt("System Core Prompt.txt")
# 识别渠道
@@ -109,11 +143,119 @@ class PromptManager:
moviepilot_info=moviepilot_info,
voice_reply_spec=voice_reply_spec,
button_choice_spec=button_choice_spec,
runtime_sections=runtime_sections,
)
return base_prompt
def load_system_tasks_definition(self) -> SystemTasksDefinition:
"""加载程序内置的后台系统任务定义。"""
system_tasks_path = self.prompts_dir / SYSTEM_TASKS_FILE
try:
stat = system_tasks_path.stat()
except FileNotFoundError as err:
logger.error(f"系统任务定义文件不存在: {system_tasks_path}")
raise PromptConfigError(f"系统任务定义文件不存在: {system_tasks_path}") from err
signature = (stat.st_mtime_ns, stat.st_size)
if (
self._system_tasks_signature == signature
and self._system_tasks_cache is not None
):
return self._system_tasks_cache
try:
content = system_tasks_path.read_text(encoding="utf-8")
except Exception as err: # noqa: BLE001
logger.error(f"读取系统任务定义失败: {system_tasks_path}, 错误: {err}")
raise PromptConfigError(
f"读取系统任务定义失败 {system_tasks_path}: {err}"
) from err
try:
data = yaml.safe_load(content) or {}
except yaml.YAMLError as err:
raise PromptConfigError(f"YAML 解析失败 {system_tasks_path}: {err}") from err
if not isinstance(data, dict):
raise PromptConfigError(
f"YAML 根节点必须是映射类型: {system_tasks_path}"
)
definition = self._parse_system_tasks_definition(system_tasks_path, data)
self._system_tasks_signature = signature
self._system_tasks_cache = definition
return definition
def render_system_task_message(
self,
task_type: str,
*,
template_context: Optional[dict[str, Any]] = None,
extra_rules: Optional[list[str]] = None,
) -> str:
"""根据程序内置 YAML 渲染后台系统任务提示词。"""
system_tasks = self.load_system_tasks_definition()
task_definition = system_tasks.task_types.get(task_type)
if not task_definition:
raise PromptConfigError(f"未定义的后台系统任务类型: {task_type}")
rendered_context = self._render_template_lines(
task_definition.context_lines,
template_context,
task_type,
"context_lines",
)
rendered_steps = self._render_template_lines(
task_definition.steps,
template_context,
task_type,
"steps",
)
rendered_task_rules = self._render_template_lines(
task_definition.task_rules,
template_context,
task_type,
"task_rules",
)
sections = [
self._render_template_text(
task_definition.header,
template_context,
task_type,
"header",
).strip(),
self._render_template_text(
task_definition.objective,
template_context,
task_type,
"objective",
).strip(),
]
if rendered_context:
sections.append(
self._format_titled_lines(
task_definition.context_title or "Task context",
rendered_context,
)
)
if rendered_steps:
sections.append(
self._format_titled_lines(
task_definition.steps_title or "Follow these steps",
rendered_steps,
)
)
rules = list(system_tasks.shared_rules)
if task_definition.empty_result:
rules.append(task_definition.empty_result)
rules.extend(rendered_task_rules)
if extra_rules:
rules.extend(rule.strip() for rule in extra_rules if rule and rule.strip())
if rules:
sections.append(self._format_numbered_rules("IMPORTANT", rules))
return "\n\n".join(section for section in sections if section).strip()
@staticmethod
def _get_moviepilot_info() -> str:
"""
@@ -214,11 +356,172 @@ class PromptManager:
)
return "- User questions: When you truly need user input, ask briefly in plain text."
def _parse_system_tasks_definition(
self,
path: Path,
data: dict[str, Any],
) -> SystemTasksDefinition:
"""把 YAML 结构转换成系统任务定义对象。"""
version = self._normalize_positive_int(data.get("version"), "version", default=1)
if version < SYSTEM_TASKS_SCHEMA_VERSION:
raise PromptConfigError(
f"{path} 的 version={version} 过旧,"
f"当前要求 System Tasks schema v{SYSTEM_TASKS_SCHEMA_VERSION} 或更高版本"
)
shared_rules = self._normalize_string_list(data.get("shared_rules"), "shared_rules")
if not shared_rules:
raise PromptConfigError(f"{path} 缺少 shared_rules")
raw_task_types = data.get("task_types")
if not isinstance(raw_task_types, dict) or not raw_task_types:
raise PromptConfigError(f"{path} 缺少 task_types 映射")
task_types: dict[str, SystemTaskTypeDefinition] = {}
for key, raw in raw_task_types.items():
if not isinstance(raw, dict):
raise PromptConfigError(f"task_types.{key} 必须是映射")
header = str(raw.get("header") or "").strip()
objective = str(raw.get("objective") or "").strip()
if not header or not objective:
raise PromptConfigError(f"task_types.{key} 缺少 header 或 objective")
task_types[str(key)] = SystemTaskTypeDefinition(
header=header,
objective=objective,
context_title=str(raw.get("context_title") or "").strip() or None,
context_lines=self._normalize_string_list(
raw.get("context_lines"),
f"task_types.{key}.context_lines",
),
steps_title=str(raw.get("steps_title") or "").strip() or None,
steps=self._normalize_string_list(
raw.get("steps"),
f"task_types.{key}.steps",
),
task_rules=self._normalize_string_list(
raw.get("task_rules"),
f"task_types.{key}.task_rules",
),
empty_result=str(raw.get("empty_result") or "").strip() or None,
)
return SystemTasksDefinition(
path=path,
version=version,
shared_rules=shared_rules,
task_types=task_types,
)
@classmethod
def _render_template_text(
cls,
text: str,
template_context: Optional[dict[str, Any]],
task_type: str,
field_name: str,
) -> str:
if not text:
return ""
formatter = Formatter()
required_fields = {
placeholder_name
for _, placeholder_name, _, _ in formatter.parse(text)
if placeholder_name
}
if not required_fields:
return text
context = cls._normalize_template_context(template_context)
missing_fields = sorted(field for field in required_fields if field not in context)
if missing_fields:
raise PromptConfigError(
f"系统任务定义 `{task_type}` 的 `{field_name}` 缺少变量: "
+ ", ".join(f"`{field}`" for field in missing_fields)
)
# 这里统一做字符串替换,让 YAML 成为后台任务文案的唯一行为来源。
return text.format_map(context)
@classmethod
def _render_template_lines(
cls,
items: list[str],
template_context: Optional[dict[str, Any]],
task_type: str,
field_name: str,
) -> list[str]:
return [
cls._render_template_text(
item,
template_context,
task_type,
f"{field_name}[{index}]",
).rstrip()
for index, item in enumerate(items, start=1)
if item and item.rstrip()
]
@staticmethod
def _normalize_template_context(
template_context: Optional[dict[str, Any]],
) -> dict[str, str]:
if not template_context:
return {}
return {
str(key): "" if value is None else str(value)
for key, value in template_context.items()
}
@staticmethod
def _format_numbered_rules(title: str, items: list[str]) -> str:
return "\n".join(
[f"{title}:"] + [f"{index}. {item}" for index, item in enumerate(items, start=1)]
)
@staticmethod
def _format_titled_lines(title: str, items: list[str]) -> str:
cleaned = [item.rstrip() for item in items if item and item.rstrip()]
return "\n".join([f"{title}:"] + cleaned)
@staticmethod
def _normalize_positive_int(
value: Any,
field_name: str,
*,
default: int,
) -> int:
if value in (None, ""):
return default
try:
normalized = int(value)
except (TypeError, ValueError) as err:
raise PromptConfigError(f"{field_name} 必须是正整数") from err
if normalized <= 0:
raise PromptConfigError(f"{field_name} 必须是正整数")
return normalized
@staticmethod
def _normalize_string_list(values: Any, field_name: str) -> list[str]:
if values is None:
return []
if not isinstance(values, list):
raise PromptConfigError(f"{field_name} 必须是字符串数组")
normalized: list[str] = []
for value in values:
text = str(value).strip()
if text:
normalized.append(text)
return normalized
def clear_cache(self):
"""
清空缓存
"""
self.prompts_cache.clear()
self._system_tasks_cache = None
self._system_tasks_signature = None
logger.info("提示词缓存已清空")

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
---
version: 1
active_persona: default
profile: personas/default/AGENT_PROFILE.md
workflow: personas/default/AGENT_WORKFLOW.md
hooks: personas/default/AGENT_HOOKS.md
user_preferences: USER_PREFERENCES.md
system_tasks: system_tasks/SYSTEM_TASKS.md
extra_context_files: []
deprecated_phrases: []
---
# CURRENT_PERSONA
当前激活人格:`default`
加载顺序固定如下:
1. `AGENT_PROFILE.md`
2. `AGENT_WORKFLOW.md`
3. `AGENT_HOOKS.md`
4. `USER_PREFERENCES.md`
5. `SYSTEM_TASKS.md`
如果需要扩展额外上下文,请使用 `extra_context_files` 显式声明,而不是把额外规则散落到 memory 中。

View File

@@ -1,10 +0,0 @@
---
version: 1
---
# USER_PREFERENCES
这是根层的运维偏好文件,不是用户长期记忆。
- 这里只放稳定的系统级输出规则或部署方偏好。
- 用户在对话中形成的长期习惯,仍应写入 `config/agent/memory/*.md`
- 默认保持精简,避免与 `AGENT_PROFILE.md``AGENT_WORKFLOW.md` 重复。

View File

@@ -1,26 +0,0 @@
---
version: 1
pre_task:
- Identify whether the request is a normal user conversation or a background system task before choosing a workflow.
- Classify intent before acting, then prefer an existing skill or dedicated workflow over ad-hoc prompting.
- Check read-only context first so the final action is based on current library, subscription, or history state.
- Only stop for confirmation when the next action is destructive, high-impact, or user-facing.
- Keep the final delivery target explicit before calling tools.
in_task:
- Execute in small, outcome-oriented steps and prefer tool calls over long explanations when the task is actionable.
- Reuse known media identity, prior tool results, and shared context instead of repeating expensive recognition or search calls.
- When a tool fails, try one narrower fallback path before escalating to the user.
- Keep intermediate user-facing output minimal; when verbose mode is disabled, stay silent until the final result.
- Treat progress reporting as task-specific glue, not a shared abstraction to leak into every tool.
post_task:
- Perform the minimum validation needed to confirm the result actually landed.
- Summarize only the outcome, key media facts, and the remaining blocker if something still failed.
- If the task established a reusable workflow, prefer encoding it in skills or root config instead of relying on prompt residue.
---
# AGENT_HOOKS
这些 hooks 由运行时结构化加载,不依赖自由文本约定。
- `pre_task` 对应开始执行前的统一检查点。
- `in_task` 对应工具调用和失败降级阶段。
- `post_task` 对应最小验证与收口阶段。

View File

@@ -1,27 +0,0 @@
---
version: 1
---
# AGENT_PROFILE
- Identity: You are an AI media assistant powered by MoviePilot. You specialize in managing home media ecosystems: searching for movies and TV shows, managing subscriptions, overseeing downloads, and organizing media libraries.
- Tone: professional, concise, restrained.
- Be direct. NO unnecessary preamble, NO repeating user's words, NO explaining your thinking.
- Prioritize task progress over conversation. Answer only what is necessary to move the task forward.
- Do NOT flatter the user, praise the question, or use overly eager service phrases.
- Do NOT use emojis, exclamation marks, cute language, or excessive apology.
- Prefer short declarative sentences. Default to one or two short paragraphs; use lists only when they improve scanability.
- Use Markdown for structured data. Use `inline code` for media titles and paths.
- Include key details such as year, rating, and resolution, but do NOT over-explain.
- Do not stop for approval on read-only operations. Only confirm before critical actions such as starting downloads or deleting subscriptions.
- NOT a coding assistant. Do not offer code snippets.
- If user has set preferred communication style in memory, follow that strictly.
# RESPONSE_FORMAT
- Responses MUST be short and punchy: one sentence for confirmations, brief list for search results.
- NO filler phrases like "Let me help you", "Here are the results", "I found..." - skip all unnecessary preamble.
- NO repeating what user said.
- NO narrating your internal reasoning.
- NO praise, emotional cushioning, or unnecessary politeness padding.
- After task completion: one line summary only.
- When error occurs: brief acknowledgment plus suggestion, then move on.

View File

@@ -1,25 +0,0 @@
---
version: 1
---
# AGENT_WORKFLOW
## FLOW
1. Media Discovery: Identify exact media metadata such as TMDB ID and Season or Episode using search tools.
2. Context Checking: Verify current status such as whether the media is already in the library or already subscribed.
3. Action Execution: Perform the task with a brief status update only if the operation takes time.
4. Final Confirmation: State the result concisely.
## TOOL_CALLING_STRATEGY
- Call independent tools in parallel whenever possible.
- If search results are ambiguous, use `query_media_detail` or `recognize_media` to clarify before proceeding.
- If `search_media` fails, fall back to `search_web` or `recognize_media`. Only ask the user when all automated methods are exhausted.
## MEDIA_MANAGEMENT_RULES
1. Download Safety: Present found torrents with size, seeds, and quality, then get explicit consent before downloading.
2. Subscription Logic: Check for the best matching quality profile based on user history or defaults.
3. Library Awareness: Check if content already exists in the library to avoid duplicates.
4. Error Handling: If a tool or site fails, briefly explain what went wrong and suggest an alternative.
5. TV Subscription Rule: When calling `add_subscribe` for a TV show, omitting `season` means subscribe to season 1 only. To subscribe multiple seasons or the full series, call `add_subscribe` separately for each season.

View File

@@ -37,6 +37,9 @@ from app.agent.tools.impl.query_schedulers import QuerySchedulersTool
from app.agent.tools.impl.run_scheduler import RunSchedulerTool
from app.agent.tools.impl.query_workflows import QueryWorkflowsTool
from app.agent.tools.impl.run_workflow import RunWorkflowTool
from app.agent.tools.impl.query_personas import QueryPersonasTool
from app.agent.tools.impl.switch_persona import SwitchPersonaTool
from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
from app.agent.tools.impl.update_site_cookie import UpdateSiteCookieTool
from app.agent.tools.impl.delete_download import DeleteDownloadTool
from app.agent.tools.impl.delete_download_history import DeleteDownloadHistoryTool
@@ -146,6 +149,9 @@ class MoviePilotToolFactory:
RunSchedulerTool,
QueryWorkflowsTool,
RunWorkflowTool,
QueryPersonasTool,
SwitchPersonaTool,
UpdatePersonaDefinitionTool,
ExecuteCommandTool,
EditFileTool,
WriteFileTool,

View File

@@ -0,0 +1,75 @@
"""查询可用人格工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.log import logger
class QueryPersonasInput(BaseModel):
"""查询人格工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
query: Optional[str] = Field(
None,
description=(
"Optional search keyword for persona_id, label, description, or aliases. "
"Use this when the user asks for a certain speaking style but the exact persona name is unknown."
),
)
class QueryPersonasTool(MoviePilotTool):
name: str = "query_personas"
description: str = (
"List all available personas (人格) and show which one is currently active. "
"Use this before switching persona when the user asks for a different speaking style but does not name "
"an exact persona_id. The result includes persona_id, label, description, aliases, and whether it is active."
)
args_schema: Type[BaseModel] = QueryPersonasInput
def get_tool_message(self, **kwargs) -> Optional[str]:
query = kwargs.get("query")
if query:
return f"查询人格列表: {query}"
return "查询人格列表"
async def run(self, query: Optional[str] = None, **kwargs) -> str:
logger.info("执行工具: %s, 参数: query=%s", self.name, query)
try:
runtime_config = agent_runtime_manager.load_runtime_config()
personas = runtime_config.list_personas()
if query:
normalized = query.strip().casefold()
personas = [
persona
for persona in personas
if normalized in persona["persona_id"].casefold()
or normalized in persona["label"].casefold()
or normalized in persona["description"].casefold()
or any(normalized in alias.casefold() for alias in persona["aliases"])
]
payload = {
"active_persona": runtime_config.active_persona,
"count": len(personas),
"personas": personas,
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("查询人格列表失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"查询人格列表时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -0,0 +1,62 @@
"""切换当前激活人格工具。"""
import json
from typing import Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.log import logger
class SwitchPersonaInput(BaseModel):
"""切换人格工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
persona_id: str = Field(
...,
description=(
"The target persona to activate. This can be the exact persona_id, label, or one of the persona aliases. "
"If the exact persona is unclear, call query_personas first."
),
)
class SwitchPersonaTool(MoviePilotTool):
name: str = "switch_persona"
description: str = (
"Switch the active persona (人格) used by the agent runtime. "
"This change is persistent for future turns. "
"Use this when the user explicitly asks to change the speaking style, tone, or response persona. "
"If the user asks for a vague style and you are not sure which persona matches best, call query_personas first."
)
args_schema: Type[BaseModel] = SwitchPersonaInput
def get_tool_message(self, **kwargs) -> str:
persona_id = kwargs.get("persona_id") or "未知人格"
return f"切换人格: {persona_id}"
async def run(self, persona_id: str, **kwargs) -> str:
logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
try:
runtime_config = agent_runtime_manager.set_active_persona(persona_id)
payload = {
"success": True,
"active_persona": runtime_config.active_persona,
"persona": runtime_config.persona.to_dict(is_active=True),
"message": f"已切换为人格 `{runtime_config.active_persona}`",
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("切换人格失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"切换人格时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -0,0 +1,131 @@
"""更新人格定义工具。"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.runtime import agent_runtime_manager
from app.agent.tools.base import MoviePilotTool
from app.log import logger
class UpdatePersonaDefinitionInput(BaseModel):
"""更新人格定义工具的输入参数模型。"""
explanation: str = Field(
...,
description="Clear explanation of why this tool is being used in the current context",
)
persona_id: str = Field(
...,
description=(
"Target persona to update. For existing personas this can be persona_id, label, or alias. "
"For new personas, provide the new lowercase persona_id."
),
)
label: Optional[str] = Field(
None,
description="Optional new label shown to users, such as 默认 or 说明型.",
)
description: Optional[str] = Field(
None,
description="Optional short description of the persona's intended style.",
)
aliases: Optional[list[str]] = Field(
None,
description="Optional full replacement list of aliases for this persona.",
)
instructions: Optional[str] = Field(
None,
description=(
"Optional full replacement body for PERSONA.md, excluding YAML frontmatter. "
"Use this when the persona definition should be rewritten completely."
),
)
append_instructions: Optional[list[str]] = Field(
None,
description=(
"Optional extra persona rules to append to the existing PERSONA body. "
"Use this for small adjustments such as '回答更短' or '复杂问题给两步解释'."
),
)
create_if_missing: bool = Field(
False,
description="Whether to create a new runtime persona if the target persona does not already exist.",
)
class UpdatePersonaDefinitionTool(MoviePilotTool):
name: str = "update_persona_definition"
description: str = (
"Create or update a runtime persona definition (人格定义) without manually editing PERSONA.md files. "
"Use this when the user explicitly asks to modify how a persona is defined, such as changing tone rules, "
"rewriting the persona body, adjusting aliases, or creating a new persona."
)
args_schema: Type[BaseModel] = UpdatePersonaDefinitionInput
require_admin: bool = True
def get_tool_message(self, **kwargs) -> str:
persona_id = kwargs.get("persona_id") or "未知人格"
action = "创建/更新人格定义"
return f"{action}: {persona_id}"
async def run(
self,
persona_id: str,
label: Optional[str] = None,
description: Optional[str] = None,
aliases: Optional[list[str]] = None,
instructions: Optional[str] = None,
append_instructions: Optional[list[str]] = None,
create_if_missing: bool = False,
**kwargs,
) -> str:
logger.info("执行工具: %s, 参数: persona_id=%s", self.name, persona_id)
if not any(
value is not None
for value in (label, description, aliases, instructions, append_instructions)
):
return json.dumps(
{
"success": False,
"message": "未提供任何要更新的人格定义字段。",
},
ensure_ascii=False,
)
try:
persona, created = agent_runtime_manager.update_persona_definition(
persona_id,
label=label,
description=description,
aliases=aliases,
instructions=instructions,
append_instructions=append_instructions,
create_if_missing=create_if_missing,
)
runtime_config = agent_runtime_manager.load_runtime_config()
payload = {
"success": True,
"created": created,
"active_persona": runtime_config.active_persona,
"persona": persona.to_dict(
is_active=persona.persona_id == runtime_config.active_persona
),
"message": (
f"已创建人格 `{persona.persona_id}`"
if created
else f"已更新人格 `{persona.persona_id}` 的定义"
),
}
return json.dumps(payload, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
logger.error("更新人格定义失败: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"message": f"更新人格定义时发生错误: {str(e)}",
},
ensure_ascii=False,
)

View File

@@ -82,7 +82,7 @@ class TestAgentInteraction(unittest.TestCase):
self.assertTrue(tool._agent_context.get("user_reply_sent"))
notification = async_post_message.await_args.args[0]
self.assertEqual(notification.text, "请选择要执行的操作")
self.assertEqual(len(notification.buttons[0]), 2)
self.assertEqual(sum(len(row) for row in notification.buttons), 2)
callback_data = notification.buttons[0][0]["callback_data"]
_, _, request_id, option_index = callback_data.split(":")

View File

@@ -0,0 +1,121 @@
import asyncio
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.agent.runtime import AgentRuntimeManager
from app.agent.tools.impl.query_personas import QueryPersonasTool
from app.agent.tools.impl.switch_persona import SwitchPersonaTool
from app.agent.tools.impl.update_persona_definition import UpdatePersonaDefinitionTool
class TestAgentPersonaTools(unittest.TestCase):
def setUp(self):
self._tempdir = tempfile.TemporaryDirectory()
self.addCleanup(self._tempdir.cleanup)
self.temp_root = Path(self._tempdir.name)
self.agent_root = self.temp_root / "agent"
defaults_root = (
Path(__file__).resolve().parents[1] / "app" / "agent" / "defaults"
)
self.runtime_manager = AgentRuntimeManager(
agent_root_dir=self.agent_root,
bundled_defaults_dir=defaults_root,
)
self.runtime_manager.ensure_layout()
def test_query_personas_returns_available_personas_and_active_state(self):
tool = QueryPersonasTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.query_personas.agent_runtime_manager",
self.runtime_manager,
):
result = asyncio.run(tool.run())
payload = json.loads(result)
self.assertEqual(payload["active_persona"], "default")
self.assertGreaterEqual(payload["count"], 3)
self.assertTrue(any(persona["persona_id"] == "concise" for persona in payload["personas"]))
self.assertTrue(any(persona["is_active"] for persona in payload["personas"]))
def test_switch_persona_updates_runtime_by_alias(self):
tool = SwitchPersonaTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.switch_persona.agent_runtime_manager",
self.runtime_manager,
):
result = asyncio.run(tool.run(persona_id="讲解"))
payload = json.loads(result)
self.assertTrue(payload["success"])
self.assertEqual(payload["active_persona"], "guide")
self.assertEqual(self.runtime_manager.load_runtime_config().active_persona, "guide")
def test_update_persona_definition_updates_existing_persona(self):
tool = UpdatePersonaDefinitionTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.update_persona_definition.agent_runtime_manager",
self.runtime_manager,
):
result = asyncio.run(
tool.run(
persona_id="default",
description="更偏执行导向的默认人格。",
append_instructions=["Prefer action-first responses."],
)
)
payload = json.loads(result)
self.assertTrue(payload["success"])
self.assertFalse(payload["created"])
runtime_config = self.runtime_manager.load_runtime_config()
default_persona = next(
persona
for persona in runtime_config.available_personas
if persona.persona_id == "default"
)
self.assertEqual(default_persona.description, "更偏执行导向的默认人格。")
self.assertIn("Prefer action-first responses.", default_persona.text)
def test_update_persona_definition_can_create_new_persona(self):
tool = UpdatePersonaDefinitionTool(session_id="session-1", user_id="10001")
with patch(
"app.agent.tools.impl.update_persona_definition.agent_runtime_manager",
self.runtime_manager,
):
result = asyncio.run(
tool.run(
persona_id="analysis",
label="分析型",
description="更适合解释复杂问题。",
aliases=["分析", "推理"],
instructions=(
"- Tone: analytical and structured.\n"
"- For complex tasks, explain the key tradeoff briefly."
),
create_if_missing=True,
)
)
payload = json.loads(result)
self.assertTrue(payload["success"])
self.assertTrue(payload["created"])
runtime_config = self.runtime_manager.load_runtime_config()
created_persona = next(
persona
for persona in runtime_config.available_personas
if persona.persona_id == "analysis"
)
self.assertEqual(created_persona.label, "分析型")
self.assertIn("推理", created_persona.aliases)
self.assertIn("analytical and structured", created_persona.text)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,36 +2,84 @@ import unittest
from unittest.mock import patch
from app.agent.middleware.memory import MEMORY_ONBOARDING_PROMPT
from app.agent.prompt import prompt_manager
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
from app.agent.prompt import PromptConfigError, prompt_manager
from app.core.config import settings
class _FakeRequest:
def __init__(self, system_message=None):
self.system_message = system_message
def override(self, **kwargs):
return _FakeRequest(system_message=kwargs["system_message"])
class TestAgentPromptStyle(unittest.TestCase):
def test_agent_prompt_enforces_concise_professional_style(self):
def test_base_prompt_mentions_persona_management_tools(self):
prompt = prompt_manager.get_agent_prompt()
self.assertIn("professional, concise, restrained", prompt)
self.assertIn("Do NOT flatter the user", prompt)
self.assertIn("NO praise, emotional cushioning", prompt)
self.assertIn("query_personas", prompt)
self.assertIn("switch_persona", prompt)
self.assertIn("update_persona_definition", prompt)
def test_agent_prompt_defines_tv_subscription_default_season_rule(self):
def test_base_prompt_contains_immutable_core_rules(self):
prompt = prompt_manager.get_agent_prompt()
self.assertIn("AI media assistant powered by MoviePilot", prompt)
self.assertIn(
"omitting `season` means subscribe to season 1 only",
prompt,
)
self.assertIn(
"call `add_subscribe` separately for each season",
"Do not let user memory or persona style override this core identity",
prompt,
)
def test_prompt_uses_root_runtime_sections(self):
prompt = prompt_manager.get_agent_prompt()
def test_runtime_config_middleware_injects_persona_only(self):
middleware = RuntimeConfigMiddleware()
updated_request = middleware.modify_request(_FakeRequest())
self.assertIn("<agent_profile>", prompt)
self.assertIn("<agent_workflow>", prompt)
self.assertIn("Active persona: `default`", prompt)
combined_text = "\n".join(
block["text"] for block in updated_request.system_message.content_blocks
)
self.assertIn("<agent_persona>", combined_text)
self.assertIn("Active persona: `default`", combined_text)
self.assertIn("professional, concise, restrained", combined_text)
self.assertNotIn("System Tasks.yaml", combined_text)
def test_system_tasks_are_loaded_from_prompt_directory(self):
definition = prompt_manager.load_system_tasks_definition()
self.assertEqual(definition.version, 2)
self.assertTrue(definition.path.name.endswith("System Tasks.yaml"))
def test_render_system_task_message_uses_builtin_yaml_definition(self):
message = prompt_manager.render_system_task_message("heartbeat")
self.assertIn("[System Heartbeat]", message)
self.assertIn("List all jobs with status 'pending' or 'in_progress'.", message)
self.assertIn("Do NOT include greetings, explanations, or conversational text.", message)
self.assertIn("If no jobs were executed, output nothing.", message)
def test_render_system_task_message_renders_template_context(self):
message = prompt_manager.render_system_task_message(
"transfer_failed_retry",
template_context={
"history_ids_csv": "7",
"history_count": 1,
"history_id": 7,
},
)
self.assertIn("Failed transfer history record IDs: 7", message)
self.assertIn("Total failed records: 1", message)
self.assertIn("history_id=7", message)
def test_missing_system_task_template_context_raises_clear_error(self):
with self.assertRaises(PromptConfigError):
prompt_manager.render_system_task_message("transfer_failed_retry")
def test_non_verbose_prompt_requires_silence_until_all_tools_finish(self):
with patch.object(settings, "AI_AGENT_VERBOSE", False):

View File

@@ -4,7 +4,7 @@ import textwrap
import unittest
from pathlib import Path
from app.agent.runtime import AgentRuntimeConfigError, AgentRuntimeManager
from app.agent.runtime import AgentRuntimeManager
class TestAgentRuntimeConfig(unittest.TestCase):
@@ -13,14 +13,14 @@ class TestAgentRuntimeConfig(unittest.TestCase):
self.addCleanup(self._tempdir.cleanup)
self.temp_root = Path(self._tempdir.name)
self.agent_root = self.temp_root / "agent"
self.bundled_root = (
Path(__file__).resolve().parents[1] / "app" / "agent" / "runtime_defaults"
self.defaults_root = (
Path(__file__).resolve().parents[1] / "app" / "agent" / "defaults"
)
def _manager(self) -> AgentRuntimeManager:
return AgentRuntimeManager(
agent_root_dir=self.agent_root,
bundled_runtime_dir=self.bundled_root,
bundled_defaults_dir=self.defaults_root,
)
def test_load_runtime_config_syncs_defaults_and_parses_sections(self):
@@ -29,12 +29,22 @@ class TestAgentRuntimeConfig(unittest.TestCase):
runtime_config = manager.load_runtime_config()
self.assertEqual(runtime_config.active_persona, "default")
self.assertIn("professional, concise, restrained", runtime_config.profile_text)
self.assertIn("professional, concise, restrained", runtime_config.persona.text)
self.assertEqual(runtime_config.persona.persona_id, "default")
self.assertIn(
"omitting `season` means subscribe to season 1 only",
runtime_config.workflow_text,
"concise",
[persona.persona_id for persona in runtime_config.available_personas],
)
self.assertTrue((self.agent_root / "runtime" / "CURRENT_PERSONA.md").exists())
self.assertTrue(
(
self.agent_root
/ "runtime"
/ "personas"
/ "default"
/ "PERSONA.md"
).exists()
)
def test_legacy_root_markdown_is_migrated_to_memory_directory(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
@@ -45,12 +55,10 @@ class TestAgentRuntimeConfig(unittest.TestCase):
textwrap.dedent(
"""\
---
version: 3
active_persona: default
profile: personas/default/AGENT_PROFILE.md
workflow: personas/default/AGENT_WORKFLOW.md
hooks: personas/default/AGENT_HOOKS.md
system_tasks: system_tasks/SYSTEM_TASKS.md
user_preferences: USER_PREFERENCES.md
extra_context_files: []
deprecated_phrases: []
---
"""
),
@@ -65,40 +73,54 @@ class TestAgentRuntimeConfig(unittest.TestCase):
self.assertFalse(legacy_persona.exists())
self.assertTrue((self.agent_root / "runtime" / "CURRENT_PERSONA.md").exists())
def test_render_system_task_message_uses_unified_system_tasks_definition(self):
manager = self._manager()
runtime_config = manager.load_runtime_config()
def test_obsolete_runtime_files_are_deleted_instead_of_migrated(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
obsolete_root = self.agent_root / "USER_PREFERENCES.md"
obsolete_root.write_text("# Obsolete\n", encoding="utf-8")
message = runtime_config.render_system_task_message("heartbeat")
obsolete_runtime = self.agent_root / "runtime" / "system_tasks" / "SYSTEM_TASKS.md"
obsolete_runtime.parent.mkdir(parents=True, exist_ok=True)
obsolete_runtime.write_text("# Obsolete Tasks\n", encoding="utf-8")
self.assertIn("[System Heartbeat]", message)
self.assertIn("List all jobs with status 'pending' or 'in_progress'.", message)
self.assertIn("Do NOT include greetings, explanations, or conversational text.", message)
self.assertIn("If no jobs were executed, output nothing.", message)
def test_render_system_task_message_renders_template_context(self):
manager = self._manager()
runtime_config = manager.load_runtime_config()
message = runtime_config.render_system_task_message(
"transfer_failed_retry",
template_context={
"history_ids_csv": "7",
"history_count": 1,
"history_id": 7,
},
obsolete_persona = (
self.agent_root
/ "runtime"
/ "personas"
/ "default"
/ "AGENT_PROFILE.md"
)
obsolete_persona.parent.mkdir(parents=True, exist_ok=True)
obsolete_persona.write_text("# Obsolete Persona\n", encoding="utf-8")
self.assertIn("Failed transfer history record IDs: 7", message)
self.assertIn("Total failed records: 1", message)
self.assertIn("history_id=7", message)
manager = self._manager()
manager.ensure_layout()
def test_missing_template_context_raises_clear_error(self):
self.assertFalse(obsolete_root.exists())
self.assertFalse(obsolete_runtime.exists())
self.assertFalse(obsolete_persona.exists())
self.assertFalse((self.agent_root / "memory" / "USER_PREFERENCES.md").exists())
def test_render_prompt_sections_uses_active_persona(self):
manager = self._manager()
runtime_config = manager.load_runtime_config()
with self.assertRaises(AgentRuntimeConfigError):
runtime_config.render_system_task_message("transfer_failed_retry")
sections = runtime_config.render_prompt_sections()
self.assertIn("<agent_persona>", sections)
self.assertIn("Active persona: `default`", sections)
self.assertIn("`guide`", sections)
def test_set_active_persona_supports_id_and_alias(self):
manager = self._manager()
manager.load_runtime_config()
guide_config = manager.set_active_persona("guide")
self.assertEqual(guide_config.active_persona, "guide")
self.assertEqual(guide_config.persona.label, "说明型")
concise_config = manager.set_active_persona("简洁")
self.assertEqual(concise_config.active_persona, "concise")
self.assertIn("active_persona: concise", concise_config.current_persona_path.read_text(encoding="utf-8"))
def test_invalid_user_runtime_config_falls_back_to_bundled_defaults(self):
manager = self._manager()
@@ -108,10 +130,10 @@ class TestAgentRuntimeConfig(unittest.TestCase):
textwrap.dedent(
"""\
---
version: 3
active_persona: broken
profile: personas/default/AGENT_PROFILE.md
hooks: personas/default/AGENT_HOOKS.md
system_tasks: system_tasks/SYSTEM_TASKS.md
extra_context_files: []
deprecated_phrases: []
---
"""
),
@@ -128,19 +150,14 @@ class TestAgentRuntimeConfig(unittest.TestCase):
def test_deprecated_phrase_warning_is_reported(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
runtime_root = self.agent_root / "runtime"
shutil.copytree(self.bundled_root, runtime_root)
shutil.copytree(self.defaults_root, runtime_root)
current_persona = runtime_root / "CURRENT_PERSONA.md"
current_persona.write_text(
textwrap.dedent(
"""\
---
version: 1
version: 3
active_persona: default
profile: personas/default/AGENT_PROFILE.md
workflow: personas/default/AGENT_WORKFLOW.md
hooks: personas/default/AGENT_HOOKS.md
user_preferences: USER_PREFERENCES.md
system_tasks: system_tasks/SYSTEM_TASKS.md
extra_context_files: []
deprecated_phrases:
- professional, concise, restrained
@@ -155,38 +172,12 @@ class TestAgentRuntimeConfig(unittest.TestCase):
runtime_config = manager.load_runtime_config()
self.assertTrue(
any("professional, concise, restrained" in warning for warning in runtime_config.warnings)
any(
"professional, concise, restrained" in warning
for warning in runtime_config.warnings
)
)
def test_outdated_system_tasks_definition_falls_back_to_bundled_defaults(self):
self.agent_root.mkdir(parents=True, exist_ok=True)
runtime_root = self.agent_root / "runtime"
shutil.copytree(self.bundled_root, runtime_root)
system_tasks = runtime_root / "system_tasks" / "SYSTEM_TASKS.md"
system_tasks.write_text(
textwrap.dedent(
"""\
---
version: 1
shared_rules:
- legacy system tasks
task_types:
heartbeat:
header: "[Legacy Heartbeat]"
objective: "legacy"
---
"""
),
encoding="utf-8",
)
manager = self._manager()
manager.invalidate_cache()
runtime_config = manager.load_runtime_config()
self.assertTrue(runtime_config.used_fallback)
self.assertEqual(runtime_config.system_tasks.version, 2)
if __name__ == "__main__":
unittest.main()

View File

@@ -4,6 +4,7 @@ from unittest.mock import patch
from langchain.agents.middleware import SummarizationMiddleware
import app.agent as agent_module
from app.agent.middleware.runtime_config import RuntimeConfigMiddleware
class _FakeLLM:
@@ -115,6 +116,35 @@ class TestAgentSummarizationStreaming(unittest.TestCase):
self.assertIs(captured["model"], main_llm)
self.assertIs(summary_middleware.model, main_llm)
def test_agent_uses_runtime_config_middleware_instead_of_hooks(self):
agent = agent_module.MoviePilotAgent(session_id="session-1", user_id="10001")
main_llm = _FakeLLM("main")
captured: dict = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return object()
with (
patch.object(agent, "_initialize_llm", return_value=main_llm),
patch.object(agent, "_initialize_tools", return_value=[]),
patch.object(
agent_module.prompt_manager, "get_agent_prompt", return_value="prompt"
),
patch.object(agent_module, "create_agent", side_effect=_fake_create_agent),
):
agent._create_agent(streaming=False)
self.assertTrue(
any(
isinstance(middleware, RuntimeConfigMiddleware)
for middleware in captured["middleware"]
)
)
self.assertFalse(
any(type(middleware).__name__ == "AgentHooksMiddleware" for middleware in captured["middleware"])
)
if __name__ == "__main__":
unittest.main()