mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
fix(tools): strip default from $ref nodes in tool schemas
Fireworks-hosted Kimi rejects tool requests when nullable MCP/Pydantic
schemas collapse to {"$ref": "...", "default": null}. Strip that sibling
during global schema sanitization so gateway and CLI calls succeed again.
This commit is contained in:
parent
c574170050
commit
b2d151abe2
2 changed files with 104 additions and 0 deletions
|
|
@ -201,6 +201,72 @@ def test_items_sanitized_in_array_schema():
|
|||
assert items == {"type": "object", "properties": {}}
|
||||
|
||||
|
||||
def test_ref_with_default_sibling_stripped():
|
||||
"""Strict backends reject ``default`` alongside ``$ref``."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {"$ref": "#/$defs/Payload", "default": None},
|
||||
},
|
||||
"$defs": {
|
||||
"Payload": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
payload = out[0]["function"]["parameters"]["properties"]["payload"]
|
||||
assert payload == {"$ref": "#/$defs/Payload"}
|
||||
|
||||
|
||||
def test_nullable_union_collapse_does_not_leave_default_on_ref():
|
||||
"""Nullable anyOf collapse must not attach ``default`` to a ``$ref`` branch."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/Payload"},
|
||||
{"type": "null"},
|
||||
],
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Payload": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["input"]
|
||||
assert prop["$ref"] == "#/$defs/Payload"
|
||||
assert "default" not in prop
|
||||
assert prop.get("nullable") is True
|
||||
|
||||
|
||||
def test_ref_description_preserved():
|
||||
"""Annotation siblings that strict backends allow should survive."""
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"$ref": "#/$defs/Payload",
|
||||
"description": "The payload",
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"Payload": {"type": "object", "properties": {}},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
payload = out[0]["function"]["parameters"]["properties"]["payload"]
|
||||
assert payload["description"] == "The payload"
|
||||
assert payload["$ref"] == "#/$defs/Payload"
|
||||
|
||||
|
||||
def test_empty_tools_list_returns_empty():
|
||||
assert sanitize_tool_schemas([]) == []
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ The failure modes we've seen in the wild:
|
|||
optional fields (common Pydantic/MCP shape). Anthropic rejects these at
|
||||
the top of ``input_schema``; collapse them to the non-null branch.
|
||||
* Unconstrained ``additionalProperties`` on objects with empty properties.
|
||||
* ``default`` (and other annotation keywords) alongside ``$ref`` — strict
|
||||
backends (Fireworks-hosted Kimi, JSON Schema draft-07 validators) reject
|
||||
sibling keywords at the same level as ``$ref``. Common MCP/Pydantic shape
|
||||
after nullable-union collapse::
|
||||
|
||||
{"$ref": "#/$defs/Foo", "default": null}
|
||||
|
||||
This module walks the final tool schema tree (after MCP-level normalization
|
||||
and any per-tool dynamic rebuilds) and fixes the known-hostile constructs
|
||||
|
|
@ -90,6 +96,35 @@ def _sanitize_single_tool(tool: dict) -> dict:
|
|||
fn["parameters"] = _strip_top_level_combinators(
|
||||
fn["parameters"], path=fn.get("name", "<tool>")
|
||||
)
|
||||
fn["parameters"] = _strip_ref_siblings(fn["parameters"])
|
||||
return out
|
||||
|
||||
|
||||
# Sibling keywords strict JSON Schema validators reject alongside ``$ref``.
|
||||
_REF_FORBIDDEN_SIBLINGS = frozenset({"default"})
|
||||
|
||||
|
||||
def _strip_ref_siblings(node: Any) -> Any:
|
||||
"""Drop forbidden sibling keywords from nodes that carry ``$ref``.
|
||||
|
||||
Fireworks (and other draft-07-strict backends) fail tool requests with::
|
||||
|
||||
JSON Schema not supported: keyword(s) ['default'] not allowed at
|
||||
the same level as $ref.
|
||||
|
||||
Nullable-union collapse and MCP ingestion can leave ``default`` on a
|
||||
``$ref`` node; strip it recursively.
|
||||
"""
|
||||
if isinstance(node, list):
|
||||
return [_strip_ref_siblings(item) for item in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
|
||||
out = {key: _strip_ref_siblings(value) for key, value in node.items()}
|
||||
if "$ref" in out:
|
||||
for key in _REF_FORBIDDEN_SIBLINGS:
|
||||
if key in out:
|
||||
out.pop(key, None)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -185,6 +220,9 @@ def strip_nullable_unions(
|
|||
replacement.setdefault("nullable", True)
|
||||
for meta_key in ("title", "description", "default", "examples"):
|
||||
if meta_key in stripped and meta_key not in replacement:
|
||||
# ``default`` is illegal alongside ``$ref`` on strict backends.
|
||||
if meta_key == "default" and "$ref" in replacement:
|
||||
continue
|
||||
replacement[meta_key] = stripped[meta_key]
|
||||
return strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint)
|
||||
return stripped
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue