mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 00:41:43 +08:00
Compare commits
1 Commits
fix/lazy-s
...
opencode-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29c850058f |
@@ -15,6 +15,16 @@ and MoonshotAI/kimi-cli#1595:
|
||||
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||||
the parent. Presence of both causes "type should be defined in anyOf
|
||||
items instead of the parent schema".
|
||||
3. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the
|
||||
reference before validation and then rejects the node if sibling keys
|
||||
like ``description`` remain on the same node as ``$ref``. Strip every
|
||||
sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives.
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
4. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]``
|
||||
for positional element schemas). Moonshot's schema engine requires a
|
||||
single object schema applied to every array element. Collapse tuple
|
||||
``items`` to the first element schema (or ``{}`` if the tuple is empty).
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -66,6 +76,16 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
}
|
||||
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
|
||||
repaired[key] = [_repair_schema(v, is_schema=True) for v in value]
|
||||
elif key == "items" and isinstance(value, list):
|
||||
# Rule 4: tuple-style ``items`` arrays (positional element
|
||||
# schemas) are not accepted by Moonshot. Collapse to the
|
||||
# first element schema if present, else to ``{}``. This
|
||||
# matches opencode's behaviour for moonshotai / kimi models.
|
||||
first = value[0] if value else {}
|
||||
if isinstance(first, dict):
|
||||
repaired[key] = _repair_schema(first, is_schema=True)
|
||||
else:
|
||||
repaired[key] = first
|
||||
elif key in _SCHEMA_NODE_KEYS:
|
||||
# items / not / additionalProperties: single nested schema.
|
||||
# additionalProperties can also be a bool — leave those alone.
|
||||
@@ -85,10 +105,12 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
repaired.pop("type", None)
|
||||
return repaired
|
||||
|
||||
# Rule 1: property schemas without type need one. $ref nodes are exempt
|
||||
# — their type comes from the referenced definition.
|
||||
# Rule 3: $ref nodes must not have sibling keywords. Strip everything
|
||||
# except $ref itself so Moonshot's validator (which expands the ref
|
||||
# before checking) doesn't reject the node for redundant keys like
|
||||
# ``description`` / ``type`` / ``default`` appearing alongside $ref.
|
||||
if "$ref" in repaired:
|
||||
return repaired
|
||||
return {"$ref": repaired["$ref"]}
|
||||
return _fill_missing_type(repaired)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ the JSON Schema ecosystem accepts:
|
||||
1. Properties without ``type`` — Moonshot requires ``type`` on every node.
|
||||
2. ``type`` at the parent of ``anyOf`` — Moonshot requires it only inside
|
||||
``anyOf`` children.
|
||||
3. ``$ref`` with sibling keywords — Moonshot expands the ref first and then
|
||||
rejects ``description``/``type`` siblings on the same node.
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
4. Tuple-style ``items`` arrays — Moonshot requires a single item schema,
|
||||
not positional ones. (Ported from anomalyco/opencode#24730.)
|
||||
|
||||
These tests cover the repairs applied by ``agent/moonshot_schema.py``.
|
||||
"""
|
||||
@@ -153,6 +158,160 @@ class TestAnyOfParentType:
|
||||
assert "type" in children[1]
|
||||
|
||||
|
||||
class TestRefSiblingStripping:
|
||||
"""Rule 3: ``$ref`` nodes may not carry sibling keywords on Moonshot.
|
||||
|
||||
Ported from anomalyco/opencode#24730. The real-world failure was MCP tools
|
||||
whose generated schemas put a ``description`` on a ``$ref`` property so the
|
||||
model would see the field's human-readable hint. The reference stays — the
|
||||
referenced definition still owns the description (on the target node itself)
|
||||
and still serves the model's context.
|
||||
"""
|
||||
|
||||
def test_description_sibling_stripped_from_ref(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"variantOptions": {
|
||||
"$ref": "#/$defs/VariantOptions",
|
||||
"description": "Required. The variant options for generation.",
|
||||
},
|
||||
},
|
||||
"$defs": {
|
||||
"VariantOptions": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"description": "Configuration options.",
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
# Sibling stripped.
|
||||
assert out["properties"]["variantOptions"] == {"$ref": "#/$defs/VariantOptions"}
|
||||
# The target definition's own description is preserved — we only strip
|
||||
# siblings ON the $ref node, not on the thing it points at.
|
||||
assert out["$defs"]["VariantOptions"]["description"] == "Configuration options."
|
||||
|
||||
def test_multiple_siblings_all_stripped(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p": {
|
||||
"$ref": "#/$defs/T",
|
||||
"type": "object",
|
||||
"description": "x",
|
||||
"default": {},
|
||||
"title": "P",
|
||||
},
|
||||
},
|
||||
"$defs": {"T": {"type": "object"}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["p"] == {"$ref": "#/$defs/T"}
|
||||
|
||||
def test_ref_without_siblings_unchanged(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"p": {"$ref": "#/$defs/T"}},
|
||||
"$defs": {"T": {"type": "object"}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["p"] == {"$ref": "#/$defs/T"}
|
||||
|
||||
def test_ref_inside_anyof_children(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"v": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/$defs/A", "description": "variant A"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"$defs": {"A": {"type": "object"}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
children = out["properties"]["v"]["anyOf"]
|
||||
assert children[0] == {"$ref": "#/$defs/A"}
|
||||
assert children[1] == {"type": "null"}
|
||||
|
||||
|
||||
class TestTupleItems:
|
||||
"""Rule 4: tuple-style ``items`` arrays collapse to a single schema.
|
||||
|
||||
Ported from anomalyco/opencode#24730. Moonshot's schema engine requires
|
||||
``items`` to be ONE schema object applied to every array element; tuple-
|
||||
style positional item schemas are rejected. We collapse to the first
|
||||
element's schema (which is the "closest" interpretation of positional →
|
||||
single) and drop the rest.
|
||||
"""
|
||||
|
||||
def test_tuple_items_collapsed_to_first(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"renderedSize": {
|
||||
"type": "array",
|
||||
"items": [{"type": "number"}, {"type": "number"}],
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["renderedSize"]["items"] == {"type": "number"}
|
||||
# Sibling constraints are preserved — only the tuple shape is repaired.
|
||||
assert out["properties"]["renderedSize"]["minItems"] == 2
|
||||
|
||||
def test_empty_tuple_items_becomes_empty_schema(self):
|
||||
# Empty tuple collapses to ``{}``; the generic repair then fills a
|
||||
# synthetic ``type`` because Moonshot requires ``type`` on every
|
||||
# schema node. Either ``{}`` or ``{"type": "string"}`` is a valid
|
||||
# final shape for Moonshot — both accept any string element — but we
|
||||
# always go through ``_fill_missing_type`` so the result is fully
|
||||
# well-formed without needing the consumer to patch it later.
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"things": {"type": "array", "items": []},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
items = out["properties"]["things"]["items"]
|
||||
# Must be a dict and must carry a ``type`` (the whole point of Rule 1).
|
||||
assert isinstance(items, dict)
|
||||
assert items.get("type")
|
||||
|
||||
def test_tuple_items_first_element_is_repaired(self):
|
||||
# The first element itself has a missing type — it should be filled.
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pair": {
|
||||
"type": "array",
|
||||
"items": [{"description": "first"}, {"description": "second"}],
|
||||
},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
# Repaired to a single schema with a synthetic type.
|
||||
assert out["properties"]["pair"]["items"] == {
|
||||
"description": "first",
|
||||
"type": "string",
|
||||
}
|
||||
|
||||
def test_single_schema_items_unchanged(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tags": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["tags"]["items"] == {"type": "string"}
|
||||
|
||||
|
||||
class TestTopLevelGuarantees:
|
||||
"""The returned top-level schema is always a well-formed object."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user