From e56d3da92935aaaf24ba1cd8e628a685149fe283 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 04:38:10 +0000 Subject: [PATCH 1/4] Fix sub-agent approval routing for as_tool() (#4963) When a sub-agent tool's inner function requires approval, the approval request propagates correctly to the outer agent. However, the approval response was silently dropped because _auto_invoke_function looked up the inner tool name in the outer agent's tool_map (where it doesn't exist) and assumed it was a hosted tool. Fix: Tag propagated approval requests with parent tool metadata (_parent_tool_name, _parent_tool_args) in additional_properties. When processing an approval response for an unknown tool, check for this metadata and re-route the invocation through the parent sub-agent tool. The _agent_wrapper forwards the approval messages to the inner agent's run() with proper conversation context so the inner function invocation loop can execute the approved tool. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_agents.py | 30 ++- .../packages/core/agent_framework/_tools.py | 36 ++- .../packages/core/tests/core/test_agents.py | 208 ++++++++++++++++++ 3 files changed, 265 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 585898ae523..42a89604aab 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -549,12 +549,30 @@ async def _agent_wrapper(ctx: FunctionInvocationContext, **kwargs: Any) -> str: ctx: the function invocation context used **kwargs: only used to dynamically load the argument that is defined for this tool. """ - stream = self.run( - str(kwargs.get(arg_name, "")), - stream=True, - session=ctx.session if propagate_session else None, - function_invocation_kwargs=dict(ctx.kwargs), - ) + # Check if this invocation carries approval responses that need to be + # forwarded to the inner agent (sub-agent approval re-routing). + approval_messages: list[Any] | None = ctx.metadata.get("_approval_messages") if ctx else None + if approval_messages: + input_messages: list[Message] = [] + for item in approval_messages: + # Include the function call as an assistant message so the inner + # agent has proper conversation context for the function result. + if hasattr(item, "function_call") and item.function_call is not None: + input_messages.append(Message("assistant", [item.function_call])) + input_messages.append(Message("user", [item])) + stream = self.run( + input_messages, + stream=True, + session=ctx.session if propagate_session else None, + function_invocation_kwargs=dict(ctx.kwargs), + ) + else: + stream = self.run( + str(kwargs.get(arg_name, "")), + stream=True, + session=ctx.session if propagate_session else None, + function_invocation_kwargs=dict(ctx.kwargs), + ) if stream_callback is not None: stream.with_transform_hook(stream_callback) final_response = await stream.get_final_response() diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 6cdc74b313d..da50f5424fa 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1359,9 +1359,26 @@ async def _auto_invoke_function( return function_call_content tool = tool_map.get(inner_call.name) # type: ignore[attr-defined, union-attr, arg-type] if tool is None: - # we assume it is a hosted tool - return function_call_content - function_call_content = inner_call # type: ignore[assignment] + # Check if this is a sub-agent approval that needs re-routing + # through the parent tool (the sub-agent wrapper). + parent_tool_name = function_call_content.additional_properties.get("_parent_tool_name") + if parent_tool_name: + tool = tool_map.get(parent_tool_name) + if tool is None: + # we assume it is a hosted tool + return function_call_content + # Re-invoke through the parent sub-agent tool with approval context. + function_call_content = Content( + type="function_call", + name=parent_tool_name, + call_id=function_call_content.call_id, + arguments=function_call_content.additional_properties.get("_parent_tool_args"), + additional_properties={ + "_approval_messages": [function_call_content], + }, + ) + else: + function_call_content = inner_call # type: ignore[assignment] parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {}) @@ -1397,6 +1414,13 @@ async def _auto_invoke_function( from ._middleware import FunctionInvocationContext + # Carry sub-agent approval context through to the FunctionInvocationContext + # so _agent_wrapper can forward approval messages to the inner agent. + approval_metadata: dict[str, Any] | None = None + approval_msgs = function_call_content.additional_properties.get("_approval_messages") + if approval_msgs: + approval_metadata = {"_approval_messages": approval_msgs} + if middleware_pipeline is None or not middleware_pipeline.has_middlewares: # No middleware - execute directly try: @@ -1406,6 +1430,7 @@ async def _auto_invoke_function( function=tool, arguments=args, session=invocation_session, + metadata=approval_metadata, kwargs=runtime_kwargs.copy(), ) function_result = await tool.invoke( @@ -1435,6 +1460,7 @@ async def _auto_invoke_function( function=tool, arguments=args, session=invocation_session, + metadata=approval_metadata, kwargs=runtime_kwargs.copy(), ) @@ -1613,6 +1639,10 @@ async def invoke_with_termination_handling( item.call_id = function_call.call_id # type: ignore[attr-defined] if not item.id: # type: ignore[attr-defined] item.id = function_call.call_id # type: ignore[attr-defined] + # Tag with parent tool info so approval responses can be + # routed back through the sub-agent on re-invocation. + item.additional_properties["_parent_tool_name"] = function_call.name + item.additional_properties["_parent_tool_args"] = function_call.arguments propagated.append(item) if propagated: extra_user_input_contents.extend(propagated[1:]) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index c7b3d7860cd..80b3bf50d64 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -2653,3 +2653,211 @@ async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetRespo assert len(exc_info.value.contents) == 1 assert exc_info.value.contents[0].type == "oauth_consent_request" assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent" + + +@pytest.mark.asyncio +async def test_chat_agent_as_tool_inner_approval_executes_tool() -> None: + """Test that approving a sub-agent's inner tool actually executes it. + + When a sub-agent used via as_tool() has an inner tool with approval_mode="always_require", + the approval request propagates to the outer caller. Sending the approval response back + must re-invoke the sub-agent and execute the approved inner tool. + """ + from unittest.mock import patch as mock_patch + + from tests.core.conftest import MockBaseChatClient + + inner_tool_executed = False + + @tool(approval_mode="always_require") + def get_detail(location: str) -> str: + """Get details for a location.""" + nonlocal inner_tool_executed + inner_tool_executed = True + return f"Details for {location}: cloudy, 15C." + + # Inner agent uses streaming (as_tool always calls run with stream=True) + inner_client = MockBaseChatClient() + inner_client.streaming_responses = [ + # Inner LLM requests get_detail (needs approval) + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="inner_call_1", + name="get_detail", + arguments='{"location": "Amsterdam"}', + ) + ], + role="assistant", + finish_reason="tool_calls", + ) + ], + # After approval + execution, inner LLM produces final text + [ + ChatResponseUpdate( + contents=[Content.from_text("Details for Amsterdam: cloudy, 15C.")], + role="assistant", + finish_reason="stop", + ) + ], + ] + + inner_agent = Agent( + client=inner_client, + name="detail_agent", + description="Agent that provides detail information.", + instructions="You are a helpful detail assistant.", + tools=[get_detail], + ) + + # Outer agent uses non-streaming run_responses + outer_client = MockBaseChatClient() + outer_client.run_responses = [ + # Outer LLM requests the sub-agent tool + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="outer_call_1", + name="detail_agent_tool", + arguments='{"task": "Get details for Amsterdam"}', + ) + ], + ), + ), + # After tool result, outer LLM generates final text + ChatResponse( + messages=Message(role="assistant", contents=["Details for Amsterdam: cloudy, 15C."]), + ), + ] + + with mock_patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", 5): + outer_agent = Agent( + client=outer_client, + name="coordinator_agent", + description="Coordinator agent.", + instructions="Use the detail_agent_tool for detail questions.", + tools=[ + inner_agent.as_tool( + name="detail_agent_tool", + description="A detail agent tool.", + approval_mode="never_require", + ) + ], + ) + + session = outer_agent.create_session() + + # First run: should propagate approval request from inner tool + response1 = await outer_agent.run("Get details for Amsterdam", session=session, stream=False) + + assert response1.user_input_requests, "Expected approval request from inner tool" + approval_request = response1.user_input_requests[0] + assert approval_request.type == "function_approval_request" + assert approval_request.function_call.name == "get_detail" + + # Second run: send approval response — inner tool must execute + approval_response_content = approval_request.to_function_approval_response(True) + response2 = await outer_agent.run( + [Message("user", [approval_response_content])], + session=session, + stream=False, + ) + + assert inner_tool_executed, ( + "Inner tool was never executed after approval. The approval response was not routed through the sub-agent." + ) + assert "Amsterdam" in response2.text + + +@pytest.mark.asyncio +async def test_chat_agent_as_tool_inner_approval_rejected() -> None: + """Test that rejecting a sub-agent's inner tool approval does not execute it.""" + from unittest.mock import patch as mock_patch + + from tests.core.conftest import MockBaseChatClient + + inner_tool_executed = False + + @tool(approval_mode="always_require") + def get_detail(location: str) -> str: + """Get details for a location.""" + nonlocal inner_tool_executed + inner_tool_executed = True + return f"Details for {location}: cloudy, 15C." + + inner_client = MockBaseChatClient() + inner_client.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="inner_call_1", + name="get_detail", + arguments='{"location": "Amsterdam"}', + ) + ], + role="assistant", + finish_reason="tool_calls", + ) + ], + ] + + inner_agent = Agent( + client=inner_client, + name="detail_agent", + description="Agent that provides detail information.", + instructions="You are a helpful detail assistant.", + tools=[get_detail], + ) + + outer_client = MockBaseChatClient() + outer_client.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="outer_call_1", + name="detail_agent_tool", + arguments='{"task": "Get details for Amsterdam"}', + ) + ], + ), + ), + ChatResponse( + messages=Message(role="assistant", contents=["I could not get the details."]), + ), + ] + + with mock_patch("agent_framework._tools.DEFAULT_MAX_ITERATIONS", 5): + outer_agent = Agent( + client=outer_client, + name="coordinator_agent", + description="Coordinator agent.", + tools=[ + inner_agent.as_tool( + name="detail_agent_tool", + description="A detail agent tool.", + approval_mode="never_require", + ) + ], + ) + + session = outer_agent.create_session() + response1 = await outer_agent.run("Get details for Amsterdam", session=session, stream=False) + + assert response1.user_input_requests + approval_request = response1.user_input_requests[0] + + # Reject the approval + rejection_content = approval_request.to_function_approval_response(False) + await outer_agent.run( + [Message("user", [rejection_content])], + session=session, + stream=False, + ) + + assert not inner_tool_executed, "Inner tool should NOT execute when approval is rejected" From eacd7d1612694ad83c7e8fccbec7c9cc9d3571e6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 05:56:19 +0000 Subject: [PATCH 2/4] Address review feedback for sub-agent approval routing (#4963) - Fix call_id=None bug: store _parent_tool_call_id during propagation and use it when constructing the synthetic function_call Content, falling back to content.id then content.call_id - Strip _approval_messages from additional_properties after extraction so internal routing metadata doesn't persist in session messages - Use setdefault for _parent_tool_name/_parent_tool_args/_parent_tool_call_id to preserve inner routing metadata for nested sub-agent boundaries - Remove unnecessary @pytest.mark.asyncio decorators (asyncio_mode='auto') - Assert rejection test captures response and verifies text output - Assert function_result with correct call_id exists in session history Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_tools.py | 13 ++++++++++--- python/packages/core/tests/core/test_agents.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index da50f5424fa..cb5ae23142e 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1368,10 +1368,13 @@ async def _auto_invoke_function( # we assume it is a hosted tool return function_call_content # Re-invoke through the parent sub-agent tool with approval context. + parent_tool_call_id = function_call_content.additional_properties.get( + "_parent_tool_call_id" + ) or function_call_content.id or function_call_content.call_id function_call_content = Content( type="function_call", name=parent_tool_name, - call_id=function_call_content.call_id, + call_id=parent_tool_call_id, arguments=function_call_content.additional_properties.get("_parent_tool_args"), additional_properties={ "_approval_messages": [function_call_content], @@ -1420,6 +1423,8 @@ async def _auto_invoke_function( approval_msgs = function_call_content.additional_properties.get("_approval_messages") if approval_msgs: approval_metadata = {"_approval_messages": approval_msgs} + # Remove internal routing metadata so it doesn't persist in session messages. + function_call_content.additional_properties.pop("_approval_messages", None) if middleware_pipeline is None or not middleware_pipeline.has_middlewares: # No middleware - execute directly @@ -1641,8 +1646,10 @@ async def invoke_with_termination_handling( item.id = function_call.call_id # type: ignore[attr-defined] # Tag with parent tool info so approval responses can be # routed back through the sub-agent on re-invocation. - item.additional_properties["_parent_tool_name"] = function_call.name - item.additional_properties["_parent_tool_args"] = function_call.arguments + # Use setdefault to preserve inner routing metadata for nested sub-agents. + item.additional_properties.setdefault("_parent_tool_name", function_call.name) + item.additional_properties.setdefault("_parent_tool_args", function_call.arguments) + item.additional_properties.setdefault("_parent_tool_call_id", function_call.call_id) propagated.append(item) if propagated: extra_user_input_contents.extend(propagated[1:]) diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 80b3bf50d64..eb9f25878ff 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -2655,7 +2655,6 @@ async def test_as_tool_raises_on_user_input_request(client: SupportsChatGetRespo assert exc_info.value.contents[0].consent_link == "https://login.microsoftonline.com/consent" -@pytest.mark.asyncio async def test_chat_agent_as_tool_inner_approval_executes_tool() -> None: """Test that approving a sub-agent's inner tool actually executes it. @@ -2771,8 +2770,16 @@ def get_detail(location: str) -> str: ) assert "Amsterdam" in response2.text + # Verify the outer tool result has the correct call_id matching the original tool call. + messages = session.state.get("in_memory", {}).get("messages", []) + assert any( + getattr(content, "type", None) == "function_result" + and getattr(content, "call_id", None) == "outer_call_1" + for message in messages + for content in (getattr(message, "contents", None) or []) + ), "Expected persisted outer tool result with call_id='outer_call_1' in session history" + -@pytest.mark.asyncio async def test_chat_agent_as_tool_inner_approval_rejected() -> None: """Test that rejecting a sub-agent's inner tool approval does not execute it.""" from unittest.mock import patch as mock_patch @@ -2854,10 +2861,11 @@ def get_detail(location: str) -> str: # Reject the approval rejection_content = approval_request.to_function_approval_response(False) - await outer_agent.run( + response2 = await outer_agent.run( [Message("user", [rejection_content])], session=session, stream=False, ) assert not inner_tool_executed, "Inner tool should NOT execute when approval is rejected" + assert response2.text, "Outer agent should still produce a response after rejection" From 6f802068812a6a2492608f79c126151c3fdbd4c6 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 06:01:15 +0000 Subject: [PATCH 3/4] Fix pyright reportMissingImports for orjson in sample files (#4963) Add type: ignore comments for optional orjson import in file_history_provider samples to suppress pyright errors in samples-check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/samples/02-agents/conversations/file_history_provider.py | 2 +- .../file_history_provider_conversation_persistence.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/02-agents/conversations/file_history_provider.py b/python/samples/02-agents/conversations/file_history_provider.py index 04a87f8224f..1b457bcf55c 100644 --- a/python/samples/02-agents/conversations/file_history_provider.py +++ b/python/samples/02-agents/conversations/file_history_provider.py @@ -21,7 +21,7 @@ from pydantic import Field try: - import orjson + import orjson # type: ignore[reportMissingImports] except ImportError: orjson = None diff --git a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py index 70c5d7e8e8f..bb9b69df129 100644 --- a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py +++ b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py @@ -22,7 +22,7 @@ from pydantic import Field try: - import orjson + import orjson # type: ignore[reportMissingImports] except ImportError: orjson = None From 2ed94325b4a45835412efc32eb9b957f3809a20b Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 16 Apr 2026 06:08:22 +0000 Subject: [PATCH 4/4] Address review feedback for #4963: Python: [Bug]: Cannot approve tool usage from sub-agents --- python/packages/core/agent_framework/_tools.py | 8 +++++--- python/packages/core/tests/core/test_agents.py | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index cb5ae23142e..d4b586f24e6 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1368,9 +1368,11 @@ async def _auto_invoke_function( # we assume it is a hosted tool return function_call_content # Re-invoke through the parent sub-agent tool with approval context. - parent_tool_call_id = function_call_content.additional_properties.get( - "_parent_tool_call_id" - ) or function_call_content.id or function_call_content.call_id + parent_tool_call_id = ( + function_call_content.additional_properties.get("_parent_tool_call_id") + or function_call_content.id + or function_call_content.call_id + ) function_call_content = Content( type="function_call", name=parent_tool_name, diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index eb9f25878ff..f23113fe6d8 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -2773,8 +2773,7 @@ def get_detail(location: str) -> str: # Verify the outer tool result has the correct call_id matching the original tool call. messages = session.state.get("in_memory", {}).get("messages", []) assert any( - getattr(content, "type", None) == "function_result" - and getattr(content, "call_id", None) == "outer_call_1" + getattr(content, "type", None) == "function_result" and getattr(content, "call_id", None) == "outer_call_1" for message in messages for content in (getattr(message, "contents", None) or []) ), "Expected persisted outer tool result with call_id='outer_call_1' in session history"