mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: sanitize tool schemas for llama.cpp backends; restore MCP in TUI (#15032)
Local llama.cpp servers (e.g. ggml-org/llama.cpp:full-cuda) fail the entire
request with HTTP 400 'Unable to generate parser for this template. ...
Unrecognized schema: "object"' when any tool schema contains shapes its
json-schema-to-grammar converter can't handle:
* 'type': 'object' without 'properties'
* bare string schema values ('additionalProperties: "object"')
* 'type': ['X', 'null'] arrays (nullable form)
Cloud providers accept these silently, so they ship from external MCP
servers (Atlassian, GCloud, Datadog) and from a couple of our own tools.
Changes
- tools/schema_sanitizer.py: walks the finalized tool list right before it
leaves get_tool_definitions() and repairs the hostile shapes in a deep
copy. No-op on well-formed schemas. Recurses into properties, items,
additionalProperties, anyOf/oneOf/allOf, and $defs.
- model_tools.get_tool_definitions(): invoke the sanitizer as the last
step so all paths (built-in, MCP, plugin, dynamically-rebuilt) get
covered uniformly.
- tools/browser_cdp_tool.py, tools/mcp_tool.py: fix our own bare-object
schemas so sanitization isn't load-bearing for in-repo tools.
- tui_gateway/server.py: _load_enabled_toolsets() was passing
include_default_mcp_servers=False at runtime. That's the config-editing
variant (see PR #3252) — it silently drops every default MCP server
from the TUI's enabled_toolsets, which is why the TUI didn't hit the
llama.cpp crash (no MCP tools sent at all). Switch to True so TUI
matches CLI behavior.
Tests
tests/tools/test_schema_sanitizer.py (17 tests) covers the individual
failure modes, well-formed pass-through, deep-copy isolation, and
required-field pruning.
E2E: loaded the default 'hermes-cli' toolset with MCP discovery and
confirmed all 27 resolved tool schemas pass a llama.cpp-compatibility
walk (no 'object' node missing 'properties', no bare-string schema
values).
This commit is contained in:
parent
5dda4cab41
commit
34c3e67109
6 changed files with 413 additions and 1 deletions
205
tests/tools/test_schema_sanitizer.py
Normal file
205
tests/tools/test_schema_sanitizer.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Tests for tools/schema_sanitizer.py.
|
||||
|
||||
Targets the known llama.cpp ``json-schema-to-grammar`` failure modes that
|
||||
cause ``HTTP 400: Unable to generate parser for this template. ...
|
||||
Unrecognized schema: "object"`` errors on local inference backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from tools.schema_sanitizer import sanitize_tool_schemas
|
||||
|
||||
|
||||
def _tool(name: str, parameters: dict) -> dict:
|
||||
return {"type": "function", "function": {"name": name, "parameters": parameters}}
|
||||
|
||||
|
||||
def test_object_without_properties_gets_empty_properties():
|
||||
tools = [_tool("t", {"type": "object"})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_nested_object_without_properties_gets_empty_properties():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"arguments": {"type": "object", "description": "free-form"},
|
||||
},
|
||||
"required": ["name"],
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
args = out[0]["function"]["parameters"]["properties"]["arguments"]
|
||||
assert args["type"] == "object"
|
||||
assert args["properties"] == {}
|
||||
assert args["description"] == "free-form"
|
||||
|
||||
|
||||
def test_bare_string_object_value_replaced_with_schema_dict():
|
||||
# Malformed: a property's schema value is the bare string "object".
|
||||
# This is the exact shape llama.cpp reports as `Unrecognized schema: "object"`.
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": "object", # <-- invalid, should be {"type": "object"}
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
payload = out[0]["function"]["parameters"]["properties"]["payload"]
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["type"] == "object"
|
||||
assert payload["properties"] == {}
|
||||
|
||||
|
||||
def test_bare_string_primitive_value_replaced_with_schema_dict():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {"name": "string"},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"]["properties"]["name"] == {"type": "string"}
|
||||
|
||||
|
||||
def test_nullable_type_array_collapsed_to_single_string():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"maybe_name": {"type": ["string", "null"]},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["maybe_name"]
|
||||
assert prop["type"] == "string"
|
||||
assert prop.get("nullable") is True
|
||||
|
||||
|
||||
def test_anyof_nested_objects_sanitized():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"opt": {
|
||||
"anyOf": [
|
||||
{"type": "object"}, # bare object
|
||||
{"type": "string"},
|
||||
],
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
variants = out[0]["function"]["parameters"]["properties"]["opt"]["anyOf"]
|
||||
assert variants[0] == {"type": "object", "properties": {}}
|
||||
assert variants[1] == {"type": "string"}
|
||||
|
||||
|
||||
def test_missing_parameters_gets_default_object_schema():
|
||||
tools = [{"type": "function", "function": {"name": "t"}}]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_non_dict_parameters_gets_default_object_schema():
|
||||
tools = [_tool("t", "object")] # pathological
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_required_pruned_to_existing_properties():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name", "missing_field"],
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"]["required"] == ["name"]
|
||||
|
||||
|
||||
def test_required_all_missing_is_dropped():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": ["x", "y"],
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert "required" not in out[0]["function"]["parameters"]
|
||||
|
||||
|
||||
def test_well_formed_schema_unchanged():
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path"},
|
||||
"offset": {"type": "integer", "minimum": 1},
|
||||
},
|
||||
"required": ["path"],
|
||||
}
|
||||
tools = [_tool("read_file", copy.deepcopy(schema))]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
assert out[0]["function"]["parameters"] == schema
|
||||
|
||||
|
||||
def test_additional_properties_bool_preserved():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
payload = out[0]["function"]["parameters"]["properties"]["payload"]
|
||||
assert payload["additionalProperties"] is True
|
||||
|
||||
|
||||
def test_additional_properties_schema_sanitized():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dict_field": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "object"}, # bare object schema
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
field = out[0]["function"]["parameters"]["properties"]["dict_field"]
|
||||
assert field["additionalProperties"] == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_deepcopy_does_not_mutate_input():
|
||||
original = {
|
||||
"type": "object",
|
||||
"properties": {"x": {"type": "object"}},
|
||||
}
|
||||
tools = [_tool("t", original)]
|
||||
_ = sanitize_tool_schemas(tools)
|
||||
# Original should still lack properties on the nested object
|
||||
assert "properties" not in original["properties"]["x"]
|
||||
|
||||
|
||||
def test_items_sanitized_in_array_schema():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bag": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"}, # bare object items
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
items = out[0]["function"]["parameters"]["properties"]["bag"]["items"]
|
||||
assert items == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_empty_tools_list_returns_empty():
|
||||
assert sanitize_tool_schemas([]) == []
|
||||
|
||||
|
||||
def test_none_tools_returns_none():
|
||||
assert sanitize_tool_schemas(None) is None
|
||||
Loading…
Add table
Add a link
Reference in a new issue