Repository navigation
Description
Summary
Workflow.run() and WorkflowAgent.run() accept three independent-ish inputs — message, checkpoint_id, and responses — whose combined semantics are only partially specified and, in at least one combination, appear to silently drop workflow output. This surfaced while hardening ResponsesHostServer (packages/foundry_hosting) crash-recovery for resilient_background=True workflow agents, and warrants a dedicated investigation + spec before the workaround currently in agent_framework_foundry_hosting/_responses.py becomes the de facto contract.
Current validation rules (Workflow._validate_run_params, packages/core/agent_framework/_workflows/_workflow.py)
message+responses: mutually exclusive (raisesValueError)message+checkpoint_id: mutually exclusive (raisesValueError)checkpoint_id+responses: allowed — "restore then send" (_restore_and_send_responses)- At least one of the three must be provided
So at the Workflow.run() level, message can never be combined with checkpoint_id in a single call.
Where the ambiguity/bug shows up: WorkflowAgent.run() / WorkflowAgent._run_core()
packages/core/agent_framework/_workflows/_agent.py (_run_core, ~line 420) simulates a message + checkpoint_id combination by issuing two sequential separate calls to the underlying Workflow:
if checkpoint_id is not None:
...
if streaming:
async for _ in self.workflow.run(stream=True, checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage):
pass # <-- events from this phase are unconditionally discarded
else:
_ = await self.workflow.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
if not input_messages:
logger.info("No input messages provided; the workflow has been restored to the checkpoint state.")
return # <-- returns without yielding anything at all
final_state = self._workflow.status
...
elif final_state == WorkflowRunState.IDLE:
async for event in self.workflow.run(message=input_messages, stream=True, ...):
yield eventThis assumes checkpoint restoration is purely "replay old history to get back to idle," so any events it produces are safe to discard, and that genuinely new work only ever happens in the follow-up message=... call. That assumption breaks down for self-driving workflows resumed mid-flight (e.g. a workflow with an executor self-edge queued to continue automatically, restored from a checkpoint taken while it was still actively running): the discard-only restore call can run the workflow all the way to completion internally — genuinely new output, not replayed history — and every event from that is thrown away. If input_messages is empty (the natural case for crash recovery, where there's no new user turn to deliver), the caller receives nothing, even though the workflow fully executed.
Repro context
Sample: samples/04-hosting/foundry-hosted-agents/responses/resilient_long_running_workflow — a workflow with a countdown executor that self-edges (add_edge(countdown, countdown)) to decrement a counter once per second via ctx.yield_output(). Using ResponsesHostServer(..., options=ResponsesServerOptions(resilient_background=True)) and crashing the process mid-countdown, then restarting and calling WorkflowAgent.run(stream=True, checkpoint_id=<last checkpoint>, checkpoint_storage=...) (no messages, per the resilience contract's recovery flow) resulted in the workflow silently completing the entire remaining countdown with zero forwarded events.
Current workaround (not proposed as the fix — flagging as evidence + stopgap)
agent_framework_foundry_hosting/_responses.py (ResponsesHostServer._resume_workflow_from_checkpoint) now bypasses WorkflowAgent.run() for this case entirely, driving agent.workflow.run(stream=True, checkpoint_id=..., checkpoint_storage=...) directly and converting each raw WorkflowEvent via WorkflowAgent._convert_workflow_event_to_agent_response_updates (a protected method, accessed cross-module with pyright: ignore[reportPrivateUsage]). This isn't a great long-term answer: it duplicates/depends on WorkflowAgent internals and doesn't address the underlying ambiguity for other callers of WorkflowAgent.run().
Questions to resolve
- What is the intended contract for
checkpoint_idalone (nomessage, noresponses) at theWorkflowAgentlevel?- "Restore to idle only, discard everything, caller must follow up with
message=...orresponses=..."? If so, shouldWorkflowAgent.run()guarantee the workflow is actually idle-and-waiting after the restore call, rather than potentially fully completing? - Or should message-less checkpoint resume forward every event produced while draining the checkpoint's own queued work (the behavior a resumed background/self-driving workflow needs)?
- "Restore to idle only, discard everything, caller must follow up with
- What is the correct behavior at
WorkflowAgentwhenmessageis supplied together withcheckpoint_id?Workflow.run()itself rejects this combination, butWorkflowAgent.run()effectively performs it via two sequential calls. Is that composition itself well-defined for all workflow states the restore can leave behind (idle, idle-with-pending-requests, or — as found here — freshly completed)? What should happen if the restore call already drives the workflow to a terminal state and amessageis still supplied? - Should
Workflow.statusdistinguish "idle, restored, ready for new input" from "idle, just completed via its own internal queued work during this very restore call"? Right now both look likeWorkflowRunState.IDLEto the caller. - Is accessing
WorkflowAgent._convert_workflow_event_to_agent_response_updatesfrom outside the class (as the current workaround does) an acceptable pattern for hosting layers that need raw event conversion, or should this be promoted to a supported/public API?
Suggested next steps
- Write out the full intended state-transition table for
(message, checkpoint_id, responses)combinations at both theWorkflowandWorkflowAgentlayers, including what happens to events produced during a restore-only phase. - Decide whether
WorkflowAgent.run(checkpoint_id=..., messages=None)should forward restore-phase events (opt-in or by default) instead of discarding them. - Add test coverage for "checkpoint being restored has its own queued in-flight work that completes the workflow during the restore phase" (currently untested — this is exactly the resilient-background crash-recovery scenario).
- Revisit the
_convert_workflow_event_to_agent_response_updatesprotected-access workaround inagent_framework_foundry_hostingonce the above is settled.
References
packages/core/agent_framework/_workflows/_agent.py—WorkflowAgent._run_core,_run_stream_impl,_convert_workflow_event_to_agent_response_updatespackages/core/agent_framework/_workflows/_workflow.py—Workflow.run,_validate_run_params,_run_corepackages/foundry_hosting/agent_framework_foundry_hosting/_responses.py—ResponsesHostServer._resume_workflow_from_checkpoint(workaround)- Sample:
samples/04-hosting/foundry-hosted-agents/responses/resilient_long_running_workflow
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
[-]Clarify and fix message/checkpoint_id/responses interaction semantics in Workflow.run() / WorkflowAgent.run()[/-][+]Python: Clarify and fix message/checkpoint_id/responses interaction semantics in Workflow.run() / WorkflowAgent.run()[/+]scott-lever commentedon Sep 21, 2026
One more consequence of the hosting workaround, observed on
agent-framework-foundry-hosting1.0.0b260918 withagent-framework-core1.19.0 andResponsesServerOptions(resilient_background=True).What happens. On first entry, hosting drives the agent through the public
WorkflowAgent.run(...). On crash recovery,ResponsesHostServer._resume_workflow_from_checkpointdrivesagent.workflow.run(stream=True, checkpoint_id=...)directly (for the reason described above:run(checkpoint_id=..., messages=None)discards the events produced while restoring).So the two entries take different paths through the application's agent. Anything an application does by subclassing
WorkflowAgentand overridingrun(), or otherwise wrapping the agent run, executes on first entry and is skipped on recovery. The workflow's executors still run, so the recovered run completes, but any per-run setup or post-processing the application attached at the agent boundary silently does not happen.Reproduction (minimal).
background=true, store=true) whose workflow takes long enough to interrupt.context.is_recoveryis true on re-entry).Observed:
agent.run enteredis printed for the first entry and not for the recovery. The recovered response still reachescompleted. With the hosting logger at DEBUG, the recovery logs "Found a workflow checkpoint … resuming from the latest checkpoint" or "Serving recovery request from workflow checkpoint …", both of which lead to_resume_workflow_from_checkpoint.Expected: recovery goes through the same public entry point as first entry, so application behaviour at the agent boundary is consistent. Fixing the restore-time event loss in
WorkflowAgent.run()described in this issue would allow hosting to callagent.run(checkpoint_id=...)again and remove the divergence.Happy to test a fix against a hosted deployment.
eavanvalkenburg commentedon Nov 3, 2026
/plan Scope a compatibility-safe resolution for the still-current restore-phase output loss, separately from new combined-input APIs and the maintainer-owned vNext removal in #8949.
Validated directly against current main b2c4de3 using a real workflow, real InMemoryCheckpointStorage, and fresh graphs:
Workflow.run(checkpoint_id=..., checkpoint_storage=...)executes that work and returns output["1", "0"].WorkflowAgent.run(checkpoint_id=..., checkpoint_storage=...)executes the same two steps but returns no messages.Both output-equivalence assertions failed; the direct-path controls passed. This is executed local API evidence, not a live Foundry or process-crash test. The current
_agent.py:523-545restore phase still discards the workflow's events, despite the public run documentation describing checkpoint resume and conversion of output events. The legacy hosting bypass avoids that loss but still skips a WorkflowAgent subclass's public run override, as the existing reporter describes; that subclass behavior was traced, not newly executed here.Please establish the smallest explicit state-transition contract for checkpoint-only restore, checkpoint plus new agent messages, and pending replies. Distinguish genuinely new output from queued work from any intentionally suppressed restoration replay. Compare a compatibility-preserving repair with an explicit supported native-migration boundary before changing defaults. Include streaming/non-streaming output, context-provider/history effects, pending requests, subclass dispatch, and queued-work-plus-new-input ordering.
Coordinate #7863, #8711, #8522 and #8949. This request does not accept a new
Workflow.run(message=..., checkpoint_id=...)shape, promote the private event converter, authorize immediate wrapper removal, or reopen a contributor PR. Preserve the current assignee and keep implementation readiness deferred until the intended behavior and compatibility boundary are agreed.eavanvalkenburg commentedon Nov 9, 2026
Python hosting release-scope clarification
The future checkpoint/restore contract redesign and new recovery APIs in this area are outside the current Foundry Hosting RC/stable scope and will likely align with a later 2.0 effort. They are not pending investigations that must finish before this hosting release.
This does not cancel the existing focused investigation or defer a compatible repair of the already-supported restore-phase output-loss behavior merely because checkpoints are involved. Keep current-contract correctness distinct from new combined-input semantics, Core vNext removal, and broader recovery guarantees. No new implementation design or change to stable Core behavior is approved by this scope note.