From 752ac912304c34f630549ab23afedec23b99a99f Mon Sep 17 00:00:00 2001 From: "F. Abyssalis" Date: Mon, 22 Jun 2026 11:35:17 +0800 Subject: [PATCH 1/4] sanitize orphaned tool_result blocks in Anthropic provider Sanitize tool_result blocks and merge consecutive messages with the same role to comply with Anthropic API requirements. --- .../core/provider/sources/anthropic_source.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 3f76237327..ed227fb8f6 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -303,6 +303,52 @@ def _prepare_payload(self, messages: list[dict]): else: new_messages.append(message) + # --- Orphaned tool_result sanitizer --- + # After context truncation, tool_result blocks may reference tool_use + # IDs that no longer exist. The Anthropic API rejects these with 400. + valid_tool_use_ids: set[str] = set() + for msg in new_messages: + if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): + for block in msg["content"]: + if isinstance(block, dict) and block.get("type") == "tool_use": + valid_tool_use_ids.add(block.get("id", "")) + + sanitized: list[dict] = [] + for msg in new_messages: + if msg.get("role") == "user" and isinstance(msg.get("content"), list): + cleaned_content = [ + block for block in msg["content"] + if not ( + isinstance(block, dict) + and block.get("type") == "tool_result" + and block.get("tool_use_id") not in valid_tool_use_ids + ) + ] + if cleaned_content: + sanitized.append({**msg, "content": cleaned_content}) + else: + sanitized.append(msg) + new_messages = sanitized + + # --- Merge consecutive same-role messages --- + # Stripping orphaned tool_results may leave adjacent messages with the + # same role, which violates Anthropic's strict alternation requirement. + merged: list[dict] = [] + for msg in new_messages: + if merged and merged[-1].get("role") == msg.get("role"): + prev = merged[-1] + prev_content = prev.get("content", []) + if isinstance(prev_content, str): + prev_content = [{"type": "text", "text": prev_content}] + prev["content"] = prev_content + cur_content = msg.get("content", []) + if isinstance(cur_content, str): + cur_content = [{"type": "text", "text": cur_content}] + prev_content.extend(cur_content) + else: + merged.append(msg) + new_messages = merged + return system_prompt, new_messages def _extract_usage(self, usage: Usage | None) -> TokenUsage: From 24aa2ee75084575beefcadcac6aee4f2cb50c170 Mon Sep 17 00:00:00 2001 From: "F. Abyssalis" Date: Mon, 22 Jun 2026 12:48:15 +0800 Subject: [PATCH 2/4] Fix content handling in message merging logic --- astrbot/core/provider/sources/anthropic_source.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index ed227fb8f6..c2e1295253 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -337,14 +337,19 @@ def _prepare_payload(self, messages: list[dict]): for msg in new_messages: if merged and merged[-1].get("role") == msg.get("role"): prev = merged[-1] - prev_content = prev.get("content", []) + prev_content = prev.get("content") or [] if isinstance(prev_content, str): prev_content = [{"type": "text", "text": prev_content}] - prev["content"] = prev_content - cur_content = msg.get("content", []) + else: + prev_content = list(prev_content) + + cur_content = msg.get("content") or [] if isinstance(cur_content, str): cur_content = [{"type": "text", "text": cur_content}] - prev_content.extend(cur_content) + else: + cur_content = list(cur_content) + + merged[-1] = {**prev, "content": prev_content + cur_content} else: merged.append(msg) new_messages = merged From 6842bd0921dea1f8c1db1d44280467816fe00e4d Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Wed, 24 Jun 2026 23:02:41 +0800 Subject: [PATCH 3/4] fix: sanitize anthropic assistant messages --- .../core/provider/sources/anthropic_source.py | 90 +++++++++++++------ tests/test_anthropic_kimi_code_provider.py | 82 +++++++++++++++++ 2 files changed, 145 insertions(+), 27 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index c2e1295253..c0db4f82c6 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -303,21 +303,46 @@ def _prepare_payload(self, messages: list[dict]): else: new_messages.append(message) - # --- Orphaned tool_result sanitizer --- - # After context truncation, tool_result blocks may reference tool_use - # IDs that no longer exist. The Anthropic API rejects these with 400. + return system_prompt, new_messages + + @staticmethod + def _sanitize_assistant_messages(payloads: dict) -> None: + """Remove orphaned tool results from Anthropic messages. + + Args: + payloads: Anthropic request payload containing a messages list. + + Returns: + None. The messages list is updated in place on ``payloads``. + """ + messages = payloads.get("messages") + if not isinstance(messages, list): + return + valid_tool_use_ids: set[str] = set() - for msg in new_messages: - if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): - for block in msg["content"]: - if isinstance(block, dict) and block.get("type") == "tool_use": - valid_tool_use_ids.add(block.get("id", "")) - - sanitized: list[dict] = [] - for msg in new_messages: - if msg.get("role") == "user" and isinstance(msg.get("content"), list): + for msg in messages: + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + content = msg.get("content") + if not isinstance(content, list): + continue + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + tool_use_id = block.get("id") + if tool_use_id: + valid_tool_use_ids.add(tool_use_id) + + sanitized: list[Any] = [] + for msg in messages: + if not isinstance(msg, dict): + sanitized.append(msg) + continue + + content = msg.get("content") + if msg.get("role") == "user" and isinstance(content, list): cleaned_content = [ - block for block in msg["content"] + block + for block in content if not ( isinstance(block, dict) and block.get("type") == "tool_result" @@ -326,35 +351,44 @@ def _prepare_payload(self, messages: list[dict]): ] if cleaned_content: sanitized.append({**msg, "content": cleaned_content}) - else: - sanitized.append(msg) - new_messages = sanitized - - # --- Merge consecutive same-role messages --- - # Stripping orphaned tool_results may leave adjacent messages with the - # same role, which violates Anthropic's strict alternation requirement. - merged: list[dict] = [] - for msg in new_messages: - if merged and merged[-1].get("role") == msg.get("role"): + continue + + sanitized.append(msg) + + merged: list[Any] = [] + for msg in sanitized: + if not isinstance(msg, dict): + merged.append(msg) + continue + + if ( + msg.get("role") + and merged + and isinstance(merged[-1], dict) + and merged[-1].get("role") == msg.get("role") + ): prev = merged[-1] prev_content = prev.get("content") or [] if isinstance(prev_content, str): prev_content = [{"type": "text", "text": prev_content}] - else: + elif isinstance(prev_content, list): prev_content = list(prev_content) + else: + prev_content = [prev_content] cur_content = msg.get("content") or [] if isinstance(cur_content, str): cur_content = [{"type": "text", "text": cur_content}] - else: + elif isinstance(cur_content, list): cur_content = list(cur_content) + else: + cur_content = [cur_content] merged[-1] = {**prev, "content": prev_content + cur_content} else: merged.append(msg) - new_messages = merged - return system_prompt, new_messages + payloads["messages"] = merged def _extract_usage(self, usage: Usage | None) -> TokenUsage: if usage is None: @@ -424,6 +458,7 @@ async def _query( if "max_tokens" not in payloads: payloads["max_tokens"] = 65536 self._apply_thinking_config(payloads) + self._sanitize_assistant_messages(payloads) try: completion = await retry_provider_request( @@ -524,6 +559,7 @@ async def _query_stream( if "max_tokens" not in payloads: payloads["max_tokens"] = 65536 self._apply_thinking_config(payloads) + self._sanitize_assistant_messages(payloads) async with retry_provider_request_context( "Anthropic", diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index 4c514cfcdc..2e62f595d6 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -451,6 +451,88 @@ def test_prepare_payload_does_not_merge_non_consecutive_tool_results(): ] +def test_sanitize_assistant_messages_removes_orphaned_tool_results_and_merges(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": [{"type": "text", "text": "First answer."}], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "missing_call", + "content": "stale result", + } + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Second answer."}], + }, + ] + } + + anthropic_source.ProviderAnthropic._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "First answer."}, + {"type": "text", "text": "Second answer."}, + ], + } + ] + + +def test_sanitize_assistant_messages_keeps_valid_tool_results_only(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_00", + "name": "read_file", + "input": {"path": "/tmp/one.txt"}, + }, + { + "type": "tool_use", + "id": "", + "name": "bad_tool", + "input": {}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_00", + "content": "one", + }, + { + "type": "tool_result", + "tool_use_id": "", + "content": "empty id should not be valid", + }, + ], + }, + ] + } + + anthropic_source.ProviderAnthropic._sanitize_assistant_messages(payloads) + + assert payloads["messages"][1]["content"] == [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"} + ] + + # ---- tool_choice 转换测试 ---- From 8a58cc623ee44e030bf82267857180fd7e19db20 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Fri, 26 Jun 2026 23:08:14 +0800 Subject: [PATCH 4/4] fix: validate anthropic tool result ordering --- .../core/provider/sources/anthropic_source.py | 134 ++++++++++++------ tests/test_anthropic_kimi_code_provider.py | 104 ++++++++++++++ 2 files changed, 191 insertions(+), 47 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index c0db4f82c6..b44cacdd3f 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -306,57 +306,18 @@ def _prepare_payload(self, messages: list[dict]): return system_prompt, new_messages @staticmethod - def _sanitize_assistant_messages(payloads: dict) -> None: - """Remove orphaned tool results from Anthropic messages. + def _merge_consecutive_anthropic_messages(messages: list[Any]) -> list[Any]: + """Merge adjacent Anthropic messages with the same role. Args: - payloads: Anthropic request payload containing a messages list. + messages: Anthropic messages to merge. Returns: - None. The messages list is updated in place on ``payloads``. + Merged Anthropic messages. When merging user messages, tool result + blocks are moved before other blocks to satisfy Anthropic ordering. """ - messages = payloads.get("messages") - if not isinstance(messages, list): - return - - valid_tool_use_ids: set[str] = set() - for msg in messages: - if not isinstance(msg, dict) or msg.get("role") != "assistant": - continue - content = msg.get("content") - if not isinstance(content, list): - continue - for block in content: - if isinstance(block, dict) and block.get("type") == "tool_use": - tool_use_id = block.get("id") - if tool_use_id: - valid_tool_use_ids.add(tool_use_id) - - sanitized: list[Any] = [] - for msg in messages: - if not isinstance(msg, dict): - sanitized.append(msg) - continue - - content = msg.get("content") - if msg.get("role") == "user" and isinstance(content, list): - cleaned_content = [ - block - for block in content - if not ( - isinstance(block, dict) - and block.get("type") == "tool_result" - and block.get("tool_use_id") not in valid_tool_use_ids - ) - ] - if cleaned_content: - sanitized.append({**msg, "content": cleaned_content}) - continue - - sanitized.append(msg) - merged: list[Any] = [] - for msg in sanitized: + for msg in messages: if not isinstance(msg, dict): merged.append(msg) continue @@ -384,11 +345,90 @@ def _sanitize_assistant_messages(payloads: dict) -> None: else: cur_content = [cur_content] - merged[-1] = {**prev, "content": prev_content + cur_content} + combined_content = prev_content + cur_content + if msg.get("role") == "user": + tool_results = [ + block + for block in combined_content + if isinstance(block, dict) + and block.get("type") == "tool_result" + ] + if tool_results: + combined_content = tool_results + [ + block + for block in combined_content + if not ( + isinstance(block, dict) + and block.get("type") == "tool_result" + ) + ] + + merged[-1] = {**prev, "content": combined_content} else: merged.append(msg) - payloads["messages"] = merged + return merged + + @staticmethod + def _sanitize_assistant_messages(payloads: dict) -> None: + """Remove orphaned tool results from Anthropic messages. + + Args: + payloads: Anthropic request payload containing a messages list. + + Returns: + None. The messages list is updated in place on ``payloads``. + """ + messages = payloads.get("messages") + if not isinstance(messages, list): + return + + merged = ProviderAnthropic._merge_consecutive_anthropic_messages(messages) + sanitized: list[Any] = [] + pending_tool_use_ids: set[str] = set() + for msg in merged: + if not isinstance(msg, dict): + sanitized.append(msg) + pending_tool_use_ids = set() + continue + + role = msg.get("role") + content = msg.get("content") + if role == "assistant": + pending_tool_use_ids = set() + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + tool_use_id = block.get("id") + if tool_use_id: + pending_tool_use_ids.add(tool_use_id) + sanitized.append(msg) + continue + + if role == "user" and isinstance(content, list): + tool_results: list[Any] = [] + other_blocks: list[Any] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_result": + tool_use_id = block.get("tool_use_id") + if tool_use_id in pending_tool_use_ids: + tool_results.append(block) + pending_tool_use_ids.remove(tool_use_id) + continue + other_blocks.append(block) + + cleaned_content = tool_results + other_blocks + if cleaned_content: + sanitized.append({**msg, "content": cleaned_content}) + pending_tool_use_ids = set() + continue + + sanitized.append(msg) + pending_tool_use_ids = set() + + payloads["messages"] = ProviderAnthropic._merge_consecutive_anthropic_messages( + sanitized + ) def _extract_usage(self, usage: Usage | None) -> TokenUsage: if usage is None: diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index 2e62f595d6..0dc33f58ba 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -533,6 +533,110 @@ def test_sanitize_assistant_messages_keeps_valid_tool_results_only(): ] +def test_sanitize_assistant_messages_removes_stale_duplicate_tool_result(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_00", + "name": "read_file", + "input": {"path": "/tmp/one.txt"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_00", + "content": "one", + } + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Done."}], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_00", + "content": "stale duplicate", + } + ], + }, + ] + } + + anthropic_source.ProviderAnthropic._sanitize_assistant_messages(payloads) + + assert payloads["messages"] == [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_00", + "name": "read_file", + "input": {"path": "/tmp/one.txt"}, + } + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"} + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Done."}], + }, + ] + + +def test_sanitize_assistant_messages_puts_tool_results_before_user_text(): + payloads = { + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_00", + "name": "read_file", + "input": {"path": "/tmp/one.txt"}, + } + ], + }, + {"role": "user", "content": "continue"}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_00", + "content": "one", + } + ], + }, + ] + } + + anthropic_source.ProviderAnthropic._sanitize_assistant_messages(payloads) + + assert payloads["messages"][1]["content"] == [ + {"type": "tool_result", "tool_use_id": "call_00", "content": "one"}, + {"type": "text", "text": "continue"}, + ] + + # ---- tool_choice 转换测试 ----