Python: Foundry hosting yields response.completed from the handler after a cancelled workflow run

Open Beginner friendly
#8,564 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
82/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
api, backend

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

python triage
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-hosting 1.0.0b260918
  • agent-framework-core 1.19.0
  • azure-ai-agentserver-responses 2.2.0b1, azure-ai-agentserver-core 2.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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from microsoft/agent-framework

All issues in microsoft/agent-framework

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.