Skip to content

Python: add background tool recovery for failed runs - #10110

Draft
sophia-ramsey wants to merge 4 commits into
mainfrom
sramsey/python-repolling-responses
Draft

sophia-ramsey wants to merge 4 commits into
mainfrom
sramsey/python-repolling-responses

Conversation

@sophia-ramsey

Copy link
Copy Markdown
Member

Motivation & Context

An OpenAI background poll can complete a local tool and then fail while submitting its result. Retrying the original continuation token can execute that tool again, repeating side effects such as sending an email. This change separates completed tool execution from result delivery in both streaming and non-streaming background polling.

Description & Review Guide

  • What are the major changes?
    Adds session-backed BackgroundToolRecovery to retain execution claims, completed results, prepared delivery messages, and invocation budgets. The shared function loop resumes result delivery without retrieving or executing completed calls again, while the OpenAI adapter scopes recovery to the provider configuration. Regression tests cover retries, restored sessions, stale tokens, partial execution, concurrency, and existing approval behavior.
  • What is the impact of these changes?
    Supported retries reuse completed results without rerunning tool bodies or function middleware, while unsafe or indeterminate recovery attempts fail explicitly. Automatic background tool execution now requires an explicit, reusable AgentSession, so stateless callers must create and retain one across polls. Durable session persistence and coordination between workers remain application responsibilities; this does not provide distributed exactly-once execution.
  • What do you want reviewers to focus on?
    Design decision and approach with the creation of BackgroundToolRecovery as well as how different states are handled. Details are in `docs/decisions/0049-python-background-tool-result-recovery.md'. Please let me know if there are any risks that I have not considered.

Related Issue

Fixes #9031

AI Assistance

  • No material AI assistance was used.
  • This is an AI-assisted contribution. I reviewed, understood, and verified all submitted content and accept responsibility for it.

AI assistance details: investigation, planning, implementation, testing

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 links to an agreed issue with no competing open PR, or the Related Issue section documents a trivial-change or repository-automation exception.
  • 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 balanced review requested due to automatic review settings November 5, 2026 03:33
@agent-framework-automation agent-framework-automation Bot added documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python labels Nov 5, 2026
@github-code-quality

github-code-quality Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Code coverage overview

Languages: Python

Python / code-coverage/python

main 07b9b3f sramsey/python-repol... 6251954 +/-
92% 92% 0%
View coverage of the 6 most impacted files.
File main 07b9b3f sramsey/python-repol... 6251954 +/-
packages/openai..._chat_client.py 94% 94% 0%
packages/foundr...undry/_agent.py 94% 94% 0%
packages/ag-ui/...i/_agent_run.py 90% 90% 0%
packages/core/a...work/_agents.py 96% 96% 0%
packages/core/a...ework/_tools.py 95% 96% +1%
packages/core/a...ool_recovery.py 0% 95% +95%

ℹ️ Calculations represent line coverage. Results may change as calculations are updated.


Updated November 06, 2026 05:12 UTC

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

Request Shared State can overwrite recovery checkpoints and the handled-token ledger, erasing pending results and defeating replay protection.

6 open issues
What changed in this PR

Adds session-backed recovery for Python background tool calls, addressing #9031 by separating completed tool execution from result delivery so supported retries avoid repeating side effects.

Changes:

  • Records execution claims, completed results, delivery checkpoints, and invocation budgets in a reusable AgentSession.
  • Integrates recovery into streaming and non-streaming function loops, with provider-scoped state and rejection of unsafe retries.
  • Adds regression coverage, documents recovery boundaries, and explicitly closes interrupted sample streams.
File Description
python/​samples/​02-agents/​background_responses.py Closes the interrupted stream before resumption.
python/​packages/​openai/​tests/​openai/​test_openai_chat_client.py Tests retries, restored sessions, concurrency, limits, and approvals.
python/​packages/​openai/​AGENTS.md Documents adapter recovery responsibilities and session requirements.
python/​packages/​openai/​agent_framework_openai/​_chat_client.py Defines recovery scope and provider-owned state.
python/​packages/​core/​AGENTS.md Adds the recovery module to the package map.
python/​packages/​core/​agent_framework/​_tools.py Integrates execution tracking and delivery recovery into both function loops.
python/​packages/​core/​agent_framework/​_tool_recovery.py Implements recovery state, checkpoints, and replay protection.
docs/​decisions/​0049-python-background-tool-result-recovery.md Describes the proposed design, lifecycle, and persistence responsibilities.

Review effort: Balanced

@github-actions github-actions Bot 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.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 9fd47927c247
Model: gpt-5.6-sol

Overview

The PR introduces a session-backed recovery state machine that claims tool calls before execution, checkpoints completed results separately from delivery, and restores transcript and budget state without rerunning tool bodies. Its tests and fail-closed guards cover retryable transport errors, stale or corrupt state, partial execution, and concurrent use. Residual risk remains around inherited Foundry identities and state ownership, premature submission-state transitions, empty-session scope binding, and an intentional behavioral break for stateless callers.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
5 verified findings remained after source verification (5 medium) across 3 files. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_tool_recovery.py, python/packages/core/agent_framework/_tools.py, python/packages/openai/agent_framework_openai/_chat_client.py

@eavanvalkenburg

Copy link
Copy Markdown
Member

sophia-ramsey Documentation direction: remove docs/decisions/0049-python-background-tool-result-recovery.md. Capture the normative recovery behavior and authoritative scenario-to-test mapping in docs/specs/004-python-function-calling-loop.md instead. A new ADR is not needed for this change.

@sophia-ramsey

sophia-ramsey commented Nov 6, 2026 •

Copy link
Copy Markdown
Member Author

still a WIP but welcome to comments/feedback, I think more fixes will be needed if we want to fully support this functionality with AG-UI

@eavanvalkenburg

Copy link
Copy Markdown
Member

sophia-ramsey, I found one material issue on the current head:

  • python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py:3955: when a local tool completes but output submission fails, the cleanup path does not durably persist the updated recovery state. With service sessions, retry can execute the side effect twice; without service sessions, the persisted snapshot can remain busy=True and permanently reject retry. Please persist authoritative recovery evidence after failure cleanup and add both service-session and local-session regressions.

if self._state.pending is not None:
pending = self._state.pending
if pending.status == "submitting":
self._remember(pending.token, pending.response_id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

sophia-ramsey This records the delivery only after the whole chat-middleware invocation reaches observe_response. MessageInjectionMiddleware can issue another provider request in that same invocation. If the tool-output submission succeeds but the later injected-message request fails, recovery marks the original delivery ready and can resubmit the already accepted tool output against the earlier response ID. That duplicates provider-side processing and branches the continuation. Please persist delivery acceptance and advance continuation at each provider-response boundary before middleware can issue another request, and add a regression for a successful delivery followed by a failed injected request.

if recovery is None:
return ResponseStream(updates, finalizer=finalize_stream)

async def run_stream() -> AsyncIterable[ChatResponseUpdate]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

sophia-ramsey, this persists busy=True before the stream yields, but ownership is released only when the stream is closed or finalized. If a caller breaks out of async for and immediately resumes on the same session, the old generator may not be finalized yet, so the resume fails; saving the session in between can make the busy state durable. Can we preserve the prior break-and-resume behavior or release abandoned stream ownership automatically, with a regression that does not require an explicit stream.close()?

pending = state.pending
if self._token != pending.token or self._inputs != pending.inputs:
raise ChatClientInvalidRequestException(
"Pending tool results must be recovered with the original token and no new input."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

sophia-ramsey, for an initial streaming background run whose tool-output submission fails with 429/503, the pending state keeps both the original inputs and the held token. Recovery then rejects a token-only poll and also rejects repeating the original call; only supplying both together works, despite the error saying to use the original token with no new input. Please make the supported recovery call unambiguous and add a regression for the initial-stream path.

@sophia-ramsey
sophia-ramsey marked this pull request as draft November 12, 2026 01:05

This branch was previously deployed

1 inactive deployment
github-app-auth — 62519545 Deployed Nov 6, 2026 by sophia-ramsey via add_label #27216
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: [Bug]: Re-polling a background response after a failed poll re-runs its function calls, repeating their side effects

3 participants