Skip to content

Support async/await orchestrators across the Python SDK packages #294

Open

Description

@andystaples

Summary

Support async def and await orchestrators across durabletask, durabletask.azuremanaged, and azure-functions-durable, including native two-argument and compatibility single-argument Functions orchestrators. Existing synchronous generator orchestrators remain supported.

Implementation: #293. Both authoring styles use the shared history/replay engine rather than separate provider runtimes.

Authoring model

from durabletask import task


async def sequence(ctx: task.OrchestrationContext, value: int) -> list[int]:
    first = await ctx.call_activity("increment", input=value, return_type=int)
    return await task.when_all([
        ctx.call_activity("increment", input=first, return_type=int),
        ctx.call_activity("increment", input=value, return_type=int),
    ])

Durable operations remain the persistence boundary. Async helpers rerun during replay; a separate durable child instance uses call_sub_orchestrator. Deterministic asyncio composition and sleeps/timeouts are limited to the supported contract. Nondeterministic I/O belongs in ordinary activities, not orchestrators.

Acceptance criteria

  • Native coroutine orchestrators work across all three packages and both Functions authoring styles.
  • Generator orchestrators remain supported, including mixed generator/coroutine durable parents and children.
  • Durable awaits preserve replay, result typing, retry, failure, payload, and entity-operation behavior.
  • Deterministic composition, durable clocks/timers, timeouts, local cancellation, and dispatch cleanup have a defined supported contract.
  • Documentation identifies unsupported APIs, framework-adapter responsibilities, and in-flight migration requirements without promising arbitrary async-library compatibility.

Local/virtual activities and automatic recording of arbitrary awaits are outside this feature. asyncio.wait and as_completed are not supported deterministic composition APIs.

Community feedback

Please share use cases and asyncio-library interoperability requirements in the discussion.

