Files
a9169efbc6 Bump pydantic-ai to 2.14.1, migrate durable tests to the durability capabilities, raise the floor (#397)
* Bump pydantic-ai to 2.14.1 and drop model_request_parameters from span snapshots

pydantic-ai 2.14.0 added `ToolDefinition.toolset_id` (pydantic/pydantic-ai#4977),
which changed the serialized `model_request_parameters` span attribute and broke
the managed-prompt baggage snapshot in the `test-latest` job. That attribute is
pydantic-ai's internal request state and drifts on upstream releases, while the
test only asserts baggage propagation, so it is now dropped with the other
volatile span attributes instead of pinned in the snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Migrate durable execution tests to the durability capabilities and raise the floor to 2.14.1

pydantic-ai 2.14 deprecated the DBOSAgent/TemporalAgent wrappers in favor of
the DBOSDurability/TemporalDurability capabilities (pydantic/pydantic-ai#4977);
the 2.14.0 deprecation warnings broke this repo's test-latest job at collection
time before being pulled in 2.14.1, and would break it again if they return.
The tests now exercise the capability style pydantic-ai recommends, and the
pydantic-ai-slim floor moves to >=2.14.1 (the first release with warning-free
capabilities) so the lowest-versions job keeps running the durable tests
instead of skipping them on a pre-capability floor.

The DBOS test also asserts the model request was recorded as a DBOS step: with
the wrapper, run_sync itself started the workflow, but the capability is
transparent outside one, so a passing run alone would not show durability
engaged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Accept pydantic-ai's ImportError in the temporal importorskip

pydantic-ai raises a plain ImportError with install instructions (not
ModuleNotFoundError) when temporalio is missing, and importorskip only
auto-skips on ModuleNotFoundError -- anything else emits a
PytestDeprecationWarning, which filterwarnings=error turned into the slim-job
failures on this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Register DBOS durability before recovery starts

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:10:06 +05:30

195 lines
7.0 KiB
Python

"""DBOS integration tests for CodeMode.
Verifies that the snapshot-based execution loop works inside a DBOS
durable workflow. DBOS uses SQLite locally -- no external services needed.
Durability is attached via the `DBOSDurability` capability; pydantic-ai 2.14
deprecated the `DBOSAgent` wrapper in its favor (pydantic/pydantic-ai#4977).
With the capability, the caller owns the workflow: `agent.run_sync()` is
called inside an explicit `@DBOS.workflow()` rather than the wrapper starting
one. The agent and workflow are defined at module scope so DBOS registers the
capability's steps and the workflow before `DBOS.launch()`.
DBOS defaults to `parallel_ordered_events` execution mode, which triggers
the sequential FutureSnapshot resolution path in the execution loop.
"""
from __future__ import annotations
import json
import uuid
from collections.abc import Generator
from typing import Any
import pytest
try:
from dbos import DBOS, DBOSConfig, SetWorkflowID
from pydantic_ai.durable_exec.dbos import DBOSDurability
except ImportError: # pragma: lax no cover
pytest.skip('dbos not installed', allow_module_level=True)
from pydantic_ai import Agent, ToolDefinition
from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, ToolCallPart, ToolReturnPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.toolsets.function import FunctionToolset
from pydantic_ai_harness import CodeMode
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope='module')
def dbos_instance(tmp_path_factory: pytest.TempPathFactory) -> Generator[DBOS, Any, None]:
dbos_sqlite_file = tmp_path_factory.mktemp('dbos') / 'dbostest.sqlite'
dbos_config: DBOSConfig = {
'name': 'pydantic_ai_harness_dbos_tests',
'system_database_url': f'sqlite:///{dbos_sqlite_file}',
'run_admin_server': False,
'enable_otlp': False,
}
dbos = DBOS(config=dbos_config)
DBOS.launch()
try:
yield dbos
finally:
DBOS.destroy()
# ---------------------------------------------------------------------------
# Tools and model
# ---------------------------------------------------------------------------
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
_captured_tool_defs: list[list[ToolDefinition]] = []
def _code_mode_model(messages: list[ModelRequest | ModelResponse], info: AgentInfo) -> ModelResponse:
_captured_tool_defs.append(info.function_tools)
for msg in messages:
if isinstance(msg, ModelResponse):
continue
for part in msg.parts:
if isinstance(part, ToolReturnPart) and part.tool_name == 'run_code':
return ModelResponse(parts=[TextPart(content=f'done: {part.content}')])
return ModelResponse(
parts=[
ToolCallPart(
tool_name='run_code',
args={'code': 'result = await add(a=3, b=4)\nresult'},
tool_call_id='test_tc_1',
)
]
)
code_mode_agent = Agent(
FunctionModel(_code_mode_model),
name='code_mode_dbos_agent',
toolsets=[FunctionToolset(tools=[add], id='math')],
capabilities=[CodeMode(), DBOSDurability()],
)
@DBOS.workflow()
def run_code_mode_agent(prompt: str) -> dict[str, Any]:
result = code_mode_agent.run_sync(prompt)
return {'output': str(result.output), 'messages': result.all_messages_json().decode()}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_code_mode_runs_in_dbos_workflow(dbos_instance: DBOS) -> None:
"""CodeMode's snapshot-based execution loop works inside a DBOS durable
workflow. DBOS defaults to `parallel_ordered_events` mode, which triggers
the sequential FutureSnapshot resolution path.
The step-log assertion at the end proves the capability actually routed the
model requests through DBOS steps: `DBOSDurability` is transparent outside
a workflow, so a passing run alone would not show durability engaged."""
_captured_tool_defs.clear()
workflow_id = str(uuid.uuid4())
with SetWorkflowID(workflow_id):
payload = run_code_mode_agent('Calculate 3 + 4')
assert payload['output'] == 'done: 7'
messages = json.loads(payload['messages'])
assert len(messages) == 4
# 1. User prompt
assert messages[0]['kind'] == 'request'
assert messages[0]['parts'][0]['part_kind'] == 'user-prompt'
assert messages[0]['parts'][0]['content'] == 'Calculate 3 + 4'
# 2. Model response -- run_code tool call
assert messages[1]['kind'] == 'response'
tc = messages[1]['parts'][0]
assert tc['part_kind'] == 'tool-call'
assert tc['tool_name'] == 'run_code'
assert tc['args'] == {'code': 'result = await add(a=3, b=4)\nresult'}
assert tc['tool_call_id'] == 'test_tc_1'
# 3. Tool return with nested tool call metadata
assert messages[2]['kind'] == 'request'
tr = messages[2]['parts'][0]
assert tr['part_kind'] == 'tool-return'
assert tr['tool_name'] == 'run_code'
assert tr['content'] == 7
assert tr['tool_call_id'] == 'test_tc_1'
metadata = tr['metadata']
assert metadata is not None
assert metadata['code_mode'] is True
nested_calls = metadata['tool_calls']
nested_returns = metadata['tool_returns']
assert len(nested_calls) == 1
assert len(nested_returns) == 1
nested_call = next(iter(nested_calls.values()))
assert nested_call['tool_name'] == 'add'
assert nested_call['args'] == {'a': 3, 'b': 4}
nested_return = next(iter(nested_returns.values()))
assert nested_return['tool_name'] == 'add'
assert nested_return['content'] == 7
assert nested_return['tool_call_id'] == nested_call['tool_call_id']
# 4. Final text response
assert messages[3]['kind'] == 'response'
assert messages[3]['parts'][0]['part_kind'] == 'text'
assert messages[3]['parts'][0]['content'] == 'done: 7'
# 5. Verify tool definitions sent to the model
# The model was called twice (first request + after tool return), both
# should see the same tool definitions.
assert len(_captured_tool_defs) == 2
for tool_defs in _captured_tool_defs:
tool_names = [td.name for td in tool_defs]
# CodeMode wraps `add` into `run_code` -- the model should only see `run_code`
assert 'run_code' in tool_names
assert 'add' not in tool_names
run_code_td = next(td for td in tool_defs if td.name == 'run_code')
assert run_code_td.description is not None
assert 'async def add' in run_code_td.description
assert run_code_td.parameters_json_schema['properties']['code']['type'] == 'string'
# 6. Durability engaged: model requests were recorded as DBOS steps
steps = dbos_instance.list_workflow_steps(workflow_id)
step_names = [step['function_name'] for step in steps]
assert step_names.count('code_mode_dbos_agent__model.request') == 2