From d4fa9d3d5d90006d665864ca5cc65799e1a87e05 Mon Sep 17 00:00:00 2001 From: "F. Abyssalis" Date: Sat, 27 Jun 2026 16:27:32 +0800 Subject: [PATCH] fix: align OpenAI tool message sanitizer (#8350) Co-authored-by: Soulter <905617992@qq.com> --- .../core/provider/sources/openai_source.py | 35 ++++- tests/test_openai_source.py | 146 ++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 3a6db02b9..a49003af1 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -477,7 +477,40 @@ class ProviderOpenAIOfficial(Provider): cleaned.append(msg) - payloads["messages"] = cleaned + # Drop orphaned or duplicate tool messages whose assistant(tool_calls) + # was removed by context truncation / compression. + pending_tool_call_ids: set[str] = set() + final: list = [] + removed_tool_messages = 0 + for msg in cleaned: + if not isinstance(msg, dict): + final.append(msg) + pending_tool_call_ids = set() + continue + role = msg.get("role") + if role == "assistant" and msg.get("tool_calls"): + pending_tool_call_ids = { + tc["id"] + for tc in msg["tool_calls"] + if isinstance(tc, dict) and "id" in tc + } + final.append(msg) + elif role == "tool": + tool_call_id = msg.get("tool_call_id") + if tool_call_id in pending_tool_call_ids: + final.append(msg) + pending_tool_call_ids.remove(tool_call_id) + else: + removed_tool_messages += 1 + else: + pending_tool_call_ids = set() + final.append(msg) + if removed_tool_messages: + logger.debug( + "Filtered %d orphaned or duplicate tool message(s)", + removed_tool_messages, + ) + payloads["messages"] = final async def _query( self, diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index ce97cdcce..b8262090e 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1479,6 +1479,152 @@ async def test_query_stream_extracts_usage_from_empty_choices_chunk(monkeypatch) await provider.terminate() +def test_sanitize_assistant_messages_removes_orphaned_tool_messages(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "tool", + "tool_call_id": "missing_call", + "content": "stale result", + }, + {"role": "user", "content": "continue"}, + ] + } + + ProviderOpenAIOfficial._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + {"role": "user", "content": "hello"}, + {"role": "user", "content": "continue"}, + ] + + +def test_sanitize_assistant_messages_keeps_valid_tool_messages_only(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_00", "content": "one"}, + { + "role": "tool", + "tool_call_id": "", + "content": "empty id should not be valid", + }, + ] + } + + ProviderOpenAIOfficial._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_00", "content": "one"}, + ] + + +def test_sanitize_assistant_messages_removes_stale_duplicate_tool_message(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_00", "content": "one"}, + { + "role": "tool", + "tool_call_id": "call_00", + "content": "stale duplicate", + }, + {"role": "assistant", "content": "done"}, + ] + } + + ProviderOpenAIOfficial._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_00", "content": "one"}, + {"role": "assistant", "content": "done"}, + ] + + +def test_sanitize_assistant_messages_resets_tool_ids_after_non_tool_message(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "new turn"}, + { + "role": "tool", + "tool_call_id": "call_00", + "content": "stale late result", + }, + ] + } + + ProviderOpenAIOfficial._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_00", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "new turn"}, + ] + + @pytest.mark.asyncio async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch): """Test that empty assistant messages without tool_calls are filtered out."""