pydantic-ai-slim 0.3.7__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pydantic-ai-slim might be problematic. Click here for more details.

Files changed (84) hide show
  1. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/PKG-INFO +4 -4
  2. pydantic_ai_slim-0.4.1/pydantic_ai/_a2a.py +305 -0
  3. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_function_schema.py +1 -1
  4. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_griffe.py +2 -2
  5. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_utils.py +4 -1
  6. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/agent.py +2 -2
  7. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/mcp.py +1 -1
  8. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/messages.py +22 -6
  9. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/anthropic.py +1 -1
  10. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/bedrock.py +9 -6
  11. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/gemini.py +1 -1
  12. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/google.py +1 -1
  13. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/groq.py +1 -1
  14. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/mistral.py +3 -1
  15. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/openai.py +1 -1
  16. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/openai.py +9 -1
  17. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/tools.py +5 -5
  18. pydantic_ai_slim-0.3.7/pydantic_ai/_a2a.py +0 -191
  19. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/.gitignore +0 -0
  20. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/LICENSE +0 -0
  21. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/README.md +0 -0
  22. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/__init__.py +0 -0
  23. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/__main__.py +0 -0
  24. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_agent_graph.py +0 -0
  25. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_cli.py +0 -0
  26. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_mcp.py +0 -0
  27. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_output.py +0 -0
  28. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_parts_manager.py +0 -0
  29. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_run_context.py +0 -0
  30. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_system_prompt.py +0 -0
  31. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/_thinking_part.py +0 -0
  32. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/common_tools/__init__.py +0 -0
  33. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/common_tools/duckduckgo.py +0 -0
  34. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/common_tools/tavily.py +0 -0
  35. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/direct.py +0 -0
  36. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/exceptions.py +0 -0
  37. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/ext/__init__.py +0 -0
  38. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/ext/aci.py +0 -0
  39. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/ext/langchain.py +0 -0
  40. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/format_as_xml.py +0 -0
  41. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/format_prompt.py +0 -0
  42. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/__init__.py +0 -0
  43. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/cohere.py +0 -0
  44. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/fallback.py +0 -0
  45. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/function.py +0 -0
  46. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/instrumented.py +0 -0
  47. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/mcp_sampling.py +0 -0
  48. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/test.py +0 -0
  49. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/models/wrapper.py +0 -0
  50. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/output.py +0 -0
  51. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/__init__.py +0 -0
  52. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/_json_schema.py +0 -0
  53. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/amazon.py +0 -0
  54. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/anthropic.py +0 -0
  55. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/cohere.py +0 -0
  56. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/deepseek.py +0 -0
  57. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/google.py +0 -0
  58. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/grok.py +0 -0
  59. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/meta.py +0 -0
  60. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/mistral.py +0 -0
  61. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/profiles/qwen.py +0 -0
  62. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/__init__.py +0 -0
  63. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/anthropic.py +0 -0
  64. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/azure.py +0 -0
  65. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/bedrock.py +0 -0
  66. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/cohere.py +0 -0
  67. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/deepseek.py +0 -0
  68. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/fireworks.py +0 -0
  69. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/github.py +0 -0
  70. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/google.py +0 -0
  71. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/google_gla.py +0 -0
  72. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/google_vertex.py +0 -0
  73. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/grok.py +0 -0
  74. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/groq.py +0 -0
  75. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/heroku.py +0 -0
  76. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/mistral.py +0 -0
  77. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/openai.py +0 -0
  78. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/openrouter.py +0 -0
  79. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/providers/together.py +0 -0
  80. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/py.typed +0 -0
  81. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/result.py +0 -0
  82. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/settings.py +0 -0
  83. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pydantic_ai/usage.py +0 -0
  84. {pydantic_ai_slim-0.3.7 → pydantic_ai_slim-0.4.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.3.7
3
+ Version: 0.4.1
4
4
  Summary: Agent Framework / shim to use Pydantic with LLMs, slim package
5
5
  Author-email: Samuel Colvin <samuel@pydantic.dev>, Marcelo Trylesinski <marcelotryle@gmail.com>, David Montague <david@pydantic.dev>, Alex Hall <alex@pydantic.dev>
6
6
  License-Expression: MIT
@@ -30,11 +30,11 @@ Requires-Dist: exceptiongroup; python_version < '3.11'
30
30
  Requires-Dist: griffe>=1.3.2
31
31
  Requires-Dist: httpx>=0.27
32
32
  Requires-Dist: opentelemetry-api>=1.28.0
33
- Requires-Dist: pydantic-graph==0.3.7
33
+ Requires-Dist: pydantic-graph==0.4.1
34
34
  Requires-Dist: pydantic>=2.10
35
35
  Requires-Dist: typing-inspection>=0.4.0
36
36
  Provides-Extra: a2a
37
- Requires-Dist: fasta2a==0.3.7; extra == 'a2a'
37
+ Requires-Dist: fasta2a==0.4.1; extra == 'a2a'
38
38
  Provides-Extra: anthropic
39
39
  Requires-Dist: anthropic>=0.52.0; extra == 'anthropic'
40
40
  Provides-Extra: bedrock
@@ -48,7 +48,7 @@ Requires-Dist: cohere>=5.13.11; (platform_system != 'Emscripten') and extra == '
48
48
  Provides-Extra: duckduckgo
49
49
  Requires-Dist: duckduckgo-search>=7.0.0; extra == 'duckduckgo'
50
50
  Provides-Extra: evals
51
- Requires-Dist: pydantic-evals==0.3.7; extra == 'evals'
51
+ Requires-Dist: pydantic-evals==0.4.1; extra == 'evals'
52
52
  Provides-Extra: google
53
53
  Requires-Dist: google-genai>=1.24.0; extra == 'google'
54
54
  Provides-Extra: groq
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations, annotations as _annotations
2
+
3
+ import uuid
4
+ from collections.abc import AsyncIterator, Sequence
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import dataclass
7
+ from functools import partial
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ from pydantic import TypeAdapter
11
+ from typing_extensions import assert_never
12
+
13
+ from pydantic_ai.messages import (
14
+ AudioUrl,
15
+ BinaryContent,
16
+ DocumentUrl,
17
+ ImageUrl,
18
+ ModelMessage,
19
+ ModelRequest,
20
+ ModelRequestPart,
21
+ ModelResponse,
22
+ ModelResponsePart,
23
+ TextPart,
24
+ ThinkingPart,
25
+ ToolCallPart,
26
+ UserPromptPart,
27
+ VideoUrl,
28
+ )
29
+
30
+ from .agent import Agent, AgentDepsT, OutputDataT
31
+
32
+ # AgentWorker output type needs to be invariant for use in both parameter and return positions
33
+ WorkerOutputT = TypeVar('WorkerOutputT')
34
+
35
+ try:
36
+ from starlette.middleware import Middleware
37
+ from starlette.routing import Route
38
+ from starlette.types import ExceptionHandler, Lifespan
39
+
40
+ from fasta2a.applications import FastA2A
41
+ from fasta2a.broker import Broker, InMemoryBroker
42
+ from fasta2a.schema import (
43
+ AgentProvider,
44
+ Artifact,
45
+ DataPart,
46
+ Message,
47
+ Part,
48
+ Skill,
49
+ TaskIdParams,
50
+ TaskSendParams,
51
+ TextPart as A2ATextPart,
52
+ )
53
+ from fasta2a.storage import InMemoryStorage, Storage
54
+ from fasta2a.worker import Worker
55
+ except ImportError as _import_error:
56
+ raise ImportError(
57
+ 'Please install the `fasta2a` package to use `Agent.to_a2a()` method, '
58
+ 'you can use the `a2a` optional group — `pip install "pydantic-ai-slim[a2a]"`'
59
+ ) from _import_error
60
+
61
+
62
+ @asynccontextmanager
63
+ async def worker_lifespan(app: FastA2A, worker: Worker) -> AsyncIterator[None]:
64
+ """Custom lifespan that runs the worker during application startup.
65
+
66
+ This ensures the worker is started and ready to process tasks as soon as the application starts.
67
+ """
68
+ async with app.task_manager:
69
+ async with worker.run():
70
+ yield
71
+
72
+
73
+ def agent_to_a2a(
74
+ agent: Agent[AgentDepsT, OutputDataT],
75
+ *,
76
+ storage: Storage | None = None,
77
+ broker: Broker | None = None,
78
+ # Agent card
79
+ name: str | None = None,
80
+ url: str = 'http://localhost:8000',
81
+ version: str = '1.0.0',
82
+ description: str | None = None,
83
+ provider: AgentProvider | None = None,
84
+ skills: list[Skill] | None = None,
85
+ # Starlette
86
+ debug: bool = False,
87
+ routes: Sequence[Route] | None = None,
88
+ middleware: Sequence[Middleware] | None = None,
89
+ exception_handlers: dict[Any, ExceptionHandler] | None = None,
90
+ lifespan: Lifespan[FastA2A] | None = None,
91
+ ) -> FastA2A:
92
+ """Create a FastA2A server from an agent."""
93
+ storage = storage or InMemoryStorage()
94
+ broker = broker or InMemoryBroker()
95
+ worker = AgentWorker(agent=agent, broker=broker, storage=storage)
96
+
97
+ lifespan = lifespan or partial(worker_lifespan, worker=worker)
98
+
99
+ return FastA2A(
100
+ storage=storage,
101
+ broker=broker,
102
+ name=name or agent.name,
103
+ url=url,
104
+ version=version,
105
+ description=description,
106
+ provider=provider,
107
+ skills=skills,
108
+ debug=debug,
109
+ routes=routes,
110
+ middleware=middleware,
111
+ exception_handlers=exception_handlers,
112
+ lifespan=lifespan,
113
+ )
114
+
115
+
116
+ @dataclass
117
+ class AgentWorker(Worker[list[ModelMessage]], Generic[WorkerOutputT, AgentDepsT]):
118
+ """A worker that uses an agent to execute tasks."""
119
+
120
+ agent: Agent[AgentDepsT, WorkerOutputT]
121
+
122
+ async def run_task(self, params: TaskSendParams) -> None:
123
+ task = await self.storage.load_task(params['id'])
124
+ if task is None:
125
+ raise ValueError(f'Task {params["id"]} not found') # pragma: no cover
126
+
127
+ # TODO(Marcelo): Should we lock `run_task` on the `context_id`?
128
+ # Ensure this task hasn't been run before
129
+ if task['status']['state'] != 'submitted':
130
+ raise ValueError( # pragma: no cover
131
+ f'Task {params["id"]} has already been processed (state: {task["status"]["state"]})'
132
+ )
133
+
134
+ await self.storage.update_task(task['id'], state='working')
135
+
136
+ # Load context - contains pydantic-ai message history from previous tasks in this conversation
137
+ message_history = await self.storage.load_context(task['context_id']) or []
138
+ message_history.extend(self.build_message_history(task.get('history', [])))
139
+
140
+ try:
141
+ result = await self.agent.run(message_history=message_history) # type: ignore
142
+
143
+ await self.storage.update_context(task['context_id'], result.all_messages())
144
+
145
+ # Convert new messages to A2A format for task history
146
+ a2a_messages: list[Message] = []
147
+
148
+ for message in result.new_messages():
149
+ if isinstance(message, ModelRequest):
150
+ # Skip user prompts - they're already in task history
151
+ continue
152
+ else:
153
+ # Convert response parts to A2A format
154
+ a2a_parts = self._response_parts_to_a2a(message.parts)
155
+ if a2a_parts: # Add if there are visible parts (text/thinking)
156
+ a2a_messages.append(
157
+ Message(role='agent', parts=a2a_parts, kind='message', message_id=str(uuid.uuid4()))
158
+ )
159
+
160
+ artifacts = self.build_artifacts(result.output)
161
+ except Exception:
162
+ await self.storage.update_task(task['id'], state='failed')
163
+ raise
164
+ else:
165
+ await self.storage.update_task(
166
+ task['id'], state='completed', new_artifacts=artifacts, new_messages=a2a_messages
167
+ )
168
+
169
+ async def cancel_task(self, params: TaskIdParams) -> None:
170
+ pass
171
+
172
+ def build_artifacts(self, result: WorkerOutputT) -> list[Artifact]:
173
+ """Build artifacts from agent result.
174
+
175
+ All agent outputs become artifacts to mark them as durable task outputs.
176
+ For string results, we use TextPart. For structured data, we use DataPart.
177
+ Metadata is included to preserve type information.
178
+ """
179
+ artifact_id = str(uuid.uuid4())
180
+ part = self._convert_result_to_part(result)
181
+ return [Artifact(artifact_id=artifact_id, name='result', parts=[part])]
182
+
183
+ def _convert_result_to_part(self, result: WorkerOutputT) -> Part:
184
+ """Convert agent result to a Part (TextPart or DataPart).
185
+
186
+ For string results, returns a TextPart.
187
+ For structured data, returns a DataPart with properly serialized data.
188
+ """
189
+ if isinstance(result, str):
190
+ return A2ATextPart(kind='text', text=result)
191
+ else:
192
+ output_type = type(result)
193
+ type_adapter = TypeAdapter(output_type)
194
+ data = type_adapter.dump_python(result, mode='json')
195
+ json_schema = type_adapter.json_schema(mode='serialization')
196
+ return DataPart(kind='data', data={'result': data}, metadata={'json_schema': json_schema})
197
+
198
+ def build_message_history(self, history: list[Message]) -> list[ModelMessage]:
199
+ model_messages: list[ModelMessage] = []
200
+ for message in history:
201
+ if message['role'] == 'user':
202
+ model_messages.append(ModelRequest(parts=self._request_parts_from_a2a(message['parts'])))
203
+ else:
204
+ model_messages.append(ModelResponse(parts=self._response_parts_from_a2a(message['parts'])))
205
+ return model_messages
206
+
207
+ def _request_parts_from_a2a(self, parts: list[Part]) -> list[ModelRequestPart]:
208
+ """Convert A2A Part objects to pydantic-ai ModelRequestPart objects.
209
+
210
+ This handles the conversion from A2A protocol parts (text, file, data) to
211
+ pydantic-ai's internal request parts (UserPromptPart with various content types).
212
+
213
+ Args:
214
+ parts: List of A2A Part objects from incoming messages
215
+
216
+ Returns:
217
+ List of ModelRequestPart objects for the pydantic-ai agent
218
+ """
219
+ model_parts: list[ModelRequestPart] = []
220
+ for part in parts:
221
+ if part['kind'] == 'text':
222
+ model_parts.append(UserPromptPart(content=part['text']))
223
+ elif part['kind'] == 'file':
224
+ file_content = part['file']
225
+ if 'bytes' in file_content:
226
+ data = file_content['bytes'].encode('utf-8')
227
+ mime_type = file_content.get('mime_type', 'application/octet-stream')
228
+ content = BinaryContent(data=data, media_type=mime_type)
229
+ model_parts.append(UserPromptPart(content=[content]))
230
+ else:
231
+ url = file_content['uri']
232
+ for url_cls in (DocumentUrl, AudioUrl, ImageUrl, VideoUrl):
233
+ content = url_cls(url=url)
234
+ try:
235
+ content.media_type
236
+ except ValueError: # pragma: no cover
237
+ continue
238
+ else:
239
+ break
240
+ else:
241
+ raise ValueError(f'Unsupported file type: {url}') # pragma: no cover
242
+ model_parts.append(UserPromptPart(content=[content]))
243
+ elif part['kind'] == 'data':
244
+ raise NotImplementedError('Data parts are not supported yet.')
245
+ else:
246
+ assert_never(part)
247
+ return model_parts
248
+
249
+ def _response_parts_from_a2a(self, parts: list[Part]) -> list[ModelResponsePart]:
250
+ """Convert A2A Part objects to pydantic-ai ModelResponsePart objects.
251
+
252
+ This handles the conversion from A2A protocol parts (text, file, data) to
253
+ pydantic-ai's internal response parts. Currently only supports text parts
254
+ as agent responses in A2A are expected to be text-based.
255
+
256
+ Args:
257
+ parts: List of A2A Part objects from stored agent messages
258
+
259
+ Returns:
260
+ List of ModelResponsePart objects for message history
261
+ """
262
+ model_parts: list[ModelResponsePart] = []
263
+ for part in parts:
264
+ if part['kind'] == 'text':
265
+ model_parts.append(TextPart(content=part['text']))
266
+ elif part['kind'] == 'file': # pragma: no cover
267
+ raise NotImplementedError('File parts are not supported yet.')
268
+ elif part['kind'] == 'data': # pragma: no cover
269
+ raise NotImplementedError('Data parts are not supported yet.')
270
+ else: # pragma: no cover
271
+ assert_never(part)
272
+ return model_parts
273
+
274
+ def _response_parts_to_a2a(self, parts: list[ModelResponsePart]) -> list[Part]:
275
+ """Convert pydantic-ai ModelResponsePart objects to A2A Part objects.
276
+
277
+ This handles the conversion from pydantic-ai's internal response parts to
278
+ A2A protocol parts. Different part types are handled as follows:
279
+ - TextPart: Converted directly to A2A TextPart
280
+ - ThinkingPart: Converted to TextPart with metadata indicating it's thinking
281
+ - ToolCallPart: Skipped (internal to agent execution)
282
+
283
+ Args:
284
+ parts: List of ModelResponsePart objects from agent response
285
+
286
+ Returns:
287
+ List of A2A Part objects suitable for sending via A2A protocol
288
+ """
289
+ a2a_parts: list[Part] = []
290
+ for part in parts:
291
+ if isinstance(part, TextPart):
292
+ a2a_parts.append(A2ATextPart(kind='text', text=part.content))
293
+ elif isinstance(part, ThinkingPart):
294
+ # Convert thinking to text with metadata
295
+ a2a_parts.append(
296
+ A2ATextPart(
297
+ kind='text',
298
+ text=part.content,
299
+ metadata={'type': 'thinking', 'thinking_id': part.id, 'signature': part.signature},
300
+ )
301
+ )
302
+ elif isinstance(part, ToolCallPart):
303
+ # Skip tool calls - they're internal to agent execution
304
+ pass
305
+ return a2a_parts
@@ -35,7 +35,7 @@ class FunctionSchema:
35
35
  """Internal information about a function schema."""
36
36
 
37
37
  function: Callable[..., Any]
38
- description: str
38
+ description: str | None
39
39
  validator: SchemaValidator
40
40
  json_schema: ObjectJsonSchema
41
41
  # if not None, the function takes a single by that name (besides potentially `info`)
@@ -19,7 +19,7 @@ def doc_descriptions(
19
19
  sig: Signature,
20
20
  *,
21
21
  docstring_format: DocstringFormat,
22
- ) -> tuple[str, dict[str, str]]:
22
+ ) -> tuple[str | None, dict[str, str]]:
23
23
  """Extract the function description and parameter descriptions from a function's docstring.
24
24
 
25
25
  The function parses the docstring using the specified format (or infers it if 'auto')
@@ -35,7 +35,7 @@ def doc_descriptions(
35
35
  """
