feat: enhance user permissions handling for admin and non-admin contexts

This commit is contained in:
jxxghp
2026-06-15 09:16:46 +08:00
parent c87b856ddf
commit ef36af8a82
17 changed files with 1058 additions and 204 deletions

View File

@@ -1,6 +1,5 @@
import asyncio
import json
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
@@ -19,134 +18,132 @@ from app.agent.middleware.subagents import (
from app.agent.tools.tags import ToolTag
class TestAgentSubagents(unittest.TestCase):
def test_create_subagent_middlewares_registers_task_tool(self):
"""子代理中间件应向主 Agent 注册 task 委派工具。"""
model = FakeListChatModel(responses=["ok"])
def test_create_subagent_middlewares_registers_task_tool():
"""子代理中间件应向主 Agent 注册 task 委派工具。"""
model = FakeListChatModel(responses=["ok"])
middlewares, task_tools = create_subagent_middlewares(
model=model,
tools=[],
stream_handler=None,
)
middlewares, task_tools = create_subagent_middlewares(
model=model,
tools=[],
stream_handler=None,
)
self.assertEqual(len(middlewares), 3)
self.assertEqual(
[tool.name for tool in task_tools],
[SUBAGENT_TASK_TOOL_NAME, SUBAGENT_CONTROL_TOOL_NAME],
)
self.assertIn("media-researcher", task_tools[0].description)
self.assertIn("moviepilot-explorer", task_tools[0].description)
self.assertIn("system-diagnostician", task_tools[0].description)
self.assertIn("action=start", task_tools[1].description)
self.assertIn("action=wait", task_tools[1].description)
def test_subagent_tools_are_selected_by_tags(self):
"""子代理应根据工具标签筛选工具,而不是依赖工具名名单。"""
model = FakeListChatModel(responses=["ok"])
tools = [
SimpleNamespace(
name="custom_media_lookup",
tags=[ToolTag.Read.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_media_writer",
tags=[ToolTag.Read.value, ToolTag.Write.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_site_lookup",
tags=[ToolTag.Read.value, ToolTag.Site.value],
),
]
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=tools,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("media-researcher")
self.assertEqual(
[tool.name for tool in captured["tools"]],
["custom_media_lookup"],
)
def test_moviepilot_explorer_selects_code_and_settings_tools(self):
"""MoviePilot 探索子代理应能读取代码、目录、设置和命令诊断工具。"""
model = FakeListChatModel(responses=["ok"])
tools = [
SimpleNamespace(
name="custom_code_reader",
tags=[ToolTag.Read.value, ToolTag.File.value],
),
SimpleNamespace(
name="custom_directory_lister",
tags=[ToolTag.Read.value, ToolTag.Directory.value],
),
SimpleNamespace(
name="custom_settings_reader",
tags=[ToolTag.Read.value, ToolTag.Settings.value],
),
SimpleNamespace(
name="custom_command_runner",
tags=[ToolTag.Read.value, ToolTag.Command.value],
),
SimpleNamespace(
name="custom_code_writer",
tags=[ToolTag.Read.value, ToolTag.Write.value, ToolTag.File.value],
),
]
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=tools,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("moviepilot-explorer")
self.assertEqual(
[tool.name for tool in captured["tools"]],
[
"custom_code_reader",
"custom_directory_lister",
"custom_settings_reader",
"custom_command_runner",
],
)
def test_builtin_tools_declare_tags_in_implementation(self):
"""所有内置工具实现都应显式声明 tags。"""
impl_dir = Path(__file__).resolve().parents[1] / "app" / "agent" / "tools" / "impl"
missing_tools = []
for path in sorted(impl_dir.glob("*.py")):
text = path.read_text()
for block in text.split("\nclass "):
if "(MoviePilotTool)" not in block:
continue
class_name = block.split("(", 1)[0].strip()
if "tags: list[str]" not in block:
missing_tools.append(f"{path.name}:{class_name}")
self.assertEqual([], missing_tools)
assert len(middlewares) == 3
assert [tool.name for tool in task_tools] == [
SUBAGENT_TASK_TOOL_NAME,
SUBAGENT_CONTROL_TOOL_NAME,
]
assert "media-researcher" in task_tools[0].description
assert "moviepilot-explorer" in task_tools[0].description
assert "system-diagnostician" in task_tools[0].description
assert "action=start" in task_tools[1].description
assert "action=wait" in task_tools[1].description
assert "action=pipeline" in task_tools[1].description
class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
async def test_call_summary_middleware_logs_subagent_tool_operations(self):
"""子代理工具包装层应记录工具执行开始和完成日志。"""
def test_subagent_tools_are_selected_by_tags():
"""子代理应根据工具标签筛选工具,而不是依赖工具名名单。"""
model = FakeListChatModel(responses=["ok"])
tools = [
SimpleNamespace(
name="custom_media_lookup",
tags=[ToolTag.Read.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_media_writer",
tags=[ToolTag.Read.value, ToolTag.Write.value, ToolTag.Media.value],
),
SimpleNamespace(
name="custom_site_lookup",
tags=[ToolTag.Read.value, ToolTag.Site.value],
),
]
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=tools,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("media-researcher")
assert [tool.name for tool in captured["tools"]] == ["custom_media_lookup"]
def test_moviepilot_explorer_selects_code_and_settings_tools():
"""MoviePilot 探索子代理应能读取代码、目录、设置和命令诊断工具。"""
model = FakeListChatModel(responses=["ok"])
tools = [
SimpleNamespace(
name="custom_code_reader",
tags=[ToolTag.Read.value, ToolTag.File.value],
),
SimpleNamespace(
name="custom_directory_lister",
tags=[ToolTag.Read.value, ToolTag.Directory.value],
),
SimpleNamespace(
name="custom_settings_reader",
tags=[ToolTag.Read.value, ToolTag.Settings.value],
),
SimpleNamespace(
name="custom_command_runner",
tags=[ToolTag.Read.value, ToolTag.Command.value],
),
SimpleNamespace(
name="custom_code_writer",
tags=[ToolTag.Read.value, ToolTag.Write.value, ToolTag.File.value],
),
]
captured = {}
def _fake_create_agent(**kwargs):
captured.update(kwargs)
return kwargs
middleware = MoviePilotSubAgentMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=tools,
)
with patch.object(subagent_module, "create_agent", side_effect=_fake_create_agent):
middleware._get_agent("moviepilot-explorer")
assert [tool.name for tool in captured["tools"]] == [
"custom_code_reader",
"custom_directory_lister",
"custom_settings_reader",
"custom_command_runner",
]
def test_builtin_tools_declare_tags_in_implementation():
"""所有内置工具实现都应显式声明 tags。"""
impl_dir = Path(__file__).resolve().parents[1] / "app" / "agent" / "tools" / "impl"
missing_tools = []
for path in sorted(impl_dir.glob("*.py")):
text = path.read_text()
for block in text.split("\nclass "):
if "(MoviePilotTool)" not in block:
continue
class_name = block.split("(", 1)[0].strip()
if "tags: list[str]" not in block:
missing_tools.append(f"{path.name}:{class_name}")
assert missing_tools == []
def test_call_summary_middleware_logs_subagent_tool_operations():
"""子代理工具包装层应记录工具执行开始和完成日志。"""
async def _run_test():
middleware = SubAgentCallSummaryMiddleware()
request = SimpleNamespace(
tool=SimpleNamespace(name=SUBAGENT_CONTROL_TOOL_NAME),
@@ -165,12 +162,17 @@ class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
result = await middleware.awrap_tool_call(request, _fake_handler)
messages = [call.args[0] for call in log_info.call_args_list]
self.assertEqual("ok", result)
self.assertTrue(any("开始执行子代理工具" in message for message in messages))
self.assertTrue(any("子代理工具执行完成" in message for message in messages))
assert result == "ok"
assert any("开始执行子代理工具" in message for message in messages)
assert any("子代理工具执行完成" in message for message in messages)
async def test_control_tool_starts_tasks_concurrently_and_waits(self):
"""异步子代理管控工具应批量启动任务,并在 wait 时收集结果。"""
asyncio.run(_run_test())
def test_control_tool_starts_tasks_concurrently_and_waits():
"""异步子代理管控工具应批量启动任务,并在 wait 时收集结果。"""
async def _run_test():
model = FakeListChatModel(responses=["ok"])
middleware = SubAgentTaskControlMiddleware(
model=model,
@@ -221,21 +223,154 @@ class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
)
)
self.assertTrue(start_payload["success"])
self.assertEqual(2, len(task_ids))
self.assertEqual(["检查媒体库", "检查下载器"], running_descriptions)
self.assertEqual(
["completed", "completed"],
[task["status"] for task in wait_payload["tasks"]],
)
self.assertIn("media-researcher:检查媒体库", wait_payload["tasks"][0]["result"])
self.assertIn(
"download-diagnostician:检查下载器",
wait_payload["tasks"][1]["result"],
assert start_payload["success"]
assert len(task_ids) == 2
assert running_descriptions == ["检查媒体库", "检查下载器"]
assert [task["status"] for task in wait_payload["tasks"]] == [
"completed",
"completed",
]
assert "media-researcher:检查媒体库" in wait_payload["tasks"][0]["result"]
assert (
"download-diagnostician:检查下载器"
in wait_payload["tasks"][1]["result"]
)
async def test_after_agent_cancels_unfinished_tasks(self):
"""Agent 结束时应取消仍在运行的异步子代理任务。"""
asyncio.run(_run_test())
def test_control_tool_pipeline_passes_previous_results_to_next_step():
"""管道模式应顺序执行子代理,并把上一步结果作为下一步私有上下文。"""
async def _run_test():
model = FakeListChatModel(responses=["ok"])
middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=[],
)
calls = []
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
calls.append(
{
"description": description,
"subagent_type": subagent_type,
"task_id": task_id,
}
)
return f"结果-{len(calls)}"
with patch.object(
subagent_module._SubAgentAgentProvider,
"run_task",
new=_fake_run_task,
):
payload = json.loads(
await middleware._control_task(
action="pipeline",
tasks=[
{
"description": "识别媒体",
"subagent_type": "media-researcher",
},
{
"description": "检查下载",
"subagent_type": "download-diagnostician",
},
{
"description": "汇总结论",
"subagent_type": "general-purpose",
},
],
timeout_ms=1000,
)
)
assert payload["success"]
assert [call["subagent_type"] for call in calls] == [
"media-researcher",
"download-diagnostician",
"general-purpose",
]
assert calls[0]["description"] == "识别媒体"
assert "结果-1" in calls[1]["description"]
assert "结果-1" in calls[2]["description"]
assert "结果-2" in calls[2]["description"]
assert [task["status"] for task in payload["tasks"]] == [
"completed",
"completed",
"completed",
]
assert [task["result"] for task in payload["tasks"]] == [
"结果-1",
"结果-2",
"结果-3",
]
asyncio.run(_run_test())
def test_control_tool_pipeline_stops_after_failed_step():
"""管道模式遇到失败步骤时应中断后续子代理。"""
async def _run_test():
model = FakeListChatModel(responses=["ok"])
middleware = SubAgentTaskControlMiddleware(
model=model,
profiles=subagent_module._builtin_subagent_profiles(),
tools=[],
)
calls = []
async def _fake_run_task(self, *, description, subagent_type, task_id=None):
calls.append(subagent_type)
if subagent_type == "download-diagnostician":
raise RuntimeError("下载器不可用")
return f"{subagent_type}:ok"
with patch.object(
subagent_module._SubAgentAgentProvider,
"run_task",
new=_fake_run_task,
):
payload = json.loads(
await middleware._control_task(
action="pipeline",
tasks=[
{
"description": "识别媒体",
"subagent_type": "media-researcher",
},
{
"description": "检查下载",
"subagent_type": "download-diagnostician",
},
{
"description": "汇总结论",
"subagent_type": "general-purpose",
},
],
timeout_ms=1000,
)
)
assert not payload["success"]
assert "第 2 个管道子代理任务执行失败" in payload["error"]
assert calls == ["media-researcher", "download-diagnostician"]
assert [task["status"] for task in payload["tasks"]] == [
"completed",
"failed",
]
assert "下载器不可用" in payload["tasks"][1]["error"]
asyncio.run(_run_test())
def test_after_agent_cancels_unfinished_tasks():
"""Agent 结束时应取消仍在运行的异步子代理任务。"""
async def _run_test():
model = FakeListChatModel(responses=["ok"])
middleware = SubAgentTaskControlMiddleware(
model=model,
@@ -269,4 +404,6 @@ class TestSubAgentTaskControlMiddleware(unittest.IsolatedAsyncioTestCase):
)
)
self.assertEqual("cancelled", status_payload["tasks"][0]["status"])
assert status_payload["tasks"][0]["status"] == "cancelled"
asyncio.run(_run_test())