Python: Harden function calling loop and approval replay - #7345
Python: Harden function calling loop and approval replay#7345eavanvalkenburg wants to merge 8 commits into
Conversation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
There was a problem hiding this comment.
Pull request overview
This PR overhauls the Python function-invocation/approval-resume flow to make approval resume immutable, stream and return terminal tool results consistently, correlate approvals/results by occurrence (even when call_id is reused), and prevent approval control wrappers from being replayed into later model turns. It also updates OpenAI Responses API continuation handling so service-side storage skips replaying approval requests while still sending new approval responses, and adds a normative function-calling loop specification plus expanded regression coverage.
Changes:
- Refactors the core function-invocation layer to split approval-resolution vs model tool-call processing, enforce immutable normalization, and unify streaming/non-streaming semantics.
- Adds extensive regression tests for approval resume ordering, replay/occurrence correlation, history filtering, limits/termination behavior, and OpenAI service-side continuation approval serialization.
- Introduces a Python function-calling loop specification and links it from the Python AGENTS docs.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| python/packages/openai/tests/openai/test_openai_chat_client.py | Updates tests to assert approval requests are stripped but approval responses are preserved under service-side storage continuation. |
| python/packages/openai/AGENTS.md | Documents service-side continuation rules for approval request vs response serialization. |
| python/packages/openai/agent_framework_openai/_chat_client.py | Ensures function_approval_response is serialized even when request_uses_service_side_storage=True, while function_approval_request is skipped. |
| python/packages/core/tests/core/test_harness_tool_approval.py | Adds end-to-end approval-resume tests for immutability, reasoning replay grouping, history filtering, user-input pauses, and iteration-budget interactions. |
| python/packages/core/tests/core/test_function_invocation_logic.py | Strengthens assertions around approval processing immutability/inertness and adds targeted unit tests for replay deduplication and occurrence-aware correlation. |
| python/packages/core/AGENTS.md | Documents the new approval-resume invariants and history filtering expectations. |
| python/packages/core/agent_framework/_tools.py | Major refactor: occurrence-aware approval normalization, explicit approval-resolution step, streamlined loop helpers, and shared policy for limits/errors/termination. |
| python/packages/core/agent_framework/_sessions.py | Filters approval request/response wrappers out of history replay before later model calls. |
| python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py | Aligns with updated _try_execute_function_calls signature (removes unused attempt_idx). |
| python/AGENTS.md | Adds guidance that function-calling loop changes must follow the new spec and require extra validation. |
| docs/specs/004-python-function-calling-loop.md | Adds the function-calling loop contract, diagrams, scenario→test matrix, and validation checklist. |
…ution
When user approves a mix of static and provider-injected tools, partition them
by tool map membership. Execute only static tools (available now). Keep deferred
provider-injected tool approvals in fcc_todo but don't add results for them.
This allows _replace_approval_contents_with_results to find them in fcc_todo
but without results, so they stay as approval responses in messages (not marked
as rejected). The harness then handles them during agent.run() when provider
tools are registered.
The previous approach removed them from fcc_todo, causing them to be treated as
rejected ("Tool call invocation was rejected by user").
Add an end-to-end test that registers a real provider-injected tool via a
ContextProvider and drives the full pause/approve/resume flow, asserting the
approved side effect runs without any rejection or transport-failure result.
Fixes microsoft#7043.
Preserve non-adjacent and provider-injected approval behavior while filtering post-limit tool content and correlating AG-UI confirmation snapshots to the original call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
Keep every content produced by one approved function execution together through approval occurrence correlation, including multiple user-input requests and reused call IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
There was a problem hiding this comment.
Automated Code Review
Reviewers: 5 | Confidence: 84%
✓ Correctness
No actionable issues found in this dimension.
✓ Security Reliability
This is a large, well-structured PR that hardens the Python function-calling loop. The security-relevant changes (approval validation, immutable message copies, occurrence-aware correlation, history filtering) are generally sound. The
deferred_approval_idsset in the AG-UI transport is populated but never consumed, which is dead code but not a bug since the deferred approvals are implicitly left in-place by the existing_replace_approval_contents_with_resultsfallback logic. The use ofid()for object identity tracking in_collect_approval_responsesis safe within the function scope. One minor reliability concern exists around the shallow copy in_copy_messages_for_function_invocation— Content objects are shared between the copied and original message lists, meaning mutations to Content attributes on the 'private copy' (likeuser_input_request = True) technically leak back. However, in practice this only occurs for freshly-created model response content (not caller-provided), so the stated immutability contract holds for caller inputs.
✓ Test Coverage
This is a well-tested PR with comprehensive coverage for the refactored function-calling loop. The spec, tests, and implementation are well-aligned. The main gap is missing dedicated test coverage for the
is_follow_up_requestpath in_collect_approval_responses, which is a new way to consume approval authority (via user_input_request content in replayed history), distinct from the terminalfunction_resultpath that has explicit coverage. All other new behaviors (deferred provider-injected tools, non-adjacent compaction pairing, unexecutable tool content dropping, immutable approval boundaries) have appropriate test coverage through either direct unit tests or integration tests. The PR has extensive test coverage for its core changes: occurrence-based approval correlation, non-adjacent compaction grouping, post-limit output filtering, and history inertness of resolved approvals. The refactored_replace_approval_contents_with_resultshas dedicated tests for reused call_ids, multi-content groups, placeholder replacement, and deduplication. The new streaming/non-streaming parametrized tests cover middleware termination and error-limit edge cases. One moderate gap exists: the standard streaming approval-resume happy path (approve/reject → results yielded → model text) lacks a dedicated test at the FunctionCallingChatClient level, though it is covered at the Agent/harness level. New behaviors — post-limit content filtering, approval resume immutability, reasoning replay, history filtering, user input propagation, middleware termination, error limits, and the OpenAI approval response serialization change — all have dedicated tests with meaningful assertions. One minor gap: the error-limit approval test does not explicitly assert the model wasn't re-invoked (unlike the middleware-termination test which does), though the assertion on fallback text implicitly guards against it.
✓ Failure Modes
The changes are well-structured and the failure mode handling is generally solid. The occurrence-aware approval correlation, compaction linking, and provider-injected tool deferral all handle their error/edge cases correctly. The only notable dead code is
deferred_approval_idswhich is populated but never consumed — a minor cleanup omission, not a failure path. The exception-swallowing pattern in AG-UI_resolve_approval_responses(line 1344-1346) is pre-existing and correctly produces per-approval error results downstream. The_filter_approval_control_messagesuses shallow copy appropriately.
✓ Design Approach
I found one design-level correctness issue in the AG-UI approval-resume path: it still assumes each approved tool produces exactly one terminal function_result, so an approved tool that pauses again for user input is converted into a synthetic failure instead of surfacing its follow-up request(s). I did not find a second well-supported issue in the files shown here. I found one design issue in the new approval-resume loop: it undercounts executed approved tools when the tool returns user-input request content instead of a
function_result, somax_function_callsno longer enforces its documented "total number of individual function invocations" invariant on that path.
Automated review by eavanvalkenburg's agents
Keep approval-time follow-up requests grouped through the AG-UI adapter and count every executed result group against the function-call budget in both response modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
|
Addressed the latest automated review in af5572f. In addition to the two inline fixes, this adds direct coverage for follow-up requests consuming only their matching approval occurrence, FunctionInvocationLayer streaming approve/reject result ordering, and the approval error-limit path making exactly the required final no-tool model call. The unused AG-UI deferred approval id set was removed. No change was made for the shallow-copy observation because caller-owned Content is not mutated on that path, while deep-copying all Content would add cost and undermine the identity correlation used by approval normalization. |
Add focused regressions for grouped execution failure fallback and malformed confirm_changes snapshot metadata, bringing PR-added AG-UI executable lines to full line coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
|
Added focused AG-UI defensive-path coverage in 19a8134: grouped executor failure fallback, non-list confirm_changes tool metadata, and malformed confirm_changes argument JSON. The PR-added executable lines in agent_framework_ag_ui/_agent_run.py are now 55/55 line-covered. |
Emit metadata-only declaration updates after provider argument chunks and preserve request metadata during function-call aggregation so streamed arguments appear exactly once. Co-authored-by: Kartik Madan <kartikmadan97@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
Motivation & Context
Python function approval resume currently has several coupled transcript failures:
call_idvalues can restore duplicate calls or attach results to the wrong logical occurrence;before_runregisters them;confirm_changessnapshots can attach unrelated function results.These failures can leave UI tool calls without results, produce provider-invalid histories, prevent approved calls
from resuming, or execute stale approval authority on a later turn.
Description & Review Guide
call_id.user-input requests.
max_function_calls, including user-input pauses.HistoryProvidermodel replay.approval requests.
FunctionInvocationLayerby splitting approval/model processing, naming the stream/non-stream loops,removing dead argument plumbing and closures, and centralizing shared loop policy.
max_function_callsormax_iterations, while preservingmetadata-only stream updates.
confirm_changessnapshots to the originalfunction_call_idwithout joining unrelated results.the remaining service-owned follow-up, and core-team coordination requirements.
confirm_changessnapshot correlation.Related Issue
Fixes #7241
Fixes #7304
Fixes #7125
Fixes #7212
Fixes #7043
Fixes #7045
Fixes #6828
Fixes #6973
This PR supersedes #7243, #7326, #7133, #7244, #7091, #7334, #7316, and #7110 by consolidating their fixes with
rejected/non-streaming parity, immutable inputs, occurrence-aware multi-round replay, history semantics, and the
shared validation specification. It preserves the substantive contributions from #7244 and #7091, incorporates the
intent of #7334 for both invocation limits, and corrects #7316 by selecting only the result identified by the
original
function_call_id. It also incorporates #7110's metadata-only declaration streaming approach and preservesthe contributor's attribution in the implementation commit.
#6851 remains a separate service-owned follow-up because proving that a terminal approval cannot execute again
through
previous_response_idrequires hosted-service continuation coverage rather than a Python core or adaptertranscript fix.
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after a language prefix) — a workflow keeps the label and title prefix in sync automatically.