36
36
  doc = func.__doc__
37
37
  if doc is None:
38
- return '', {}
38
+ return None, {}
39
39
 
40
40
  # see https://github.com/mkdocstrings/griffe/issues/293
41
41
  parent = cast(GriffeObject, sig)
@@ -315,8 +315,11 @@ def dataclasses_no_defaults_repr(self: Any) -> str:
315
315
  return f'{self.__class__.__qualname__}({", ".join(kv_pairs)})'
316
316
 
317
317
 
318
+ _datetime_ta = TypeAdapter(datetime)
319
+
320
+
318
321
  def number_to_datetime(x: int | float) -> datetime:
319
- return TypeAdapter(datetime).validate_python(x)
322
+ return _datetime_ta.validate_python(x)
320
323
 
321
324
 
322
325
  AwaitableCallable = Callable[..., Awaitable[T]]
@@ -63,7 +63,7 @@ if TYPE_CHECKING:
63
63
 
64
64
  from fasta2a.applications import FastA2A
65
65
  from fasta2a.broker import Broker
66
- from fasta2a.schema import Provider, Skill
66
+ from fasta2a.schema import AgentProvider, Skill
67
67
  from fasta2a.storage import Storage
68
68
  from pydantic_ai.mcp import MCPServer
69
69
 
