Files
hermes-agent/tools/schema_sanitizer.py
T

512 lines
21 KiB
Python

"""Sanitize tool JSON schemas for broad LLM-backend compatibility.
Some backends are strict about JSON Schema shapes that OpenAI/Anthropic/most
cloud providers silently accept — llama.cpp's ``json-schema-to-grammar`` fails
the whole request (``Unrecognized schema: "object"``), Anthropic rejects
nullable ``anyOf`` at the top of ``input_schema``, Fireworks rejects ``default``
beside ``$ref``, OpenAI's Codex backend rejects top-level combinators. Known
hostile constructs:
* ``{"type": "object"}`` with no ``properties``.
* A bare string (``"object"``) where a schema dict belongs (malformed MCP output).
* ``"type": ["string", "null"]`` array types.
* ``anyOf``/``oneOf`` unions whose only purpose is to permit ``null``.
* ``default`` (etc.) alongside ``$ref`` — e.g. ``{"$ref": "#/$defs/Foo", "default": null}``.
This module walks the final tool schema tree (after MCP normalization and any
per-tool dynamic rebuilds) and fixes those in place on a deep copy. It is
deliberately conservative: it only modifies shapes the backend couldn't use.
"""
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 tool input schemas
# whose property keys don't match this pattern; one bad key anywhere in the
# tools array 400s the entire request (Cloudflare's MCP ships 61 such keys).
_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")
# Outer-node metadata carried onto a union's replacement node.
_UNION_META_KEYS = ("title", "description", "default", "examples")
def _empty_object() -> dict:
return {"type": "object", "properties": {}}
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]:
"""Return {original_key: conforming_key} for one properties dict.
Identity entries are omitted. Deterministic (insertion order, numeric
suffixes on collision) so the model-visible schema and the dispatch-time
reverse map computed from the registry's original schema always agree.
"""
renames: dict[str, str] = {}
taken = {k for k in props if _PROP_KEY_RE.match(k)}
for key in props:
if _PROP_KEY_RE.match(key):
continue
base = sanitize_property_key(key)
candidate, i = base, 2
while candidate in taken:
suffix = f"_{i}"
candidate = base[: 64 - len(suffix)] + suffix
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 property keys in model-emitted args back to wire names.
``params_schema`` is the ORIGINAL (unsanitized) registry schema. Recurses
into object values and array items; unknown keys pass through untouched.
"""
if not isinstance(params_schema, dict) or not isinstance(args, dict):
return args
props = params_schema.get("properties")
if not isinstance(props, 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)
subschema = props.get(orig)
if isinstance(subschema, dict):
if isinstance(value, dict):
value = unrename_tool_args(subschema, value)
elif isinstance(value, list) and isinstance(subschema.get("items"), dict):
value = [
unrename_tool_args(subschema["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]:
"""Return a deep-copied ``tools`` list (OpenAI format) with each tool's
parameter schema sanitized; callers may mutate the result freely."""
if not tools:
return tools
return [_sanitize_single_tool(tool) for tool in tools]
def _sanitize_single_tool(tool: dict) -> dict:
"""Deep-copy and sanitize a single OpenAI-format tool entry."""
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)
# Guarantee the top level is an object with properties.
if not isinstance(top, dict):
top = _empty_object()
else:
if top.get("type") != "object":
top["type"] = "object"
if not isinstance(top.get("properties"), dict):
top["properties"] = {}
# Collapse nullable unions the recursive pass leaves intact (it only
# handles the array-form ``type: [X, "null"]``); keep ``nullable: true`` so
# runtime coercion (``model_tools._schema_allows_null``) still maps a
# model-emitted ``"null"`` string to Python ``None``.
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
# Sibling keywords strict JSON Schema validators reject alongside ``$ref``.
_REF_FORBIDDEN_SIBLINGS = frozenset({"default"})
def _strip_ref_siblings(node: Any) -> Any:
"""Recursively drop forbidden sibling keywords from nodes carrying ``$ref``
(Fireworks: ``keyword(s) ['default'] not allowed at the same level as $ref``)."""
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:
out.pop(key, None)
return out
_TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not")
def _strip_top_level_combinators(params: dict, *, path: str = "<tool>") -> dict:
"""Drop combinator keywords from the TOP level of a parameters schema only.
OpenAI's Codex backend rejects ``oneOf/anyOf/allOf/enum/not`` at the top
level. They are usually conditional-required hints; dropping them does not
change which argument values are valid (handlers re-validate). Nested
combinators are preserved.
"""
if not isinstance(params, dict):
return params
out = dict(params)
for key in _TOP_LEVEL_FORBIDDEN_KEYS:
if key in out:
logger.debug(
"schema_sanitizer[%s]: stripped top-level %r combinator "
"from tool parameters (strict-backend compat)",
path, key,
)
out.pop(key, None)
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."""
for meta_key in _UNION_META_KEYS:
if meta_key in outer and meta_key not in replacement:
# ``default`` is illegal alongside ``$ref`` on strict backends.
if skip_default_on_ref and meta_key == "default" and "$ref" in replacement:
continue
replacement[meta_key] = outer[meta_key]
def strip_nullable_unions(
schema: Any,
*,
keep_nullable_hint: bool = True,
) -> Any:
"""Collapse ``anyOf``/``oneOf`` nullable unions to the single non-null branch.
MCP/Pydantic optional fields arrive as
``{"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}``;
Anthropic rejects the null branch, and optionality is already expressed by
the parent's ``required``. Only collapses when a null branch was dropped
AND exactly one non-null branch survives. Outer metadata is carried over.
``keep_nullable_hint`` sets ``nullable: true`` on the replacement for
downstream consumers (runtime ``"null"`` → ``None`` coercion).
"""
if isinstance(schema, list):
return [strip_nullable_unions(item, keep_nullable_hint=keep_nullable_hint) for item in schema]
if not isinstance(schema, dict):
return schema
stripped = {
k: strip_nullable_unions(v, keep_nullable_hint=keep_nullable_hint)
for k, v in schema.items()
}
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 strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint)
return stripped
_CONST_PRIMITIVE_TYPES: dict[type, str] = {
bool: "boolean",
int: "integer",
float: "number",
str: "string",
}
def _const_branch_type(branch: Any) -> str | None:
"""JSON-Schema primitive type of a pure ``const`` branch, else None.
Qualifies when the dict carries a primitive ``const`` and any declared
``type`` matches it; ``title``/``description`` are allowed, any other
constraining keyword disqualifies.
"""
if not isinstance(branch, dict) or "const" not in branch:
return None
if set(branch) - {"const", "type", "title", "description"}:
return None
value = branch["const"]
# ``type(value) is`` (not isinstance): bool is a subclass of int.
json_type = _CONST_PRIMITIVE_TYPES.get(type(value))
if json_type is None:
return None
declared = branch.get("type")
if declared is not None and declared != json_type:
return None
return json_type
def collapse_const_unions(schema: Any) -> Any:
"""Collapse ``anyOf``/``oneOf`` unions of same-typed consts to ``enum``.
Ported from block/goose ``tool_schema_normalize.rs`` (Apache-2.0). MCP
servers generated from Rust/TS union types emit
``{"anyOf": [{"const": "red"}, {"const": "green"}]}``; strict backends
mishandle these while ``{"type": "string", "enum": [...]}`` is universal.
Applies 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 and recorded as ``nullable: true``
(``strip_nullable_unions`` only handles single-non-null unions, so
null+multi-const unions land here). Enum order preserves branch order;
outer metadata is carried over; input is never mutated.
"""
if isinstance(schema, list):
return [collapse_const_unions(item) for item in schema]
if not isinstance(schema, dict):
return schema
out = {k: collapse_const_unions(v) for k, v in schema.items()}
for key in _UNION_KEYS:
variants = out.get(key)
if not isinstance(variants, list) or not variants:
continue
null_branches = [
item for item in variants if _is_null_branch(item) and "const" not in item
]
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
_BARE_TYPE_NAMES = frozenset({"object", "string", "number", "integer", "boolean", "array", "null"})
# Sibling keywords whose values are NOT schemas: recursing would mistake literal
# strings like "path" for bare-string schemas. Passed through unchanged
# (``required`` remapped through property renames).
_NON_SCHEMA_LIST_KEYS = frozenset({"required", "enum", "examples", "dependentRequired"})
def _normalize_type_array(value: list, out: dict) -> None:
"""Normalize a ``type: [...]`` array into *out*.
Several backends reject array types (llama.cpp's grammar generator; Gemini
via OpenAI-compatible transports 400s). Per the AI-SDK behavior: one
non-null type → ``type: X`` (+ ``nullable`` if ``null`` present); several →
``anyOf`` of single-type schemas so EVERY branch survives; none → ``null``
or the object fallback. Ported from anomalyco/opencode#31877.
"""
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]
elif len(non_null) >= 2:
out["anyOf"] = [{"type": t} for t in non_null]
else:
out["type"] = "null" if has_null else "object"
return
if has_null:
out.setdefault("nullable", True)
def _sanitize_node(node: Any, path: str) -> Any:
"""Recursively sanitize a JSON-Schema fragment.
- Bare-string schema values become ``{"type": <value>}`` (unknown strings
become a permissive object schema rather than something backends reject).
- Object-typed nodes gain ``properties: {}`` (llama.cpp can't constrain a
free-form object).
- ``type`` arrays are normalized (see ``_normalize_type_array``).
- Recurses into ``properties``, ``items``, ``additionalProperties``,
``anyOf``/``oneOf``/``allOf`` and ``$defs``/``definitions``; property
keys are renamed to the provider-safe pattern and ``required`` follows.
- ``required`` entries that don't exist in ``properties`` are pruned
(malformed MCP schemas; built-in/plugin tools skip the MCP-level check).
"""
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 are computed up front so ``required`` can be remapped even when
# it precedes ``properties`` in the source dict.
prop_renames: dict[str, str] = {}
if isinstance(node.get("properties"), dict):
prop_renames = _rename_property_keys(node["properties"], f"{path}.properties")
out: dict = {}
for key, value in node.items():
if key == "type" and isinstance(value, list):
_normalize_type_array(value, out)
elif key in {"properties", "$defs", "definitions"} and isinstance(value, dict):
renames = prop_renames if key == "properties" else {}
out[key] = {
renames.get(sub_k, sub_k): _sanitize_node(sub_v, f"{path}.{key}.{renames.get(sub_k, sub_k)}")
for sub_k, sub_v in value.items()
}
elif key in {"items", "additionalProperties"}:
# Bool ``additionalProperties`` is valid and widely accepted;
# ``items: true/false`` is non-standard but preserved rather than dropped.
out[key] = value if isinstance(value, bool) else _sanitize_node(value, f"{path}.{key}")
elif key in {"anyOf", "oneOf", "allOf"} and isinstance(value, list):
out[key] = [_sanitize_node(item, f"{path}.{key}[{i}]") for i, item in enumerate(value)]
elif key in _NON_SCHEMA_LIST_KEYS:
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
else:
out[key] = _sanitize_node(value, f"{path}.{key}") if isinstance(value, (dict, list)) else value
if out.get("type") == "object":
if not isinstance(out.get("properties"), dict):
out["properties"] = {}
if isinstance(out.get("required"), list):
props = out.get("properties") or {}
valid = [r for r in out["required"] if isinstance(r, str) and r in props]
if not valid:
out.pop("required", None)
elif len(valid) != len(out["required"]):
out["required"] = valid
return out
# =============================================================================
# Reactive strips — only invoked after a backend rejects a schema
# =============================================================================
_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"})
def _reactive_strip(tools: list[dict], strip_node: Callable[[dict], int], log_msg: str) -> tuple[list[dict], int]:
"""Walk every tool's parameters in place, applying *strip_node* to each dict
node (it returns how many keywords it removed). Handles OpenAI format
(``{"function": {"parameters": ...}}``) and Responses format
(``{"name": ..., "parameters": ...}`` — codex_responses mode, xAI, etc.).
Returns ``(tools, stripped_count)`` — the same list reference."""
if not tools:
return tools, 0
stripped = 0
def _walk(node: Any) -> None:
nonlocal stripped
if isinstance(node, dict):
stripped += strip_node(node)
for v in node.values():
_walk(v)
elif isinstance(node, list):
for item in node:
_walk(item)
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict) and isinstance(fn.get("parameters"), dict):
_walk(fn["parameters"])
continue
if isinstance(tool.get("parameters"), dict):
_walk(tool["parameters"])
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`` keywords from tool schemas, in place.
Reactive: invoked only after llama.cpp's grammar converter rejected a
schema with HTTP 400. Its regex engine supports a small ECMAScript subset
(no ``\\d``/``\\w``/``\\s``) and most ``format`` values; cloud providers rely
on these as prompting hints, so they stay in the default schema.
Only strips as a sibling of ``type``/combinators (i.e. on schema nodes), so
a property literally *named* ``pattern`` (``search_files``) is untouched —
property names live inside ``properties``, not beside ``type``.
"""
def _strip(node: dict) -> int:
if not ("type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node):
return 0
hits = [k for k in node if k in _STRIP_ON_RECOVERY_KEYS]
for k in hits:
node.pop(k, None)
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 ``/v1/responses`` and ``/v1/chat/completions`` compile schemas to a
grammar that rejects ``/`` in enum values (HTTP 400 before any token) —
typically MCP enums of HuggingFace model IDs or owner/name env IDs. The
constraint is a prompting hint only; the model still sees the description.
"""
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):
node.pop("enum", None)
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)",
)