Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
Harness Compat / harness compat (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

4804 lines
204 KiB
Python

from __future__ import annotations
import asyncio
import os
import re
from collections.abc import AsyncIterable, AsyncIterator, Generator, Iterator, Sequence
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Literal, cast
from unittest.mock import patch
import pytest
from pydantic import BaseModel
from pydantic_ai import (
AbstractToolset,
Agent,
AgentRunResultEvent,
AgentStreamEvent,
BinaryContent,
BinaryImage,
CodeExecutionTool,
DocumentUrl,
ExternalToolset,
FinalResultEvent,
FunctionToolCallEvent,
FunctionToolResultEvent,
FunctionToolset,
ModelMessage,
ModelRequest,
ModelResponse,
ModelSettings,
MultiModalContent,
OutputToolCallEvent,
OutputToolResultEvent,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
RetryPromptPart,
RunContext,
RunUsage,
TextContent,
TextPart,
TextPartDelta,
ToolCallPart,
ToolCallPartDelta,
ToolReturn,
ToolReturnPart,
UserContent,
UserPromptPart,
WebSearchTool,
WebSearchUserLocation,
)
from pydantic_ai.capabilities import Instrumentation, NativeTool, ProcessHistory
from pydantic_ai.direct import model_request_stream
from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry, UserError
from pydantic_ai.messages import UploadedFile
from pydantic_ai.models import (
Model,
ModelRequestParameters,
create_async_http_client,
infer_model,
infer_model_profile,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.models.instrumented import InstrumentationSettings
from pydantic_ai.models.test import TestModel
from pydantic_ai.native_tools import SUPPORTED_NATIVE_TOOLS, AbstractNativeTool
from pydantic_ai.profiles import DEFAULT_PROFILE
from pydantic_ai.run import AgentRunResult
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolDefinition
from pydantic_ai.usage import RequestUsage, UsageLimits
from pydantic_graph import GraphBuilder, StepContext
from pydantic_graph.join import reduce_list_append
from ._inline_snapshot import snapshot
try:
import temporalio.api.common.v1
from temporalio import workflow
from temporalio.activity import _Definition as ActivityDefinition # pyright: ignore[reportPrivateUsage]
from temporalio.client import Client, WorkflowFailureError, WorkflowHistory
from temporalio.common import RetryPolicy
from temporalio.contrib.opentelemetry import TracingInterceptor
from temporalio.contrib.pydantic import PydanticPayloadConverter, pydantic_data_converter
from temporalio.converter import DataConverter, DefaultPayloadConverter, PayloadCodec
from temporalio.exceptions import ApplicationError
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Replayer, UnsandboxedWorkflowRunner, Worker
from temporalio.workflow import ActivityConfig
from pydantic_ai.durable_exec.temporal import (
AgentPlugin,
LogfirePlugin,
PydanticAIPlugin,
PydanticAIWorkflow,
TemporalAgent,
)
from pydantic_ai.durable_exec.temporal._function_toolset import TemporalFunctionToolset
from pydantic_ai.durable_exec.temporal._mcp_toolset import TemporalMCPToolset
from pydantic_ai.durable_exec.temporal._model import TemporalModel
from pydantic_ai.durable_exec.temporal._run_context import TemporalRunContext
except ImportError: # pragma: lax no cover
pytest.skip('temporal not installed', allow_module_level=True)
import sys
if sys.version_info >= (3, 14):
pytest.skip(
'temporalio sandbox is incompatible with Python 3.14: '
'sandbox module state accumulates across validation cycles causing import failures after ~22 workflows '
'(remove when https://github.com/temporalio/sdk-python/issues/1326 closes)',
allow_module_level=True,
)
try:
import logfire
from logfire import Logfire
from logfire._internal.tracer import _ProxyTracer # pyright: ignore[reportPrivateUsage]
from logfire.testing import CaptureLogfire
from opentelemetry.trace import ProxyTracer
except ImportError: # pragma: lax no cover
pytest.skip('logfire not installed', allow_module_level=True)
try:
from fastmcp.client.transports import StdioTransport
from pydantic_ai.mcp import MCPToolset
except ImportError: # pragma: lax no cover
pytest.skip('mcp not installed', allow_module_level=True)
try:
from pydantic_ai.models.openai import OpenAIChatModel, OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
except ImportError: # pragma: lax no cover
pytest.skip('openai not installed', allow_module_level=True)
with workflow.unsafe.imports_passed_through():
# Workaround for a race condition when running `logfire.info` inside an activity with attributes to serialize and pandas importable:
# AttributeError: partially initialized module 'pandas' has no attribute '_pandas_parser_CAPI' (most likely due to a circular import)
try:
import pandas # pyright: ignore[reportUnusedImport] # noqa: F401
except ImportError: # pragma: lax no cover
pass
# https://github.com/temporalio/sdk-python/blob/3244f8bffebee05e0e7efefb1240a75039903dda/tests/test_client.py#L112C1-L113C1
from mcp.client.session import ClientSession
from mcp.types import ClientRequest
from ._inline_snapshot import snapshot
# Loads `vcr`, which Temporal doesn't like without passing through the import
from .conftest import IsDatetime, IsStr, message
pytestmark = [
pytest.mark.anyio,
pytest.mark.vcr,
pytest.mark.xdist_group(name='temporal'),
]
# We need to use a custom cached HTTP client here as the default one created for OpenAIProvider will be closed automatically
# at the end of each test, but we need this one to live longer.
http_client = create_async_http_client()
# Scoped to `session` rather than `module`: the `http_client` and the module-level agents that
# capture it are constructed at import time, so they must outlive a single module entry. This is a
# sync fixture so it doesn't force AnyIO to reuse a session-level event loop for all Temporal async
# fixtures; the `temporal_env` teardown can make that loop unusable for later tests.
@pytest.fixture(autouse=True, scope='session')
def close_cached_httpx_client() -> Iterator[None]:
try:
yield
finally:
asyncio.run(http_client.aclose())
# `LogfirePlugin` calls `logfire.instrument_pydantic_ai()`, so we need to make sure this doesn't bleed into other tests.
@pytest.fixture(autouse=True, scope='module')
def uninstrument_pydantic_ai() -> Iterator[None]:
try:
yield
finally:
Agent.instrument_all(False)
@contextmanager
def workflow_raises(exc_type: type[Exception], exc_message: str) -> Generator[None]:
"""Helper for asserting that a Temporal workflow fails with the expected error."""
with pytest.raises(WorkflowFailureError) as exc_info:
yield
assert isinstance(exc_info.value.__cause__, ApplicationError)
assert exc_info.value.__cause__.type == exc_type.__name__
assert exc_info.value.__cause__.message == exc_message
TEMPORAL_PORT = 7243
TASK_QUEUE = 'pydantic-ai-agent-task-queue'
BASE_ACTIVITY_CONFIG = ActivityConfig(
start_to_close_timeout=timedelta(seconds=60),
retry_policy=RetryPolicy(maximum_attempts=1),
)
@pytest.fixture(scope='module')
async def temporal_env() -> AsyncIterator[WorkflowEnvironment]:
async with await WorkflowEnvironment.start_local( # pyright: ignore[reportUnknownMemberType]
port=TEMPORAL_PORT,
ui=True,
dev_server_extra_args=['--dynamic-config-value', 'frontend.enableServerVersionCheck=false'],
) as env:
yield env
@pytest.fixture
async def client(temporal_env: WorkflowEnvironment) -> Client:
return await Client.connect(
f'localhost:{TEMPORAL_PORT}',
plugins=[PydanticAIPlugin()],
)
@pytest.fixture
async def client_with_logfire(temporal_env: WorkflowEnvironment) -> Client:
return await Client.connect(
f'localhost:{TEMPORAL_PORT}',
plugins=[PydanticAIPlugin(), LogfirePlugin()],
)
# Can't use the `openai_api_key` fixture here because the workflow needs to be defined at the top level of the file.
model = OpenAIChatModel(
'gpt-4o',
provider=OpenAIProvider(
api_key=os.getenv('OPENAI_API_KEY', 'mock-api-key'),
http_client=http_client,
),
)
simple_agent = Agent(model, name='simple_agent')
# This needs to be done before the `TemporalAgent` is bound to the workflow.
simple_temporal_agent = TemporalAgent(simple_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class SimpleAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt)
return result.output
async def test_simple_agent_run_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflow],
plugins=[AgentPlugin(simple_temporal_agent)],
):
output = await client.execute_workflow(
SimpleAgentWorkflow.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
class Deps(BaseModel):
country: str
async def event_stream_handler(
ctx: RunContext[Deps],
stream: AsyncIterable[AgentStreamEvent],
):
logfire.info(f'{ctx.run_step=}')
async for event in stream:
logfire.info('event', event=event)
async def get_country(ctx: RunContext[Deps]) -> str:
return ctx.deps.country
class WeatherArgs(BaseModel):
city: str
def get_weather(args: WeatherArgs) -> str:
if args.city == 'Mexico City':
return 'sunny'
else:
return 'unknown' # pragma: no cover
@dataclass
class Answer:
label: str
answer: str
@dataclass
class Response:
answers: list[Answer]
complex_agent = Agent(
model,
deps_type=Deps,
output_type=Response,
toolsets=[
FunctionToolset[Deps](tools=[get_country], id='country'),
MCPToolset(StdioTransport(command='python', args=['-m', 'tests.mcp_server']), id='mcp', init_timeout=20),
ExternalToolset(tool_defs=[ToolDefinition(name='external')], id='external'),
],
tools=[get_weather],
name='complex_agent',
)
# This needs to be done before the `TemporalAgent` is bound to the workflow.
complex_temporal_agent = TemporalAgent(
complex_agent,
event_stream_handler=event_stream_handler,
activity_config=BASE_ACTIVITY_CONFIG,
model_activity_config=ActivityConfig(start_to_close_timeout=timedelta(seconds=90)),
toolset_activity_config={
'country': ActivityConfig(start_to_close_timeout=timedelta(seconds=120)),
},
tool_activity_config={
'country': {
'get_country': False,
},
'mcp': {
'get_product_name': ActivityConfig(start_to_close_timeout=timedelta(seconds=150)),
},
'<agent>': {
'get_weather': ActivityConfig(start_to_close_timeout=timedelta(seconds=180)),
},
},
)
@workflow.defn
class ComplexAgentWorkflow:
@workflow.run
async def run(self, prompt: str, deps: Deps) -> Response:
result = await complex_temporal_agent.run(prompt, deps=deps)
return result.output
@dataclass
class BasicSpan:
content: str
children: list[BasicSpan] = field(default_factory=list['BasicSpan'])
parent_id: int | None = field(repr=False, compare=False, default=None)
async def test_complex_agent_run_in_workflow(
allow_model_requests: None, client_with_logfire: Client, capfire: CaptureLogfire
):
async with Worker(
client_with_logfire,
task_queue=TASK_QUEUE,
workflows=[ComplexAgentWorkflow],
plugins=[AgentPlugin(complex_temporal_agent)],
):
output = await client_with_logfire.execute_workflow(
ComplexAgentWorkflow.run,
args=[
'Tell me: the capital of the country; the weather there; the product name',
Deps(country='Mexico'),
],
id=ComplexAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot(
Response(
answers=[
Answer(label='Capital of the country', answer='Mexico City'),
Answer(label='Weather in the capital', answer='Sunny'),
Answer(label='Product Name', answer='Pydantic AI'),
]
)
)
exporter = capfire.exporter
spans = exporter.exported_spans_as_dict()
basic_spans_by_id = {
span['context']['span_id']: BasicSpan(
parent_id=span['parent']['span_id'] if span['parent'] else None,
content=attributes.get('event') or attributes['logfire.msg'],
)
for span in spans
if (attributes := span.get('attributes'))
}
root_span = None
for basic_span in basic_spans_by_id.values():
if basic_span.parent_id is None:
root_span = basic_span
else:
parent_id = basic_span.parent_id
parent_span = basic_spans_by_id[parent_id]
parent_span.children.append(basic_span)
def _normalize_json_spans(span: BasicSpan) -> None:
"""Normalize non-deterministic tool_call_ids in JSON event spans."""
import json
for child in span.children:
if child.content.startswith('{'):
try:
data = json.loads(child.content)
_strip_volatile_fields(data)
child.content = json.dumps(data)
except json.JSONDecodeError:
pass
_normalize_json_spans(child)
def _strip_volatile_fields(obj: dict[str, Any]) -> None:
for k, v in obj.items():
if k in ('tool_call_id', 'timestamp'):
obj[k] = None
elif isinstance(v, dict):
_strip_volatile_fields(cast(dict[str, Any], v))
assert root_span is not None
_normalize_json_spans(root_span)
assert root_span == snapshot(
BasicSpan(
content='StartWorkflow:ComplexAgentWorkflow',
children=[
BasicSpan(content='RunWorkflow:ComplexAgentWorkflow'),
BasicSpan(
content='complex_agent run',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__mcp_server__mcp__get_tools',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__mcp_server__mcp__get_tools',
children=[BasicSpan(content='tools/list')],
)
],
),
BasicSpan(
content='chat gpt-4o',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(content='ctx.run_step=1'),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "get_country", "args": "", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "previous_part_kind": null, "event_kind": "part_start"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "{}", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "get_country", "args": "{}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "next_part_kind": "tool-call", "event_kind": "part_end"}'
),
BasicSpan(
content='{"index": 1, "part": {"tool_name": "get_product_name", "args": "", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "previous_part_kind": "tool-call", "event_kind": "part_start"}'
),
BasicSpan(
content='{"index": 1, "delta": {"tool_name_delta": null, "args_delta": "{}", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 1, "part": {"tool_name": "get_product_name", "args": "{}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "next_part_kind": null, "event_kind": "part_end"}'
),
],
)
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=1'),
BasicSpan(
content='{"part": {"tool_name": "get_country", "args": "{}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "args_valid": true, "event_kind": "function_tool_call"}'
),
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=1'),
BasicSpan(
content='{"part": {"tool_name": "get_product_name", "args": "{}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "args_valid": true, "event_kind": "function_tool_call"}'
),
],
)
],
),
BasicSpan(content='running tool: get_country'),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=1'),
BasicSpan(
content='{"part": {"tool_name": "get_country", "content": "Mexico", "tool_call_id": null, "tool_kind": null, "metadata": null, "timestamp": null, "outcome": "success", "part_kind": "tool-return"}, "content": null, "event_kind": "function_tool_result"}'
),
],
)
],
),
BasicSpan(
content='running tool: get_product_name',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__mcp_server__mcp__call_tool',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__mcp_server__mcp__call_tool',
children=[BasicSpan(content='tools/call get_product_name')],
)
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=1'),
BasicSpan(
content='{"part": {"tool_name": "get_product_name", "content": "Pydantic AI", "tool_call_id": null, "tool_kind": null, "metadata": null, "timestamp": null, "outcome": "success", "part_kind": "tool-return"}, "content": null, "event_kind": "function_tool_result"}'
),
],
)
],
),
BasicSpan(
content='chat gpt-4o',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(content='ctx.run_step=2'),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "get_weather", "args": "", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "previous_part_kind": null, "event_kind": "part_start"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "{\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "city", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Mexico", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " City", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\"}", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "get_weather", "args": "{\\"city\\":\\"Mexico City\\"}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "next_part_kind": null, "event_kind": "part_end"}'
),
],
)
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=2'),
BasicSpan(
content='{"part": {"tool_name": "get_weather", "args": "{\\"city\\":\\"Mexico City\\"}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "args_valid": true, "event_kind": "function_tool_call"}'
),
],
)
],
),
BasicSpan(
content='running tool: get_weather',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__toolset__<agent>__call_tool',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__toolset__<agent>__call_tool'
)
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=2'),
BasicSpan(
content='{"part": {"tool_name": "get_weather", "content": "sunny", "tool_call_id": null, "tool_kind": null, "metadata": null, "timestamp": null, "outcome": "success", "part_kind": "tool-return"}, "content": null, "event_kind": "function_tool_result"}'
),
],
)
],
),
BasicSpan(
content='chat gpt-4o',
children=[
BasicSpan(
content='StartActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__model_request_stream',
children=[
BasicSpan(content='ctx.run_step=3'),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "final_result", "args": "", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "previous_part_kind": null, "event_kind": "part_start"}'
),
BasicSpan(
content='{"tool_name": "final_result", "tool_call_id": null, "event_kind": "final_result"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "{\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "answers", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":[", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "{\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "label", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Capital", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " of", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " the", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " country", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\",\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "answer", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Mexico", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " City", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\"},{\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "label", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Weather", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " in", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " the", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " capital", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\",\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "answer", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Sunny", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\"},{\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "label", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "Product", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " Name", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\",\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "answer", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\":\\"", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "P", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "yd", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "antic", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": " AI", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "\\"}", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "delta": {"tool_name_delta": null, "args_delta": "]}", "tool_call_id": null, "provider_name": null, "provider_details": null, "part_delta_kind": "tool_call"}, "event_kind": "part_delta"}'
),
BasicSpan(
content='{"index": 0, "part": {"tool_name": "final_result", "args": "{\\"answers\\":[{\\"label\\":\\"Capital of the country\\",\\"answer\\":\\"Mexico City\\"},{\\"label\\":\\"Weather in the capital\\",\\"answer\\":\\"Sunny\\"},{\\"label\\":\\"Product Name\\",\\"answer\\":\\"Pydantic AI\\"}]}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "next_part_kind": null, "event_kind": "part_end"}'
),
],
)
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=3'),
BasicSpan(
content='{"part": {"tool_name": "final_result", "args": "{\\"answers\\":[{\\"label\\":\\"Capital of the country\\",\\"answer\\":\\"Mexico City\\"},{\\"label\\":\\"Weather in the capital\\",\\"answer\\":\\"Sunny\\"},{\\"label\\":\\"Product Name\\",\\"answer\\":\\"Pydantic AI\\"}]}", "tool_call_id": null, "tool_kind": null, "id": null, "provider_name": null, "provider_details": null, "part_kind": "tool-call"}, "args_valid": true, "event_kind": "output_tool_call"}'
),
],
)
],
),
BasicSpan(
content='StartActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(
content='RunActivity:agent__complex_agent__event_stream_handler',
children=[
BasicSpan(content='ctx.run_step=3'),
BasicSpan(
content='{"part": {"tool_name": "final_result", "content": "Final result processed.", "tool_call_id": null, "tool_kind": null, "metadata": null, "timestamp": null, "outcome": "success", "part_kind": "tool-return"}, "event_kind": "output_tool_result"}'
),
],
)
],
),
],
),
BasicSpan(content='CompleteWorkflow:ComplexAgentWorkflow'),
],
)
)
async def test_mcp_tools_cached_across_activities(allow_model_requests: None, client: Client):
"""Verify that MCP tool caching reduces server round-trips across activities.
The complex agent makes 3 model requests, each preceded by a get_tools activity.
With the run-scoped tool-defs cache, only the first get_tools activity actually runs
(opening an MCP connection and calling `tools/list`). Subsequent get_tools calls return
the run-cached tool definitions without scheduling an activity at all.
"""
original_send_request = ClientSession.send_request
methods_called: list[str] = []
async def tracking_send_request(self_: ClientSession, request: ClientRequest, *args: Any, **kwargs: Any) -> Any:
methods_called.append(request.root.method)
return await original_send_request(self_, request, *args, **kwargs)
with patch.object(ClientSession, 'send_request', tracking_send_request):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ComplexAgentWorkflow],
plugins=[AgentPlugin(complex_temporal_agent)],
):
coro = client.execute_workflow(
ComplexAgentWorkflow.run,
args=[
'Tell me: the capital of the country; the weather there; the product name',
Deps(country='Mexico'),
],
id=f'{ComplexAgentWorkflow.__name__}_cache_test',
task_queue=TASK_QUEUE,
)
output = await coro
assert output is not None
# 3 get_tools calls are made, but only 1 results in an actual tools/list MCP request
assert methods_called.count('tools/list') == 1
# call_tool should still make a request each time (not cached)
assert methods_called.count('tools/call') == 1
def _call_mcp_then_finish(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
"""Two model steps: call an MCP tool on the first request, return text on the second.
Two model requests means `get_tools` is invoked twice on the MCP toolset within one run,
so the run-scoped cache (and the activity it does or doesn't schedule each step) is exercised.
"""
tool_returned = any(isinstance(part, ToolReturnPart) for message in messages for part in message.parts)
if tool_returned:
return ModelResponse(parts=[TextPart('done')])
return ModelResponse(parts=[ToolCallPart('get_weather_forecast', {'location': 'Mexico City'})])
# A holder lets the replay step swap in a freshly-constructed (cold-process) instance,
# reproducing the worker-restart scenario from #5875.
mcp_replay_holder: dict[str, TemporalAgent[None, str]] = {}
def _make_mcp_replay_agent(cache_tools: bool = True) -> TemporalAgent[None, str]:
agent = Agent(
FunctionModel(_call_mcp_then_finish),
name='mcp_replay_agent',
toolsets=[
MCPToolset(
StdioTransport(command='python', args=['-m', 'tests.mcp_server']),
id='mcp',
init_timeout=20,
cache_tools=cache_tools,
)
],
)
return TemporalAgent(agent, activity_config=BASE_ACTIVITY_CONFIG)
mcp_replay_holder['agent'] = _make_mcp_replay_agent()
@workflow.defn
class MCPReplayWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await mcp_replay_holder['agent'].run(prompt)
return result.output
def _scheduled_get_tools_count(history: WorkflowHistory) -> int:
return sum(
1
for event in history.events
if event.HasField('activity_task_scheduled_event_attributes')
and event.activity_task_scheduled_event_attributes.activity_type.name.endswith('__get_tools')
)
async def test_temporal_mcp_get_tools_replay_deterministic(allow_model_requests: None, client: Client):
"""#5875 regression: `get_tools` activity scheduling must be replay-deterministic.
The tool-defs cache must not let shared-process cache warmth decide whether a workflow
emits a `get_tools` activity command — otherwise a history recorded on a warm worker fails
replay on a cold one (and vice versa) with `TMPRL1100`. Each run must independently record
exactly one `get_tools` activity: the #4331 within-run win (N calls collapse to one activity)
without leaking cache state across the replay boundary.
"""
warm = _make_mcp_replay_agent()
mcp_replay_holder['agent'] = warm
histories: list[WorkflowHistory] = []
# Unsandboxed so the module-level instance (and its cache) is shared across both runs,
# exactly as a long-running worker process shares it in production — the condition under
# which #5875 records a warm run with no `get_tools` event.
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MCPReplayWorkflow],
activities=warm.temporal_activities,
workflow_runner=UnsandboxedWorkflowRunner(),
):
for i in range(2):
wf_id = f'{MCPReplayWorkflow.__name__}_{i}'
await client.execute_workflow(MCPReplayWorkflow.run, args=['hello'], id=wf_id, task_queue=TASK_QUEUE)
histories.append(await client.get_workflow_handle(wf_id).fetch_history())
h1, h2 = histories
# Within a run, the run-scoped cache collapses the per-step `get_tools` calls to one activity...
assert _scheduled_get_tools_count(h1) == 1
# ...and each run records it independently — run 2 does not inherit run 1's warm process cache.
assert _scheduled_get_tools_count(h2) == 1
def replayer() -> Replayer:
return Replayer(
workflows=[MCPReplayWorkflow],
workflow_runner=UnsandboxedWorkflowRunner(),
data_converter=pydantic_data_converter,
)
try:
# Direction 1: cold-recorded history (run 1) replayed after the process cache warmed
# (the same-process sticky-cache-eviction trigger). Holder still points at the warm instance.
await replayer().replay_workflow(h1)
# Direction 2: warm-recorded history (run 2) replayed on a freshly-constructed cold instance
# (the worker-restart trigger).
mcp_replay_holder['agent'] = _make_mcp_replay_agent()
await replayer().replay_workflow(h2)
finally:
mcp_replay_holder['agent'] = warm
async def test_temporal_mcp_get_tools_not_cached_when_disabled(allow_model_requests: None, client: Client):
"""With `cache_tools=False`, `get_tools` is scheduled for every model request (no run cache).
The complementary case to the run-scoped cache: each of the two model requests records its own
`get_tools` activity, so disabling the cache stays replay-deterministic by always scheduling.
"""
agent = _make_mcp_replay_agent(cache_tools=False)
mcp_replay_holder['agent'] = agent
try:
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MCPReplayWorkflow],
activities=agent.temporal_activities,
workflow_runner=UnsandboxedWorkflowRunner(),
):
wf_id = f'{MCPReplayWorkflow.__name__}_no_cache'
await client.execute_workflow(MCPReplayWorkflow.run, args=['hello'], id=wf_id, task_queue=TASK_QUEUE)
history = await client.get_workflow_handle(wf_id).fetch_history()
assert _scheduled_get_tools_count(history) == 2
finally:
mcp_replay_holder['agent'] = _make_mcp_replay_agent()
async def test_complex_agent_run(allow_model_requests: None):
events: list[AgentStreamEvent] = []
async def event_stream_handler(
ctx: RunContext[Deps],
stream: AsyncIterable[AgentStreamEvent],
):
async for event in stream:
events.append(event)
with complex_temporal_agent.override(deps=Deps(country='Mexico')):
result = await complex_temporal_agent.run(
'Tell me: the capital of the country; the weather there; the product name',
deps=Deps(country='The Netherlands'),
event_stream_handler=event_stream_handler,
)
assert result.output == snapshot(
Response(
answers=[
Answer(label='Capital', answer='The capital of Mexico is Mexico City.'),
Answer(label='Weather', answer='The weather in Mexico City is currently sunny.'),
Answer(label='Product Name', answer='The product name is Pydantic AI.'),
]
)
)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='get_country', args='', tool_call_id='call_q2UyBRP7eXNTzAoR8lEhjc9Z'),
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='{}', tool_call_id='call_q2UyBRP7eXNTzAoR8lEhjc9Z')
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='get_country', args='{}', tool_call_id='call_q2UyBRP7eXNTzAoR8lEhjc9Z'),
next_part_kind='tool-call',
),
PartStartEvent(
index=1,
part=ToolCallPart(tool_name='get_product_name', args='', tool_call_id='call_b51ijcpFkDiTQG1bQzsrmtW5'),
previous_part_kind='tool-call',
),
PartDeltaEvent(
index=1, delta=ToolCallPartDelta(args_delta='{}', tool_call_id='call_b51ijcpFkDiTQG1bQzsrmtW5')
),
PartEndEvent(
index=1,
part=ToolCallPart(
tool_name='get_product_name', args='{}', tool_call_id='call_b51ijcpFkDiTQG1bQzsrmtW5'
),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='get_country', args='{}', tool_call_id='call_q2UyBRP7eXNTzAoR8lEhjc9Z'),
args_valid=True,
),
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='get_product_name', args='{}', tool_call_id='call_b51ijcpFkDiTQG1bQzsrmtW5'
),
args_valid=True,
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_country',
content='Mexico',
tool_call_id='call_q2UyBRP7eXNTzAoR8lEhjc9Z',
timestamp=IsDatetime(),
)
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_product_name',
content='Pydantic AI',
tool_call_id='call_b51ijcpFkDiTQG1bQzsrmtW5',
timestamp=IsDatetime(),
)
),
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='get_weather', args='', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv'),
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='{"', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='city', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='Mexico', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' City', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='"}', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv')
),
PartEndEvent(
index=0,
part=ToolCallPart(
tool_name='get_weather', args='{"city":"Mexico City"}', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv'
),
),
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='get_weather', args='{"city":"Mexico City"}', tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv'
),
args_valid=True,
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_weather',
content='sunny',
tool_call_id='call_LwxJUB9KppVyogRRLQsamRJv',
timestamp=IsDatetime(),
)
),
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='final_result', args='', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn'),
),
FinalResultEvent(tool_name='final_result', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn'),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='{"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='answers', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":[', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='{"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='label', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='Capital', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='","', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='answer', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='The', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' capital', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' of', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' Mexico', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' is', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' Mexico', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' City', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='."', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='},{"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='label', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='Weather', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='","', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='answer', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='The', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' weather', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' in', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' Mexico', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' City', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' is', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' currently', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' sunny', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='."', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='},{"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='label', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='Product', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' Name', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='","', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='answer', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='":"', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='The', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' product', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' name', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' is', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' P', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='yd', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='antic', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=' AI', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='."', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta='}', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartDeltaEvent(
index=0, delta=ToolCallPartDelta(args_delta=']}', tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn')
),
PartEndEvent(
index=0,
part=ToolCallPart(
tool_name='final_result',
args='{"answers":[{"label":"Capital","answer":"The capital of Mexico is Mexico City."},{"label":"Weather","answer":"The weather in Mexico City is currently sunny."},{"label":"Product Name","answer":"The product name is Pydantic AI."}]}',
tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn',
),
),
OutputToolCallEvent(
part=ToolCallPart(
tool_name='final_result',
args='{"answers":[{"label":"Capital","answer":"The capital of Mexico is Mexico City."},{"label":"Weather","answer":"The weather in Mexico City is currently sunny."},{"label":"Product Name","answer":"The product name is Pydantic AI."}]}',
tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn',
),
args_valid=True,
),
OutputToolResultEvent(
part=ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call_CCGIWaMeYWmxOQ91orkmTvzn',
timestamp=IsDatetime(),
)
),
]
)
async def test_multiple_agents(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflow, ComplexAgentWorkflow],
plugins=[AgentPlugin(simple_temporal_agent), AgentPlugin(complex_temporal_agent)],
):
output = await client.execute_workflow(
SimpleAgentWorkflow.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
output = await client.execute_workflow(
ComplexAgentWorkflow.run,
args=[
'Tell me: the capital of the country; the weather there; the product name',
Deps(country='Mexico'),
],
id=ComplexAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot(
Response(
answers=[
Answer(label='Capital of the Country', answer='Mexico City'),
Answer(label='Weather in Mexico City', answer='Sunny'),
Answer(label='Product Name', answer='Pydantic AI'),
]
)
)
async def test_agent_name_collision(allow_model_requests: None, client: Client):
with pytest.raises(ValueError, match='More than one activity named agent__simple_agent__event_stream_handler'):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflow],
plugins=[AgentPlugin(simple_temporal_agent), AgentPlugin(simple_temporal_agent)],
):
pass
async def test_agent_without_name():
with pytest.raises(
UserError,
match=re.escape(
"An agent needs to have a unique `name` in order to be used with Temporal. The name will be used to identify the agent's activities within the workflow."
),
):
TemporalAgent(Agent())
async def test_agent_without_model():
with pytest.raises(
UserError,
match=re.escape(
"The wrapped agent's `model` or the TemporalAgent's `models` parameter must provide at least one Model instance to be used with Temporal. Models cannot be set at agent run time."
),
):
TemporalAgent(Agent(name='test_agent'))
async def test_old_temporalize_toolset_func_compat():
"""Old 6-arg temporalize_toolset_func implementations still work."""
from pydantic_ai.durable_exec.temporal._toolset import temporalize_toolset
def old_style_func(
toolset: Any, prefix: Any, config: Any, tool_config: Any, deps_type: Any, run_context_type: Any
) -> Any:
return temporalize_toolset(toolset, prefix, config, tool_config, deps_type, run_context_type)
TemporalAgent(
Agent(model=model, name='old_compat_agent'),
activity_config=BASE_ACTIVITY_CONFIG,
temporalize_toolset_func=old_style_func, # pyright: ignore[reportArgumentType]
)
async def test_toolset_without_id():
with pytest.raises(
UserError,
match=re.escape(
"Toolsets that are 'leaves' (i.e. those that implement their own tool listing and calling) need to have a unique `id` in order to be used with Temporal. The ID will be used to identify the toolset's activities within the workflow."
),
):
TemporalAgent(Agent(model=model, name='test_agent', toolsets=[FunctionToolset()]))
# --- DynamicToolset / @agent.toolset tests ---
@dataclass
class DynamicToolsetDeps:
user_name: str
dynamic_toolset_agent = Agent(TestModel(), name='dynamic_toolset_agent', deps_type=DynamicToolsetDeps)
@dynamic_toolset_agent.toolset(id='my_dynamic_tools')
def my_dynamic_toolset(ctx: RunContext[DynamicToolsetDeps]) -> FunctionToolset[DynamicToolsetDeps]:
toolset = FunctionToolset[DynamicToolsetDeps](id='dynamic_weather')
@toolset.tool_plain
def get_dynamic_weather(location: str) -> str:
"""Get the weather for a location."""
user = ctx.deps.user_name
return f'Weather in {location} for {user}: sunny.'
return toolset
dynamic_toolset_temporal_agent = TemporalAgent(
dynamic_toolset_agent,
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class DynamicToolsetAgentWorkflow:
@workflow.run
async def run(self, prompt: str, deps: DynamicToolsetDeps) -> str:
result = await dynamic_toolset_temporal_agent.run(prompt, deps=deps)
return result.output
async def test_dynamic_toolset_in_workflow(client: Client):
"""Test that @agent.toolset works correctly in a Temporal workflow."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[DynamicToolsetAgentWorkflow],
plugins=[AgentPlugin(dynamic_toolset_temporal_agent)],
):
output = await client.execute_workflow(
DynamicToolsetAgentWorkflow.run,
args=['Get the weather for London', DynamicToolsetDeps(user_name='Alice')],
id='test_dynamic_toolset_workflow',
task_queue=TASK_QUEUE,
)
assert output == snapshot('{"get_dynamic_weather":"Weather in a for Alice: sunny."}')
async def test_dynamic_toolset_outside_workflow():
"""Test that the dynamic toolset agent works correctly outside of a workflow."""
result = await dynamic_toolset_temporal_agent.run(
'Get the weather for Paris', deps=DynamicToolsetDeps(user_name='Bob')
)
assert result.output == snapshot('{"get_dynamic_weather":"Weather in a for Bob: sunny."}')
# --- DynamicToolset.get_instructions test (issue #5282) ---
# A dynamic toolset whose resolved toolset implements `get_instructions()` must contribute those
# instructions under `TemporalAgent`, resolved inside an activity like `get_tools`.
def _echo_instructions(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
request = message(messages, ModelRequest, index=-1)
return ModelResponse(parts=[TextPart(request.instructions or '<no instructions>')])
dynamic_instructions_agent = Agent(FunctionModel(_echo_instructions), name='dynamic_instructions_agent')
@dynamic_instructions_agent.toolset(id='dynamic_instruction_toolset', per_run_step=False)
def dynamic_instruction_toolset(ctx: RunContext[object]) -> AbstractToolset[object]:
# A toolset that only contributes instructions, no tools.
return FunctionToolset(instructions='SENTINEL_INSTRUCTION_FROM_DYNAMIC_TOOLSET', id='instruction-only-toolset')
dynamic_instructions_temporal_agent = TemporalAgent(
dynamic_instructions_agent,
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class DynamicInstructionsAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await dynamic_instructions_temporal_agent.run(prompt)
return result.output
async def test_dynamic_toolset_instructions_in_workflow(allow_model_requests: None, client: Client):
"""A dynamic toolset's `get_instructions()` reaches the model under `TemporalAgent` (issue #5282).
The model echoes the request's instructions back as its output, so the sentinel in the output
proves the resolved dynamic toolset's instructions were collected via the new activity.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[DynamicInstructionsAgentWorkflow],
plugins=[AgentPlugin(dynamic_instructions_temporal_agent)],
):
output = await client.execute_workflow(
DynamicInstructionsAgentWorkflow.run,
args=['hello'],
id='test_dynamic_toolset_instructions_workflow',
task_queue=TASK_QUEUE,
)
assert output == snapshot('SENTINEL_INSTRUCTION_FROM_DYNAMIC_TOOLSET')
def test_dynamic_toolset_temporal_activities():
"""`TemporalDynamicToolset` collects instructions inside `get_tools`, so it has no separate `get_instructions` activity."""
activity_names = {
ActivityDefinition.must_from_callable(activity).name # pyright: ignore[reportUnknownMemberType]
for activity in dynamic_instructions_temporal_agent.temporal_activities
}
prefix = 'agent__dynamic_instructions_agent__dynamic_toolset__dynamic_instruction_toolset'
assert {f'{prefix}__get_tools', f'{prefix}__call_tool'} <= activity_names
assert f'{prefix}__get_instructions' not in activity_names
# --- DynamicToolset instructions refresh across run steps (issue #5282 follow-up) ---
# The per-run instructions cache is written by `get_tools` and read by `get_instructions` each
# step; this guards against it serving a stale step-1 value on a later step.
def _echo_instructions_after_tool_call(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# First request: call a tool to force a second model-request step.
# Second request (carrying the tool return): echo the instructions, which by then must
# reflect the current step — proving the cache is repopulated by `get_tools` each step.
request = message(messages, ModelRequest, index=-1)
if any(isinstance(part, ToolReturnPart) for part in request.parts):
return ModelResponse(parts=[TextPart(request.instructions or '<no instructions>')])
return ModelResponse(parts=[ToolCallPart('noop', {})])
multi_step_instructions_agent = Agent(
FunctionModel(_echo_instructions_after_tool_call), name='multi_step_instructions_agent'
)
@multi_step_instructions_agent.toolset(id='multi_step_instruction_toolset')
def multi_step_instruction_toolset(ctx: RunContext[object]) -> AbstractToolset[object]:
# Instructions encode the run step, so a stale step-1 cached value read at step 2 would
# surface as the wrong sentinel in the model output.
toolset = FunctionToolset[object](
instructions=f'INSTRUCTIONS_FOR_STEP_{ctx.run_step}', id='step-instruction-toolset'
)
@toolset.tool_plain
def noop() -> str:
return 'noop'
return toolset
multi_step_instructions_temporal_agent = TemporalAgent(
multi_step_instructions_agent,
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class MultiStepInstructionsAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await multi_step_instructions_temporal_agent.run(prompt)
return result.output
async def test_dynamic_toolset_instructions_refresh_across_steps_in_workflow(
allow_model_requests: None, client: Client
):
"""A dynamic toolset's instructions are refreshed each run step under `TemporalAgent` (issue #5282).
The toolset encodes the run step in its instructions; the model calls a tool on the first request to
force a second step, then echoes the instructions on the second request. The output being the step-2
sentinel (not the step-1 one) proves `get_tools` repopulates the per-run instructions cache each step
rather than serving a stale step-1 value.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiStepInstructionsAgentWorkflow],
plugins=[AgentPlugin(multi_step_instructions_temporal_agent)],
):
output = await client.execute_workflow(
MultiStepInstructionsAgentWorkflow.run,
args=['hello'],
id='test_dynamic_toolset_instructions_refresh_workflow',
task_queue=TASK_QUEUE,
)
assert output == snapshot('INSTRUCTIONS_FOR_STEP_2')
# --- DynamicToolset instructions replay determinism (issue #5282) ---
# The per-run instructions cache lives on a `for_run` copy of the wrapper rather than on the
# process-shared, module-level instance. A history recorded on one worker must replay on a
# freshly-constructed (cold) one, proving the `for_run` override reconstructs identically and
# introduces no `TMPRL1100` nondeterminism.
# A holder lets the replay step swap in a freshly-constructed (cold-process) instance.
dynamic_instructions_replay_holder: dict[str, TemporalAgent[object, str]] = {}
def _make_dynamic_instructions_replay_agent() -> TemporalAgent[object, str]:
agent = Agent(FunctionModel(_echo_instructions_after_tool_call), name='dynamic_instructions_replay_agent')
@agent.toolset(id='replay_instruction_toolset')
def _replay_toolset(ctx: RunContext[object]) -> AbstractToolset[object]:
toolset = FunctionToolset[object](
instructions=f'INSTRUCTIONS_FOR_STEP_{ctx.run_step}', id='step-instruction-toolset'
)
@toolset.tool_plain
def noop() -> str:
return 'noop'
return toolset
return TemporalAgent(agent, activity_config=BASE_ACTIVITY_CONFIG)
dynamic_instructions_replay_holder['agent'] = _make_dynamic_instructions_replay_agent()
@workflow.defn
class DynamicInstructionsReplayWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await dynamic_instructions_replay_holder['agent'].run(prompt)
return result.output
async def test_dynamic_toolset_instructions_replay_deterministic(allow_model_requests: None, client: Client):
"""The per-run `for_run` instructions cache must be replay-deterministic (issue #5282).
Instructions resolved by `get_tools` are held on a per-run `for_run` copy of the wrapper, not
on the module-level instance. This records a two-step workflow (instructions differ per step)
and replays its history on a freshly-constructed cold instance — the worker-restart scenario —
asserting no nondeterminism, so the `for_run` copy is reconstructed identically on replay.
"""
warm = _make_dynamic_instructions_replay_agent()
dynamic_instructions_replay_holder['agent'] = warm
# Unsandboxed so the module-level instance is shared across the run exactly as a long-running
# worker process shares it in production.
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[DynamicInstructionsReplayWorkflow],
activities=warm.temporal_activities,
workflow_runner=UnsandboxedWorkflowRunner(),
):
wf_id = DynamicInstructionsReplayWorkflow.__name__
output = await client.execute_workflow(
DynamicInstructionsReplayWorkflow.run, args=['hello'], id=wf_id, task_queue=TASK_QUEUE
)
assert output == snapshot('INSTRUCTIONS_FOR_STEP_2')
history = await client.get_workflow_handle(wf_id).fetch_history()
# Warm-recorded history replayed on a freshly-constructed cold instance (worker-restart trigger).
dynamic_instructions_replay_holder['agent'] = _make_dynamic_instructions_replay_agent()
try:
await Replayer(
workflows=[DynamicInstructionsReplayWorkflow],
workflow_runner=UnsandboxedWorkflowRunner(),
data_converter=pydantic_data_converter,
).replay_workflow(history)
finally:
dynamic_instructions_replay_holder['agent'] = warm
# --- MCP-based DynamicToolset test ---
# Tests that @agent.toolset returning an MCPToolset works with Temporal workflows.
# Uses an HTTP-based MCP server rather than subprocess-based since the subprocess transports
# don't play nicely with Temporal's sandbox.
mcptoolset_dynamic_toolset_agent = Agent(model, name='mcptoolset_dynamic_toolset_agent')
@mcptoolset_dynamic_toolset_agent.toolset(id='mcptoolset_dynamic')
def my_mcptoolset_dynamic_toolset(ctx: RunContext) -> MCPToolset:
"""Dynamic toolset that returns an `MCPToolset` — exercises lifecycle + `TemporalMCPToolset`."""
return MCPToolset('https://mcp.deepwiki.com/mcp')
mcptoolset_dynamic_toolset_temporal_agent = TemporalAgent(
mcptoolset_dynamic_toolset_agent,
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class MCPToolsetDynamicToolsetAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await mcptoolset_dynamic_toolset_temporal_agent.run(prompt)
return result.output
async def test_mcptoolset_dynamic_toolset_in_workflow(allow_model_requests: None, client: Client):
"""`@agent.toolset` returning an `MCPToolset` works in a Temporal workflow.
Verifies the `MCPToolset`/`TemporalMCPToolset` pair handles `DynamicToolset` lifecycle
(entering/exiting the context manager around each activity invocation).
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MCPToolsetDynamicToolsetAgentWorkflow],
plugins=[AgentPlugin(mcptoolset_dynamic_toolset_temporal_agent)],
):
output = await client.execute_workflow(
MCPToolsetDynamicToolsetAgentWorkflow.run,
args=['Can you tell me about the pydantic/pydantic-ai repo? Keep it short.'],
id='test_mcptoolset_dynamic_toolset_workflow',
task_queue=TASK_QUEUE,
)
assert 'pydantic' in output.lower() or 'agent' in output.lower()
# Regression test for the workflow-sandbox passthrough list (`_workflow_runner` in
# `durable_exec/temporal/__init__.py`). A `gateway/` model named by string is constructed lazily via
# `infer_model` *inside* the workflow, so the provider's SDK is imported and its client built under
# the `SandboxedWorkflowRunner`. Provider SDKs touch the filesystem/env at construction time, which
# the sandbox forbids unless the SDK module is passed through. Every other test builds its model at
# module scope (outside the sandbox), so this seam was previously uncovered. Construction-only (no
# model request) keeps it deterministic.
@workflow.defn
class ConstructModelInWorkflow:
@workflow.run
async def run(self, model_name: str) -> str:
# We assert only that construction succeeds — no request is made.
return type(infer_model(model_name)).__name__
@pytest.mark.parametrize(
('model_name', 'expected_model_class'),
[
# Only `gateway/` providers exercise the sandbox: they import their SDK lazily inside
# `gateway_provider()`, so the import and client construction run *inside* the workflow. Direct
# providers (e.g. `anthropic:`) import their SDK at module level, which rides Temporal's
# transitive passthrough of `pydantic_ai` and never trips — so they give no regression coverage.
#
# The reported regression: `gateway/anthropic:` in-workflow tripped the `anthropic` SDK's
# `Path.home()` access.
pytest.param('gateway/anthropic:claude-sonnet-4-6', 'AnthropicModel', id='gateway-anthropic'),
# Canary: OpenAI needs no passthrough today; turns red here (not in a user's workflow) if a
# future SDK release makes a restricted call (e.g. reads `~/...`) during construction.
pytest.param('gateway/openai-chat:gpt-5', 'OpenAIChatModel', id='gateway-openai'),
# Positive coverage of the `google.auth` (+`certifi`) passthrough: `google-genai` lazily
# imports `google.auth` during construction, which the sandbox flags without it.
pytest.param('gateway/google-cloud:gemini-2.5-pro', 'GoogleModel', id='gateway-google'),
],
)
async def test_model_construction_in_workflow_passes_sandbox(
model_name: str,
expected_model_class: str,
client: Client,
monkeypatch: pytest.MonkeyPatch,
):
# Dummy credentials suffice since no request is made. The gateway key must encode a region
# (`pylf_v<n>_<region>_...`) so the base URL can be inferred.
monkeypatch.setenv('PYDANTIC_AI_GATEWAY_API_KEY', 'pylf_v1_us_0123456789abcdef')
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ConstructModelInWorkflow],
# A sandbox violation surfaces as a workflow *task* failure, which Temporal retries forever
# by default — so a regression would hang rather than fail. Promote any in-workflow exception
# (e.g. `RestrictedWorkflowAccessError`) to a workflow failure so it surfaces immediately.
workflow_failure_exception_types=[Exception],
):
# Without the SDK passed through this fails with a `WorkflowFailureError`: under the suite's
# warnings-as-errors, Temporal's "imported after initial workflow load" becomes a hard error;
# in production the SDK's restricted `Path.home()`/env access raises `RestrictedWorkflowAccessError`.
result = await client.execute_workflow(
ConstructModelInWorkflow.run,
args=[model_name],
id=f'construct_model_{re.sub(r"[^a-zA-Z0-9]", "_", model_name)}',
task_queue=TASK_QUEUE,
)
assert result == expected_model_class
# Regression test for the `genai_prices`/`httpx2` passthrough entries in `_workflow_runner`.
# `ModelResponse.cost()` lazily imports genai-prices on first call; inside a workflow that trips the
# sandbox unless those modules are passed through (see #6215).
@workflow.defn
class CalculateCostInWorkflow:
@workflow.run
async def run(self) -> float:
response = ModelResponse(
parts=[TextPart('ok')],
usage=RequestUsage(input_tokens=100, output_tokens=10),
model_name='claude-sonnet-4-5',
provider_name='anthropic',
)
return float(response.cost().total_price)
async def test_response_cost_in_workflow_passes_sandbox(client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[CalculateCostInWorkflow],
workflow_failure_exception_types=[Exception],
):
result = await client.execute_workflow(
CalculateCostInWorkflow.run,
id='calculate_cost_in_workflow',
task_queue=TASK_QUEUE,
)
assert result > 0
async def test_temporal_agent():
assert isinstance(complex_temporal_agent.model, TemporalModel)
assert complex_temporal_agent.model.wrapped == complex_agent.model
toolsets = complex_temporal_agent.toolsets
assert len(toolsets) == 5
# Empty function toolset for the agent's own tools
assert isinstance(toolsets[0], FunctionToolset)
assert toolsets[0].id == '<agent>'
assert toolsets[0].tools == {}
# Wrapped function toolset for the agent's own tools
assert isinstance(toolsets[1], TemporalFunctionToolset)
assert toolsets[1].id == '<agent>'
assert isinstance(toolsets[1].wrapped, FunctionToolset)
assert toolsets[1].wrapped.tools.keys() == {'get_weather'}
# Wrapped 'country' toolset
assert isinstance(toolsets[2], TemporalFunctionToolset)
assert toolsets[2].id == 'country'
assert toolsets[2].wrapped == complex_agent.toolsets[1]
assert isinstance(toolsets[2].wrapped, FunctionToolset)
assert toolsets[2].wrapped.tools.keys() == {'get_country'}
# Wrapped 'mcp' MCP server
assert isinstance(toolsets[3], TemporalMCPToolset)
assert toolsets[3].id == 'mcp'
assert toolsets[3].wrapped == complex_agent.toolsets[2]
# Unwrapped 'external' toolset
assert isinstance(toolsets[4], ExternalToolset)
assert toolsets[4].id == 'external'
assert toolsets[4] == complex_agent.toolsets[3]
assert [
ActivityDefinition.must_from_callable(activity).name # pyright: ignore[reportUnknownMemberType]
for activity in complex_temporal_agent.temporal_activities
] == snapshot(
[
'agent__complex_agent__event_stream_handler',
'agent__complex_agent__model_request',
'agent__complex_agent__model_request_stream',
'agent__complex_agent__toolset__<agent>__call_tool',
'agent__complex_agent__toolset__country__call_tool',
'agent__complex_agent__mcp_server__mcp__get_instructions',
'agent__complex_agent__mcp_server__mcp__get_tools',
'agent__complex_agent__mcp_server__mcp__call_tool',
]
)
def test_temporal_model_request_activities_capture_deps_type():
"""Both model-request activities must capture the real `deps_type` as the `deps` argument type.
`temporalio`'s `@activity.defn` freezes a function's type hints into `arg_types` at decoration time for
payload conversion, so `deps`'s annotation has to be set before decorating. If it's set afterwards (as the
non-streaming activity used to do), the patch is cosmetic and the activity deserializes `deps` as a raw
dict instead of the declared deps type.
"""
model = dynamic_toolset_temporal_agent.model
assert isinstance(model, TemporalModel)
# `arg_types[1]` is the `deps` argument's captured type, which drives Temporal's payload conversion.
deps_type = DynamicToolsetDeps | None
request_arg_types = ActivityDefinition.must_from_callable(model.request_activity).arg_types # pyright: ignore[reportUnknownMemberType]
stream_arg_types = ActivityDefinition.must_from_callable(model.request_stream_activity).arg_types # pyright: ignore[reportUnknownMemberType]
assert request_arg_types is not None and request_arg_types[1] == deps_type
assert stream_arg_types is not None and stream_arg_types[1] == deps_type
def test_temporal_wrapper_visit_and_replace():
"""Temporal wrapper toolsets should not be replaced by visit_and_replace."""
from pydantic_ai.durable_exec.temporal._function_toolset import TemporalFunctionToolset
toolsets = complex_temporal_agent._toolsets # pyright: ignore[reportPrivateUsage]
temporal_function_toolsets = [ts for ts in toolsets if isinstance(ts, TemporalFunctionToolset)]
assert len(temporal_function_toolsets) >= 1
temporal_function_toolset = temporal_function_toolsets[0]
# visit_and_replace should return self for temporal wrappers
result = temporal_function_toolset.visit_and_replace(lambda t: FunctionToolset(id='replaced'))
assert result is temporal_function_toolset
async def test_temporal_agent_run(allow_model_requests: None):
result = await simple_temporal_agent.run('What is the capital of Mexico?')
assert result.output == snapshot('The capital of Mexico is Mexico City.')
def test_temporal_agent_run_sync(allow_model_requests: None):
result = simple_temporal_agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot('The capital of Mexico is Mexico City.')
async def test_temporal_agent_run_stream(allow_model_requests: None):
async with simple_temporal_agent.run_stream('What is the capital of Mexico?') as result:
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(
[
'The',
'The capital',
'The capital of',
'The capital of Mexico',
'The capital of Mexico is',
'The capital of Mexico is Mexico',
'The capital of Mexico is Mexico City',
'The capital of Mexico is Mexico City.',
]
)
async def test_temporal_agent_run_stream_events(allow_model_requests: None):
async with simple_temporal_agent.run_stream_events('What is the capital of Mexico?') as event_stream:
events = [event async for event in event_stream]
assert events == snapshot(
[
PartStartEvent(index=0, part=TextPart(content='The')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' capital')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' of')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' Mexico')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' is')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' Mexico')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta=' City')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='.')),
PartEndEvent(index=0, part=TextPart(content='The capital of Mexico is Mexico City.')),
AgentRunResultEvent(result=AgentRunResult(output='The capital of Mexico is Mexico City.')),
]
)
async def test_temporal_agent_iter(allow_model_requests: None):
output: list[str] = []
async with simple_temporal_agent.iter('What is the capital of Mexico?') as run:
async for node in run:
if Agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for chunk in stream.stream_text(debounce_by=None):
output.append(chunk)
assert output == snapshot(
[
'The',
'The capital',
'The capital of',
'The capital of Mexico',
'The capital of Mexico is',
'The capital of Mexico is Mexico',
'The capital of Mexico is Mexico City',
'The capital of Mexico is Mexico City.',
]
)
@workflow.defn
class SimpleAgentWorkflowWithRunSync:
@workflow.run
async def run(self, prompt: str) -> str:
result = simple_temporal_agent.run_sync(prompt)
return result.output # pragma: no cover
async def test_temporal_agent_run_sync_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithRunSync],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot('`agent.run_sync()` cannot be used inside a Temporal workflow. Use `await agent.run()` instead.'),
):
await client.execute_workflow(
SimpleAgentWorkflowWithRunSync.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithRunSync.__name__,
task_queue=TASK_QUEUE,
)
def drop_first_message(msgs: list[ModelMessage]) -> list[ModelMessage]:
return msgs[1:] if len(msgs) > 1 else msgs
agent_with_sync_history_processor = Agent(
model, name='agent_with_sync_history_processor', capabilities=[ProcessHistory(drop_first_message)]
)
temporal_agent_with_sync_history_processor = TemporalAgent(
agent_with_sync_history_processor, activity_config=BASE_ACTIVITY_CONFIG
)
@workflow.defn
class AgentWithSyncHistoryProcessorWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await temporal_agent_with_sync_history_processor.run(prompt)
return result.output
async def test_temporal_agent_with_sync_history_processor(allow_model_requests: None, client: Client):
"""Test that sync history processors work inside Temporal workflows.
This validates that the _disable_threads ContextVar is properly set
by TemporalAgent._temporal_overrides(), allowing sync history processors to
execute without triggering NotImplementedError from anyio.to_thread.run_sync.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[AgentWithSyncHistoryProcessorWorkflow],
plugins=[AgentPlugin(temporal_agent_with_sync_history_processor)],
):
output = await client.execute_workflow(
AgentWithSyncHistoryProcessorWorkflow.run,
args=['What is the capital of Mexico?'],
id=AgentWithSyncHistoryProcessorWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
agent_with_sync_instructions = Agent(model, name='agent_with_sync_instructions')
@agent_with_sync_instructions.instructions
def sync_instructions_fn() -> str:
return 'You are a helpful assistant.'
temporal_agent_with_sync_instructions = TemporalAgent(
agent_with_sync_instructions, activity_config=BASE_ACTIVITY_CONFIG
)
@workflow.defn
class AgentWithSyncInstructionsWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await temporal_agent_with_sync_instructions.run(prompt)
return result.output
async def test_temporal_agent_with_sync_instructions(allow_model_requests: None, client: Client):
"""Test that sync instructions functions work inside Temporal workflows.
This validates that the _disable_threads ContextVar is properly set
by TemporalAgent._temporal_overrides(), allowing sync instructions functions to
execute without triggering NotImplementedError from anyio.to_thread.run_sync.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[AgentWithSyncInstructionsWorkflow],
plugins=[AgentPlugin(temporal_agent_with_sync_instructions)],
):
output = await client.execute_workflow(
AgentWithSyncInstructionsWorkflow.run,
args=['What is the capital of Mexico?'],
id=AgentWithSyncInstructionsWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
@workflow.defn
class SimpleAgentWorkflowWithRunStream:
@workflow.run
async def run(self, prompt: str) -> str:
async with simple_temporal_agent.run_stream(prompt) as result:
pass
return await result.get_output() # pragma: no cover
async def test_temporal_agent_run_stream_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithRunStream],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'`agent.run_stream()` cannot be used inside a Temporal workflow. Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithRunStream.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithRunStream.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithRunStreamEvents:
@workflow.run
async def run(self, prompt: str) -> list[AgentStreamEvent | AgentRunResultEvent]:
async with simple_temporal_agent.run_stream_events(prompt) as event_stream:
return [event async for event in event_stream] # pragma: no cover
async def test_temporal_agent_run_stream_events_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithRunStreamEvents],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'`agent.run_stream_events()` cannot be used inside a Temporal workflow. Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithRunStreamEvents.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithRunStreamEvents.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithIter:
@workflow.run
async def run(self, prompt: str) -> str:
async with simple_temporal_agent.iter(prompt) as run:
async for _ in run:
pass
return 'done' # pragma: no cover
async def test_temporal_agent_iter_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithIter],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'`agent.iter()` cannot be used inside a Temporal workflow. Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithIter.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithIter.__name__,
task_queue=TASK_QUEUE,
)
async def simple_event_stream_handler(
ctx: RunContext,
stream: AsyncIterable[AgentStreamEvent],
):
pass
@workflow.defn
class SimpleAgentWorkflowWithEventStreamHandler:
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt, event_stream_handler=simple_event_stream_handler)
return result.output # pragma: no cover
async def test_temporal_agent_run_in_workflow_with_event_stream_handler(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithEventStreamHandler],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Event stream handler cannot be set at agent run time inside a Temporal workflow, it must be set at agent creation time.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithEventStreamHandler.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithEventStreamHandler.__name__,
task_queue=TASK_QUEUE,
)
# Unregistered model instance for testing error case
unregistered_model = OpenAIChatModel(
'gpt-4o-mini',
provider=OpenAIProvider(
api_key=os.getenv('OPENAI_API_KEY', 'mock-api-key'),
http_client=http_client,
),
)
@workflow.defn
class SimpleAgentWorkflowWithRunModel:
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt, model=unregistered_model)
return result.output # pragma: no cover
async def test_temporal_agent_run_in_workflow_with_model(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithRunModel],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Arbitrary model instances cannot be used at runtime inside a Temporal workflow. Register the model via `models` or reference a registered model by id.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithRunModel.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithRunModel.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithRunToolsets:
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt, toolsets=[FunctionToolset()])
return result.output # pragma: no cover
async def test_temporal_agent_run_in_workflow_with_executing_toolsets(allow_model_requests: None, client: Client):
# Executing toolsets (here a `FunctionToolset`) can't be added per-run because their activities must
# be registered with the worker before the workflow runs.
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithRunToolsets],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'FunctionToolset cannot be passed to `run(toolsets=...)` at runtime with Temporal, because '
'toolsets that execute their own tools or resolve dynamically must be registered for durable '
'execution when the agent is constructed. Pass them to the agent constructor instead. '
'Non-executing toolsets like `ExternalToolset` can be passed at runtime.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithRunToolsets.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithRunToolsets.__name__,
task_queue=TASK_QUEUE,
)
def request_runtime_external_tool(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('external', {'query': 'runtime'}, tool_call_id='call-1')])
runtime_external_agent = Agent(
FunctionModel(request_runtime_external_tool),
name='runtime_external_toolset_agent',
output_type=[str, DeferredToolRequests],
)
runtime_external_temporal_agent = TemporalAgent(runtime_external_agent, activity_config=BASE_ACTIVITY_CONFIG)
runtime_external_toolset = ExternalToolset(
tool_defs=[
ToolDefinition(
name='external',
parameters_json_schema={
'type': 'object',
'properties': {'query': {'type': 'string'}},
'required': ['query'],
},
)
],
id='external',
)
@workflow.defn
class RuntimeExternalToolsetWorkflow:
@workflow.run
async def run(self, prompt: str) -> AgentRunResult[str | DeferredToolRequests]:
return await runtime_external_temporal_agent.run(prompt, toolsets=[runtime_external_toolset])
async def test_temporal_agent_run_in_workflow_with_runtime_external_toolset(allow_model_requests: None, client: Client):
# Non-executing toolsets like `ExternalToolset` need no durable wrapping, so they can be added per-run.
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[RuntimeExternalToolsetWorkflow],
plugins=[AgentPlugin(runtime_external_temporal_agent)],
):
result = await client.execute_workflow(
RuntimeExternalToolsetWorkflow.run,
args=['Call the runtime external tool.'],
id=RuntimeExternalToolsetWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert result.output == DeferredToolRequests(
calls=[ToolCallPart('external', {'query': 'runtime'}, tool_call_id='call-1')]
)
@workflow.defn
class SimpleAgentWorkflowWithOverrideModel:
@workflow.run
async def run(self, prompt: str) -> None:
with simple_temporal_agent.override(model=model):
pass
async def test_temporal_agent_override_model_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithOverrideModel],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Model cannot be contextually overridden inside a Temporal workflow, it must be set at agent creation time.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithOverrideModel.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithOverrideModel.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithOverrideToolsets:
@workflow.run
async def run(self, prompt: str) -> None:
with simple_temporal_agent.override(toolsets=[FunctionToolset()]):
pass
async def test_temporal_agent_override_toolsets_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithOverrideToolsets],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Toolsets cannot be contextually overridden inside a Temporal workflow, they must be set at agent creation time.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithOverrideToolsets.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithOverrideToolsets.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithOverrideTools:
@workflow.run
async def run(self, prompt: str) -> None:
with simple_temporal_agent.override(tools=[get_weather]):
pass
async def test_temporal_agent_override_tools_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithOverrideTools],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Tools cannot be contextually overridden inside a Temporal workflow, they must be set at agent creation time.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithOverrideTools.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithOverrideTools.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithOverrideBuiltinTools:
@workflow.run
async def run(self, prompt: str) -> None:
with simple_temporal_agent.override(native_tools=[WebSearchTool()]):
pass
async def test_temporal_agent_override_builtin_tools_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithOverrideBuiltinTools],
plugins=[AgentPlugin(simple_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'Native tools cannot be contextually overridden inside a Temporal workflow, they must be set at agent creation time.'
),
):
await client.execute_workflow(
SimpleAgentWorkflowWithOverrideBuiltinTools.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithOverrideBuiltinTools.__name__,
task_queue=TASK_QUEUE,
)
@workflow.defn
class SimpleAgentWorkflowWithOverrideDeps:
@workflow.run
async def run(self, prompt: str) -> str:
with simple_temporal_agent.override(deps=None):
result = await simple_temporal_agent.run(prompt)
return result.output
async def test_temporal_agent_override_deps_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SimpleAgentWorkflowWithOverrideDeps],
plugins=[AgentPlugin(simple_temporal_agent)],
):
output = await client.execute_workflow(
SimpleAgentWorkflowWithOverrideDeps.run,
args=['What is the capital of Mexico?'],
id=SimpleAgentWorkflowWithOverrideDeps.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
agent_with_sync_tool = Agent(model, name='agent_with_sync_tool', tools=[get_weather])
# This needs to be done before the `TemporalAgent` is bound to the workflow.
temporal_agent_with_sync_tool_activity_disabled = TemporalAgent(
agent_with_sync_tool,
activity_config=BASE_ACTIVITY_CONFIG,
tool_activity_config={
'<agent>': {
'get_weather': False,
},
},
)
@workflow.defn
class AgentWorkflowWithSyncToolActivityDisabled:
@workflow.run
async def run(self, prompt: str) -> str:
result = await temporal_agent_with_sync_tool_activity_disabled.run(prompt)
return result.output # pragma: no cover
async def test_temporal_agent_sync_tool_activity_disabled(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[AgentWorkflowWithSyncToolActivityDisabled],
plugins=[AgentPlugin(temporal_agent_with_sync_tool_activity_disabled)],
):
with workflow_raises(
UserError,
snapshot(
"Temporal activity config for tool 'get_weather' has been explicitly set to `False` (activity disabled), but non-async tools are run in threads which are not supported outside of an activity. Make the tool function async instead."
),
):
await client.execute_workflow(
AgentWorkflowWithSyncToolActivityDisabled.run,
args=['What is the weather in Mexico City?'],
id=AgentWorkflowWithSyncToolActivityDisabled.__name__,
task_queue=TASK_QUEUE,
)
async def test_temporal_agent_mcp_server_activity_disabled(client: Client):
with pytest.raises(
UserError,
match=re.escape(
"Temporal activity config for MCP tool 'get_product_name' has been explicitly set to `False` (activity disabled), "
'but MCP tools require the use of IO and so cannot be run outside of an activity.'
),
):
TemporalAgent(
complex_agent,
tool_activity_config={
'mcp': {
'get_product_name': False,
},
},
)
@workflow.defn
class DirectStreamWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
messages: list[ModelMessage] = [ModelRequest.user_text_prompt(prompt)]
async with model_request_stream(complex_temporal_agent.model, messages) as stream:
async for _ in stream:
pass
return 'done' # pragma: no cover
async def test_temporal_model_stream_direct(client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[DirectStreamWorkflow],
plugins=[AgentPlugin(complex_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
'A Temporal model cannot be used with `pydantic_ai.direct.model_request_stream()` as it requires a `run_context`. Set an `event_stream_handler` on the agent and use `agent.run()` instead.'
),
):
await client.execute_workflow(
DirectStreamWorkflow.run,
args=['What is the capital of Mexico?'],
id=DirectStreamWorkflow.__name__,
task_queue=TASK_QUEUE,
)
unserializable_deps_agent = Agent(model, name='unserializable_deps_agent', deps_type=Model)
@unserializable_deps_agent.tool
async def get_model_name(ctx: RunContext[Model]) -> str:
return ctx.deps.model_name # pragma: no cover
# This needs to be done before the `TemporalAgent` is bound to the workflow.
unserializable_deps_temporal_agent = TemporalAgent(unserializable_deps_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class UnserializableDepsAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await unserializable_deps_temporal_agent.run(prompt, deps=unserializable_deps_temporal_agent.model)
return result.output # pragma: no cover
async def test_temporal_agent_with_unserializable_deps_type(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[UnserializableDepsAgentWorkflow],
plugins=[AgentPlugin(unserializable_deps_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot(
"The `deps` object failed to be serialized. Temporal requires all objects that are passed to activities to be serializable using Pydantic's `TypeAdapter`."
),
):
await client.execute_workflow(
UnserializableDepsAgentWorkflow.run,
args=['What is the model name?'],
id=UnserializableDepsAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
async def test_logfire_plugin(client: Client):
def setup_logfire(send_to_logfire: bool = True, metrics: Literal[False] | None = None) -> Logfire:
instance = logfire.configure(local=True, metrics=metrics)
instance.config.token = 'test'
instance.config.send_to_logfire = send_to_logfire
return instance
plugin = LogfirePlugin(setup_logfire)
config = client.config()
config['plugins'] = [plugin]
new_client = Client(**config)
interceptor = new_client.config(active_config=True)['interceptors'][0]
assert isinstance(interceptor, TracingInterceptor)
if isinstance(interceptor.tracer, ProxyTracer):
assert interceptor.tracer._instrumenting_module_name == 'temporalio' # pyright: ignore[reportPrivateUsage] # pragma: lax no cover
elif isinstance(interceptor.tracer, _ProxyTracer):
assert interceptor.tracer.instrumenting_module_name == 'temporalio' # pragma: lax no cover
else:
assert False, f'Unexpected tracer type: {type(interceptor.tracer)}' # pragma: no cover
new_client = await Client.connect(client.service_client.config.target_host, plugins=[plugin])
# We can't check if the metrics URL was actually set correctly because it's on a `temporalio.bridge.runtime.Runtime` that we can't read from.
assert new_client.service_client.config.runtime is not None
plugin = LogfirePlugin(setup_logfire, metrics=False)
new_client = await Client.connect(client.service_client.config.target_host, plugins=[plugin])
assert new_client.service_client.config.runtime is None
plugin = LogfirePlugin(lambda: setup_logfire(send_to_logfire=False))
new_client = await Client.connect(client.service_client.config.target_host, plugins=[plugin])
assert new_client.service_client.config.runtime is None
plugin = LogfirePlugin(lambda: setup_logfire(metrics=False))
new_client = await Client.connect(client.service_client.config.target_host, plugins=[plugin])
assert new_client.service_client.config.runtime is None
hitl_agent = Agent(
model,
name='hitl_agent',
output_type=[str, DeferredToolRequests],
instructions='Just call tools without asking for confirmation.',
)
@hitl_agent.tool
async def create_file(ctx: RunContext, path: str) -> None:
raise CallDeferred
@hitl_agent.tool
async def delete_file(ctx: RunContext, path: str) -> bool:
if not ctx.tool_call_approved:
raise ApprovalRequired
return True
hitl_temporal_agent = TemporalAgent(hitl_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class HitlAgentWorkflow:
def __init__(self):
self._status: Literal['running', 'waiting_for_results', 'done'] = 'running'
self._deferred_tool_requests: DeferredToolRequests | None = None
self._deferred_tool_results: DeferredToolResults | None = None
@workflow.run
async def run(self, prompt: str) -> AgentRunResult[str | DeferredToolRequests]:
messages: list[ModelMessage] = [ModelRequest.user_text_prompt(prompt)]
while True:
result = await hitl_temporal_agent.run(
message_history=messages, deferred_tool_results=self._deferred_tool_results
)
messages = result.all_messages()
if isinstance(result.output, DeferredToolRequests):
self._deferred_tool_requests = result.output
self._deferred_tool_results = None
self._status = 'waiting_for_results'
await workflow.wait_condition(lambda: self._deferred_tool_results is not None)
self._status = 'running'
else:
self._status = 'done'
return result
@workflow.query
def get_status(self) -> Literal['running', 'waiting_for_results', 'done']:
return self._status
@workflow.query
def get_deferred_tool_requests(self) -> DeferredToolRequests | None:
return self._deferred_tool_requests
@workflow.signal
def set_deferred_tool_results(self, results: DeferredToolResults) -> None:
self._status = 'running'
self._deferred_tool_requests = None
self._deferred_tool_results = results
async def test_temporal_agent_with_hitl_tool(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[HitlAgentWorkflow],
plugins=[AgentPlugin(hitl_temporal_agent)],
):
workflow = await client.start_workflow(
HitlAgentWorkflow.run,
args=['Delete the file `.env` and create `test.txt`'],
id=HitlAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
while True:
await asyncio.sleep(1)
status = await workflow.query(HitlAgentWorkflow.get_status)
if status == 'done':
break
elif status == 'waiting_for_results': # pragma: no branch
deferred_tool_requests = await workflow.query(HitlAgentWorkflow.get_deferred_tool_requests)
assert deferred_tool_requests is not None
results = DeferredToolResults()
# Approve all calls
for tool_call in deferred_tool_requests.approvals:
results.approvals[tool_call.tool_call_id] = True
for tool_call in deferred_tool_requests.calls:
results.calls[tool_call.tool_call_id] = 'Success'
await workflow.signal(HitlAgentWorkflow.set_deferred_tool_results, results)
result = await workflow.result()
assert result.output == snapshot(
'The file `.env` has been deleted and `test.txt` has been created successfully.'
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Delete the file `.env` and create `test.txt`',
timestamp=IsDatetime(),
)
],
# NOTE in other tests we check timestamp=IsNow(tz=timezone.utc)
# but temporal tests fail when we use IsNow
timestamp=IsDatetime(),
instructions='Just call tools without asking for confirmation.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='delete_file',
args='{"path": ".env"}',
tool_call_id='call_jYdIdRZHxZTn5bWCq5jlMrJi',
),
ToolCallPart(
tool_name='create_file',
args='{"path": "test.txt"}',
tool_call_id='call_TmlTVWQbzrXCZ4jNsCVNbNqu',
),
],
usage=RequestUsage(
input_tokens=71,
output_tokens=46,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name=IsStr(),
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'tool_calls', 'timestamp': '2025-08-28T22:11:03Z'},
provider_response_id=IsStr(),
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='delete_file',
content=True,
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='create_file',
content='Success',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
instructions='Just call tools without asking for confirmation.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='The file `.env` has been deleted and `test.txt` has been created successfully.'
)
],
usage=RequestUsage(
input_tokens=133,
output_tokens=19,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'stop', 'timestamp': '2025-08-28T22:11:06Z'},
provider_response_id=IsStr(),
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
model_retry_agent = Agent(model, name='model_retry_agent')
@model_retry_agent.tool_plain
def get_weather_in_city(city: str) -> str:
if city != 'Mexico City':
raise ModelRetry('Did you mean Mexico City?')
return 'sunny'
model_retry_temporal_agent = TemporalAgent(model_retry_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class ModelRetryWorkflow:
@workflow.run
async def run(self, prompt: str) -> AgentRunResult[str]:
result = await model_retry_temporal_agent.run(prompt)
return result
async def test_temporal_agent_with_model_retry(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ModelRetryWorkflow],
plugins=[AgentPlugin(model_retry_temporal_agent)],
):
workflow = await client.start_workflow(
ModelRetryWorkflow.run,
args=['What is the weather in CDMX?'],
id=ModelRetryWorkflow.__name__,
task_queue=TASK_QUEUE,
)
result = await workflow.result()
assert result.output == snapshot('The weather in Mexico City is currently sunny.')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the weather in CDMX?',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_weather_in_city',
args='{"city":"CDMX"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(
input_tokens=47,
output_tokens=17,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'tool_calls', 'timestamp': '2025-08-28T23:19:50Z'},
provider_response_id=IsStr(),
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Did you mean Mexico City?',
tool_name='get_weather_in_city',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_weather_in_city',
args='{"city":"Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(
input_tokens=87,
output_tokens=17,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'tool_calls', 'timestamp': '2025-08-28T23:19:51Z'},
provider_response_id=IsStr(),
finish_reason='tool_call',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_weather_in_city',
content='sunny',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='The weather in Mexico City is currently sunny.')],
usage=RequestUsage(
input_tokens=116,
output_tokens=10,
details={
'accepted_prediction_tokens': 0,
'audio_tokens': 0,
'reasoning_tokens': 0,
'rejected_prediction_tokens': 0,
},
),
model_name='gpt-4o-2024-08-06',
timestamp=IsDatetime(),
provider_name='openai',
provider_url='https://api.openai.com/v1/',
provider_details={'finish_reason': 'stop', 'timestamp': '2025-08-28T23:19:52Z'},
provider_response_id=IsStr(),
finish_reason='stop',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class CustomModelSettings(ModelSettings, total=False):
custom_setting: str
def return_settings(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(str(agent_info.model_settings))])
model_settings = CustomModelSettings(max_tokens=123, custom_setting='custom_value')
return_settings_model = FunctionModel(return_settings, settings=model_settings)
settings_agent = Agent(return_settings_model, name='settings_agent')
# This needs to be done before the `TemporalAgent` is bound to the workflow.
settings_temporal_agent = TemporalAgent(settings_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class SettingsAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await settings_temporal_agent.run(prompt)
return result.output
async def test_custom_model_settings(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[SettingsAgentWorkflow],
plugins=[AgentPlugin(settings_temporal_agent)],
):
output = await client.execute_workflow(
SettingsAgentWorkflow.run,
args=['Give me those settings'],
id=SettingsAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot("{'max_tokens': 123, 'custom_setting': 'custom_value'}")
def return_mcp_instructions(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(agent_info.instructions or '')])
# Exercises the `TemporalMCPToolset` wrapper's `get_instructions` activity path.
mcptoolset_instructions_agent = Agent(
FunctionModel(return_mcp_instructions),
name='mcptoolset_instructions_agent',
toolsets=[
MCPToolset(
StdioTransport(command='python', args=['-m', 'tests.mcp_server']),
include_instructions=True,
id='mcp',
)
],
)
mcptoolset_instructions_temporal_agent = TemporalAgent(
mcptoolset_instructions_agent, activity_config=BASE_ACTIVITY_CONFIG
)
@workflow.defn
class MCPToolsetInstructionsWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await mcptoolset_instructions_temporal_agent.run(prompt)
return result.output
async def test_temporal_mcptoolset_instructions_propagate(client: Client):
"""`MCPToolset` instructions propagate through the `TemporalMCPToolset` wrapper."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MCPToolsetInstructionsWorkflow],
plugins=[AgentPlugin(mcptoolset_instructions_temporal_agent)],
):
output = await client.execute_workflow(
MCPToolsetInstructionsWorkflow.run,
args=['Use MCP instructions'],
id=MCPToolsetInstructionsWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('Be a helpful assistant.')
def test_temporalize_mcptoolset_dispatches_to_temporalmcptoolset():
"""`temporalize_toolset` wraps `MCPToolset` in `TemporalMCPToolset`."""
toolset = MCPToolset('https://example.com/mcp', id='test_dispatch')
agent = Agent(model=model, name='dispatch_agent', toolsets=[toolset])
temporal = TemporalAgent(agent, activity_config=BASE_ACTIVITY_CONFIG)
wrapped = next(ts for ts in temporal.toolsets if isinstance(ts, TemporalMCPToolset))
assert wrapped.wrapped is toolset
image_agent = Agent(model, name='image_agent', output_type=BinaryImage)
# This needs to be done before the `TemporalAgent` is bound to the workflow.
image_temporal_agent = TemporalAgent(image_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class ImageAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> BinaryImage:
result = await image_temporal_agent.run(prompt)
return result.output # pragma: no cover
async def test_image_agent(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ImageAgentWorkflow],
plugins=[AgentPlugin(image_temporal_agent)],
):
with workflow_raises(
UserError,
snapshot('Image output is not supported with Temporal because of the 2MB payload size limit.'),
):
await client.execute_workflow(
ImageAgentWorkflow.run,
args=['Generate an image of an axolotl.'],
id=ImageAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
# ============================================================================
# DocumentUrl Serialization Test - Verifies that DocumentUrl with custom
# media_type is properly serialized through Temporal activities
# ============================================================================
document_url_agent = Agent(
TestModel(custom_output_args={'url': 'https://example.com/doc/12345', 'media_type': 'application/pdf'}),
name='document_url_agent',
output_type=DocumentUrl,
)
document_url_temporal_agent = TemporalAgent(document_url_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class DocumentUrlAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> DocumentUrl:
result = await document_url_temporal_agent.run(prompt)
return result.output
async def test_document_url_serialization_preserves_media_type(allow_model_requests: None, client: Client):
"""Test that `DocumentUrl` with custom `media_type` is preserved through Temporal serialization.
This is a regression test for https://github.com/pydantic/pydantic-ai/issues/3949
where `DocumentUrl.media_type` (a computed field) was lost during Temporal activity
serialization because the backing field `_media_type` was excluded from serialization.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[DocumentUrlAgentWorkflow],
plugins=[AgentPlugin(document_url_temporal_agent)],
):
output = await client.execute_workflow(
DocumentUrlAgentWorkflow.run,
args=['Return a document'],
id=DocumentUrlAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot(
DocumentUrl(url='https://example.com/doc/12345', _media_type='application/pdf', _identifier='eb8998')
)
# ============================================================================
# UploadedFile Serialization Test - Verifies that UploadedFile with custom
# media_type is properly serialized through Temporal activities
# ============================================================================
uploaded_file_agent = Agent(
TestModel(
custom_output_args={
'file_id': 'file-abc123',
'provider_name': 'openai',
'media_type': 'image/png',
'identifier': 'file-1',
}
),
name='uploaded_file_agent',
output_type=UploadedFile,
)
uploaded_file_temporal_agent = TemporalAgent(uploaded_file_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class UploadedFileAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> UploadedFile:
result = await uploaded_file_temporal_agent.run(prompt)
return result.output
async def test_uploaded_file_serialization_preserves_media_type(allow_model_requests: None, client: Client):
"""Test that `UploadedFile` with custom `media_type` is preserved through Temporal serialization."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[UploadedFileAgentWorkflow],
plugins=[AgentPlugin(uploaded_file_temporal_agent)],
):
output = await client.execute_workflow(
UploadedFileAgentWorkflow.run,
args=['Return a file reference'],
id=UploadedFileAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot(
UploadedFile(file_id='file-abc123', provider_name='openai', _media_type='image/png', _identifier='file-1')
)
# Can't use the `openai_api_key` fixture here because the workflow needs to be defined at the top level of the file.
web_search_model = OpenAIResponsesModel(
'gpt-5',
provider=OpenAIProvider(
api_key=os.getenv('OPENAI_API_KEY', 'mock-api-key'),
http_client=http_client,
),
)
web_search_agent = Agent(
web_search_model,
name='web_search_agent',
capabilities=[NativeTool(WebSearchTool(user_location=WebSearchUserLocation(city='Mexico City', country='MX')))],
)
# This needs to be done before the `TemporalAgent` is bound to the workflow.
web_search_temporal_agent = TemporalAgent(
web_search_agent,
activity_config=BASE_ACTIVITY_CONFIG,
model_activity_config=ActivityConfig(start_to_close_timeout=timedelta(seconds=300)),
)
@workflow.defn
class WebSearchAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await web_search_temporal_agent.run(prompt)
return result.output
async def test_web_search_agent_run_in_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[WebSearchAgentWorkflow],
plugins=[AgentPlugin(web_search_temporal_agent)],
):
output = await client.execute_workflow(
WebSearchAgentWorkflow.run,
args=['In one sentence, what is the top news story in my country today?'],
id=WebSearchAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot(
'Severe floods and landslides across Veracruz, Hidalgo, and Puebla have cut off hundreds of communities and left dozens dead and many missing, prompting a major federal emergency response. ([apnews.com](https://apnews.com/article/5d036e18057361281e984b44402d3b1b?utm_source=openai))'
)
def test_temporal_run_context_preserves_run_id():
ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
run_id='run-123',
)
serialized = TemporalRunContext.serialize_run_context(ctx)
assert serialized['run_id'] == 'run-123'
reconstructed = TemporalRunContext.deserialize_run_context(serialized, deps=None)
assert reconstructed.run_id == 'run-123'
def test_temporal_run_context_serializes_metadata():
ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
run_id='run-123',
metadata={'env': 'prod'},
)
serialized = TemporalRunContext.serialize_run_context(ctx)
assert serialized['metadata'] == {'env': 'prod'}
reconstructed = TemporalRunContext.deserialize_run_context(serialized, deps=None)
assert reconstructed.metadata == {'env': 'prod'}
def test_temporal_run_context_excludes_agent():
"""agent is not serialized but defaults to None after deserialization."""
from pydantic_ai.durable_exec.temporal._run_context import deserialize_run_context
agent = Agent('test', name='test_agent')
ctx = RunContext(
deps=None,
agent=agent,
model=TestModel(),
usage=RunUsage(),
run_id='run-123',
)
serialized = TemporalRunContext.serialize_run_context(ctx)
assert 'agent' not in serialized
# Without agent — e.g. when _agent was never set on a temporal wrapper
reconstructed = deserialize_run_context(TemporalRunContext, serialized, deps=None, agent=None)
assert reconstructed.agent is None
# With agent — as used by TemporalAgent's wrappers
reconstructed = deserialize_run_context(TemporalRunContext, serialized, deps=None, agent=agent)
assert reconstructed.agent is agent
assert agent.name == 'test_agent'
def test_temporal_run_context_serializes_usage():
ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(
requests=2,
tool_calls=1,
input_tokens=123,
output_tokens=456,
details={'foo': 1},
),
run_id='run-123',
)
serialized = TemporalRunContext.serialize_run_context(ctx)
assert serialized['usage'] == ctx.usage
reconstructed = TemporalRunContext.deserialize_run_context(serialized, deps=None)
assert reconstructed.usage == ctx.usage
def test_temporal_run_context_serializes_usage_limits():
ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
usage_limits=UsageLimits(request_limit=7, total_tokens_limit=1000),
run_id='run-123',
)
serialized = TemporalRunContext.serialize_run_context(ctx)
assert serialized['usage_limits'] == ctx.usage_limits
reconstructed = TemporalRunContext.deserialize_run_context(serialized, deps=None)
assert reconstructed.usage_limits == ctx.usage_limits
def test_temporal_run_context_serialization_is_exhaustive():
"""Every `RunContext` field must be consciously categorized for Temporal serialization.
Guards against silent drift: when a `RunContext` field is added, this test fails until
the author either includes it in `TemporalRunContext.serialize_run_context` or lists it
in `intentionally_unserialized` below with a reason. Without that decision a new field
silently becomes unavailable inside a Temporal activity (the `__getattribute__` guard
raises `UserError` on access), which is how the deferred-capability fields were missed.
"""
# Fields deliberately NOT carried across the activity boundary, each with its reason.
intentionally_unserialized = {
'deps', # passed separately to deserialize_run_context
'agent', # reattached after deserialize by deserialize_run_context
'model', # live Model instance, not serializable
'tracer', # live tracer, not serializable
'tool_manager', # live ToolManager, not serializable (documented on the field)
'capabilities', # live capability objects (toolsets/hooks/callables), not serializable
'pending_messages', # live run queue, meaningless outside the running agent
'messages', # not currently exposed inside activities
'prompt', # not currently exposed inside activities
'validation_context', # arbitrary user object, possibly unserializable
'trace_include_content', # tracing config, not run state
'instrumentation_version', # tracing config, not run state
'conversation_id', # not currently exposed inside activities
'model_settings', # not currently exposed inside activities
'_mcp_tool_defs_cache', # run-local cache read/written in workflow code; never needed inside an activity
}
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
serialized = set(TemporalRunContext.serialize_run_context(ctx))
all_fields = set(RunContext.__dataclass_fields__)
overlap = serialized & intentionally_unserialized
assert not overlap, f'Fields both serialized and excluded: {overlap}'
uncategorized = all_fields - (serialized | intentionally_unserialized)
assert not uncategorized, (
f'Uncategorized `RunContext` fields: {uncategorized}. Add each to '
'`TemporalRunContext.serialize_run_context` or to `intentionally_unserialized` (with a reason).'
)
def _tool_return_metadata_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('analyze_data', {})])
else:
return ModelResponse(parts=[TextPart('done')])
_tool_return_metadata_agent = Agent(
FunctionModel(_tool_return_metadata_model),
name='tool_return_metadata_agent',
)
@_tool_return_metadata_agent.tool_plain
def analyze_data() -> ToolReturn:
return ToolReturn(
return_value='analysis result',
content='extra content for model',
metadata={'key': 'value', 'count': 42},
)
_tool_return_metadata_temporal_agent = TemporalAgent(_tool_return_metadata_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class ToolReturnMetadataWorkflow:
@workflow.run
async def run(self, prompt: str) -> list[ModelMessage]:
result = await _tool_return_metadata_temporal_agent.run(prompt)
return result.all_messages()
async def test_tool_return_metadata_survives_temporal(allow_model_requests: None, client: Client):
"""ToolReturn metadata and content survive Temporal serialization.
Regression test for https://github.com/pydantic/pydantic-ai/issues/4676
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ToolReturnMetadataWorkflow],
plugins=[AgentPlugin(_tool_return_metadata_temporal_agent)],
):
messages = await client.execute_workflow(
ToolReturnMetadataWorkflow.run,
args=['analyze'],
id=ToolReturnMetadataWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='analyze', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='analyze_data', args={}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:_tool_return_metadata_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='analyze_data',
content='analysis result',
tool_call_id=IsStr(),
metadata={'key': 'value', 'count': 42},
timestamp=IsDatetime(),
),
UserPromptPart(content='extra content for model', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=57, output_tokens=3),
model_name='function:_tool_return_metadata_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
mcptoolset_agent = Agent(
model,
name='mcptoolset_agent',
toolsets=[MCPToolset('https://mcp.deepwiki.com/mcp', id='deepwiki')],
)
mcptoolset_temporal_agent = TemporalAgent(
mcptoolset_agent,
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class MCPToolsetAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> str:
result = await mcptoolset_temporal_agent.run(prompt)
return result.output
async def test_mcptoolset_in_temporal_workflow(allow_model_requests: None, client: Client):
"""`MCPToolset` works in a Temporal workflow — parallel to `test_fastmcp_toolset`."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MCPToolsetAgentWorkflow],
plugins=[AgentPlugin(mcptoolset_temporal_agent)],
):
output = await client.execute_workflow(
MCPToolsetAgentWorkflow.run,
args=['Can you tell me more about the pydantic/pydantic-ai repo? Keep your answer short'],
id=MCPToolsetAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert 'pydantic' in output.lower() or 'agent' in output.lower()
# ============================================================================
# ctx.agent in Temporal activities
# ============================================================================
def _ctx_agent_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('get_agent_name', {})])
else:
return ModelResponse(parts=[TextPart('done')])
_ctx_agent_test_agent = Agent(
FunctionModel(_ctx_agent_model),
name='ctx_agent_test',
)
@_ctx_agent_test_agent.tool
def get_agent_name(ctx: RunContext) -> str:
return (ctx.agent.name or 'unnamed') if ctx.agent else 'unknown'
_ctx_agent_temporal_agent = TemporalAgent(_ctx_agent_test_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class CtxAgentWorkflow:
@workflow.run
async def run(self, prompt: str) -> list[ModelMessage]:
result = await _ctx_agent_temporal_agent.run(prompt)
return result.all_messages()
async def test_ctx_agent_in_temporal_activity(allow_model_requests: None, client: Client):
"""ctx.agent is available inside Temporal activities, giving access to agent properties like name."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[CtxAgentWorkflow],
plugins=[AgentPlugin(_ctx_agent_temporal_agent)],
):
messages = await client.execute_workflow(
CtxAgentWorkflow.run,
args=['test'],
id=CtxAgentWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='get_agent_name', args={}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:_ctx_agent_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_agent_name',
content='ctx_agent_test',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=52, output_tokens=3),
model_name='function:_ctx_agent_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# ============================================================================
# Beta Graph API Tests - Tests for running pydantic-graph beta API in Temporal
# ============================================================================
@dataclass
class GraphState:
"""State for the graph execution test."""
values: list[int] = field(default_factory=list[int])
# Create a graph with parallel execution using the beta API
graph_builder = GraphBuilder(
name='parallel_test_graph',
state_type=GraphState,
input_type=int,
output_type=list[int],
)
@graph_builder.step
async def source(ctx: StepContext[GraphState, None, int]) -> int:
"""Source step that passes through the input value."""
return ctx.inputs
@graph_builder.step
async def multiply_by_two(ctx: StepContext[GraphState, None, int]) -> int:
"""Multiply input by 2."""
return ctx.inputs * 2
@graph_builder.step
async def multiply_by_three(ctx: StepContext[GraphState, None, int]) -> int:
"""Multiply input by 3."""
return ctx.inputs * 3
@graph_builder.step
async def multiply_by_four(ctx: StepContext[GraphState, None, int]) -> int:
"""Multiply input by 4."""
return ctx.inputs * 4
# Create a join to collect results
result_collector = graph_builder.join(reduce_list_append, initial_factory=list[int])
# Build the graph with parallel edges (broadcast pattern)
graph_builder.add(
graph_builder.edge_from(graph_builder.start_node).to(source),
# Broadcast: send value to all three parallel steps
graph_builder.edge_from(source).to(multiply_by_two, multiply_by_three, multiply_by_four),
# Collect all results
graph_builder.edge_from(multiply_by_two, multiply_by_three, multiply_by_four).to(result_collector),
graph_builder.edge_from(result_collector).to(graph_builder.end_node),
)
parallel_test_graph = graph_builder.build()
@workflow.defn
class ParallelGraphWorkflow:
"""Workflow that executes a graph with parallel task execution."""
@workflow.run
async def run(self, input_value: int) -> list[int]:
"""Run the parallel graph workflow.
Args:
input_value: The input number to process
Returns:
List of results from parallel execution
"""
result = await parallel_test_graph.run(
state=GraphState(),
inputs=input_value,
)
return result
async def test_beta_graph_parallel_execution_in_workflow(client: Client):
"""Test that beta graph API with parallel execution works in Temporal workflows.
This test verifies the fix for the bug where parallel task execution in graphs
wasn't working properly with Temporal workflows due to GraphTask/GraphTaskRequest
serialization issues.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[ParallelGraphWorkflow],
):
output = await client.execute_workflow(
ParallelGraphWorkflow.run,
args=[10],
id=ParallelGraphWorkflow.__name__,
task_queue=TASK_QUEUE,
)
# Results can be in any order due to parallel execution
# 10 * 2 = 20, 10 * 3 = 30, 10 * 4 = 40
assert sorted(output) == [20, 30, 40]
@workflow.defn
class WorkflowWithAgents(PydanticAIWorkflow):
__pydantic_ai_agents__ = [simple_temporal_agent]
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt)
return result.output
@workflow.defn
class WorkflowWithAgentsWithoutPydanticAIWorkflow:
__pydantic_ai_agents__ = [simple_temporal_agent]
@workflow.run
async def run(self, prompt: str) -> str:
result = await simple_temporal_agent.run(prompt)
return result.output
async def test_passing_agents_through_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[WorkflowWithAgents],
):
output = await client.execute_workflow(
WorkflowWithAgents.run,
args=['What is the capital of Mexico?'],
id=WorkflowWithAgents.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
async def test_passing_agents_through_workflow_without_pydantic_ai_workflow(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[WorkflowWithAgentsWithoutPydanticAIWorkflow],
):
output = await client.execute_workflow(
WorkflowWithAgentsWithoutPydanticAIWorkflow.run,
args=['What is the capital of Mexico?'],
id=WorkflowWithAgentsWithoutPydanticAIWorkflow.__name__,
task_queue=TASK_QUEUE,
)
assert output == snapshot('The capital of Mexico is Mexico City.')
# Multi-Model Support Tests
# Module-level test models for multi-model selection test
test_model_selection_1 = TestModel(custom_output_text='Response from model 1')
test_model_selection_2 = TestModel(custom_output_text='Response from model 2')
test_model_selection_3 = TestModel(custom_output_text='Response from model 3')
# Module-level test models for error test
test_model_error_1 = TestModel()
test_model_error_2 = TestModel()
test_model_error_unregistered = TestModel()
# Module-level temporal agents
agent_selection = Agent(test_model_selection_1, name='multi_model_workflow_test')
multi_model_selection_test_agent = TemporalAgent(
agent_selection,
name='multi_model_workflow_test',
models={
'model_2': test_model_selection_2,
'model_3': test_model_selection_3,
},
activity_config=BASE_ACTIVITY_CONFIG,
)
agent_error = Agent(test_model_error_1, name='error_test')
multi_model_error_test_agent = TemporalAgent(
agent_error,
name='error_test',
models={'other': test_model_error_2},
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class MultiModelWorkflow:
@workflow.run
async def run(self, prompt: str, model_id: str | None = None) -> str:
result = await multi_model_selection_test_agent.run(prompt, model=model_id)
return result.output
class _BuiltinToolModel(TestModel):
SUPPORTED_TOOLS: frozenset[type[AbstractNativeTool]] = frozenset()
@classmethod
def supported_native_tools(cls) -> frozenset[type[AbstractNativeTool]]:
return cls.SUPPORTED_TOOLS
def _request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
# Override to skip TestModel._request's builtin tools rejection
return ModelResponse(parts=[TextPart(self.custom_output_text or '')], model_name=self.model_name)
class _WebSearchOnlyModel(_BuiltinToolModel):
SUPPORTED_TOOLS = frozenset({WebSearchTool})
class _CodeExecutionOnlyModel(_BuiltinToolModel):
SUPPORTED_TOOLS = frozenset({CodeExecutionTool})
def _select_builtin_tool(ctx: RunContext[Any]) -> AbstractNativeTool:
if WebSearchTool in ctx.model.profile.get('supported_native_tools', SUPPORTED_NATIVE_TOOLS):
return WebSearchTool()
return CodeExecutionTool()
web_search_builtin_model = _WebSearchOnlyModel(custom_output_text='search model', model_name='web-search')
code_execution_builtin_model = _CodeExecutionOnlyModel(custom_output_text='code model', model_name='code-exec')
builtin_tool_agent = Agent(
web_search_builtin_model,
name='builtin_tool_dynamic_agent',
capabilities=[NativeTool(_select_builtin_tool)],
)
builtin_tool_temporal_agent = TemporalAgent(
builtin_tool_agent,
name='builtin_tool_dynamic_agent',
models={'code': code_execution_builtin_model},
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class BuiltinToolWorkflow:
@workflow.run
async def run(self, prompt: str, model_id: str | None = None) -> str:
result = await builtin_tool_temporal_agent.run(prompt, model=model_id)
return result.output
# Model that does NOT support any builtin tools (used as default)
no_builtin_support_model = _BuiltinToolModel(custom_output_text='no builtin support', model_name='no-builtin-test')
# Model that DOES support WebSearchTool (registered as alternate model)
web_search_builtin_override_model = _WebSearchOnlyModel(
custom_output_text='web search response',
model_name='web-search-override',
)
# Agent initialized with model that doesn't support builtins, but has builtin tools configured
builtins_in_workflow_agent = Agent(
no_builtin_support_model,
capabilities=[NativeTool(WebSearchTool()), Instrumentation(settings=InstrumentationSettings())],
name='builtins_in_workflow',
)
# TemporalAgent registers an alternate model that DOES support builtins
builtins_in_workflow_temporal_agent = TemporalAgent(
builtins_in_workflow_agent,
name='builtins_in_workflow',
models={'web_search': web_search_builtin_override_model},
activity_config=BASE_ACTIVITY_CONFIG,
)
@workflow.defn
class BuiltinsInWorkflow(PydanticAIWorkflow):
@workflow.run
async def run(self, prompt: str, model_id: str | None = None) -> str:
result = await builtins_in_workflow_temporal_agent.run(prompt, model=model_id)
return result.output
@workflow.defn
class MultiModelWorkflowUnregistered:
@workflow.run
async def run(self, prompt: str) -> str:
# Try to use an unregistered model
result = await multi_model_error_test_agent.run(prompt, model=test_model_error_unregistered)
return result.output # pragma: no cover
async def test_temporal_agent_multi_model_reserved_id():
"""Test that reserved model IDs raise helpful errors."""
test_model1 = TestModel()
test_model2 = TestModel()
agent = Agent(test_model1, name='reserved_id_test')
with pytest.raises(UserError, match="Model ID 'default' is reserved"):
TemporalAgent(
agent,
name='reserved_id_test',
models={'default': test_model2},
)
async def test_temporal_agent_multi_model_selection_in_workflow(allow_model_requests: None, client: Client):
"""Test selecting different models in a workflow using the model parameter."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModelWorkflow],
plugins=[AgentPlugin(multi_model_selection_test_agent)],
):
# Test using default model (model_id=None)
output = await client.execute_workflow(
MultiModelWorkflow.run,
args=['Hello', None],
id='MultiModelWorkflow_default',
task_queue=TASK_QUEUE,
)
assert output == 'Response from model 1'
# Test selecting second model by ID
output = await client.execute_workflow(
MultiModelWorkflow.run,
args=['Hello', 'model_2'],
id='MultiModelWorkflow_model2',
task_queue=TASK_QUEUE,
)
assert output == 'Response from model 2'
# Test selecting third model by ID
output = await client.execute_workflow(
MultiModelWorkflow.run,
args=['Hello', 'model_3'],
id='MultiModelWorkflow_model3',
task_queue=TASK_QUEUE,
)
assert output == 'Response from model 3'
async def test_temporal_dynamic_builtin_tools_select_by_model(allow_model_requests: None, client: Client):
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[BuiltinToolWorkflow],
plugins=[AgentPlugin(builtin_tool_temporal_agent)],
):
output = await client.execute_workflow(
BuiltinToolWorkflow.run,
args=['Hello', None],
id='BuiltinToolWorkflow_default',
task_queue=TASK_QUEUE,
)
assert output == 'search model'
assert isinstance(web_search_builtin_model.last_model_request_parameters, ModelRequestParameters)
assert web_search_builtin_model.last_model_request_parameters.native_tools
assert isinstance(web_search_builtin_model.last_model_request_parameters.native_tools[0], WebSearchTool)
output = await client.execute_workflow(
BuiltinToolWorkflow.run,
args=['Hello', 'code'],
id='BuiltinToolWorkflow_code',
task_queue=TASK_QUEUE,
)
assert output == 'code model'
assert isinstance(code_execution_builtin_model.last_model_request_parameters, ModelRequestParameters)
assert code_execution_builtin_model.last_model_request_parameters.native_tools
assert isinstance(
code_execution_builtin_model.last_model_request_parameters.native_tools[0],
CodeExecutionTool,
)
async def test_builtins_in_workflow_with_runtime_model_override(allow_model_requests: None, client: Client):
"""Test that builtin tools work when agent is initialized with a non-supporting model
but run with a model that does support builtins."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[BuiltinsInWorkflow],
plugins=[AgentPlugin(builtins_in_workflow_temporal_agent)],
):
# Run with the model that supports WebSearchTool
result = await client.execute_workflow(
BuiltinsInWorkflow.run,
args=['search for something', 'web_search'],
id='BuiltinsInWorkflow',
task_queue=TASK_QUEUE,
)
assert result == 'web search response'
# Verify the web search model received the WebSearchTool in its request parameters
assert isinstance(web_search_builtin_override_model.last_model_request_parameters, ModelRequestParameters)
assert web_search_builtin_override_model.last_model_request_parameters.native_tools
assert isinstance(
web_search_builtin_override_model.last_model_request_parameters.native_tools[0],
WebSearchTool,
)
async def test_temporal_agent_multi_model_unregistered_error(allow_model_requests: None, client: Client):
"""Test that using an unregistered model raises a helpful error."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModelWorkflowUnregistered],
plugins=[AgentPlugin(multi_model_error_test_agent)],
):
with workflow_raises(
UserError,
'Arbitrary model instances cannot be used at runtime inside a Temporal workflow. Register the model via `models` or reference a registered model by id.',
):
await client.execute_workflow(
MultiModelWorkflowUnregistered.run,
args=['Hello'],
id='MultiModelWorkflowUnregistered',
task_queue=TASK_QUEUE,
)
async def test_temporal_agent_multi_model_outside_workflow():
"""Test that multi-model agents work outside workflows (using wrapped agent behavior).
Outside a workflow, a TemporalAgent should behave like a regular Agent.
This includes supporting model selection by registered ID or instance.
"""
test_model1 = TestModel(custom_output_text='Model 1 response')
test_model2 = TestModel(custom_output_text='Model 2 response')
test_model_unregistered = TestModel(custom_output_text='Unregistered model response')
agent = Agent(test_model1, name='outside_workflow_test')
temporal_agent = TemporalAgent(
agent,
name='outside_workflow_test',
models={'secondary': test_model2},
)
# Outside workflow, should use default model
result = await temporal_agent.run('Hello')
assert result.output == 'Model 1 response'
# Outside workflow, passing a registered model ID should also work
result = await temporal_agent.run('Hello', model='secondary')
assert result.output == 'Model 2 response'
# Passing a registered model instance should also work
result = await temporal_agent.run('Hello', model=test_model2)
assert result.output == 'Model 2 response'
# Passing an unregistered model instance should also work outside workflow
result = await temporal_agent.run('Hello', model=test_model_unregistered)
assert result.output == 'Unregistered model response'
async def test_temporal_agent_without_default_model():
"""Test that a TemporalAgent can be created without a default model if models is provided.
When no model is provided to run(), the first registered model should be used.
"""
test_model1 = TestModel(custom_output_text='Model 1 response')
test_model2 = TestModel(custom_output_text='Model 2 response')
# Agent without a model
agent = Agent(name='no_default_model_test')
temporal_agent = TemporalAgent(
agent,
name='no_default_model_test',
models={
'primary': test_model1,
'secondary': test_model2,
},
)
# Without a model, should use the first registered model
result = await temporal_agent.run('Hello')
assert result.output == 'Model 1 response'
# Outside workflow, can use registered models by id
result = await temporal_agent.run('Hello', model='primary')
assert result.output == 'Model 1 response'
result = await temporal_agent.run('Hello', model='secondary')
assert result.output == 'Model 2 response'
# Workflow for testing passing model instances (can't be workflow args, so map by key)
_model_instance_map = {
'default_instance': test_model_selection_1,
'model_2_instance': test_model_selection_2,
}
@workflow.defn
class MultiModelWorkflowInstance:
@workflow.run
async def run(self, prompt: str, instance_key: str) -> str:
model_instance = _model_instance_map[instance_key]
result = await multi_model_selection_test_agent.run(prompt, model=model_instance)
return result.output
@pytest.mark.parametrize(
('model_id', 'expected_output'),
[
pytest.param('default', 'Response from model 1', id='default_explicit'),
],
)
async def test_temporal_agent_model_selection_by_id(
allow_model_requests: None, client: Client, model_id: str, expected_output: str
):
"""Test model selection by passing model ID strings."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModelWorkflow],
plugins=[AgentPlugin(multi_model_selection_test_agent)],
):
output = await client.execute_workflow(
MultiModelWorkflow.run,
args=['Hello', model_id],
id=f'MultiModelWorkflow_{model_id}',
task_queue=TASK_QUEUE,
)
assert output == expected_output
@pytest.mark.parametrize(
('instance_key', 'expected_output'),
[
pytest.param('default_instance', 'Response from model 1', id='default_instance'),
pytest.param('model_2_instance', 'Response from model 2', id='registered_instance'),
],
)
async def test_temporal_agent_model_selection_by_instance(
allow_model_requests: None, client: Client, instance_key: str, expected_output: str
):
"""Test model selection by passing model instances."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModelWorkflowInstance],
plugins=[AgentPlugin(multi_model_selection_test_agent)],
):
output = await client.execute_workflow(
MultiModelWorkflowInstance.run,
args=['Hello', instance_key],
id=f'MultiModelWorkflowInstance_{instance_key}',
task_queue=TASK_QUEUE,
)
assert output == expected_output
def test_temporal_model_profile_for_raw_strings():
"""Test TemporalModel infers model_name, system, and profile from raw strings without constructing providers."""
default_model = TestModel(custom_output_text='default')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__profile_inference',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
# Without using_model, properties come from default
assert temporal_model.profile == default_model.profile
assert temporal_model.model_name == default_model.model_name
assert temporal_model.system == default_model.system
# With raw string, all properties are inferred correctly
with temporal_model.using_model('openai:gpt-5'):
assert temporal_model.model_name == 'gpt-5'
assert temporal_model.system == 'openai'
assert temporal_model.profile == infer_model_profile('openai:gpt-5')
# Anthropic profile inference includes WebSearchTool support
with temporal_model.using_model('anthropic:claude-sonnet-4-5'):
assert temporal_model.model_name == 'claude-sonnet-4-5'
assert temporal_model.system == 'anthropic'
assert temporal_model.profile == infer_model_profile('anthropic:claude-sonnet-4-5')
# Registered models work correctly for all properties
alt_model = TestModel(custom_output_text='alt', model_name='alt-model')
temporal_model_with_registry = TemporalModel(
default_model,
activity_name_prefix='test__profile_registry',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
models={'alt': alt_model},
)
with temporal_model_with_registry.using_model('alt'):
assert temporal_model_with_registry.model_name == 'alt-model'
assert temporal_model_with_registry.system == alt_model.system
assert temporal_model_with_registry.profile == alt_model.profile
async def test_temporal_model_request_outside_workflow():
"""Test that TemporalModel.request() falls back to wrapped model outside a workflow.
When TemporalModel.request() is called directly (not through TemporalAgent.run())
and not inside a Temporal workflow, it should delegate to the wrapped model's request method.
"""
test_model = TestModel(custom_output_text='Direct model response')
temporal_model = TemporalModel(
test_model,
activity_name_prefix='test__direct_request',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
# Call request() directly - outside a workflow, this should fall back to super().request()
messages: list[ModelMessage] = [ModelRequest.user_text_prompt('Hello')]
response = await temporal_model.request(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(
function_tools=[],
native_tools=[],
output_mode='text',
allow_text_output=True,
output_tools=[],
output_object=None,
),
)
# Verify response comes from the wrapped TestModel
assert any(isinstance(part, TextPart) and part.content == 'Direct model response' for part in response.parts)
async def test_temporal_model_request_stream_outside_workflow():
"""Test that TemporalModel.request_stream() falls back to wrapped model outside a workflow.
When TemporalModel.request_stream() is called directly (not through TemporalAgent.run())
and not inside a Temporal workflow, it should delegate to the wrapped model's request_stream method.
"""
test_model = TestModel(custom_output_text='Direct stream response')
temporal_model = TemporalModel(
test_model,
activity_name_prefix='test__direct_stream',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
# Call request_stream() directly - outside a workflow, this should fall back to super().request_stream()
messages: list[ModelMessage] = [ModelRequest.user_text_prompt('Hello')]
async with temporal_model.request_stream(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(
function_tools=[],
native_tools=[],
output_mode='text',
allow_text_output=True,
output_tools=[],
output_object=None,
),
) as stream:
# Consume the stream
async for _ in stream:
pass
# Get the final response
response = stream.get()
# Verify response comes from the wrapped TestModel
assert any(isinstance(part, TextPart) and part.content == 'Direct stream response' for part in response.parts)
class CustomPydanticPayloadConverter(PydanticPayloadConverter):
"""A custom payload converter that inherits from PydanticPayloadConverter."""
pass
class CustomPayloadConverter(DefaultPayloadConverter):
"""A custom payload converter that does not inherit from PydanticPayloadConverter."""
pass
class MockPayloadCodec(PayloadCodec):
"""A mock payload codec for testing (simulates encryption codec)."""
async def encode(
self, payloads: Sequence[temporalio.api.common.v1.Payload]
) -> list[temporalio.api.common.v1.Payload]: # pragma: no cover
return list(payloads)
async def decode(
self, payloads: Sequence[temporalio.api.common.v1.Payload]
) -> list[temporalio.api.common.v1.Payload]: # pragma: no cover
return list(payloads)
def test_pydantic_ai_plugin_no_converter_returns_pydantic_data_converter() -> None:
"""When no converter is provided, PydanticAIPlugin uses the standard pydantic_data_converter."""
plugin = PydanticAIPlugin()
# Create a minimal config without data_converter
config: dict[str, Any] = {}
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'] is pydantic_data_converter
def test_pydantic_ai_plugin_with_pydantic_payload_converter_unchanged() -> None:
"""When converter already uses PydanticPayloadConverter, return it unchanged."""
plugin = PydanticAIPlugin()
converter = DataConverter(payload_converter_class=PydanticPayloadConverter)
config: dict[str, Any] = {'data_converter': converter}
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'] is converter
def test_pydantic_ai_plugin_with_custom_pydantic_subclass_unchanged() -> None:
"""When converter uses a subclass of PydanticPayloadConverter, return it unchanged (no warning)."""
plugin = PydanticAIPlugin()
converter = DataConverter(payload_converter_class=CustomPydanticPayloadConverter)
config: dict[str, Any] = {'data_converter': converter}
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'] is converter
assert result['data_converter'].payload_converter_class is CustomPydanticPayloadConverter
def test_pydantic_ai_plugin_with_default_payload_converter_replaced() -> None:
"""When converter uses DefaultPayloadConverter, replace payload_converter_class without warning."""
plugin = PydanticAIPlugin()
converter = DataConverter(payload_converter_class=DefaultPayloadConverter)
config: dict[str, Any] = {'data_converter': converter}
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'] is not converter
assert result['data_converter'].payload_converter_class is PydanticPayloadConverter
def test_pydantic_ai_plugin_preserves_custom_payload_codec() -> None:
"""When converter has a custom payload_codec, preserve it while replacing payload_converter_class."""
plugin = PydanticAIPlugin()
codec = MockPayloadCodec()
converter = DataConverter(
payload_converter_class=DefaultPayloadConverter,
payload_codec=codec,
)
config: dict[str, Any] = {'data_converter': converter}
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'] is not converter
assert result['data_converter'].payload_converter_class is PydanticPayloadConverter
assert result['data_converter'].payload_codec is codec
def test_pydantic_ai_plugin_with_non_pydantic_converter_warns() -> None:
"""When converter uses a non-Pydantic payload converter, warn and replace."""
plugin = PydanticAIPlugin()
converter = DataConverter(payload_converter_class=CustomPayloadConverter)
config: dict[str, Any] = {'data_converter': converter}
with pytest.warns(
UserWarning,
match='A non-Pydantic Temporal payload converter was used which has been replaced with PydanticPayloadConverter',
):
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'].payload_converter_class is PydanticPayloadConverter
def test_pydantic_ai_plugin_with_non_pydantic_converter_preserves_codec() -> None:
"""When converter uses a non-Pydantic payload converter with custom codec, warn but preserve codec."""
plugin = PydanticAIPlugin()
codec = MockPayloadCodec()
converter = DataConverter(
payload_converter_class=CustomPayloadConverter,
payload_codec=codec,
)
config: dict[str, Any] = {'data_converter': converter}
with pytest.warns(UserWarning):
result = plugin.configure_client(config) # type: ignore[arg-type]
assert result['data_converter'].payload_converter_class is PydanticPayloadConverter
assert result['data_converter'].payload_codec is codec
def test_temporal_model_profile_with_no_provider_prefix() -> None:
"""Test TemporalModel uses DEFAULT_PROFILE when model string has no inferable provider."""
default_model = TestModel(custom_output_text='default')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__no_provider_prefix',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
# A model string without a provider prefix that can't be inferred returns DEFAULT_PROFILE
with temporal_model.using_model('some-random-model'):
assert temporal_model.profile is DEFAULT_PROFILE
def test_temporal_model_profile_with_unknown_provider() -> None:
"""Test TemporalModel uses DEFAULT_PROFILE when provider is unknown."""
default_model = TestModel(custom_output_text='default')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__unknown_provider',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
# An unknown provider should return DEFAULT_PROFILE
with temporal_model.using_model('unknown-provider:some-model'):
assert temporal_model.profile is DEFAULT_PROFILE
@pytest.mark.parametrize(
'model_id',
[
'openai:gpt-5',
'gateway/openai:gpt-5',
],
)
def test_temporal_model_prepare_request_with_unregistered_model_string(model_id: str) -> None:
"""Test prepare_request uses inferred profile for unregistered model strings.
Verifies that the OpenAI json_schema_transformer is applied to function tool
schemas (adding additionalProperties: false) when using an OpenAI model string,
both directly and via gateway/.
"""
default_model = TestModel(custom_output_text='default')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__prepare_request_unregistered',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
tool_def = ToolDefinition(
name='my_tool',
description='A test tool',
parameters_json_schema={
'type': 'object',
'properties': {'x': {'type': 'integer'}},
'required': ['x'],
},
)
model_request_params = ModelRequestParameters(
function_tools=[tool_def],
native_tools=[],
output_mode='text',
allow_text_output=True,
output_tools=[],
output_object=None,
)
# With an unregistered model string, prepare_request should use the inferred
# profile's json_schema_transformer (OpenAI adds additionalProperties: false)
with temporal_model.using_model(model_id):
_, params = temporal_model.prepare_request(None, model_request_params)
assert params.output_mode == 'text'
assert len(params.function_tools) == 1
assert params.function_tools[0].parameters_json_schema['additionalProperties'] is False
def test_temporal_model_prepare_messages_with_unregistered_model_string() -> None:
"""`prepare_messages` falls back to `Model.prepare_messages` for unregistered model strings.
Mirrors `prepare_request`: when `using_model('openai:...')` swaps in a model the
registry doesn't know, the temporal wrapper has no concrete `Model` instance to
delegate to, so it must invoke the grandparent `Model.prepare_messages` against
its own profile-derived behavior.
"""
default_model = TestModel(custom_output_text='default')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__prepare_messages_unregistered',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
)
messages: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content='hi')])]
with temporal_model.using_model('openai:gpt-5'):
prepared = temporal_model.prepare_messages(messages)
assert prepared == messages
def test_temporal_model_customize_request_parameters_with_registered_model() -> None:
"""Test customize_request_parameters delegates to the currently active registered model."""
class _CustomizingTestModel(TestModel):
def customize_request_parameters(
self, model_request_parameters: ModelRequestParameters
) -> ModelRequestParameters:
return ModelRequestParameters(output_mode='tool', allow_text_output=False)
default_model = TestModel(custom_output_text='default')
alternate_model = _CustomizingTestModel(custom_output_text='alternate')
temporal_model = TemporalModel(
default_model,
activity_name_prefix='test__customize_registered',
activity_config={'start_to_close_timeout': timedelta(seconds=60)},
deps_type=type(None),
models={'alternate': alternate_model},
)
with temporal_model.using_model('alternate'):
customized = temporal_model.customize_request_parameters(ModelRequestParameters())
assert customized.output_mode == 'tool'
assert customized.allow_text_output is False
# Tests for BinaryContent and DocumentUrl serialization in Temporal
# This is a regression test for #3702 (BinaryContent) and verifies that FileUrl
# instances (like DocumentUrl) with explicit media_type are properly preserved.
multimodal_content_agent = Agent(TestModel(), name='multimodal_content_agent')
@multimodal_content_agent.tool
def get_multimodal_content(ctx: RunContext) -> list[str | MultiModalContent]:
"""Return a list with text, BinaryContent, and DocumentUrl."""
return [
'test',
BinaryImage(data=b'\x89PNG', media_type='image/png'),
# URL doesn't hint at media type, so media_type must be specified explicitly
DocumentUrl(url='https://example.com/doc/12345', media_type='application/pdf'),
]
multimodal_content_temporal_agent = TemporalAgent(multimodal_content_agent, activity_config=BASE_ACTIVITY_CONFIG)
@workflow.defn
class MultiModalContentWorkflow:
@workflow.run
async def run(self, prompt: list[UserContent]) -> list[ModelMessage]:
result = await multimodal_content_temporal_agent.run(prompt)
return result.all_messages()
async def test_multimodal_content_serialization_in_workflow(client: Client):
"""Test that BinaryContent and DocumentUrl survive Temporal serialization.
This tests both:
1. Passing BinaryContent and DocumentUrl as input to agent.run (workflow→activity)
2. Returning BinaryContent and DocumentUrl from a tool (activity→workflow)
BinaryContent is serialized with base64 encoding. DocumentUrl requires explicit
media_type since it cannot be inferred from the URL.
"""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModalContentWorkflow],
plugins=[AgentPlugin(multimodal_content_temporal_agent)],
):
# Pass both BinaryContent and DocumentUrl as input
prompt: list[str | MultiModalContent] = [
'Process these files and call the tool',
BinaryImage(data=b'\x89PNG', media_type='image/png'),
DocumentUrl(url='https://example.com/doc/12345', media_type='application/pdf'),
]
messages = await client.execute_workflow(
MultiModalContentWorkflow.run,
args=[prompt],
id='test_multimodal_content_serialization',
task_queue=TASK_QUEUE,
)
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content=[
'Process these files and call the tool',
BinaryImage(data=b'\x89PNG', media_type='image/png', identifier='4effda'),
DocumentUrl(
url='https://example.com/doc/12345',
_media_type='application/pdf',
_identifier='eb8998',
),
],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='get_multimodal_content',
args={},
tool_call_id='pyd_ai_tool_call_id__get_multimodal_content',
)
],
usage=RequestUsage(input_tokens=61, output_tokens=2),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_multimodal_content',
content=[
'test',
BinaryImage(data=b'\x89PNG', media_type='image/png', identifier='4effda'),
DocumentUrl(
url='https://example.com/doc/12345',
_media_type='application/pdf',
_identifier='eb8998',
),
],
tool_call_id='pyd_ai_tool_call_id__get_multimodal_content',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='{"get_multimodal_content":["test",{"data":"iVBORw==","media_type":"image/png","vendor_metadata":null,"kind":"binary","identifier":"4effda"},{"url":"https://example.com/doc/12345","force_download":false,"vendor_metadata":null,"kind":"document-url","media_type":"application/pdf","identifier":"eb8998"}]}'
)
],
usage=RequestUsage(input_tokens=62, output_tokens=34),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# Explicitly verify that media_type is preserved through serialization for both
# BinaryContent and DocumentUrl. This is important because _media_type has compare=False
# on DocumentUrl, so the snapshot comparison doesn't actually verify it. The media_type
# cannot be inferred from the URL, so if serialization loses it, accessing media_type
# would raise an error.
media_types: list[tuple[str, str]] = []
for message in messages:
for part in message.parts:
if isinstance(part, UserPromptPart):
for content in part.content:
if isinstance(content, (BinaryContent, DocumentUrl)):
media_types.append((type(content).__name__, content.media_type))
elif isinstance(part, ToolReturnPart):
for content in part.content_items():
if isinstance(content, (BinaryContent, DocumentUrl)):
media_types.append((type(content).__name__, content.media_type))
# Should have 4 items: 2 from user input, 2 from tool return.
# The image `BinaryContent` round-trips as `BinaryImage`: narrowing is applied during
# `MultiModalContent` validation, so it now survives the Temporal serialization boundary too.
assert media_types == [
('BinaryImage', 'image/png'),
('DocumentUrl', 'application/pdf'),
('BinaryImage', 'image/png'),
('DocumentUrl', 'application/pdf'),
]
nested_multimodal_tool_return_agent = Agent(TestModel(), name='nested_multimodal_tool_return_agent')
@nested_multimodal_tool_return_agent.tool
def get_nested_multimodal_content(ctx: RunContext) -> dict[str, str | MultiModalContent]:
"""Return multimodal content nested inside a mapping."""
return {
'caption': 'see attached',
'attachment': BinaryImage(data=b'\x89PNG', media_type='image/png'),
'source': DocumentUrl(url='https://example.com/doc/12345', media_type='application/pdf'),
}
nested_multimodal_tool_return_temporal_agent = TemporalAgent(
nested_multimodal_tool_return_agent, activity_config=BASE_ACTIVITY_CONFIG
)
@workflow.defn
class NestedMultiModalToolReturnWorkflow:
@workflow.run
async def run(self, prompt: str) -> list[ModelMessage]:
result = await nested_multimodal_tool_return_temporal_agent.run(prompt)
return result.all_messages()
async def test_nested_multimodal_tool_return_survives_temporal(client: Client):
"""Nested multimodal values in tool returns survive the Temporal activity boundary."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[NestedMultiModalToolReturnWorkflow],
plugins=[AgentPlugin(nested_multimodal_tool_return_temporal_agent)],
):
messages = await client.execute_workflow(
NestedMultiModalToolReturnWorkflow.run,
args=['inspect attachment'],
id='test_nested_multimodal_tool_return',
task_queue=TASK_QUEUE,
)
tool_return = next(
part
for message in messages
for part in message.parts
if isinstance(part, ToolReturnPart) and part.tool_name == 'get_nested_multimodal_content'
)
tool_return_content_obj = tool_return.content
assert isinstance(tool_return_content_obj, dict)
tool_return_content = cast(dict[str, object], tool_return_content_obj)
assert tool_return_content['caption'] == 'see attached'
attachment = tool_return_content['attachment']
assert isinstance(attachment, BinaryImage)
assert attachment.media_type == 'image/png'
assert attachment.data == b'\x89PNG'
source = tool_return_content['source']
assert isinstance(source, DocumentUrl)
assert source.media_type == 'application/pdf'
assert source.url == 'https://example.com/doc/12345'
async def test_text_content_serialization_in_workflow(client: Client):
"""Test that TextContent is properly serialized in Temporal."""
async with Worker(
client,
task_queue=TASK_QUEUE,
workflows=[MultiModalContentWorkflow],
plugins=[AgentPlugin(multimodal_content_temporal_agent)],
):
prompt = [
'This is a text content test',
TextContent(content='This should be preserved as TextContent', metadata={'preserved': True}),
]
messages = await client.execute_workflow(
MultiModalContentWorkflow.run,
args=[prompt],
id='test_text_content_serialization',
task_queue=TASK_QUEUE,
)
assert messages[0] == snapshot(
ModelRequest(
parts=[
UserPromptPart(
content=[
'This is a text content test',
TextContent(
content='This should be preserved as TextContent', metadata={'preserved': True}
),
],
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
)