@@ -1764,7 +1764,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1764
1764
  url: str = 'http://localhost:8000',
1765
1765
  version: str = '1.0.0',
1766
1766
  description: str | None = None,
1767
- provider: Provider | None = None,
1767
+ provider: AgentProvider | None = None,
1768
1768
  skills: list[Skill] | None = None,
1769
1769
  # Starlette
1770
1770
  debug: bool = False,
@@ -98,7 +98,7 @@ class MCPServer(ABC):
98
98
  return [
99
99
  tools.ToolDefinition(
100
100
  name=self.get_prefixed_tool_name(tool.name),
101
- description=tool.description or '',
101
+ description=tool.description,
102
102
  parameters_json_schema=tool.inputSchema,
103
103
  )
104
104
  for tool in mcp_tools.tools
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
25
25
  from .models.instrumented import InstrumentationSettings
26
26
 
27
27
 
28
- AudioMediaType: TypeAlias = Literal['audio/wav', 'audio/mpeg']
28
+ AudioMediaType: TypeAlias = Literal['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/flac', 'audio/aiff', 'audio/aac']
29
29
  ImageMediaType: TypeAlias = Literal['image/jpeg', 'image/png', 'image/gif', 'image/webp']
30
30
  DocumentMediaType: TypeAlias = Literal[
31
31
  'application/pdf',
@@ -48,7 +48,7 @@ VideoMediaType: TypeAlias = Literal[
48
48
  'video/3gpp',
49
49
  ]
50
50
 
51
- AudioFormat: TypeAlias = Literal['wav', 'mp3']
51
+ AudioFormat: TypeAlias = Literal['wav', 'mp3', 'oga', 'flac', 'aiff', 'aac']
52
52
  ImageFormat: TypeAlias = Literal['jpeg', 'png', 'gif', 'webp']
53
53
  DocumentFormat: TypeAlias = Literal['csv', 'doc', 'docx', 'html', 'md', 'pdf', 'txt', 'xls', 'xlsx']
54
54
  VideoFormat: TypeAlias = Literal['mkv', 'mov', 'mp4', 'webm', 'flv', 'mpeg', 'mpg', 'wmv', 'three_gp']
@@ -182,13 +182,25 @@ class AudioUrl(FileUrl):
182
182
 
183
183
  @property
184
184
  def media_type(self) -> AudioMediaType:
185
- """Return the media type of the audio file, based on the url."""
185
+ """Return the media type of the audio file, based on the url.
186
+
187
+ References:
188
+ - Gemini: https://ai.google.dev/gemini-api/docs/audio#supported-formats
189
+ """
186
190
  if self.url.endswith('.mp3'):
187
191
  return 'audio/mpeg'
188
- elif self.url.endswith('.wav'):
192
+ if self.url.endswith('.wav'):
189
193
  return 'audio/wav'
190
- else:
191
- raise ValueError(f'Unknown audio file extension: {self.url}')
194
+ if self.url.endswith('.flac'):
195
+ return 'audio/flac'
196
+ if self.url.endswith('.oga'):
197
+ return 'audio/ogg'
198
+ if self.url.endswith('.aiff'):
199
+ return 'audio/aiff'
200
+ if self.url.endswith('.aac'):
201
+ return 'audio/aac'
202
+
203
+ raise ValueError(f'Unknown audio file extension: {self.url}')
192
204
 
193
205
  @property
194
206
  def format(self) -> AudioFormat:
@@ -358,6 +370,10 @@ _document_format_lookup: dict[str, DocumentFormat] = {
358
370
  _audio_format_lookup: dict[str, AudioFormat] = {
359
371
  'audio/mpeg': 'mp3',
360
372
  'audio/wav': 'wav',
373
+ 'audio/flac': 'flac',
374
+ 'audio/ogg': 'oga',
375
+ 'audio/aiff': 'aiff',
376
+ 'audio/aac': 'aac',
361
377
  }
362
378
  _image_format_lookup: dict[str, ImageFormat] = {
363
379
  'image/jpeg': 'jpeg',
@@ -416,7 +416,7 @@ class AnthropicModel(Model):
416
416
  def _map_tool_definition(f: ToolDefinition) -> BetaToolParam:
417
417
  return {
418
418
  'name': f.name,
419
- 'description': f.description,
419
+ 'description': f.description or '',
420
420
  'input_schema': f.parameters_json_schema,
421
421
  }
422
422
 
@@ -62,6 +62,7 @@ if TYPE_CHECKING:
62
62
  SystemContentBlockTypeDef,
63
63
  ToolChoiceTypeDef,
64
64
  ToolConfigurationTypeDef,
65
+ ToolSpecificationTypeDef,
65
66
  ToolTypeDef,
66
67
  VideoBlockTypeDef,
67
68
  )
@@ -228,14 +229,16 @@ class BedrockConverseModel(Model):
228
229
 
229
230
  @staticmethod
230
231
  def _map_tool_definition(f: ToolDefinition) -> ToolTypeDef:
231
- return {
232
- 'toolSpec': {
233
- 'name': f.name,
234
- 'description': f.description,
235
- 'inputSchema': {'json': f.parameters_json_schema},
236
- }
232
+ tool_spec: ToolSpecificationTypeDef = {
233
+ 'name': f.name,
234
+ 'inputSchema': {'json': f.parameters_json_schema},
237
235
  }
238
236
 
237
+ if f.description: # pragma: no branch
238
+ tool_spec['description'] = f.description
239
+
240
+ return {'toolSpec': tool_spec}
241
+
239
242
  @property
240
243
  def base_url(self) -> str:
241
244
  return str(self.client.meta.endpoint_url)
@@ -773,7 +773,7 @@ class _GeminiFunction(TypedDict):
773
773
 
774
774
  def _function_from_abstract_tool(tool: ToolDefinition) -> _GeminiFunction:
775
775
  json_schema = tool.parameters_json_schema
776
- f = _GeminiFunction(name=tool.name, description=tool.description, parameters=json_schema)
776
+ f = _GeminiFunction(name=tool.name, description=tool.description or '', parameters=json_schema)
777
777
  return f
778
778
 
779
779
 
@@ -534,7 +534,7 @@ def _function_declaration_from_tool(tool: ToolDefinition) -> FunctionDeclaration
534
534
  json_schema = tool.parameters_json_schema
535
535
  f = FunctionDeclarationDict(
536
536
  name=tool.name,
537
- description=tool.description,
537
+ description=tool.description or '',
538
538
  parameters=json_schema, # type: ignore
539
539
  )
540
540
  return f
@@ -333,7 +333,7 @@ class GroqModel(Model):
333
333
  'type': 'function',
334
334
  'function': {
335
335
  'name': f.name,
336
- 'description': f.description,
336
+ 'description': f.description or '',
337
337
  'parameters': f.parameters_json_schema,
338
338
  },
339
339
  }
@@ -306,7 +306,9 @@ class MistralModel(Model):
306
306
  )
307
307
  tools = [
308
308
  MistralTool(
309
- function=MistralFunction(name=r.name, parameters=r.parameters_json_schema, description=r.description)
309
+ function=MistralFunction(
310
+ name=r.name, parameters=r.parameters_json_schema, description=r.description or ''
311
+ )
310
312
  )
311
313
  for r in all_tools
312
314
  ]
@@ -469,7 +469,7 @@ class OpenAIModel(Model):
469
469
  'type': 'function',
470
470
  'function': {
471
471
  'name': f.name,
472
- 'description': f.description,
472
+ 'description': f.description or '',
473
473
  'parameters': f.parameters_json_schema,
474
474
  },
475
475
  }
@@ -93,10 +93,18 @@ class OpenAIJsonSchemaTransformer(JsonSchemaTransformer):
93
93
  def transform(self, schema: JsonSchema) -> JsonSchema: # noqa C901
94
94
  # Remove unnecessary keys
95
95
  schema.pop('title', None)
96
- schema.pop('default', None)
97
96
  schema.pop('$schema', None)
98
97
  schema.pop('discriminator', None)
99
98
 
99
+ default = schema.get('default', _sentinel)
100
+ if default is not _sentinel:
101
+ # the "default" keyword is not allowed in strict mode, but including it makes some Ollama models behave
102
+ # better, so we keep it around when not strict
103
+ if self.strict is True:
104
+ schema.pop('default', None)
105
+ elif self.strict is None: # pragma: no branch
106
+ self.is_strict_compatible = False
107
+
100
108
  if schema_ref := schema.get('$ref'):
101
109
  if schema_ref == self.root_ref:
102
110
  schema['$ref'] = '#'
@@ -161,7 +161,7 @@ class Tool(Generic[AgentDepsT]):
161
161
  takes_ctx: bool
162
162
  max_retries: int | None
163
163
  name: str
164
- description: str
164
+ description: str | None
165
165
  prepare: ToolPrepareFunc[AgentDepsT] | None
166
166
  docstring_format: DocstringFormat
167
167
  require_parameter_descriptions: bool
@@ -269,7 +269,7 @@ class Tool(Generic[AgentDepsT]):
269
269
  cls,
270
270
  function: Callable[..., Any],
271
271
  name: str,
272
- description: str,
272
+ description: str | None,
273
273
  json_schema: JsonSchemaValue,
274
274
  ) -> Self:
