Skip to content

Commit e6a3ed8

Browse files
fix: avoid duplicating content and signed thinking blocks across parallel tool-call splits (openai#3261)
1 parent e86dff2 commit e6a3ed8

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

src/agents/extensions/models/any_llm_model.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,14 +1203,28 @@ def _fix_tool_message_ordering(
12031203
if message_dict.get("role") == "assistant" and message_dict.get("tool_calls"):
12041204
tool_calls = message_dict.get("tool_calls", [])
12051205
if isinstance(tool_calls, list):
1206+
split_idx = 0
12061207
for tool_call in tool_calls:
12071208
if isinstance(tool_call, dict) and tool_call.get("id"):
1209+
# Create a separate assistant message for each tool call.
1210+
# Only the first split keeps the assistant text/thinking
1211+
# blocks/reasoning content; the rest carry tool_calls only,
1212+
# to avoid duplicating signed thinking blocks (which
1213+
# Anthropic rejects) and assistant text in history.
12081214
single_tool_msg = message_dict.copy()
12091215
single_tool_msg["tool_calls"] = [tool_call]
1216+
if split_idx > 0:
1217+
for shared_field in (
1218+
"content",
1219+
"thinking_blocks",
1220+
"reasoning_content",
1221+
):
1222+
single_tool_msg.pop(shared_field, None)
12101223
tool_call_messages[str(tool_call["id"])] = (
12111224
index,
12121225
cast(ChatCompletionMessageParam, single_tool_msg),
12131226
)
1227+
split_idx += 1
12141228
elif message_dict.get("role") == "tool" and message_dict.get("tool_call_id"):
12151229
tool_result_messages[str(message_dict["tool_call_id"])] = (
12161230
index,

tests/models/test_any_llm_model.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -842,3 +842,57 @@ def test_any_llm_reasoning_objects_prefer_content_attributes_over_iterable_pairs
842842
delta = pytypes.SimpleNamespace(reasoning=Reasoning(content="用户"))
843843

844844
assert _extract_any_llm_reasoning_text(delta) == "用户"
845+
846+
847+
def test_any_llm_split_does_not_duplicate_content_or_thinking(monkeypatch) -> None:
848+
"""Splitting multi-tool assistant messages must not duplicate text/thinking blocks.
849+
850+
Anthropic's extended thinking API rejects requests that include the same signed
851+
thinking block more than once, and duplicated assistant text corrupts conversation
852+
history. Only the first split should retain content, thinking_blocks, and
853+
reasoning_content; subsequent splits should carry the tool_call alone.
854+
"""
855+
provider = FakeAnyLLMProvider(supports_responses=False)
856+
module, _ = _import_any_llm_module(monkeypatch, provider)
857+
AnyLLMModel = module.AnyLLMModel
858+
859+
model = AnyLLMModel(model="anthropic/claude-3-5-sonnet")
860+
messages: list[Any] = [
861+
{"role": "user", "content": "Search both"},
862+
{
863+
"role": "assistant",
864+
"content": "Looking up both queries.",
865+
"thinking_blocks": [{"type": "thinking", "thinking": "plan", "signature": "sig_abc"}],
866+
"reasoning_content": "internal plan",
867+
"tool_calls": [
868+
{
869+
"id": "call_1",
870+
"type": "function",
871+
"function": {"name": "s", "arguments": "{}"},
872+
},
873+
{
874+
"id": "call_2",
875+
"type": "function",
876+
"function": {"name": "s", "arguments": "{}"},
877+
},
878+
],
879+
},
880+
{"role": "tool", "tool_call_id": "call_1", "content": "ok1"},
881+
{"role": "tool", "tool_call_id": "call_2", "content": "ok2"},
882+
]
883+
884+
result = model._fix_tool_message_ordering(messages)
885+
886+
assistants = [m for m in result if m.get("role") == "assistant"]
887+
assert len(assistants) == 2
888+
# First split keeps the shared fields.
889+
assert assistants[0].get("content") == "Looking up both queries."
890+
assert "thinking_blocks" in assistants[0]
891+
assert "reasoning_content" in assistants[0]
892+
# Second split must NOT duplicate them.
893+
assert "content" not in assistants[1]
894+
assert "thinking_blocks" not in assistants[1]
895+
assert "reasoning_content" not in assistants[1]
896+
# Tool calls are still split one-per-message.
897+
assert assistants[0]["tool_calls"][0]["id"] == "call_1"
898+
assert assistants[1]["tool_calls"][0]["id"] == "call_2"

0 commit comments

Comments
 (0)