Skip to content

Python: Clarify and fix message/checkpoint_id/responses interaction semantics in Workflow.run() / WorkflowAgent.run() #7677

Open

Description

@TaoChenOSU

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 (raises ValueError)
  • message + checkpoint_id: mutually exclusive (raises ValueError)
  • 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 event

This 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

  1. What is the intended contract for checkpoint_id alone (no message, no responses) at the WorkflowAgent level?
    • "Restore to idle only, discard everything, caller must follow up with message=... or responses=..."? If so, should WorkflowAgent.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)?
  2. What is the correct behavior at WorkflowAgent when message is supplied together with checkpoint_id? Workflow.run() itself rejects this combination, but WorkflowAgent.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 a message is still supplied?
  3. Should Workflow.status distinguish "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 like WorkflowRunState.IDLE to the caller.
  4. Is accessing WorkflowAgent._convert_workflow_event_to_agent_response_updates from 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 the Workflow and WorkflowAgent layers, 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_updates protected-access workaround in agent_framework_foundry_hosting once the above is settled.

References

  • packages/core/agent_framework/_workflows/_agent.py — WorkflowAgent._run_core, _run_stream_impl, _convert_workflow_event_to_agent_response_updates
  • packages/core/agent_framework/_workflows/_workflow.py — Workflow.run, _validate_run_params, _run_core
  • packages/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

Activity

  1. changed the title [-]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()[/+] on Aug 15, 2026
  2. scott-lever commented on Sep 21, 2026

    @scott-lever

    One more consequence of the hosting workaround, observed on agent-framework-foundry-hosting 1.0.0b260918 with agent-framework-core 1.19.0 and ResponsesServerOptions(resilient_background=True).

    What happens. On first entry, hosting drives the agent through the public WorkflowAgent.run(...). On crash recovery, ResponsesHostServer._resume_workflow_from_checkpoint drives agent.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 WorkflowAgent and overriding run(), 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).

    class RecordingWorkflowAgent(WorkflowAgent):
        def run(self, messages=None, **kwargs):
            print("agent.run entered")          # or set a flag / bind per-run state
            return super().run(messages, **kwargs)
    
    server = ResponsesHostServer(
        RecordingWorkflowAgent(workflow=build_workflow(), name="demo"),
        options=ResponsesServerOptions(resilient_background=True),
    )
    1. Start a background response (background=true, store=true) whose workflow takes long enough to interrupt.
    2. Stop the process after at least one superstep checkpoint exists.
    3. Let hosting recover the response (context.is_recovery is true on re-entry).

    Observed: agent.run entered is printed for the first entry and not for the recovery. The recovered response still reaches completed. 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 call agent.run(checkpoint_id=...) again and remove the divergence.

    Happy to test a fix against a hosted deployment.

  3. eavanvalkenburg commented on Nov 3, 2026

    @eavanvalkenburg
    Member

    /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:

    • A checkpoint after the first countdown superstep contains queued work for values 1 and 0.
    • Direct 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.
    • The streaming wrapper likewise executes both steps but yields no output updates.

    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-545 restore 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.

  4. eavanvalkenburg commented on Nov 9, 2026

    @eavanvalkenburg
    Member

    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.

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

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: PythonworkflowsUsage: [Issues, PRs], Target: Workflows

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions