Skip to content

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

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-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().

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions