mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-06 20:42:43 +08:00
Refactor agent persona runtime layering
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
19
app/agent/defaults/CURRENT_PERSONA.md
Normal file
19
app/agent/defaults/CURRENT_PERSONA.md
Normal 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` 中的长期偏好可以细化回复方式,但不应覆盖系统核心身份、目标和安全边界。
|
||||
23
app/agent/defaults/personas/concise/PERSONA.md
Normal file
23
app/agent/defaults/personas/concise/PERSONA.md
Normal 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.
|
||||
24
app/agent/defaults/personas/default/PERSONA.md
Normal file
24
app/agent/defaults/personas/default/PERSONA.md
Normal 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.
|
||||
22
app/agent/defaults/personas/guide/PERSONA.md
Normal file
22
app/agent/defaults/personas/guide/PERSONA.md
Normal 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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
42
app/agent/middleware/runtime_config.py
Normal file
42
app/agent/middleware/runtime_config.py
Normal 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"]
|
||||
@@ -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>
|
||||
|
||||
@@ -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` 负责定义该任务独有的补充约束。
|
||||
- 代码侧只负责触发任务并提供模板变量,不再保存具体行为提示词。
|
||||
@@ -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
@@ -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 中。
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
version: 1
|
||||
---
|
||||
# USER_PREFERENCES
|
||||
|
||||
这是根层的运维偏好文件,不是用户长期记忆。
|
||||
|
||||
- 这里只放稳定的系统级输出规则或部署方偏好。
|
||||
- 用户在对话中形成的长期习惯,仍应写入 `config/agent/memory/*.md`。
|
||||
- 默认保持精简,避免与 `AGENT_PROFILE.md` 或 `AGENT_WORKFLOW.md` 重复。
|
||||
@@ -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` 对应最小验证与收口阶段。
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
75
app/agent/tools/impl/query_personas.py
Normal file
75
app/agent/tools/impl/query_personas.py
Normal 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,
|
||||
)
|
||||
62
app/agent/tools/impl/switch_persona.py
Normal file
62
app/agent/tools/impl/switch_persona.py
Normal 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,
|
||||
)
|
||||
131
app/agent/tools/impl/update_persona_definition.py
Normal file
131
app/agent/tools/impl/update_persona_definition.py
Normal 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,
|
||||
)
|
||||
@@ -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(":")
|
||||
|
||||
121
tests/test_agent_persona_tools.py
Normal file
121
tests/test_agent_persona_tools.py
Normal 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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user