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:
2026-04-04 22:47:48 +08:00
parent a7b6b5eb90
commit e5bd492d74
16 changed files with 2541 additions and 2 deletions

View 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