fix(streaming): flag empty tool-call args on clean stream end (#80498)

When the stream closes right after a tool call's name arrives but
before any argument bytes are delivered, has_truncated_tool_args
was never set (the existing check required a non-empty, whitespace-
stripped arguments buffer). The call fell through to a normal "stop"
finish_reason, later coerced to "{}" at dispatch and executed
silently with no arguments and no retry.

Route this case through the same dropped-mid-tool-call stub/retry
path already used for partially-truncated JSON.
This commit is contained in:
joaomarcos 2026-08-06 19:33:39 -03:00
parent 0957277f2f
commit 015a114a29
2 changed files with 69 additions and 0 deletions

View File

@ -3434,6 +3434,19 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
else:
# Unrepairable — flag for truncation handling
has_truncated_tool_args = True
elif finish_reason is None:
# Stream ended with no finish_reason AND this tool call's
# arguments never received a single byte (name arrived,
# argument generation never started before the connection
# died). Left unflagged, this fell through to
# `effective_finish_reason = finish_reason or "stop"`
# below — a normal "stop" turn carrying a tool call whose
# empty arguments string later gets silently coerced to
# "{}" at the dispatch boundary and executed with no
# arguments and no retry (#80498). Route it through the
# same dropped-mid-tool-call stub path already used for a
# truncated-but-nonempty JSON string.
has_truncated_tool_args = True
mock_tool_calls.append(SimpleNamespace(
id=tc["id"],
type=tc["type"],

View File

@ -151,6 +151,62 @@ class TestCleanStreamEndMidToolCall:
# ── Clean stream-end before any argument byte arrives (#80498) ─────────────
class TestCleanStreamEndBeforeAnyToolArgs:
"""The upstream closes the SSE stream cleanly right after delivering the
tool NAME not a single byte of the arguments delta ever arrived, no
exception, no finish_reason, no [DONE].
Before the fix, an empty ``arguments`` string skipped the
truncated-JSON check entirely (it only ran when ``arguments and
arguments.strip()``), so ``has_truncated_tool_args`` stayed False. With
no other guard catching this shape, the stub-builder fell through to
``effective_finish_reason = finish_reason or "stop"`` and returned a
normal "stop" turn carrying a tool call with ``arguments=""`` which
the dispatch boundary silently coerces to "{}" and executes with no
retry (#80498, e.g. ``write_file`` running with no arguments).
"""
@patch("run_agent.AIAgent._create_request_openai_client")
@patch("run_agent.AIAgent._close_request_openai_client")
def test_empty_tool_args_routes_to_stub_not_silent_empty_object(
self, _mock_close, mock_create, monkeypatch,
):
def _clean_ending_stream():
# Tool name arrives, then the generator simply RETURNS
# (StopIteration) before any arguments delta chunk — no raise,
# no finish_reason chunk, no [DONE].
yield _make_stream_chunk(tool_calls=[
_make_tool_call_delta(index=0, tc_id="call_x", name="write_file"),
])
# falls off the end — clean close, no terminator, zero args bytes
mock_client = MagicMock()
mock_client.chat.completions.create.side_effect = (
lambda *a, **kw: _clean_ending_stream()
)
mock_create.return_value = mock_client
agent = _make_agent()
agent._fire_stream_delta = lambda text: None
response = agent._interruptible_streaming_api_call({})
assert response.id == PARTIAL_STREAM_STUB_ID, (
"A tool call whose arguments never started streaming before a "
"clean stream end must be tagged as a partial-stream stub, not "
"silently returned as a completed 'stop' turn with empty "
"arguments (#80498)."
)
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
assert response.choices[0].message.tool_calls is None, (
"A tool call with zero argument bytes delivered must never "
"auto-execute with a silently substituted empty object."
)
assert getattr(response, "_dropped_tool_names", None) == ["write_file"]
# ── Length-continuation prompt branching ──────────────────────────────────
class TestLengthContinuationPromptBranching: