mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-30 18:27:38 +08:00
Refactor agent tool inputs and background activity logging
This commit is contained in:
@@ -28,6 +28,13 @@ def _write_activity_log(activity_dir, date_str: str, lines: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _wait_activity_log_tasks(middleware: ActivityLogMiddleware) -> None:
|
||||
"""等待活动日志后台任务完成,避免测试与后台写入竞态。"""
|
||||
tasks = list(middleware._background_tasks)
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
def test_activity_log_index_counts_entries_without_body(tmp_path):
|
||||
"""活动日志索引只应包含条目数量,不暴露完整摘要正文。"""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
@@ -91,19 +98,19 @@ def test_activity_log_abefore_agent_refreshes_existing_state(tmp_path):
|
||||
|
||||
def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
|
||||
"""无实际任务的寒暄不应调用 LLM,也不应写入活动日志。"""
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
summarize_mock = AsyncMock(return_value="不应写入")
|
||||
append_mock = AsyncMock()
|
||||
async def _run_test():
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
summarize_mock = AsyncMock(return_value="不应写入")
|
||||
append_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||
new=summarize_mock,
|
||||
),
|
||||
patch.object(middleware, "_append_activity", new=append_mock),
|
||||
):
|
||||
asyncio.run(
|
||||
middleware.aafter_agent(
|
||||
with (
|
||||
patch(
|
||||
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||
new=summarize_mock,
|
||||
),
|
||||
patch.object(middleware, "_append_activity", new=append_mock),
|
||||
):
|
||||
await middleware.aafter_agent(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="你好"),
|
||||
@@ -112,7 +119,11 @@ def test_activity_log_skips_trivial_greeting_without_llm(tmp_path):
|
||||
},
|
||||
runtime=None,
|
||||
)
|
||||
)
|
||||
await _wait_activity_log_tasks(middleware)
|
||||
|
||||
return summarize_mock, append_mock
|
||||
|
||||
summarize_mock, append_mock = asyncio.run(_run_test())
|
||||
|
||||
summarize_mock.assert_not_awaited()
|
||||
append_mock.assert_not_awaited()
|
||||
@@ -137,18 +148,18 @@ def test_summarize_with_llm_ignores_skip_marker():
|
||||
|
||||
def test_activity_log_records_detailed_summary(tmp_path):
|
||||
"""有实际工具动作的交互应写入较完整的活动摘要。"""
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
summary = (
|
||||
"用户要求整理 `/downloads/Show`,助手调用 transfer_file 识别并转移剧集,"
|
||||
"结果成功写入目标媒体库。"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||
new=AsyncMock(return_value=summary),
|
||||
):
|
||||
asyncio.run(
|
||||
middleware.aafter_agent(
|
||||
async def _run_test():
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
with patch(
|
||||
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||
new=AsyncMock(return_value=summary),
|
||||
):
|
||||
await middleware.aafter_agent(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="帮我整理 /downloads/Show"),
|
||||
@@ -170,7 +181,9 @@ def test_activity_log_records_detailed_summary(tmp_path):
|
||||
},
|
||||
runtime=None,
|
||||
)
|
||||
)
|
||||
await _wait_activity_log_tasks(middleware)
|
||||
|
||||
asyncio.run(_run_test())
|
||||
|
||||
log_files = list(tmp_path.glob("*.md"))
|
||||
assert len(log_files) == 1
|
||||
@@ -179,6 +192,61 @@ def test_activity_log_records_detailed_summary(tmp_path):
|
||||
assert "- **" in content
|
||||
|
||||
|
||||
def test_activity_log_after_agent_does_not_wait_for_summary(tmp_path):
|
||||
"""活动日志摘要生成应在后台执行,不阻塞当前 Agent 会话结束。"""
|
||||
|
||||
async def _slow_summarize(_conversation_text: str) -> str:
|
||||
"""模拟较慢的活动摘要生成。"""
|
||||
await asyncio.sleep(0.05)
|
||||
return "用户要求检查下载任务,助手调用工具完成检查。"
|
||||
|
||||
async def _run_test():
|
||||
middleware = ActivityLogMiddleware(activity_dir=str(tmp_path))
|
||||
append_mock = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"app.agent.middleware.activity_log._summarize_with_llm",
|
||||
side_effect=_slow_summarize,
|
||||
) as summarize_mock,
|
||||
patch.object(middleware, "_append_activity", new=append_mock),
|
||||
):
|
||||
await middleware.aafter_agent(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="帮我检查下载任务"),
|
||||
AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "query_download_tasks",
|
||||
"args": {},
|
||||
"id": "call_1",
|
||||
}
|
||||
],
|
||||
),
|
||||
ToolMessage(
|
||||
content='{"success": true}',
|
||||
tool_call_id="call_1",
|
||||
),
|
||||
],
|
||||
},
|
||||
runtime=None,
|
||||
)
|
||||
called_before_wait = summarize_mock.await_count
|
||||
pending_before_wait = len(middleware._background_tasks)
|
||||
await _wait_activity_log_tasks(middleware)
|
||||
return called_before_wait, pending_before_wait, summarize_mock, append_mock
|
||||
|
||||
called_before_wait, pending_before_wait, summarize_mock, append_mock = asyncio.run(
|
||||
_run_test()
|
||||
)
|
||||
|
||||
assert called_before_wait == 0
|
||||
assert pending_before_wait == 1
|
||||
summarize_mock.assert_awaited_once()
|
||||
append_mock.assert_awaited_once_with("用户要求检查下载任务,助手调用工具完成检查。")
|
||||
|
||||
|
||||
def test_query_activity_logs_filters_by_keyword_and_date(tmp_path):
|
||||
"""活动日志查询应支持日期和关键词过滤。"""
|
||||
_write_activity_log(
|
||||
|
||||
@@ -563,7 +563,6 @@ class AgentImageSupportTest(unittest.TestCase):
|
||||
|
||||
def test_send_message_input_accepts_image_only_payload(self):
|
||||
payload = SendMessageInput(
|
||||
explanation="send poster image",
|
||||
image_url="https://example.com/poster.png",
|
||||
)
|
||||
|
||||
@@ -659,7 +658,6 @@ class AgentImageSupportTest(unittest.TestCase):
|
||||
|
||||
def test_send_local_file_input_accepts_file_payload(self):
|
||||
payload = SendLocalFileInput(
|
||||
explanation="send generated report",
|
||||
file_path="/tmp/report.txt",
|
||||
message="请下载查看",
|
||||
)
|
||||
|
||||
@@ -125,6 +125,7 @@ class TestAgentInteraction(unittest.TestCase):
|
||||
notification = async_post_message.await_args.args[0]
|
||||
self.assertEqual(notification.text, "请选择要执行的操作")
|
||||
self.assertEqual(sum(len(row) for row in notification.buttons), 2)
|
||||
self.assertNotIn("description", notification.buttons[0][0])
|
||||
|
||||
callback_data = notification.buttons[0][0]["callback_data"]
|
||||
_, _, request_id, option_index = callback_data.split(":")
|
||||
|
||||
@@ -132,7 +132,6 @@ async def test_skill_tool_call_records_streaming_summary(tmp_path):
|
||||
tool_call={
|
||||
"args": {
|
||||
"name": "moviepilot-cli",
|
||||
"explanation": "测试加载技能",
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -150,7 +149,6 @@ async def test_skill_tool_call_records_streaming_summary(tmp_path):
|
||||
"tool_message": "Skill loaded",
|
||||
"tool_kwargs": {
|
||||
"name": "moviepilot-cli",
|
||||
"explanation": "测试加载技能",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import importlib.util
|
||||
from types import SimpleNamespace
|
||||
from typing import Iterator, Optional
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Type
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.middleware.activity_log import QueryActivityLogInput
|
||||
from app.agent.middleware.skills import SkillToolInput
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.factory import MoviePilotToolFactory
|
||||
from app.agent.tools.impl.ask_user_choice import AskUserChoiceInput, AskUserChoiceTool
|
||||
from app.agent.tools.impl.send_local_file import SendLocalFileTool
|
||||
from app.agent.tools.impl.send_voice_message import SendVoiceMessageTool
|
||||
from app.core.plugin import PluginManager
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -56,6 +64,66 @@ def _build_plugin(
|
||||
)
|
||||
|
||||
|
||||
def _schema_properties(args_schema: Type[BaseModel]) -> dict:
|
||||
"""返回工具输入模型的 JSON Schema 属性。"""
|
||||
return args_schema.model_json_schema().get("properties", {})
|
||||
|
||||
|
||||
def _load_lexiannot_tool_schemas() -> list[Type[BaseModel]]:
|
||||
"""只加载 LexiAnnot schema 文件,避免触发插件包可选依赖。"""
|
||||
schema_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "app"
|
||||
/ "plugins"
|
||||
/ "lexiannot"
|
||||
/ "schemas.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_test_lexiannot_schemas",
|
||||
schema_path,
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return [
|
||||
module.VocabularyAnnotatingToolInput,
|
||||
module.QueryAnnotationTasksToolInput,
|
||||
]
|
||||
|
||||
|
||||
def test_agent_tool_schemas_do_not_expose_explanation_parameter() -> None:
|
||||
"""所有 Agent 工具输入模型都不应暴露 explanation 参数。"""
|
||||
tool_classes = [
|
||||
*MoviePilotToolFactory.BUILTIN_TOOL_CLASSES,
|
||||
AskUserChoiceTool,
|
||||
SendLocalFileTool,
|
||||
SendVoiceMessageTool,
|
||||
]
|
||||
middleware_schemas = [
|
||||
SkillToolInput,
|
||||
QueryActivityLogInput,
|
||||
]
|
||||
plugin_schemas = _load_lexiannot_tool_schemas()
|
||||
|
||||
for tool_class in tool_classes:
|
||||
args_schema = getattr(tool_class, "args_schema", None)
|
||||
if args_schema is None:
|
||||
continue
|
||||
assert "explanation" not in _schema_properties(args_schema), tool_class.name
|
||||
|
||||
for args_schema in middleware_schemas + plugin_schemas:
|
||||
assert "explanation" not in _schema_properties(args_schema), args_schema.__name__
|
||||
|
||||
|
||||
def test_ask_user_choice_option_schema_does_not_expose_description() -> None:
|
||||
"""询问用户意图工具的选项参数不应暴露 description 字段。"""
|
||||
schema = AskUserChoiceInput.model_json_schema()
|
||||
option_schema = schema["$defs"]["UserChoiceOptionInput"]
|
||||
|
||||
assert "description" not in option_schema["properties"]
|
||||
assert option_schema["required"] == ["label", "value"]
|
||||
|
||||
|
||||
def test_plugin_agent_tools_are_cached(plugin_manager: PluginManager) -> None:
|
||||
"""插件智能体工具注册表应缓存,避免同一轮启动反复询问插件实例。"""
|
||||
calls: list[int] = []
|
||||
|
||||
@@ -49,6 +49,10 @@ class DummyTool(MoviePilotTool):
|
||||
name: str = "dummy_tool"
|
||||
description: str = "Dummy tool for streaming tests."
|
||||
|
||||
def get_tool_message(self, **kwargs) -> str:
|
||||
"""返回固定工具执行提示。"""
|
||||
return "run test tool"
|
||||
|
||||
async def run(self, **kwargs) -> str:
|
||||
"""返回固定工具执行结果。"""
|
||||
return "ok"
|
||||
@@ -67,7 +71,7 @@ class TestAgentToolStreaming:
|
||||
tool.set_stream_handler(handler)
|
||||
|
||||
with patch.object(settings, "AI_AGENT_VERBOSE", False):
|
||||
result = await tool._arun(explanation="run test tool")
|
||||
result = await tool._arun()
|
||||
|
||||
buffered_message = await handler.take()
|
||||
return result, buffered_message
|
||||
@@ -103,7 +107,7 @@ class TestAgentToolStreaming:
|
||||
tool.set_stream_handler(handler)
|
||||
|
||||
with patch.object(settings, "AI_AGENT_VERBOSE", False):
|
||||
await tool._arun(explanation="run test tool")
|
||||
await tool._arun()
|
||||
|
||||
handler.emit("已经拿到结果")
|
||||
return await handler.take()
|
||||
@@ -470,7 +474,7 @@ class TestAgentToolStreaming:
|
||||
DummyTool, "send_tool_message", new_callable=AsyncMock
|
||||
) as send_tool_message,
|
||||
):
|
||||
result = await tool._arun(explanation="run test tool")
|
||||
result = await tool._arun()
|
||||
buffered_message = await handler.take()
|
||||
return result, buffered_message, send_tool_message
|
||||
|
||||
@@ -497,7 +501,7 @@ class TestAgentToolStreaming:
|
||||
DummyTool, "send_tool_message", new_callable=AsyncMock
|
||||
) as send_tool_message,
|
||||
):
|
||||
result = await tool._arun(explanation="run test tool")
|
||||
result = await tool._arun()
|
||||
buffered_message = await handler.take()
|
||||
return result, buffered_message, send_tool_message
|
||||
|
||||
|
||||
Reference in New Issue
Block a user