Durable Agents with Pydantic AI

Pydantic-AI is one of the most popular libraries for writing agents, because it abstracts away all of the per-provider complexities. You can write your agent once and switch to OpenAI, Anthropic, Mistral, Deepseek, or whatever. It also has a simple Python API that makes it easy to wrap native logic.

Building an Agent From Scratch made our agent loop explicit: send messages, run tools, send tool results back. We recommend reading that first since it leaves nothing to the imagination.

If you're already super comfortable writing agent loops yourself, and that's what has brought you to this page - we see your game. Pydantic AI puts your loop behind a common Agent abstraction but it does not remove the durability question. If your process crashes you still have to worry about recovery, retries, and resumption.

Waymark takes care of that for you. This tutorial covers a realistic workflow implemented with pydantic-ai, including some deep cut typehinting with generics. This should be fun!

Define the agent

Define the Pydantic AI agent the same way you normally would: as a module-level object. Waymark actions run in the same Python interpreter as the rest of your code, so they can read global names.

from pydantic import BaseModel
from pydantic_ai import Agent

class SupportDeps(BaseModel):
    customer_id: str
    tenant: str = "default"

support_agent = Agent(
    "openai:gpt-5.5",
    deps_type=SupportDeps,
    output_type=str,
    instructions=(
        "You are a concise support agent. Use tools before making "
        "claims about orders."
    ),
)

Pydantic AI tools are native Python functions. When you decorate one with agent.tool, Pydantic AI reads the function signature for the parameter names and types, reads the docstring for the tool description, and sends that metadata with the model request. This way the model doesn't need a handwritten schema that can drift away from the code.

Tool registration also validates the model's requested arguments before your function runs. If order_id is typed as str, your tool receives a string. If it tries to pass an integer it will raise (or prompt the model to try again).

from pydantic_ai import RunContext

@support_agent.tool
async def lookup_order(ctx: RunContext[SupportDeps], order_id: str) -> str:
    """Look up the current status for a customer order."""
    return (
        f"Customer {ctx.deps.customer_id} has order {order_id}: "
        "packed and waiting for carrier pickup."
    )

SupportDeps is the main context that's passed across multiple tools that happen in a row. For that reason it makes the natural location for the data structure that we want to persist to the database with Waymark so we can pick it up later.

Type the state

Our manual dependencies make up one element of the agent lifecycle. The other are the past messages that are in the current conversation. These usually contain text messages, tool requests, and tool call results. This whole payload together composes your linear agent history.

Since pydantic-ai doesn't natively provide a clean serialization of these elements, we create a wrapper that will typehint these properly.

from typing import Generic, TypeVar

DepsT = TypeVar("DepsT", bound=BaseModel)
OutputT = TypeVar("OutputT")

class DurableAgentState(BaseModel, Generic[DepsT, OutputT]):
    deps: DepsT
    user_prompt: str | None = None
    messages_json: str | None = None
    is_done: bool = False
    final_output: OutputT | None = None

We are creating this in a generic way so it can apply to multiple future agents. DepsT and OutputT in particular keep this wrapper reusable. A support agent can store SupportDeps and return str, while another agent could use a different deps model and a structured Pydantic output.

We also want to track which agent is responsible for this conversation history. Since messages and tools are firmly tied to the underlying Agent implementation. We could keep track of a pointer to the global support_agent variable, but this wouldn't survive reloads of your environment. A critical issue for long term persistence.

Instead we would rather triage where this variable is defined (module/file) and use that as the permanent indicator. If we don't yet have it imported, we'll always be able to do so dynamically on the fly.

def _agent_ref(agent: Agent[DepsT, OutputT]) -> tuple[str, str]:
    # Hard-coded module paths and variable names are invisible to linters, so
    # derive the durable reference from the actual global agent object instead.
    for name, value in globals().items():
        if value is agent:
            return __name__, name
    raise ValueError("Agent must be assigned to a module-level variable")

We write a little helper to aid in this lookup. Unlike hard-coding the module and variable, this helper will let typehinters flag any issues in the future and won't break during a large project refactor and rename. Let's wire that into the agent state.

import importlib
from typing import Generic, Self, TypeVar, cast
 
from pydantic import Field, InstanceOf, model_validator
 
DepsT = TypeVar("DepsT", bound=BaseModel)
OutputT = TypeVar("OutputT")
 
