feat(agents): Phase 6 tool system refactoring
Phase 6.1: ToolRegistry infrastructure - Add ToolManifest with ToolCategory, PermissionClass, SideEffectScope - Add ToolRegistry singleton with register/get/unregister/list/search - Add BaseTool abstract class with ReadTool/WriteTool/DBWriteTool/ExternalTool/NetworkTool subclasses - Add migration layer for backward compatibility Phase 6.2: Hook interception system - Add HookType (PRE_TOOL_USE, POST_TOOL_USE, TOOL_ERROR, TOOL_SKIP) - Add HookManager with singleton for hook registration - Add HookExecutor for pre/post/error hook execution Phase 6.3: Streaming execution - Add StreamingToolExecutor with batch execution support Phase 6.4: New builtin tools - Add file_tools: GlobTool, GrepTool, ReadFileTool, WriteFileTool - Add system_tools: BashTool, PowerShellTool - Add dev_tools: LSPTools, GitTool - Add collaboration_tools: TeamAgentTool, TaskBroadcastTool Tests: 29 passed
This commit is contained in:
324
backend/tests/backend/app/agents/test_tools_registry.py
Normal file
324
backend/tests/backend/app/agents/test_tools_registry.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""ToolRegistry 单元测试"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.agents.tools.manifest import (
|
||||
HookConfig,
|
||||
PermissionClass,
|
||||
SideEffectScope,
|
||||
ToolCategory,
|
||||
ToolManifest,
|
||||
)
|
||||
from app.agents.tools.registry import (
|
||||
ToolRegistry,
|
||||
get_tool_registry,
|
||||
reset_tool_registry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
"""创建空的 ToolRegistry 实例"""
|
||||
return ToolRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest():
|
||||
"""创建示例工具元数据"""
|
||||
return ToolManifest(
|
||||
name="test_tool",
|
||||
description="A test tool",
|
||||
category=ToolCategory.READ,
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"input": {"type": "string"}},
|
||||
"required": ["input"],
|
||||
},
|
||||
return_schema={"type": "string"},
|
||||
permission_class=PermissionClass.READ,
|
||||
side_effect_scope=SideEffectScope.NONE,
|
||||
requires_confirmation=False,
|
||||
is_streaming=False,
|
||||
tags=["test", "sample"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_executor():
|
||||
"""创建示例执行器"""
|
||||
|
||||
def executor(input: str) -> str:
|
||||
return f"processed: {input}"
|
||||
|
||||
return executor
|
||||
|
||||
|
||||
class TestToolRegistryInit:
|
||||
"""测试 ToolRegistry 初始化"""
|
||||
|
||||
def test_empty_registry(self, registry):
|
||||
assert len(registry) == 0
|
||||
assert list(registry) == []
|
||||
|
||||
def test_empty_contains_false(self, registry):
|
||||
assert "test_tool" not in registry
|
||||
|
||||
|
||||
class TestToolRegistryRegister:
|
||||
"""测试工具注册"""
|
||||
|
||||
def test_register_single_tool(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert len(registry) == 1
|
||||
assert "test_tool" in registry
|
||||
|
||||
def test_register_duplicate_raises(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
with pytest.raises(ValueError, match="Tool already registered"):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
def test_register_with_hooks(self, registry, sample_manifest, sample_executor):
|
||||
hooks = [
|
||||
HookConfig(name="audit", hook_type="pre_tool_use"),
|
||||
HookConfig(name="security", hook_type="post_tool_use"),
|
||||
]
|
||||
registry.register(sample_manifest, sample_executor, hooks=hooks)
|
||||
|
||||
tool_hooks = registry.get_hooks("test_tool")
|
||||
assert len(tool_hooks) == 2
|
||||
|
||||
|
||||
class TestToolRegistryGet:
|
||||
"""测试获取工具"""
|
||||
|
||||
def test_get_existing_tool(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
manifest = registry.get("test_tool")
|
||||
assert manifest is not None
|
||||
assert manifest.name == "test_tool"
|
||||
|
||||
def test_get_nonexistent_tool(self, registry):
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_get_executor(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
executor = registry.get_executor("test_tool")
|
||||
assert executor is not None
|
||||
assert executor("hello") == "processed: hello"
|
||||
|
||||
def test_get_nonexistent_executor(self, registry):
|
||||
assert registry.get_executor("nonexistent") is None
|
||||
|
||||
|
||||
class TestToolRegistryList:
|
||||
"""测试工具列表"""
|
||||
|
||||
def test_list_all_empty(self, registry):
|
||||
assert registry.list_all() == []
|
||||
|
||||
def test_list_all_with_tools(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
manifest2 = ToolManifest(
|
||||
name="test_tool2",
|
||||
description="Another test tool",
|
||||
category=ToolCategory.WRITE,
|
||||
parameters={"type": "object"},
|
||||
return_schema={"type": "string"},
|
||||
permission_class=PermissionClass.WRITE,
|
||||
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||
requires_confirmation=True,
|
||||
)
|
||||
registry.register(manifest2, sample_executor)
|
||||
|
||||
all_tools = registry.list_all()
|
||||
assert len(all_tools) == 2
|
||||
|
||||
def test_list_by_category(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
manifest2 = ToolManifest(
|
||||
name="write_tool",
|
||||
description="A write tool",
|
||||
category=ToolCategory.WRITE,
|
||||
parameters={"type": "object"},
|
||||
return_schema={"type": "string"},
|
||||
permission_class=PermissionClass.WRITE,
|
||||
side_effect_scope=SideEffectScope.LOCAL_STATE,
|
||||
requires_confirmation=True,
|
||||
)
|
||||
registry.register(manifest2, sample_executor)
|
||||
|
||||
read_tools = registry.list_by_category(ToolCategory.READ)
|
||||
write_tools = registry.list_by_category(ToolCategory.WRITE)
|
||||
|
||||
assert len(read_tools) == 1
|
||||
assert len(write_tools) == 1
|
||||
|
||||
def test_list_by_permission(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
read_tools = registry.list_by_permission(PermissionClass.READ)
|
||||
write_tools = registry.list_by_permission(PermissionClass.WRITE)
|
||||
|
||||
assert len(read_tools) == 1
|
||||
assert len(write_tools) == 0
|
||||
|
||||
|
||||
class TestToolRegistrySearch:
|
||||
"""测试工具搜索"""
|
||||
|
||||
def test_search_by_tag(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
results = registry.search_by_tag("test")
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "test_tool"
|
||||
|
||||
results = registry.search_by_tag("nonexistent")
|
||||
assert len(results) == 0
|
||||
|
||||
def test_search_by_name(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
results = registry.search_by_name("test")
|
||||
assert len(results) == 1
|
||||
|
||||
results = registry.search_by_name("tool")
|
||||
assert len(results) == 1
|
||||
|
||||
results = registry.search_by_name("xyz")
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
class TestToolRegistryUtility:
|
||||
"""测试工具方法"""
|
||||
|
||||
def test_requires_confirmation_true(self, registry, sample_manifest, sample_executor):
|
||||
sample_manifest.requires_confirmation = True
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert registry.get_requires_confirmation("test_tool") is True
|
||||
|
||||
def test_requires_confirmation_false(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert registry.get_requires_confirmation("test_tool") is False
|
||||
|
||||
def test_requires_confirmation_nonexistent(self, registry):
|
||||
assert registry.get_requires_confirmation("nonexistent") is False
|
||||
|
||||
def test_is_streaming_true(self, registry, sample_manifest, sample_executor):
|
||||
sample_manifest.is_streaming = True
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert registry.get_is_streaming("test_tool") is True
|
||||
|
||||
def test_is_streaming_false(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert registry.get_is_streaming("test_tool") is False
|
||||
|
||||
|
||||
class TestToolRegistryUnregister:
|
||||
"""测试工具注销"""
|
||||
|
||||
def test_unregister_existing(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
assert registry.unregister("test_tool") is True
|
||||
assert len(registry) == 0
|
||||
assert "test_tool" not in registry
|
||||
|
||||
def test_unregister_nonexistent(self, registry):
|
||||
assert registry.unregister("nonexistent") is False
|
||||
|
||||
def test_unregister_clears_hooks(self, registry, sample_manifest, sample_executor):
|
||||
hooks = [HookConfig(name="test", hook_type="pre_tool_use")]
|
||||
registry.register(sample_manifest, sample_executor, hooks=hooks)
|
||||
|
||||
registry.unregister("test_tool")
|
||||
|
||||
assert registry.get_hooks("test_tool") == []
|
||||
|
||||
|
||||
class TestToolRegistryClear:
|
||||
"""测试清空注册表"""
|
||||
|
||||
def test_clear(self, registry, sample_manifest, sample_executor):
|
||||
registry.register(sample_manifest, sample_executor)
|
||||
|
||||
manifest2 = ToolManifest(
|
||||
name="tool2",
|
||||
description="Another tool",
|
||||
category=ToolCategory.READ,
|
||||
parameters={"type": "object"},
|
||||
return_schema={"type": "string"},
|
||||
permission_class=PermissionClass.READ,
|
||||
side_effect_scope=SideEffectScope.NONE,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
registry.register(manifest2, sample_executor)
|
||||
|
||||
registry.clear()
|
||||
|
||||
assert len(registry) == 0
|
||||
|
||||
|
||||
class TestGlobalRegistry:
|
||||
"""测试全局注册表"""
|
||||
|
||||
def test_get_tool_registry_returns_singleton(self):
|
||||
reset_tool_registry()
|
||||
|
||||
reg1 = get_tool_registry()
|
||||
reg2 = get_tool_registry()
|
||||
|
||||
assert reg1 is reg2
|
||||
|
||||
def test_reset_tool_registry(self):
|
||||
reset_tool_registry()
|
||||
reg1 = get_tool_registry()
|
||||
|
||||
reset_tool_registry()
|
||||
reg2 = get_tool_registry()
|
||||
|
||||
# After reset, should get a new instance
|
||||
# (Note: this tests the reset function works)
|
||||
|
||||
|
||||
class TestToolManifest:
|
||||
"""测试 ToolManifest"""
|
||||
|
||||
def test_to_dict(self, sample_manifest):
|
||||
d = sample_manifest.to_dict()
|
||||
|
||||
assert d["name"] == "test_tool"
|
||||
assert d["description"] == "A test tool"
|
||||
assert d["category"] == "read"
|
||||
assert d["permission_class"] == "read"
|
||||
assert d["side_effect_scope"] == "none"
|
||||
assert d["requires_confirmation"] is False
|
||||
assert d["is_streaming"] is False
|
||||
assert d["tags"] == ["test", "sample"]
|
||||
|
||||
|
||||
class TestHookConfig:
|
||||
"""测试 HookConfig"""
|
||||
|
||||
def test_matches_tool_with_no_filter(self):
|
||||
hook = HookConfig(name="test", hook_type="pre_tool_use")
|
||||
|
||||
assert hook.matches_tool("any_tool") is True
|
||||
|
||||
def test_matches_tool_with_filter(self):
|
||||
hook = HookConfig(name="test", hook_type="pre_tool_use", filter_names=["tool_a", "tool_b"])
|
||||
|
||||
assert hook.matches_tool("tool_a") is True
|
||||
assert hook.matches_tool("tool_b") is True
|
||||
assert hook.matches_tool("tool_c") is False
|
||||
Reference in New Issue
Block a user