From f6533450391b781a66ce171277b3e868a2e26c03 Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Sat, 8 Aug 2026 11:32:12 +0100 Subject: [PATCH 1/2] Python: fix handoff target invoked with no messages when response has no text HandoffAgentExecutor sent the handoff target an AgentExecutorRequest with messages=[] whenever the handing-off agent's response cleaned to an empty conversation (e.g. a response consisting solely of the handoff tool call, with no text content). Send a continuation instruction instead so agents that reject empty input are never invoked with nothing at all. --- .../_handoff.py | 16 +- .../orchestrations/tests/test_handoff.py | 154 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 4c0de8001be..f82121bc26b 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -196,6 +196,13 @@ def terminate() -> list[Message]: _AUTONOMOUS_MODE_DEFAULT_PROMPT = "User did not respond. Continue assisting autonomously." _DEFAULT_AUTONOMOUS_TURN_LIMIT = 50 +# Sent to the handoff target when the handing-off agent's response cleans to no messages at +# all (e.g. a response consisting solely of the handoff tool call, with no text content). In +# that case the broadcast to other participants carries nothing, so the target's executor +# cache is empty; without this, agents that reject empty input (e.g. A2AAgent) raise instead +# of running. +_HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION = "Continue the conversation." + # region Handoff Agent Executor @@ -407,8 +414,15 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[Any, Any]) -> None: # tool result. self._cache.append(handoff_message) + # cleaned_response drops every message with no text content, so a response that + # is only a handoff tool call cleans to an empty list. When that happens the + # broadcast above carried nothing, so send a continuation instruction instead of + # an empty request to avoid invoking the target with no messages at all. + handoff_request_messages = ( + [] if cleaned_response else [Message(role="user", contents=[_HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION])] + ) await ctx.send_message( - AgentExecutorRequest(messages=[], should_respond=True), + AgentExecutorRequest(messages=handoff_request_messages, should_respond=True), target_id=handoff_target, ) await ctx.add_event( diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 21964ebddb4..f0b7405922b 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -36,6 +36,7 @@ from pytest import param from agent_framework_orchestrations._handoff import ( + _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION, # pyright: ignore[reportPrivateUsage] HANDOFF_FUNCTION_RESULT_KEY, HandoffAgentExecutor, HandoffConfiguration, @@ -1433,3 +1434,156 @@ async def test_simple_handoff_workflow_with_approval_request(store: bool) -> Non # endregion + + +# region Empty-input regression (issue #7573) + + +class TextlessHandoffChatClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]): + """Mock chat client whose only reply content is a handoff tool call, with no text at all.""" + + def __init__(self, *, handoff_to: str) -> None: + ChatMiddlewareLayer.__init__(self) + FunctionInvocationLayer.__init__(self) + BaseChatClient.__init__(self) + self._handoff_to = handoff_to + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + del messages, options, kwargs + contents: list[Content] = [ + Content.from_function_call( + call_id="handoff-call-1", + name=f"handoff_to_{self._handoff_to}", + arguments={"handoff_to": self._handoff_to}, + ) + ] + if stream: + return self._build_streaming_response(contents) + + async def _get() -> ChatResponse: + return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="textless-handoff") + + return _get() + + def _build_streaming_response(self, contents: list[Content]) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop") + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse.from_updates(updates) + + return ResponseStream(_stream(), finalizer=_finalize) + + +class RecordingChatClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]): + """Mock chat client that records the exact messages it is invoked with on every call.""" + + def __init__(self) -> None: + ChatMiddlewareLayer.__init__(self) + FunctionInvocationLayer.__init__(self) + BaseChatClient.__init__(self) + self.received_messages: list[list[Message]] = [] + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + del options, kwargs + self.received_messages.append(list(messages)) + contents: list[Content] = [Content.from_text(text="specialist reply")] + if stream: + return self._build_streaming_response(contents) + + async def _get() -> ChatResponse: + return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="specialist-reply") + + return _get() + + def _build_streaming_response(self, contents: list[Content]) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop") + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse.from_updates(updates) + + return ResponseStream(_stream(), finalizer=_finalize) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_handoff_target_gets_continuation_instruction_when_response_has_no_text(stream: bool) -> None: + """Regression test for #7573. + + ``clean_conversation_for_handoff`` drops every message with no text content, so a response + consisting solely of the handoff tool call cleans to an empty list. Before the fix, the + handoff target's ``AgentExecutorRequest`` carried ``messages=[]`` in that case; it must now + carry the continuation instruction instead, so agents that reject empty input (e.g. + ``A2AAgent``) are never invoked with nothing at all. + """ + triage = Agent( + client=TextlessHandoffChatClient(handoff_to="specialist"), + name="triage", + id="triage", + require_per_service_call_history_persistence=True, + ) + specialist_client = RecordingChatClient() + specialist = Agent( + client=specialist_client, + name="specialist", + id="specialist", + require_per_service_call_history_persistence=True, + ) + + workflow = ( + HandoffBuilder(participants=_as_handoff_agents(triage, specialist)) + .with_start_agent(_as_handoff_agent(triage)) + .build() + ) + + if stream: + await _drain(workflow.run("Need technical support", stream=True)) + else: + await workflow.run("Need technical support") + + assert specialist_client.received_messages, "Handoff target was never invoked" + assert any( + _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION in (m.text or "") for m in specialist_client.received_messages[0] + ), "Handoff target's first invocation should carry the continuation instruction" + + +async def test_handoff_no_continuation_instruction_when_response_has_text() -> None: + """The continuation instruction must only be injected when the broadcast would otherwise be empty.""" + triage = MockHandoffAgent(name="triage", handoff_to="specialist") + specialist_client = RecordingChatClient() + specialist = Agent( + client=specialist_client, + name="specialist", + id="specialist", + require_per_service_call_history_persistence=True, + ) + + workflow = ( + HandoffBuilder(participants=_as_handoff_agents(triage, specialist)) + .with_start_agent(_as_handoff_agent(triage)) + .build() + ) + + await workflow.run("Need technical support") + + assert specialist_client.received_messages, "Handoff target was never invoked" + assert not any( + _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION in (m.text or "") for m in specialist_client.received_messages[0] + ), "Continuation instruction should not be injected when the handoff response already has text" + + +# endregion From df4c114445ca63bc4fdc4e9f593b1a0c6c0194ea Mon Sep 17 00:00:00 2001 From: cr-sbarbouche Date: Sat, 8 Aug 2026 11:44:27 +0100 Subject: [PATCH 2/2] Python: test the handoff target's request directly, not through the mesh The prior test went through HandoffBuilder's full multi-agent graph, but that graph broadcasts the initial user message to every participant before anyone runs, so the target's cache is never actually empty there - the test only checked for the injected text, not the reported failure mode. Drive HandoffAgentExecutor directly instead, so the assertion is exactly about what the fix changes: the direct request sent to the handoff target. Also correct the source comment, which claimed the target's cache is guaranteed empty when it's actually conditional on broadcast history, and drop the A2AAgent example (it can't be a HandoffBuilder participant). --- .../_handoff.py | 6 +- .../orchestrations/tests/test_handoff.py | 131 ++++++++---------- 2 files changed, 62 insertions(+), 75 deletions(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index f82121bc26b..8968fe18e64 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -198,9 +198,9 @@ def terminate() -> list[Message]: # Sent to the handoff target when the handing-off agent's response cleans to no messages at # all (e.g. a response consisting solely of the handoff tool call, with no text content). In -# that case the broadcast to other participants carries nothing, so the target's executor -# cache is empty; without this, agents that reject empty input (e.g. A2AAgent) raise instead -# of running. +# that case the broadcast to other participants carries nothing. Depending on what the target +# has already accumulated from earlier broadcasts, this can leave its cache empty; agents that +# reject empty input would otherwise be invoked with nothing to work with. _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION = "Continue the conversation." # region Handoff Agent Executor diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index f0b7405922b..c9d54ca282a 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -9,6 +9,7 @@ import pytest from agent_framework import ( Agent, + AgentExecutorRequest, AgentResponse, AgentResponseUpdate, ChatOptions, @@ -1482,108 +1483,94 @@ def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: return ResponseStream(_stream(), finalizer=_finalize) -class RecordingChatClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]): - """Mock chat client that records the exact messages it is invoked with on every call.""" +class _RecordingWorkflowContext: + """Stand-in for WorkflowContext that just records what a handler sends. - def __init__(self) -> None: - ChatMiddlewareLayer.__init__(self) - FunctionInvocationLayer.__init__(self) - BaseChatClient.__init__(self) - self.received_messages: list[list[Message]] = [] + Deliberately bypasses the real multi-agent graph (broadcasts, other executors' caches, + handoff bookkeeping) so the assertions below are only about the one thing this fix changes: + the direct request `HandoffAgentExecutor` sends to the handoff target. + """ - def _inner_get_response( - self, - *, - messages: Sequence[Message], - stream: bool, - options: Mapping[str, Any], - **kwargs: Any, - ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - del options, kwargs - self.received_messages.append(list(messages)) - contents: list[Content] = [Content.from_text(text="specialist reply")] - if stream: - return self._build_streaming_response(contents) + def __init__(self, *, streaming: bool = False) -> None: + self._streaming = streaming + self.sent: list[tuple[Any, str | None]] = [] - async def _get() -> ChatResponse: - return ChatResponse(messages=[Message(role="assistant", contents=contents)], response_id="specialist-reply") + def is_streaming(self) -> bool: + return self._streaming - return _get() + def get_state(self, key: str, default: Any = None) -> Any: + del key + return default - def _build_streaming_response(self, contents: list[Content]) -> ResponseStream[ChatResponseUpdate, ChatResponse]: - async def _stream() -> AsyncIterable[ChatResponseUpdate]: - yield ChatResponseUpdate(contents=contents, role="assistant", finish_reason="stop") + async def send_message(self, message: Any, target_id: str | None = None) -> None: + self.sent.append((message, target_id)) - def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: - return ChatResponse.from_updates(updates) + async def add_event(self, event: Any) -> None: + del event + + async def yield_output(self, output: Any) -> None: + del output + + async def request_info(self, data: Any, response_type: Any, *, request_id: str | None = None) -> None: + del data, response_type, request_id - return ResponseStream(_stream(), finalizer=_finalize) + +def _sent_to_target(ctx: _RecordingWorkflowContext, target_id: str) -> AgentExecutorRequest: + """Return the single should-respond request `ctx` sent directly to `target_id`.""" + matches = [msg for msg, sent_target_id in ctx.sent if sent_target_id == target_id and msg.should_respond] + assert len(matches) == 1, f"Expected exactly one direct request to '{target_id}', got {len(matches)}" + return cast(AgentExecutorRequest, matches[0]) @pytest.mark.parametrize("stream", [False, True]) -async def test_handoff_target_gets_continuation_instruction_when_response_has_no_text(stream: bool) -> None: +async def test_handoff_sends_continuation_instruction_when_cleaned_response_is_empty(stream: bool) -> None: """Regression test for #7573. ``clean_conversation_for_handoff`` drops every message with no text content, so a response consisting solely of the handoff tool call cleans to an empty list. Before the fix, the - handoff target's ``AgentExecutorRequest`` carried ``messages=[]`` in that case; it must now - carry the continuation instruction instead, so agents that reject empty input (e.g. - ``A2AAgent``) are never invoked with nothing at all. + direct request sent to the handoff target carried `messages=[]` in that case; it must now + carry the continuation instruction instead, so agents that reject empty input are never + invoked with nothing at all. """ - triage = Agent( + agent = Agent( client=TextlessHandoffChatClient(handoff_to="specialist"), name="triage", id="triage", require_per_service_call_history_persistence=True, ) - specialist_client = RecordingChatClient() - specialist = Agent( - client=specialist_client, - name="specialist", - id="specialist", - require_per_service_call_history_persistence=True, - ) + executor = HandoffAgentExecutor(agent=agent, handoffs=[HandoffConfiguration(target="specialist")]) + ctx = _RecordingWorkflowContext(streaming=stream) - workflow = ( - HandoffBuilder(participants=_as_handoff_agents(triage, specialist)) - .with_start_agent(_as_handoff_agent(triage)) - .build() + await executor.run( + AgentExecutorRequest(messages=[Message(role="user", contents=["Need technical support"])], should_respond=True), + cast(Any, ctx), ) - if stream: - await _drain(workflow.run("Need technical support", stream=True)) - else: - await workflow.run("Need technical support") - - assert specialist_client.received_messages, "Handoff target was never invoked" - assert any( - _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION in (m.text or "") for m in specialist_client.received_messages[0] - ), "Handoff target's first invocation should carry the continuation instruction" + handoff_request = _sent_to_target(ctx, "specialist") + assert handoff_request.messages, "Handoff target's request must not be empty" + assert any(_HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION in (m.text or "") for m in handoff_request.messages), ( + "Handoff target's request should carry the continuation instruction" + ) -async def test_handoff_no_continuation_instruction_when_response_has_text() -> None: - """The continuation instruction must only be injected when the broadcast would otherwise be empty.""" - triage = MockHandoffAgent(name="triage", handoff_to="specialist") - specialist_client = RecordingChatClient() - specialist = Agent( - client=specialist_client, - name="specialist", - id="specialist", +async def test_handoff_no_continuation_instruction_when_cleaned_response_has_text() -> None: + """The continuation instruction must only be injected when the cleaned response is empty.""" + agent = Agent( + client=MockChatClient(name="triage", handoff_to="specialist"), + name="triage", + id="triage", require_per_service_call_history_persistence=True, ) + executor = HandoffAgentExecutor(agent=agent, handoffs=[HandoffConfiguration(target="specialist")]) + ctx = _RecordingWorkflowContext() - workflow = ( - HandoffBuilder(participants=_as_handoff_agents(triage, specialist)) - .with_start_agent(_as_handoff_agent(triage)) - .build() + await executor.run( + AgentExecutorRequest(messages=[Message(role="user", contents=["Need technical support"])], should_respond=True), + cast(Any, ctx), ) - await workflow.run("Need technical support") - - assert specialist_client.received_messages, "Handoff target was never invoked" - assert not any( - _HANDOFF_CONTINUATION_DEFAULT_INSTRUCTION in (m.text or "") for m in specialist_client.received_messages[0] - ), "Continuation instruction should not be injected when the handoff response already has text" + handoff_request = _sent_to_target(ctx, "specialist") + assert handoff_request.messages == [], "No continuation instruction should be injected when the response has text" # endregion