class DurableAgentState(BaseModel, Generic[DepsT, OutputT]):
    agent: InstanceOf[Agent[DepsT, OutputT]] | None = Field(
        default=None,
        exclude=True,
        repr=False,
    )
    agent_module: str | None = None
    agent_variable: str | None = None
 
    deps: DepsT
    user_prompt: str | None = None
    messages_json: str | None = None
    is_done: bool = False
    final_output: OutputT | None = None
 
    @model_validator(mode="after")
    def resolve_agent_ref(self) -> Self:
        if self.agent is not None:
            if not isinstance(self.deps, self.agent.deps_type):
                raise TypeError("Agent deps_type does not match deps")
            self.agent_module, self.agent_variable = _agent_ref(self.agent)
            self.agent = None
 
        if self.agent_module is None or self.agent_variable is None:
            raise ValueError("Pass agent=... when creating agent state")
 
        return self
 
    def get_agent(self) -> Agent[DepsT, OutputT]:
        if self.agent_module is None or self.agent_variable is None:
            raise ValueError("Agent reference has not been resolved")
        module = importlib.import_module(self.agent_module)
        agent = getattr(module, self.agent_variable)
        return cast(Agent[DepsT, OutputT], agent)

From here we can create a state object with our agent, and get the agent back from the state object on another device. And everything stays strongly typed.

We're well on our way to wiring up our full agent to have background persistence. Now just to save the conversation history.

The message history needs its own translation layer. Pydantic AI messages are polymorphic: the list can contain model requests and model responses, and each message can contain different part types such as user prompts, tool calls, tool returns, and text output. A plain list[dict[str, object]] loses the Python types Pydantic AI expects on resume.

ModelMessagesTypeAdapter is Pydantic AI's adapter for that whole union of types. We can serialize and deserialize from the adapter to restore our expected typehints on resume.

from collections.abc import Sequence
from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter
 
class DurableAgentState(BaseModel, Generic[DepsT, OutputT]):
    agent: InstanceOf[Agent[DepsT, OutputT]] | None = Field(
        default=None,
        exclude=True,
        repr=False,
    )
    agent_module: str | None = None
    agent_variable: str | None = None
 
    deps: DepsT
    user_prompt: str | None = None
    messages_json: str | None = None
    is_done: bool = False
    final_output: OutputT | None = None
 
    @model_validator(mode="after")
    def resolve_agent_ref(self) -> Self:
        if self.agent is not None:
            if not isinstance(self.deps, self.agent.deps_type):
                raise TypeError("Agent deps_type does not match deps")
            self.agent_module, self.agent_variable = _agent_ref(self.agent)
            self.agent = None
 
        if self.agent_module is None or self.agent_variable is None:
            raise ValueError("Pass agent=... when creating agent state")
 
        return self
 
    def get_agent(self) -> Agent[DepsT, OutputT]:
        if self.agent_module is None or self.agent_variable is None:
            raise ValueError("Agent reference has not been resolved")
        module = importlib.import_module(self.agent_module)
        agent = getattr(module, self.agent_variable)
        return cast(Agent[DepsT, OutputT], agent)
 
    def get_messages(self) -> list[ModelMessage]:
        if not self.messages_json:
            return []
        return ModelMessagesTypeAdapter.validate_json(self.messages_json)
 
    def set_messages(self, messages: Sequence[ModelMessage]) -> None:
        self.messages_json = ModelMessagesTypeAdapter.dump_json(
            list(messages)
        ).decode("utf-8")

Own the loop

Before we implement the actions, sketch the workflow loop they need to serve. The workflow is going to keep handing one durable state payload from action to action until state.is_done flips to True.

roundtrip_agent will own one model turn. It either updates the state with final output or returns tool calls. handle_tool_call will run each requested tool and return results for the next roundtrip.

from waymark import Workflow


class AgentWorkflowBase(Workflow, Generic[DepsT, OutputT]):
    async def run_agent(
        self,
        state: DurableAgentState[DepsT, OutputT],
    ) -> DurableAgentState[DepsT, OutputT]:
        tool_results: list[AgentToolResult[DepsT, OutputT]] = []
        while True:
            roundtrip = await self.run_action(
                roundtrip_agent(state, tool_results=tool_results)
            )
            state = roundtrip.state
            tool_results = []

            if state.is_done:
                return state

            if not roundtrip.tool_calls:
                raise RuntimeError("Agent stopped without output or tool calls")

            tool_results = [
                await self.run_action(
                    handle_tool_call(
                        AgentToolCall(state=state, tool_call=tool_call)
                    )
                )
                for tool_call in roundtrip.tool_calls
            ]

