Files
hermes-agent/tools/schema_sanitizer.py
teknium1 3dbaffd814 fix(tools): coerce a missing required to [] and pin the MCP normalizer entry (#56123)
Review follow-up. Object nodes that never had a `required` key sanitized to exactly
{type: object, properties: {}} (built-in read_window_below, read_terminal, ...), the shape
strict OpenAI-compatible backends reject as `null is not of type "array"`. Both sanitizer
sites (tools/schema_sanitizer.py::_sanitize_node and the top-level/default shapes, and the MCP
mirror tools/mcp_tool_schema.py::_repair_object_shape) now always emit a `required` list.

The MCP test now drives `_normalize_mcp_input_schema` (the production entry) instead of the
private repair helper, so removing the repair call goes red; the docstring describes the real
base behaviour (the key was dropped only when every entry was invalid).
2026-09-19 10:16:59 -07:00

388 lines
19 KiB
Python

"""Sanitize tool JSON schemas for strict LLM backends. llama.cpp's grammar converter fails on
``{"type": "object"}`` without ``properties``, bare-string schemas and ``type`` arrays; Anthropic
rejects nullable ``anyOf`` at the top of ``input_schema``; Fireworks rejects ``default`` beside
``$ref``; Codex rejects top-level combinators. Walks a deep copy and fixes only those shapes.
Legacy boolean ``required`` flags are removed from schema positions, lifting property-level
``true`` flags into the parent's required-name list; literal defaults/consts are not schemas."""
from __future__ import annotations
import copy
import logging
import re
from typing import Any, Callable
logger = logging.getLogger(__name__)
# Anthropic (and Bedrock/Vertex/Azure fronting it) reject property keys not matching this; one bad
# key anywhere in the tools array 400s the request (Cloudflare's MCP ships 61).
_PROP_KEY_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$")
_PROP_KEY_BAD_CHARS = re.compile(r"[^a-zA-Z0-9_.-]")
_UNION_KEYS = ("anyOf", "oneOf")
_UNION_META_KEYS = ("title", "description", "default", "examples") # copied onto replacements
def _empty_object() -> dict:
return {"type": "object", "properties": {}, "required": []}
def _rewrite(schema: Any, fn: Callable[[dict], Any]) -> Any:
"""Bottom-up map over a schema tree: lists/dicts recurse, then *fn* sees each dict."""
if isinstance(schema, list):
return [_rewrite(item, fn) for item in schema]
if not isinstance(schema, dict):
return schema
return fn({k: _rewrite(v, fn) for k, v in schema.items()})
def sanitize_property_key(key: str) -> str:
"""Deterministically map an arbitrary property key to a conforming one."""
return _PROP_KEY_BAD_CHARS.sub("_", key)[:64] or "param"
def _rename_property_keys(props: dict, path: str) -> dict[str, str]:
"""{original_key: conforming_key} for one properties dict (identity entries omitted).
Deterministic (insertion order, numeric suffixes on collision) so the model-visible schema
and the dispatch-time reverse map from the registry's original schema agree."""
renames: dict[str, str] = {}
taken = {k for k in props if _PROP_KEY_RE.match(k)}
for key in (k for k in props if not _PROP_KEY_RE.match(k)):
base = sanitize_property_key(key)
candidate, i = base, 2
while candidate in taken:
candidate, i = base[: 64 - len(f"_{i}")] + f"_{i}", i + 1
taken.add(candidate)
renames[key] = candidate
logger.debug("schema_sanitizer[%s]: renamed property key %r -> %r "
"(provider key-pattern compat)", path, key, candidate)
return renames
def unrename_tool_args(params_schema: Any, args: Any) -> Any:
"""Map sanitized keys in model-emitted args back to wire names. ``params_schema`` is the
ORIGINAL registry schema; recurses into objects/array items; unknown keys pass through."""
props = params_schema.get("properties") if isinstance(params_schema, dict) else None
if not isinstance(props, dict) or not isinstance(args, dict):
return args
reverse = {v: k for k, v in _rename_property_keys(props, "<unrename>").items()}
out = {}
for key, value in args.items():
orig = reverse.get(key, key)
sub = props.get(orig) if isinstance(props.get(orig), dict) else {}
if isinstance(value, dict) and sub:
value = unrename_tool_args(sub, value)
elif isinstance(value, list) and isinstance(sub.get("items"), dict):
value = [unrename_tool_args(sub["items"], item) if isinstance(item, dict) else item
for item in value]
out[orig] = value
return out
def sanitize_tool_schemas(tools: list[dict]) -> list[dict]:
"""Deep-copied ``tools`` (OpenAI format) with sanitized parameter schemas; safe to mutate."""
return [_sanitize_single_tool(tool) for tool in tools] if tools else tools
def _sanitize_single_tool(tool: dict) -> dict:
out = copy.deepcopy(tool)
fn = out.get("function") if isinstance(out, dict) else None
if not isinstance(fn, dict):
return out
params = fn.get("parameters")
if not isinstance(params, dict): # missing / non-dict → minimal valid shape
fn["parameters"] = _empty_object()
return out
name = fn.get("name", "<tool>")
top = _sanitize_node(params, path=name)
top = top if isinstance(top, dict) else {} # guarantee an object with properties on top
top["type"] = "object"
if not isinstance(top.get("properties"), dict):
top["properties"] = {}
if not isinstance(top.get("required"), list):
top["required"] = []
# The recursive pass only handles array-form ``type: [X, "null"]``; collapse anyOf unions
# here, keeping ``nullable: true`` so ``tools.arg_coercion._schema_allows_null`` still coerces.
top = strip_nullable_unions(top, keep_nullable_hint=True)
top = _strip_top_level_combinators(top, path=name)
fn["parameters"] = _strip_ref_siblings(top)
return out
_REF_FORBIDDEN_SIBLINGS = frozenset({"default"}) # strict validators reject these beside ``$ref``
def _strip_ref_siblings(node: Any) -> Any:
"""Recursively drop forbidden siblings of ``$ref`` (Fireworks rejects ``default`` there)."""
def strip(out: dict) -> dict:
for key in _REF_FORBIDDEN_SIBLINGS if "$ref" in out else ():
out.pop(key, None)
return out
return _rewrite(node, strip)
_TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not")
def _strip_top_level_combinators(params: dict, *, path: str = "<tool>") -> dict:
"""Drop combinators from the TOP level only (Codex rejects them there). They are usually
conditional-required hints, so validity is unchanged (handlers re-validate); nested ones
stay."""
if not isinstance(params, dict):
return params
out = dict(params)
for key in [k for k in _TOP_LEVEL_FORBIDDEN_KEYS if k in out]:
logger.debug("schema_sanitizer[%s]: stripped top-level %r combinator "
"from tool parameters (strict-backend compat)", path, key)
del out[key]
return out
def _is_null_branch(item: Any) -> bool:
return isinstance(item, dict) and item.get("type") == "null"
def _carry_union_meta(outer: dict, replacement: dict, *, skip_default_on_ref: bool) -> None:
"""Copy outer-union metadata onto *replacement* where absent (``default`` is illegal beside
``$ref`` on strict backends, hence ``skip_default_on_ref``)."""
for meta_key in _UNION_META_KEYS:
if meta_key in outer and meta_key not in replacement and not (
skip_default_on_ref and meta_key == "default" and "$ref" in replacement):
replacement[meta_key] = outer[meta_key]
def strip_nullable_unions(schema: Any, *, keep_nullable_hint: bool = True) -> Any:
"""Collapse ``anyOf``/``oneOf`` nullable unions (MCP/Pydantic optional fields) to the single
non-null branch: Anthropic rejects the null branch and optionality already lives in the parent's
``required``. Only when a null branch was dropped AND exactly one non-null branch survives.
``keep_nullable_hint`` sets ``nullable: true`` for runtime ``"null"`` → ``None`` coercion."""
def collapse(stripped: dict) -> Any:
for key in _UNION_KEYS:
variants = stripped.get(key)
if not isinstance(variants, list):
continue
non_null = [item for item in variants if not _is_null_branch(item)]
if len(non_null) == 1 and len(non_null) != len(variants):
replacement = dict(non_null[0]) if isinstance(non_null[0], dict) else {}
if keep_nullable_hint:
replacement.setdefault("nullable", True)
_carry_union_meta(stripped, replacement, skip_default_on_ref=True)
return _rewrite(replacement, collapse) # the survivor may itself be a union
return stripped
return _rewrite(schema, collapse)
_CONST_PRIMITIVE_TYPES: dict[type, str] = {
bool: "boolean", int: "integer", float: "number", str: "string"}
def _const_branch_type(branch: Any) -> str | None:
"""Primitive JSON-Schema type of a pure ``const`` branch (declared ``type``, if any, must match;
only ``title``/``description`` may accompany it), else None."""
if not isinstance(branch, dict) or "const" not in branch \
or set(branch) - {"const", "type", "title", "description"}:
return None
# ``type(value)`` lookup (not isinstance): bool is a subclass of int.
json_type = _CONST_PRIMITIVE_TYPES.get(type(branch["const"]))
return json_type if branch.get("type") in (None, json_type) else None
def collapse_const_unions(schema: Any) -> Any:
"""Collapse ``anyOf``/``oneOf`` unions of same-typed consts (Rust/TS MCP servers emit
``{"anyOf": [{"const": "red"}, {"const": "green"}]}``) to ``enum``; ported from block/goose
``tool_schema_normalize.rs`` (Apache-2.0). Only when EVERY non-null branch is a pure ``const``
of one primitive type (``bool`` never merges with ``integer``); one ``{"type": "null"}`` branch
is tolerated as ``nullable: true``. Branch order kept; outer metadata carried; input never
mutated."""
def collapse(out: dict) -> Any:
for key in _UNION_KEYS:
variants = out.get(key)
if not isinstance(variants, list) or not variants:
continue
null_branches = [i for i in variants if _is_null_branch(i) and "const" not in i]
const_branches = [item for item in variants if item not in null_branches]
if len(null_branches) > 1 or not const_branches:
continue
branch_types = {_const_branch_type(item) for item in const_branches}
if len(branch_types) != 1 or None in branch_types:
continue
replacement: dict = {
"type": branch_types.pop(), "enum": [item["const"] for item in const_branches]}
if null_branches:
replacement["nullable"] = True
_carry_union_meta(out, replacement, skip_default_on_ref=False)
return replacement
return out
return _rewrite(schema, collapse)
_BARE_TYPE_NAMES = frozenset({"object", "string", "number", "integer", "boolean", "array", "null"})
# Values that are NOT schemas (recursing would treat a required name like "path" as a bare schema).
_NON_SCHEMA_LIST_KEYS = frozenset({"required", "enum", "examples", "dependentRequired"})
_SCHEMA_MAP_KEYS = frozenset({"properties", "$defs", "definitions", "patternProperties", "dependentSchemas"})
_SCHEMA_CHILD_KEYS = frozenset({
"items", "additionalItems", "additionalProperties", "unevaluatedItems", "unevaluatedProperties",
"contains", "propertyNames", "not", "if", "then", "else", "anyOf", "oneOf", "allOf", "prefixItems",
})
def _normalize_type_array(value: list, out: dict) -> None:
"""Normalize a ``type: [...]`` array into *out* (llama.cpp and Gemini-via-OpenAI reject arrays).
Per AI-SDK: one non-null type → ``type: X`` (+ ``nullable`` if ``null`` present); several →
``anyOf`` of single-type schemas so EVERY branch survives; none → ``null``/object fallback."""
has_null = "null" in value
non_null = [t for t in value if isinstance(t, str) and t != "null"]
if not non_null:
out["type"] = "null" if has_null else "object"
return
if len(non_null) == 1:
out["type"] = non_null[0]
else:
out["anyOf"] = [{"type": t} for t in non_null]
if has_null:
out.setdefault("nullable", True)
def _sanitize_node(node: Any, path: str) -> Any:
"""Recursively sanitize a JSON-Schema fragment: bare-string schemas → ``{"type": <value>}``
(unknown strings → permissive object); object nodes gain ``properties: {}``; ``type`` arrays
are normalized; property keys are renamed to the provider-safe pattern and ``required``
follows, with entries missing from ``properties`` pruned.
- Normalizes ``type: [X, "null"]`` arrays to single ``type: X`` (keeping ``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``.
"""
if isinstance(node, str):
if node in _BARE_TYPE_NAMES:
logger.debug("schema_sanitizer[%s]: replacing bare-string schema %r with {'type': %r}",
path, node, node)
return _empty_object() if node == "object" else {"type": node}
logger.debug("schema_sanitizer[%s]: replacing non-schema string %r "
"with empty object schema", path, node)
return _empty_object()
if isinstance(node, list):
return [_sanitize_node(item, f"{path}[{i}]") for i, item in enumerate(node)]
if not isinstance(node, dict):
return node
# Renames computed up front so ``required`` remaps even when it precedes ``properties``.
props_in = node.get("properties")
prop_renames = (_rename_property_keys(props_in, f"{path}.properties")
if isinstance(props_in, dict) else {})
out: dict = {}
for key, value in node.items():
# 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):
_normalize_type_array(value, out)
elif key in _SCHEMA_MAP_KEYS and isinstance(value, dict):
renames = prop_renames if key == "properties" else {}
out[key] = {
renames.get(k, k): _sanitize_node(v, f"{path}.{key}.{renames.get(k, k)}")
for k, v in value.items()}
elif key == "dependencies" and isinstance(value, dict):
out[key] = {k: _sanitize_node(v, f"{path}.{key}.{k}") if isinstance(v, dict)
else copy.deepcopy(v) for k, v in value.items()}
elif key in {"items", "additionalProperties"}:
# Bool ``additionalProperties`` is valid; bool ``items`` is non-standard but preserved.
out[key] = value if isinstance(value, bool) else _sanitize_node(value, f"{path}.{key}")
elif key in _NON_SCHEMA_LIST_KEYS:
if key == "required" and isinstance(value, bool):
continue # Legacy property flags are lifted by the parent below.
if key == "required" and prop_renames and isinstance(value, list):
out[key] = [prop_renames.get(r, r) if isinstance(r, str) else r for r in value]
else:
out[key] = copy.deepcopy(value) if isinstance(value, (list, dict)) else value
elif key in _SCHEMA_CHILD_KEYS:
out[key] = _sanitize_node(value, f"{path}.{key}")
else:
# Defaults, consts and extension metadata are literal data, not schemas.
out[key] = copy.deepcopy(value)
if isinstance(props_in, dict):
lifted = [prop_renames.get(k, k) for k, v in props_in.items()
if isinstance(v, dict) and v.get("required") is True]
if lifted:
required = out.get("required", [])
required = required if isinstance(required, list) else []
out["required"] = required + [key for key in lifted if key not in required]
if out.get("type") == "object":
if not isinstance(out.get("properties"), dict):
out["properties"] = {}
# Always emit a list: ``required: []`` is valid everywhere, while a missing or
# non-list key reads as ``null`` on strict OpenAI-compatible proxies (#56123).
required = out.get("required")
out["required"] = ([r for r in required if isinstance(r, str) and r in out["properties"]]
if isinstance(required, list) else [])
return out
# ---- Reactive strips — only invoked after a backend rejects a schema ----
_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"})
_SCHEMA_MARKERS = frozenset({"type", "anyOf", "oneOf", "allOf"}) # a node with one IS a schema
def _dict_nodes(node: Any):
"""Pre-order walk over every dict node (yielded before its values, so it may be mutated)."""
if isinstance(node, dict):
yield node
children = node.values() if isinstance(node, dict) else node if isinstance(node, list) else ()
for child in children:
yield from _dict_nodes(child)
def _reactive_strip(
tools: list[dict], strip_node: Callable[[dict], int], log_msg: str) -> tuple[list[dict], int]:
"""Apply *strip_node* (-> keywords removed) to every dict node of each tool's parameters, in
place; OpenAI (``{"function": {"parameters"}}``) and Responses (``{"parameters"}``) formats."""
stripped = 0
for tool in tools or ():
if not isinstance(tool, dict):
continue
fn = tool.get("function")
params = fn.get("parameters") if isinstance(fn, dict) else None
params = params if isinstance(params, dict) else tool.get("parameters")
if isinstance(params, dict):
stripped += sum(strip_node(node) for node in _dict_nodes(params))
if stripped:
logger.info(log_msg, stripped)
return tools, stripped
def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]:
"""Strip ``pattern``/``format`` in place — reactive, only after llama.cpp's grammar converter
rejected a schema (its regex engine is a small ECMAScript subset); cloud providers use these as
prompting hints. Only beside ``type``/combinators, so a property *named* ``pattern`` stays."""
def _strip(node: dict) -> int:
is_schema = bool(node.keys() & _SCHEMA_MARKERS)
hits = [k for k in node if k in _STRIP_ON_RECOVERY_KEYS] if is_schema else []
for k in hits:
del node[k]
return len(hits)
return _reactive_strip(
tools, _strip,
"schema_sanitizer: stripped %d pattern/format keyword(s) from "
"tool schemas (llama.cpp grammar-parse recovery)")
def strip_slash_enum(tools: list[dict]) -> tuple[list[dict], int]:
"""Strip ``enum`` keywords whose string values contain ``/``, in place: xAI's grammar compiler
rejects them (HTTP 400 before any token) — typically MCP enums of HuggingFace model IDs."""
def _strip(node: dict) -> int:
enum_val = node.get("enum")
if isinstance(enum_val, list) and any(isinstance(v, str) and "/" in v for v in enum_val):
del node["enum"]
return 1
return 0
return _reactive_strip(
tools, _strip,
"schema_sanitizer: stripped %d enum keyword(s) containing '/' "
"from tool schemas (xAI Responses grammar-compile recovery)")