From 8cb5920f98cce82291ca33b1ca0742f572a722de Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 10:54:05 +0200 Subject: [PATCH 1/8] Python: bound stateless pause batches to user turns --- .../specs/004-python-function-calling-loop.md | 11 ++-- .../packages/core/agent_framework/_tools.py | 44 ++++++++----- .../core/test_function_invocation_logic.py | 62 ++++++++++++++++++- 3 files changed, 94 insertions(+), 23 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 4603417996..5c69cef180 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -191,10 +191,11 @@ Before acting on a model function-call batch, the loop classifies every actionab call aborts the complete batch before approval state changes or execution. Otherwise approval-required and Host-owned calls are returned together in model order, while session-backed executable siblings remain deferred. An incomplete session-backed mixed approval/Host response remains pending without executing a deferred call; a stateless incomplete -response is rejected, including when no response is supplied, message roles vary within the model output, or a newer -standalone request follows the incomplete batch. Stateless discovery selects the latest unresolved mixed batch, so a -completed batch remains inert on later turns. Correlation is scoped to the active mixed batch so completed or abandoned -historical Host calls remain unchanged. +response is rejected, including when no response is supplied, non-user message roles vary within the model output, or +a newer standalone request follows the incomplete batch. Stateless discovery selects the latest unresolved mixed batch +across non-user output messages. An unrelated user turn delimits standalone batches, so pauses from separate turns are +not synthesized into one mixed batch; a completed batch remains inert on later turns. Correlation is scoped to the +active mixed batch so completed or abandoned historical Host calls remain unchanged. `ToolApprovalMiddleware` may resolve approval requests through standing or automatic policies, but it preserves non-approval user-input requests and does not split or reorder manual approvals relative to their Host-owned siblings. @@ -615,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across message roles and cannot be hidden by a newer standalone request; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_message_roles_requires_complete_responses`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a292383ac7..d2f16b4b47 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3142,6 +3142,7 @@ def _stateless_mixed_pause_batch_status( batch_items: list[dict[str, Any]] = [] batch_kinds: set[str] = set() last_request_index = -1 + content_index = -1 def answers_current_batch(content: Content) -> bool: if content.type not in {"function_approval_response", "function_result"}: @@ -3172,22 +3173,35 @@ def answers_current_batch(content: Content) -> bool: return True return False - for content_index, content in enumerate(flattened_contents): - if batch_items and answers_current_batch(content): - request_batches.append((last_request_index, batch_items, batch_kinds)) - batch_items = [] - batch_kinds = set() - if content.type == "function_approval_request": - kind = "approval" - elif content.type == "function_call" and content.user_input_request: - kind = "host" - else: - continue - last_request_index = content_index - batch_kinds.add(kind) - batch_items.append({"kind": kind, "request": content.to_dict()}) - if batch_items: + def finish_current_batch() -> None: + nonlocal batch_items, batch_kinds + if not batch_items: + return request_batches.append((last_request_index, batch_items, batch_kinds)) + batch_items = [] + batch_kinds = set() + + for message in messages: + if ( + message.role == "user" + and batch_items + and not any(answers_current_batch(content) for content in message.contents) + ): + finish_current_batch() + for content in message.contents: + content_index += 1 + if batch_items and answers_current_batch(content): + finish_current_batch() + if content.type == "function_approval_request": + kind = "approval" + elif content.type == "function_call" and content.user_input_request: + kind = "host" + else: + continue + last_request_index = content_index + batch_kinds.add(kind) + batch_items.append({"kind": kind, "request": content.to_dict()}) + finish_current_batch() for batch_end, items, kinds in reversed(request_batches): if kinds != {"approval", "host"}: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 9dea58bf4c..d98106b429 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3521,9 +3521,11 @@ def approval_func() -> str: assert calls == 0 -@pytest.mark.parametrize("metadata_role", ["tool", "user"]) -def test_stateless_mixed_batch_across_message_roles_requires_complete_responses(metadata_role: str) -> None: - """Intervening message roles do not split a mixed batch or change response order.""" +@pytest.mark.parametrize("metadata_role", ["assistant", "tool"]) +def test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses( + metadata_role: str, +) -> None: + """Intervening non-user message roles do not split a mixed batch or change response order.""" from agent_framework._tools import _stateless_mixed_pause_batch_status approval_call = Content.from_function_call( @@ -3574,6 +3576,60 @@ def test_stateless_mixed_batch_across_message_roles_requires_complete_responses( assert host_result_ids == {id(complete_messages[-1].contents[1])} +async def test_stateless_abandoned_approval_does_not_join_later_host_request( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Standalone pauses separated by a user turn do not form a synthetic mixed batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="host", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="host", result="host result") + host_result.id = "host-occurrence" + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[host_result]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: From 57b204e22e48e3a35a9152bd055eb964cef53920 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 11:07:08 +0200 Subject: [PATCH 2/8] Python: preserve stateless pause response ownership --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 32 +++-- .../core/test_function_invocation_logic.py | 118 ++++++++++++++++++ 3 files changed, 139 insertions(+), 13 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 5c69cef180..d3f1d44965 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index d2f16b4b47..665cf1032f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3134,7 +3134,7 @@ def bind_approval_response(response: Content) -> Content | None: def _stateless_mixed_pause_batch_status( messages: list[Message], ) -> tuple[bool, set[int]]: - """Validate the latest unresolved stateless mixed batch and order its responses.""" + """Validate the latest unresolved stateless mixed batch and identify Host results.""" from ._types import Message flattened_contents = [content for message in messages for content in message.contents] @@ -3203,35 +3203,43 @@ def finish_current_batch() -> None: batch_items.append({"kind": kind, "request": content.to_dict()}) finish_current_batch() + claimed_response_ids: set[int] = set() + matched_host_result_ids: set[int] = set() for batch_end, items, kinds in reversed(request_batches): - if kinds != {"approval", "host"}: - continue responses = [ content for content in flattened_contents[batch_end + 1 :] if content.type in {"function_approval_response", "function_result"} + and id(content) not in claimed_response_ids ] - matched_response_ids, incomplete, ordered_responses, host_result_ids = _match_mixed_pause_responses( + matched_response_ids, incomplete, ordered_responses, ordered_host_result_ids = _match_mixed_pause_responses( items, responses, ) + claimed_response_ids.update(matched_response_ids) + original_host_result_ids = { + id(content) + for content in responses + if content.type == "function_result" and id(content) in matched_response_ids + } + matched_host_result_ids.update(original_host_result_ids) + if kinds != {"approval", "host"}: + continue if incomplete: - return True, host_result_ids + return True, matched_host_result_ids pending_approval_response_ids = { id(response) for response in _collect_approval_responses( messages, - non_approval_result_ids={ - id(content) - for content in responses - if content.type == "function_result" and id(content) in matched_response_ids - }, + non_approval_result_ids=matched_host_result_ids, ).values() } if matched_response_ids.isdisjoint(pending_approval_response_ids): continue + matched_host_result_ids.difference_update(original_host_result_ids) + matched_host_result_ids.update(ordered_host_result_ids) filtered_messages: list[Message] = [] for message in messages: message.contents = [content for content in message.contents if id(content) not in matched_response_ids] @@ -3239,8 +3247,8 @@ def finish_current_batch() -> None: filtered_messages.append(message) filtered_messages.append(Message(role="user", contents=ordered_responses)) messages[:] = filtered_messages - return False, host_result_ids - return False, set() + return False, matched_host_result_ids + return False, matched_host_result_ids def _collect_approval_responses( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index d98106b429..420d172761 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3630,6 +3630,64 @@ def approval_func() -> str: assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +@pytest.mark.parametrize("approval_response_first", [True, False], ids=["approval-first", "host-first"]) +async def test_stateless_separated_pauses_with_reused_call_id_are_order_independent( + chat_client_base: SupportsChatGetResponse, + approval_response_first: bool, +) -> None: + """Responses for standalone pauses with a reused call ID remain occurrence-scoped.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + responses = [approval_response, host_result] if approval_response_first else [host_result, approval_response] + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=responses), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -3692,6 +3750,66 @@ def approval_func() -> str: assert calls == 0 +async def test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A call-ID-only Host result belongs to the nearest compatible request batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + older_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="older-host-occurrence", + ) + older_host_request.user_input_request = True + later_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="later-host-occurrence", + ) + later_host_request.user_input_request = True + later_host_result = Content.from_function_result(call_id="shared", result="later host result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + messages = [ + Message(role="assistant", contents=[approval_request, older_host_request]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=[later_host_request]), + Message(role="user", contents=[later_host_result]), + ] + + with pytest.raises( + RuntimeError, + match="A mixed function-call batch requires responses for every approval and Host-owned request", + ): + await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert calls == 0 + assert chat_client_base.call_count == 0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_completed_split_stateless_mixed_batch_is_inert_on_later_turn( chat_client_base: SupportsChatGetResponse, ) -> None: From e39501cffb706893963e86b5c9a5f1c192c39805 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 12:14:19 +0200 Subject: [PATCH 3/8] Python: linearize stateless pause response ownership --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 115 ++++++++-- .../core/test_function_invocation_logic.py | 213 ++++++++++++++++++ 3 files changed, 307 insertions(+), 23 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index d3f1d44965..5c8043ef3e 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, and response ownership is discovered in linear time; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 665cf1032f..3c6151c19f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3203,41 +3203,112 @@ def finish_current_batch() -> None: batch_items.append({"kind": kind, "request": content.to_dict()}) finish_current_batch() - claimed_response_ids: set[int] = set() + responses_by_batch: list[list[Content]] = [[] for _ in request_batches] + latest_approval_by_identity: dict[str, int] = {} + latest_approval_by_call: dict[str, int] = {} + latest_host_by_occurrence: dict[tuple[str, str], int] = {} + latest_host_by_call: dict[str, int] = {} + latest_idless_host_by_call: dict[str, int] = {} matched_host_result_ids: set[int] = set() - for batch_end, items, kinds in reversed(request_batches): - responses = [ - content - for content in flattened_contents[batch_end + 1 :] - if content.type in {"function_approval_response", "function_result"} - and id(content) not in claimed_response_ids - ] + next_batch_index = 0 + + def register_request_batch(batch_index: int) -> None: + for item in request_batches[batch_index][1]: + request = _content_from_state(item.get("request")) + if request is None: + continue + if item.get("kind") == "approval": + function_call = request.function_call + for identity in ( + request.id, + function_call.id if function_call is not None else None, + ): + if identity is not None: + latest_approval_by_identity[identity] = batch_index + if function_call is not None and function_call.call_id is not None: + latest_approval_by_call[function_call.call_id] = batch_index + elif item.get("kind") == "host" and request.call_id is not None: + latest_host_by_call[request.call_id] = batch_index + if request.id is None: + latest_idless_host_by_call[request.call_id] = batch_index + else: + latest_host_by_occurrence[request.call_id, request.id] = batch_index + + for response_index, content in enumerate(flattened_contents): + if content.type not in {"function_approval_response", "function_result"}: + continue + while next_batch_index < len(request_batches) and request_batches[next_batch_index][0] < response_index: + register_request_batch(next_batch_index) + next_batch_index += 1 + + owner_candidates: list[tuple[int, str]] = [] + if content.type == "function_approval_response": + identities = { + str(identity) + for identity in ( + content.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), + content.id, + ) + if identity is not None + } + owner_candidates.extend( + (batch_index, "approval") + for identity in identities + if (batch_index := latest_approval_by_identity.get(identity)) is not None + ) + elif content.call_id is not None: + if (approval_batch_index := latest_approval_by_call.get(content.call_id)) is not None: + owner_candidates.append((approval_batch_index, "approval")) + if content.id is None: + host_batch_index = latest_host_by_call.get(content.call_id) + else: + host_batch_index = max( + ( + batch_index + for batch_index in ( + latest_host_by_occurrence.get((content.call_id, content.id)), + latest_idless_host_by_call.get(content.call_id), + ) + if batch_index is not None + ), + default=None, + ) + if host_batch_index is not None: + owner_candidates.append((host_batch_index, "host")) + + if not owner_candidates: + continue + owner_batch_index, owner_kind = max( + owner_candidates, + key=lambda candidate: (candidate[0], candidate[1] == "host"), + ) + responses_by_batch[owner_batch_index].append(content) + if owner_kind == "host": + matched_host_result_ids.add(id(content)) + + pending_approval_response_ids = { + id(response) + for response in _collect_approval_responses( + messages, + non_approval_result_ids=matched_host_result_ids, + ).values() + } + for batch_index in range(len(request_batches) - 1, -1, -1): + _, items, kinds = request_batches[batch_index] + responses = responses_by_batch[batch_index] matched_response_ids, incomplete, ordered_responses, ordered_host_result_ids = _match_mixed_pause_responses( items, responses, ) - claimed_response_ids.update(matched_response_ids) - original_host_result_ids = { - id(content) - for content in responses - if content.type == "function_result" and id(content) in matched_response_ids - } - matched_host_result_ids.update(original_host_result_ids) if kinds != {"approval", "host"}: continue if incomplete: return True, matched_host_result_ids - pending_approval_response_ids = { - id(response) - for response in _collect_approval_responses( - messages, - non_approval_result_ids=matched_host_result_ids, - ).values() - } if matched_response_ids.isdisjoint(pending_approval_response_ids): continue + original_host_result_ids = matched_response_ids & matched_host_result_ids matched_host_result_ids.difference_update(original_host_result_ids) matched_host_result_ids.update(ordered_host_result_ids) filtered_messages: list[Message] = [] diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 420d172761..7806cb79a9 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3810,6 +3810,219 @@ def approval_func() -> str: assert chat_client_base.call_count == 0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +async def test_completed_approval_result_is_not_claimed_by_older_stateless_host_request( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A completed approval result remains owned by the nearer approval batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + older_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="older-host-occurrence", + ) + older_host_request.user_input_request = True + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + completed_approval_result = Content.from_function_result(call_id="shared", result="approved") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["later response"])), + ] + messages = [ + Message(role="assistant", contents=[older_host_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="tool", contents=[completed_approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "later response" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +async def test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An ambiguous result remains reserved to the nearer Host batch.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + older_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="older-host-occurrence", + ) + older_host_request.user_input_request = True + later_host_requests = [ + Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id=f"later-host-occurrence-{index}", + ) + for index in range(2) + ] + for request in later_host_requests: + request.user_input_request = True + ambiguous_result = Content.from_function_result(call_id="shared", result="later host result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + messages = [ + Message(role="assistant", contents=[approval_request, older_host_request]), + Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=later_host_requests), + Message(role="user", contents=[ambiguous_result]), + ] + + with pytest.raises( + RuntimeError, + match="A mixed function-call batch requires responses for every approval and Host-owned request", + ): + await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert calls == 0 + assert chat_client_base.call_count == 0 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +async def test_id_bearing_result_for_idless_host_request_does_not_consume_approval( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A result compatible with an ID-less Host request remains Host-owned.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + ) + host_request.user_input_request = True + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-result-occurrence" + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[approval_response, host_result]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +def test_stateless_pause_response_ownership_scans_contents_linearly() -> None: + """Stateless response ownership reads transcript content a bounded number of times.""" + from agent_framework._tools import _stateless_mixed_pause_batch_status + + class CountingContent(Content): + type_reads = 0 + + def __getattribute__(self, name: str) -> Any: + if name == "type": + CountingContent.type_reads += 1 + return super().__getattribute__(name) + + batch_count = 200 + messages: list[Message] = [] + expected_result_ids: set[int] = set() + for index in range(batch_count): + request = CountingContent.from_function_call( + call_id=f"host-{index}", + name="host_func", + arguments={}, + id=f"host-occurrence-{index}", + ) + request.user_input_request = True + result = CountingContent.from_function_result(call_id=f"host-{index}", result="host result") + result.id = f"host-occurrence-{index}" + messages.extend([ + Message(role="assistant", contents=[request]), + Message(role="user", contents=[result]), + ]) + expected_result_ids.add(id(result)) + + CountingContent.type_reads = 0 + incomplete, host_result_ids = _stateless_mixed_pause_batch_status(messages) + + assert incomplete is False + assert host_result_ids == expected_result_ids + assert CountingContent.type_reads < batch_count * 50 + + async def test_completed_split_stateless_mixed_batch_is_inert_on_later_turn( chat_client_base: SupportsChatGetResponse, ) -> None: From 54f2048c60becfd37b1033b44914b54bcb4f9273 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 13:25:28 +0200 Subject: [PATCH 4/8] Python: scope stateless Host result exclusions --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 25 +++++-- .../core/test_function_invocation_logic.py | 68 ++++++++++++++++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 5c8043ef3e..828cc9b58d 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, and response ownership is discovered in linear time; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions do not keep historical completed calls open during later approval normalization; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 3c6151c19f..75dc08fe36 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3210,6 +3210,7 @@ def finish_current_batch() -> None: latest_host_by_call: dict[str, int] = {} latest_idless_host_by_call: dict[str, int] = {} matched_host_result_ids: set[int] = set() + host_result_batch_indices: dict[int, int] = {} next_batch_index = 0 def register_request_batch(batch_index: int) -> None: @@ -3285,6 +3286,7 @@ def register_request_batch(batch_index: int) -> None: responses_by_batch[owner_batch_index].append(content) if owner_kind == "host": matched_host_result_ids.add(id(content)) + host_result_batch_indices[id(content)] = owner_batch_index pending_approval_response_ids = { id(response) @@ -3293,6 +3295,17 @@ def register_request_batch(batch_index: int) -> None: non_approval_result_ids=matched_host_result_ids, ).values() } + pending_approval_batch_indices = { + batch_index + for batch_index, responses in enumerate(responses_by_batch) + if any(id(response) in pending_approval_response_ids for response in responses) + } + first_pending_approval_batch = min(pending_approval_batch_indices, default=None) + active_host_result_ids = { + result_id + for result_id, batch_index in host_result_batch_indices.items() + if first_pending_approval_batch is not None and batch_index >= first_pending_approval_batch + } for batch_index in range(len(request_batches) - 1, -1, -1): _, items, kinds = request_batches[batch_index] responses = responses_by_batch[batch_index] @@ -3303,14 +3316,14 @@ def register_request_batch(batch_index: int) -> None: if kinds != {"approval", "host"}: continue if incomplete: - return True, matched_host_result_ids + return True, active_host_result_ids if matched_response_ids.isdisjoint(pending_approval_response_ids): continue - original_host_result_ids = matched_response_ids & matched_host_result_ids - matched_host_result_ids.difference_update(original_host_result_ids) - matched_host_result_ids.update(ordered_host_result_ids) + original_host_result_ids = matched_response_ids & active_host_result_ids + active_host_result_ids.difference_update(original_host_result_ids) + active_host_result_ids.update(ordered_host_result_ids) filtered_messages: list[Message] = [] for message in messages: message.contents = [content for content in message.contents if id(content) not in matched_response_ids] @@ -3318,8 +3331,8 @@ def register_request_batch(batch_index: int) -> None: filtered_messages.append(message) filtered_messages.append(Message(role="user", contents=ordered_responses)) messages[:] = filtered_messages - return False, matched_host_result_ids - return False, matched_host_result_ids + return False, active_host_result_ids + return False, active_host_result_ids def _collect_approval_responses( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 7806cb79a9..df95deaac0 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3866,6 +3866,70 @@ def approval_func() -> str: assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +def test_historical_stateless_host_result_does_not_capture_later_reused_call_approval() -> None: + """Historical Host exclusions do not keep completed calls open during approval normalization.""" + from agent_framework._tools import ( + _collect_approval_responses, + _replace_approval_contents_with_results, + _stateless_mixed_pause_batch_status, + ) + + old_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="old-host-occurrence", + ) + old_host_request.user_input_request = True + old_host_result = Content.from_function_result(call_id="shared", result="old host result") + old_host_result.id = "old-host-occurrence" + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + approval_response = approval_request.to_function_approval_response(approved=True) + messages = [ + Message(role="assistant", contents=[old_host_request]), + Message(role="user", contents=[old_host_result]), + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=[approval_response]), + ] + + incomplete, active_host_result_ids = _stateless_mixed_pause_batch_status(messages) + pending_responses = _collect_approval_responses( + messages, + non_approval_result_ids=active_host_result_ids, + ) + approval_result = Content.from_function_result(call_id="shared", result="approved result") + _replace_approval_contents_with_results( + messages, + pending_responses, + [[approval_result]], + non_approval_result_ids=active_host_result_ids, + ) + + assert incomplete is False + assert active_host_result_ids == set() + assert list(pending_responses) == ["approval-occurrence"] + assert [ + (content.type, content.name, content.id, content.result) + for message in messages + for content in message.contents + if content.type in {"function_call", "function_result"} + ] == [ + ("function_call", "host_func", "old-host-occurrence", None), + ("function_result", None, "old-host-occurrence", "old host result"), + ("function_call", "approval_func", "approval-occurrence", None), + ("function_result", None, None, "approved result"), + ] + + async def test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -3998,7 +4062,6 @@ def __getattribute__(self, name: str) -> Any: batch_count = 200 messages: list[Message] = [] - expected_result_ids: set[int] = set() for index in range(batch_count): request = CountingContent.from_function_call( call_id=f"host-{index}", @@ -4013,13 +4076,12 @@ def __getattribute__(self, name: str) -> Any: Message(role="assistant", contents=[request]), Message(role="user", contents=[result]), ]) - expected_result_ids.add(id(result)) CountingContent.type_reads = 0 incomplete, host_result_ids = _stateless_mixed_pause_batch_status(messages) assert incomplete is False - assert host_result_ids == expected_result_ids + assert host_result_ids == set() assert CountingContent.type_reads < batch_count * 50 From cd4d6e1351f36a279663cfbf5fe9bbfddde99ad4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 13:56:19 +0200 Subject: [PATCH 5/8] Python: disambiguate completed mixed results --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 24 ++++++- .../core/test_function_invocation_logic.py | 62 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 828cc9b58d..9a47093813 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions do not keep historical completed calls open during later approval normalization; completed mixed batches remain inert; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions do not keep historical completed calls open or reinterpret later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 75dc08fe36..17f48cbb7f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3209,6 +3209,8 @@ def finish_current_batch() -> None: latest_host_by_occurrence: dict[tuple[str, str], int] = {} latest_host_by_call: dict[str, int] = {} latest_idless_host_by_call: dict[str, int] = {} + host_request_counts: dict[tuple[int, str], int] = {} + owned_host_result_counts: dict[tuple[int, str], int] = {} matched_host_result_ids: set[int] = set() host_result_batch_indices: dict[int, int] = {} next_batch_index = 0 @@ -3229,6 +3231,8 @@ def register_request_batch(batch_index: int) -> None: if function_call is not None and function_call.call_id is not None: latest_approval_by_call[function_call.call_id] = batch_index elif item.get("kind") == "host" and request.call_id is not None: + batch_call = (batch_index, request.call_id) + host_request_counts[batch_call] = host_request_counts.get(batch_call, 0) + 1 latest_host_by_call[request.call_id] = batch_index if request.id is None: latest_idless_host_by_call[request.call_id] = batch_index @@ -3243,6 +3247,8 @@ def register_request_batch(batch_index: int) -> None: next_batch_index += 1 owner_candidates: list[tuple[int, str]] = [] + approval_batch_index: int | None = None + host_batch_index: int | None = None if content.type == "function_approval_response": identities = { str(identity) @@ -3258,7 +3264,8 @@ def register_request_batch(batch_index: int) -> None: if (batch_index := latest_approval_by_identity.get(identity)) is not None ) elif content.call_id is not None: - if (approval_batch_index := latest_approval_by_call.get(content.call_id)) is not None: + approval_batch_index = latest_approval_by_call.get(content.call_id) + if approval_batch_index is not None: owner_candidates.append((approval_batch_index, "approval")) if content.id is None: host_batch_index = latest_host_by_call.get(content.call_id) @@ -3283,10 +3290,25 @@ def register_request_batch(batch_index: int) -> None: owner_candidates, key=lambda candidate: (candidate[0], candidate[1] == "host"), ) + if ( + content.type == "function_result" + and content.call_id is not None + and owner_kind == "host" + and approval_batch_index == owner_batch_index + and host_batch_index == owner_batch_index + and ( + content.id is None or latest_host_by_occurrence.get((content.call_id, content.id)) != owner_batch_index + ) + and owned_host_result_counts.get((owner_batch_index, content.call_id), 0) + >= host_request_counts.get((owner_batch_index, content.call_id), 0) + ): + owner_kind = "approval" responses_by_batch[owner_batch_index].append(content) if owner_kind == "host": matched_host_result_ids.add(id(content)) host_result_batch_indices[id(content)] = owner_batch_index + batch_call = (owner_batch_index, cast(str, content.call_id)) + owned_host_result_counts[batch_call] = owned_host_result_counts.get(batch_call, 0) + 1 pending_approval_response_ids = { id(response) diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index df95deaac0..ac5a028f2f 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -4149,6 +4149,68 @@ def approval_func() -> str: assert calls == 0 +async def test_completed_stateless_mixed_batch_with_reused_call_id_is_inert( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A terminal local result cannot become Host-owned after the Host response is complete.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + approval_result = Content.from_function_result(call_id="shared", result="approved") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["later response"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + ], + ), + Message(role="tool", contents=[approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "later response" + assert calls == 0 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + def test_active_mixed_pause_ignores_historical_host_requests() -> None: """Only the session-recorded mixed batch participates in response correlation.""" from agent_framework._tools import ( From 179b0e91fe38be43f9963e468d00cc717bd0b337 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 13:58:59 +0200 Subject: [PATCH 6/8] Python: preserve exact Host occurrence ownership --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 58 ++++++-- .../core/test_function_invocation_logic.py | 135 ++++++++++++++++++ 3 files changed, 183 insertions(+), 12 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 9a47093813..47811d92b7 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions do not keep historical completed calls open or reinterpret later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 17f48cbb7f..d02fad4300 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3212,7 +3212,7 @@ def finish_current_batch() -> None: host_request_counts: dict[tuple[int, str], int] = {} owned_host_result_counts: dict[tuple[int, str], int] = {} matched_host_result_ids: set[int] = set() - host_result_batch_indices: dict[int, int] = {} + host_result_content_indices: dict[int, int] = {} next_batch_index = 0 def register_request_batch(batch_index: int) -> None: @@ -3249,6 +3249,7 @@ def register_request_batch(batch_index: int) -> None: owner_candidates: list[tuple[int, str]] = [] approval_batch_index: int | None = None host_batch_index: int | None = None + exact_host_batch_index: int | None = None if content.type == "function_approval_response": identities = { str(identity) @@ -3270,11 +3271,12 @@ def register_request_batch(batch_index: int) -> None: if content.id is None: host_batch_index = latest_host_by_call.get(content.call_id) else: + exact_host_batch_index = latest_host_by_occurrence.get((content.call_id, content.id)) host_batch_index = max( ( batch_index for batch_index in ( - latest_host_by_occurrence.get((content.call_id, content.id)), + exact_host_batch_index, latest_idless_host_by_call.get(content.call_id), ) if batch_index is not None @@ -3286,10 +3288,13 @@ def register_request_batch(batch_index: int) -> None: if not owner_candidates: continue - owner_batch_index, owner_kind = max( - owner_candidates, - key=lambda candidate: (candidate[0], candidate[1] == "host"), - ) + if exact_host_batch_index is not None: + owner_batch_index, owner_kind = exact_host_batch_index, "host" + else: + owner_batch_index, owner_kind = max( + owner_candidates, + key=lambda candidate: (candidate[0], candidate[1] == "host"), + ) if ( content.type == "function_result" and content.call_id is not None @@ -3306,7 +3311,7 @@ def register_request_batch(batch_index: int) -> None: responses_by_batch[owner_batch_index].append(content) if owner_kind == "host": matched_host_result_ids.add(id(content)) - host_result_batch_indices[id(content)] = owner_batch_index + host_result_content_indices[id(content)] = response_index batch_call = (owner_batch_index, cast(str, content.call_id)) owned_host_result_counts[batch_call] = owned_host_result_counts.get(batch_call, 0) + 1 @@ -3322,11 +3327,14 @@ def register_request_batch(batch_index: int) -> None: for batch_index, responses in enumerate(responses_by_batch) if any(id(response) in pending_approval_response_ids for response in responses) } - first_pending_approval_batch = min(pending_approval_batch_indices, default=None) + first_pending_approval_content_index = min( + (request_batches[batch_index][0] for batch_index in pending_approval_batch_indices), + default=None, + ) active_host_result_ids = { result_id - for result_id, batch_index in host_result_batch_indices.items() - if first_pending_approval_batch is not None and batch_index >= first_pending_approval_batch + for result_id, response_index in host_result_content_indices.items() + if first_pending_approval_content_index is not None and response_index > first_pending_approval_content_index } for batch_index in range(len(request_batches) - 1, -1, -1): _, items, kinds = request_batches[batch_index] @@ -3624,11 +3632,37 @@ def find_open_occurrence(call_id: str, *, require_unbound: bool = False) -> _App for occurrence in occurrences_by_call_id.get(call_id, []): if occurrence.closed: continue - if require_unbound and occurrence.approval_id is not None: + if require_unbound and (occurrence.approval_id is not None or occurrence.function_call.user_input_request): continue return occurrence return None + def find_open_host_occurrence(result: Content) -> _ApprovalCallOccurrence | None: + if result.call_id is None: + return None + occurrences = occurrences_by_call_id.get(result.call_id, []) + if result.id is not None: + exact = next( + ( + occurrence + for occurrence in occurrences + if not occurrence.closed + and occurrence.function_call.user_input_request + and occurrence.function_call.id == result.id + ), + None, + ) + if exact is not None: + return exact + return next( + ( + occurrence + for occurrence in occurrences + if not occurrence.closed and occurrence.function_call.user_input_request + ), + None, + ) + def find_approval_occurrence(approval_id: str) -> _ApprovalCallOccurrence | None: for occurrence in occurrences_by_approval_id.get(approval_id, []): if not occurrence.closed: @@ -3729,6 +3763,8 @@ def find_approval_occurrence(approval_id: str) -> _ApprovalCallOccurrence | None if content.call_id is None: continue if non_approval_result_ids is not None and id(content) in non_approval_result_ids: + if occurrence := find_open_host_occurrence(content): + occurrence.closed = True continue occurrence = find_open_occurrence(content.call_id) if occurrence is None: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index ac5a028f2f..3aff9886d3 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3688,6 +3688,64 @@ def approval_func() -> str: assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +@pytest.mark.parametrize("approval_response_first", [True, False], ids=["approval-first", "host-first"]) +async def test_exact_older_host_result_does_not_consume_newer_reused_call_approval( + chat_client_base: SupportsChatGetResponse, + approval_response_first: bool, +) -> None: + """An exact Host occurrence remains authoritative over a newer call-ID-only approval candidate.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + approval_response = approval_request.to_function_approval_response(approved=True) + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + responses = [approval_response, host_result] if approval_response_first else [host_result, approval_response] + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=["unrelated follow-up"]), + Message(role="assistant", contents=[approval_request]), + Message(role="user", contents=responses), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: @@ -3930,6 +3988,83 @@ def test_historical_stateless_host_result_does_not_capture_later_reused_call_app ] +def test_excluded_host_result_closes_own_occurrence_before_reused_call_approval() -> None: + """Excluded Host results close only Host calls before later approval normalization.""" + from agent_framework._tools import ( + _collect_approval_responses, + _replace_approval_contents_with_results, + _stateless_mixed_pause_batch_status, + ) + + earlier_call = Content.from_function_call( + call_id="earlier", + name="earlier_func", + arguments={}, + id="earlier-occurrence", + ) + earlier_request = Content.from_function_approval_request( + id="earlier-occurrence", + function_call=earlier_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + host_result.id = "host-occurrence" + later_call = Content.from_function_call( + call_id="shared", + name="later_func", + arguments={}, + id="later-occurrence", + ) + later_request = Content.from_function_approval_request( + id="later-occurrence", + function_call=later_call, + ) + messages = [ + Message(role="assistant", contents=[earlier_request]), + Message(role="user", contents=[earlier_request.to_function_approval_response(approved=True)]), + Message(role="assistant", contents=[host_request]), + Message(role="user", contents=[host_result]), + Message(role="assistant", contents=[later_request]), + Message(role="user", contents=[later_request.to_function_approval_response(approved=True)]), + ] + + incomplete, active_host_result_ids = _stateless_mixed_pause_batch_status(messages) + pending_responses = _collect_approval_responses( + messages, + non_approval_result_ids=active_host_result_ids, + ) + earlier_result = Content.from_function_result(call_id="earlier", result="earlier result") + later_result = Content.from_function_result(call_id="shared", result="later result") + _replace_approval_contents_with_results( + messages, + pending_responses, + [[earlier_result], [later_result]], + non_approval_result_ids=active_host_result_ids, + ) + + assert incomplete is False + assert list(pending_responses) == ["earlier-occurrence", "later-occurrence"] + assert [ + (content.type, content.name, content.id, content.result) + for message in messages + for content in message.contents + if content.type in {"function_call", "function_result"} + ] == [ + ("function_call", "earlier_func", "earlier-occurrence", None), + ("function_result", None, None, "earlier result"), + ("function_call", "host_func", "host-occurrence", None), + ("function_result", None, "host-occurrence", "host result"), + ("function_call", "later_func", "later-occurrence", None), + ("function_result", None, None, "later result"), + ] + + async def test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch( chat_client_base: SupportsChatGetResponse, ) -> None: From c3703f84e1c492fef6fb8561962683103137d897 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 18 Sep 2026 08:55:00 +0200 Subject: [PATCH 7/8] Python: match Host responses by occurrence --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 164 ++++++++--------- .../core/test_function_invocation_logic.py | 167 ++++++++++++++++++ 3 files changed, 251 insertions(+), 82 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 47811d92b7..085870a1f5 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; occurrence-identified Host results reserve their slots before id-less results use the unique unanswered occurrence, while equivalent duplicates remain Host-owned and conflicting duplicates fail closed; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence`, `test_stateless_mixed_batch_rejects_conflicting_identified_host_results`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_duplicate_idless_host_result_remains_host_owned_in_reused_call_mixed_batch`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index d02fad4300..2e409fc4d7 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -3000,63 +3000,73 @@ def _match_mixed_pause_responses( host_items_by_occurrence[request.id] = index matched_content_ids: set[int] = set() - for response in responses: - item_index: int | None = None - if response.type == "function_approval_response": - candidate = approval_response_binder(response) if approval_response_binder is not None else response - if candidate is None: + for match_idless_host_results in (False, True): + for response in responses: + is_idless_host_result = response.type == "function_result" and response.id is None + if is_idless_host_result != match_idless_host_results: continue - response_identities = { - str(identity) - for identity in ( - candidate.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), - candidate.id, - ) - if identity is not None - } - matching_indexes = { - matched_index - for identity in response_identities - if (matched_index := approval_items.get(identity)) is not None - } - if len(matching_indexes) == 1: - item_index = matching_indexes.pop() - elif response.type == "function_result" and response.call_id in host_items_by_call: - candidate = response - if response.id is not None: - item_index = host_items_by_occurrence.get(response.id) - request = _content_from_state(items[item_index].get("request")) if item_index is not None else None - if request is None or request.call_id != response.call_id: - item_index = None - else: - matching_indexes = [ - pending_index - for pending_index in host_items_by_call[response.call_id] - if items[pending_index].get("response") is None - or ( - isinstance(items[pending_index].get("response"), Mapping) - and _same_mixed_pause_response( - cast(Mapping[str, Any], items[pending_index]["response"]), - response.to_dict(), - ) + + item_index: int | None = None + if response.type == "function_approval_response": + candidate = approval_response_binder(response) if approval_response_binder is not None else response + if candidate is None: + continue + response_identities = { + str(identity) + for identity in ( + candidate.additional_properties.get(_APPROVAL_REQUEST_ID_KEY), + candidate.id, ) - ] + if identity is not None + } + matching_indexes = { + matched_index + for identity in response_identities + if (matched_index := approval_items.get(identity)) is not None + } if len(matching_indexes) == 1: - item_index = matching_indexes[0] - else: - continue + item_index = matching_indexes.pop() + elif response.type == "function_result" and response.call_id in host_items_by_call: + candidate = response + if response.id is not None: + item_index = host_items_by_occurrence.get(response.id) + request = _content_from_state(items[item_index].get("request")) if item_index is not None else None + if request is None or request.call_id != response.call_id: + item_index = None + else: + unanswered_indexes = [ + pending_index + for pending_index in host_items_by_call[response.call_id] + if items[pending_index].get("response") is None + ] + if len(unanswered_indexes) == 1: + item_index = unanswered_indexes[0] + elif not unanswered_indexes: + duplicate_indexes = [ + pending_index + for pending_index in host_items_by_call[response.call_id] + if isinstance(items[pending_index].get("response"), Mapping) + and _same_mixed_pause_response( + cast(Mapping[str, Any], items[pending_index]["response"]), + response.to_dict(), + ) + ] + if len(duplicate_indexes) == 1: + item_index = duplicate_indexes[0] + else: + continue - if item_index is None: - continue - candidate_state = candidate.to_dict() - stored_response = items[item_index].get("response") - if stored_response is not None and ( - not isinstance(stored_response, Mapping) - or not _same_mixed_pause_response(cast(Mapping[str, Any], stored_response), candidate_state) - ): - raise RuntimeError(f"Conflicting response for mixed pause occurrence {candidate.id!r}.") - items[item_index]["response"] = candidate_state - matched_content_ids.add(id(response)) + if item_index is None: + continue + candidate_state = candidate.to_dict() + stored_response = items[item_index].get("response") + if stored_response is not None and ( + not isinstance(stored_response, Mapping) + or not _same_mixed_pause_response(cast(Mapping[str, Any], stored_response), candidate_state) + ): + raise RuntimeError(f"Conflicting response for mixed pause occurrence {candidate.id!r}.") + items[item_index]["response"] = candidate_state + matched_content_ids.add(id(response)) if any(item.get("response") is None for item in items): return matched_content_ids, True, [], set() @@ -3209,9 +3219,8 @@ def finish_current_batch() -> None: latest_host_by_occurrence: dict[tuple[str, str], int] = {} latest_host_by_call: dict[str, int] = {} latest_idless_host_by_call: dict[str, int] = {} - host_request_counts: dict[tuple[int, str], int] = {} - owned_host_result_counts: dict[tuple[int, str], int] = {} - matched_host_result_ids: set[int] = set() + reserved_host_result_ids: set[int] = set() + response_content_indices: dict[int, int] = {} host_result_content_indices: dict[int, int] = {} next_batch_index = 0 @@ -3231,8 +3240,6 @@ def register_request_batch(batch_index: int) -> None: if function_call is not None and function_call.call_id is not None: latest_approval_by_call[function_call.call_id] = batch_index elif item.get("kind") == "host" and request.call_id is not None: - batch_call = (batch_index, request.call_id) - host_request_counts[batch_call] = host_request_counts.get(batch_call, 0) + 1 latest_host_by_call[request.call_id] = batch_index if request.id is None: latest_idless_host_by_call[request.call_id] = batch_index @@ -3295,25 +3302,24 @@ def register_request_batch(batch_index: int) -> None: owner_candidates, key=lambda candidate: (candidate[0], candidate[1] == "host"), ) - if ( - content.type == "function_result" - and content.call_id is not None - and owner_kind == "host" - and approval_batch_index == owner_batch_index - and host_batch_index == owner_batch_index - and ( - content.id is None or latest_host_by_occurrence.get((content.call_id, content.id)) != owner_batch_index - ) - and owned_host_result_counts.get((owner_batch_index, content.call_id), 0) - >= host_request_counts.get((owner_batch_index, content.call_id), 0) - ): - owner_kind = "approval" responses_by_batch[owner_batch_index].append(content) - if owner_kind == "host": - matched_host_result_ids.add(id(content)) + response_content_indices[id(content)] = response_index + if owner_kind == "host" and (exact_host_batch_index is not None or approval_batch_index != owner_batch_index): + reserved_host_result_ids.add(id(content)) host_result_content_indices[id(content)] = response_index - batch_call = (owner_batch_index, cast(str, content.call_id)) - owned_host_result_counts[batch_call] = owned_host_result_counts.get(batch_call, 0) + 1 + + batch_matches: list[tuple[set[int], bool, list[Content], set[int]]] = [] + matched_host_result_ids = set(reserved_host_result_ids) + for batch_index, (_, items, _) in enumerate(request_batches): + responses = responses_by_batch[batch_index] + match = _match_mixed_pause_responses(items, responses) + batch_matches.append(match) + matched_response_ids = match[0] + for response in responses: + response_id = id(response) + if response.type == "function_result" and response_id in matched_response_ids: + matched_host_result_ids.add(response_id) + host_result_content_indices[response_id] = response_content_indices[response_id] pending_approval_response_ids = { id(response) @@ -3337,12 +3343,8 @@ def register_request_batch(batch_index: int) -> None: if first_pending_approval_content_index is not None and response_index > first_pending_approval_content_index } for batch_index in range(len(request_batches) - 1, -1, -1): - _, items, kinds = request_batches[batch_index] - responses = responses_by_batch[batch_index] - matched_response_ids, incomplete, ordered_responses, ordered_host_result_ids = _match_mixed_pause_responses( - items, - responses, - ) + _, _, kinds = request_batches[batch_index] + matched_response_ids, incomplete, ordered_responses, ordered_host_result_ids = batch_matches[batch_index] if kinds != {"approval", "host"}: continue if incomplete: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 3aff9886d3..ac0d706a7d 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -4346,6 +4346,173 @@ def approval_func() -> str: assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +async def test_duplicate_idless_host_result_remains_host_owned_in_reused_call_mixed_batch( + chat_client_base: SupportsChatGetResponse, +) -> None: + """An equivalent Host-result replay cannot consume the approval decision.""" + from agent_framework import FunctionTool + + calls = 0 + + @tool(name="approval_func", approval_mode="always_require") + def approval_func() -> str: + nonlocal calls + calls += 1 + return "approved" + + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + duplicate_host_result = Content.from_function_result(call_id="shared", result="host result") + host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + duplicate_host_result, + ], + ), + ] + + response = await chat_client_base.get_response( + messages, + options={"tools": [approval_func, host_func]}, + ) + + assert response.text == "done" + assert calls == 1 + assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + +@pytest.mark.parametrize("identified_first", [True, False], ids=["identified-first", "idless-first"]) +def test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence( + identified_first: bool, +) -> None: + """Occurrence-identified Host results reserve their slots before id-less matching.""" + from agent_framework._tools import ( + _stage_pending_mixed_pause_responses, + _store_pending_approval_requests, + _store_pending_mixed_pause_batch, + ) + + session = AgentSession() + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + first_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="first-host-occurrence", + ) + first_host_request.user_input_request = True + second_host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="second-host-occurrence", + ) + second_host_request.user_input_request = True + identified_result = Content.from_function_result(call_id="shared", result="same result") + identified_result.id = "first-host-occurrence" + idless_result = Content.from_function_result(call_id="shared", result="same result") + host_results = [identified_result, idless_result] if identified_first else [idless_result, identified_result] + _store_pending_approval_requests(session, [approval_request]) + _store_pending_mixed_pause_batch( + session, + [[approval_request], [first_host_request], [second_host_request]], + ) + messages = [ + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + *host_results, + ], + ) + ] + + incomplete, completed, host_result_ids = _stage_pending_mixed_pause_responses(messages, session) + + assert incomplete is False + assert completed is True + assert [(content.type, content.id, content.result) for content in messages[-1].contents] == [ + ("function_approval_response", "approval-occurrence", None), + ("function_result", "first-host-occurrence", "same result"), + ("function_result", None, "same result"), + ] + assert host_result_ids == {id(messages[-1].contents[1]), id(messages[-1].contents[2])} + + +def test_stateless_mixed_batch_rejects_conflicting_identified_host_results() -> None: + """Conflicting results for one identified Host occurrence fail closed.""" + from agent_framework._tools import _stateless_mixed_pause_batch_status + + approval_call = Content.from_function_call( + call_id="approval", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="host", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + first_result = Content.from_function_result(call_id="host", result="first result") + first_result.id = "host-occurrence" + conflicting_result = Content.from_function_result(call_id="host", result="conflicting result") + conflicting_result.id = "host-occurrence" + messages = [ + Message(role="assistant", contents=[approval_request, host_request]), + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + first_result, + conflicting_result, + ], + ), + ] + + with pytest.raises(RuntimeError, match="Conflicting response for mixed pause occurrence 'host-occurrence'"): + _stateless_mixed_pause_batch_status(messages) + + def test_active_mixed_pause_ignores_historical_host_requests() -> None: """Only the session-recorded mixed batch participates in response correlation.""" from agent_framework._tools import ( From a62d827f2b3787b4fc2dfe4c4226dea8aaddf5e0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 18 Sep 2026 10:01:58 +0200 Subject: [PATCH 8/8] Python: require provenance for idless Host duplicates --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 9 ++- .../core/test_function_invocation_logic.py | 72 ++++++++++++++++--- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 085870a1f5..5e0560c152 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -616,7 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Fatal call mixed with pauses | Complete-batch classification raises before approval or execution, independent of call order. | `packages/core/tests/core/test_function_invocation_logic.py::test_mixed_batch_fatal_unknown_precedes_every_pause` | -| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; occurrence-identified Host results reserve their slots before id-less results use the unique unanswered occurrence, while equivalent duplicates remain Host-owned and conflicting duplicates fail closed; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence`, `test_stateless_mixed_batch_rejects_conflicting_identified_host_results`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_duplicate_idless_host_result_remains_host_owned_in_reused_call_mixed_batch`, `test_active_mixed_pause_ignores_historical_host_requests` | +| Approval and Host-owned calls | Both pause types are returned in model order; a session-backed partial response remains pending across serialization; occurrence-identified Host results reserve their slots before id-less results use the unique unanswered occurrence, while authoritative session state recognizes equivalent duplicates and conflicting duplicates fail closed; stateless replay does not treat an ambiguous id-less result as a Host duplicate without occurrence provenance; a stateless zero-response or partial response fails closed across non-user message roles and cannot be hidden by a newer standalone request; standalone pauses separated by an unrelated user turn remain independent and response-order invariant when call IDs are reused; exact Host occurrence identity outranks newer call-ID-only candidates; each stateless response belongs to the nearest compatible request batch even when it cannot be assigned to one item, response ownership is discovered in linear time, and Host-result exclusions close only their Host occurrences without keeping historical completed calls open or reinterpreting later local terminal results during approval normalization; completed mixed batches remain inert even when approval and Host requests reuse a call ID; historical Host calls do not participate; a complete response executes the exact approved arguments once. | `test_mixed_batch_returns_approval_and_host_pause_in_model_order`, `test_mixed_batch_requires_complete_responses_before_execution`, `test_stateful_mixed_batch_accepts_equivalent_idless_host_result_replay`, `test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence`, `test_stateless_mixed_batch_rejects_conflicting_identified_host_results`, `test_stateless_split_mixed_batch_rejects_incomplete_replay_before_execution`, `test_stateless_mixed_batch_across_non_user_message_roles_requires_complete_responses`, `test_stateless_abandoned_approval_does_not_join_later_host_request`, `test_stateless_separated_pauses_with_reused_call_id_are_order_independent`, `test_exact_older_host_result_does_not_consume_newer_reused_call_approval`, `test_later_standalone_request_does_not_hide_incomplete_stateless_mixed_batch`, `test_later_idless_host_result_does_not_complete_older_stateless_mixed_batch`, `test_completed_approval_result_is_not_claimed_by_older_stateless_host_request`, `test_historical_stateless_host_result_does_not_capture_later_reused_call_approval`, `test_excluded_host_result_closes_own_occurrence_before_reused_call_approval`, `test_ambiguous_later_host_result_does_not_complete_older_stateless_mixed_batch`, `test_id_bearing_result_for_idless_host_request_does_not_consume_approval`, `test_stateless_pause_response_ownership_scans_contents_linearly`, `test_completed_split_stateless_mixed_batch_is_inert_on_later_turn`, `test_completed_stateless_mixed_batch_with_reused_call_id_is_inert`, `test_equal_idless_terminal_result_does_not_reexecute_completed_stateless_mixed_approval`, `test_active_mixed_pause_ignores_historical_host_requests` | | 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` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 2e409fc4d7..6207a822e1 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -2974,6 +2974,7 @@ def _match_mixed_pause_responses( responses: Sequence[Content], *, approval_response_binder: Callable[[Content], Content | None] | None = None, + allow_idless_host_duplicates: bool = True, ) -> tuple[set[int], bool, list[Content], set[int]]: """Match one complete mixed pause batch without depending on its storage source.""" approval_items: dict[str, int | None] = {} @@ -3041,7 +3042,7 @@ def _match_mixed_pause_responses( ] if len(unanswered_indexes) == 1: item_index = unanswered_indexes[0] - elif not unanswered_indexes: + elif not unanswered_indexes and allow_idless_host_duplicates: duplicate_indexes = [ pending_index for pending_index in host_items_by_call[response.call_id] @@ -3312,7 +3313,11 @@ def register_request_batch(batch_index: int) -> None: matched_host_result_ids = set(reserved_host_result_ids) for batch_index, (_, items, _) in enumerate(request_batches): responses = responses_by_batch[batch_index] - match = _match_mixed_pause_responses(items, responses) + match = _match_mixed_pause_responses( + items, + responses, + allow_idless_host_duplicates=False, + ) batch_matches.append(match) matched_response_ids = match[0] for response in responses: diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index ac0d706a7d..dcc1e85b34 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -4346,10 +4346,10 @@ def approval_func() -> str: assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] -async def test_duplicate_idless_host_result_remains_host_owned_in_reused_call_mixed_batch( +async def test_equal_idless_terminal_result_does_not_reexecute_completed_stateless_mixed_approval( chat_client_base: SupportsChatGetResponse, ) -> None: - """An equivalent Host-result replay cannot consume the approval decision.""" + """An ambiguous id-less result cannot restore stateless approval authority.""" from agent_framework import FunctionTool calls = 0 @@ -4358,7 +4358,7 @@ async def test_duplicate_idless_host_result_remains_host_owned_in_reused_call_mi def approval_func() -> str: nonlocal calls calls += 1 - return "approved" + return "same result" approval_call = Content.from_function_call( call_id="shared", @@ -4377,11 +4377,11 @@ def approval_func() -> str: id="host-occurrence", ) host_request.user_input_request = True - host_result = Content.from_function_result(call_id="shared", result="host result") - duplicate_host_result = Content.from_function_result(call_id="shared", result="host result") + host_result = Content.from_function_result(call_id="shared", result="same result") + approval_result = Content.from_function_result(call_id="shared", result="same result") host_func = FunctionTool(name="host_func", func=None, description="Handled by the caller") chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - ChatResponse(messages=Message(role="assistant", contents=["done"])), + ChatResponse(messages=Message(role="assistant", contents=["later response"])), ] messages = [ Message(role="assistant", contents=[approval_request, host_request]), @@ -4390,9 +4390,11 @@ def approval_func() -> str: contents=[ approval_request.to_function_approval_response(approved=True), host_result, - duplicate_host_result, ], ), + Message(role="tool", contents=[approval_result]), + Message(role="assistant", contents=["done"]), + Message(role="user", contents=["later"]), ] response = await chat_client_base.get_response( @@ -4400,11 +4402,63 @@ def approval_func() -> str: options={"tools": [approval_func, host_func]}, ) - assert response.text == "done" - assert calls == 1 + assert response.text == "later response" + assert calls == 0 assert chat_client_base.call_count == 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] +def test_stateful_mixed_batch_accepts_equivalent_idless_host_result_replay() -> None: + """Authoritative session state can recognize an equivalent Host-result replay.""" + from agent_framework._tools import ( + _stage_pending_mixed_pause_responses, + _store_pending_approval_requests, + _store_pending_mixed_pause_batch, + ) + + session = AgentSession() + approval_call = Content.from_function_call( + call_id="shared", + name="approval_func", + arguments={}, + id="approval-occurrence", + ) + approval_request = Content.from_function_approval_request( + id="approval-occurrence", + function_call=approval_call, + ) + host_request = Content.from_function_call( + call_id="shared", + name="host_func", + arguments={}, + id="host-occurrence", + ) + host_request.user_input_request = True + host_result = Content.from_function_result(call_id="shared", result="host result") + duplicate_host_result = Content.from_function_result(call_id="shared", result="host result") + _store_pending_approval_requests(session, [approval_request]) + _store_pending_mixed_pause_batch(session, [[approval_request], [host_request]]) + messages = [ + Message( + role="user", + contents=[ + approval_request.to_function_approval_response(approved=True), + host_result, + duplicate_host_result, + ], + ) + ] + + incomplete, completed, host_result_ids = _stage_pending_mixed_pause_responses(messages, session) + + assert incomplete is False + assert completed is True + assert [(content.type, content.result) for content in messages[-1].contents] == [ + ("function_approval_response", None), + ("function_result", "host result"), + ] + assert host_result_ids == {id(messages[-1].contents[1])} + + @pytest.mark.parametrize("identified_first", [True, False], ids=["identified-first", "idless-first"]) def test_stateful_mixed_batch_assigns_idless_equal_result_to_unanswered_occurrence( identified_first: bool,