fix(serve): repair invalid backslash escapes in typed tool-call parameters - #101
fix(serve): repair invalid backslash escapes in typed tool-call parameters#101gregtakacs wants to merge 2 commits into
Conversation
…eters A strictly-typed (array/object/...) tool-call parameter whose JSON value contains a backslash the model didn't double for JSON escaping (e.g. a literal shell/regex \* or \| embedded in a run_commands string argument -- legal in the shell command itself, not a legal JSON escape on its own: JSON only allows \" \\ \/ \b \f \n \r \t \uXXXX) made the strict decoder reject the entire tool call outright and fall back to raw text, discarding an otherwise well-formed call. Retry once against a copy with only the illegal backslashes doubled before giving up. Genuinely broken JSON (truncated/mismatched brackets, not just an escaping quirk) still fails after repair and correctly falls back, same as before. AI usage disclosure: implementation and tests generated entirely by Claude Sonnet 5 (claude-sonnet-5), reviewed and verified by the submitter.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59a4ad0100
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (in_string && c == '\\') { | ||
| constexpr std::string_view kLegalEscapes = "\"\\/bfnrtu"; | ||
| const bool has_next = i + 1 < text.size(); | ||
| if (has_next && kLegalEscapes.find(text[i + 1]) != std::string_view::npos) { |
There was a problem hiding this comment.
Validate full Unicode escapes before preserving them
When a typed object or array contains an undoubled backslash followed by u but not four hexadecimal digits—for example the model output C:\users—this condition classifies \u as legal without inspecting the required digits. The repaired text is therefore unchanged, the retry still fails JSON parsing, and the entire otherwise valid tool call falls back to plain content. Preserve \u only when a complete four-hex-digit escape follows; otherwise double the backslash like the other invalid escapes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch, fixed in 8ef19f2 — now requires exactly 4 hex digits after \u before treating it as legal (matching JSON's actual \uXXXX grammar); anything else, including a bare \u, now gets the backslash doubled like any other illegal escape. Added regression coverage for both the Windows-path case (C:\users\greg) and a genuine \uXXXX escape to confirm it still round-trips untouched.
Addresses review feedback: a bare/incomplete \u (not followed by exactly four hex digits, e.g. a Windows-style path like "C:\users\greg") was being treated as an already-legal escape and left unrepaired, so the retry still failed to parse and the whole tool call still incorrectly fell back to plain content -- the same bug this PR fixes, just for a very common input shape the original repair didn't cover. Now only a complete \uXXXX (four valid hex digits) is preserved untouched; anything else after a backslash, including a bare \u, gets the backslash doubled like any other illegal escape. A genuine \uXXXX escape (verified with a non-ASCII case) still round-trips untouched. AI usage disclosure: this fix and its test additions were also generated entirely by Claude Sonnet 5 (claude-sonnet-5), reviewed by the submitter.
|
Thanks for the report. However, ninfer intentionally does not repair malformed model output. It follows the declared tool call contract and json schema instead. |
|
Understood, thanks for the quick response — makes sense as a deliberate design boundary. We'll keep this as a local patch on our end. Appreciate the review either way, including catching the \u edge case. |
Concrete problem
A strictly-typed (
array/object/...) tool-call parameter whose JSON value contains a backslash the model didn't double for JSON escaping makesparse_parameter()insrc/serve/tool_call_parser.cppreject the entire tool call and fall back to returning the raw<tool_call>...text as plain content, discarding an otherwise completely well-formed call.Concretely: a
run_commandstool with anarray-typedcommandsparameter, where the model emits a shell command containing a literal regex/grep backslash escape (e.g.grep -n '^\*\|^* ').\*and\|aren't legal JSON string escapes (JSON only allows\"\\\/\b\f\n\r\t\uXXXX), soJson::parsediscards the value,parse_parameterreturnsfalse, and the caller falls all the way back tofallback(text)— even though the tool-call structure itself (tags, function name, other parameters) was perfectly valid.This is a different failure mode from #10 (structural tag/wrapper drift): the JSON here is syntactically complete, just contains one un-escaped backslash inside an otherwise well-formed value, and it's the first call in the response, so #10's tolerant recovery (which only engages once at least one call has already been successfully parsed) never gets a chance to help.
Reproduced against a real
qwen3_8_27b_nvfp4NVFP4 artifact under Cline-style long agentic tool-use sessions.Design and why it's appropriate
In the strict (non-string, contract-typed) decode branch of
parse_parameter, retry once against a repaired copy of the value before giving up:repair_invalid_json_escapesis a narrow, single-pass repair: it tracks whether it's inside a JSON string and doubles a backslash only when it isn't already the first character of a legal JSON escape (\" \\ \/ \b \f \n \r \t \u). Legal escapes are copied through byte-for-byte untouched. It never touches structural characters outside strings, and it's applied only as a fallback after strict decode already failed — the common (already-valid) case never runs it, and genuinely broken JSON (truncated strings, mismatched brackets — not just an escaping quirk) still fails to parse after repair and correctly falls through to the existing rejection path unchanged.This is deliberately scoped to the exact failure observed rather than a general lenient-JSON parser: it repairs only the specific defect (an un-doubled backslash), so it can't silently accept or misinterpret other classes of malformed input.
Affected behavior/contract
parse_qwen_tool_call_output's typed-parameter decode path insrc/serve/tool_call_parser.cpponly. No CLI flag, no public API change, no schema version bump — this is purely an internal parsing robustness fix. Untyped ("legacy"/no-contract) parameters were already unaffected by this failure (they degrade to a string on parse failure rather than rejecting the call), so this only changes behavior for tools with explicit non-string JSON Schema parameter types.Verification
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON cmake --build build --parallel --target ninfer_tool_call_parser_test ./build/tests/ninfer_tool_call_parser_testResult:
ok(all cases pass), built clean against currentmaster(2190c4a1) in annvidia/cuda:13.3.1-devel-ubuntu24.04container.Added
test_typed_parameter_repairs_invalid_backslash_escapeintests/test_tool_call_parser.cpp, covering:arrayparameter with an un-doubled\*/\|is now recovered, decoding to the correct literal-backslash string;\n,\") round-trips untouched by the repair pass;No performance claim is made; this is a correctness fix on an already-failing path, not a modification to a hot loop (only reached once, after a strict decode has already failed).
Not run
GPU-dependent tests (anything requiring an actual device/artifact) were not run — this change doesn't touch engine/runtime code, only the OpenAI-facing tool-call text parser, and the added test is a pure CPU unit test.
AI usage disclosure
This implementation and its test were generated entirely by Claude Sonnet 5 (
claude-sonnet-5). I (the submitter) reviewed the complete diff, reasoned through the escape-handling edge cases (legal-escape preservation, string-boundary tracking, genuinely-broken-JSON non-recovery) and the verification above, and take full responsibility for this change.