mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix(tool-schemas): reactive strip of pattern/format on llama.cpp grammar 400s
MCP servers commonly emit JSON Schema `pattern` (e.g. `\\d{4}-\\d{2}-\\d{2}`
for date-time params) and `format` keywords. llama.cpp's
`json-schema-to-grammar` converter rejects regex escape classes
(\\d/\\w/\\s) and most format values, returning HTTP 400
"parse: error parsing grammar: unknown escape at \\d" — the whole request
fails.
Cloud providers (OpenAI, Anthropic, OpenRouter, Gemini) accept these
keywords fine and use them as prompting hints. Stripping unconditionally
loses useful hints for every cloud user to fix a llama.cpp-only bug.
Approach: classify the llama.cpp grammar-parse 400 in the error
classifier, and on match do a one-shot in-place strip of pattern/format
from `self.tools`, then retry. Follows the existing
`thinking_signature` recovery pattern. Cloud users hit zero overhead;
llama.cpp users pay one failed request per session.
Changes
- agent/error_classifier.py: new `FailoverReason.llama_cpp_grammar_pattern`
+ narrow HTTP-400 branch matching "error parsing grammar",
"json-schema-to-grammar", or "unable to generate parser ... template".
- tools/schema_sanitizer.py: new `strip_pattern_and_format()` helper —
reactive, walks schema nodes, skips property names (search_files.pattern
survives). Returns strip count for logging.
- run_agent.py: new one-shot recovery block in the retry loop. Strips,
logs, continues. Falls through to normal retry if nothing to strip.
- tests: 4 classifier tests (3 variants + 1 non-400 negative), 7 strip
tests including the property-name preservation and idempotency checks.
Co-authored-by: Chris Danis <cdanis@gmail.com>
This commit is contained in:
parent
542e06c789
commit
28f4d6db63
5 changed files with 280 additions and 1 deletions
|
|
@ -255,3 +255,75 @@ def _sanitize_node(node: Any, path: str) -> Any:
|
|||
out["required"] = valid
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Reactive strip — only invoked when llama.cpp rejects a schema
|
||||
# =============================================================================
|
||||
|
||||
_STRIP_ON_RECOVERY_KEYS = frozenset({"pattern", "format"})
|
||||
|
||||
|
||||
def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]:
|
||||
"""Strip ``pattern`` and ``format`` JSON Schema keywords from tool schemas.
|
||||
|
||||
This is a *reactive* sanitizer invoked only when llama.cpp's
|
||||
``json-schema-to-grammar`` converter has rejected a tool schema with an
|
||||
HTTP 400 grammar-parse error. llama.cpp's regex engine supports only a
|
||||
small subset of ECMAScript regex (literals, ``.``, ``[...]``, ``|``,
|
||||
``*``, ``+``, ``?``, ``{n,m}``) — it rejects escape classes like ``\\d``,
|
||||
``\\w``, ``\\s`` and most ``format`` values. Cloud providers (OpenAI,
|
||||
Anthropic, OpenRouter, Gemini) accept these keywords fine and rely on
|
||||
them as prompting hints, so we keep them in the default schema and only
|
||||
strip on demand.
|
||||
|
||||
The strip operates on a sibling of ``type`` (so schema keywords are
|
||||
removed) — a property literally *named* ``pattern`` (e.g. the first arg
|
||||
of the built-in ``search_files`` tool) is not affected because property
|
||||
names live in the ``properties`` dict, not as siblings of ``type``.
|
||||
|
||||
Args:
|
||||
tools: OpenAI-format tool list, mutated in place for efficiency.
|
||||
Callers that need to preserve the original should deep-copy first.
|
||||
|
||||
Returns:
|
||||
``(tools, stripped_count)`` — the same list reference plus a count of
|
||||
how many ``pattern``/``format`` keywords were removed across all tools.
|
||||
"""
|
||||
if not tools:
|
||||
return tools, 0
|
||||
|
||||
stripped = 0
|
||||
|
||||
def _walk(node: Any) -> None:
|
||||
nonlocal stripped
|
||||
if isinstance(node, dict):
|
||||
# Only strip as a sibling of ``type`` — i.e. when this node is
|
||||
# itself a schema. This avoids stripping literal property keys
|
||||
# named "pattern" (search_files.pattern, etc.) because those live
|
||||
# inside a ``properties`` dict, not as siblings of ``type``.
|
||||
is_schema_node = "type" in node or "anyOf" in node or "oneOf" in node or "allOf" in node
|
||||
for key in list(node.keys()):
|
||||
if is_schema_node and key in _STRIP_ON_RECOVERY_KEYS:
|
||||
node.pop(key, None)
|
||||
stripped += 1
|
||||
continue
|
||||
_walk(node[key])
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_walk(item)
|
||||
|
||||
for tool in tools:
|
||||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||
if isinstance(fn, dict):
|
||||
params = fn.get("parameters")
|
||||
if isinstance(params, dict):
|
||||
_walk(params)
|
||||
|
||||
if stripped:
|
||||
logger.info(
|
||||
"schema_sanitizer: stripped %d pattern/format keyword(s) from "
|
||||
"tool schemas (llama.cpp grammar-parse recovery)",
|
||||
stripped,
|
||||
)
|
||||
return tools, stripped
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue