Skip to content

Python: preserve mixed-pause recovery invariants - #8518

Open
RongJie G (CorgiBoyG) wants to merge 1 commit into
microsoft:mainfrom
CorgiBoyG:fix/post-8428-mixed-batch-safety
Open

RongJie G (CorgiBoyG) wants to merge 1 commit into
microsoft:mainfrom
CorgiBoyG:fix/post-8428-mixed-batch-safety

Conversation

@CorgiBoyG

Copy link
Copy Markdown

Motivation & Context

A mixed batch containing approval-required and Host-owned calls can finish local execution before the provider accepts the result-delivery request. If that request is invalidated, clearing the completed batch immediately loses the only replayable copy even though the approved local tool has already run.

PR #8449 bounded stateless pause-response ownership to user turns and rejected direct conflicting occurrence-identified Host results. This change builds on that merged work and closes the remaining #8436 gaps: stateful identified/id-less correlation, handling of extra Host replays, and recovery when provider result delivery is invalidated.

Description & Review Guide

  • What are the major changes?

    • Keep a serializable provider outbox until result delivery succeeds, then clear it.
    • Replay stored Host and local results without re-authorizing or re-executing approved tools.
    • Preserve charged invocation budgets across invalidation and session serialization, including cross-process duration rebasing and fail-closed validation of malformed state.
    • Match occurrence-identified Host results before id-less compatibility results when a call_id is reused.
    • Deduplicate equivalent extra Host replays only when they can be attributed to active Host occurrences, and reject conflicting extra responses before provider delivery.
    • Preserve provider continuation rollback for framework-created, non-authoritative sessions without granting them approval authority.
    • Update the function-calling specification, provider-invalidation decision record, core contributor guidance, and regression coverage.
  • What is the impact of these changes?

    • Approved local tool results are replayed after provider invalidation without re-running the tool.
    • Streaming and non-streaming paths use the same serializable outbox recovery flow.
    • Reused call_id batches no longer remain pending or forward contradictory/orphaned Host results.
    • Completed historical stateless batches remain inert under later identifier reuse.
    • No public API signatures change.
  • What do you want reviewers to focus on?

    • The outbox lifecycle: persist before provider delivery, retain on invalidation, and clear only after success.
    • The boundary between authoritative approval state and continuation cleanup.
    • How id-less Host replays are deduplicated without consuming an approval result.
    • The serialized duration-budget validation and fail-closed behavior.

Related Issue

Fixes #8436

Built on #8449, which has already been merged.

Validation

Run from python/:

  • uv run pytest packages/core/tests -q
  • uv run pytest packages/ag-ui/tests -q (1367 passed, 14 skipped)
  • uv run pytest packages/declarative/tests -q
  • uv run pytest packages/foundry_hosting/tests -q
  • uv run ruff check packages/core/agent_framework/_tools.py packages/core/tests/core/test_function_invocation_logic.py
  • uv run ruff format --check packages/core/agent_framework/_tools.py packages/core/tests/core/test_function_invocation_logic.py
  • uv run pyright packages/openai packages/core/agent_framework packages/core/tests/core/test_function_invocation_logic.py (0 errors, 0 warnings)
  • git diff --check

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Cross-turn equal-result correlation can remain permanently pending, and malformed restored error counters can weaken the configured failure limit.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds reliable recovery for mixed approval/Host pause batches after provider invalidation.

Changes:

  • Persists and replays provider outboxes and invocation budgets.
  • Improves Host-result correlation and conflict handling.
  • Adds specifications, guidance, and regression coverage.
File summaries
File Description
python/packages/core/agent_framework/_tools.py Implements outbox recovery, budget restoration, and correlation logic.
python/packages/core/tests/core/test_function_invocation_logic.py Adds recovery and correlation tests.
python/packages/core/AGENTS.md Documents the outbox invariant.
docs/specs/004-python-function-calling-loop.md Updates function-loop requirements.
docs/decisions/0041-handle-provider-invalidated-responses.md Records the invalidation recovery decision.
Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +3241 to 3245
if len(replay_indexes) == 1:
item_index = replay_indexes[0]
observed_preexisting_indexes.add(item_index)
elif len(unanswered_indexes) == 1:
item_index = unanswered_indexes[0]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with a regression test that stages the identified equal-payload result in one turn and supplies the id-less result in a later turn while retaining the full transcript. The initial fix of preferring the unanswered slot was still too broad because it could misassign a historical id-less replay. The matcher now treats a preexisting response as an id-less replay only when the stored response itself has no occurrence ID; otherwise the later id-less result fills the sole unanswered occurrence. The focused cross-turn and existing same-turn/replay cases pass.

Comment on lines +4352 to +4356
action = outbox.get("action")
if action not in {"continue", "stop"}:
raise RuntimeError("The pending mixed-batch provider outbox contains an invalid action.")
return _FunctionProcessingResult(
errors_in_a_row=int(outbox.get("errors_in_a_row", 0) or 0),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with parameterized regression cases for a negative integer, bool, string, and float. Restored errors_in_a_row is now accepted only when it is a non-boolean, non-negative integer, and validation happens before replaying the outbox. A separate compatibility test verifies that an older outbox with the field missing still restores the documented default of zero.

@CorgiBoyG

Copy link
Copy Markdown
Author

Copilot review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: Mixed pause recovery can lose provider outbox state and mis-correlate Host results

2 participants