Python: fix: execute non-approval tools in mixed approval batches - #6400
Python: fix: execute non-approval tools in mixed approval batches#6400King Star (jstar0) wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR updates function invocation behavior so that, when multiple tool calls are returned, only the tools configured to require approval are deferred for approval while non-approval tools execute normally.
Changes:
- Adjust
_try_execute_function_callsto defer only approval-required calls, execute the rest, and preserve original call ordering in returnedContent. - Update response construction to append
function_resultcontents as a separate"tool"message. - Add/adjust tests to validate mixed approval and execution behavior across streaming and non-streaming flows.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| python/packages/core/tests/core/test_function_invocation_logic.py | Adds a targeted regression test for mixed approval batching; updates scenario assertions to reflect new mixed execution/approval behavior. |
| python/packages/core/agent_framework/_tools.py | Changes invocation logic to defer only approval-required tools; updates docstrings and response message shaping for function results. |
| @@ -1708,36 +1706,25 @@ async def _try_execute_function_calls( | |||
| ) | |||
| if fcc.type == "function_call" and fcc.name in approval_tools: # type: ignore[attr-defined] | |||
| logger.debug("Approval needed for function: %s", fcc.name) | |||
| approval_needed = True | |||
| break | |||
| deferred_results_by_index[idx] = Content.from_function_approval_request( | |||
| id=fcc.call_id, # type: ignore[arg-type, attr-defined] | |||
| function_call=fcc, # type: ignore[arg-type] | |||
| ) | |||
| continue | |||
| if fcc.type == "function_call" and (fcc.name in declaration_only or fcc.name in additional_tool_names): # type: ignore[attr-defined] | |||
| declaration_only_flag = True | |||
| break | |||
| fcc.user_input_request = True | |||
| fcc.id = fcc.call_id | |||
| deferred_results_by_index[idx] = fcc | |||
| continue | |||
| if ( | |||
| 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. | |||
| declaration_only_calls: list[Content] = [] | |||
| for fcc in function_calls: | |||
| if fcc.type == "function_call": | |||
| fcc.user_input_request = True | |||
| fcc.id = fcc.call_id | |||
| declaration_only_calls.append(fcc) | |||
| return (declaration_only_calls, False) | |||
| function_calls_to_execute.append((idx, fcc)) | |||
| 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 | ||
| assert messages[2].contents[0].type == "function_result" | ||
| assert messages[2].contents[0].call_id == "1" | ||
| assert messages[2].contents[0].result == "Processed value1" | ||
| assert messages[2].contents[1].type == "function_approval_request" | ||
| assert messages[2].contents[1].function_call.name == "approval_func" | ||
| assert exec_counter == 1 |
| if fcc.type == "function_call" and (fcc.name in declaration_only or fcc.name in additional_tool_names): # type: ignore[attr-defined] | ||
| declaration_only_flag = True | ||
| break | ||
| fcc.user_input_request = True | ||
| fcc.id = fcc.call_id | ||
| deferred_results_by_index[idx] = fcc | ||
| continue |
|
@microsoft-github-policy-service agree |
| response.messages[0].contents.extend(new_items) | ||
| else: | ||
| response.messages.append(Message(role="assistant", contents=new_items)) | ||
| function_result_items = [fccr for fccr in function_call_results if fccr.type == "function_result"] |
There was a problem hiding this comment.
Could we apply this same split to the streamed update path? This keeps non-streaming mixed approval responses role-correct, but _process_function_requests still returns the unsplit function_call_results, and the streaming loop emits them all with update_role == "assistant". Then stream.get_final_response() contains a function_result on an assistant message, so persisting it for approval continuation can send provider-invalid history. Should the streaming path yield the tool results as a separate role="tool" update before the approval request?
Python Test Coverage Report •
Python Unit Test Overview
|
||||||||||||||||||||||||||||||
|
We have a different design for this in mind and will get that going this week, so closing this PR, thanks for the look King Star (@jstar0) |
Summary
Fixes #6385.
When an assistant response contains a mixed batch of tool calls, the auto-invocation path currently uses one batch-level approval flag. If any tool in the batch requires approval, every function call is converted to a
function_approval_request, including tools configured withapproval_mode="never_require". That blocks non-sensitive tools behind the approval flow and prevents their normal results from being returned.Changes
This change makes approval handling per function call:
approval_mode="always_require"are returned as approval requests.toolmessages, while approval requests remain attached to the assistant message.tool()documentation note to match the per-call behavior.Verification
Contribution Checklist