Activity

  1. berndverst commented on Oct 15, 2026

    @berndverst
    Member

    How do we avoid code duplication or breaking changes?

  2. andystaples commented on Oct 15, 2026

    @andystaples
    ContributorAuthor

    Follow-up exploration: virtual activities / recorded async calls

    Preserving a design idea from the discussion of draft #293: an explicit local/virtual activity or recorded-side-effect boundary could let an orchestrator obtain the result of a nondeterministic async operation without rerunning that operation during replay.

    The intended contract would be:

    • Give the operation a stable identity/position in the orchestration. On first execution, run it and durably record its serialized result or failure; on replay, return that result or raise that failure without invoking the operation again.
    • Make the boundary explicit and accept a callable factory, so history can be consulted before the operation starts. Automatically intercepting arbitrary await expressions may be too late: side effects may already have occurred.
    • The simplest implementation could lower an explicitly registered callable to an ordinary activity, reusing existing activity scheduling and history. A truly inline variant that avoids separate activity dispatch would need a durable marker or a carefully designed journal over existing durable messages. The current worker protocol has no dedicated arbitrary-result recording action; provider behavior across DTS and Functions would need separate design and validation.
    • Preserve at-least-once, not exactly-once, expectations. If an external operation succeeds and the worker crashes before recording the outcome, recovery may execute it again. External writes still need idempotency keys or deduplication.
    • Define timeout, retry, cancellation, payload, operation-versioning, and cleanup semantics. A recorded body must not schedule durable orchestration operations that would disappear when that body is skipped during replay.

    This is a separate exploration, not functionality implemented by the async/await prototype or an expansion of its supported await contract. Ordinary asyncio I/O remains unsupported inside orchestrators unless it is placed behind an appropriate durable boundary.

  3. andystaples commented on Oct 15, 2026

    @andystaples
    ContributorAuthor

    Bernd Verst (@berndverst) Check out the draft PR linked, it required no separate provider implementations and surprisingly little code duplication to get something functional. What remains to be seen is whether that approach continues to be viable as we address the remaining items. Breaking change risk with the current approach is also limited, given the minimally invasive edits needed to the replay logic itself but this would need to be carefully guarded - our test suite is a pretty good metric for this already. If we decide to continue down the draft PR's path, or adopt the additional functionality above, agreed, we'll need to be very careful about both before accepting anything

  4. tamirdresher commented on Oct 18, 2026

    @tamirdresher
    Contributor

    Big +1 on this andystaples / Bernd Verst (@berndverst) — while building the Durable Task / DTS extensions for LangGraph and Pydantic AI (pydantic_ai.durable_exec), I hit this exact gap and ended up having to build a coroutine-to-generator bridge myself.

    In both frameworks (and especially Pydantic AI and pydantic-graph), the entire agent loop and durability hook surface (BaseDurabilityCapability / RegisteredOperationBackend) is async def-only: every model call, tool call, MCP call, and human-in-the-loop wait (wait_for_external_event) is awaited inside await agent.run(...).

    Having Task be natively awaitable and supporting async def orchestrators in the SDK would let me drop a chunk of custom bridge code and let users compose durable agents directly with await instead of yield from.

    Two quick learnings from what I built that might be useful as we take this forward:

    1. Handling internal asyncio.gather / anyio task groups (parallel tool calls): When an LLM returns multiple tool calls in one turn, Pydantic AI fans them out internally using anyio.create_task_group() / asyncio.gather(), which calls asyncio.get_running_loop(). Pure coro.send()/throw() works great for sequential await ctx.call_activity(...), and to make framework-internal gather/TaskGroup work I paired it with a tiny deterministic single-thread AbstractEventLoop shim (call_soon microtask queue only, no threads or selector I/O) that drains ready tasks in the current turn and coalesces the pending Tasks into a single task.when_all(pending).
    2. Recorded async calls: Andy's follow-up comment above on explicit recorded-side-effect / virtual-activity boundaries maps 1:1 to how both my LangGraph and Pydantic AI extensions checkpoint individual model/tool boundaries during replay.

    Happy to share the trampoline / bridge code from the Pydantic AI & LangGraph prototypes if we want to compare notes or fold any of it in!

  5. andystaples commented on Oct 30, 2026

    @andystaples
    ContributorAuthor

    #293 adds native async def orchestrators across all three packages while retaining generator orchestrators and mixed-style durable children. The shared runtime supports ordered asyncio.gather, local tasks, durable sleeps/timeouts, and the documented asyncio/AnyIO task-group and cancellation paths.

    The async contract defines supported APIs, replay and migration requirements, and dispatch cleanup. Arbitrary I/O and automatic recording of ordinary async helpers are unsupported; nondeterministic work belongs in activities. asyncio.wait and as_completed are outside the deterministic composition contract. Framework adapters retain activity registration, serialization, and replay-stable metadata responsibilities.

  6. tamirdresher commented on Nov 1, 2026

    @tamirdresher
    Contributor

    Andy, this is great, thanks for putting it together so quickly. this also lines up with what the Pydantic maintainers asked for. Once we have this wired through, we should be able to remove most of the bridge and use a normal async orchestrator.

    I noticed the proof uses a custom DurableModel and ReplayClock rather than our AzureDurableTaskDurability capability. That makes sense for the spike. The next step is to run the actual capability on top of this so we can see what still needs adapting on the harness side.

    The main thing I want us to work through is parallel tool execution. Pydantic AI currently uses asyncio.wait with FIRST_COMPLETED and iterates the returned done set, while the proposed contract excludes wait and as_completed. Our bridge currently handles progress through when_any. Do you see a deterministic way to support that pattern in the SDK, or should we work with the Pydantic maintainers on changing it upstream? That will tell us whether we can keep this entirely within the harness.

    We also need a public way to get the orchestration context from inside the capability, and to confirm how switching from a generator orchestrator to async works across continue as new while existing histories still replay. We can sort out the minimum SDK version when we’re ready to wire it in.

    This looks like a good path to getting rid of the custom loop. Happy to take the Pydantic side and work through the remaining pieces together.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions