mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-12 16:02:35 +08:00
Refactor agent persona runtime layering
This commit is contained in:
@@ -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