Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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)
])
Expand Down
29 changes: 18 additions & 11 deletions python/packages/core/tests/core/test_function_invocation_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading