Refactor agent tool inputs and background activity logging

This commit is contained in:
jxxghp
2026-06-27 07:53:09 +08:00
parent 544ed6d84d
commit 318cfe68e9
88 changed files with 223 additions and 238 deletions

View File

@@ -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(

View File

@@ -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="请下载查看",
)

View File

@@ -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(":")

View File

@@ -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": "测试加载技能",
},
}
]

View File

@@ -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] = []

View File

@@ -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