-
Notifications
You must be signed in to change notification settings - Fork 19
Add Pydantic AI Durable Capability Example #552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
nvasiu
wants to merge
1
commit into
main
Choose a base branch
from
pydantic-ai-durable-capability
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
206 changes: 206 additions & 0 deletions
206
...ages/aws-durable-execution-sdk-python-examples/src/pydantic_ai_agent/lambda_durability.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| # The capability bridges the sync Lambda durable functions API with the async | ||
| # agent, using two threads connected by a queue: | ||
| # | ||
| # - Main thread: runs the durable handler and its steps. | ||
| # - Background thread: runs the async agent and its model/tool calls. | ||
| # | ||
| # On each model/tool call the agent enqueues a step request. The main thread | ||
| # consumes the queue, runs the durable step, and dispatches the actual async | ||
| # call back to the background loop. | ||
|
|
||
| import asyncio | ||
| import contextvars | ||
| import threading | ||
| from concurrent.futures import Future | ||
| from queue import Queue | ||
| from typing import Any | ||
|
|
||
| from aws_durable_execution_sdk_python import DurableContext | ||
| from aws_durable_execution_sdk_python.config import StepConfig | ||
|
|
||
| try: | ||
| from pydantic_ai.capabilities import AbstractCapability | ||
| except ImportError: | ||
|
|
||
| class AbstractCapability: # type: ignore[no-redef] | ||
| pass | ||
|
|
||
|
|
||
| # Durable steps checkpoint JSON data by default. | ||
| # Use these to convert model responses and retry prompts from/to JSON. | ||
| def _model_response_adapter() -> Any: | ||
| from pydantic import TypeAdapter | ||
| from pydantic_ai.messages import ModelResponse | ||
|
|
||
| return TypeAdapter(ModelResponse) | ||
|
|
||
|
|
||
| def _retry_prompt_adapter() -> Any: | ||
| from pydantic import TypeAdapter | ||
| from pydantic_ai.messages import RetryPromptPart | ||
|
|
||
| return TypeAdapter(RetryPromptPart) | ||
|
|
||
|
|
||
| # Persistent background-thread event loop that runs the agent and its | ||
| # model/tool calls, reused across warm invocations so loop-bound async | ||
| # resources (e.g. a cached HTTP client) stay valid. | ||
| class _AgentLoop: | ||
| def __init__(self): | ||
| self._lock = threading.Lock() | ||
| self._loop: asyncio.AbstractEventLoop | None = None | ||
|
|
||
| def get(self) -> asyncio.AbstractEventLoop: | ||
| with self._lock: | ||
| if self._loop is None or self._loop.is_closed(): | ||
| self._loop = asyncio.new_event_loop() | ||
| threading.Thread(target=self._loop.run_forever, daemon=True).start() | ||
| return self._loop | ||
|
|
||
|
|
||
| _agent_loop = _AgentLoop() | ||
|
|
||
|
|
||
| # Queue-based bridge: the agent runs on the background thread and sends step | ||
| # requests to the queue. The main handler thread consumes the queue and runs | ||
| # context.step(), so every durable step is created on the same thread as the | ||
| # handler (one continuous context). | ||
| class _StepQueue: | ||
| def __init__(self): | ||
| self._queue: Queue = Queue() | ||
|
|
||
| # Run on background thread: add step to queue, await result from main thread | ||
| async def run_step(self, step_body, name, config, context): | ||
| loop = asyncio.get_running_loop() | ||
| future = loop.create_future() | ||
|
|
||
| def reply(success, value): | ||
| fn = future.set_result if success else future.set_exception | ||
| loop.call_soon_threadsafe(fn, value) | ||
|
|
||
| # Copy the agent's contextvars to the durable step. | ||
| ctx = contextvars.copy_context() | ||
| self._queue.put(("step", step_body, name, config, context, reply, ctx)) | ||
| return await future | ||
|
|
||
| def finish(self, result=None, error=None): | ||
| self._queue.put(("done", result, error)) | ||
|
|
||
| # Run on main thread: execute queued durable steps until finish() | ||
| def consume(self): | ||
| while True: | ||
| item = self._queue.get() | ||
| if item[0] == "done": | ||
| _, result, error = item | ||
| if error is not None: | ||
| raise error | ||
| return result | ||
| _, step_body, name, config, context, reply, ctx = item | ||
| try: | ||
| reply(True, ctx.run(context.step, step_body, name=name, config=config)) | ||
| except BaseException as e: | ||
| reply(False, e) | ||
|
|
||
|
|
||
| class LambdaDurability(AbstractCapability): | ||
| def __init__( | ||
| self, | ||
| context: DurableContext, | ||
| step_config: StepConfig | None = None, | ||
| tool_configs: dict[str, StepConfig] | None = None, | ||
| ): | ||
| self.context = context | ||
| self.step_config = step_config | ||
| self.tool_configs = tool_configs or {} | ||
| self.step_queue: _StepQueue | None = None | ||
|
|
||
| async def wrap_run(self, ctx, *, handler): | ||
| from pydantic_ai.tool_manager import ToolManager | ||
|
|
||
| # Run tool calls one at a time. Durable step IDs are assigned by | ||
| # call order, so concurrent step() calls could race and break replay. | ||
| with ToolManager.parallel_execution_mode("sequential"): | ||
| return await handler() | ||
|
|
||
| async def _durable_step(self, coro_factory, name, config): | ||
| loop = asyncio.get_running_loop() | ||
|
|
||
| # step_body runs on the main thread. It schedules the async model/tool | ||
| # call back onto the background thread (which stays free while we await). | ||
| def step_body(_step_ctx): | ||
| future: Future = Future() | ||
|
|
||
| # Copy the durable step's contextvars to model/tool calls. | ||
| step_ctx = contextvars.copy_context() | ||
|
|
||
| def on_done(task): | ||
| if task.exception(): | ||
| future.set_exception(task.exception()) | ||
| else: | ||
| future.set_result(task.result()) | ||
|
|
||
| def schedule(): | ||
| task = loop.create_task(coro_factory(), context=step_ctx) | ||
| task.add_done_callback(on_done) | ||
|
|
||
| loop.call_soon_threadsafe(schedule) | ||
| return future.result() | ||
|
|
||
| return await self.step_queue.run_step(step_body, name, config, self.context) | ||
|
|
||
| async def wrap_model_request(self, ctx, *, request_context, handler): | ||
| adapter = _model_response_adapter() | ||
|
|
||
| async def call_model(): | ||
| response = await handler(request_context) | ||
| return adapter.dump_python(response, mode="json") | ||
|
|
||
| checkpointed = await self._durable_step( | ||
| call_model, "model.request", self.step_config | ||
| ) | ||
| return adapter.validate_python(checkpointed) | ||
|
|
||
| async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler): | ||
| from pydantic_ai.exceptions import ToolRetryError | ||
|
|
||
| adapter = _retry_prompt_adapter() | ||
|
|
||
| # A ToolRetryError is a control signal, not a failure. Checkpoint it as | ||
| # a normal result inside the step, then re-raise it outside so the step | ||
| # is not recorded as failed. | ||
| async def call_tool(): | ||
| try: | ||
| return {"retry": False, "value": await handler(args)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return value from handler may not be serializable. |
||
| except ToolRetryError as e: | ||
| retry_prompt = adapter.dump_python(e.tool_retry, mode="json") | ||
| return {"retry": True, "retry_prompt": retry_prompt} | ||
|
|
||
| config = self.tool_configs.get(tool_def.name, self.step_config) | ||
| result = await self._durable_step(call_tool, f"tool.{tool_def.name}", config) | ||
| if result["retry"]: | ||
| raise ToolRetryError(adapter.validate_python(result["retry_prompt"])) | ||
| return result["value"] | ||
|
|
||
|
|
||
| # Run the agent on the background thread while the main thread consumes | ||
| # and executes its durable steps from the queue. | ||
| def run_durable(coro_factory, capability: LambdaDurability): | ||
| step_queue = _StepQueue() | ||
| capability.step_queue = step_queue | ||
| loop = _agent_loop.get() | ||
|
|
||
| async def run_agent(): | ||
| try: | ||
| step_queue.finish(result=await coro_factory()) | ||
| except BaseException as e: | ||
| step_queue.finish(error=e) | ||
|
|
||
| # Copy the handler's contextvars to the agent. Fresh copy each invocation | ||
| # so warm starts do not reuse a stale context. | ||
| ctx = contextvars.copy_context() | ||
|
|
||
| def schedule(): | ||
| loop.create_task(run_agent(), context=ctx) | ||
|
|
||
| loop.call_soon_threadsafe(schedule) | ||
| return step_queue.consume() # blocks on the main thread until the agent finishes | ||
Binary file added
BIN
+5.23 MB
...ges/aws-durable-execution-sdk-python-examples/src/pydantic_ai_agent/pydantic-ai-agent.zip
Binary file not shown.
32 changes: 32 additions & 0 deletions
32
...ages/aws-durable-execution-sdk-python-examples/src/pydantic_ai_agent/pydantic_ai_agent.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import os | ||
|
|
||
| from aws_durable_execution_sdk_python import DurableContext, durable_execution | ||
| from pydantic_ai import Agent, RunContext | ||
| from pydantic_ai.models.bedrock import BedrockConverseModel | ||
|
|
||
| try: | ||
| from lambda_durability import LambdaDurability, run_durable | ||
| except ImportError: | ||
| from src.pydantic_ai_agent.lambda_durability import LambdaDurability, run_durable | ||
|
|
||
| MODEL_ID = os.environ.get("PYDANTIC_AI_MODEL", "us.amazon.nova-micro-v1:0") | ||
|
|
||
| agent = Agent(BedrockConverseModel(MODEL_ID)) | ||
|
|
||
|
|
||
| @agent.tool | ||
| def get_weather(ctx: RunContext, city: str) -> str: | ||
| return f"It is sunny and 22C in {city}." | ||
|
|
||
|
|
||
| @durable_execution | ||
| def handler(event: dict, context: DurableContext) -> str: | ||
| prompt = event.get("prompt", "What is the weather in Vancouver?") | ||
| capability = LambdaDurability(context) | ||
| result = run_durable( | ||
| lambda: agent.run(prompt, capabilities=[capability]), | ||
| capability, | ||
| ) | ||
| return result.output |
84 changes: 84 additions & 0 deletions
84
...ws-durable-execution-sdk-python-examples/test/pydantic_ai_agent/test_pydantic_ai_agent.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Tests for the Pydantic AI durable agent example.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import threading | ||
|
|
||
| import pytest | ||
|
|
||
| from aws_durable_execution_sdk_python.execution import InvocationStatus | ||
| from src.pydantic_ai_agent import lambda_durability | ||
| from test.conftest import deserialize_operation_payload | ||
|
|
||
|
|
||
| class FakeStepContext: | ||
| pass | ||
|
|
||
|
|
||
| class FakeDurableContext: | ||
| """Records the durable steps run against it and the thread they run on.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| self.steps: list[str] = [] | ||
| self.step_thread_ids: list[int] = [] | ||
|
|
||
| def step(self, func, name=None, config=None): | ||
| self.step_thread_ids.append(threading.get_ident()) | ||
| self.steps.append(name) | ||
| return func(FakeStepContext()) | ||
|
|
||
|
|
||
| @pytest.mark.example | ||
| def test_run_durable_runs_steps_on_the_handler_thread(): | ||
| context = FakeDurableContext() | ||
| handler_thread_id = threading.get_ident() | ||
| agent_thread_ids: list[int] = [] | ||
|
|
||
| capability = lambda_durability.LambdaDurability(context) | ||
|
|
||
| async def call_model(): | ||
| agent_thread_ids.append(threading.get_ident()) | ||
| return {"answer": 42} | ||
|
|
||
| async def run_agent(): | ||
| # Exercise a single durable step through the queue bridge. | ||
| return await capability._durable_step(call_model, "model.request", None) | ||
|
|
||
| result = lambda_durability.run_durable(run_agent, capability) | ||
|
|
||
| assert result == {"answer": 42} | ||
| # The durable step ran on the handler (main) thread. | ||
| assert context.step_thread_ids == [handler_thread_id] | ||
| assert context.steps == ["model.request"] | ||
| # The model call ran on the background agent thread, not the handler thread. | ||
| assert agent_thread_ids and agent_thread_ids[0] != handler_thread_id | ||
|
|
||
|
|
||
| @pytest.mark.example | ||
| def test_agent_loop_thread_is_reused_across_invocations(): | ||
| def loop_identity() -> int: | ||
| return id(lambda_durability._agent_loop.get()) | ||
|
|
||
| assert loop_identity() == loop_identity() | ||
|
|
||
|
|
||
| @pytest.mark.example | ||
| @pytest.mark.durable_execution( | ||
| lambda_function_name="Pydantic AI Durable Agent", | ||
| ) | ||
| def test_pydantic_ai_agent_runs_in_cloud(durable_runner): | ||
| if durable_runner.mode != "cloud": | ||
| pytest.skip("Pydantic AI Bedrock example only runs in cloud mode") | ||
|
|
||
| with durable_runner: | ||
| result = durable_runner.run( | ||
| input={"prompt": "What is the weather in Vancouver?"}, | ||
| timeout=120, | ||
| ) | ||
|
|
||
| assert result.status is InvocationStatus.SUCCEEDED | ||
| assert deserialize_operation_payload(result.result) | ||
| operation_names = {operation.name for operation in result.operations} | ||
| assert any(name and name.startswith("model.request") for name in operation_names) | ||
| assert any(name and name.startswith("tool.") for name in operation_names) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
future will not be resolved if this call raises an exception.