Repository navigation
Support async/await orchestrators across the Python SDK packages #294
Description
Summary
Add support for authoring durable orchestrators with async def and await across all three packages in this repository:
durabletask— core SDK.durabletask.azuremanaged— Azure Durable Task Scheduler provider.azure-functions-durable— Azure Functions provider, including both native two-argument and compatibility single-argument orchestrators.
This would be an additive authoring option. Existing synchronous generator orchestrators using yield would remain supported.
Draft prototype: #293. The spike demonstrates a bounded change to the shared replay engine rather than separate provider implementations. It is not production-ready and does not establish a final API contract or release commitment.
Current behavior
Orchestrators suspend on durable tasks using synchronous generators and yield. Asynchronous client APIs and async activities do not currently provide an async def orchestrator authoring model.
Supporting await would make orchestrator code more familiar to Python users and allow reusable async helpers that compose durable operations, without changing how orchestration history is persisted and replayed.
Proposed shape
Illustrative usage:
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)
pending: list[task.Task[int]] = [
ctx.call_activity("increment", input=first, return_type=int),
ctx.call_activity("increment", input=value, return_type=int),
]
return await task.when_all(pending)Functions compatibility-style orchestrators would similarly use await context.call_activity(...) and await context.task_all(...).
An async helper would run within its caller's orchestration. A separately tracked durable child instance would still require await ctx.call_sub_orchestrator(...).
Prototype findings
The draft makes durable tasks awaitable and drives native coroutines through the existing replay engine's send / throw protocol. Neither provider worker needs a runtime rewrite, and no protobuf or host-extension changes are required.
The prototype runs against the core gRPC backend, the DTS emulator, and real Azure Functions hosts. It exercises mixed generator/coroutine parents and children, activities, fan-out/fan-in, timers, external events, retries, and exception handling. Entity calls and locks have replay-level coverage. Both Functions authoring styles also run with strict payload typing.
Design points and remaining work
- Durable awaits, not general-purpose asyncio execution. Define the supported await contract and diagnostics. Use durable timers and durable task combinators rather than
asyncio.sleep,gather,create_task, orTaskGroup. Direct network/storage I/O remains activity work. - Guardrails have limits. Rejecting non-durable suspension cannot reliably detect already-completed foreign awaitables or side effects performed before suspension. Documentation must not imply that arbitrary async code is replay-safe.
- Coroutine lifecycle. Harden termination, cancellation, finalization,
finally, and async context-manager cleanup semantics, including nested helpers and pending durable tasks. - Replay and migration. Preserve existing generator behavior and document conversion of in-flight orchestrators. Switching from
yieldtoawaitmust preserve durable action order and payloads; basic action parity in a spike is not a blanket migration guarantee. - Public API and coverage. Finish callable/awaitable typing review, testing-helper support, examples, and broader coverage of pre-completed tasks, failures, races, long timers, entities, and provider behavior across the supported Python versions. Cloud deployment scenarios and the full Python-version matrix remain unvalidated by the spike.
- Release coordination. Publish the core capability first, then coordinate provider dependency minimums and releases. The draft intentionally does not change package versions or minimum dependencies.
Acceptance criteria
- Native coroutine orchestrators are supported across all three packages, including both Azure Functions authoring styles.
- Existing generator orchestrators remain supported, and generator/coroutine orchestrations can call one another as durable sub-orchestrations.
- Awaiting durable activities, timers, external events, task combinators, sub-orchestrations, and entity operations preserves replay, result typing, retry, and failure behavior.
- Coroutine lifecycle behavior and unsupported-await diagnostics are defined, documented, and covered by regression tests.
- Provider integration, replay/migration scenarios, and the supported Python-version matrix are covered before production support.
- Documentation clearly separates durable await syntax from arbitrary asyncio execution, and package releases enforce the required core/provider compatibility.
Community feedback
Please react with 👍 on this issue to vote for the feature. Share your use cases in the discussion below to help prioritize the work and shape the supported contract.
Useful context includes the SDK/provider you use, orchestration patterns you would simplify, reusable async helpers you would like to write, and any asyncio-library interoperability you expect. Small examples of existing yield-based code or desired await-based code are especially helpful.
Activity
berndverst commentedon Oct 15, 2026
How do we avoid code duplication or breaking changes?
andystaples commentedon Oct 15, 2026
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:
awaitexpressions may be too late: side effects may already have occurred.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.
andystaples commentedon Oct 15, 2026
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
tamirdresher commentedon Oct 18, 2026
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) isasync def-only: every model call, tool call, MCP call, and human-in-the-loop wait (wait_for_external_event) is awaited insideawait agent.run(...).Having
Taskbe natively awaitable and supportingasync deforchestrators in the SDK would let me drop a chunk of custom bridge code and let users compose durable agents directly withawaitinstead ofyield from.Two quick learnings from what I built that might be useful as we take this forward:
asyncio.gather/anyiotask groups (parallel tool calls): When an LLM returns multiple tool calls in one turn, Pydantic AI fans them out internally usinganyio.create_task_group()/asyncio.gather(), which callsasyncio.get_running_loop(). Purecoro.send()/throw()works great for sequentialawait ctx.call_activity(...), and to make framework-internalgather/TaskGroupwork I paired it with a tiny deterministic single-threadAbstractEventLoopshim (call_soonmicrotask queue only, no threads or selector I/O) that drains ready tasks in the current turn and coalesces the pendingTasks into a singletask.when_all(pending).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!
fix(worker): close orchestrator generators when a dispatch ends (#300)
andystaples commentedon Oct 30, 2026
The expanded implementation is now in the existing draft #293, as one coordinated release unit. This supersedes the spike's earlier guidance to avoid
asyncio.gather/sleep/ task groups. The supported contract now defines deterministic ordered gather, task/future scheduling, durable loop clock/deadlines/timers, sleep(0), timeouts, local cancellation, AnyIO task groups and cooperative cleanup. All three packages share the existing history/retry/payload/entity engine; generators and mixed-style durable children remain supported.Tamir's integration branch still points to reviewed snapshot
8e9ae67a492171b7839a94f72ff44238f03d597a. The framework proof executes actual Pydantic AIAgent.run/ parallel-tool AnyIO paths usingTestModel, including recorded tool failure and identical-history replay. It needs no adapter loop, trampoline or batch-wide join. Ordinary model/tool activities, registration, codecs and replay-stable framework metadata remain adapter responsibilities. No paid calls or changes to Tamir's repository were made.Boundaries remain explicit: no virtual activities, arbitrary-await recording, external I/O/threads/subprocesses in orchestrators, or blanket library compatibility.
asyncio.wait/as_completedare deliberately excluded from deterministic support because unordered inputs/results and tie ordering can change action order. Helpers rerun; activity side effects still need idempotency. Local waiter cancellation does not undo durable operations. Dispatch teardown is separate from workflow cancellation, cannot schedule durable work or replace the snapshotted response, and closes cooperative task/context/async-generator resources. In-flight migration still requires preserving the durable history contract.Validation now includes 55 focused runtime/framework tests on Python 3.14; 51 passed / 4 version-gated skips on 3.10; 420 Functions unit tests; final mixed-style DTS emulator probes; and full real Functions-host runs in both payload modes (86 passed / 6 skipped in each). Repository-standard strict Pyright checks pass for core/Azure Managed and Functions. The final full-core run passed (1,511 passed / 3 skipped). All checks on implementation head
8c050a7are green: Linux core/DTS and Windows async/replay/lifecycle Python 3.10–3.15, Functions units Python 3.13–3.15, real-host E2E in both payload modes, typing, lint and CodeQL. #293 links the runs and records the detailed evidence and limitations. No deployed Azure cloud validation is claimed.The PR remains draft. Versions and dependency minimums are unchanged. Nothing has been merged or released; provider dependency/version changes belong in the dedicated coordinated release PR after the complete capability is ready.
tamirdresher commentedon Nov 1, 2026
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.