Azure Durable Task
AzureDurableTaskDurability lets agents survive worker restarts with
Durable Task and Azure Durable Task Scheduler.
You write the orchestrator; model requests, tool calls and durable capability operations run as
activities, and completed results come from orchestration history when the orchestrator replays.
Outside an orchestration, the agent runs normally. Streamed model responses are buffered inside
the activity, not streamed to the client. Azure Durable Functions also uses Durable Task, but this
capability does not register orchestrators or activities with Azure Functions.
While Pydantic AI Harness is on 0.x releases, the API may change between minor releases; when it does, deprecation warnings and release-note migration guidance tell you (or your agent) exactly how to upgrade. See the version policy.
pip install "pydantic-ai-harness[azure-durable-task]" "pydantic-ai-slim[openai]"
uv add "pydantic-ai-harness[azure-durable-task]" "pydantic-ai-slim[openai]"
Start the Durable Task Scheduler emulator:
docker run --rm --name dts-emulator -p 127.0.0.1:8080:8080 -p 127.0.0.1:8082:8082 mcr.microsoft.com/dts/dts-emulator:v0.0.9
The emulator uses task hub default; its dashboard is at http://localhost:8082.
With the emulator running and OPENAI_API_KEY set:
from collections.abc import Generator
from durabletask import task
from durabletask.client import TaskHubGrpcClient
from durabletask.worker import TaskHubGrpcWorker
from pydantic_ai import Agent
from pydantic_ai_harness.azure_durable_task import AzureDurableTaskDurability, run_durable
agent = Agent('openai:gpt-6.1-sol', name='support', capabilities=[AzureDurableTaskDurability()])
def support(ctx: task.OrchestrationContext, prompt: str) -> Generator[task.Task[object], object, str]:
result = yield from run_durable(ctx, lambda: agent.run(prompt))
return result.output
worker = TaskHubGrpcWorker(
host_address='localhost:8080', metadata=[('taskhub', 'default')], secure_channel=False
)
worker.add_orchestrator(support)
durability = AzureDurableTaskDurability.from_agent(agent)
assert durability is not None
with durability.register(worker), worker, TaskHubGrpcClient(
host_address='localhost:8080', metadata=[('taskhub', 'default')], secure_channel=False
) as client:
worker.start()
instance_id = client.schedule_new_orchestration(support, input='Explain durable execution.')
state = client.wait_for_orchestration_completion(instance_id, timeout=60)
assert state is not None
state.raise_if_failed()
print(state.get_output())
Call yield from run_durable(ctx, lambda: agent.run(...)) wherever the agent belongs in your orchestrator.
Agent binds a copy of its capabilities, so get the bound one with from_agent. Enter its
register(worker) before starting the worker and leave it after the worker stops; it registers
the agent’s activities and runs them. Register orchestrators and use clients with the SDK as usual.
Clients can run in other processes and don’t need the agent.
The orchestrator returns JSON-compatible output. For a Pydantic output model, return
result.output.model_dump(mode='json').
Swap the emulator worker and client for the Azure-managed ones; the orchestrator and register(worker) stay the same.
The azure-durable-task extra installs them and azure-identity:
from azure.identity import DefaultAzureCredential
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
credential = DefaultAzureCredential()
endpoint = '<scheduler>.<region>.durabletask.io'
worker = DurableTaskSchedulerWorker(host_address=endpoint, taskhub='<hub>', token_credential=credential)
client = DurableTaskSchedulerClient(host_address=endpoint, taskhub='<hub>', token_credential=credential)
Grant the worker’s identity the
Durable Task Data Contributor
role on the scheduler or task hub. Use the same taskhub on workers and clients, and a separate hub per environment.
Every time an activity finishes, the SDK runs your orchestrator again from the start and replays completed results from history. This shapes how you write agents for it:
- Activities run at least once. A worker can fail after a tool’s side effect but before its
result is recorded. Make tools and event handlers idempotent;
ctx.run_idandctx.tool_call_idmake good idempotency keys. - Orchestrator-side code runs on every replay. Hooks, instruction functions, output validators and
inline tools must be deterministic and do no network, file or thread I/O. Put I/O in tools or a
capability’s
@durable_operationmethod. - Names are persisted. Activity names come from the agent’s
name, toolset IDs and tool names. Don’t rename them while orchestrations that use them are still running. - Replay cost grows with run length. Each replay re-runs the agent up to its latest step, so orchestrator CPU grows with the square of the number of model and tool steps. Keep very long conversations in separate orchestrations.
- Instrumented runs repeat spans. Pydantic AI spans created in orchestrator code are emitted again on every replay, so one run shows its agent run and model request spans several times.
- Activity errors change type. An exception raised in a model request or tool reaches the
orchestrator as
durabletask.task.TaskFailedError.UserErrorkeeps its type. - Activities on a worker share one event loop. A blocking call in an async tool or event handler stalls every other activity on that worker.
Dependencies must be JSON-serializable: dataclasses, Pydantic models and JSON values. Activities rebuild
them from the agent’s deps_type. Create clients and connections inside the tool or @durable_operation
that uses them.
from collections.abc import Generator
from dataclasses import dataclass
from durabletask import task
from pydantic_ai import Agent, RunContext
from pydantic_ai_harness.azure_durable_task import AzureDurableTaskDurability, run_durable
@dataclass
class Dependencies:
tenant: str
agent = Agent(
'openai:gpt-6.1-sol',
name='tenant_support',
deps_type=Dependencies,
capabilities=[AzureDurableTaskDurability()],
)
@agent.tool
def tenant(ctx: RunContext[Dependencies]) -> str:
return ctx.deps.tenant
def support(
ctx: task.OrchestrationContext, payload: dict[str, str]
) -> Generator[task.Task[object], object, str]:
result = yield from run_durable(
ctx, lambda: agent.run(payload['prompt'], deps=Dependencies(tenant=payload['tenant']))
)
return result.output
Inside activities, live run fields such as ctx.messages and ctx.prompt are unavailable and raise
UserError, and tools cannot emit run events.
Approvals belong to your orchestrator. Include DeferredToolRequests
in the output type, wait for an external event, then resume with the message history, usage and
DeferredToolResults. No activity runs while the orchestration waits.
from collections.abc import Generator
from durabletask.client import TaskHubGrpcClient
from durabletask.task import OrchestrationContext, Task
from pydantic import TypeAdapter
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, UserError
from pydantic_ai_harness.azure_durable_task import AzureDurableTaskDurability, run_durable
agent = Agent(
'openai:gpt-6.1-sol',
name='review',
output_type=[str, DeferredToolRequests],
capabilities=[AzureDurableTaskDurability()],
)
@agent.tool_plain(requires_approval=True)
def approve_payment(amount: int) -> str:
return f'Paid {amount}'
def review(ctx: OrchestrationContext, prompt: str) -> Generator[Task[object], object, str]:
result = yield from run_durable(ctx, lambda: agent.run(prompt))
while isinstance(result.output, DeferredToolRequests):
requests = result.output
event_name = f'review:{ctx.new_uuid()}'
ctx.set_custom_status({
'event': event_name,
'requests': TypeAdapter(DeferredToolRequests).dump_python(requests, mode='json'),
})
payload = yield ctx.wait_for_external_event(event_name)
approvals = TypeAdapter(dict[str, bool]).validate_python(payload, strict=True)
if set(approvals) != {call.tool_call_id for call in requests.approvals}:
raise UserError('Decisions must match the reviewed tool-call IDs exactly.')
result = yield from run_durable(
ctx,
lambda: agent.run(
message_history=result.all_messages(),
deferred_tool_results=DeferredToolResults(approvals=dict(approvals)),
usage=result.usage,
conversation_id=result.conversation_id,
),
)
ctx.set_custom_status(None)
return result.output
def submit_review(client: TaskHubGrpcClient, instance_id: str, event: str, decisions: dict[str, bool]) -> None:
"""Send the decisions for the review the reviewer saw, if it is still pending."""
state = client.get_orchestration_state(instance_id)
pending = state.get_custom_status() if state is not None else None
if not isinstance(pending, dict) or pending.get('event') != event:
raise UserError('This review is no longer pending.')
client.raise_orchestration_event(instance_id, event, data=decisions)
The orchestrator publishes the pending requests and a fresh event name in its custom status.
Show the requests to the reviewer, then call submit_review with the event they saw and a decision
for every approval: True to approve, False to deny. A new event name per pause means a late
decision can’t answer a later review. Durable Task matches event names case-insensitively, so keep
tool-call IDs in the payload, as the example does, not in event names.
For argument overrides, denial messages and externally executed calls, see Deferred Tools and external tool execution.
Pass native durabletask.task.RetryPolicy values to set activity retries per role:
from datetime import timedelta
from durabletask.task import RetryPolicy
from pydantic_ai_harness.azure_durable_task import AzureDurableTaskDurability
durability = AzureDurableTaskDurability(
model_options=RetryPolicy(first_retry_interval=timedelta(seconds=1), max_number_of_attempts=3),
tool_options=RetryPolicy(first_retry_interval=timedelta(seconds=1), max_number_of_attempts=2),
)
event_options and capability_options cover event handler activities and other capabilities’
@durable_operation methods. ModelRetry and deferred calls
are agent control flow, not activity failures. UserError and serialization failures are not retried.
resolve_tool_options(operation_id, tool, tool_name) sets a policy per tool: return a RetryPolicy,
None to keep tool_options, or False to run a function tool inline in the orchestrator. A function
tool can also opt out with metadata={'azure_durable_task': False}. Inline tools must be deterministic
and do no I/O. MCP and dynamic tools always run as activities.
name= sets the activity name prefix (the agent’s name by default), models= registers extra models
by ID for agent.run(model=...), and event_stream_handler= runs a handler as activities.