Python: Improve function approval resume and replay (#7345)

* Python: Harden function approval resume and replay

Make approval resume immutable and occurrence-aware, return grouped approved and rejected results consistently, preserve pending approval history without model-orphaned calls, and align streaming, non-streaming, and AG-UI result boundaries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1

* Python: Clarify function invocation orchestration

Simplify approval-resolution setup and add phase-level comments around the key function invocation orchestration paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
EOF && git push origin python-approval-resume-contract

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1ee1d250-d1e2-4c6f-8c36-aae0d94fe7a1
This commit is contained in:
Eduard van Valkenburg
2026-07-29 22:01:42 +02:00
committed by GitHub
parent 0e6a104192
commit 5987a6791b
13 changed files with 3244 additions and 847 deletions
@@ -0,0 +1,543 @@
---
status: proposed
contact: eavanvalkenburg
date: 2026-07-27
deciders: eavanvalkenburg
---
# Python function-calling loop contract and validation matrix
## Scope
This specification defines the required behavior and validation coverage for the Python function-calling loop.
It covers:
- normal local function execution;
- streaming and non-streaming response aggregation;
- tool approval request and resume;
- approved, rejected, mixed, and replayed approval rounds;
- reasoning content and opaque reasoning signatures bound to function calls;
- history persistence and service-side continuation;
- error, user-input, middleware-termination, and loop-limit paths;
- provider and transport serialization of function calls and results.
The primary implementation is in `python/packages/core/agent_framework/_tools.py`. History replay behavior in
`python/packages/core/agent_framework/_sessions.py`, provider serializers, hosting packages, and UI transports are
part of the same contract when they carry function-call loop content.
## Change sensitivity
This code is high risk. Small changes can produce duplicate side effects, orphaned calls or results, invalid
provider histories, invisible streaming results, stale approval authority, or loops that never terminate.
Dropping reasoning content that a service binds to a tool call can also make an otherwise balanced call/result
transcript invalid.
Any change to the function-calling loop or its approval/history/serialization paths must:
1. identify every affected row in the scenario matrix below;
2. add or update the corresponding regression tests;
3. validate streaming updates, streaming finalization, and non-streaming output where applicable;
4. validate both model-bound history and caller-visible responses;
5. run the full core package tests plus every affected provider or transport package;
6. run source typing, test typing, and syntax checks for every affected package;
7. receive extra review focused on call/result pairing, exactly-once execution, and history replay.
A passing narrow regression test is not sufficient evidence for changes in this area.
### Contribution ownership
Issues involving this code must not be picked up by external contributors without first checking with the Agent
Framework core team. The core team must confirm the intended behavior, affected scenario-matrix rows, ownership
across core/providers/transports, and the required validation scope before implementation starts.
## Flow diagrams and code map
### Main function-calling flow
The main control flow deliberately has separate streaming and non-streaming methods. They share policy helpers, but
their output mechanics differ: one returns an aggregated `ChatResponse`; the other yields `ChatResponseUpdate`
items and is finalized by `ResponseStream`.
The diagrams use only the generic distinction between **local tools**, which Agent Framework executes, and
**hosted-service tools**, whose calls and approval decisions are owned by a remote service. Provider-specific wire
formats and regression tests appear later in the scenario matrix.
```mermaid
flowchart TD
Entry["FunctionInvocationLayer.get_response(...)"]
Setup["Prepare middleware, options, session, budget state,<br/>and execute_function_calls partial"]
Enabled{"Function invocation enabled?"}
Direct["Delegate directly to super().get_response(...)"]
Mode{"stream?"}
NonStream["_get_response_with_function_invocation(...)"]
Stream["_stream_response_with_function_invocation(...)"]
Resolve["_resolve_approval_responses(...)<br/>runs once before the model-iteration loop"]
ApprovalAction{"approval action"}
Immediate["Return/yield terminal result or user-input request<br/>without another model call"]
ApprovalPolicy["Record approval executions;<br/>apply stop/function-call-limit policy"]
Model["Call super_get_response(...)<br/>response may contain reasoning + function_call"]
Process["_process_model_function_calls(...)"]
FunctionAction{"function-processing action"}
Execute["_execute_function_calls(...)"]
Try["_try_execute_function_calls(...)"]
Single["_execute_single_function_call(...)"]
Handle["_handle_function_call_results(...)"]
PostCallPolicy["Record executions; apply error/function-call-limit policy;<br/>reset required tool choice"]
Advance["_prepare_messages_for_next_iteration(...)"]
More{"iteration budget remains?"}
Final["Final model call with tool_choice = none<br/>and deterministic fallback if needed"]
Output["Return ChatResponse or complete ResponseStream"]
Entry --> Setup --> Enabled
Enabled -- no --> Direct
Enabled -- yes --> Mode
Mode -- no --> NonStream
Mode -- yes --> Stream
NonStream --> Resolve
Stream --> Resolve
Resolve --> ApprovalAction
ApprovalAction -- return --> Immediate --> Output
ApprovalAction -- stop --> ApprovalPolicy
ApprovalAction -- continue --> ApprovalPolicy
ApprovalPolicy --> More
Model --> Process
Process --> Execute --> Try --> Single --> Handle --> FunctionAction
FunctionAction -- return --> Output
FunctionAction -- stop --> PostCallPolicy
FunctionAction -- continue --> PostCallPolicy
PostCallPolicy --> Advance
Advance --> More
More -- yes --> Model
More -- no --> Final --> Output
```
Code-reading landmarks:
- `get_response(...)` owns setup and selects the response mode.
- `_get_response_with_function_invocation(...)` owns non-streaming aggregation.
- `_stream_response_with_function_invocation(...)` owns streamed emission/finalization.
- `_resolve_approval_responses(...)` handles only inbound approval decisions.
- `_process_model_function_calls(...)` handles only calls from a completed model response.
- `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch.
- `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer.
### Approval pause and resume
```mermaid
sequenceDiagram
participant Caller
participant History as HistoryProvider
participant Layer as FunctionInvocationLayer
participant Tool
participant Model
Caller->>Layer: Initial user request
Layer->>Model: Messages + tools
Model-->>Layer: reasoning content + function_call
Layer->>Layer: Tool requires approval
Layer-->>Caller: function_call + function_approval_request
Caller->>Layer: function_approval_response
Layer->>Layer: Copy caller-owned messages
Layer->>Layer: _resolve_approval_responses(...)
alt approved
Layer->>Tool: Execute exactly once
Tool-->>Layer: result or exception
Layer->>Layer: Create terminal function_result
else rejected
Layer->>Layer: Create synthetic rejection function_result
end
Layer-->>Caller: Terminal result message/update
alt tool requests more user input
Layer-->>Caller: User-input request with assistant role
else middleware terminates
Layer-->>Caller: Termination result
else error limit reached
Layer->>Model: Normalized reasoning/call/result history, tools disabled
Model-->>Layer: Final assistant response
Layer-->>Caller: Final assistant response
else continue normally
Layer->>Model: Normalized reasoning/call/result history
Model-->>Layer: Final assistant response or another function_call
Layer-->>Caller: Final assistant response / continued loop
end
Layer-->>History: Persist caller input + returned response
Note over History: Later model replay filters approval request/response wrappers
```
The terminal result is caller-visible in both modes. The private normalized message copy is model-visible. The
original caller input and earlier response remain unchanged.
### Reasoning-bound function-call groups
Some hosted services bind reasoning content or an opaque reasoning signature to the function call that follows it.
For those services, reasoning is not optional decoration; it is part of the provider-valid function-call group.
```mermaid
flowchart TD
Response["Assistant response:<br/>reasoning content + function_call"]
Group["One logical reasoning/function-call group"]
Owner{"local or hosted-service tool?"}
Local["Local execution"]
Hosted["Hosted service owns tool execution/state"]
Result["Terminal function_result or hosted result"]
Continuation{"continuation mode"}
Stateless["Stateless or framework-history replay"]
Replayable{"reasoning payload/signature<br/>is replayable?"}
Replay["Replay reasoning + call + result atomically"]
Reject["Fail before the service call;<br/>do not send a lossy transcript"]
Service["Hosted-service continuation"]
Reference["Reference service-stored reasoning/call;<br/>send only the new result or approval decision"]
Compact{"compaction needed?"}
Atomic["Keep or exclude the complete<br/>reasoning/call/result group"]
Caller["Caller-visible response retains reasoning<br/>with the function-call turn"]
Response --> Group --> Owner
Group --> Caller
Owner -- local --> Local --> Result
Owner -- hosted service --> Hosted --> Result
Result --> Compact
Compact -- yes --> Atomic --> Continuation
Compact -- no --> Continuation
Continuation -- stateless / local history --> Stateless --> Replayable
Replayable -- yes --> Replay
Replayable -- no --> Reject
Continuation -- service-managed --> Service --> Reference
```
The generic contract is:
- reasoning content remains ordered immediately before or alongside the function call it explains;
- a terminal result does not replace or discard the reasoning/call portion of the active group;
- stateless replay includes the service-required reasoning payload or opaque signature;
- if required reasoning cannot be reconstructed, the adapter fails before sending invalid or lossy history;
- service-managed continuation may rely on the hosted service's stored reasoning/call items and send only new
outputs or approval decisions;
- compaction keeps or removes the entire reasoning/call/result group atomically.
In the code, core response aggregation preserves reasoning `Content` items, compaction annotations bind reasoning to
the tool-call group, and provider adapters serialize or reconstruct the provider-specific reasoning representation.
### Approval correlation, replay, and reused ids
`call_id` is not globally unique forever. The normalizer therefore tracks open logical occurrences in transcript
order instead of keeping one global result per id.
```mermaid
flowchart TD
Scan["Scan normalized messages in order"]
Kind{"content type"}
Call["function_call:<br/>open a call occurrence"]
Request["function_approval_request"]
Bind{"unbound call occurrence<br/>with same call_id?"}
BindExisting["Bind request id to existing occurrence<br/>and remove wrapper"]
Duplicate{"same request identity<br/>already restored?"}
DropDuplicate["Remove replayed duplicate wrapper"]
Restore["Restore embedded function_call<br/>as a new occurrence"]
Placeholder["function_result with APPROVAL_PENDING:<br/>attach placeholder to open occurrence"]
Completed["terminal function_result:<br/>close earliest open occurrence"]
Response["function_approval_response"]
Pending{"response still pending?"}
RemoveOld["Remove already-resolved historical response"]
Decision{"approved?"}
Approved["Pop next execution result for this call_id"]
Rejected["Create synthetic rejection result"]
HasPlaceholder{"occurrence has placeholder?"}
Replace["Replace placeholder and remove response wrapper"]
ReplaceResponse["Replace response wrapper with terminal content"]
Close["Close occurrence; append terminal content<br/>to resumed response"]
Next["Continue scan"]
Scan --> Kind
Kind -- function_call --> Call --> Next
Kind -- approval request --> Request --> Bind
Bind -- yes --> BindExisting --> Next
Bind -- no --> Duplicate
Duplicate -- yes --> DropDuplicate --> Next
Duplicate -- no --> Restore --> Next
Kind -- pending placeholder --> Placeholder --> Next
Kind -- terminal result --> Completed --> Next
Kind -- approval response --> Response --> Pending
Pending -- no --> RemoveOld --> Next
Pending -- yes --> Decision
Decision -- yes --> Approved --> HasPlaceholder
Decision -- no --> Rejected --> HasPlaceholder
HasPlaceholder -- yes --> Replace --> Close --> Next
HasPlaceholder -- no --> ReplaceResponse --> Close --> Next
Next --> Kind
```
This flow corresponds to `_ApprovalCallOccurrence`, `_collect_approval_responses(...)`, and
`_replace_approval_contents_with_results(...)`.
### History and service-side continuation
```mermaid
flowchart LR
Store["History backing store<br/>(may retain approval wrappers for audit)"]
Load{"HistoryProvider.load_messages?"}
Filter["_filter_approval_control_messages(...)"]
Context["SessionContext model history:<br/>function_call + terminal function_result"]
Current["Current caller input:<br/>new function_approval_response"]
Layer["FunctionInvocationLayer private copy"]
Local{"local or hosted-service approval?"}
LocalResult["Execute locally and normalize to function_result"]
Hosted["Hosted-service adapter"]
StoredRequest["Prior service-issued approval request"]
NewResponse["Current hosted approval decision"]
Skip["Do not replay the stored request inline"]
Send["Send the approval decision exactly once"]
Later["Later turn"]
Manual["Manual-history caller"]
Store --> Load
Load -- yes --> Filter --> Context --> Layer
Load -- no --> Layer
Current --> Layer
Layer --> Local
Local -- local --> LocalResult --> Later
Local -- hosted service --> Hosted
StoredRequest --> Hosted --> Skip
NewResponse --> Hosted --> Send --> Later
Later --> Store
Manual -. owns equivalent filtering .-> Layer
```
When `load_messages=False`, no history is replayed and the history filter is intentionally not invoked. Callers
that manually replay messages own the equivalent rule: do not resend an approval response after its terminal result.
## Normative contract
### Function calls and results
- Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses
for a new user-input request.
- Parallel calls retain model order in the returned transcript.
- Reused `call_id` values are correlated by logical occurrence, not one global value per id.
- A completed function call/result pair is inert on later turns.
- Informational-only and declaration-only calls are not executed as local tools.
### Reasoning-bound calls
- Reasoning content or opaque reasoning metadata that a service binds to a function call is part of the same logical
group as that call and its terminal result.
- Active function loops preserve the reasoning content, function call, function result, and final assistant output
in caller-visible responses.
- Framework-managed/stateless replay includes the service-required reasoning representation before the paired call.
- Service-managed continuation may omit inline reasoning/call items only when the hosted service already owns them.
- Missing non-reconstructable reasoning fails explicitly before a provider request instead of silently dropping the
content.
- Compaction preserves or excludes the complete reasoning/call/result group atomically.
### Approval request and resume
- A tool that requires approval does not execute before an approved response.
- An approved tool executes exactly once.
- A rejected tool executes zero times and produces one synthetic rejection `function_result` using the original
function `call_id`.
- The resumed response contains the newly resolved approved and rejected terminal results before any final assistant
message.
- Streaming yields the same logical result content and ordering as non-streaming output and
`ResponseStream.get_final_response()`.
- The function invocation layer normalizes a private copy of caller messages. It must not mutate the caller's
approval `Message`, approval `Content`, or an earlier returned response.
- Approval-time `UserInputRequiredException` and `MiddlewareTermination` return immediately without another model
call.
### Approval control content
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
transcript items.
- A current hosted approval response must be sent once on the immediate resume request.
- A server-issued approval request must not be replayed inline during service-side continuation.
- History providers may retain approval control contents in their backing store for audit, but base history replay
filters them before later model calls.
- Callers that manually own and replay message history without a loading `HistoryProvider` must likewise omit a
previously submitted approval response from later continuation requests.
### History and continuation
- Model-bound history contains one function call/result pair per completed logical occurrence.
- Append-only history must not replay stale approval request/response wrappers to the model.
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
## Scenario-to-test matrix
### Normal function invocation
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Single non-streaming call | Call, result, and final assistant message are returned in order. | `packages/core/tests/core/test_function_invocation_logic.py::test_base_client_with_function_calling` |
| String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` |
| Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` |
| Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` |
| Reasoning-bound call | Finalized output retains reasoning, function call, function result, and final text. | `test_streaming_function_calling_response_includes_reasoning_and_tool_results` |
| Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` |
| Parallel calls | Results retain the corresponding call ids and execution count. | `test_max_function_calls_limits_parallel_invocations`, `test_streaming_multiple_function_calls_parallel_execution` |
| Informational-only call | The call is returned but not executed or approved. | `test_informational_only_function_call_is_not_invoked`, `test_informational_only_function_call_does_not_request_approval`, `test_streaming_informational_only_function_call_is_not_invoked` |
| Declaration-only call | The call is surfaced as user input and is not executed. | `test_declaration_only_tool` |
| Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` |
| Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` |
### Approval pause and resume
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Initial approval request | Assistant response contains the original call and approval request; tool does not execute. | `test_approval_requests_in_assistant_message`, `test_streaming_approval_request_generated`, `test_streaming_approval_requests_in_assistant_message` |
| Approved non-streaming resume | Result precedes final text; tool executes once; inputs remain unchanged. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_result_without_mutating_inputs[non-streaming-approved]` |
| Rejected non-streaming resume | Rejection result precedes final text; tool executes zero times; inputs remain unchanged. | `test_approval_resume_returns_result_without_mutating_inputs[non-streaming-rejected]` |
| Approved streaming resume | Result update precedes final text and final response matches non-streaming shape. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-approved]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[approved]` |
| Rejected streaming resume | Rejection result update precedes final text and tool executes zero times. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-rejected]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[rejected]` |
| Mixed approved/rejected batch | Every call gets one correctly correlated terminal result. | `packages/core/tests/core/test_function_invocation_logic.py::test_rejected_approval` |
| Persisted approval replay | Resume executes with the prior call available. | `test_persisted_approval_messages_replay_correctly` |
| Hosted approval pass-through | Hosted requests/responses are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_mixed_local_and_hosted_approval_flow` |
| Approval-time user input | Every user-input request from one approved execution returns in order with assistant role and no extra model call; the execution consumes one call-budget unit. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_all_user_input_requests_without_another_model_call`, `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_user_input_counts_toward_function_call_budget` |
| Mixed terminal result and follow-up input | Completed siblings remain tool-role while only follow-up input requests use assistant-role messages/updates. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_separates_terminal_results_from_follow_up_requests`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_mixed_approval_resume_roles_serialize_function_result_as_tool` |
| Approval-time middleware termination | Terminal result returns with no extra model call in either response mode. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_honors_middleware_termination` |
| Approval re-entry after iteration budget | Pending approved calls resolve once even when prior model calls consumed `max_iterations`. | `packages/core/tests/core/test_harness_tool_approval.py::test_auto_approval_resolves_after_iteration_budget_is_exhausted` |
| Approval resume with reasoning | Model-bound resume history retains reasoning before the call and terminal result in both modes. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_replays_reasoning_with_function_call_group` |
### Approval correlation and replay
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Result matching without placeholders | Results match calls by id even when the result list is reordered. | `test_replace_approval_contents_with_results_uses_result_call_ids_without_placeholders` |
| Reused id after completion | A later round with the same id creates a second valid pair. | `test_replace_approval_contents_with_results_allows_reused_call_id_after_completion` |
| Replayed approval wrapper | A duplicated wrapper does not restore another function call. | `test_replace_approval_contents_with_results_deduplicates_replayed_approval_request` |
| Historical resolved response plus new round | The old response is removed from normalized input and is not converted into a rejection result. | `test_replace_approval_contents_with_results_ignores_already_resolved_response` |
| Multiple reused-id rounds | Approved and rejected rounds retain separate call/result occurrences. | `test_replace_approval_contents_with_results_correlates_reused_call_id_occurrences` |
| Multi-content result with reused id | Every content produced by one execution stays with that approval occurrence and cannot bleed into the next reused-id round. | `test_replace_approval_contents_with_results_keeps_multi_content_group_with_reused_call_id` |
| Follow-up request closes one occurrence | A user-input follow-up consumes only the preceding approval authority and leaves a later reused-id response pending. | `test_collect_approval_responses_consumes_matching_follow_up_request_occurrence` |
| Reused-id placeholders | Placeholder results consume approved results by occurrence. | `test_replace_approval_contents_with_results_correlates_reused_call_id_placeholders` |
| Rejected placeholder | Rejection replaces the pending placeholder instead of adding a second result. | `test_replace_approval_contents_with_results_replaces_rejected_placeholder` |
| Results reordered with placeholders | Results still match the correct call ids. | `test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders` |
| Missing result call id | A malformed result does not steal another approval's result. | `test_replace_approval_contents_with_results_skips_results_without_call_id` |
| Empty approval message cleanup | Fully consumed approval messages are removed from normalized model input. | `test_replace_approval_contents_with_results_prunes_emptied_messages` |
| Later stateless turn | A prior terminal approval response cannot execute again. | `test_resolved_approval_response_is_inert_on_later_stateless_turn` |
| Pending history turn | An unresolved approval batch is omitted atomically from unrelated model input while a later decision can still resume it once. | `packages/core/tests/core/test_harness_tool_approval.py::test_pending_approval_from_file_history_stays_resumable_without_model_orphan` |
| Duplicate function-call prevention | Approval normalization does not create a second call for one round. | `test_no_duplicate_function_calls_after_approval_processing` |
| Rejection call id | Rejection result uses the function call id, not only the approval id. | `test_rejection_result_uses_function_call_id` |
### Mixed batches and approval middleware
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Safe and approval-required calls in one batch | Hidden safe calls replay only with the matching visible approval. | `packages/core/tests/core/test_harness_tool_approval.py::test_mixed_batch_hides_already_approved_request_until_approval_replay` |
| Restored approval state | Serialized `ToolApprovalState` restores mixed-batch behavior. | `test_mixed_batch_accepts_restored_tool_approval_state` |
| Unrelated turn before approval | Hidden calls do not execute on an unrelated turn. | `test_hidden_mixed_batch_requests_do_not_replay_on_unrelated_turn` |
| Multiple abandoned batches | Hidden calls replay only for the matching batch. | `test_hidden_mixed_batch_requests_replay_only_for_matching_visible_approval` |
| Queued approvals | One unresolved approval is surfaced per run without premature execution. | `test_tool_approval_middleware_queues_multiple_approval_requests`, `test_tool_approval_middleware_queues_streamed_approval_requests` |
| Middleware state plus hidden core state | State saves do not discard hidden mixed-batch calls. | `test_tool_approval_middleware_preserves_hidden_mixed_batch_requests` |
| Auto-approval callback | Callback receives the original function call and executes the approved set once. | `test_tool_approval_middleware_auto_approval_rule_receives_function_call` |
| Shared call budget | Auto-approved re-entry does not reset `max_function_calls`, and every executed approval group counts even when it pauses for input. | `test_tool_approval_middleware_auto_approved_loops_share_function_call_budget`, `test_approval_resume_user_input_counts_toward_function_call_budget` |
| Standing tool rule | Tool-level approval applies only to later matching tools. | `test_tool_approval_middleware_always_approve_tool_rule` |
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
### Errors, control flow, and limits
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` |
| Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` |
| Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` |
| Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` |
| Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` |
| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents |
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
| Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents |
| Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent |
| Conversation continuation | Conversation id updates between iterations and is cleared on stop where required. | `test_conversation_id_updated_in_options_between_tool_iterations`, `test_function_invocation_stop_clears_conversation_id_non_stream`, `test_streaming_function_invocation_stop_clears_conversation_id` |
### History and provider serialization
| Scenario | Required invariant | Primary regression test |
|---|---|---|
| Append-only history replay | Resolved approval wrappers do not reach a later model call; one call/result pair remains. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_filters_resolved_control_items_from_file_history` |
| Pending placeholder history | An approval response remains replayable while its only result is `[APPROVAL_PENDING]`. | `packages/core/tests/core/test_sessions.py::test_filter_approval_controls_keeps_response_for_pending_placeholder` |
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
| Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` |
| Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests |
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_resume_emits_tool_call_result`, `test_approval_resume_result_has_content`, `test_approval_resume_snapshot_replaces_approval_payload_with_tool_result`, `test_approval_resume_zero_updates_emits_tool_result` |
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `test_rejection_does_not_emit_tool_call_result`, `test_mixed_approve_reject_emits_only_approved_tool_result`, `test_resolve_approval_responses_returns_only_approved` |
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `test_resolve_approval_responses_preserves_follow_up_user_input_group` |
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `test_resolve_approval_responses_returns_failure_when_grouped_execution_raises` |
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
| Compaction pair integrity | Function call/result groups remain atomic. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group` |
## Required coverage gaps
These scenarios are required but are not fully covered by merged tests on `main`:
| Gap | Tracking |
|---|---|
| Non-adjacent and reused-id call/result occurrences remain atomic during compaction. | #7212 |
| Provider-injected approval-required tools defer until `before_run` tools exist and still emit one result. | #7043 |
| Service-side storage sends the current approval response while omitting the stored request. | #7125 |
| Service-owned `previous_response_id` continuation cannot execute a terminal approval again on a later turn. | #6851 |
| A provider that ignores `tool_choice="none"` after an invocation limit cannot expose an unanswered call. | #7045 |
| Declaration-only streaming preserves request metadata without duplicating arguments. | #6973 |
| AG-UI `confirm_changes` cleanup correlates one result by original function call id when several results exist. | #6828 |
Do not mark these rows covered by nearby tests; each needs a dedicated regression at the owning layer.
## Minimum validation commands
Run from `python/` for any core function-loop change:
```bash
uv run poe test -P core
uv run poe syntax -P core
uv run poe pyright -P core
uv run poe test-typing -P core
```
Also run every affected package. Common approval-loop changes require:
```bash
uv run poe test -P openai
uv run poe syntax -P openai
uv run poe pyright -P openai
uv run poe test-typing -P openai
uv run poe test -P ag-ui
uv run --directory packages/foundry_hosting poe test
```
Run focused regression files first while iterating, but do not substitute them for the full package commands above.
## Review checklist
Before accepting an update, reviewers must confirm:
- the changed behavior is represented in this specification;
- the matrix names a regression test for every affected scenario;
- approved tools cannot execute twice;
- rejected tools cannot execute;
- no call or result becomes orphaned or duplicated;
- call/result matching does not assume `call_id` is globally unique forever;
- reasoning content or opaque signatures remain in the same logical group as the paired call/result, or replay fails
explicitly before sending a lossy provider request;
- caller messages and previous responses remain immutable;
- streaming updates and final response agree with non-streaming output;
- history replay does not reintroduce approval authority;
- full package, syntax, source typing, and test typing checks were run.
## Related issues
- #7241 — approval-resolution result streaming
- #7267 / #7271 and #7304 — replayed calls and reused ids
- #6851 — duplicate side effects after approval continuation
- #7383 — bind approval responses to framework-issued requests after this foundation merges
- #6963 / #7095 — opaque reasoning-signature replay
- #6074 / #7233 — reasoning-paired tool-call replay
- #6450 / #6794 — provider message and tool-result serialization
+12
View File
@@ -6,6 +6,8 @@ Instructions for AI coding agents working in the Python codebase.
- [DEV_SETUP.md](DEV_SETUP.md) - Development environment setup and available poe tasks
- [CODING_STANDARD.md](CODING_STANDARD.md) - Coding standards, docstring format, and performance guidelines
- [samples/SAMPLE_GUIDELINES.md](samples/SAMPLE_GUIDELINES.md) - Sample structure and guidelines
- [Python function-calling loop specification](../docs/specs/004-python-function-calling-loop.md) - Required
behavior, scenario-to-test mapping, coverage gaps, and extra validation for function-loop changes
**Agent Skills** (`.github/skills/`) — detailed, task-specific instructions loaded on demand:
- `python-development` — coding standards, type annotations, docstrings, logging, performance
@@ -48,6 +50,16 @@ When preparing a PR description:
Run `uv run poe` from the `python/` directory to see available commands. See [DEV_SETUP.md](DEV_SETUP.md) for detailed usage.
## Function-Calling Loop Changes
Changes to the Python function-calling loop, approval resume behavior, function-call history, provider
serialization, or transport result handling must follow
[the function-calling loop specification](../docs/specs/004-python-function-calling-loop.md). This area requires
extra validation because small changes can duplicate side effects, orphan call/result pairs, replay stale approval
authority, or make streaming and non-streaming behavior diverge. Update the specification and its scenario-to-test
mapping whenever coverage or behavior changes. External contributors must check with the Agent Framework core team
before picking up issues in this area.
## Project Structure
```
+2
View File
@@ -29,6 +29,8 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard.
- Multimodal user inputs support both legacy (`text`, `binary`) and draft-style (`image`, `audio`, `video`, `document`) shapes.
- Interrupted runs complete with `RUN_FINISHED.outcome.type == "interrupt"` and canonical `outcome.interrupts`; do not document or add new flows that depend on the legacy top-level `RUN_FINISHED.interrupt` field.
- `Interrupt` and `ResumeEntry` come from the `ag-ui-protocol` package (`ag_ui.core`), not from an Agent Framework-specific interrupt model.
- Approval-time execution preserves each call's complete result group. Follow-up user-input requests remain in the
resumed messages, while `TOOL_CALL_RESULT` events are emitted only for terminal `function_result` contents.
- SSE keepalive is endpoint-owned transport behavior configured through
`add_agent_framework_fastapi_endpoint(keepalive_seconds=...)`. It emits SSE comments only; do not add `PING`,
`HEARTBEAT`, or `KEEPALIVE` AG-UI events, and do not add runner-level keepalive settings.
@@ -42,7 +42,7 @@ from agent_framework._tools import (
_collect_approval_responses, # type: ignore
_replace_approval_contents_with_results, # type: ignore
_TOOL_APPROVAL_STATE_KEY, # type: ignore
_try_execute_function_calls, # type: ignore
_try_execute_function_call_groups, # type: ignore
normalize_function_invocation_configuration,
)
from agent_framework._types import ResponseStream
@@ -1306,7 +1306,7 @@ async def _resolve_approval_responses(
approved_responses = validated
rejected_responses = validated_rejected
approved_function_results: list[Any] = []
approved_function_result_groups: list[list[Content]] = []
# Execute approved tool calls
if approved_responses and tools:
@@ -1319,36 +1319,30 @@ async def _resolve_approval_responses(
# Filter out AG-UI-specific kwargs that should not be passed to tool execution
tool_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
try:
results, _ = await _try_execute_function_calls(
approved_function_result_groups, _ = await _try_execute_function_call_groups(
custom_args=tool_kwargs,
attempt_idx=0,
function_calls=approved_responses,
tools=tools,
middleware_pipeline=middleware_pipeline,
config=config,
)
approved_function_results = list(results)
except Exception as e:
logger.exception("Failed to execute approved tool calls; injecting error results: %s", e)
approved_function_results = []
approved_function_result_groups = []
# Build results for approved responses (used for TOOL_CALL_RESULT event emission)
# Normalize one group per approval and collect only terminal results for TOOL_CALL_RESULT events.
replacement_groups: list[list[Content]] = []
approved_results: list[Content] = []
for idx, approval in enumerate(approved_responses):
if (
idx < len(approved_function_results)
and getattr(approved_function_results[idx], "type", None) == "function_result"
):
approved_results.append(approved_function_results[idx])
continue
# Get call_id from function_call if present, otherwise use approval.id
func_call = approval.function_call
call_id = (func_call.call_id if func_call else None) or approval.id or ""
approved_results.append(
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")
)
result_group = approved_function_result_groups[idx] if idx < len(approved_function_result_groups) else []
if not result_group:
func_call = approval.function_call
call_id = (func_call.call_id if func_call else None) or approval.id or ""
result_group = [Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")]
replacement_groups.append(result_group)
approved_results.extend(content for content in result_group if content.type == "function_result")
_replace_approval_contents_with_results(messages, fcc_todo, approved_results)
_replace_approval_contents_with_results(messages, fcc_todo, replacement_groups)
# Post-process: Convert user messages with function_result content to proper tool messages.
# After _replace_approval_contents_with_results, approved tool calls have their results
@@ -524,6 +524,90 @@ async def test_resolve_approval_responses_returns_only_approved() -> None:
assert "rejected" in str(rejection_results[0].result).lower()
async def test_resolve_approval_responses_preserves_follow_up_user_input_group() -> None:
"""Approval-time follow-up requests stay grouped and do not emit a synthetic tool result."""
from agent_framework import Message
from agent_framework.exceptions import UserInputRequiredException
from agent_framework_ag_ui._agent_run import _resolve_approval_responses
def request_consent() -> str:
raise UserInputRequiredException(
contents=[
Content.from_oauth_consent_request(consent_link="https://example.com/consent-1"),
Content.from_oauth_consent_request(consent_link="https://example.com/consent-2"),
]
)
consent_tool = FunctionTool(
name="request_consent",
description="Request two consent steps",
func=request_consent,
approval_mode="always_require",
)
function_call = Content.from_function_call(call_id="call_consent", name="request_consent", arguments="{}")
approval_request = Content.from_function_approval_request(id="approval_consent", function_call=function_call)
messages: list[Any] = [
Message(role="assistant", contents=[approval_request]),
Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]),
]
agent = StubAgent(updates=[], default_options={"tools": [consent_tool]})
results = await _resolve_approval_responses(messages, [consent_tool], agent, {})
follow_up_requests = [content for message in messages for content in message.contents if content.user_input_request]
assert results == []
assert [request.consent_link for request in follow_up_requests] == [
"https://example.com/consent-1",
"https://example.com/consent-2",
]
assert not [content for message in messages for content in message.contents if content.type == "function_result"]
assert any(message.role == "assistant" and message.contents == follow_up_requests for message in messages)
async def test_resolve_approval_responses_returns_failure_when_grouped_execution_raises(
monkeypatch: Any,
) -> None:
"""A grouped-execution failure produces one deterministic result for the approved call."""
from agent_framework import Message
from agent_framework_ag_ui._agent_run import _resolve_approval_responses
async def fail_grouped_execution(**kwargs: Any) -> tuple[list[list[Content]], bool]:
del kwargs
raise RuntimeError("execution failed")
monkeypatch.setattr(
"agent_framework_ag_ui._agent_run._try_execute_function_call_groups",
fail_grouped_execution,
)
weather_tool = _make_weather_tool()
function_call = Content.from_function_call(
call_id="call_execution_failure",
name="get_weather",
arguments='{"city": "Seattle"}',
)
approval_request = Content.from_function_approval_request(
id="approval_execution_failure",
function_call=function_call,
)
messages: list[Any] = [
Message(role="assistant", contents=[approval_request]),
Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]),
]
agent = StubAgent(updates=[], default_options={"tools": [weather_tool]})
results = await _resolve_approval_responses(messages, [weather_tool], agent, {})
assert len(results) == 1
assert results[0].type == "function_result"
assert results[0].call_id == "call_execution_failure"
assert results[0].result == "Error: Tool call invocation failed."
assert [
content.result for message in messages for content in message.contents if content.type == "function_result"
] == ["Error: Tool call invocation failed."]
class TestApprovalToolResultDisplayChannel:
"""Approved tools using ``state_update(..., tool_result=...)`` must route the
display payload to the UI event while ``flow.tool_results`` still receives
+21
View File
@@ -145,6 +145,27 @@ agent_framework/
available, approval requests for known non-approval-required tools are treated as already approved, hidden, stored
in session state keyed to the visible approval request ids from that batch, and reinjected only when that visible
approval flow resumes.
- Approval resume is an immutable response boundary: the function invocation layer normalizes a private copy of
caller messages, returns approved and rejected terminal results in the resumed response (and stream) before any
final assistant message, and does not mutate the caller's approval `Message` or the earlier approval-request
response.
- Approval/result correlation is occurrence-aware. A `call_id` may be reused after a completed round, so approval
normalization matches ordered call occurrences and consumes approved results per occurrence rather than using one
global result per `call_id`. All contents produced by one execution remain one result group and are consumed
together, including multiple user-input requests.
- Approval resume keeps terminal `function_result` contents in tool-role messages and follow-up user-input requests
in assistant-role messages, including mixed sibling batches.
- Function-call budget accounting counts one unit per executed result group, not per emitted `function_result`, so
executions that pause for user input still consume `max_function_calls`.
- `function_approval_request` and `function_approval_response` are control-plane contents. History providers may
retain them in their backing store for audit. The base `HistoryProvider.before_run` filters resolved wrappers from
later model replay, but preserves unresolved requests/responses until a terminal result or follow-up request closes
the occurrence. On unrelated turns the function layer omits the complete pending call batch from model input while
retaining it for a later approval response. Providers configured with `load_messages=False` do not replay history,
so this filter is intentionally not invoked.
- Reasoning content or opaque reasoning metadata bound to a function call is part of the same logical group as the
call and terminal result. Function-loop replay and compaction must preserve that group atomically; adapters should
fail before a stateless request when required reasoning cannot be reconstructed.
### Agent Loop (`_harness/_loop.py`)
- **`AgentLoopMiddleware`** - `AgentMiddleware` that re-runs an agent in a loop by calling `call_next()` repeatedly (the pipeline re-reads `context.messages` each time). One configurable class covers two patterns: a required user `should_continue` predicate (sync or async, the first positional/keyword arg), and a chat-client judge built via the `.with_judge(...)` factory (a second chat client decides whether the original request was answered; loops while it is *not*, using a `JudgeVerdict` structured-output response — internally just an async `should_continue` predicate). The constructor covers the predicate pattern directly; only the judge has a convenience classmethod factory (`.with_judge(judge_client, ...)`) that forwards to `__init__`. Supports both streaming and non-streaming runs. By default a non-streaming run returns an aggregated `AgentResponse` containing every iteration's messages plus the injected `next_message` "nudge" messages (as `user` messages); set `return_final_only=True` to return only the last iteration's response. Streaming runs always yield each iteration's updates and emit the injected nudge messages as `user` updates between iterations (the `return_final_only` flag has no effect on streaming, and the final response reflects the last iteration; `MiddlewareTermination` is handled cleanly). `should_continue` is required; other constructor args are optional: `max_iterations` (safety cap; defaults to `DEFAULT_MAX_ITERATIONS`=10, explicit `None`→unbounded, positive int caps; `.with_judge` uses `DEFAULT_JUDGE_MAX_ITERATIONS`=5 as its default), `next_message` (defaults to a short "continue" nudge), `return_final_only`, and `additional_instructions` (an extra `system` message injected ahead of the input before the agent runs — becomes part of the original messages so it survives `fresh_context` resets and persists via a session). The judge is configured only through `.with_judge` (`judge_client`/`instructions`/`criteria`), not the constructor, and its `reasoning` is fed back to the agent as the next iteration's input; the judge forwards the original request messages and the agent's latest response messages verbatim so multi-modal content is preserved. `criteria` (a `list[str]`) is both injected as the agent's `additional_instructions` and rendered into the judge instructions wherever the `{{criteria}}` placeholder (`CRITERIA_PLACEHOLDER`) appears (`DEFAULT_JUDGE_INSTRUCTIONS` ends with it; custom `instructions` may include it, and it is stripped when no criteria are given). The `should_continue`/`next_message` callables are invoked with keyword args (`iteration`, `last_result`, `messages`, `original_messages`, `session`, `agent`, `progress`, `feedback`) and may be sync or async; declare only what you need plus `**kwargs`. `should_continue` may return a plain `bool` or a `(bool, str | None)` tuple whose second item is feedback surfaced to `next_message`/`record_feedback` via the `feedback` kwarg (the judge uses this to relay its `reasoning`). Stop precedence per iteration is `max_iterations``should_continue`, evaluated before `record_feedback` so the feedback is available to it.
@@ -22,6 +22,7 @@ import uuid
import weakref
from abc import abstractmethod
from base64 import urlsafe_b64encode
from collections import deque
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable, Mapping, Sequence
from contextlib import suppress
from pathlib import Path
@@ -34,6 +35,7 @@ from ._types import (
AgentRunInputs,
ChatResponse,
ChatResponseUpdate,
Content,
Message,
ResponseStream,
_build_agent_response_from_chat_response, # pyright: ignore[reportPrivateUsage]
@@ -477,6 +479,76 @@ class ContextProvider:
"""
def _is_approval_placeholder_result(content: Content) -> bool:
result = getattr(content, "result", None)
return isinstance(result, str) and "[APPROVAL_PENDING]" in result
def _approval_controls_to_keep(messages: Sequence[Message]) -> set[int]:
unresolved_requests_by_id: dict[str, Content] = {}
unresolved_local_responses_by_id: dict[str, Content] = {}
local_response_ids_by_call_id: dict[str, deque[str]] = {}
for message in messages:
for content in message.contents:
if content.type == "function_approval_request":
function_call = content.function_call
if content.id is not None and function_call is not None and function_call.call_id is not None:
unresolved_requests_by_id.setdefault(content.id, content)
continue
if content.type == "function_approval_response":
function_call = content.function_call
if content.id is not None:
unresolved_requests_by_id.pop(content.id, None)
if (
content.id is not None
and function_call is not None
and function_call.call_id is not None
and not function_call.additional_properties.get("server_label")
and content.id not in unresolved_local_responses_by_id
):
unresolved_local_responses_by_id[content.id] = content
local_response_ids_by_call_id.setdefault(function_call.call_id, deque()).append(content.id)
continue
if content.call_id is None:
continue
is_terminal_result = content.type == "function_result" and not _is_approval_placeholder_result(content)
is_follow_up_request = content.user_input_request and content.type not in {
"function_approval_request",
"function_approval_response",
}
if not (is_terminal_result or is_follow_up_request):
continue
if response_ids := local_response_ids_by_call_id.get(content.call_id):
unresolved_local_responses_by_id.pop(response_ids.popleft(), None)
return {
id(content) for content in (*unresolved_requests_by_id.values(), *unresolved_local_responses_by_id.values())
}
def _filter_approval_control_messages(messages: Sequence[Message]) -> list[Message]:
"""Remove resolved approval controls while preserving pending occurrences."""
controls_to_keep = _approval_controls_to_keep(messages)
filtered_messages: list[Message] = []
for message in messages:
filtered_contents = [
content
for content in message.contents
if content.type not in {"function_approval_request", "function_approval_response"}
or id(content) in controls_to_keep
]
if not filtered_contents:
continue
if len(filtered_contents) == len(message.contents):
filtered_messages.append(message)
continue
filtered_message = copy.copy(message)
filtered_message.contents = filtered_contents
filtered_messages.append(filtered_message)
return filtered_messages
class HistoryProvider(ContextProvider):
"""Base class for conversation history storage providers.
@@ -579,7 +651,7 @@ class HistoryProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Load history into context. Skipped by the agent when load_messages=False."""
history = await self.get_messages(context.session_id, state=state)
history = _filter_approval_control_messages(await self.get_messages(context.session_id, state=state))
context.extend_messages(self, history)
async def after_run(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,13 @@
from __future__ import annotations
import warnings
from collections.abc import MutableSequence
from pathlib import Path
from typing import Any
import pytest
from agent_framework import (
DEFAULT_TOOL_APPROVAL_SOURCE_ID,
Agent,
@@ -9,6 +16,7 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
FileHistoryProvider,
Message,
ToolApprovalMiddleware,
ToolApprovalState,
@@ -16,6 +24,7 @@ from agent_framework import (
create_always_approve_tool_with_arguments_response,
tool,
)
from agent_framework._feature_stage import ExperimentalWarning
from .conftest import MockBaseChatClient
@@ -31,6 +40,381 @@ def _function_call(request: Content) -> Content:
return request.function_call
@pytest.mark.parametrize("approved", [True, False], ids=["approved", "rejected"])
@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"])
async def test_approval_resume_returns_result_without_mutating_inputs(
chat_client_base: MockBaseChatClient,
approved: bool,
streaming: bool,
) -> None:
"""Approval resume should return its terminal result without changing caller-owned messages."""
calls = 0
@tool(name="guarded_tool", approval_mode="always_require")
def guarded_tool() -> str:
nonlocal calls
calls += 1
return "approved result"
agent = Agent(client=chat_client_base, tools=[guarded_tool])
session = AgentSession(session_id=f"immutable-approval-{streaming}-{approved}")
function_call = Content.from_function_call(call_id="call_guarded", name="guarded_tool", arguments="{}")
if streaming:
chat_client_base.streaming_responses = [[ChatResponseUpdate(role="assistant", contents=[function_call])]]
first_stream = agent.run("run guarded", stream=True, session=session)
first_updates = [update async for update in first_stream]
first_response = await first_stream.get_final_response()
approval_request = next(content for update in first_updates for content in update.user_input_requests)
else:
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=[function_call]))]
first_response = await agent.run("run guarded", session=session)
approval_request = first_response.user_input_requests[0]
approval_response = approval_request.to_function_approval_response(approved=approved)
approval_message = Message(role="user", contents=[approval_response])
if streaming:
chat_client_base.streaming_responses = [
[ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])]
]
second_stream = agent.run(approval_message, stream=True, session=session)
second_updates = [update async for update in second_stream]
second_response = await second_stream.get_final_response()
assert [[content.type for content in update.contents] for update in second_updates] == [
["function_result"],
["text"],
]
else:
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))]
second_response = await agent.run(approval_message, session=session)
assert [[content.type for content in message.contents] for message in second_response.messages] == [
["function_result"],
["text"],
]
result = second_response.messages[0].contents[0]
assert result.call_id == "call_guarded"
assert result.result == ("approved result" if approved else "Error: Tool call invocation was rejected by user.")
assert calls == int(approved)
assert approval_message.role == "user"
assert approval_message.contents == [approval_response]
assert [[content.type for content in message.contents] for message in first_response.messages] == [
["function_call", "function_approval_request"]
]
@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"])
async def test_approval_resume_replays_reasoning_with_function_call_group(
chat_client_base: MockBaseChatClient,
streaming: bool,
) -> None:
"""Reasoning bound to a function call must be replayed with its terminal result."""
@tool(name="reasoning_tool", approval_mode="always_require")
def reasoning_tool() -> str:
return "approved result"
captured_calls: list[list[tuple[str, list[tuple[str, str | None, str | None, str | None]]]]] = []
def capture(messages: MutableSequence[Message]) -> None:
captured_calls.append([
(
message.role,
[(content.type, content.id, content.call_id, content.protected_data) for content in message.contents],
)
for message in messages
])
if streaming:
original_get_streaming_response = chat_client_base._get_streaming_response
def capture_streaming_messages(
*,
messages: MutableSequence[Message],
options: dict[str, Any],
**kwargs: Any,
) -> Any:
capture(messages)
return original_get_streaming_response(messages=messages, options=options, **kwargs)
chat_client_base._get_streaming_response = capture_streaming_messages # type: ignore[method-assign] # ty: ignore[invalid-assignment]
else:
original_get_non_streaming_response = chat_client_base._get_non_streaming_response
async def capture_non_streaming_messages(
*,
messages: MutableSequence[Message],
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
capture(messages)
return await original_get_non_streaming_response(messages=messages, options=options, **kwargs)
chat_client_base._get_non_streaming_response = capture_non_streaming_messages # type: ignore[method-assign] # ty: ignore[invalid-assignment]
agent = Agent(client=chat_client_base, tools=[reasoning_tool])
session = AgentSession(session_id=f"approval-reasoning-{streaming}")
reasoning = Content.from_text_reasoning(
id="reasoning_1",
text="I need to run the tool",
protected_data="encrypted-reasoning",
additional_properties={"status": "completed"},
)
function_call = Content.from_function_call(
call_id="call_reasoning",
name="reasoning_tool",
arguments="{}",
)
if streaming:
chat_client_base.streaming_responses = [
[
ChatResponseUpdate(role="assistant", contents=[reasoning]),
ChatResponseUpdate(role="assistant", contents=[function_call]),
],
[ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])],
]
first_stream = agent.run("run with reasoning", stream=True, session=session)
first_updates = [update async for update in first_stream]
first_response = await first_stream.get_final_response()
approval_request = next(content for update in first_updates for content in update.user_input_requests)
resumed_stream = agent.run(
approval_request.to_function_approval_response(approved=True),
stream=True,
session=session,
)
_ = [update async for update in resumed_stream]
resumed_response = await resumed_stream.get_final_response()
else:
chat_client_base.run_responses = [
ChatResponse(messages=Message(role="assistant", contents=[reasoning, function_call])),
ChatResponse(messages=Message(role="assistant", contents=["done"])),
]
first_response = await agent.run("run with reasoning", session=session)
resumed_response = await agent.run(
first_response.user_input_requests[0].to_function_approval_response(approved=True),
session=session,
)
replayed_contents = [content for _, contents in captured_calls[1] for content in contents]
replayed_types = [content_type for content_type, _, _, _ in replayed_contents]
reasoning_index = replayed_types.index("text_reasoning")
call_index = replayed_types.index("function_call")
result_index = replayed_types.index("function_result")
assert reasoning_index < call_index < result_index
assert replayed_contents[reasoning_index] == (
"text_reasoning",
"reasoning_1",
None,
"encrypted-reasoning",
)
assert "function_approval_request" not in replayed_types
assert "function_approval_response" not in replayed_types
assert any(content.type == "text_reasoning" for content in first_response.messages[0].contents)
assert [[content.type for content in message.contents] for message in resumed_response.messages] == [
["function_result"],
["text"],
]
async def test_approval_resume_filters_resolved_control_items_from_file_history(
chat_client_base: MockBaseChatClient,
tmp_path: Path,
) -> None:
"""Resolved approval wrappers should not be replayed from append-only history."""
calls = 0
@tool(name="guarded_history_tool", approval_mode="always_require")
def guarded_history_tool() -> str:
nonlocal calls
calls += 1
return "approved result"
with warnings.catch_warnings():
warnings.simplefilter("ignore", ExperimentalWarning)
history_provider = FileHistoryProvider(tmp_path)
agent = Agent(
client=chat_client_base,
tools=[guarded_history_tool],
context_providers=[history_provider],
)
session = AgentSession(session_id="approval-file-history")
chat_client_base.run_responses = [
ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_guarded_history",
name="guarded_history_tool",
arguments="{}",
)
],
)
)
]
first_response = await agent.run("run guarded", session=session)
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))]
await agent.run(
first_response.user_input_requests[0].to_function_approval_response(approved=True),
session=session,
)
captured_types: list[list[str]] = []
original_get_response = chat_client_base._get_non_streaming_response
async def capture_messages(
*,
messages: MutableSequence[Message],
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
captured_types.extend([[content.type for content in message.contents] for message in messages])
return await original_get_response(messages=messages, options=options, **kwargs)
chat_client_base._get_non_streaming_response = capture_messages # type: ignore[method-assign] # ty: ignore[invalid-assignment]
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["later"]))]
await agent.run("unrelated later turn", session=session)
flattened_types = [content_type for message_types in captured_types for content_type in message_types]
assert flattened_types.count("function_call") == 1
assert flattened_types.count("function_result") == 1
assert "function_approval_request" not in flattened_types
assert "function_approval_response" not in flattened_types
assert calls == 1
async def test_pending_approval_from_file_history_stays_resumable_without_model_orphan(
chat_client_base: MockBaseChatClient,
tmp_path: Path,
) -> None:
"""An unrelated turn hides a pending batch from the model without discarding its approval."""
calls = 0
@tool(name="guarded_pending_tool", approval_mode="always_require")
def guarded_pending_tool() -> str:
nonlocal calls
calls += 1
return "approved result"
with warnings.catch_warnings():
warnings.simplefilter("ignore", ExperimentalWarning)
history_provider = FileHistoryProvider(tmp_path)
agent = Agent(
client=chat_client_base,
tools=[guarded_pending_tool],
context_providers=[history_provider],
)
session = AgentSession(session_id="pending-approval-file-history")
function_call = Content.from_function_call(
call_id="call_pending_history",
name="guarded_pending_tool",
arguments="{}",
)
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=[function_call]))]
first_response = await agent.run("run guarded", session=session)
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["unrelated answer"]))]
captured_types: list[str] = []
original_get_response = chat_client_base._get_non_streaming_response
async def capture_messages(
*,
messages: MutableSequence[Message],
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
captured_types.extend(content.type for message in messages for content in message.contents)
return await original_get_response(messages=messages, options=options, **kwargs)
chat_client_base._get_non_streaming_response = capture_messages # type: ignore[method-assign] # ty: ignore[invalid-assignment]
unrelated_response = await agent.run("unrelated turn", session=session)
assert unrelated_response.text == "unrelated answer"
assert "function_call" not in captured_types
assert "function_approval_request" not in captured_types
assert calls == 0
chat_client_base.run_responses = [ChatResponse(messages=Message(role="assistant", contents=["done"]))]
resumed_response = await agent.run(
first_response.user_input_requests[0].to_function_approval_response(approved=True),
session=session,
)
assert resumed_response.text == "done"
assert calls == 1
@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"])
async def test_approval_resume_returns_all_user_input_requests_without_another_model_call(
chat_client_base: MockBaseChatClient,
streaming: bool,
) -> None:
"""All user input requested during approved execution should return before another model call."""
from agent_framework.exceptions import UserInputRequiredException
calls = 0
@tool(name="oauth_tool", approval_mode="always_require")
def oauth_tool() -> str:
nonlocal calls
calls += 1
raise UserInputRequiredException(
contents=[
Content.from_oauth_consent_request(consent_link="https://example.com/consent-1"),
Content.from_oauth_consent_request(consent_link="https://example.com/consent-2"),
Content.from_oauth_consent_request(consent_link="https://example.com/consent-3"),
]
)
agent = Agent(client=chat_client_base, tools=[oauth_tool])
session = AgentSession(session_id=f"approval-user-input-{streaming}")
function_call = Content.from_function_call(call_id="call_oauth", name="oauth_tool", arguments="{}")
if streaming:
chat_client_base.streaming_responses = [
[ChatResponseUpdate(role="assistant", contents=[function_call])],
[ChatResponseUpdate(role="assistant", contents=[Content.from_text("unexpected model call")])],
]
first_stream = agent.run("run oauth", stream=True, session=session)
first_updates = [update async for update in first_stream]
approval_request = next(content for update in first_updates for content in update.user_input_requests)
resumed_stream = agent.run(
approval_request.to_function_approval_response(approved=True),
stream=True,
session=session,
)
resumed_updates = [update async for update in resumed_stream]
resumed_response = await resumed_stream.get_final_response()
assert len(chat_client_base.streaming_responses) == 1
assert [content.consent_link for update in resumed_updates for content in update.user_input_requests] == [
"https://example.com/consent-1",
"https://example.com/consent-2",
"https://example.com/consent-3",
]
else:
chat_client_base.run_responses = [
ChatResponse(messages=Message(role="assistant", contents=[function_call])),
ChatResponse(messages=Message(role="assistant", contents=["unexpected model call"])),
]
first_response = await agent.run("run oauth", session=session)
resumed_response = await agent.run(
first_response.user_input_requests[0].to_function_approval_response(approved=True),
session=session,
)
assert len(chat_client_base.run_responses) == 1
assert [content.consent_link for content in resumed_response.user_input_requests] == [
"https://example.com/consent-1",
"https://example.com/consent-2",
"https://example.com/consent-3",
]
assert resumed_response.messages[0].role == "assistant"
assert calls == 1
async def test_mixed_batch_hides_already_approved_request_until_approval_replay(
chat_client_base: MockBaseChatClient,
) -> None:
@@ -482,6 +866,54 @@ async def test_tool_approval_middleware_auto_approved_loops_share_function_call_
assert calls == 1
@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"])
async def test_auto_approval_resolves_after_iteration_budget_is_exhausted(
chat_client_base: MockBaseChatClient,
streaming: bool,
) -> None:
"""Approval resolution must run before the model-iteration budget is checked."""
calls = 0
@tool(name="last_iteration_tool", approval_mode="always_require")
def last_iteration_tool() -> str:
nonlocal calls
calls += 1
return "executed"
chat_client_base.function_invocation_configuration["max_iterations"] = 1
agent = Agent(
client=chat_client_base,
tools=[last_iteration_tool],
middleware=[ToolApprovalMiddleware(auto_approval_rules=[lambda function_call: True])],
)
session = AgentSession(session_id=f"approval-iteration-budget-{streaming}")
function_call = Content.from_function_call(
call_id="call_last_iteration",
name="last_iteration_tool",
arguments="{}",
)
if streaming:
chat_client_base.streaming_responses = [
[ChatResponseUpdate(role="assistant", contents=[function_call])],
[ChatResponseUpdate(role="assistant", contents=[Content.from_text("unused")])],
]
response_stream = agent.run("run once", stream=True, session=session)
updates = [update async for update in response_stream]
response = await response_stream.get_final_response()
assert any(content.type == "function_result" for update in updates for content in update.contents)
else:
chat_client_base.run_responses = [
ChatResponse(messages=Message(role="assistant", contents=[function_call])),
ChatResponse(messages=Message(role="assistant", contents=["unused"])),
]
response = await agent.run("run once", session=session)
assert calls == 1
assert any(content.type == "function_result" for message in response.messages for content in message.contents)
assert response.text == "I broke out of the function invocation loop..."
async def test_tool_approval_middleware_queues_streamed_approval_requests(
chat_client_base: MockBaseChatClient,
) -> None:
@@ -14,6 +14,7 @@ from agent_framework import (
AgentContext,
AgentSession,
ChatContext,
Content,
ContextProvider,
ExperimentalFeature,
FileHistoryProvider,
@@ -24,7 +25,11 @@ from agent_framework import (
agent_middleware,
chat_middleware,
)
from agent_framework._sessions import LOCAL_HISTORY_CONVERSATION_ID, is_local_history_conversation_id
from agent_framework._sessions import (
LOCAL_HISTORY_CONVERSATION_ID,
_filter_approval_control_messages,
is_local_history_conversation_id,
)
from agent_framework.exceptions import MiddlewareException
# ---------------------------------------------------------------------------
@@ -348,6 +353,83 @@ class ConcreteHistoryProvider(HistoryProvider):
self.stored.extend(messages)
def test_filter_approval_controls_preserves_only_unresolved_occurrences() -> None:
local_call = Content.from_function_call(call_id="local", name="guarded", arguments="{}")
local_request = Content.from_function_approval_request(id="local_approval", function_call=local_call)
hosted_call = Content.from_function_call(
call_id="hosted",
name="hosted_tool",
arguments="{}",
additional_properties={"server_label": "server"},
)
hosted_request = Content.from_function_approval_request(id="hosted_approval", function_call=hosted_call)
resolved_call = Content.from_function_call(call_id="resolved", name="guarded", arguments="{}")
resolved_request = Content.from_function_approval_request(id="resolved_approval", function_call=resolved_call)
resolved_response = resolved_request.to_function_approval_response(approved=True)
filtered = _filter_approval_control_messages([
Message(role="assistant", contents=[local_call, local_request]),
Message(role="assistant", contents=[hosted_request]),
Message(role="assistant", contents=[resolved_call, resolved_request]),
Message(role="user", contents=[resolved_response]),
Message(role="tool", contents=[Content.from_function_result(call_id="resolved", result="done")]),
])
controls = [
content
for message in filtered
for content in message.contents
if content.type in {"function_approval_request", "function_approval_response"}
]
assert controls == [local_request, hosted_request]
assert any(
content.type == "function_result" and content.call_id == "resolved"
for message in filtered
for content in message.contents
)
def test_filter_approval_controls_deduplicates_pending_request_replay() -> None:
function_call = Content.from_function_call(call_id="call_1", name="guarded", arguments="{}")
request = Content.from_function_approval_request(id="approval_1", function_call=function_call)
replayed_request = Content.from_dict(request.to_dict())
filtered = _filter_approval_control_messages([
Message(role="assistant", contents=[function_call, request]),
Message(role="assistant", contents=[replayed_request]),
])
requests = [
content for message in filtered for content in message.contents if content.type == "function_approval_request"
]
assert requests == [request]
def test_filter_approval_controls_keeps_response_for_pending_placeholder() -> None:
function_call = Content.from_function_call(call_id="call_pending", name="guarded", arguments="{}")
request = Content.from_function_approval_request(id="approval_pending", function_call=function_call)
response = request.to_function_approval_response(approved=True)
placeholder = Content.from_function_result(
call_id="call_pending",
result="[APPROVAL_PENDING] waiting for execution",
)
filtered = _filter_approval_control_messages([
Message(role="assistant", contents=[function_call, request]),
Message(role="user", contents=[response]),
Message(role="tool", contents=[placeholder]),
])
controls = [
content
for message in filtered
for content in message.contents
if content.type in {"function_approval_request", "function_approval_response"}
]
assert controls == [response]
assert any(placeholder in message.contents for message in filtered)
class TestHistoryProviderBase:
def test_default_flags(self) -> None:
provider = ConcreteHistoryProvider("mem")
@@ -33,6 +33,7 @@ from agent_framework._sessions import (
AgentSession,
InMemoryHistoryProvider,
SessionContext,
_filter_approval_control_messages,
)
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from agent_framework.exceptions import (
@@ -7818,6 +7819,38 @@ def test_prepare_messages_strips_approval_items_under_storage() -> None:
assert "mcp_approval_response" in storage_off_types
def test_stateless_history_preserves_pending_hosted_approval_request_until_response() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
function_call = Content.from_function_call(
call_id="mcp_pending",
name="sensitive_action",
arguments='{"action": "delete"}',
additional_properties={"server_label": "hosted_server"},
)
approval_request = Content.from_function_approval_request(
id="approval_pending",
function_call=function_call,
)
approval_response = approval_request.to_function_approval_response(approved=True)
pending_history = _filter_approval_control_messages([Message(role="assistant", contents=[approval_request])])
pending_items = client._prepare_messages_for_openai(
pending_history,
request_uses_service_side_storage=False,
)
assert [item.get("type") for item in pending_items] == ["mcp_approval_request"]
resolved_history = _filter_approval_control_messages([
Message(role="assistant", contents=[approval_request]),
Message(role="user", contents=[approval_response]),
])
resolved_items = client._prepare_messages_for_openai(
resolved_history,
request_uses_service_side_storage=False,
)
assert resolved_items == []
def test_prepare_messages_strips_local_shell_call_under_storage() -> None:
"""Local-shell-call function_results carry a server-issued local_shell_call_item_id and must
be stripped under storage. Plain function_results (no shell ID) are kept either way (#3295)."""
@@ -1090,6 +1090,31 @@ def test_function_approval_content_is_skipped_in_preparation(
assert prepared_mixed[0]["content"] == "I need approval for this action."
def test_mixed_approval_resume_roles_serialize_function_result_as_tool(
openai_unit_test_env: dict[str, str],
) -> None:
client = OpenAIChatCompletionClient()
follow_up_request = Content.from_oauth_consent_request(consent_link="https://example.com/consent")
follow_up_request.call_id = "call_paused"
messages = [
Message(
role="tool",
contents=[Content.from_function_result(call_id="call_completed", result="completed")],
),
Message(role="assistant", contents=[follow_up_request]),
]
prepared = client._prepare_messages_for_openai(messages)
assert prepared[0] == {
"role": "tool",
"tool_call_id": "call_completed",
"content": "completed",
}
assert prepared[1]["role"] == "assistant"
assert "tool_call_id" not in prepared[1]
def test_usage_content_in_streaming_response(
openai_unit_test_env: dict[str, str],
) -> None: