fix(chat-completions): coerce integer tool_call id in non-streaming normalize_response - #58562
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a provider-boundary type mismatch in the non-streaming chat-completions transport by coercing tool_call.id from int to str during response normalization, preventing tool-call ID divergence between assistant tool calls and subsequent tool-result messages (which can trigger HTTP 400 replay failures).
Changes:
- Coerce non-streaming
tool_call.idvalues fromint → strinChatCompletionsTransport.normalize_response. - Add a regression test covering Poolside’s integer
tool_call.idwire shape on the non-streaming path.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
agent/transports/chat_completions.py |
Coerces integer tool_call.id to str during normalization to keep tool-call IDs consistent for replay. |
tests/agent/transports/test_chat_completions.py |
Adds a regression test asserting tool_call.id is normalized to a string when provided as an integer. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for the focused follow-up. The premise remains valid on current The proposed coercion matches the already-shipped streaming handling in The target block and neighboring test remain present on current main; this should be straightforward to salvage despite the stale branch. Automated hermes-sweeper review. |
…ormalize_response
Poolside returns an integer tool_call.id on the non-streaming
ChatCompletionsTransport.normalize_response path (stream-unsupported
fallback, ACP/MoA/gateway/health-check callers). ToolCall.id is typed
str | None and downstream id-pairing assumes str: build_assistant_message
routes a non-str id through _split_responses_tool_id, which rejects it and
substitutes a synthetic _deterministic_call_id string, while
make_tool_result_message stores the raw int as tool_call_id. The assistant
tool_calls[].id (synthetic str) then diverges from the paired tool result's
tool_call_id (int), and the next replay turn is rejected by the provider
with HTTP 400 ("tool_call_id did not match a preceding tool_calls").
Coerce int -> str for the tool_call.id, mirroring the integer finish_reason
coercion 25 lines above. Sibling follow-up to 55e7986, which coerced the
integer tool_call.id only in the streaming helper.
5d68204 to
60c5319
Compare
This is a sibling follow-up to #58502 (commit 55e7986)
finish_reasoninnormalize_response, andtool_call.idin the streaming path (chat_completion_helpers.py).normalize_responsetool_call.id atagent/transports/chat_completions.py:639— the same integer-id shape reaches it there.int → strcoercion for the non-streaming path, mirroring thefinish_reasoncoercion 25 lines above.What does this PR do?
Coerces an integer
tool_call.idtostrinChatCompletionsTransport.normalize_responseso a Poolside tool turn on the non-streaming path (stream-unsupported fallback viaagent._disable_streaming, ACP/MoA-no-consumer callers, gateway/health-check probes) does not persist an int id.Left uncoerced, the assistant message's
tool_calls[].idbecomes a synthetic_deterministic_call_idstring (because_split_responses_tool_idguardsisinstance(raw_id, str)and falls through on a non-str id), while the paired tool result stores the raw int viamake_tool_result_message. The two diverge, and the next replay turn is rejected by the provider with HTTP 400 ("tool_call_id did not match a preceding tool_calls"). Poolside is a first-classCANONICAL_PROVIDERSentry (CLI/TUI/desktop picker), so this path is user-reachable.Related Issue
Sibling follow-up to #58502 (commit 55e7986). No separate issue.
Type of Change
Changes Made
agent/transports/chat_completions.py: innormalize_response, coerce an integertool_call.idtostrbefore constructingToolCall, mirroring the existing integerfinish_reasoncoercion.tests/agent/transports/test_chat_completions.py: addtest_tool_call_integer_id_coerced_to_stringregression test.How to Test
uv run --with pytest --with pytest-asyncio python3 -m pytest tests/agent/transports/test_chat_completions.py -q→ 83 passed.nr.tool_calls[0].id == "24"andisinstance(... , str). With the coercion reverted it fails (assert 24 == '24'); with the fix it passes.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/agent/transports/test_chat_completions.py -qand all tests passast.parse(feature_version=(3,9)))Documentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A