The base workflow does not know anything about support tickets, Pydantic AI message objects, or provider return payloads. It just keeps asking for the next state transition until the durable state says the agent is done.

Initialize state

Before we start the agent, we need to provide the initial context for the agent to run. We start by creating our wrapper object that includes both the immediate state and the augmentary structures we'll use to store messages, the agent reference, and the like.

Note the @action decorator. This is a Waymark action because Waymark owns the state from here on out.

from waymark import action

@action
async def initialize_support_agent(
    customer_id: str,
    user_prompt: str,
) -> DurableAgentState[SupportDeps, str]:
    return DurableAgentState[SupportDeps, str](
        agent=support_agent,
        deps=SupportDeps(customer_id=customer_id),
        user_prompt=user_prompt,
    )

Defer tool calls

If we call support_agent.run(...) directly, Pydantic AI will run the model and the tools inside one action. That works, but it hides the interesting durable boundary. We want the model request to stop when it asks for tools, then let Waymark execute those tools as separate actions.

Pydantic AI already has that escape hatch: a tool can raise CallDeferred. When the run's output_type includes DeferredToolRequests, Pydantic AI returns the requested calls instead of treating the exception as a failure.

Ideally we want our tools to work normally when called from waymark. But we want them to always defer when called internally from pydantic-ai. We can provide this necessary conditional switch with a context manager.

from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar

_DEFER_TOOLS = ContextVar("_DEFER_TOOLS", default=False)

@contextmanager
def defer_tool_calls() -> Iterator[None]:
    token = _DEFER_TOOLS.set(True)
    try:
        yield
    finally:
        _DEFER_TOOLS.reset(token)

Now anything running within a with defer_tool_calls block will be able to skip the immediate tool call. We'll place all of our single turn .run() calls within this wrapper.

from collections.abc import Callable
from functools import wraps
from inspect import isawaitable
from typing import Any

from pydantic_ai import CallDeferred

ToolFuncT = TypeVar("ToolFuncT", bound=Callable[..., object])

def deferable_tool(
    agent: Agent[DepsT, OutputT],
) -> Callable[[ToolFuncT], ToolFuncT]:
    def decorator(fn: ToolFuncT) -> ToolFuncT:
        @wraps(fn)
        async def wrapper(*args: Any, **kwargs: Any) -> object:
            if _DEFER_TOOLS.get():
                raise CallDeferred()

            result = fn(*args, **kwargs)
            if isawaitable(result):
                return await result
            return result

        agent.tool(wrapper)
        return fn

    return decorator

Wrapping functions in our deferable_tool will provide a strict superset of the functionality that the normal tool call will. Pydantic AI still sees the function name, signature, and docstring, so it generates the same tool schema as it would have originally. Now let's switch the tool registration to use it.

from pydantic_ai import RunContext
 
@support_agent.tool
@deferable_tool(support_agent)
async def lookup_order(ctx: RunContext[SupportDeps], order_id: str) -> str:
    """Look up the current status for a customer order."""
    return (
        f"Customer {ctx.deps.customer_id} has order {order_id}: "
        "packed and waiting for carrier pickup."
    )

Our agent "roundtrip" is issuing a remote call to the LLM provider. We do this only for a single turn to get the new messages and requested tool calls. Then it's up to our durable waymark to handle them properly.

from pydantic import Field
from pydantic_ai import DeferredToolRequests, DeferredToolResults
from pydantic_ai.messages import ToolCallPart

class AgentRoundtrip(BaseModel, Generic[DepsT, OutputT]):
    state: DurableAgentState[DepsT, OutputT]
    tool_calls: list[ToolCallPart] = Field(default_factory=list)

class AgentToolResult(BaseModel, Generic[DepsT, OutputT]):
    tool_call: ToolCallPart
    content: object

