Skip to content

fix(llm): fail closed on malformed native tool calls - #144

Open
canblmz1 wants to merge 6 commits into
AtomicBot-ai:mainfrom
canblmz1:fix/native-tool-call-integrity
Open

fix(llm): fail closed on malformed native tool calls#144
canblmz1 wants to merge 6 commits into
AtomicBot-ai:mainfrom
canblmz1:fix/native-tool-call-integrity

Conversation

@canblmz1

Copy link
Copy Markdown

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:

  • malformed non-empty tool arguments were silently parsed as {} after JSON.parse failed
  • a tool-call stream ending by bare EOF with neither finish_reason nor [DONE] was treated like a confirmed completion

That allowed malformed or unconfirmed tool arguments to reach the normal execution path.

Fix

  • malformed non-empty tool arguments now surface through the existing parse failure / repair path instead of becoming {}
  • the stream consumer records whether a terminal signal was actually observed
  • pending tool calls whose stream ends without a terminal signal route through the existing truncated/model-failure path before dispatch

Legitimate zero-argument calls remain supported.

A provider that sends an explicit finish_reason without [DONE] remains accepted, explicit finish_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:

  • healthy valid tool call -> executes once
  • malformed args + clean terminal -> 0 executions
  • malformed args + abrupt EOF -> 0
  • container-level truncation + EOF -> 0
  • syntactically complete args + ambiguous EOF -> 0
  • explicit finish_reason: "length" -> 0
  • parallel complete + truncated under ambiguous EOF -> 0 / 0
  • stream read error -> 0

Also verified:

  • explicit finish_reason without [DONE] remains accepted
  • plain-text bare-EOF behavior remains unchanged

Tests

  • npm run lint (typecheck): clean
  • npm run build: clean
  • Targeted suites (openai provider/stream-consumer/tool-call-adapter, step-executor, reliability, parallel-tool-calls integration, plus the new execution-integrity suite): 135/135 passed
  • Full suite: 3983 passed, 20 failed, 5 skipped (4008 total)
  • The same 20 failures reproduce identically on unmodified current main (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.

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.
@sosidudku1

Copy link
Copy Markdown
Collaborator

Thanks for this. The failure mode you are closing is real and it is the expensive kind: a truncated function.arguments string silently became {} and the tool still ran. For os.fs.delete or os.shell.run that turns "the stream got cut mid-argument" into "we executed something the model never asked for", and in unattended runs nobody is watching when it happens.

The test design is also a step above the usual: driving the real OpenAiProvider and the real executeStep() with a counting no-op tool proves the tool never got invoked, rather than proving a function returned an error. The redaction test on ToolCallArgumentsParseError and the two compatibility tests for providers that omit [DONE] show you thought about the blast radius.

I merged this onto current main and ran it. It merges clean, npm run lint is clean, and the full suite is 4851 passed / 2 failed, with both failures reproducing identically on unmodified main (send-message-concurrency, fs-glob-real). So nothing here regresses the suite.

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 (openai-provider.ts:230)

hasPendingToolCalls reads streamFinal.toolCalls, but on the taggedToolCompatibility: "qwen" path the tool calls do not exist yet at that point. They are synthesised from content by adaptQwenCompletionResult at line 154, which runs after completionFromStreamFinal at line 137 has already computed truncated and stop.

Probe on the merged tree, qwen-compat provider, tagged call in content, connection closed with no finish_reason and no [DONE]:

QWEN   -> toolCalls: 1 | truncated: false | stop: true | finishReason: tool_calls
NATIVE -> toolCalls: 1 | truncated: true  | stop: false

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 (openai-stream-consumer.ts:47)

terminalObserved is only assigned inside the \n\n-delimited event loop (line 55). If a provider or proxy sends a valid terminal chunk as the last bytes without the trailing \n\n, that event is never parsed, so neither branch that sets terminalObserved is reached.

Differential probe, identical request body, only the tree differs:

clean main:  finishReason: tool_calls | truncated: false   -> call executes
with PR:     finishReason: null       | truncated: true    -> ModelError

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. detectModelFailure turns truncated: true into a thrown ModelError("truncated") before the parser ever runs, and unlike reason "empty", "truncated" has no exemption in isNativeToolsEmptyCompletionHandledByParser. So there is no repair round-trip and no recovery: every request to such a provider fails. Flushing the tail buffer as a final event at EOF would cover it.

Two smaller ones, not blocking:

3. openai-tool-call-adapter.ts:143 The catch is unqualified, so any error from the try block gets relabelled ToolCallArgumentsParseError(name) and the original is dropped. Only SyntaxError from JSON.parse is intended here. If something unrelated ever throws inside that block, the operator and the repair prompt both get told the arguments were malformed, the one-shot repair is spent on a non-problem, and the real cause never reaches the logs. Narrowing to SyntaxError (and letting anything else through) keeps the diagnostic honest.

4. native-tool-call-execution-integrity.test.ts:244 Test 7 hand-builds its CompletionResult with truncated: true instead of driving a real stream, so it exercises only the step-executor half. Its comment says the literal is "exactly what completionFromStreamFinal() now derives", but nothing in the suite enforces that. If the provider-side computation regresses for parallel calls, test 7 stays green. Worth driving it through a real fetch like tests 1 to 6, or at least asserting the provider derives that shape.

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.

@canblmz1

Copy link
Copy Markdown
Author

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:

  1. Qwen tagged calls — the termination-safety decision now runs after adaptQwenCompletionResult(), so it sees the final dispatchable toolCalls for both native and tagged paths. A synthetic finishReason: "tool_calls" from the adapter no longer counts as proof that the provider actually terminated cleanly. I added coverage for both ambiguous-EOF Qwen (blocked) and explicitly terminated Qwen (allowed).

  2. Final SSE event without the trailing blank line — on EOF the consumer now flushes the TextDecoder and runs any remaining non-empty buffer through the same parseOpenAiSseEvent() path as delimiter-separated events. I added execution-level cases for both a terminal finish_reason event and [DONE] arriving as the final bytes with no trailing \n\n.

  3. Broad catch — agreed. Only SyntaxError is translated to ToolCallArgumentsParseError now; unrelated exceptions are rethrown unchanged.

  4. Parallel-call test — Test 7 no longer hand-builds truncated: true. It now drives a real two-call SSE stream through OpenAiProvider, asserts the provider derives truncated: true / stop: false, and then proves neither counting tool is invoked by the real executor.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants