mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
345 lines
13 KiB
Python
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"
|