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().
Summary
After #7511,
ResponsesHostServercorrectly forwardscancellation_signalinto the workflow path and interrupts the in-flight run. But when that cancelled run returns,_handle_responsefalls through and still yieldsresponse.completed(statuscompleted) to whatever is consuming the handler.On the wire this is masked:
azure-ai-agentserver-responsesrewrites the terminal tocancelled(_maybe_override_to_cancelled), soPOST /responses/{id}/canceland a laterGETboth reportcancelled. Anything that sits between the hosting handler and the orchestrator, for example a subclass that wraps_handle_responseto 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.0The same code path is present on
maintoday (python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py, the block afterasync for event in inner).Cause
In
_handle_inner_workflow, a cancel makes_SignalledIteratorstop and the generator returns normally ("Cancellation needs no extra action here (the loop above already stopped)"). Back in_handle_response, a normal return frominneris treated as success:_handle_inner_agenttakes 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.
Actual
Expected
After a cancel-signalled return, the hosting handler should not emit
response.completed. Either emit nothing and let agentserver synthesise thecancelledterminal (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:before the
tracker.close()/emit_completedblock, 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.completedsnapshot carries it as a completed result. We currently work around it by droppingresponse.completedwhencancellation_signal.is_set().