From 64bcc47fb5f9668b76876087576c7eef660e5a2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 12:17:18 +0000 Subject: [PATCH] fix(compact): skip tool_result-only user turns; bedrock: elif for context_management - compact_20260112 Phase D: when keeping the last user turn after a full summary, skip role=user turns whose content is exclusively tool_result blocks. Such turns translate to OpenAI tool-role messages with no preceding assistant tool_calls (those got summarized away), which non-Anthropic providers reject. Fall back to a synthetic continuation prompt if no eligible user question exists, so the downstream call always has a non-empty user message. - bedrock converse: chain the context_management param as elif so it follows the same if/elif pattern as the surrounding thinking/ reasoning_effort checks. Co-authored-by: Yassin Kortam --- .../context_management/editors/compact.py | 60 ++++++++++++++++--- .../bedrock/chat/converse_transformation.py | 2 +- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py index 62e4e2c3602..745c8e12e46 100644 --- a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py +++ b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py @@ -257,6 +257,50 @@ def _count_effective_tokens( return total +def _is_tool_result_only_user_turn(msg: Dict[str, Any]) -> bool: + """Return True if ``msg`` is a ``role=user`` turn that carries only + ``tool_result`` blocks (i.e. an Anthropic tool-use response). + + Such turns are not real "user question" turns and must not be used as + the sole downstream message after a full compaction: the adapter + translates them to OpenAI ``tool``-role messages, which require a + preceding assistant ``tool_calls`` turn that no longer exists once the + history has been summarized away. + """ + if msg.get("role") != "user": + return False + content = msg.get("content") + if not isinstance(content, list) or not content: + return False + for block in content: + if not isinstance(block, dict): + return False + if block.get("type") != "tool_result": + return False + return True + + +def _select_last_user_question( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Pick the most recent ``user`` turn that is a real question. + + Returns a one-element message list, or a synthetic continuation prompt + if no eligible turn exists (e.g. the conversation only ever contained + ``tool_result`` turns, or contained no user turns at all). The + downstream call always needs a non-empty user message. + """ + for msg in reversed(messages): + if msg.get("role") == "user" and not _is_tool_result_only_user_turn(msg): + return [msg] + return [ + { + "role": "user", + "content": "Please continue based on the conversation summary above.", + } + ] + + def _extract_summary_text(raw: Optional[str]) -> Optional[str]: if not raw: return None @@ -476,14 +520,16 @@ async def apply_compact_20260112( # Per Anthropic's contract, everything before the compaction block is # dropped. Phase D: the user/assistant log goes empty; the summary lives # on the system message instead. Anthropic requires a non-empty messages - # array, so keep the most recent original user turn so the model has the - # question to answer. + # array, so keep the most recent original user *question* turn so the + # model has something to answer. Skip ``tool_result``-only user turns: + # in Anthropic's format those are role=user but represent the response + # from a tool, and surfacing one as the sole downstream message would + # produce an orphaned ``tool``-role message on non-Anthropic providers + # with no matching ``tool_calls`` in the prior assistant history. If no + # eligible turn exists, fall back to a synthetic continuation prompt so + # the downstream call still has a non-empty user message. summarized_system = _augment_system_with_summary(system, summary_text) - downstream_messages_after_summary: List[Dict[str, Any]] = [] - for msg in reversed(messages): - if msg.get("role") == "user": - downstream_messages_after_summary = [msg] - break + downstream_messages_after_summary = _select_last_user_question(messages) return PolyfillResult( messages=downstream_messages_after_summary, diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 98b78c0468c..eaeb017b7c2 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -935,7 +935,7 @@ class AmazonConverseConfig(BaseConfig): self._handle_reasoning_effort_parameter( model=model, reasoning_effort=value, optional_params=optional_params ) - if param == "context_management" and isinstance(value, (dict, list)): + elif param == "context_management" and isinstance(value, (dict, list)): self._map_context_management_param(value, optional_params) if param == "requestMetadata": self._map_request_metadata_param(value, optional_params)