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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/specs/004-python-function-calling-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,9 @@ that manually replay messages own the equivalent rule: do not resend an approval
request, never from the response payload.
- A matched approval response consumes its pending entry once. Unmatched, duplicate, and replayed responses do not
reach local execution.
- If policy middleware detects that the exact resolved invocation changed after approval, the old response executes
nothing and yields a caller-visible, session-persisted replacement request for the same occurrence; execution
requires a second approval and happens exactly once.
- Unmatched occurrence-aware responses leave the pending request intact for a corrected retry and produce an
observable warning/log. A nested `call_id` is never accepted as an occurrence-identity alias.
- Session-backed pending snapshots are trusted host state and require tenant-scoped, authorized storage. Consume-on-bind
Expand Down Expand Up @@ -500,10 +503,11 @@ that manually replay messages own the equivalent rule: do not resend an 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 bound to the recorded provider request and are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_session_approval_binding_reconstructs_hosted_response`, `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` |
| 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`, `packages/core/tests/core/test_harness_tool_approval.py::test_dynamic_policy_approval_partitions_safe_sibling_result_roles` |
| 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` |
| Changed resolved policy invocation | The stale decision executes nothing; a same-occurrence replacement request is visible and persisted in both modes, and the second approval executes exactly once. | `packages/core/tests/core/test_harness_tool_approval.py::test_changed_hidden_snapshot_requires_visible_second_approval` |
| Session-bound substituted response | A response is rebound to the immutable recorded call and cannot replace its call id, tool name, or arguments. | `packages/core/tests/core/test_function_invocation_logic.py::test_session_approval_binding_rebinds_consumes_and_rejects_duplicates` |
| Occurrence-aware local binding | New local requests use `function_call.id`; missing, mismatched, or stale occurrence ids do not execute or consume pending state, while the canonical occurrence id binds without an embedded call. | `test_occurrence_aware_approval_rejects_stale_reused_call_id_response`, `test_occurrence_aware_approval_mismatched_identity_does_not_consume_pending`, `test_occurrence_aware_approval_binds_without_embedded_function_call` |
| Legacy stored approval | A serialized pending request without `function_call.id` retains exact request-id binding once and warns only when resumed. | `test_legacy_serialized_pending_approval_resumes_once_with_migration_warning`, `packages/core/tests/core/test_types.py::test_legacy_function_call_deserialization_does_not_generate_an_occurrence_id` |
Expand Down
43 changes: 37 additions & 6 deletions python/packages/core/agent_framework/_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,20 +836,34 @@ def _is_approval_placeholder_result(content: Content) -> bool:

def _approval_controls_to_keep(messages: Sequence[Message]) -> set[int]:
unresolved_requests_by_id: dict[str, Content] = {}
local_request_ids_by_call_id: dict[str, deque[str]] = {}
local_request_ids_by_occurrence: dict[str, str] = {}
unresolved_local_responses_by_id: dict[str, Content] = {}
local_response_ids_by_call_id: dict[str, deque[str]] = {}
local_responses_by_call_id: dict[str, deque[tuple[str, str | None]]] = {}

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)
if content.id not in unresolved_requests_by_id:
unresolved_requests_by_id[content.id] = content
local_request_ids_by_call_id.setdefault(function_call.call_id, deque()).append(content.id)
if function_call.id is not None:
local_request_ids_by_occurrence[function_call.id] = content.id
# A replacement request supersedes the decision that triggered
# reapproval; that old decision is no longer executable authority.
unresolved_local_responses_by_id.pop(content.id, None)
if (
content.additional_properties.get("_replacement_approval_request") is True
and function_call.id is not None
):
unresolved_local_responses_by_id.pop(function_call.id, None)
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)
unresolved_requests_by_id.pop(local_request_ids_by_occurrence.get(content.id, content.id), None)
if (
content.id is not None
and function_call is not None
Expand All @@ -858,7 +872,11 @@ def _approval_controls_to_keep(messages: Sequence[Message]) -> set[int]:
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)
request_id = local_request_ids_by_occurrence.get(content.id)
local_responses_by_call_id.setdefault(function_call.call_id, deque()).append((
content.id,
request_id,
))
continue
if content.call_id is None:
continue
Expand All @@ -869,8 +887,21 @@ def _approval_controls_to_keep(messages: Sequence[Message]) -> set[int]:
}
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)
resolved_response = False
if responses := local_responses_by_call_id.get(content.call_id):
while responses and responses[0][0] not in unresolved_local_responses_by_id:
responses.popleft()
if responses:
response_id, request_id = responses.popleft()
unresolved_local_responses_by_id.pop(response_id, None)
if request_id is not None:
unresolved_requests_by_id.pop(request_id, None)
resolved_response = True
if not resolved_response and (request_ids := local_request_ids_by_call_id.get(content.call_id)):
while request_ids and request_ids[0] not in unresolved_requests_by_id:
request_ids.popleft()
if request_ids:
unresolved_requests_by_id.pop(request_ids.popleft(), None)

return {
id(content) for content in (*unresolved_requests_by_id.values(), *unresolved_local_responses_by_id.values())
Expand Down
Loading
Loading