Skip to content

Python: fix: filter history providers in handoff cloning to prevent duplicate messages#5214

Open
LEDazzio01 wants to merge 2 commits intomicrosoft:mainfrom
LEDazzio01:fix/handoff-duplicate-messages-4695-v2
Open

Python: fix: filter history providers in handoff cloning to prevent duplicate messages#5214
LEDazzio01 wants to merge 2 commits intomicrosoft:mainfrom
LEDazzio01:fix/handoff-duplicate-messages-4695-v2

Conversation

@LEDazzio01
Copy link
Copy Markdown
Contributor

Summary

Fixes #4695 — Supersedes #4714 (rebased onto current main).

When a HandoffAgentExecutor clones an agent via _clone_chat_agent(), history providers from
the original agent are copied verbatim. These providers re-inject previously stored messages on
each agent.run() call, causing the entire conversation to appear twice — once from the
handoff's _full_conversation and again from the history provider.

Fix

Filter out BaseHistoryProvider instances during cloning and replace them with a no-op
InMemoryHistoryProvider(load_messages=False, store_inputs=False, store_outputs=False)
to prevent the agent from auto-injecting a default one at runtime.

+ from agent_framework._sessions import AgentSession, BaseHistoryProvider, InMemoryHistoryProvider
  ...
+       filtered_providers = [
+           p for p in agent.context_providers
+           if not isinstance(p, BaseHistoryProvider)
+       ]
+       filtered_providers.append(
+           InMemoryHistoryProvider(
+               load_messages=False,
+               store_inputs=False,
+               store_outputs=False,
+           )
+       )
        return Agent(
            ...
-           context_providers=agent.context_providers,
+           context_providers=filtered_providers,

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • Is this a breaking change? No

Copilot AI review requested due to automatic review settings April 10, 2026 21:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to fix duplicate chat history in Python handoff workflows by changing how agents are cloned for HandoffAgentExecutor, specifically preventing cloned agents from re-loading/storing prior messages via history providers.

Changes:

  • Filter BaseHistoryProvider instances out of cloned agents’ context_providers and add a no-op InMemoryHistoryProvider.
  • Refactor handoff executor conversation tracking/broadcasting and adjust handoff detection to return additional context.
  • Add a new build-time validation intended to enforce a per-service-call history persistence requirement.
Comments suppressed due to low confidence (1)

python/packages/orchestrations/agent_framework_orchestrations/_handoff.py:428

  • On handoff, the code appends handoff_message (a function_result-bearing message) to self._cache and then returns without clearing it. That leaves stale tool-result content in this executor’s cache, so the next time this agent is asked to respond it may replay tool artifacts and potentially trigger tool call/result mismatches. Either avoid caching this message, ensure it’s cleaned, or clear the cache before returning.
            # Add the handoff message to the cache so that the next invocation of the agent includes
            # the tool call result. This is necessary because each tool call must have a corresponding
            # tool result.
            self._cache.append(handoff_message)

            await ctx.send_message(
                AgentExecutorRequest(messages=[], should_respond=True),
                target_id=handoff_target,
            )
            await ctx.add_event(
                WorkflowEvent("handoff_sent", data=HandoffSentEvent(source=self.id, target=handoff_target))
            )
            self._autonomous_mode_turns = 0  # Reset autonomous mode turn counter on handoff
            return

Comment on lines 304 to 312
return Agent(
client=agent.client,
id=agent.id,
name=agent.name,
description=agent.description,
context_providers=agent.context_providers,
context_providers=filtered_providers,
middleware=agent.agent_middleware,
require_per_service_call_history_persistence=agent.require_per_service_call_history_persistence,
default_options=cloned_options, # type: ignore[assignment]
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent.require_per_service_call_history_persistence is not an attribute on agent_framework.Agent (Agent stores unknown kwargs in additional_properties). This will raise AttributeError at runtime when cloning. If you need to propagate a flag, read it from agent.additional_properties (or add a real Agent property in core) and avoid passing it as a deprecated direct kwarg to Agent(...).

Copilot uses AI. Check for mistakes.
# this ensures all options (including custom ones) are kept
cloned_options = deepcopy(options)
# Disable parallel tool calls to prevent the agent from invoking multiple handoff tools at once.
cloned_options["allow_multiple_tool_calls"] = False
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_clone_chat_agent() no longer forces default_options['store']=False. Handoff executors replay conversation state via their own _full_conversation; allowing provider/service-side storage here can reintroduce duplicated history injection or stale tool-call state across turns. Consider restoring store=False (or otherwise explicitly defining the expected persistence mode for handoff clones).

Suggested change
cloned_options["allow_multiple_tool_calls"] = False
cloned_options["allow_multiple_tool_calls"] = False
# Handoff executors replay the full conversation via _full_conversation, so cloned agents
# must not rely on provider/service-side persistence that could rehydrate prior turns or
# stale tool-call state and cause duplicate history injection.
cloned_options["store"] = False

Copilot uses AI. Check for mistakes.
Comment on lines +370 to 373
# Full conversation maintains the chat history between agents across handoffs,
# excluding internal agent messages such as tool calls and results.
self._full_conversation.extend(self._cache.copy())

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_full_conversation is extended with the raw _cache before the run. _cache can contain tool-control messages (e.g., function approval responses are stored as role="tool" with non-text contents by the base AgentExecutor). Persisting those into _full_conversation contradicts the comment here and can leak tool artifacts into termination checks/output. Clean/filter _cache (e.g., via clean_conversation_for_handoff) before extending _full_conversation.

Copilot uses AI. Check for mistakes.
Comment on lines +955 to +972
# Validate that all agents have require_per_service_call_history_persistence enabled.
# Handoff workflows use middleware that short-circuits tool calls (MiddlewareTermination),
# which means the service never sees those tool results. Without per-service-call
# history persistence, local history providers would persist tool results that
# the service has no record of, causing call/result mismatches on subsequent turns.
agents_missing_flag = [
resolve_agent_id(agent)
for agent in resolved_agents.values()
if not agent.require_per_service_call_history_persistence
]
if agents_missing_flag:
raise ValueError(
f"Handoff workflows require all participant agents to have "
f"'require_per_service_call_history_persistence=True'. "
f"The following agents are missing this setting: {', '.join(agents_missing_flag)}. "
f"Set this flag when constructing each Agent to ensure local history stays "
f"consistent with the service across handoff tool-call short-circuits."
)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new build-time validation references agent.require_per_service_call_history_persistence, but Agent does not define this attribute (unknown kwargs are stored under additional_properties). As written, .build() will raise AttributeError for normal agents. Either remove this check, implement the flag as a first-class Agent property in core, or read it safely from additional_properties with a documented default.

Copilot uses AI. Check for mistakes.
Comment on lines +284 to +303
# Filter out history providers to prevent duplicate messages.
# The HandoffAgentExecutor manages conversation history via _full_conversation,
# so history providers would re-inject previously stored messages on each
# agent.run() call, causing the entire conversation to appear twice.
# A no-op InMemoryHistoryProvider placeholder prevents the agent from
# auto-injecting a default one at runtime.
filtered_providers = [
p for p in agent.context_providers
if not isinstance(p, BaseHistoryProvider)
]
# Always add a no-op placeholder to prevent the agent from
# auto-injecting a default InMemoryHistoryProvider at runtime.
filtered_providers.append(
InMemoryHistoryProvider(
load_messages=False,
store_inputs=False,
store_outputs=False,
)
)

Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior change: cloning now filters out BaseHistoryProvider instances and injects a no-op InMemoryHistoryProvider to avoid duplicate message injection. There are existing orchestration tests in this repo for handoff behavior; please add/adjust a regression test that asserts no duplicated messages are sent after handoff + resume when agents originally had history providers configured.

Copilot uses AI. Check for mistakes.
@LEDazzio01
Copy link
Copy Markdown
Contributor Author

Addressing Copilot's review comments:

Important context: This PR branch was created from a fork whose main was slightly behind upstream. The diff appears large (79 additions, 144 deletions), but the only changes introduced by this PR are in _clone_chat_agent():

  1. Import line: added BaseHistoryProvider, InMemoryHistoryProvider to existing imports from agent_framework._sessions
  2. In _clone_chat_agent(): Added the history provider filtering logic (lines 279-297) and changed context_providers=agent.context_providerscontext_providers=filtered_providers

Everything else in the diff — require_per_service_call_history_persistence, _is_handoff_requested return type change, _persist_pending_approval_function_calls removal, _full_conversation.extend(self._cache.copy()), build-time validation, store=False removal — is already on upstream main. These are not changes introduced by this PR.

Specifically responding to each comment:

1. require_per_service_call_history_persistence AttributeError — This attribute and the build-time validation already exist on main. Not our change.

2. store=False removal — Already removed on main. Not our change.

3. _full_conversation.extend(self._cache.copy()) stale content — Already on main. Not our change.

4. _cache stale content after handoff — Already on main. Not our change.

5. Missing regression tests — Valid feedback. I'll push a test for the history provider filtering behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Duplicate messages in Handoff workflow

3 participants