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:
parent
0957277f2f
commit
015a114a29
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue