Files
AstrBot/tests/unit/test_tool.py
2026-04-29 08:43:53 +08:00

345 lines
13 KiB
Python

"""Unit tests for astrbot.core.agent.tool: FunctionTool, ToolSchema, ToolSet."""
import pytest
from unittest.mock import MagicMock, AsyncMock
from jsonschema.exceptions import ValidationError
from astrbot.core.agent.tool import (
FunctionTool,
ToolSchema,
ToolSet,
ToolArgumentSpec,
ToolExecResult,
)
class TestToolSchema:
"""ToolSchema construction and validation."""
def test_tool_schema_minimal(self):
"""Construct with only name and description."""
schema = ToolSchema(name="test_tool", description="A test tool")
assert schema.name == "test_tool"
assert schema.description == "A test tool"
assert schema.parameters is None
assert schema.active is True
def test_tool_schema_with_parameters(self):
"""Construct with valid JSON Schema parameters."""
params = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"],
}
schema = ToolSchema(name="search", description="Search tool", parameters=params)
assert schema.name == "search"
assert schema.parameters == params
def test_tool_schema_invalid_parameters_raises(self):
"""Reject parameters that are not valid JSON Schema."""
with pytest.raises(ValidationError):
ToolSchema(
name="bad",
description="Bad schema",
parameters={"type": "nonexistent"},
)
def test_tool_schema_inactive(self):
"""Explicitly set active=False."""
schema = ToolSchema(name="off", description="Disabled", active=False)
assert schema.active is False
def test_tool_schema_parameters_none_allowed(self):
"""parameters=None is valid and passes validation."""
schema = ToolSchema(name="no_params", description="No parameters", parameters=None)
assert schema.parameters is None
def test_tool_schema_empty_parameters_object(self):
"""Empty object parameters are valid JSON Schema."""
schema = ToolSchema(
name="empty",
description="Empty params",
parameters={"type": "object", "properties": {}},
)
assert schema.parameters == {"type": "object", "properties": {}}
class TestFunctionTool:
"""FunctionTool construction and call interface."""
def test_function_tool_minimal(self):
"""Construct with minimal fields."""
tool = FunctionTool(name="echo", description="Echo tool")
assert tool.name == "echo"
assert tool.description == "Echo tool"
assert tool.handler is None
assert tool.active is True
assert tool.is_background_task is False
assert tool.source == "plugin"
def test_function_tool_with_handler(self):
"""Construct with an async handler."""
async def handler(context, **kwargs):
return "ok"
tool = FunctionTool(
name="greet",
description="Greet",
handler=handler,
handler_module_path="tests.test_tool",
)
assert tool.handler is handler
assert tool.handler_module_path == "tests.test_tool"
def test_function_tool_background_task(self):
"""Construct with is_background_task=True."""
tool = FunctionTool(
name="bg",
description="Background",
is_background_task=True,
)
assert tool.is_background_task is True
def test_function_tool_source_values(self):
"""Construct with different source values."""
for source in ("plugin", "internal", "mcp"):
tool = FunctionTool(
name=f"tool_{source}",
description=source,
source=source,
)
assert tool.source == source
def test_function_tool_repr(self):
"""__repr__ returns a meaningful string."""
tool = FunctionTool(name="sum", description="Sum numbers")
rep = repr(tool)
assert "FuncTool" in rep
assert "sum" in rep
def test_function_tool_call_not_implemented(self):
"""call() raises NotImplementedError when no handler is set."""
tool = FunctionTool(name="todo", description="Not implemented")
with pytest.raises(NotImplementedError, match="FunctionTool.call"):
import asyncio
asyncio.run(tool.call(MagicMock()))
def test_function_tool_with_parameters(self):
"""Construct with valid parameters."""
params = {
"type": "object",
"properties": {"x": {"type": "integer"}},
}
tool = FunctionTool(name="add", description="Add", parameters=params)
assert tool.parameters == params
def test_function_tool_active_false(self):
"""Construct with active=False."""
tool = FunctionTool(name="inactive_tool", description="Not active", active=False)
assert tool.active is False
def test_function_tool_inherits_validation(self):
"""FunctionTool inherits ToolSchema parameter validation."""
with pytest.raises(ValidationError):
FunctionTool(
name="bad",
description="Bad",
parameters={"type": "madeup"},
)
class TestToolSet:
"""ToolSet add/remove/get and serialization methods."""
def test_empty_toolset(self):
"""New ToolSet is empty."""
ts = ToolSet()
assert ts.empty() is True
assert len(ts) == 0
assert bool(ts) is False
def test_add_tool(self):
"""Add a tool increases length."""
ts = ToolSet()
tool = FunctionTool(name="a", description="Tool A")
ts.add_tool(tool)
assert len(ts) == 1
assert ts.empty() is False
assert bool(ts) is True
def test_add_duplicate_name_overwrites(self):
"""Adding a tool with same name replaces the existing one."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="dup", description="Original"))
ts.add_tool(FunctionTool(name="dup", description="Replacement"))
assert len(ts) == 1
tool = ts.get_tool("dup")
assert tool is not None
assert tool.description == "Replacement"
def test_add_duplicate_active_prefers_active(self):
"""When a duplicate is added, active=True wins over active=False."""
ts = ToolSet()
inactive = FunctionTool(name="x", description="Inactive", active=False)
active = FunctionTool(name="x", description="Active", active=True)
ts.add_tool(inactive)
ts.add_tool(active)
tool = ts.get_tool("x")
assert tool is not None
assert tool.description == "Active"
def test_add_duplicate_inactive_does_not_replace_active(self):
"""Adding an inactive tool with an already active name does not overwrite."""
ts = ToolSet()
active = FunctionTool(name="y", description="Active", active=True)
inactive = FunctionTool(name="y", description="Inactive", active=False)
ts.add_tool(active)
ts.add_tool(inactive)
tool = ts.get_tool("y")
assert tool is not None
assert tool.description == "Active"
def test_remove_tool(self):
"""Remove a tool by name."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="keep", description="Keep"))
ts.add_tool(FunctionTool(name="remove_me", description="Remove"))
ts.remove_tool("remove_me")
assert len(ts) == 1
assert ts.get_tool("keep") is not None
assert ts.get_tool("remove_me") is None
def test_remove_nonexistent_tool(self):
"""Removing a non-existent tool does not raise."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="a", description="A"))
ts.remove_tool("nonexistent")
assert len(ts) == 1
def test_get_tool_returns_none_for_non_functiontool(self):
"""get_tool returns None when a ToolSchema (not FunctionTool) exists with that name."""
ts = ToolSet()
schema = ToolSchema(name="plain", description="Plain schema")
ts.add_tool(schema)
assert ts.get_tool("plain") is None
def test_get_tool_returns_functiontool(self):
"""get_tool returns the FunctionTool when present."""
ts = ToolSet()
tool = FunctionTool(name="found", description="Found tool")
ts.add_tool(tool)
result = ts.get_tool("found")
assert result is tool
def test_normalize_sorts_by_name(self):
"""normalize() sorts tools alphabetically by name."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="z", description="Z"))
ts.add_tool(FunctionTool(name="a", description="A"))
ts.add_tool(FunctionTool(name="m", description="M"))
ts.normalize()
assert [t.name for t in ts.tools] == ["a", "m", "z"]
def test_names(self):
"""names() returns list of tool names."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="a", description="A"))
ts.add_tool(FunctionTool(name="b", description="B"))
assert ts.names() == ["a", "b"]
def test_func_list(self):
"""func_list only includes FunctionTool instances."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="func", description="Func"))
ts.add_tool(ToolSchema(name="plain", description="Plain"))
assert len(ts.func_list) == 1
assert ts.func_list[0].name == "func"
def test_merge(self):
"""merge() combines tools from another ToolSet."""
ts1 = ToolSet()
ts1.add_tool(FunctionTool(name="a", description="A"))
ts2 = ToolSet()
ts2.add_tool(FunctionTool(name="b", description="B"))
ts1.merge(ts2)
assert len(ts1) == 2
def test_iteration(self):
"""ToolSet is iterable and yields ToolSchema items."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="a", description="A"))
ts.add_tool(FunctionTool(name="b", description="B"))
names = [t.name for t in ts]
assert names == ["a", "b"]
def test_get_light_tool_set(self):
"""get_light_tool_set returns tools with empty parameters and no handler."""
params = {
"type": "object",
"properties": {"q": {"type": "string"}},
}
ts = ToolSet()
ts.add_tool(FunctionTool(
name="search", description="Search", parameters=params,
handler=AsyncMock(),
))
light = ts.get_light_tool_set()
assert len(light) == 1
light_tool = light.get_tool("search")
assert light_tool is not None
assert light_tool.description == "Search"
assert light_tool.parameters == {"type": "object", "properties": {}}
assert light_tool.handler is None
def test_get_light_tool_skips_inactive(self):
"""get_light_tool_set skips inactive tools."""
ts = ToolSet()
ts.add_tool(FunctionTool(name="active", description="Active", active=True))
ts.add_tool(FunctionTool(name="inactive", description="Inactive", active=False))
light = ts.get_light_tool_set()
assert light.names() == ["active"]
def test_get_param_only_tool_set(self):
"""get_param_only_tool_set returns tools with empty description."""
params = {
"type": "object",
"properties": {"x": {"type": "integer"}},
}
ts = ToolSet()
ts.add_tool(FunctionTool(name="calc", description="Calc", parameters=params))
param_only = ts.get_param_only_tool_set()
assert len(param_only) == 1
tool = param_only.get_tool("calc")
assert tool is not None
assert tool.description == ""
assert tool.parameters == params
def test_anthropic_schema(self):
"""anthropic_schema returns Anthropic-compatible format."""
ts = ToolSet()
params = {
"type": "object",
"properties": {"x": {"type": "integer"}},
"required": ["x"],
}
ts.add_tool(FunctionTool(name="add", description="Add", parameters=params))
schema = ts.anthropic_schema()
assert len(schema) == 1
assert schema[0]["name"] == "add"
assert schema[0]["description"] == "Add"
assert schema[0]["input_schema"]["properties"] == {"x": {"type": "integer"}}
assert schema[0]["input_schema"]["required"] == ["x"]
def test_google_schema(self):
"""google_schema returns Google GenAI-compatible format."""
ts = ToolSet()
params = {
"type": "object",
"properties": {"val": {"type": "number", "description": "A value"}},
}
ts.add_tool(FunctionTool(name="sqrt", description="Square root", parameters=params))
schema = ts.google_schema()
assert "function_declarations" in schema
assert schema["function_declarations"][0]["name"] == "sqrt"