fix(llm): fail closed on malformed native tool calls - #144
Conversation
Two behaviors combined to let a truncated or malformed native
OpenAI-compatible tool call reach real execution:
1. parseArguments() caught any JSON.parse failure on a tool call's
arguments and silently returned {} instead of surfacing an error -
a truncated argument string was indistinguishable from an
intentional empty call.
2. A tool-call stream ending by bare EOF, with no provider
finish_reason and no [DONE], was treated identically to a
confirmed clean completion (finishReason -> null, stop -> true,
truncated -> false). Since native tool calls with empty text
content are intentionally allowed through when toolCalls exist,
nothing stopped the unconfirmed call from reaching dispatch.
Fix:
- parseArguments() now throws on genuinely non-empty malformed JSON
(or JSON that parses to something other than an object) instead of
substituting {}. A legitimately empty/whitespace argument string
still maps to {}. The failure surfaces through
openAiToolCallsToBatch() as ToolCallArgumentsParseError, which
reaches tryParseToolCalls()'s existing catch block and routes
through the same one-shot repair path grammar-parsed batches
already use - no new error subsystem. The raw arguments are never
included in the error message, since they may carry sensitive data
that reaches logs.
- The stream consumer now tracks whether a trustworthy terminal
signal was actually observed (an explicit provider finish_reason on
any chunk, or a parser-recognized [DONE]) before the stream ended.
completionFromStreamFinal() folds an unconfirmed pending tool call
into the existing truncated/stop computation, so it is caught by
detectModelFailure's first check before parseArguments or
validateBatch are ever reached. A provider that sends finish_reason
without [DONE] remains accepted; explicit finish_reason: "length"
is unchanged; plain-text-only responses are unaffected, since the
fix only applies when a tool call is actually pending.
Verified with an execution-level regression suite driving the real
OpenAiProvider, step executor, and ToolRegistry with an instrumented
no-op tool: a malformed or unconfirmed call now executes zero times
in every case that previously executed once - malformed args under a
clean terminal, malformed args under a bare-EOF stream, container-
level truncation under a bare-EOF stream, syntactically complete args
under a bare-EOF stream, and the same case duplicated across parallel
calls. A healthy call still executes exactly once, explicit
finish_reason: "length" remains zero executions, and a stream read
error still propagates as a rejection rather than a silent no-op.
|
Thanks for this. The failure mode you are closing is real and it is the expensive kind: a truncated The test design is also a step above the usual: driving the real I merged this onto current Two things I would want resolved before this lands, both found by tracing the flag rather than by the tests. 1. The qwen tagged path gets no protection at all (
Probe on the merged tree, qwen-compat provider, tagged call in Same ambiguous termination, same dispatchable call, opposite verdict. The native path is protected and the qwen path is wide open. Either the guard needs to move after the adapt seam (or run again there), or the PR description should state the qwen path is out of scope and why, so it does not come back as a follow-up issue. 2. A final chunk without its trailing blank line is now misread as ambiguous (
Differential probe, identical request body, only the tree differs: This one worries me more than a missed detection, because it is a false positive on a working path and there is no way back. Two smaller ones, not blocking: 3. 4. On the whole: right diagnosis, right instinct to fail closed, and the zero-arg case is handled correctly. It is finding 2 that I think has to change before merge, since it converts a working provider into a hard failure. |
|
Thanks for tracing this past the tests — both blockers were real, and the second one in particular caught a false-positive failure mode I definitely wouldn't want to ship. I've updated the existing branch and addressed all four points:
I also merged the current upstream main into the branch so the PR is now based on the same tree you're reviewing against, without a force-push. I don't have a fresh full-suite execution to quote from this environment, so I don't want to recycle the previous numbers as if they covered these follow-up changes. The regression coverage is on the branch now, and I'd appreciate a rerun of the same current-main suite when you get a chance. Thanks again for the careful review. The Qwen seam and especially the undelimited terminal event were exactly the kind of cross-path details this change needed checked |
Summary
Prevents native OpenAI-compatible tool calls from executing when their arguments are malformed or when a tool-call stream ends without a confirmed terminal signal.
Root cause
Two behaviors combined:
{}afterJSON.parsefailedfinish_reasonnor[DONE]was treated like a confirmed completionThat allowed malformed or unconfirmed tool arguments to reach the normal execution path.
Fix
{}Legitimate zero-argument calls remain supported.
A provider that sends an explicit
finish_reasonwithout[DONE]remains accepted, explicitfinish_reason: "length"behavior is unchanged, and plain-text-only responses are unaffected.Validation
Execution-level regression coverage using the real provider, step executor, ToolRegistry, and instrumented no-op tools:
finish_reason: "length"-> 0Also verified:
finish_reasonwithout[DONE]remains acceptedTests
npm run lint(typecheck): cleannpm run build: cleanmain(same test names, same files) — confirmed by running the full suite on both in the same session. No new failure names introduced by this change.