mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
803 lines
30 KiB
Python
803 lines
30 KiB
Python
"""Tests for astrbot._internal.tools.base module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from astrbot._internal.tools.base import (
|
|
FunctionTool,
|
|
ToolSchema,
|
|
ToolSet,
|
|
)
|
|
|
|
# =============================================================================
|
|
# ToolSchema Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestToolSchema:
|
|
"""Test suite for ToolSchema."""
|
|
|
|
def test_valid_parameters_schema(self):
|
|
"""Valid JSON Schema parameters should pass validation."""
|
|
schema = ToolSchema(
|
|
name="test_tool",
|
|
description="A test tool",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"arg": {"type": "string", "description": "An argument"}},
|
|
"required": ["arg"],
|
|
},
|
|
)
|
|
assert schema.name == "test_tool"
|
|
assert schema.description == "A test tool"
|
|
assert schema.parameters["type"] == "object"
|
|
|
|
def test_empty_parameters(self):
|
|
"""Empty parameters dict should be valid."""
|
|
schema = ToolSchema(name="test", description="test", parameters={})
|
|
assert schema.parameters == {}
|
|
|
|
def test_invalid_parameters_no_op(self):
|
|
"""NOTE: ToolSchema is a plain @dataclass, not a Pydantic BaseModel.
|
|
The @model_validator decorator has no effect, so validation is dead code.
|
|
This test documents the current (broken) behavior for coverage.
|
|
"""
|
|
# This creates successfully because model_validator is a no-op on plain dataclass
|
|
schema = ToolSchema(
|
|
name="test",
|
|
description="test",
|
|
parameters={"type": "invalid_type_not_real"},
|
|
)
|
|
assert schema.parameters == {"type": "invalid_type_not_real"}
|
|
"""Parameters without type field should still be valid since jsonschema validates structure."""
|
|
# Actually this should be valid - jsonschema validates the schema itself
|
|
schema = ToolSchema(
|
|
name="test",
|
|
description="test",
|
|
parameters={"type": "string"},
|
|
)
|
|
assert schema.parameters["type"] == "string"
|
|
|
|
|
|
# =============================================================================
|
|
# FunctionTool Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestFunctionTool:
|
|
"""Test suite for FunctionTool."""
|
|
|
|
def test_basic_function_tool(self):
|
|
"""Basic tool creation with name, description, parameters."""
|
|
tool = FunctionTool(
|
|
name="my_tool",
|
|
description="Does something useful",
|
|
parameters={"type": "object", "properties": {}},
|
|
)
|
|
assert tool.name == "my_tool"
|
|
assert tool.description == "Does something useful"
|
|
assert tool.active is True
|
|
assert tool.is_background_task is False
|
|
assert tool.source == "mcp"
|
|
|
|
def test_function_tool_with_handler(self):
|
|
"""Tool with an async handler."""
|
|
handler = AsyncMock(return_value="result")
|
|
|
|
async def async_gen(**kwargs):
|
|
yield "chunk1"
|
|
yield "chunk2"
|
|
|
|
tool = FunctionTool(
|
|
name="handler_tool",
|
|
description="Tool with handler",
|
|
parameters={},
|
|
handler=handler,
|
|
)
|
|
assert tool.handler is handler
|
|
|
|
def test_function_tool_with_handler_module_path(self):
|
|
"""Tool preserves handler_module_path."""
|
|
tool = FunctionTool(
|
|
name="path_tool",
|
|
description="Tool with module path",
|
|
parameters={},
|
|
handler_module_path="mymodule.myfunction",
|
|
)
|
|
assert tool.handler_module_path == "mymodule.myfunction"
|
|
|
|
def test_function_tool_active_flag(self):
|
|
"""Active flag can be set to False."""
|
|
tool = FunctionTool(
|
|
name="inactive",
|
|
description="Not active",
|
|
parameters={},
|
|
active=False,
|
|
)
|
|
assert tool.active is False
|
|
|
|
def test_function_tool_background_task_flag(self):
|
|
"""Background task flag can be set."""
|
|
tool = FunctionTool(
|
|
name="background",
|
|
description="Background task",
|
|
parameters={},
|
|
is_background_task=True,
|
|
)
|
|
assert tool.is_background_task is True
|
|
|
|
def test_function_tool_source_defaults_to_mcp(self):
|
|
"""Source defaults to 'mcp'."""
|
|
tool = FunctionTool(name="t", description="t", parameters={})
|
|
assert tool.source == "mcp"
|
|
|
|
def test_function_tool_source_can_be_plugin_or_internal(self):
|
|
"""Source can be 'plugin' or 'internal'."""
|
|
plugin_tool = FunctionTool(
|
|
name="p", description="p", parameters={}, source="plugin"
|
|
)
|
|
internal_tool = FunctionTool(
|
|
name="i", description="i", parameters={}, source="internal"
|
|
)
|
|
assert plugin_tool.source == "plugin"
|
|
assert internal_tool.source == "internal"
|
|
|
|
def test_function_tool_repr(self):
|
|
"""__repr__ returns correct string."""
|
|
tool = FunctionTool(
|
|
name="repr_tool",
|
|
description="For repr test",
|
|
parameters={"type": "object"},
|
|
)
|
|
r = repr(tool)
|
|
assert "repr_tool" in r
|
|
assert "parameters" in r
|
|
assert "repr test" in r
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_raises_not_implemented(self):
|
|
"""call() without handler raises NotImplementedError."""
|
|
tool = FunctionTool(name="t", description="t", parameters={})
|
|
with pytest.raises(NotImplementedError, match="must be implemented"):
|
|
await tool.call(arg="value")
|
|
|
|
|
|
# =============================================================================
|
|
# ToolSet Tests
|
|
# =============================================================================
|
|
|
|
|
|
class TestToolSetConstruction:
|
|
"""Test ToolSet construction and basic operations."""
|
|
|
|
def test_empty_toolset(self):
|
|
"""Empty ToolSet with namespace."""
|
|
ts = ToolSet("my_namespace")
|
|
assert ts.namespace == "my_namespace"
|
|
assert len(ts) == 0
|
|
assert ts.empty()
|
|
|
|
def test_toolset_from_list(self):
|
|
"""ToolSet initialized with a list of tools."""
|
|
tool1 = FunctionTool(name="tool1", description="First", parameters={})
|
|
tool2 = FunctionTool(name="tool2", description="Second", parameters={})
|
|
ts = ToolSet("ns", [tool1, tool2])
|
|
assert len(ts) == 2
|
|
assert not ts.empty()
|
|
|
|
def test_toolset_with_duplicate_names(self):
|
|
"""Last tool with same name overwrites earlier one."""
|
|
tool1 = FunctionTool(name="dup", description="First", parameters={})
|
|
tool2 = FunctionTool(name="dup", description="Second", parameters={})
|
|
ts = ToolSet("ns", [tool1, tool2])
|
|
assert len(ts) == 1
|
|
assert ts.get("dup").description == "Second"
|
|
|
|
|
|
class TestToolSetAddRemove:
|
|
"""Test ToolSet add/remove operations."""
|
|
|
|
def test_add_tool(self):
|
|
"""add() puts tool in set."""
|
|
ts = ToolSet("ns")
|
|
tool = FunctionTool(name="add_test", description="Add test", parameters={})
|
|
ts.add(tool)
|
|
assert ts.get("add_test") is tool
|
|
|
|
def test_add_tool_alias(self):
|
|
"""add_tool() is alias for add()."""
|
|
ts = ToolSet("ns")
|
|
tool = FunctionTool(name="alias_test", description="Alias test", parameters={})
|
|
ts.add_tool(tool)
|
|
assert ts.get("alias_test") is tool
|
|
|
|
def test_remove_tool(self):
|
|
"""remove_tool() removes by name (void return)."""
|
|
ts = ToolSet("ns")
|
|
tool = FunctionTool(name="remove_me", description="Remove me", parameters={})
|
|
ts.add(tool)
|
|
ts.remove_tool("remove_me")
|
|
assert ts.get("remove_me") is None
|
|
|
|
def test_remove_method(self):
|
|
"""remove() removes and returns tool."""
|
|
ts = ToolSet("ns")
|
|
tool = FunctionTool(name="return_me", description="Return me", parameters={})
|
|
ts.add(tool)
|
|
result = ts.remove("return_me")
|
|
assert result is tool
|
|
assert ts.get("return_me") is None
|
|
|
|
def test_remove_nonexistent(self):
|
|
"""remove() returns None for missing name."""
|
|
ts = ToolSet("ns")
|
|
result = ts.remove("does_not_exist")
|
|
assert result is None
|
|
|
|
def test_get_tool_alias(self):
|
|
"""get_tool() is alias for get()."""
|
|
ts = ToolSet("ns")
|
|
tool = FunctionTool(name="get_alias", description="Get alias", parameters={})
|
|
ts.add(tool)
|
|
assert ts.get_tool("get_alias") is tool
|
|
|
|
|
|
class TestToolSetIteration:
|
|
"""Test ToolSet iteration and length."""
|
|
|
|
def test_len(self):
|
|
"""__len__ returns count."""
|
|
ts = ToolSet("ns")
|
|
assert len(ts) == 0
|
|
ts.add(FunctionTool(name="a", description="a", parameters={}))
|
|
assert len(ts) == 1
|
|
ts.add(FunctionTool(name="b", description="b", parameters={}))
|
|
assert len(ts) == 2
|
|
|
|
def test_bool_true_when_has_tools(self):
|
|
"""__bool__ is True when tools exist."""
|
|
ts = ToolSet("ns")
|
|
assert not ts
|
|
ts.add(FunctionTool(name="x", description="x", parameters={}))
|
|
assert ts
|
|
|
|
def test_iter(self):
|
|
"""__iter__ yields tools."""
|
|
tool1 = FunctionTool(name="iter1", description="Iter 1", parameters={})
|
|
tool2 = FunctionTool(name="iter2", description="Iter 2", parameters={})
|
|
ts = ToolSet("ns", [tool1, tool2])
|
|
tools = list(ts)
|
|
assert tool1 in tools
|
|
assert tool2 in tools
|
|
|
|
def test_list_tools(self):
|
|
"""list_tools() returns all tools."""
|
|
tool1 = FunctionTool(name="list1", description="List 1", parameters={})
|
|
tool2 = FunctionTool(name="list2", description="List 2", parameters={})
|
|
ts = ToolSet("ns", [tool1, tool2])
|
|
assert len(ts.list_tools()) == 2
|
|
|
|
def test_tools_property(self):
|
|
"""tools property returns list of tools."""
|
|
tool = FunctionTool(name="prop", description="Prop", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
assert tool in ts.tools
|
|
|
|
def test_names(self):
|
|
"""names() returns list of tool names."""
|
|
tool1 = FunctionTool(name="alpha", description="Alpha", parameters={})
|
|
tool2 = FunctionTool(name="beta", description="Beta", parameters={})
|
|
ts = ToolSet("ns", [tool1, tool2])
|
|
assert set(ts.names()) == {"alpha", "beta"}
|
|
|
|
def test_empty_method(self):
|
|
"""empty() returns True when no tools."""
|
|
ts = ToolSet("ns")
|
|
assert ts.empty()
|
|
ts.add(FunctionTool(name="y", description="y", parameters={}))
|
|
assert not ts.empty()
|
|
|
|
|
|
class TestToolSetRepr:
|
|
"""Test ToolSet string representations."""
|
|
|
|
def test_repr(self):
|
|
"""__repr__ includes namespace and tools."""
|
|
tool = FunctionTool(name="repr_t", description="R", parameters={})
|
|
ts = ToolSet("repr_ns", [tool])
|
|
r = repr(ts)
|
|
assert "repr_ns" in r
|
|
assert "repr_t" in r
|
|
|
|
def test_str(self):
|
|
"""__str__ includes namespace and count."""
|
|
ts = ToolSet("str_ns")
|
|
assert "str_ns" in str(ts)
|
|
assert "0 tools" in str(ts)
|
|
ts.add(FunctionTool(name="s", description="s", parameters={}))
|
|
assert "1 tools" in str(ts)
|
|
|
|
|
|
class TestToolSetMerge:
|
|
"""Test ToolSet merge and normalize."""
|
|
|
|
def test_merge(self):
|
|
"""merge() adds all tools from another ToolSet."""
|
|
ts1 = ToolSet("ns1")
|
|
ts1.add(FunctionTool(name="keep", description="Keep", parameters={}))
|
|
ts2 = ToolSet("ns2")
|
|
ts2.add(FunctionTool(name="added", description="Added", parameters={}))
|
|
ts1.merge(ts2)
|
|
assert ts1.get("keep") is not None
|
|
assert ts1.get("added") is not None
|
|
assert len(ts1) == 2
|
|
|
|
def test_merge_overwrites_duplicate(self):
|
|
"""merge() overwrites tools with same name."""
|
|
ts1 = ToolSet("ns1")
|
|
ts1.add(FunctionTool(name="dup", description="Original", parameters={}))
|
|
ts2 = ToolSet("ns2")
|
|
ts2.add(FunctionTool(name="dup", description="Merged", parameters={}))
|
|
ts1.merge(ts2)
|
|
assert ts1.get("dup").description == "Merged"
|
|
|
|
def test_normalize_sorts_by_name(self):
|
|
"""normalize() sorts tools by name for deterministic output."""
|
|
tool_c = FunctionTool(name="charlie", description="C", parameters={})
|
|
tool_a = FunctionTool(name="alpha", description="A", parameters={})
|
|
tool_b = FunctionTool(name="bravo", description="B", parameters={})
|
|
ts = ToolSet("ns", [tool_c, tool_a, tool_b])
|
|
ts.normalize()
|
|
names = list(ts._tools.keys())
|
|
assert names == ["alpha", "bravo", "charlie"]
|
|
|
|
|
|
class TestToolSetLightToolSet:
|
|
"""Test get_light_tool_set()."""
|
|
|
|
def test_light_tool_set_excludes_inactive(self):
|
|
"""Inactive tools are excluded."""
|
|
active = FunctionTool(
|
|
name="active", description="Active", parameters={}, active=True
|
|
)
|
|
inactive = FunctionTool(
|
|
name="inactive", description="Inactive", parameters={}, active=False
|
|
)
|
|
ts = ToolSet("ns", [active, inactive])
|
|
light = ts.get_light_tool_set()
|
|
assert light.get("active") is not None
|
|
assert light.get("inactive") is None
|
|
|
|
def test_light_tool_set_preserves_name_and_description(self):
|
|
"""Light tool set has name/description only."""
|
|
tool = FunctionTool(
|
|
name="light_test",
|
|
description="Original description",
|
|
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
light = ts.get_light_tool_set()
|
|
light_tool = light.get("light_test")
|
|
assert light_tool.name == "light_test"
|
|
assert light_tool.description == "Original description"
|
|
assert light_tool.parameters == {"type": "object", "properties": {}}
|
|
|
|
def test_light_tool_set_has_empty_handler(self):
|
|
"""Light tools have handler=None."""
|
|
tool = FunctionTool(name="lh", description="LH", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
light = ts.get_light_tool_set()
|
|
assert light.get("lh").handler is None
|
|
|
|
|
|
class TestToolSetParamOnlyToolSet:
|
|
"""Test get_param_only_tool_set()."""
|
|
|
|
def test_param_only_excludes_inactive(self):
|
|
"""Inactive tools are excluded."""
|
|
active = FunctionTool(name="a", description="A", parameters={}, active=True)
|
|
inactive = FunctionTool(name="i", description="I", parameters={}, active=False)
|
|
ts = ToolSet("ns", [active, inactive])
|
|
param = ts.get_param_only_tool_set()
|
|
assert param.get("a") is not None
|
|
assert param.get("i") is None
|
|
|
|
def test_param_only_preserves_parameters(self):
|
|
"""Parameters are deep copied."""
|
|
tool = FunctionTool(
|
|
name="param_test",
|
|
description="Keep this",
|
|
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
param = ts.get_param_only_tool_set()
|
|
param_tool = param.get("param_test")
|
|
assert param_tool.parameters == {
|
|
"type": "object",
|
|
"properties": {"x": {"type": "integer"}},
|
|
}
|
|
assert param_tool.description == ""
|
|
|
|
def test_param_only_empty_parameters_defaults(self):
|
|
"""Tools with no parameters get empty object schema."""
|
|
tool = FunctionTool(name="no_params", description="No params", parameters=None)
|
|
ts = ToolSet("ns", [tool])
|
|
param = ts.get_param_only_tool_set()
|
|
assert param.get("no_params").parameters == {"type": "object", "properties": {}}
|
|
|
|
|
|
# =============================================================================
|
|
# ToolSet Schema Tests - OpenAI
|
|
# =============================================================================
|
|
|
|
|
|
class TestToolSetOpenAISchema:
|
|
"""Test openai_schema()."""
|
|
|
|
def test_empty_toolset(self):
|
|
"""Empty toolset returns empty list."""
|
|
ts = ToolSet("ns")
|
|
assert ts.openai_schema() == []
|
|
|
|
def test_basic_openai_schema(self):
|
|
"""Basic tool converts to OpenAI format."""
|
|
tool = FunctionTool(
|
|
name="openai_tool",
|
|
description="An OpenAI tool",
|
|
parameters={"type": "object", "properties": {}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.openai_schema()
|
|
assert len(schema) == 1
|
|
assert schema[0]["type"] == "function"
|
|
assert schema[0]["function"]["name"] == "openai_tool"
|
|
assert schema[0]["function"]["description"] == "An OpenAI tool"
|
|
assert "parameters" in schema[0]["function"]
|
|
|
|
def test_openai_schema_no_description(self):
|
|
"""Tool without description omits description field."""
|
|
tool = FunctionTool(name="nodesc", description="", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.openai_schema()
|
|
assert "description" not in schema[0]["function"]
|
|
|
|
def test_openai_schema_omit_empty_parameters_true(self):
|
|
"""omit_empty_parameter_field=True removes empty parameters."""
|
|
tool = FunctionTool(
|
|
name="omit_empty",
|
|
description="Test",
|
|
parameters={"type": "object", "properties": {}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.openai_schema(omit_empty_parameter_field=True)
|
|
assert "parameters" not in schema[0]["function"]
|
|
|
|
def test_openai_schema_omit_empty_with_properties(self):
|
|
"""omit_empty=True but has properties -> keeps parameters."""
|
|
tool = FunctionTool(
|
|
name="keep_params",
|
|
description="Test",
|
|
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.openai_schema(omit_empty_parameter_field=True)
|
|
assert "parameters" in schema[0]["function"]
|
|
|
|
def test_openai_schema_null_parameters(self):
|
|
"""Tool with parameters=None skips parameters field."""
|
|
tool = FunctionTool(name="null_params", description="Test", parameters=None)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.openai_schema()
|
|
# Since parameters is None, tool.parameters is None, so the condition
|
|
# tool.parameters is not None is False, and omit_empty is False by default
|
|
# so parameters should not be in the output
|
|
assert "parameters" not in schema[0]["function"]
|
|
|
|
|
|
# =============================================================================
|
|
# ToolSet Schema Tests - Anthropic
|
|
# =============================================================================
|
|
|
|
|
|
class TestToolSetAnthropicSchema:
|
|
"""Test anthropic_schema()."""
|
|
|
|
def test_empty_toolset(self):
|
|
"""Empty toolset returns empty list."""
|
|
ts = ToolSet("ns")
|
|
assert ts.anthropic_schema() == []
|
|
|
|
def test_basic_anthropic_schema(self):
|
|
"""Basic tool converts to Anthropic format."""
|
|
tool = FunctionTool(
|
|
name="anthropic_tool",
|
|
description="An Anthropic tool",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.anthropic_schema()
|
|
assert len(schema) == 1
|
|
assert schema[0]["name"] == "anthropic_tool"
|
|
assert schema[0]["description"] == "An Anthropic tool"
|
|
assert schema[0]["input_schema"]["properties"] == {"query": {"type": "string"}}
|
|
assert schema[0]["input_schema"]["required"] == ["query"]
|
|
|
|
def test_anthropic_schema_no_parameters(self):
|
|
"""Tool with no parameters gets empty input_schema."""
|
|
tool = FunctionTool(name="no_params", description="No params", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.anthropic_schema()
|
|
assert schema[0]["input_schema"] == {"type": "object"}
|
|
|
|
def test_anthropic_schema_no_description(self):
|
|
"""Tool without description omits description field."""
|
|
tool = FunctionTool(name="nodesc", description="", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.anthropic_schema()
|
|
assert "description" not in schema[0]
|
|
|
|
|
|
# =============================================================================
|
|
# ToolSet Schema Tests - Google GenAI
|
|
# =============================================================================
|
|
|
|
|
|
class TestToolSetGoogleSchema:
|
|
"""Test google_schema()."""
|
|
|
|
def test_empty_toolset(self):
|
|
"""Empty toolset returns empty declarations."""
|
|
ts = ToolSet("ns")
|
|
assert ts.google_schema() == {}
|
|
|
|
def test_basic_google_schema(self):
|
|
"""Basic tool converts to Google format."""
|
|
tool = FunctionTool(
|
|
name="google_tool",
|
|
description="A Google tool",
|
|
parameters={"type": "object", "properties": {}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
assert "function_declarations" in schema
|
|
assert len(schema["function_declarations"]) == 1
|
|
decl = schema["function_declarations"][0]
|
|
assert decl["name"] == "google_tool"
|
|
assert decl["description"] == "A Google tool"
|
|
|
|
def test_google_convert_any_of(self):
|
|
"""anyOf schemas are recursively converted."""
|
|
tool = FunctionTool(
|
|
name="anyof_tool",
|
|
description="AnyOf test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {
|
|
"anyOf": [
|
|
{"type": "string"},
|
|
{"type": "integer"},
|
|
]
|
|
}
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert "anyOf" in props["value"]
|
|
assert len(props["value"]["anyOf"]) == 2
|
|
|
|
def test_google_convert_array_with_items(self):
|
|
"""Array types with items dict are converted."""
|
|
tool = FunctionTool(
|
|
name="array_tool",
|
|
description="Array test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
}
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["tags"]["type"] == "array"
|
|
assert props["tags"]["items"] == {"type": "string"}
|
|
|
|
def test_google_convert_array_with_non_dict_items(self):
|
|
"""Array types with non-dict items default to string."""
|
|
tool = FunctionTool(
|
|
name="array_tool2",
|
|
description="Array test 2",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"items": {"type": "array", "items": "not_a_dict"}},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["items"]["items"] == {"type": "string"}
|
|
|
|
def test_google_unsupported_type_becomes_null(self):
|
|
"""Unsupported types become 'null'."""
|
|
tool = FunctionTool(
|
|
name="unsupported",
|
|
description="Unsupported type",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"unknown": {"type": "unsupported_type_xyz"}},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["unknown"]["type"] == "null"
|
|
|
|
def test_google_type_list_picks_non_null(self):
|
|
"""Type list like ['string', 'null'] picks 'string'."""
|
|
tool = FunctionTool(
|
|
name="nullable_str",
|
|
description="Nullable string",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"name": {"type": ["string", "null"]}},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["name"]["type"] == "string"
|
|
|
|
def test_google_removes_default_and_additional_props(self):
|
|
"""default and additionalProperties are removed during conversion.
|
|
These fields survive convert_schema via support_fields (e.g. via 'description').
|
|
"""
|
|
tool = FunctionTool(
|
|
name="cleanup",
|
|
description="Cleanup test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"field": {
|
|
"type": "string",
|
|
"description": "A field with default",
|
|
"default": "foo",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
field = schema["function_declarations"][0]["parameters"]["properties"]["field"]
|
|
# description should be preserved, default should be removed
|
|
assert field.get("description") == "A field with default"
|
|
assert "default" not in field
|
|
|
|
def test_google_supported_fields_preserved(self):
|
|
"""Supported fields like enum, minimum, maximum are preserved."""
|
|
tool = FunctionTool(
|
|
name="fields",
|
|
description="Fields test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"status": {
|
|
"type": "string",
|
|
"enum": ["active", "inactive"],
|
|
"description": "Status field",
|
|
},
|
|
"count": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
},
|
|
"items": {
|
|
"type": "array",
|
|
"maxItems": 10,
|
|
"minItems": 1,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["status"]["enum"] == ["active", "inactive"]
|
|
assert props["status"]["description"] == "Status field"
|
|
assert props["count"]["minimum"] == 0
|
|
assert props["count"]["maximum"] == 100
|
|
assert props["items"]["maxItems"] == 10
|
|
assert props["items"]["minItems"] == 1
|
|
|
|
def test_google_format_fields(self):
|
|
"""Format fields are preserved for supported types."""
|
|
tool = FunctionTool(
|
|
name="format_test",
|
|
description="Format test",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"dt": {"type": "string", "format": "date-time"},
|
|
"enum_val": {"type": "string", "format": "enum"},
|
|
"int32": {"type": "integer", "format": "int32"},
|
|
"int64": {"type": "integer", "format": "int64"},
|
|
"float_val": {"type": "number", "format": "float"},
|
|
"double_val": {"type": "number", "format": "double"},
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert props["dt"]["format"] == "date-time"
|
|
assert props["int32"]["format"] == "int32"
|
|
assert props["int64"]["format"] == "int64"
|
|
assert props["float_val"]["format"] == "float"
|
|
assert props["double_val"]["format"] == "double"
|
|
|
|
def test_google_unsupported_format_ignored(self):
|
|
"""Format not in supported list is ignored."""
|
|
tool = FunctionTool(
|
|
name="bad_format",
|
|
description="Bad format",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"bad": {"type": "string", "format": "unsupported-format-xyz"}
|
|
},
|
|
},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
schema = ts.google_schema()
|
|
props = schema["function_declarations"][0]["parameters"]["properties"]
|
|
assert "format" not in props["bad"]
|
|
|
|
|
|
class TestToolSetDeprecatedSchemaMethods:
|
|
"""Test deprecated schema convenience methods."""
|
|
|
|
def test_get_func_desc_openai_style(self):
|
|
"""get_func_desc_openai_style returns same as openai_schema."""
|
|
tool = FunctionTool(name="dep_openai", description="Deprecated", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
assert ts.get_func_desc_openai_style() == ts.openai_schema()
|
|
|
|
def test_get_func_desc_openai_style_with_flag(self):
|
|
"""get_func_desc_openai_style passes omit_empty flag."""
|
|
tool = FunctionTool(
|
|
name="dep_omit",
|
|
description="Omit",
|
|
parameters={"type": "object", "properties": {}},
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
assert ts.get_func_desc_openai_style(
|
|
omit_empty_parameter_field=True
|
|
) == ts.openai_schema(omit_empty_parameter_field=True)
|
|
|
|
def test_get_func_desc_anthropic_style(self):
|
|
"""get_func_desc_anthropic_style returns same as anthropic_schema."""
|
|
tool = FunctionTool(
|
|
name="dep_anthropic", description="Anthropic", parameters={}
|
|
)
|
|
ts = ToolSet("ns", [tool])
|
|
assert ts.get_func_desc_anthropic_style() == ts.anthropic_schema()
|
|
|
|
def test_get_func_desc_google_genai_style(self):
|
|
"""get_func_desc_google_genai_style returns same as google_schema."""
|
|
tool = FunctionTool(name="dep_google", description="Google", parameters={})
|
|
ts = ToolSet("ns", [tool])
|
|
assert ts.get_func_desc_google_genai_style() == ts.google_schema()
|