mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
fix(schema): preserve multi-type arrays as anyOf instead of dropping branches
Port from anomalyco/opencode#31877: JSON Schema type arrays like ["number","string"] (common in MCP tool schemas) were collapsed to the first non-null type, silently dropping every other branch. Several tool-call backends reject the array form outright — llama.cpp's grammar generator and Gemini via OpenAI-compatible transports (e.g. GitHub Copilot proxying to Gemini) 400 on it. _sanitize_node now mirrors @ai-sdk/google: a single non-null type stays type:X (+nullable if null was present), multiple non-null types become an anyOf of single-type schemas so no branch is lost, and an all-null array becomes type:null. Single-null collapse is unchanged. Verified nested (object props, array items) survive the full sanitize pipeline — combinator stripping is top-level-only and nullable-union collapse only fires on single-survivor unions, so multi-type anyOf is left intact.
This commit is contained in:
parent
7647eff360
commit
ce038a0e05
2 changed files with 90 additions and 13 deletions
|
|
@ -80,6 +80,65 @@ def test_nullable_type_array_collapsed_to_single_string():
|
|||
assert prop.get("nullable") is True
|
||||
|
||||
|
||||
def test_multitype_array_becomes_anyof_no_branch_dropped():
|
||||
# Ported from anomalyco/opencode#31877: a genuine multi-type array such as
|
||||
# ["number", "string"] (common in MCP tool schemas) must keep BOTH branches
|
||||
# as an anyOf, not silently drop all but the first. Several backends
|
||||
# (llama.cpp, Gemini via OpenAI-compatible transports) reject the array form.
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": ["number", "string"], "description": "status filter"},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["status"]
|
||||
assert "type" not in prop
|
||||
assert prop["anyOf"] == [{"type": "number"}, {"type": "string"}]
|
||||
assert prop.get("nullable") is None
|
||||
# Sibling keywords survive alongside the generated anyOf.
|
||||
assert prop["description"] == "status filter"
|
||||
|
||||
|
||||
def test_multitype_array_with_null_lifts_nullable():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"v": {"type": ["integer", "boolean", "null"]},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["v"]
|
||||
assert "type" not in prop
|
||||
assert prop["anyOf"] == [{"type": "integer"}, {"type": "boolean"}]
|
||||
assert prop.get("nullable") is True
|
||||
|
||||
|
||||
def test_all_null_type_array_becomes_null_type():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n": {"type": ["null"]},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["n"]
|
||||
assert prop["type"] == "null"
|
||||
|
||||
|
||||
def test_single_element_type_array_unwrapped():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"s": {"type": ["string"]},
|
||||
},
|
||||
})]
|
||||
out = sanitize_tool_schemas(tools)
|
||||
prop = out[0]["function"]["parameters"]["properties"]["s"]
|
||||
assert prop["type"] == "string"
|
||||
assert prop.get("nullable") is None
|
||||
|
||||
|
||||
def test_anyof_nested_objects_sanitized():
|
||||
tools = [_tool("t", {
|
||||
"type": "object",
|
||||
|
|
|
|||
|
|
@ -235,7 +235,9 @@ def _sanitize_node(node: Any, path: str) -> Any:
|
|||
``{"type": <value>}`` so downstream consumers see a dict.
|
||||
- Injects ``properties: {}`` into object-typed nodes missing it.
|
||||
- Normalizes ``type: [X, "null"]`` arrays to single ``type: X`` (keeping
|
||||
``nullable: true`` as a hint).
|
||||
``nullable: true`` as a hint), and multi-type arrays like
|
||||
``["number", "string"]`` to an ``anyOf`` of single-type schemas so no
|
||||
branch is dropped (ported from anomalyco/opencode#31877).
|
||||
- Recurses into ``properties``, ``items``, ``additionalProperties``,
|
||||
``anyOf``, ``oneOf``, ``allOf``, and ``$defs`` / ``definitions``.
|
||||
"""
|
||||
|
|
@ -268,23 +270,39 @@ def _sanitize_node(node: Any, path: str) -> Any:
|
|||
|
||||
out: dict = {}
|
||||
for key, value in node.items():
|
||||
# type: [X, "null"] → type: X (the backend's tool-call parser only
|
||||
# accepts singular string types; nullable is lost but the call still
|
||||
# succeeds, and the model can still pass null on its own.)
|
||||
# JSON Schema ``type`` arrays (e.g. ``["number", "string"]``, common
|
||||
# in MCP tool schemas) are rejected by several tool-call backends:
|
||||
# * llama.cpp's grammar generator only accepts a singular string type.
|
||||
# * Gemini (including OpenAI-compatible transports such as GitHub
|
||||
# Copilot proxying to Gemini) rejects the array form outright —
|
||||
# plain @ai-sdk/google rewrites it, but the OpenAI-compatible path
|
||||
# forwards it verbatim and the backend 400s.
|
||||
#
|
||||
# Normalize per the SDK's behavior:
|
||||
# * single non-null type → ``type: X`` (+ ``nullable: true`` if the
|
||||
# array also contained "null"). No data lost.
|
||||
# * multiple non-null types → ``anyOf`` of single-type schemas, so
|
||||
# EVERY branch survives instead of silently dropping all but the
|
||||
# first. ``null`` is lifted into ``nullable: true``.
|
||||
# * all-null / empty → ``type: "null"`` (or object fallback).
|
||||
# Ported from anomalyco/opencode#31877.
|
||||
if key == "type" and isinstance(value, list):
|
||||
non_null = [t for t in value if t != "null"]
|
||||
if len(non_null) == 1 and isinstance(non_null[0], str):
|
||||
has_null = "null" in value
|
||||
non_null = [t for t in value if isinstance(t, str) and t != "null"]
|
||||
if len(non_null) == 1:
|
||||
out["type"] = non_null[0]
|
||||
if "null" in value:
|
||||
if has_null:
|
||||
out.setdefault("nullable", True)
|
||||
continue
|
||||
# Fallback: pick the first string type, drop the rest.
|
||||
first_str = next((t for t in value if isinstance(t, str) and t != "null"), None)
|
||||
if first_str:
|
||||
out["type"] = first_str
|
||||
if len(non_null) >= 2:
|
||||
# Preserve all branches as a union instead of dropping them.
|
||||
out["anyOf"] = [{"type": t} for t in non_null]
|
||||
if has_null:
|
||||
out.setdefault("nullable", True)
|
||||
continue
|
||||
# All-null or empty list → treat as object.
|
||||
out["type"] = "object"
|
||||
# No usable non-null type: all-null array → type: "null";
|
||||
# otherwise an empty/garbage array → object fallback.
|
||||
out["type"] = "null" if has_null else "object"
|
||||
continue
|
||||
|
||||
if key in {"properties", "$defs", "definitions"} and isinstance(value, dict):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue