Python: [Bug]: Re-polling a background response after a failed poll re-runs its function calls, repeating their side effects
Maintainer thường phản hồi trong vòng 1 ngày
@sophia-ramsey đang làm issue này rồi.
Từ ngày 5/10/2026.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 48/100
Hướng nghiên cứu
Start with the background-response polling and function loop in _chat_client.py, especially the continuation-token handling around the referenced lines. Run repro.py with the listed package versions to observe the repeated payment after the failed output POST. Trace how the caller-held token and executed function calls are represented, then verify that a re-poll does not repeat the side effect or leaves the caller with a way to continue safely.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Description
This is the caller-side sibling of #5394.
With options={"background": True}, the caller holds a continuation token and
polls with it until it is None, as in the background-responses docs. When the
background response completes with function calls, the poll itself:
- runs the calls (the function loop);
- POSTs their outputs.
If step 2 fails, for example on a sustained 429 or 503 after the SDK's
retries, the poll raises ChatClientException. The token the caller still
holds names the response that requested the calls. Re-polling with it, the
obvious recovery after an exception (the docs present continuation tokens as
the handle for operations "interrupted by network issues or client timeouts"),
retrieves that response again and runs its calls a second time.
The fix for #5394 (#5462, and #8357 for the streaming resume) handled the
in-loop form of this: it pops continuation_token from
the options dict once the response completes (_chat_client.py, around lines
956–963 and 876–883 in agent-framework-openai 1.14.4; the comment there reads
"the tools run again each time"). The caller-held token is the remaining form.
After an exception the caller has no other token to use, and nothing tells it
that the calls behind that token have already run.
Code Sample
# --- repro.py (verbatim) ---
"""Agent Framework (Python): re-polling a background response with the held
continuation token re-runs the function calls of that response.
Self-contained: `pip install agent-framework-core==1.19.0 agent-framework-openai==1.14.4`
then `python repro.py`. No network access and no API key; nothing leaves 127.0.0.1.
- The agent is the documented background-responses flow: `agent.run(...,
options={"background": True})`, then poll with `continuation_token` until it
is None.
- A local fake OpenAI Responses endpoint queues the first request and, on
retrieve, returns it completed with one `pay_invoice` function call.
Submitting the function output fails with 429 on every attempt (a sustained
rate limit or outage after the tool ran).
- A local fake payment provider records each payment.
- When a poll raises, the caller polls once more with the token it holds,
which is the only token it has.
"""
import asyncio
import json
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Annotated
MODEL, LEDGER = ("127.0.0.1", 8963), ("127.0.0.1", 8964)
PAYMENTS = [] # the provider's ledger: one entry per committed payment
STORED = {} # response id -> the completed response object
ARGS = {"invoice": "INV-1", "payee": "ACME", "amount": "100.00"}
class Ledger(BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
self.rfile.read(int(self.headers["Content-Length"]))
PAYMENTS.append(time.time())
data = json.dumps({"status": "PAID", "payment": len(PAYMENTS)}).encode()
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
class FakeResponsesAPI(BaseHTTPRequestHandler):
def send(self, status, obj, headers=()):
data = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
for k, v in headers:
self.send_header(k, v)
self.end_headers()
self.wfile.write(data)
def do_GET(self): # noqa: N802 retrieve: the stored response, completed
rid = self.path.split("?")[0].rstrip("/").rsplit("/", 1)[-1]
self.send(200, STORED[rid]) if rid in STORED else self.send(404, {"error": {"message": "no such response"}})
def do_POST(self): # noqa: N802
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
items = body.get("input") if isinstance(body.get("input"), list) else []
if any(isinstance(i, dict) and i.get("type") == "function_call_output" for i in items):
return self.send(429, {"error": {"message": "Rate limit reached", "type": "requests",
"code": "rate_limit_exceeded"}}, [("retry-after-ms", "50")])
rid = f"resp_{len(STORED) + 1}"
STORED[rid] = {
"id": rid, "object": "response", "created_at": int(time.time()), "status": "completed",
"model": "gpt-4o-mini", "background": bool(body.get("background")), "store": True,
"output": [{"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "pay_invoice",
"arguments": json.dumps(ARGS), "status": "completed"}],
"parallel_tool_calls": True, "tool_choice": "auto", "tools": [], "error": None,
"incomplete_details": None, "instructions": None, "metadata": {},
"previous_response_id": body.get("previous_response_id"), "text": {"format": {"type": "text"}},
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2,
"input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}}}
self.send(200, dict(STORED[rid], status="queued", output=[], usage=None))
def log_message(self, *args):
pass
def serve(addr, handler):
s = ThreadingHTTPServer(addr, handler)
s.daemon_threads = True
threading.Thread(target=s.serve_forever, daemon=True).start()
serve(MODEL, FakeResponsesAPI)
serve(LEDGER, Ledger)
from importlib.metadata import version # noqa: E402
from agent_framework import Agent, tool # noqa: E402
from agent_framework.openai import OpenAIChatClient # noqa: E402
from pydantic import Field # noqa: E402
@tool(approval_mode="never_require")
def pay_invoice(invoice: Annotated[str, Field(description="Invoice number")],
payee: Annotated[str, Field(description="Payee account")],
amount: Annotated[str, Field(description="Amount to pay")]) -> str:
"""Pay a vendor invoice. This moves money and cannot be undone."""
req = urllib.request.Request(f"http://{LEDGER[0]}:{LEDGER[1]}/pay", method="POST",
data=json.dumps({"invoice": invoice, "payee": payee, "amount": amount}).encode())
with urllib.request.urlopen(req, timeout=10) as r:
return r.read().decode()
async def main():
client = OpenAIChatClient(model="gpt-4o-mini", api_key="placeholder",
base_url=f"http://{MODEL[0]}:{MODEL[1]}/v1")
agent = Agent(client=client, name="PayAgent", instructions="You pay vendor invoices.", tools=[pay_invoice])
session = agent.create_session()
response = await agent.run(messages="Pay invoice INV-1 to ACME for 100.00.", session=session,
options={"background": True})
token = response.continuation_token
print(f"started; holding continuation token {dict(token)}")
for attempt in (1, 2): # a poll, then one re-poll with the held token after it raised
await asyncio.sleep(0.2)
try:
response = await agent.run(session=session, options={"continuation_token": token})
token = response.continuation_token
print(f"poll {attempt}: ok")
if token is None:
break
except Exception as exc:
print(f"poll {attempt} raised {type(exc).__name__} (payments so far: {len(PAYMENTS)})")
print(f"agent-framework-core {version('agent-framework-core')}")
asyncio.run(main())
print(f"payments made for one requested payment: {len(PAYMENTS)}")
# --- end repro.py ---
Error Messages / Stack Traces
### Reproduction
_Re-checked on 2026-10-03 against agent-framework-core 1.20.0 and agent-framework-openai 1.15.0, the current releases: same result._
Self-contained script below (`pip install agent-framework-core==1.19.0
agent-framework-openai==1.14.4`, then `python repro.py`). It needs no network
access and no API key. It uses:
- a local fake Responses endpoint that queues the first request and, on
retrieve, returns it completed with one `pay_invoice` call;
- a POST of the function output that returns 429 every time;
- a local fake payment provider.
Output:
agent-framework-core 1.19.0
started; holding continuation token {'response_id': 'resp_1'}
poll 1 raised ChatClientException (payments so far: 1)
poll 2 raised ChatClientException (payments so far: 2)
payments made for one requested payment: 2
The service never received either function output, so it has no record of
either payment.
### Expected
After a poll has run a response's function calls, re-polling with the same
token should not run them again. Failing that, the caller should get a way to
see that they ran.
Package Versions
agent-framework-core 1.19.0, agent-framework-openai 1.14.4, openai 3.20.0
Python Version
Python 3.13.9
Additional Context
Possible changes
- Surface the executed calls when the output POST fails. For example,
return or raise with a token (or an error attribute) that carries the
function results already produced, so the next poll submits them instead of
re-running the tools. - Record executed
call_ids per response in the session. On a
re-retrieve of the same response, skip or refuse the calls that already ran. - At least, document it. Say that a failed poll may have run tools, that
re-polling with the same token runs them again, and that side-effecting
tools should key oncall_id.
Found while testing retry and resume paths across agent frameworks.
- Ngôn ngữ chính
- Python
- Star
- 13.9k
- Fork
- 2.4k
- Merge trung bình
- 1 ngày 16 giờ
- Pull request đã merge (30 ngày)
- 440
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của microsoft/agent-framework
-
.NET: Proposal: add an llms.txtĐang mở.NET python triage
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 85/100
microsoft/agent-framework#9092 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Python: raw-data content mappings lose annotations and attachment metadataCó thể đã có người làm @moonbox3 đã nhận 7 ngày trước. Đang mởpython triage
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
microsoft/agent-framework#8632 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Python: Clarify when to use platformCó thể đã có người làm @eavanvalkenburg đã nhận 7 ngày trước. Đang mởpython triage
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
microsoft/agent-framework#8599 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
.NET Compaction - Update docs to refer to `AIContextProvider` deep diveCó thể đã có người làm Có pull request liên kết đang mở hoặc đã được merge. Đang mở.NET compaction documentation
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 82/100
microsoft/agent-framework#4629 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Python: [Feature]: A deterministic pre-execution verification middleware for agent actions — AgentDojo v2.2 re-run: ASR=0 / FP=0 (open artifacts, full fix-cycle trajectory inside)Có thể đã có người làm @eavanvalkenburg đã nhận hôm nay. Đang mởagents middleware python
microsoft/agent-framework#9132 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của microsoft/agent-framework
Issue tương tự
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 85/100
Vector35/community-plugins#376 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
py-econometrics/pyfixest#1883 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bad links in rfc5890.htmlĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 65/100
ietf-tools/rfc2html#81 ·
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
mysql/mysql-operator#60 ·
-
Python: Bug: split_plaintext_paragraph / split_markdown_paragraph can return a chunk larger than max_tokensCó thể đã có người làm @xThreeh đã nhận hôm nay. Đang mởpython triage
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 75/100
microsoft/semantic-kernel#14566 ·
Maintainer thường phản hồi trong vòng 4 ngày