275
275
  """Creates a Pydantic tool from a function and a JSON schema.
@@ -440,12 +440,12 @@ class ToolDefinition:
440
440
  name: str
441
441
  """The name of the tool."""
442
442
 
443
- description: str
444
- """The description of the tool."""
445
-
446
443
  parameters_json_schema: ObjectJsonSchema
447
444
  """The JSON schema for the tool's parameters."""
448
445
 
446
+ description: str | None = None
447
+ """The description of the tool."""
448
+
449
449
  outer_typed_dict_key: str | None = None
450
450
  """The key in the outer [TypedDict] that wraps an output tool.
451
451
 
@@ -1,191 +0,0 @@
1
- from __future__ import annotations, annotations as _annotations
2
-
3
- from collections.abc import AsyncIterator, Sequence
4
- from contextlib import asynccontextmanager
5
- from dataclasses import dataclass
6
- from functools import partial
7
- from typing import Any, Generic
8
-
9
- from typing_extensions import assert_never
10
-
11
- from pydantic_ai.messages import (
12
- AudioUrl,
13
- BinaryContent,
14
- DocumentUrl,
15
- ImageUrl,
16
- ModelMessage,
17
- ModelRequest,
18
- ModelRequestPart,
19
- ModelResponse,
20
- ModelResponsePart,
21
- TextPart,
22
- UserPromptPart,
23
- VideoUrl,
24
- )
25
-
26
- from .agent import Agent, AgentDepsT, OutputDataT
27
-
28
- try:
29
- from starlette.middleware import Middleware
30
- from starlette.routing import Route
31
- from starlette.types import ExceptionHandler, Lifespan
32
-
33
- from fasta2a.applications import FastA2A
34
- from fasta2a.broker import Broker, InMemoryBroker
35
- from fasta2a.schema import (
36
- Artifact,
37
- Message,
38
- Part,
39
- Provider,
40
- Skill,
41
- TaskIdParams,
42
- TaskSendParams,
43
- TextPart as A2ATextPart,
44
- )
45
- from fasta2a.storage import InMemoryStorage, Storage
46
- from fasta2a.worker import Worker
47
- except ImportError as _import_error:
48
- raise ImportError(
49
- 'Please install the `fasta2a` package to use `Agent.to_a2a()` method, '
50
- 'you can use the `a2a` optional group — `pip install "pydantic-ai-slim[a2a]"`'
51
- ) from _import_error
52
-
53
-
54
- @asynccontextmanager
55
- async def worker_lifespan(app: FastA2A, worker: Worker) -> AsyncIterator[None]:
56
- """Custom lifespan that runs the worker during application startup.
57
-
58
- This ensures the worker is started and ready to process tasks as soon as the application starts.
59
- """
60
- async with app.task_manager:
61
- async with worker.run():
62
- yield
63
-
64
-
65
- def agent_to_a2a(
66
- agent: Agent[AgentDepsT, OutputDataT],
67
- *,
68
- storage: Storage | None = None,
69
- broker: Broker | None = None,
70
- # Agent card
71
- name: str | None = None,
72
- url: str = 'http://localhost:8000',
73
- version: str = '1.0.0',
74
- description: str | None = None,
75
- provider: Provider | None = None,
76
- skills: list[Skill] | None = None,
77
- # Starlette
78
- debug: bool = False,
79
- routes: Sequence[Route] | None = None,
80
- middleware: Sequence[Middleware] | None = None,
81
- exception_handlers: dict[Any, ExceptionHandler] | None = None,
82
- lifespan: Lifespan[FastA2A] | None = None,
83
- ) -> FastA2A:
84
- """Create a FastA2A server from an agent."""
85
- storage = storage or InMemoryStorage()
86
- broker = broker or InMemoryBroker()
87
- worker = AgentWorker(agent=agent, broker=broker, storage=storage)
88
-
89
- lifespan = lifespan or partial(worker_lifespan, worker=worker)
90
-
91
- return FastA2A(
92
- storage=storage,
93
- broker=broker,
94
- name=name or agent.name,
95
- url=url,
96
- version=version,
97
- description=description,
98
- provider=provider,
99
- skills=skills,
100
- debug=debug,
101
- routes=routes,
102
- middleware=middleware,
103
- exception_handlers=exception_handlers,
104
- lifespan=lifespan,
105
- )
106
-
107
-
108
- @dataclass
109
- class AgentWorker(Worker, Generic[AgentDepsT, OutputDataT]):
110
- """A worker that uses an agent to execute tasks."""
111
-
112
- agent: Agent[AgentDepsT, OutputDataT]
113
-
114
- async def run_task(self, params: TaskSendParams) -> None:
115
- task = await self.storage.load_task(params['id'], history_length=params.get('history_length'))
116
- assert task is not None, f'Task {params["id"]} not found'
117
- assert 'session_id' in task, 'Task must have a session_id'
118
-
119
- await self.storage.update_task(task['id'], state='working')
120
-
121
- # TODO(Marcelo): We need to have a way to communicate when the task is set to `input-required`. Maybe
122
- # a custom `output_type` with a `more_info_required` field, or something like that.
123
-
124
- task_history = task.get('history', [])
125
- message_history = self.build_message_history(task_history=task_history)
126
-
127
- # TODO(Marcelo): We need to make this more customizable e.g. pass deps.
128
- result = await self.agent.run(message_history=message_history) # type: ignore
129
-
130
- artifacts = self.build_artifacts(result.output)
131
- await self.storage.update_task(task['id'], state='completed', artifacts=artifacts)
132
-
133
- async def cancel_task(self, params: TaskIdParams) -> None:
134
- pass
135
-
136
- def build_artifacts(self, result: Any) -> list[Artifact]:
137
- # TODO(Marcelo): We need to send the json schema of the result on the metadata of the message.
138
- return [Artifact(name='result', index=0, parts=[A2ATextPart(type='text', text=str(result))])]
139
-
140
- def build_message_history(self, task_history: list[Message]) -> list[ModelMessage]:
141
- model_messages: list[ModelMessage] = []
142
- for message in task_history:
143
- if message['role'] == 'user':
144
- model_messages.append(ModelRequest(parts=self._map_request_parts(message['parts'])))
145
- else:
146
- model_messages.append(ModelResponse(parts=self._map_response_parts(message['parts'])))
147
- return model_messages
148
-
149
- def _map_request_parts(self, parts: list[Part]) -> list[ModelRequestPart]:
150
- model_parts: list[ModelRequestPart] = []
151
- for part in parts:
152
- if part['type'] == 'text':
153
- model_parts.append(UserPromptPart(content=part['text']))
154
- elif part['type'] == 'file':
155
- file = part['file']
156
- if 'data' in file:
157
- data = file['data'].encode('utf-8')
158
- content = BinaryContent(data=data, media_type=file['mime_type'])
159
- model_parts.append(UserPromptPart(content=[content]))
160
- else:
161
- url = file['url']
162
- for url_cls in (DocumentUrl, AudioUrl, ImageUrl, VideoUrl):
163
- content = url_cls(url=url)
164
- try:
165
- content.media_type
166
- except ValueError: # pragma: no cover
167
- continue
168
- else:
169
- break
170
- else:
171
- raise ValueError(f'Unknown file type: {file["mime_type"]}') # pragma: no cover
172
- model_parts.append(UserPromptPart(content=[content]))
173
- elif part['type'] == 'data':
174
- # TODO(Marcelo): Maybe we should use this for `ToolReturnPart`, and `RetryPromptPart`.
175
- raise NotImplementedError('Data parts are not supported yet.')
176
- else:
177
- assert_never(part)
178
- return model_parts
179
-
180
- def _map_response_parts(self, parts: list[Part]) -> list[ModelResponsePart]:
181
- model_parts: list[ModelResponsePart] = []
182
- for part in parts:
183
- if part['type'] == 'text':
184
- model_parts.append(TextPart(content=part['text']))
185
- elif part['type'] == 'file': # pragma: no cover
186
- raise NotImplementedError('File parts are not supported yet.')
187
- elif part['type'] == 'data': # pragma: no cover
188
- raise NotImplementedError('Data parts are not supported yet.')
189
- else: # pragma: no cover
190
- assert_never(part)
191
- return model_parts