diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index f7bc3f0e15..2c77596ff5 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1547,8 +1547,7 @@ async def _try_execute_function_calls( declaration_only = [tool_name for tool_name, tool in tool_map.items() if tool.declaration_only] configured_additional_tools = config.get("additional_tools") or [] additional_tool_names = [tool.name for tool in configured_additional_tools] - # check if any are calling functions that need approval - # if so, we return approval request for all + # Check which function calls need approval, are declaration-only, or are unknown approval_needed = False declaration_only_flag = False for fcc in function_calls: @@ -1570,17 +1569,6 @@ async def _try_execute_function_calls( config.get("terminate_on_unknown_calls", False) and fcc.type == "function_call" and fcc.name not in tool_map # type: ignore[attr-defined] ): raise KeyError(f'Error: Requested function "{fcc.name}" not found.') # type: ignore[attr-defined] - if approval_needed: - # approval can only be needed for Function Call Content, not Approval Responses. - logger.debug("Returning function_approval_request contents") - return ( - [ - Content.from_function_approval_request(id=fcc.call_id, function_call=fcc) # type: ignore[attr-defined, arg-type] - for fcc in function_calls - if fcc.type == "function_call" - ], - False, - ) if declaration_only_flag: # return the declaration only tools to the user, since we cannot execute them. # Mark as user_input_request so AgentExecutor emits request_info events and pauses the workflow. @@ -1645,6 +1633,29 @@ async def invoke_with_termination_handling( False, ) + if approval_needed: + # Only create approval requests for tools that actually require approval; + # execute non-approval tools normally. + logger.debug("Returning function_approval_request contents for approval-required tools") + approval_results: list[Content] = [] + non_approval_calls: list[Content] = [] + for fcc in function_calls: + if fcc.type == "function_call" and fcc.name in approval_tools: # type: ignore[attr-defined] + approval_results.append( + Content.from_function_approval_request(id=fcc.call_id, function_call=fcc) # type: ignore[attr-defined, arg-type] + ) + else: + non_approval_calls.append(fcc) + + if non_approval_calls: + execution_results = await asyncio.gather(*[ + invoke_with_termination_handling(fc, seq_idx) for seq_idx, fc in enumerate(non_approval_calls) + ]) + approval_results.extend(result[0] for result in execution_results) + approval_results.extend(extra_user_input_contents) + + return (approval_results, False) + execution_results = await asyncio.gather(*[ invoke_with_termination_handling(function_call, seq_idx) for seq_idx, function_call in enumerate(function_calls) ]) 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 d9659837a8..ac3eacf367 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -677,26 +677,33 @@ def func_with_approval(arg1: str) -> str: else: # num_functions == 2 # Two functions with mixed approval if not streaming: - # Mixed: assistant message has both calls + approval requests (4 items total) - # (because when one requires approval, all are batched for approval) + # Mixed: only the approval-required tool gets an approval request; + # the never_require tool executes immediately and produces a result. assert len(messages) == 1 - # Should have: 2 FunctionCallContent + 2 FunctionApprovalRequestContent + # Should have: 2 FunctionCallContent + 1 FunctionApprovalRequest + 1 FunctionResult assert len(messages[0].contents) == 4 assert messages[0].contents[0].type == "function_call" assert messages[0].contents[1].type == "function_call" - # Both should result in approval requests approval_requests = [c for c in messages[0].contents if c.type == "function_approval_request"] - assert len(approval_requests) == 2 - assert exec_counter == 0 # Neither function executed yet + assert len(approval_requests) == 1 + assert approval_requests[0].function_call.name == "approval_func" + function_results = [c for c in messages[0].contents if c.type == "function_result"] + assert len(function_results) == 1 + assert function_results[0].result == "Processed value1" + assert exec_counter == 1 # Only the no-approval function executed else: - # Streaming: 2 function call updates + 1 approval request with 2 contents + # Streaming: 2 function call updates + 1 update with approval request + function result assert len(messages) == 3 assert messages[0].contents[0].type == "function_call" assert messages[1].contents[0].type == "function_call" - # The approval request message contains both approval requests - assert len(messages[2].contents) == 2 - assert all(c.type == "function_approval_request" for c in messages[2].contents) - assert exec_counter == 0 # Neither function executed yet + # The result message contains the approval request and function result + approval_requests = [c for c in messages[2].contents if c.type == "function_approval_request"] + function_results_list = [c for c in messages[2].contents if c.type == "function_result"] + assert len(approval_requests) == 1 + assert approval_requests[0].function_call.name == "approval_func" + assert len(function_results_list) == 1 + assert function_results_list[0].result == "Processed value1" + assert exec_counter == 1 # Only the no-approval function executed async def test_rejected_approval(chat_client_base: SupportsChatGetResponse): diff --git a/python/samples/02-agents/tools/function_tool_with_approval.py b/python/samples/02-agents/tools/function_tool_with_approval.py index 3a2a565ed4..2eb78d9968 100644 --- a/python/samples/02-agents/tools/function_tool_with_approval.py +++ b/python/samples/02-agents/tools/function_tool_with_approval.py @@ -24,9 +24,7 @@ conditions = ["sunny", "cloudy", "raining", "snowing", "clear"] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; -# see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# This tool does not require approval (approval_mode="never_require"). @tool(approval_mode="never_require") def get_weather(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str: """Get the current weather for a given location."""