@action
async def roundtrip_agent(
    state: DurableAgentState[DepsT, OutputT],
    tool_results: list[AgentToolResult[DepsT, OutputT]] | None = None,
) -> AgentRoundtrip[DepsT, OutputT]:
    next_state = state.model_copy(deep=True)
    history = next_state.get_messages()
    agent = next_state.get_agent()
    with defer_tool_calls():
        result = await agent.run(
            user_prompt=next_state.user_prompt if not history else None,
            deps=next_state.deps,
            message_history=history or None,
            deferred_tool_results=(
                DeferredToolResults(
                    calls={
                        result.tool_call.tool_call_id: result.content
                        for result in tool_results
                    }
                )
                if tool_results
                else None
            ),
            output_type=[agent.output_type, DeferredToolRequests],
        )
    next_state.set_messages(result.all_messages())

    if isinstance(result.output, DeferredToolRequests):
        return AgentRoundtrip(
            state=next_state,
            tool_calls=[*result.output.calls, *result.output.approvals],
        )

    next_state.is_done = True
    next_state.final_output = result.output
    return AgentRoundtrip(state=next_state)

After the first model call, every later roundtrip is driven by the stored Pydantic AI message history. When the previous step produced tool results, Pydantic AI's deferred_tool_results parameter turns them into the right tool-return messages before asking the model to continue.

Run tools durably

When the model asks for a tool, we want to run that tool in its own action. This is where the real support_agent comes back. We ask the agent's public toolsets for the registered tools, let Pydantic AI validate the arguments, and call the same function it would have called inside a normal agent run.

from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage

class AgentToolCall(BaseModel, Generic[DepsT, OutputT]):
    state: DurableAgentState[DepsT, OutputT]
    tool_call: ToolCallPart

@action
async def handle_tool_call(
    payload: AgentToolCall[DepsT, OutputT],
) -> AgentToolResult[DepsT, OutputT]:
    next_state = payload.state.model_copy(deep=True)
    agent = next_state.get_agent()

    ctx = RunContext(
        deps=next_state.deps,
        model=TestModel(),
        usage=RunUsage(),
        agent=agent,
        prompt=next_state.user_prompt,
        messages=next_state.get_messages(),
        tool_call_id=payload.tool_call.tool_call_id,
        tool_name=payload.tool_call.tool_name,
    )

    for toolset in agent.toolsets:
        tools = await toolset.get_tools(ctx)
        if payload.tool_call.tool_name not in tools:
            continue

        tool = tools[payload.tool_call.tool_name]
        content = await toolset.call_tool(
            payload.tool_call.tool_name,
            payload.tool_call.args_as_dict(),
            ctx,
            tool,
        )
        return AgentToolResult(
            tool_call=payload.tool_call,
            content=content,
        )

    raise ValueError(f"Unknown tool: {payload.tool_call.tool_name}")

For this tutorial, tools return plain JSONable values. Pydantic AI also supports richer return shapes for retry prompts and other tool results; those can go into the same DeferredToolResults object later.

TestModel() is only there because we are constructing RunContext ourselves. The real provider call already happened in roundtrip_agent. The next roundtrip will pair this content with the original tool_call_id through Pydantic AI's DeferredToolResults, so we do not need to hand-build ToolReturnPart.

Wire the workflow

The concrete workflow is finally boring, which is what we wanted. It does not thread ad hoc messages through a support-specific action. It starts the agent, runs the generic durable loop, and returns the final output.

from waymark import action, workflow

@action
async def require_support_output(
    state: DurableAgentState[SupportDeps, str],
) -> str:
    if state.final_output is None:
        raise ValueError("Agent finished without output")
    return state.final_output

@workflow
class DurableSupportAgentWorkflow(AgentWorkflowBase[SupportDeps, str]):
    async def run(self, customer_id: str, user_prompt: str) -> str:
        state = await self.run_action(
            initialize_support_agent(
                customer_id=customer_id,
                user_prompt=user_prompt,
            )
        )
        state = await self.run_agent(state)
        return await self.run_action(require_support_output(state))

Production

At this point you could flesh out your customer support agent to talk to your database, have addition tool calls, or retro fit it for some other purpose. But the nice part of all the work that we've done in this tutorial (coupled with the magic of Python generics) is you'll be able to reuse 95% of this same logic for any agent you build in the future.

Just implement a new Agent, a fresh workflow that inherits from AgentWorkflowBase and you're off to the races.