From 46577a243064522f3113554ea73b855cda2024b7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 2 Apr 2026 04:44:38 +0000 Subject: [PATCH 1/3] Add SubAgent capability for agent-to-agent task delegation Implements the Agent-as-Tool pattern: a parent agent can delegate tasks to named sub-agents via a `delegate_task` tool that blocks until the sub-agent finishes and returns its text output as the tool result. Refs #32 Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 36 ++++ pyproject.toml | 1 + src/pydantic_harness/__init__.py | 6 +- src/pydantic_harness/subagent.py | 141 ++++++++++++++ tests/test_subagent.py | 312 +++++++++++++++++++++++++++++++ uv.lock | 153 +++++---------- 6 files changed, 538 insertions(+), 111 deletions(-) create mode 100644 PLAN.md create mode 100644 src/pydantic_harness/subagent.py create mode 100644 tests/test_subagent.py diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8a6e1fa --- /dev/null +++ b/PLAN.md @@ -0,0 +1,36 @@ +# SubAgent Capability + +## Problem + +When building multi-agent systems with Pydantic AI, there's no reusable capability for delegating tasks from a parent (orchestrator) agent to specialized sub-agents. Users currently have to manually wire up tool functions that call `agent.run()`, duplicate boilerplate for description injection, and handle error cases like unknown agent names. + +## Solution + +A `SubAgent` capability (implementing `AbstractCapability`) that: + +1. Accepts a dict of named `Agent` instances +2. Provides a `delegate_task(agent_name, task)` tool to the parent agent +3. Injects sub-agent descriptions into the system prompt so the parent knows what's available +4. Forwards the parent's `deps` to sub-agents (configurable via `pass_deps`) +5. Returns sub-agent output as a string tool result +6. Raises `ModelRetry` for unknown agent names (self-correcting) + +## Design decisions + +- **Synchronous delegation only** (for now): the `delegate_task` tool blocks until the sub-agent finishes. This is the simplest correct behavior and matches the "Agent-as-Tool" pattern from OpenAI Agents SDK. Async background tasks (#32 scope expansion) and full handoffs (#44) are left for follow-up. +- **Descriptions from agent metadata**: falls back through `agent.description`, `agent.name`, then a default. Users can also pass explicit `descriptions` dict. +- **Not spec-serializable**: since it takes `Agent` instances, YAML/JSON serialization is not supported (`get_serialization_name()` returns `None`). +- **`str()` conversion of output**: all sub-agent outputs are converted to string for the tool result, regardless of the sub-agent's `output_type`. + +## Files + +- `src/pydantic_harness/subagent.py` — the `SubAgent` capability +- `src/pydantic_harness/__init__.py` — re-exports `SubAgent` +- `tests/test_subagent.py` — 19 tests covering construction, instructions, toolset, end-to-end delegation (deps forwarding, unknown agent retry, multiple agents), and imports +- `pyproject.toml` — added `pytest-asyncio` dev dependency + +## References + +- Issue #32: SubAgent / Agent-as-Tool capability +- Issue #44: Handoff / Agent Transfer (follow-up, blocked by this) +- Prior art: [vstorm-co/subagents-pydantic-ai](https://github.com/vstorm-co/subagents-pydantic-ai), OpenAI Agents SDK handoffs, Google ADK sub_agents, Pydantic AI's `ImageGeneration` capability subagent pattern diff --git a/pyproject.toml b/pyproject.toml index 0d573a0..9faa49b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,3 +100,4 @@ exclude_lines = [ 'assert_never', 'if TYPE_CHECKING:', ] + diff --git a/src/pydantic_harness/__init__.py b/src/pydantic_harness/__init__.py index 9d728b6..020347f 100644 --- a/src/pydantic_harness/__init__.py +++ b/src/pydantic_harness/__init__.py @@ -7,4 +7,8 @@ # Each capability module is imported and re-exported here. # Capabilities are listed alphabetically. -__all__: list[str] = [] +from .subagent import SubAgent + +__all__: list[str] = [ + 'SubAgent', +] diff --git a/src/pydantic_harness/subagent.py b/src/pydantic_harness/subagent.py new file mode 100644 index 0000000..ca31171 --- /dev/null +++ b/src/pydantic_harness/subagent.py @@ -0,0 +1,141 @@ +"""SubAgent capability: delegate tasks from a parent agent to specialized sub-agents.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from pydantic_ai import Agent +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT, RunContext, Tool +from pydantic_ai.toolsets import AgentToolset +from pydantic_ai.toolsets.function import FunctionToolset + +__all__ = ('SubAgent',) + + +def _resolve_description(name: str, agent: Agent[Any]) -> str: + """Derive a description for a sub-agent from its metadata.""" + if agent.description: + return agent.description + if agent.name: + return agent.name + return f'Sub-agent: {name}' + + +@dataclass +class SubAgent(AbstractCapability[AgentDepsT]): + """Capability that lets a parent agent delegate tasks to named sub-agents. + + Each sub-agent is an independent `Agent` instance. The parent agent receives + a `delegate_task` tool that runs a named sub-agent with a given prompt and + returns its text output as the tool result. + + Example: + ```python + from pydantic_ai import Agent + from pydantic_harness.subagent import SubAgent + + researcher = Agent('openai:gpt-4o', description='Researches topics thoroughly.') + coder = Agent('openai:gpt-4o', description='Writes and reviews code.') + + orchestrator = Agent( + 'openai:gpt-4o', + capabilities=[ + SubAgent(agents={'researcher': researcher, 'coder': coder}), + ], + ) + ``` + """ + + agents: dict[str, Agent[Any]] + """Mapping of agent name to `Agent` instance. + + Names are used by the parent agent in the `delegate_task` tool to select + which sub-agent to run. + """ + + descriptions: dict[str, str] = field(default_factory=dict[str, str]) + """Optional explicit descriptions for each sub-agent. + + These are included in the system prompt and in the `delegate_task` tool + description so the parent agent knows what each sub-agent does. + + When a name is not present in this dict, the description is derived from + `agent.description`, `agent.name`, or a default. + """ + + pass_deps: bool = True + """Whether to forward the parent agent's `deps` to sub-agents. + + When True (the default), sub-agents receive the same dependency object + as the parent. Set to False if sub-agents use incompatible dependency types. + """ + + _resolved_descriptions: dict[str, str] = field(default_factory=dict[str, str], init=False, repr=False) + + def __post_init__(self) -> None: + """Resolve descriptions for all registered sub-agents.""" + for name, agent in self.agents.items(): + if name in self.descriptions: + self._resolved_descriptions[name] = self.descriptions[name] + else: + self._resolved_descriptions[name] = _resolve_description(name, agent) + + @classmethod + def get_serialization_name(cls) -> str | None: + """Not spec-serializable (takes Agent instances).""" + return None + + def get_instructions(self) -> str | None: + """Inject descriptions of available sub-agents into the system prompt.""" + if not self.agents: + return None + + lines = ['You can delegate tasks to the following sub-agents using the `delegate_task` tool:'] + for name in self.agents: + desc = self._resolved_descriptions[name] + lines.append(f'- **{name}**: {desc}') + return '\n'.join(lines) + + def get_toolset(self) -> AgentToolset[AgentDepsT] | None: + """Provide the `delegate_task` tool.""" + if not self.agents: + return None + + agents = self.agents + pass_deps = self.pass_deps + + async def delegate_task(ctx: RunContext[AgentDepsT], agent_name: str, task: str) -> str: + """Delegate a task to a named sub-agent and return its text output. + + Args: + ctx: The run context from the parent agent. + agent_name: The name of the sub-agent to run. Must be one of the registered agent names. + task: The prompt describing the task to delegate. + """ + agent = agents.get(agent_name) + if agent is None: + available = ', '.join(sorted(agents)) + raise ModelRetry(f'Unknown agent {agent_name!r}. Available agents: {available}') + + deps = ctx.deps if pass_deps else None + result = await agent.run(task, deps=deps) + return str(result.output) + + tool = Tool[AgentDepsT]( + delegate_task, + name='delegate_task', + description=self._delegate_task_description(), + ) + return FunctionToolset[AgentDepsT]([tool]) + + def _delegate_task_description(self) -> str: + """Build a description for the delegate_task tool including available agent names.""" + parts: list[str] = [] + for name in self.agents: + desc = self._resolved_descriptions[name] + parts.append(f'{name} ({desc})') + agent_list = ', '.join(parts) + return f'Delegate a task to a sub-agent. Available agents: {agent_list}' diff --git a/tests/test_subagent.py b/tests/test_subagent.py new file mode 100644 index 0000000..3735c01 --- /dev/null +++ b/tests/test_subagent.py @@ -0,0 +1,312 @@ +"""Tests for the SubAgent capability.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from pydantic_ai import Agent, RunContext, Tool +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart +from pydantic_ai.models.function import AgentInfo, FunctionModel +from pydantic_ai.models.test import TestModel + +from pydantic_harness.subagent import SubAgent + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_delegate_call(agent_name: str, task: str, tool_call_id: str = 'call-1') -> ModelResponse: + """Build a ModelResponse that calls the delegate_task tool.""" + return ModelResponse( + parts=[ + ToolCallPart( + tool_name='delegate_task', + args=json.dumps({'agent_name': agent_name, 'task': task}), + tool_call_id=tool_call_id, + ) + ] + ) + + +def _parent_model_that_delegates(agent_name: str, task: str) -> FunctionModel: + """Create a FunctionModel that delegates once, then returns a text answer.""" + call_count = 0 + + def handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_call(agent_name, task) + return ModelResponse(parts=[TextPart('Parent final answer')]) + + return FunctionModel(handle) + + +def _simple_sub_model(output: str = 'sub done') -> FunctionModel: + """A sub-agent model that always returns the given text.""" + return FunctionModel(lambda msgs, info: ModelResponse(parts=[TextPart(output)])) + + +def _sub_model_with_tool(tool_name: str) -> FunctionModel: + """A sub-agent model that calls a tool on first request, then returns text.""" + call_count = 0 + + def handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1 and info.function_tools: + return ModelResponse(parts=[ToolCallPart(tool_name=tool_name, args='{}', tool_call_id='sub-call-1')]) + return ModelResponse(parts=[TextPart('sub done')]) + + return FunctionModel(handle) + + +# --------------------------------------------------------------------------- +# Tools defined at module level to avoid annotation resolution issues +# --------------------------------------------------------------------------- + + +_captured_deps: list[Any] = [] + + +async def _capture_deps_tool(ctx: RunContext[Any]) -> str: + """Capture deps via RunContext.""" + _captured_deps.append(ctx.deps) + return 'captured' + + +async def _check_deps_tool(ctx: RunContext[Any]) -> str: + """Check that deps is None.""" + _captured_deps.append(ctx.deps) + return 'checked' + + +# --------------------------------------------------------------------------- +# Construction tests +# --------------------------------------------------------------------------- + + +class TestSubAgentConstruction: + """Test SubAgent construction and configuration.""" + + def test_is_capability(self) -> None: + agent: Agent[None] = Agent(TestModel(), description='Helper') + cap = SubAgent[None](agents={'helper': agent}) + assert isinstance(cap, AbstractCapability) + + def test_descriptions_from_agent_description(self) -> None: + agent: Agent[None] = Agent(TestModel(), description='Researches topics') + cap = SubAgent[None](agents={'researcher': agent}) + assert cap._resolved_descriptions['researcher'] == 'Researches topics' # pyright: ignore[reportPrivateUsage] + + def test_descriptions_from_agent_name(self) -> None: + agent: Agent[None] = Agent(TestModel(), name='my-helper') + cap = SubAgent[None](agents={'helper': agent}) + assert cap._resolved_descriptions['helper'] == 'my-helper' # pyright: ignore[reportPrivateUsage] + + def test_descriptions_fallback(self) -> None: + agent: Agent[None] = Agent(TestModel()) + cap = SubAgent[None](agents={'helper': agent}) + assert cap._resolved_descriptions['helper'] == 'Sub-agent: helper' # pyright: ignore[reportPrivateUsage] + + def test_explicit_descriptions_override(self) -> None: + agent: Agent[None] = Agent(TestModel(), description='From agent') + cap = SubAgent[None]( + agents={'helper': agent}, + descriptions={'helper': 'Custom description'}, + ) + assert cap._resolved_descriptions['helper'] == 'Custom description' # pyright: ignore[reportPrivateUsage] + + def test_get_serialization_name_is_none(self) -> None: + assert SubAgent.get_serialization_name() is None + + def test_empty_agents_instructions_none(self) -> None: + cap: SubAgent[None] = SubAgent(agents={}) + assert cap.get_instructions() is None + + def test_empty_agents_toolset_none(self) -> None: + cap: SubAgent[None] = SubAgent(agents={}) + assert cap.get_toolset() is None + + +# --------------------------------------------------------------------------- +# Instructions tests +# --------------------------------------------------------------------------- + + +class TestSubAgentInstructions: + """Test system prompt injection.""" + + def test_instructions_list_agents(self) -> None: + a: Agent[None] = Agent(TestModel(), description='Researches topics') + b: Agent[None] = Agent(TestModel(), description='Writes code') + cap = SubAgent[None](agents={'researcher': a, 'coder': b}) + instructions = cap.get_instructions() + assert instructions is not None + assert 'researcher' in instructions + assert 'coder' in instructions + assert 'delegate_task' in instructions + + def test_instructions_include_descriptions(self) -> None: + agent: Agent[None] = Agent(TestModel(), description='Does math') + cap = SubAgent[None](agents={'calculator': agent}) + instructions = cap.get_instructions() + assert instructions is not None + assert 'Does math' in instructions + + +# --------------------------------------------------------------------------- +# Toolset tests +# --------------------------------------------------------------------------- + + +class TestSubAgentToolset: + """Test that the delegate_task tool is registered correctly.""" + + def test_get_toolset_not_none(self) -> None: + agent: Agent[None] = Agent(TestModel(), description='Helper') + cap = SubAgent[None](agents={'helper': agent}) + toolset = cap.get_toolset() + assert toolset is not None + + def test_tool_description_lists_agents(self) -> None: + a: Agent[None] = Agent(TestModel(), description='Researches') + b: Agent[None] = Agent(TestModel(), description='Codes') + cap = SubAgent[None](agents={'researcher': a, 'coder': b}) + desc = cap._delegate_task_description() # pyright: ignore[reportPrivateUsage] + assert 'researcher' in desc + assert 'coder' in desc + assert 'Researches' in desc + assert 'Codes' in desc + + +# --------------------------------------------------------------------------- +# End-to-end delegation tests +# --------------------------------------------------------------------------- + + +class TestSubAgentDelegation: + """Test end-to-end delegation via agent.run.""" + + @pytest.mark.anyio + async def test_delegate_returns_sub_output(self) -> None: + """The parent delegates to a sub-agent and gets its output as a tool result.""" + sub: Agent[None] = Agent(_simple_sub_model('hello from sub'), description='Echoes input') + + parent_model = _parent_model_that_delegates('echo', 'say hello') + parent: Agent[None] = Agent( + parent_model, + capabilities=[SubAgent[None](agents={'echo': sub})], + ) + result = await parent.run('Please delegate') + assert result.output == 'Parent final answer' + + @pytest.mark.anyio + async def test_unknown_agent_triggers_model_retry(self) -> None: + """Calling delegate_task with an unknown name raises ModelRetry, then the model corrects itself.""" + sub: Agent[None] = Agent(_simple_sub_model(), description='Echoes') + + call_count = 0 + + def handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_call('nonexistent', 'test') + if call_count == 2: + return _make_delegate_call('echo', 'test') + return ModelResponse(parts=[TextPart('Done')]) + + parent: Agent[None] = Agent( + FunctionModel(handle), + capabilities=[SubAgent[None](agents={'echo': sub})], + ) + result = await parent.run('Go') + assert result.output == 'Done' + + @pytest.mark.anyio + async def test_deps_passed_to_subagent(self) -> None: + """When pass_deps=True, the sub-agent receives the parent's deps.""" + _captured_deps.clear() + + sub: Agent[Any] = Agent( + _sub_model_with_tool('capture_deps'), + description='Captures deps', + tools=[Tool(_capture_deps_tool, name='capture_deps')], + ) + + parent_model = _parent_model_that_delegates('sub', 'do it') + parent: Agent[Any] = Agent( + parent_model, + capabilities=[SubAgent[Any](agents={'sub': sub}, pass_deps=True)], + ) + result = await parent.run('Go', deps='my-dep-value') + assert result.output == 'Parent final answer' + assert _captured_deps == ['my-dep-value'] + + @pytest.mark.anyio + async def test_pass_deps_false(self) -> None: + """When pass_deps=False, sub-agents receive None for deps.""" + _captured_deps.clear() + + sub: Agent[Any] = Agent( + _sub_model_with_tool('check_deps'), + description='Checks deps', + tools=[Tool(_check_deps_tool, name='check_deps')], + ) + + parent_model = _parent_model_that_delegates('sub', 'check') + parent: Agent[Any] = Agent( + parent_model, + capabilities=[SubAgent[Any](agents={'sub': sub}, pass_deps=False)], + ) + result = await parent.run('Go', deps='should-not-be-passed') + assert result.output == 'Parent final answer' + assert _captured_deps == [None] + + @pytest.mark.anyio + async def test_multiple_agents(self) -> None: + """Multiple sub-agents can be registered and called sequentially.""" + sub_a: Agent[None] = Agent(_simple_sub_model('alpha'), description='Agent A') + sub_b: Agent[None] = Agent(_simple_sub_model('beta'), description='Agent B') + + call_count = 0 + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_call('a', 'task for a') + if call_count == 2: + return _make_delegate_call('b', 'task for b') + return ModelResponse(parts=[TextPart('All done')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'a': sub_a, 'b': sub_b})], + ) + result = await parent.run('Use both') + assert result.output == 'All done' + + +# --------------------------------------------------------------------------- +# Import tests +# --------------------------------------------------------------------------- + + +class TestSubAgentImport: + """Test that SubAgent is importable from the top-level package.""" + + def test_import_from_package(self) -> None: + from pydantic_harness import SubAgent as SubAgentFromPkg + + assert SubAgentFromPkg is SubAgent + + def test_in_all(self) -> None: + import pydantic_harness + + assert 'SubAgent' in pydantic_harness.__all__ diff --git a/uv.lock b/uv.lock index 0730281..7081685 100644 --- a/uv.lock +++ b/uv.lock @@ -25,18 +25,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[package.optional-dependencies] -trio = [ - { name = "trio" }, -] - [[package]] -name = "attrs" -version = "26.1.0" +name = "backports-asyncio-runner" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] @@ -48,34 +43,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -210,6 +177,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "genai-prices" version = "0.0.56" @@ -330,18 +306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - [[package]] name = "packaging" version = "26.0" @@ -360,15 +324,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -386,7 +341,7 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "1.76.0" +version = "1.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -398,9 +353,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/12/625331a88ea2db885e4cda4c2384f8dac9a876260ee3e6e982a950733e6c/pydantic_ai_slim-1.76.0.tar.gz", hash = "sha256:db82bc9a24f9c80d00be23f7a18e5cda8484d77c61a5cd8eedfc2fc8515657b2", size = 508214, upload-time = "2026-04-02T00:25:51.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/97/d57ee44976c349658ea7c645c5c2e1a26830e4b60fdeeee2669d4aaef6eb/pydantic_ai_slim-1.70.0.tar.gz", hash = "sha256:3df0c0e92f72c35e546d24795bce1f4d38f81da2d10addd2e9f255b2d2c83c91", size = 445474, upload-time = "2026-03-18T04:24:34.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/c6/7801af6853502bd53f00e88560f60270d8a2ab3bd8e19732d5ae8f261503/pydantic_ai_slim-1.76.0-py3-none-any.whl", hash = "sha256:1932799ff46a03e83fca3fb194f580dcbf3b24c9d2571ef64d0789c950499e23", size = 651190, upload-time = "2026-04-02T00:25:43.987Z" }, + { url = "https://files.pythonhosted.org/packages/da/8c/8545d28d0b3a9957aa21393cfdab8280bb854362360b296cd486ed1713ec/pydantic_ai_slim-1.70.0-py3-none-any.whl", hash = "sha256:162907092a562b3160d9ef0418d317ec941c5c0e6dd6e0aa0dbb53b5a5cd3450", size = 576244, upload-time = "2026-03-18T04:24:27.301Z" }, ] [[package]] @@ -523,7 +478,7 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "1.76.0" +version = "1.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -531,9 +486,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/3c/6dc8c19c9eba073884b861d88cc96658d38bde4dd49f4b07e9a87f589eec/pydantic_graph-1.76.0.tar.gz", hash = "sha256:e0f8f85ab08b0f896aed50bc888f946f7c2ef3f032b78fefc8dc1fd77a49406e", size = 58716, upload-time = "2026-04-02T00:25:53.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/27/f7a71ca2a3705e7c24fd777959cf5515646cc5f23b5b16c886a2ed373340/pydantic_graph-1.70.0.tar.gz", hash = "sha256:3f76d9137369ef8748b0e8a6df1a08262118af20a32bc139d23e5c0509c6b711", size = 58578, upload-time = "2026-03-18T04:24:37.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/4f/60b018568a33c907734613fb089ff288168faa6affb634e05e1a53f1f9e8/pydantic_graph-1.76.0-py3-none-any.whl", hash = "sha256:eda23a36bcaf4ab09ce10e7860818c6eeb2f42b3429ba9a575cc3b713ef3bbd6", size = 72502, upload-time = "2026-04-02T00:25:47.148Z" }, + { url = "https://files.pythonhosted.org/packages/38/fd/19c42b60c37dfdbbf5b76c7b218e8309b43dac501f7aaf2025527ca05023/pydantic_graph-1.70.0-py3-none-any.whl", hash = "sha256:6083c1503a2587990ee1b8a15915106e3ddabc8f3f11fbc4a108a7d7496af4a5", size = 72351, upload-time = "2026-03-18T04:24:30.291Z" }, ] [[package]] @@ -545,10 +500,10 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "anyio", extra = ["trio"] }, { name = "coverage" }, { name = "pytest" }, - { name = "pytest-anyio" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, ] lint = [ { name = "pyright" }, @@ -556,14 +511,14 @@ lint = [ ] [package.metadata] -requires-dist = [{ name = "pydantic-ai-slim", specifier = ">=1.76.0" }] +requires-dist = [{ name = "pydantic-ai-slim", specifier = ">=0.1" }] [package.metadata.requires-dev] dev = [ - { name = "anyio", extras = ["trio"] }, { name = "coverage" }, { name = "pytest" }, - { name = "pytest-anyio" }, + { name = "pytest-asyncio" }, + { name = "pytest-xdist" }, ] lint = [ { name = "pyright", specifier = ">=1.1.408" }, @@ -611,16 +566,30 @@ wheels = [ ] [[package]] -name = "pytest-anyio" -version = "0.0.0" +name = "pytest-asyncio" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] @@ -648,24 +617,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "tomli" version = "2.4.0" @@ -720,24 +671,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] -[[package]] -name = "trio" -version = "0.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" From 9d4f04c8070ffcd1e855576cbd3c6cde642b3c87 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 2 Apr 2026 05:30:21 +0000 Subject: [PATCH 2/3] Fix test compatibility: restrict anyio backend to asyncio Pydantic AI uses asyncio.gather in capabilities/combined.py which is incompatible with the Trio event loop. Override the anyio_backend fixture to asyncio-only, switch from pytest-asyncio to pytest-anyio, and update the lock file. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/conftest.py | 10 +++ uv.lock | 153 +++++++++++++++++++++++++++++++++------------- 2 files changed, 120 insertions(+), 43 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index cae0815..ba275b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,3 +35,13 @@ def allow_model_requests() -> Iterator[None]: """Temporarily allow real model requests within a test.""" with pydantic_ai.models.override_allow_model_requests(True): yield + + +@pytest.fixture(scope='module', params=['asyncio']) +def anyio_backend(request: pytest.FixtureRequest) -> str: + """Override anyio backend to asyncio-only. + + Pydantic AI uses ``asyncio.gather`` internally (e.g. in capabilities/combined.py) + which is incompatible with the Trio event loop. + """ + return request.param # type: ignore[return-value] diff --git a/uv.lock b/uv.lock index 7081685..0730281 100644 --- a/uv.lock +++ b/uv.lock @@ -25,13 +25,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[package.optional-dependencies] +trio = [ + { name = "trio" }, +] + [[package]] -name = "backports-asyncio-runner" -version = "1.2.0" +name = "attrs" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -43,6 +48,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -177,15 +210,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "genai-prices" version = "0.0.56" @@ -306,6 +330,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -324,6 +360,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -341,7 +386,7 @@ wheels = [ [[package]] name = "pydantic-ai-slim" -version = "1.70.0" +version = "1.76.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -353,9 +398,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/97/d57ee44976c349658ea7c645c5c2e1a26830e4b60fdeeee2669d4aaef6eb/pydantic_ai_slim-1.70.0.tar.gz", hash = "sha256:3df0c0e92f72c35e546d24795bce1f4d38f81da2d10addd2e9f255b2d2c83c91", size = 445474, upload-time = "2026-03-18T04:24:34.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/12/625331a88ea2db885e4cda4c2384f8dac9a876260ee3e6e982a950733e6c/pydantic_ai_slim-1.76.0.tar.gz", hash = "sha256:db82bc9a24f9c80d00be23f7a18e5cda8484d77c61a5cd8eedfc2fc8515657b2", size = 508214, upload-time = "2026-04-02T00:25:51.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/8c/8545d28d0b3a9957aa21393cfdab8280bb854362360b296cd486ed1713ec/pydantic_ai_slim-1.70.0-py3-none-any.whl", hash = "sha256:162907092a562b3160d9ef0418d317ec941c5c0e6dd6e0aa0dbb53b5a5cd3450", size = 576244, upload-time = "2026-03-18T04:24:27.301Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c6/7801af6853502bd53f00e88560f60270d8a2ab3bd8e19732d5ae8f261503/pydantic_ai_slim-1.76.0-py3-none-any.whl", hash = "sha256:1932799ff46a03e83fca3fb194f580dcbf3b24c9d2571ef64d0789c950499e23", size = 651190, upload-time = "2026-04-02T00:25:43.987Z" }, ] [[package]] @@ -478,7 +523,7 @@ wheels = [ [[package]] name = "pydantic-graph" -version = "1.70.0" +version = "1.76.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -486,9 +531,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/27/f7a71ca2a3705e7c24fd777959cf5515646cc5f23b5b16c886a2ed373340/pydantic_graph-1.70.0.tar.gz", hash = "sha256:3f76d9137369ef8748b0e8a6df1a08262118af20a32bc139d23e5c0509c6b711", size = 58578, upload-time = "2026-03-18T04:24:37.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/3c/6dc8c19c9eba073884b861d88cc96658d38bde4dd49f4b07e9a87f589eec/pydantic_graph-1.76.0.tar.gz", hash = "sha256:e0f8f85ab08b0f896aed50bc888f946f7c2ef3f032b78fefc8dc1fd77a49406e", size = 58716, upload-time = "2026-04-02T00:25:53.605Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fd/19c42b60c37dfdbbf5b76c7b218e8309b43dac501f7aaf2025527ca05023/pydantic_graph-1.70.0-py3-none-any.whl", hash = "sha256:6083c1503a2587990ee1b8a15915106e3ddabc8f3f11fbc4a108a7d7496af4a5", size = 72351, upload-time = "2026-03-18T04:24:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/56/4f/60b018568a33c907734613fb089ff288168faa6affb634e05e1a53f1f9e8/pydantic_graph-1.76.0-py3-none-any.whl", hash = "sha256:eda23a36bcaf4ab09ce10e7860818c6eeb2f42b3429ba9a575cc3b713ef3bbd6", size = 72502, upload-time = "2026-04-02T00:25:47.148Z" }, ] [[package]] @@ -500,10 +545,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "anyio", extra = ["trio"] }, { name = "coverage" }, { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-xdist" }, + { name = "pytest-anyio" }, ] lint = [ { name = "pyright" }, @@ -511,14 +556,14 @@ lint = [ ] [package.metadata] -requires-dist = [{ name = "pydantic-ai-slim", specifier = ">=0.1" }] +requires-dist = [{ name = "pydantic-ai-slim", specifier = ">=1.76.0" }] [package.metadata.requires-dev] dev = [ + { name = "anyio", extras = ["trio"] }, { name = "coverage" }, { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-xdist" }, + { name = "pytest-anyio" }, ] lint = [ { name = "pyright", specifier = ">=1.1.408" }, @@ -566,30 +611,16 @@ wheels = [ ] [[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.8.0" +name = "pytest-anyio" +version = "0.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "execnet" }, + { name = "anyio" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/44/a02e5877a671b0940f21a7a0d9704c22097b123ed5cdbcca9cab39f17acc/pytest-anyio-0.0.0.tar.gz", hash = "sha256:b41234e9e9ad7ea1dbfefcc1d6891b23d5ef7c9f07ccf804c13a9cc338571fd3", size = 1560, upload-time = "2021-06-29T22:57:30.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/bd6493ae85d0a281b6a0f248d0fdb1d9aa2b31f18bcd4a8800cf397d8209/pytest_anyio-0.0.0-py2.py3-none-any.whl", hash = "sha256:dc8b5c4741cb16ff90be37fddd585ca943ed12bbeb563de7ace6cd94441d8746", size = 1999, upload-time = "2021-06-29T22:57:29.158Z" }, ] [[package]] @@ -617,6 +648,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -671,6 +720,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 12c6ac61eecb8707930dbf63c4ec263e5e434b39 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 2 Apr 2026 05:48:21 +0000 Subject: [PATCH 3/3] Add share_history, delegate_tasks, and structured output preservation Implements the three audit findings for SubAgent: - `share_history: bool = False` parameter: when True, passes the parent's message history (minus pending tool calls) to sub-agent runs via `message_history`, giving sub-agents full conversation context. - `delegate_tasks` tool for parallel delegation: accepts a list of `{"agent": "name", "task": "prompt"}` dicts and runs them concurrently via `asyncio.gather`, returning results in input order. - Structured output preservation: sub-agent outputs that are Pydantic models get `model_dump_json()`, dicts/lists get `json.dumps()`, and other non-str types get `repr()` instead of generic `str()`. Also widens `agents` dict type from `Agent[Any]` to `Agent[Any, Any]` to accept sub-agents with any output type. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pydantic_harness/subagent.py | 116 +++++++++-- tests/test_subagent.py | 330 ++++++++++++++++++++++++++++++- 2 files changed, 425 insertions(+), 21 deletions(-) diff --git a/src/pydantic_harness/subagent.py b/src/pydantic_harness/subagent.py index ca31171..242cd15 100644 --- a/src/pydantic_harness/subagent.py +++ b/src/pydantic_harness/subagent.py @@ -2,12 +2,16 @@ from __future__ import annotations +import asyncio +import json from dataclasses import dataclass, field from typing import Any +from pydantic import BaseModel from pydantic_ai import Agent from pydantic_ai.capabilities import AbstractCapability from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart from pydantic_ai.tools import AgentDepsT, RunContext, Tool from pydantic_ai.toolsets import AgentToolset from pydantic_ai.toolsets.function import FunctionToolset @@ -15,7 +19,7 @@ __all__ = ('SubAgent',) -def _resolve_description(name: str, agent: Agent[Any]) -> str: +def _resolve_description(name: str, agent: Agent[Any, Any]) -> str: """Derive a description for a sub-agent from its metadata.""" if agent.description: return agent.description @@ -24,6 +28,38 @@ def _resolve_description(name: str, agent: Agent[Any]) -> str: return f'Sub-agent: {name}' +def _shareable_history(messages: list[ModelMessage]) -> list[ModelMessage]: + """Return a copy of the message history safe to pass to a sub-agent. + + The parent's ``ctx.messages`` may end with a ``ModelResponse`` containing + the ``ToolCallPart`` currently being executed, which the sub-agent cannot + process (it would conflict with its own user prompt). This helper strips + such trailing responses to yield a clean conversation history. + """ + history = list(messages) + while history and isinstance(history[-1], ModelResponse): + if any(isinstance(p, ToolCallPart) for p in history[-1].parts): + history.pop() + else: + break + return history + + +def _format_output(output: Any) -> str: + """Format a sub-agent's output as a string for the parent agent. + + Preserves structured data by JSON-serializing Pydantic models, dicts, and + lists, and using `repr()` for other non-string types. + """ + if isinstance(output, str): + return output + if isinstance(output, BaseModel): + return output.model_dump_json() + if isinstance(output, (dict, list)): + return json.dumps(output) + return repr(output) + + @dataclass class SubAgent(AbstractCapability[AgentDepsT]): """Capability that lets a parent agent delegate tasks to named sub-agents. @@ -49,9 +85,12 @@ class SubAgent(AbstractCapability[AgentDepsT]): ``` """ - agents: dict[str, Agent[Any]] + agents: dict[str, Agent[Any, Any]] """Mapping of agent name to `Agent` instance. + Sub-agents may have any output type; structured outputs are automatically + serialized to strings for the parent agent. + Names are used by the parent agent in the `delegate_task` tool to select which sub-agent to run. """ @@ -73,6 +112,15 @@ class SubAgent(AbstractCapability[AgentDepsT]): as the parent. Set to False if sub-agents use incompatible dependency types. """ + share_history: bool = False + """Whether to pass the parent agent's message history to sub-agents. + + When True, the parent's conversation history is forwarded as + ``message_history`` to each sub-agent run, giving it access to the + full conversation context. When False (the default), sub-agents start + with a fresh conversation. + """ + _resolved_descriptions: dict[str, str] = field(default_factory=dict[str, str], init=False, repr=False) def __post_init__(self) -> None: @@ -93,43 +141,73 @@ def get_instructions(self) -> str | None: if not self.agents: return None - lines = ['You can delegate tasks to the following sub-agents using the `delegate_task` tool:'] + lines = [ + 'You can delegate tasks to the following sub-agents using the ' + '`delegate_task` tool (one at a time) or the `delegate_tasks` tool (multiple in parallel):' + ] for name in self.agents: desc = self._resolved_descriptions[name] lines.append(f'- **{name}**: {desc}') return '\n'.join(lines) def get_toolset(self) -> AgentToolset[AgentDepsT] | None: - """Provide the `delegate_task` tool.""" + """Provide the `delegate_task` and `delegate_tasks` tools.""" if not self.agents: return None agents = self.agents pass_deps = self.pass_deps + share_history = self.share_history + + async def _run_sub_agent(ctx: RunContext[AgentDepsT], agent_name: str, task: str) -> str: + """Run a single sub-agent, returning its formatted output.""" + agent = agents.get(agent_name) + if agent is None: + available = ', '.join(sorted(agents)) + raise ModelRetry(f'Unknown agent {agent_name!r}. Available agents: {available}') + + deps = ctx.deps if pass_deps else None + message_history = _shareable_history(ctx.messages) if share_history else None + result = await agent.run(task, deps=deps, message_history=message_history) + return _format_output(result.output) async def delegate_task(ctx: RunContext[AgentDepsT], agent_name: str, task: str) -> str: - """Delegate a task to a named sub-agent and return its text output. + """Delegate a task to a named sub-agent and return its output. Args: ctx: The run context from the parent agent. agent_name: The name of the sub-agent to run. Must be one of the registered agent names. task: The prompt describing the task to delegate. """ - agent = agents.get(agent_name) - if agent is None: - available = ', '.join(sorted(agents)) - raise ModelRetry(f'Unknown agent {agent_name!r}. Available agents: {available}') + return await _run_sub_agent(ctx, agent_name, task) - deps = ctx.deps if pass_deps else None - result = await agent.run(task, deps=deps) - return str(result.output) - - tool = Tool[AgentDepsT]( - delegate_task, - name='delegate_task', - description=self._delegate_task_description(), - ) - return FunctionToolset[AgentDepsT]([tool]) + async def delegate_tasks( + ctx: RunContext[AgentDepsT], + tasks: list[dict[str, str]], + ) -> list[str]: + """Delegate multiple tasks to sub-agents in parallel and return their outputs. + + Args: + ctx: The run context from the parent agent. + tasks: A list of task objects, each with ``agent`` (sub-agent name) and ``task`` (prompt). + """ + coros = [_run_sub_agent(ctx, t['agent'], t['task']) for t in tasks] + return list(await asyncio.gather(*coros)) + + agent_desc = self._delegate_task_description() + tools: list[Tool[AgentDepsT]] = [ + Tool[AgentDepsT]( + delegate_task, + name='delegate_task', + description=agent_desc, + ), + Tool[AgentDepsT]( + delegate_tasks, + name='delegate_tasks', + description=f'Delegate multiple tasks in parallel. Each item needs "agent" and "task" keys. {agent_desc}', + ), + ] + return FunctionToolset[AgentDepsT](tools) def _delegate_task_description(self) -> str: """Build a description for the delegate_task tool including available agent names.""" diff --git a/tests/test_subagent.py b/tests/test_subagent.py index 3735c01..60844a2 100644 --- a/tests/test_subagent.py +++ b/tests/test_subagent.py @@ -6,13 +6,22 @@ from typing import Any import pytest +from pydantic import BaseModel from pydantic_ai import Agent, RunContext, Tool from pydantic_ai.capabilities import AbstractCapability -from pydantic_ai.messages import ModelMessage, ModelResponse, TextPart, ToolCallPart +from pydantic_ai.messages import ( + ModelMessage, + ModelRequest, + ModelResponse, + TextPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, +) from pydantic_ai.models.function import AgentInfo, FunctionModel from pydantic_ai.models.test import TestModel -from pydantic_harness.subagent import SubAgent +from pydantic_harness.subagent import SubAgent, _format_output, _shareable_history # --------------------------------------------------------------------------- # Helpers @@ -32,6 +41,19 @@ def _make_delegate_call(agent_name: str, task: str, tool_call_id: str = 'call-1' ) +def _make_delegate_tasks_call(tasks: list[dict[str, str]], tool_call_id: str = 'call-1') -> ModelResponse: + """Build a ModelResponse that calls the delegate_tasks (plural) tool.""" + return ModelResponse( + parts=[ + ToolCallPart( + tool_name='delegate_tasks', + args=json.dumps({'tasks': tasks}), + tool_call_id=tool_call_id, + ) + ] + ) + + def _parent_model_that_delegates(agent_name: str, task: str) -> FunctionModel: """Create a FunctionModel that delegates once, then returns a text answer.""" call_count = 0 @@ -150,6 +172,7 @@ def test_instructions_list_agents(self) -> None: assert 'researcher' in instructions assert 'coder' in instructions assert 'delegate_task' in instructions + assert 'delegate_tasks' in instructions def test_instructions_include_descriptions(self) -> None: agent: Agent[None] = Agent(TestModel(), description='Does math') @@ -293,6 +316,309 @@ def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelRespons assert result.output == 'All done' +# --------------------------------------------------------------------------- +# Share history tests +# --------------------------------------------------------------------------- + + +_captured_history: list[list[ModelMessage] | None] = [] + + +async def _capture_history_tool(ctx: RunContext[Any]) -> str: + """Capture the message history on the sub-agent's RunContext.""" + _captured_history.append(list(ctx.messages)) + return 'captured' + + +class TestShareableHistory: + """Test _shareable_history helper.""" + + def test_strips_trailing_tool_call_response(self) -> None: + messages: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content='hi')]), + ModelResponse(parts=[ToolCallPart(tool_name='t', args='{}', tool_call_id='c1')]), + ] + result = _shareable_history(messages) + assert len(result) == 1 + assert isinstance(result[0], ModelRequest) + + def test_preserves_trailing_text_response(self) -> None: + messages: list[ModelMessage] = [ + ModelRequest(parts=[UserPromptPart(content='hi')]), + ModelResponse(parts=[TextPart('reply')]), + ] + result = _shareable_history(messages) + assert len(result) == 2 + + def test_empty_list(self) -> None: + assert _shareable_history([]) == [] + + +class TestSubAgentShareHistory: + """Test share_history parameter.""" + + @pytest.mark.anyio + async def test_share_history_false_by_default(self) -> None: + """With default share_history=False, sub-agent gets no parent history.""" + _captured_history.clear() + + sub: Agent[Any] = Agent( + _sub_model_with_tool('capture_history'), + description='Captures history', + tools=[Tool(_capture_history_tool, name='capture_history')], + ) + + parent_model = _parent_model_that_delegates('sub', 'do it') + parent: Agent[Any] = Agent( + parent_model, + capabilities=[SubAgent[Any](agents={'sub': sub})], + ) + result = await parent.run('Hello parent') + assert result.output == 'Parent final answer' + # Sub-agent should have only its own messages (not the parent's) + assert len(_captured_history) == 1 + sub_messages = _captured_history[0] + assert sub_messages is not None + # The sub-agent messages should not contain the parent's user prompt + all_text = str(sub_messages) + assert 'Hello parent' not in all_text + + @pytest.mark.anyio + async def test_share_history_true(self) -> None: + """With share_history=True, sub-agent receives parent's message history.""" + _captured_history.clear() + + sub: Agent[Any] = Agent( + _sub_model_with_tool('capture_history'), + description='Captures history', + tools=[Tool(_capture_history_tool, name='capture_history')], + ) + + parent_model = _parent_model_that_delegates('sub', 'do it') + parent: Agent[Any] = Agent( + parent_model, + capabilities=[SubAgent[Any](agents={'sub': sub}, share_history=True)], + ) + result = await parent.run('Hello parent') + assert result.output == 'Parent final answer' + assert len(_captured_history) == 1 + sub_messages = _captured_history[0] + assert sub_messages is not None + # The sub-agent's history should contain the parent's original user prompt + all_text = str(sub_messages) + assert 'Hello parent' in all_text + + +# --------------------------------------------------------------------------- +# Parallel delegation tests +# --------------------------------------------------------------------------- + + +class TestSubAgentDelegateTasks: + """Test the delegate_tasks (parallel) tool.""" + + @pytest.mark.anyio + async def test_delegate_tasks_parallel(self) -> None: + """delegate_tasks runs multiple sub-agents and returns all outputs.""" + sub_a: Agent[None] = Agent(_simple_sub_model('alpha'), description='Agent A') + sub_b: Agent[None] = Agent(_simple_sub_model('beta'), description='Agent B') + + call_count = 0 + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_tasks_call( + [ + {'agent': 'a', 'task': 'task for a'}, + {'agent': 'b', 'task': 'task for b'}, + ] + ) + return ModelResponse(parts=[TextPart('All done parallel')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'a': sub_a, 'b': sub_b})], + ) + result = await parent.run('Use both in parallel') + assert result.output == 'All done parallel' + + @pytest.mark.anyio + async def test_delegate_tasks_returns_results_in_order(self) -> None: + """Results from delegate_tasks are in the same order as the input tasks.""" + sub_a: Agent[None] = Agent(_simple_sub_model('first'), description='A') + sub_b: Agent[None] = Agent(_simple_sub_model('second'), description='B') + + call_count = 0 + captured_tool_return: list[Any] = [] + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_tasks_call( + [ + {'agent': 'a', 'task': 'go a'}, + {'agent': 'b', 'task': 'go b'}, + ] + ) + # Capture the tool return from the messages + for msg in messages: + for part in msg.parts: + if isinstance(part, ToolReturnPart) and part.tool_name == 'delegate_tasks': + captured_tool_return.append(part.content) + return ModelResponse(parts=[TextPart('Done')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'a': sub_a, 'b': sub_b})], + ) + await parent.run('Go') + assert len(captured_tool_return) == 1 + assert captured_tool_return[0] == ['first', 'second'] + + @pytest.mark.anyio + async def test_delegate_tasks_unknown_agent_triggers_retry(self) -> None: + """If any task references an unknown agent, ModelRetry is raised.""" + sub: Agent[None] = Agent(_simple_sub_model('ok'), description='Sub') + + call_count = 0 + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_tasks_call( + [ + {'agent': 'nonexistent', 'task': 'bad'}, + ] + ) + if call_count == 2: + return _make_delegate_tasks_call( + [ + {'agent': 'sub', 'task': 'good'}, + ] + ) + return ModelResponse(parts=[TextPart('Recovered')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'sub': sub})], + ) + result = await parent.run('Go') + assert result.output == 'Recovered' + + +# --------------------------------------------------------------------------- +# Structured output tests +# --------------------------------------------------------------------------- + + +class _SampleModel(BaseModel): + name: str + value: int + + +class TestFormatOutput: + """Test _format_output for structured output preservation.""" + + def test_str_passthrough(self) -> None: + assert _format_output('hello') == 'hello' + + def test_pydantic_model_json(self) -> None: + model = _SampleModel(name='test', value=42) + result = _format_output(model) + parsed = json.loads(result) + assert parsed == {'name': 'test', 'value': 42} + + def test_dict_json(self) -> None: + result = _format_output({'key': 'val', 'num': 1}) + parsed = json.loads(result) + assert parsed == {'key': 'val', 'num': 1} + + def test_list_json(self) -> None: + result = _format_output([1, 2, 3]) + parsed = json.loads(result) + assert parsed == [1, 2, 3] + + def test_other_type_repr(self) -> None: + result = _format_output(42) + assert result == '42' + + def test_bool_repr(self) -> None: + result = _format_output(True) + assert result == 'True' + + +class TestStructuredOutputEndToEnd: + """Test that structured sub-agent outputs are preserved in delegation.""" + + @pytest.mark.anyio + async def test_pydantic_model_output(self) -> None: + """A sub-agent returning a Pydantic model gets JSON-serialized.""" + sub: Agent[None, _SampleModel] = Agent( + TestModel(custom_output_args={'name': 'sub', 'value': 99}), + output_type=_SampleModel, + description='Returns structured', + ) + + call_count = 0 + captured_tool_return: list[str] = [] + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_call('sub', 'get data') + for msg in messages: + for part in msg.parts: + if isinstance(part, ToolReturnPart) and part.tool_name == 'delegate_task': + captured_tool_return.append(str(part.content)) + return ModelResponse(parts=[TextPart('Done')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'sub': sub})], + ) + await parent.run('Get structured data') + assert len(captured_tool_return) == 1 + parsed = json.loads(captured_tool_return[0]) + assert parsed == {'name': 'sub', 'value': 99} + + @pytest.mark.anyio + async def test_dict_output(self) -> None: + """A sub-agent returning a dict gets JSON-serialized.""" + sub: Agent[None, dict[str, int]] = Agent( + TestModel(custom_output_args={'a': 1}), + output_type=dict[str, int], + description='Returns dict', + ) + + call_count = 0 + captured_tool_return: list[str] = [] + + def parent_handle(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + nonlocal call_count + call_count += 1 + if call_count == 1: + return _make_delegate_call('sub', 'get dict') + for msg in messages: + for part in msg.parts: + if isinstance(part, ToolReturnPart) and part.tool_name == 'delegate_task': + captured_tool_return.append(str(part.content)) + return ModelResponse(parts=[TextPart('Done')]) + + parent: Agent[None] = Agent( + FunctionModel(parent_handle), + capabilities=[SubAgent[None](agents={'sub': sub})], + ) + await parent.run('Get dict data') + assert len(captured_tool_return) == 1 + parsed = json.loads(captured_tool_return[0]) + assert parsed == {'a': 1} + + # --------------------------------------------------------------------------- # Import tests # ---------------------------------------------------------------------------