From 00b9423e00987c33a14e2ee018653e469708b462 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 9 Mar 2026 12:48:54 -0700 Subject: [PATCH 1/3] Python: Fix as_tool() swallowing user_input_request events (#4499) When a sub-agent wrapped with as_tool() emits user_input_request content (e.g. oauth_consent_request), the event was silently dropped because as_tool() only returns .text. This fix propagates user_input_request Content through the tool invocation pipeline to the parent response. Changes: - Add UserInputRequiredException to bridge the str-return boundary - Check response.user_input_requests in as_tool() before returning .text - Add re-raise guards in _auto_invoke_function to prevent the generic except Exception from swallowing the exception - Catch in invoke_with_termination_handling to extract Content items - Expand _handle_function_call_results to detect user_input_request Content Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/__init__.py | 2 + .../packages/core/agent_framework/_agents.py | 12 +++- .../packages/core/agent_framework/_tools.py | 26 ++++++- .../core/agent_framework/exceptions.py | 28 ++++++++ .../packages/core/tests/core/test_agents.py | 68 +++++++++++++++++++ .../core/test_function_invocation_logic.py | 57 ++++++++++++++++ 6 files changed, 188 insertions(+), 5 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index ef03652898..57a438da2a 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -181,6 +181,7 @@ ) from .exceptions import ( MiddlewareException, + UserInputRequiredException, WorkflowCheckpointException, WorkflowConvergenceException, WorkflowException, @@ -291,6 +292,7 @@ "TypeCompatibilityError", "UpdateT", "UsageDetails", + "UserInputRequiredException", "ValidationTypeEnum", "Workflow", "WorkflowAgent", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 3aaf9f1419..248058300b 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -51,7 +51,7 @@ map_chat_to_agent_update, normalize_messages, ) -from .exceptions import AgentInvalidResponseException +from .exceptions import AgentInvalidResponseException, UserInputRequiredException from .observability import AgentTelemetryLayer if sys.version_info >= (3, 13): @@ -532,7 +532,10 @@ async def agent_wrapper(**kwargs: Any) -> str: if stream_callback is None: # Use non-streaming mode - return (await self.run(input_text, stream=False, session=parent_session, **forwarded_kwargs)).text + response = await self.run(input_text, stream=False, session=parent_session, **forwarded_kwargs) + if response.user_input_requests: + raise UserInputRequiredException(contents=response.user_input_requests) + return response.text # Use streaming mode - accumulate updates and create final response response_updates: list[AgentResponseUpdate] = [] @@ -544,7 +547,10 @@ async def agent_wrapper(**kwargs: Any) -> str: stream_callback(update) # Create final text from accumulated updates - return AgentResponse.from_updates(response_updates).text + final_response = AgentResponse.from_updates(response_updates) + if final_response.user_input_requests: + raise UserInputRequiredException(contents=final_response.user_input_requests) + return final_response.text agent_tool: FunctionTool = FunctionTool( name=tool_name, diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 105738e717..5b021276a1 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -37,7 +37,7 @@ from pydantic import BaseModel, Field, ValidationError, create_model from ._serialization import SerializationMixin -from .exceptions import ToolException +from .exceptions import ToolException, UserInputRequiredException from .observability import ( OPERATION_DURATION_BUCKET_BOUNDARIES, OtelAttr, @@ -1228,6 +1228,8 @@ async def _auto_invoke_function( result=function_result, additional_properties=function_call_content.additional_properties, ) + except UserInputRequiredException: + raise except Exception as exc: message = "Error: Function failed." if config.get("include_detailed_errors", False): @@ -1274,6 +1276,8 @@ async def final_function_handler(context_obj: Any) -> Any: additional_properties=function_call_content.additional_properties, ) raise + except UserInputRequiredException: + raise except Exception as exc: message = "Error: Function failed." if config.get("include_detailed_errors", False): @@ -1407,6 +1411,21 @@ async def invoke_with_termination_handling( result=exc.result, ) return (result_content, True) + except UserInputRequiredException as exc: + # Sub-agent requires user input — propagate the Content items so + # _handle_function_call_results can surface them to the parent response. + if exc.contents: + content = exc.contents[0] + content.call_id = function_call.call_id # type: ignore[attr-defined] + return (content, False) + return ( + Content.from_function_result( + call_id=function_call.call_id, # type: ignore[arg-type] + result="Tool requires user input but no request details were provided.", + exception="UserInputRequiredException", + ), + False, + ) execution_results = await asyncio.gather(*[ invoke_with_termination_handling(function_call, seq_idx) for seq_idx, function_call in enumerate(function_calls) @@ -1645,7 +1664,10 @@ def _handle_function_call_results( ) -> FunctionRequestResult: from ._types import Message - if any(fccr.type in {"function_approval_request", "function_call"} for fccr in function_call_results): + if any( + fccr.type in {"function_approval_request", "function_call"} or fccr.user_input_request + for fccr in function_call_results + ): # Only add items that aren't already in the message (e.g. function_approval_request wrappers). # Declaration-only function_call items are already present from the LLM response. new_items = [fccr for fccr in function_call_results if fccr.type != "function_call"] diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index f38aa38590..4f3e547f31 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -180,6 +180,34 @@ class ToolExecutionException(ToolException): pass +class UserInputRequiredException(ToolException): + """Raised when a tool wrapping a sub-agent requires user input to proceed. + + This exception carries the ``user_input_request`` Content items emitted by + the sub-agent (e.g., ``oauth_consent_request``, ``function_approval_request``) + so the tool invocation layer can propagate them to the parent agent's response + instead of swallowing them as a generic tool error. + + Args: + contents: The user-input-request Content items from the sub-agent response. + message: Human-readable description of why user input is needed. + """ + + def __init__( + self, + contents: list[Any], + message: str = "Tool requires user input to proceed.", + ) -> None: + """Create a UserInputRequiredException. + + Args: + contents: The user-input-request Content items from the sub-agent response. + message: Human-readable description of why user input is needed. + """ + super().__init__(message, log_level=10) + self.contents = contents + + # endregion # region Middleware Exceptions diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index d41b87b707..70366c91b1 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1452,4 +1452,72 @@ async def test_stores_by_default_with_store_false_injects_inmemory(client: Suppo # endregion +# region as_tool user_input_request propagation + + +async def test_as_tool_raises_on_user_input_request_non_streaming(client: SupportsChatGetResponse) -> None: + """Test that as_tool raises UserInputRequiredException when the sub-agent response has user_input_requests.""" + from agent_framework.exceptions import UserInputRequiredException + + # Configure mock client to return a response with oauth_consent_request content + consent_content = Content.from_oauth_consent_request( + consent_link="https://login.microsoftonline.com/consent", + ) + client.responses = [ # type: ignore[attr-defined] + ChatResponse(messages=Message(role="assistant", contents=[consent_content])), + ] + + agent = Agent(client=client, name="OAuthAgent", description="Agent requiring consent") + agent_tool = agent.as_tool() + + with raises(UserInputRequiredException) as exc_info: + await agent_tool.invoke(arguments=agent_tool.input_model(task="Do something")) + + 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" + + +async def test_as_tool_raises_on_user_input_request_streaming(client: SupportsChatGetResponse) -> None: + """Test that as_tool raises UserInputRequiredException in streaming mode.""" + from agent_framework.exceptions import UserInputRequiredException + + consent_content = Content.from_oauth_consent_request( + consent_link="https://login.microsoftonline.com/consent", + ) + client.streaming_responses = [ # type: ignore[attr-defined] + [ChatResponseUpdate(contents=[consent_content], role="assistant")], + ] + + collected_updates: list[AgentResponseUpdate] = [] + + def stream_callback(update: AgentResponseUpdate) -> None: + collected_updates.append(update) + + agent = Agent(client=client, name="OAuthAgent", description="Agent requiring consent") + agent_tool = agent.as_tool(stream_callback=stream_callback) + + with raises(UserInputRequiredException) as exc_info: + await agent_tool.invoke(arguments=agent_tool.input_model(task="Do something")) + + assert len(exc_info.value.contents) == 1 + assert exc_info.value.contents[0].type == "oauth_consent_request" + # Stream callback should still have received the update before the exception + assert len(collected_updates) > 0 + + +async def test_as_tool_returns_text_when_no_user_input_request(client: SupportsChatGetResponse) -> None: + """Test that as_tool returns text normally when there are no user_input_requests.""" + client.responses = [ # type: ignore[attr-defined] + ChatResponse(messages=Message(role="assistant", text="Here is the result")), + ] + + agent = Agent(client=client, name="NormalAgent", description="Normal agent") + agent_tool = agent.as_tool() + + result = await agent_tool.invoke(arguments=agent_tool.input_model(task="Do something")) + + assert result == "Here is the result" + + # endregion diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 7f0eda62fc..a460f2ea7f 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3511,4 +3511,61 @@ def test_dict_overwrites_existing_conversation_id(self): assert kwargs["chat_options"]["conversation_id"] == "new_id" +# region UserInputRequiredException propagation through tool invocation + + +async def test_user_input_request_propagates_through_as_tool(chat_client_base: SupportsChatGetResponse): + """Test that user_input_request content from a sub-agent wrapped as a tool propagates to the parent response. + + This is an end-to-end test: sub-agent returns oauth_consent_request → + as_tool raises UserInputRequiredException → invoke_with_termination_handling catches it → + _handle_function_call_results returns "action": "return" → Content ends up in parent response. + """ + from agent_framework.exceptions import UserInputRequiredException + + # Create a mock tool that simulates what as_tool does when the sub-agent + # returns user_input_request content + @tool(name="delegate_agent", approval_mode="never_require") + def delegate_tool(task: str) -> str: + raise UserInputRequiredException( + contents=[ + Content.from_oauth_consent_request( + consent_link="https://login.microsoftonline.com/consent", + ) + ] + ) + + # Parent agent calls the tool, which raises UserInputRequiredException + chat_client_base.run_responses = [ + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="delegate_agent", arguments='{"task": "do it"}'), + ], + ) + ), + ] + + response = await chat_client_base.get_response( + [Message(role="user", text="delegate this")], + options={"tool_choice": "auto", "tools": [delegate_tool]}, + ) + + # The oauth_consent_request Content should be in the parent response's assistant message + user_requests = [ + content + for msg in response.messages + for content in msg.contents + if isinstance(content, Content) and content.user_input_request + ] + assert len(user_requests) == 1 + assert user_requests[0].type == "oauth_consent_request" + assert user_requests[0].consent_link == "https://login.microsoftonline.com/consent" + assert user_requests[0].user_input_request is True + + +# endregion + + # endregion From ebe0aedc71f3a412699d093b9a5f2d5daeba2b03 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Mon, 9 Mar 2026 12:59:33 -0700 Subject: [PATCH 2/3] Address PR review: propagate all user_input_request items, set content.id, suppress logging - Propagate all Content items from UserInputRequiredException, not just the first - Set content.id for AgentExecutor request tracking - Change log_level to None (control-flow signal, not an error) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_tools.py | 19 ++++++++++++++++--- .../core/agent_framework/exceptions.py | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 5b021276a1..b051fd58f1 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1385,6 +1385,8 @@ async def _try_execute_function_calls( # Run all function calls concurrently, handling MiddlewareTermination from ._middleware import MiddlewareTermination + extra_user_input_contents: list[Content] = [] + async def invoke_with_termination_handling( function_call: Content, seq_idx: int, @@ -1415,9 +1417,18 @@ async def invoke_with_termination_handling( # Sub-agent requires user input — propagate the Content items so # _handle_function_call_results can surface them to the parent response. if exc.contents: - content = exc.contents[0] - content.call_id = function_call.call_id # type: ignore[attr-defined] - return (content, False) + propagated: list[Content] = [] + for item in exc.contents: + if isinstance(item, Content): + 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] + propagated.append(item) + if propagated: + # Return the first item; any additional items are appended to + # execution_results via extra_user_input_contents below. + extra_user_input_contents.extend(propagated[1:]) + return (propagated[0], False) return ( Content.from_function_result( call_id=function_call.call_id, # type: ignore[arg-type] @@ -1433,6 +1444,8 @@ async def invoke_with_termination_handling( # Unpack results - each is (Content, terminate_flag) contents: list[Content] = [result[0] for result in execution_results] + # Append any additional user_input_request Content items from multi-item exceptions + contents.extend(extra_user_input_contents) # If any function requested termination, terminate the loop should_terminate = any(result[1] for result in execution_results) return (contents, should_terminate) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 4f3e547f31..4f56c34b5c 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -204,7 +204,7 @@ def __init__( contents: The user-input-request Content items from the sub-agent response. message: Human-readable description of why user input is needed. """ - super().__init__(message, log_level=10) + super().__init__(message, log_level=None) self.contents = contents From 490cb78994708583277b0b1473bcc97017f46da4 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Tue, 10 Mar 2026 12:19:54 -0700 Subject: [PATCH 3/3] addressed comments --- .../packages/core/agent_framework/_tools.py | 48 +++++++++---------- .../core/agent_framework/exceptions.py | 11 +++-- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index b051fd58f1..f595cd20a0 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1385,13 +1385,11 @@ async def _try_execute_function_calls( # Run all function calls concurrently, handling MiddlewareTermination from ._middleware import MiddlewareTermination - extra_user_input_contents: list[Content] = [] - async def invoke_with_termination_handling( function_call: Content, seq_idx: int, - ) -> tuple[Content, bool]: - """Invoke function and catch MiddlewareTermination, returning (result, should_terminate).""" + ) -> tuple[list[Content], bool]: + """Invoke function and catch MiddlewareTermination, returning (results, should_terminate).""" try: result = await _auto_invoke_function( function_call_content=function_call, # type: ignore[arg-type] @@ -1402,39 +1400,37 @@ async def invoke_with_termination_handling( middleware_pipeline=middleware_pipeline, config=config, ) - return (result, False) + return ([result], False) except MiddlewareTermination as exc: # Middleware requested termination - return result as Content # exc.result may already be a Content (set by _auto_invoke_function) or raw value if isinstance(exc.result, Content): - return (exc.result, True) + return ([exc.result], True) result_content = Content.from_function_result( call_id=function_call.call_id, # type: ignore[arg-type] result=exc.result, ) - return (result_content, True) + return ([result_content], True) except UserInputRequiredException as exc: # Sub-agent requires user input — propagate the Content items so # _handle_function_call_results can surface them to the parent response. if exc.contents: propagated: list[Content] = [] - for item in exc.contents: - if isinstance(item, Content): - 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] - propagated.append(item) + for idx, item in enumerate(exc.contents): + item.call_id = function_call.call_id # type: ignore[attr-defined] + if not item.id: # type: ignore[attr-defined] + item.id = f"{function_call.call_id}:{idx}" # type: ignore[attr-defined] + propagated.append(item) if propagated: - # Return the first item; any additional items are appended to - # execution_results via extra_user_input_contents below. - extra_user_input_contents.extend(propagated[1:]) - return (propagated[0], False) + return (propagated, False) return ( - Content.from_function_result( - call_id=function_call.call_id, # type: ignore[arg-type] - result="Tool requires user input but no request details were provided.", - exception="UserInputRequiredException", - ), + [ + Content.from_function_result( + call_id=function_call.call_id, # type: ignore[arg-type] + result="Tool requires user input but no request details were provided.", + exception="UserInputRequiredException", + ) + ], False, ) @@ -1442,10 +1438,10 @@ async def invoke_with_termination_handling( invoke_with_termination_handling(function_call, seq_idx) for seq_idx, function_call in enumerate(function_calls) ]) - # Unpack results - each is (Content, terminate_flag) - contents: list[Content] = [result[0] for result in execution_results] - # Append any additional user_input_request Content items from multi-item exceptions - contents.extend(extra_user_input_contents) + # Flatten results in original function_calls order — each task returns (list[Content], terminate_flag) + contents: list[Content] = [] + for result_contents, _ in execution_results: + contents.extend(result_contents) # If any function requested termination, terminate the loop should_terminate = any(result[1] for result in execution_results) return (contents, should_terminate) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 4f56c34b5c..24d38fd1dc 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -6,8 +6,13 @@ and guidance on choosing the correct exception class. """ +from __future__ import annotations + import logging -from typing import Any, Literal +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from ._types import Content logger = logging.getLogger("agent_framework") @@ -195,7 +200,7 @@ class UserInputRequiredException(ToolException): def __init__( self, - contents: list[Any], + contents: list[Content], message: str = "Tool requires user input to proceed.", ) -> None: """Create a UserInputRequiredException. @@ -205,7 +210,7 @@ def __init__( message: Human-readable description of why user input is needed. """ super().__init__(message, log_level=None) - self.contents = contents + self.contents: list[Content] = contents # endregion