Python: Foundry hosting yields response.completed from the handler after a cancelled workflow run
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 82/100
Research direction
Start in python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py, focusing on _handle_response after the async iteration over inner and comparing it with the cancellation returns in _handle_inner_workflow and _handle_inner_agent. Use the self-contained repro to confirm the handler currently yields response.completed after cancellation. Done means a cancel-signalled return no longer yields response.completed while the public cancel and GET responses remain cancelled.
Written by the indexing model from the issue text.
Description
Summary
After #7511, ResponsesHostServer correctly forwards cancellation_signal into the workflow path and interrupts the in-flight run. But when that cancelled run returns, _handle_response falls through and still yields response.completed (status completed) to whatever is consuming the handler.
On the wire this is masked: azure-ai-agentserver-responses rewrites the terminal to cancelled (_maybe_override_to_cancelled), so POST /responses/{id}/cancel and a later GET both report cancelled. Anything that sits between the hosting handler and the orchestrator, for example a subclass that wraps _handle_response to persist results, sees a successful terminal for a run that was cancelled.
Versions
agent-framework-foundry-hosting1.0.0b260918agent-framework-core1.19.0azure-ai-agentserver-responses2.2.0b1,azure-ai-agentserver-core2.1.0- Python 3.11, Linux
The same code path is present on main today (python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py, the block after async for event in inner).
Cause
In _handle_inner_workflow, a cancel makes _SignalledIterator stop and the generator returns normally ("Cancellation needs no extra action here (the loop above already stopped)"). Back in _handle_response, a normal return from inner is treated as success:
try:
async for event in inner:
yield event
except BaseException:
await inner.aclose()
raise
for event in tracker.close():
yield event
if tracker.oauth_consent_requested:
yield response_event_stream.emit_incomplete(usage=tracker.usage)
else:
yield response_event_stream.emit_completed(usage=tracker.usage) # also reached after cancel
_handle_inner_agent takes the same signal, so the non-workflow path looks affected in the same way.
Repro
Self-contained, no model or network needed. A stored background response is cancelled through the public cancel endpoint while a workflow executor is suspended.
import asyncio
from typing import Any
import httpx
from agent_framework import Executor, Message, WorkflowBuilder, WorkflowContext, handler
from agent_framework_foundry_hosting import ResponsesHostServer
started = asyncio.Event()
interrupted = asyncio.Event()
seen: list[tuple[Any, bool]] = []
class Stuck(Executor):
@handler
async def run(self, messages: list[Message], ctx: WorkflowContext[Any, Any]) -> None:
started.set()
try:
await asyncio.sleep(3600) # stands in for a slow model/tool call
except asyncio.CancelledError:
interrupted.set()
raise
class Tapped(ResponsesHostServer):
async def _handle_response(self, request, context, cancellation_signal):
async for event in super()._handle_response(request, context, cancellation_signal):
event_type = event.get("type") if isinstance(event, dict) else type(event).__name__
seen.append((event_type, cancellation_signal.is_set()))
yield event
async def main() -> None:
agent = WorkflowBuilder(start_executor=Stuck(id="stuck")).build().as_agent(name="repro")
server = Tapped(agent)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=server), base_url="http://repro"
) as client:
created = await client.post(
"/responses",
json={"input": "go", "background": True, "stream": False, "store": True},
)
response_id = created.json()["id"]
await asyncio.wait_for(started.wait(), 20)
cancelled = await client.post(f"/responses/{response_id}/cancel")
print("POST /cancel ->", cancelled.json()["status"])
await asyncio.wait_for(interrupted.wait(), 20)
await asyncio.sleep(0.5)
final = await client.get(f"/responses/{response_id}")
print("GET ->", final.json()["status"])
print("executor interrupted:", interrupted.is_set())
for event_type, signalled in seen:
print(f"handler yielded {event_type!r} (cancellation_signal set: {signalled})")
asyncio.run(main())
Actual
POST /cancel -> cancelled
GET -> cancelled
executor interrupted: True
handler yielded 'response.created' (cancellation_signal set: False)
handler yielded 'response.in_progress' (cancellation_signal set: False)
handler yielded 'response.completed' (cancellation_signal set: True)
Expected
After a cancel-signalled return, the hosting handler should not emit response.completed. Either emit nothing and let agentserver synthesise the cancelled terminal (its existing "handler returned without a terminal event while the cancellation signal is set" path does exactly that), or emit a cancelled terminal itself. For example:
if cancellation_signal.is_set():
return
before the tracker.close() / emit_completed block, mirroring the early returns already used inside _handle_inner_workflow.
Impact
Consumers of the handler stream cannot tell a cancelled run from a finished one without also checking the signal themselves. If any text had streamed before the cancel, the response.completed snapshot carries it as a completed result. We currently work around it by dropping response.completed when cancellation_signal.is_set().
- Dominant language
- Python
- Stars
- 13.6k
- Forks
- 2.3k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 342
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from microsoft/agent-framework
-
python triage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
microsoft/agent-framework#8523 · 1 comment ·
-
python triage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
microsoft/agent-framework#8520 ·
-
Python: checkpoints flatten dict subclasses (defaultdict/Counter/OrderedDict become plain dict) Openpython triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
microsoft/agent-framework#8517 ·
-
python triage
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
microsoft/agent-framework#8515 · 2 comments ·
-
.NET compaction documentation
Difficulty 1/5 Under an hour Newbie friendliness 82/100
microsoft/agent-framework#4629 · 1 comment ·
All issues in microsoft/agent-framework
Similar issues
-
Difficulty 1/5 Under an hour Newbie friendliness 90/100
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
zostera/django-bootstrap4#894 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
use-agent-os/agent-os#3276 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
zephyrproject-rtos/zephyr#119726 ·
-
area/auth bug comp/agent P3 platform/discord type/security
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
NousResearch/hermes-agent#117848 ·