From 58780032992f4f156b14cec7935296fca9d2eca8 Mon Sep 17 00:00:00 2001 From: Sam Schillace Date: Thu, 12 Feb 2026 14:16:11 -0800 Subject: [PATCH] fix: prevent consecutive same-role messages in ephemeral injections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All injection paths in the orchestrator now check if the last message has the same role before appending. If so, they merge the content into the existing message instead of creating a consecutive duplicate. This is structural prevention - regardless of what role a hook requests, the orchestrator will never create consecutive messages with the same role. This fixes model confusion about user vs. system messages. Affected paths: - provider:request ephemeral injection (default path) - provider:request append_to_last_tool_result fallback - tool:post pending ephemeral injection (both paths) - orchestrator loop-limit reminder 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- amplifier_module_loop_streaming/__init__.py | 128 +++++++++++++++----- 1 file changed, 97 insertions(+), 31 deletions(-) diff --git a/amplifier_module_loop_streaming/__init__.py b/amplifier_module_loop_streaming/__init__.py index 6927b68..bdc77c1 100644 --- a/amplifier_module_loop_streaming/__init__.py +++ b/amplifier_module_loop_streaming/__init__.py @@ -267,25 +267,52 @@ async def _execute_stream( "Appended ephemeral injection to last tool result message" ) else: - # Fall back to new message if last message isn't a tool result + # Fall back: merge if same role, else new message + if last_msg.get("role") == result.context_injection_role: + original_content = last_msg.get("content", "") + message_dicts[-1] = { + **last_msg, + "content": f"{original_content}\n\n{result.context_injection}", + } + logger.debug( + "Merged ephemeral injection into last message (same role: %s)", + result.context_injection_role, + ) + else: + message_dicts.append( + { + "role": result.context_injection_role, + "content": result.context_injection, + } + ) + logger.debug( + f"Last message role is '{last_msg.get('role')}', not 'tool' - " + "created new message for injection" + ) + else: + # Structural prevention: merge into last message if same role + # to avoid consecutive messages with the same role (confuses models) + if ( + len(message_dicts) > 0 + and message_dicts[-1].get("role") == result.context_injection_role + ): + last_msg = message_dicts[-1] + original_content = last_msg.get("content", "") + message_dicts[-1] = { + **last_msg, + "content": f"{original_content}\n\n{result.context_injection}", + } + logger.debug( + "Merged ephemeral injection into last message (same role: %s)", + result.context_injection_role, + ) + else: message_dicts.append( { "role": result.context_injection_role, "content": result.context_injection, } ) - logger.debug( - f"Last message role is '{last_msg.get('role')}', not 'tool' - " - "created new message for injection" - ) - else: - # Default behavior: append as new message - message_dicts.append( - { - "role": result.context_injection_role, - "content": result.context_injection, - } - ) # Apply pending ephemeral injections from tool:post hooks if self._pending_ephemeral_injections: @@ -305,22 +332,50 @@ async def _execute_stream( "Applied pending ephemeral injection to last tool result" ) else: - message_dicts.append( - { - "role": injection["role"], - "content": injection["content"], + # Merge if same role as last message + if last_msg.get("role") == injection["role"]: + original_content = last_msg.get("content", "") + message_dicts[-1] = { + **last_msg, + "content": f"{original_content}\n\n{injection['content']}", } + logger.debug( + "Merged pending injection into last message (same role: %s)", + injection["role"], + ) + else: + message_dicts.append( + { + "role": injection["role"], + "content": injection["content"], + } + ) + logger.debug( + "Last message not a tool result, created new message for injection" + ) + else: + # Structural prevention: merge if same role as last message + if ( + len(message_dicts) > 0 + and message_dicts[-1].get("role") == injection["role"] + ): + last_msg = message_dicts[-1] + original_content = last_msg.get("content", "") + message_dicts[-1] = { + **last_msg, + "content": f"{original_content}\n\n{injection['content']}", + } + logger.debug( + "Merged pending ephemeral injection into last message (same role: %s)", + injection["role"], + ) + else: + message_dicts.append( + {"role": injection["role"], "content": injection["content"]} ) logger.debug( - "Last message not a tool result, created new message for injection" + "Applied pending ephemeral injection as new message" ) - else: - message_dicts.append( - {"role": injection["role"], "content": injection["content"]} - ) - logger.debug( - "Applied pending ephemeral injection as new message" - ) # Clear pending injections after applying self._pending_ephemeral_injections = [] @@ -669,16 +724,27 @@ async def _execute_stream( # Get one final response with the reminder (via _execute_stream helper) message_dicts = await context.get_messages_for_request(provider=provider) message_dicts = list(message_dicts) - message_dicts.append( - { - "role": "user", - "content": """ + # Merge loop-limit reminder into last message if same role, + # to avoid consecutive user messages that confuse role attribution + _loop_limit_content = """ You have reached the maximum number of iterations for this turn. Please provide a response to the user now, summarizing your progress and noting what remains to be done. You can continue in the next turn if needed. DO NOT mention this iteration limit or reminder to the user explicitly. Simply wrap up naturally. -""", +""" + if ( + len(message_dicts) > 0 + and message_dicts[-1].get("role") == "user" + ): + last_msg = message_dicts[-1] + original_content = last_msg.get("content", "") + message_dicts[-1] = { + **last_msg, + "content": f"{original_content}\n\n{_loop_limit_content}", } - ) + else: + message_dicts.append( + {"role": "user", "content": _loop_limit_content} + ) try: # Convert dicts to ChatRequest