forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
"""Tests for Moonshot/Kimi flavored-JSON-Schema sanitizer.
|
||||
|
||||
Moonshot's tool-parameter validator rejects several shapes that the rest of
|
||||
the JSON Schema ecosystem accepts:
|
||||
|
||||
1. Properties without ``type`` — Moonshot requires ``type`` on every node.
|
||||
2. ``type`` at the parent of ``anyOf`` — Moonshot requires it only inside
|
||||
``anyOf`` children.
|
||||
|
||||
These tests cover the repairs applied by ``agent/moonshot_schema.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.moonshot_schema import (
|
||||
is_moonshot_model,
|
||||
sanitize_moonshot_tool_parameters,
|
||||
sanitize_moonshot_tools,
|
||||
)
|
||||
|
||||
|
||||
class TestMoonshotModelDetection:
|
||||
"""is_moonshot_model() must match across aggregator prefixes."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"kimi-k2.6",
|
||||
"kimi-k2-thinking",
|
||||
"moonshotai/Kimi-K2.6",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"nous/moonshotai/kimi-k2.6",
|
||||
"openrouter/moonshotai/kimi-k2-thinking",
|
||||
"MOONSHOTAI/KIMI-K2.6",
|
||||
],
|
||||
)
|
||||
def test_positive_matches(self, model):
|
||||
assert is_moonshot_model(model) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"",
|
||||
None,
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"openai/gpt-5.4",
|
||||
"google/gemini-3-flash-preview",
|
||||
"deepseek-chat",
|
||||
],
|
||||
)
|
||||
def test_negative_matches(self, model):
|
||||
assert is_moonshot_model(model) is False
|
||||
|
||||
|
||||
class TestMissingTypeFilled:
|
||||
"""Rule 1: every property must carry a type."""
|
||||
|
||||
def test_property_without_type_gets_string(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"query": {"description": "a bare property"}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["query"]["type"] == "string"
|
||||
|
||||
def test_property_with_enum_infers_type_from_first_value(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"flag": {"enum": [True, False]}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["flag"]["type"] == "boolean"
|
||||
|
||||
def test_nested_properties_are_repaired(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"field": {"description": "no type"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["filter"]["properties"]["field"]["type"] == "string"
|
||||
|
||||
def test_array_items_without_type_get_repaired(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"description": "tag entry"},
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["tags"]["items"]["type"] == "string"
|
||||
|
||||
def test_ref_node_is_not_given_synthetic_type(self):
|
||||
"""$ref nodes should NOT get a synthetic type — the referenced
|
||||
definition supplies it, and Moonshot would reject the conflict."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"payload": {"$ref": "#/$defs/Payload"}},
|
||||
"$defs": {"Payload": {"type": "object", "properties": {}}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert "type" not in out["properties"]["payload"]
|
||||
assert out["properties"]["payload"]["$ref"] == "#/$defs/Payload"
|
||||
|
||||
|
||||
class TestAnyOfParentType:
|
||||
"""Rule 2: type must not appear at the anyOf parent level.
|
||||
|
||||
When an anyOf contains a null-type branch, Moonshot rejects it.
|
||||
The sanitizer collapses the anyOf: single non-null branch is promoted,
|
||||
multiple non-null branches have null removed from the list.
|
||||
"""
|
||||
|
||||
def test_anyof_null_branch_collapsed_to_single_type(self):
|
||||
"""anyOf [string, null] → plain string (anyOf removed)."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_format": {
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
from_format = out["properties"]["from_format"]
|
||||
# null branch removed, anyOf collapsed to the single non-null type
|
||||
assert "anyOf" not in from_format
|
||||
assert from_format["type"] == "string"
|
||||
|
||||
def test_anyof_multiple_non_null_preserved(self):
|
||||
"""anyOf [string, integer] (no null) → kept as-is with parent type stripped."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
mode = out["properties"]["mode"]
|
||||
assert "anyOf" in mode
|
||||
assert "type" not in mode # parent type stripped
|
||||
|
||||
def test_anyof_enum_with_null_collapsed(self):
|
||||
"""anyOf [{enum: [...], type: string}, {type: null}] → enum + type only."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"db_type": {
|
||||
"anyOf": [
|
||||
{"enum": ["mysql", "postgresql", ""]},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
db_type = out["properties"]["db_type"]
|
||||
assert "anyOf" not in db_type
|
||||
assert db_type["type"] == "string"
|
||||
assert db_type["enum"] == ["mysql", "postgresql"] # "" stripped by enum cleanup
|
||||
|
||||
|
||||
class TestTopLevelGuarantees:
|
||||
"""The returned top-level schema is always a well-formed object."""
|
||||
|
||||
def test_non_dict_input_returns_empty_object(self):
|
||||
assert sanitize_moonshot_tool_parameters(None) == {"type": "object", "properties": {}}
|
||||
assert sanitize_moonshot_tool_parameters("garbage") == {"type": "object", "properties": {}}
|
||||
assert sanitize_moonshot_tool_parameters([]) == {"type": "object", "properties": {}}
|
||||
|
||||
def test_non_object_top_level_coerced(self):
|
||||
params = {"type": "string"}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["type"] == "object"
|
||||
assert "properties" in out
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"description": "no type"}},
|
||||
}
|
||||
snapshot = {
|
||||
"type": params["type"],
|
||||
"properties": {"q": dict(params["properties"]["q"])},
|
||||
}
|
||||
sanitize_moonshot_tool_parameters(params)
|
||||
assert params["type"] == snapshot["type"]
|
||||
assert "type" not in params["properties"]["q"]
|
||||
|
||||
|
||||
class TestToolListSanitizer:
|
||||
"""sanitize_moonshot_tools() walks an OpenAI-format tool list."""
|
||||
|
||||
def test_applies_per_tool(self):
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"description": "query"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "noop",
|
||||
"description": "Does nothing",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
out = sanitize_moonshot_tools(tools)
|
||||
assert out[0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
|
||||
# Second tool already clean — should be structurally equivalent
|
||||
assert out[1]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
def test_empty_list_is_passthrough(self):
|
||||
assert sanitize_moonshot_tools([]) == []
|
||||
assert sanitize_moonshot_tools(None) is None
|
||||
|
||||
def test_skips_malformed_entries(self):
|
||||
"""Entries without a function dict are passed through untouched."""
|
||||
tools = [{"type": "function"}, {"not": "a tool"}]
|
||||
out = sanitize_moonshot_tools(tools)
|
||||
assert out == tools
|
||||
|
||||
|
||||
class TestRealWorldMCPShape:
|
||||
"""End-to-end: a realistic MCP-style schema that used to 400 on Moonshot."""
|
||||
|
||||
def test_combined_rewrites(self):
|
||||
# Shape: missing type on a property, anyOf with parent type + null, array
|
||||
# items without type — all in one tool.
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"description": "search text"},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"description": "tag"},
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["query"]["type"] == "string"
|
||||
# anyOf with null collapsed to plain type
|
||||
assert "anyOf" not in out["properties"]["filter"]
|
||||
assert out["properties"]["filter"]["type"] == "string"
|
||||
assert out["properties"]["tags"]["items"]["type"] == "string"
|
||||
assert out["required"] == ["query"]
|
||||
|
||||
|
||||
class TestEnumNullStripping:
|
||||
"""Rule 3: Moonshot rejects null/empty-string inside enum arrays."""
|
||||
|
||||
def test_enum_null_value_stripped(self):
|
||||
"""enum containing Python None must have it removed for Moonshot."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"db_type": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql", None],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
db_type = out["properties"]["db_type"]
|
||||
assert None not in db_type["enum"]
|
||||
assert "mysql" in db_type["enum"]
|
||||
assert "postgresql" in db_type["enum"]
|
||||
|
||||
def test_enum_empty_string_stripped(self):
|
||||
"""enum containing empty string '' must have it removed for Moonshot."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"db_type": {
|
||||
"type": "string",
|
||||
"enum": ["mysql", "postgresql", ""],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
db_type = out["properties"]["db_type"]
|
||||
assert "" not in db_type["enum"]
|
||||
assert db_type["enum"] == ["mysql", "postgresql"]
|
||||
|
||||
def test_enum_all_null_becomes_no_enum(self):
|
||||
"""enum that only had null/empty values is dropped entirely."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"val": {
|
||||
"type": "string",
|
||||
"enum": [None, ""],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert "enum" not in out["properties"]["val"]
|
||||
|
||||
def test_dataslayer_db_type_after_mcp_normalize(self):
|
||||
"""Real-world: dataslayer db_type anyOf+enum after MCP normalization."""
|
||||
# This is the exact shape after _normalize_mcp_input_schema runs:
|
||||
# anyOf collapsed, but enum still has null + empty string
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"datasource": {"type": "string"},
|
||||
"db_type": {
|
||||
"enum": ["mysql", "mariadb", "postgresql", "sqlserver", "oracle", "", None],
|
||||
"type": "string",
|
||||
"nullable": True,
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
"required": ["datasource"],
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
db_type = out["properties"]["db_type"]
|
||||
assert "nullable" not in db_type, "nullable keyword must be stripped"
|
||||
assert None not in db_type["enum"]
|
||||
assert "" not in db_type["enum"]
|
||||
assert db_type["enum"] == ["mysql", "mariadb", "postgresql", "sqlserver", "oracle"]
|
||||
assert db_type["type"] == "string"
|
||||
|
||||
def test_enum_on_object_type_not_stripped(self):
|
||||
"""enum on non-scalar types (object) should NOT be touched."""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"config": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"enum": [{}, None],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
# object-typed enum should pass through unchanged
|
||||
assert "enum" in out["properties"]["config"]
|
||||
|
||||
def test_anyof_collapse_still_runs_nullable_and_enum_cleanup(self):
|
||||
"""After anyOf collapses to a single non-null branch, the merged
|
||||
node must still have ``nullable`` stripped and null/empty-string
|
||||
values removed from enum — not skipped by the early anyOf return.
|
||||
"""
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"db_type": {
|
||||
"anyOf": [
|
||||
{"enum": ["mysql", "postgresql", "", None]},
|
||||
{"type": "null"},
|
||||
],
|
||||
"nullable": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
db_type = out["properties"]["db_type"]
|
||||
assert "anyOf" not in db_type
|
||||
assert "nullable" not in db_type, "nullable must be stripped after anyOf collapse"
|
||||
assert db_type["type"] == "string"
|
||||
assert db_type["enum"] == ["mysql", "postgresql"], \
|
||||
"null/empty enum values must be stripped after anyOf collapse"
|
||||
|
||||
|
||||
class TestUnionTypeList:
|
||||
"""Moonshot sanitizer accepts JSON Schema union type arrays."""
|
||||
|
||||
def test_union_type_list_normalizes_to_first_concrete_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": ["number", "string"],
|
||||
"description": "Max results",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["limit"]["type"] == "number"
|
||||
|
||||
def test_union_type_list_skips_null_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": ["null", "string"]},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["name"]["type"] == "string"
|
||||
|
||||
def test_union_type_list_with_enum_does_not_crash_or_mutate_input(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["asc", "desc", None, ""],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
sort = out["properties"]["sort"]
|
||||
assert sort["type"] == "string"
|
||||
assert sort["enum"] == ["asc", "desc"]
|
||||
assert params["properties"]["sort"]["type"] == ["string", "null"]
|
||||
Reference in New Issue
Block a user