Skip to content

Commit becbc57

Browse files
Merge branch 'main' into update-versins
2 parents 0b4e81a + 0b3d020 commit becbc57

File tree

17 files changed

+1042
-275
lines changed

17 files changed

+1042
-275
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ examples/pydantic_ai_examples/.chat_app_messages.sqlite
1515
.vscode/
1616
/question_graph_history.json
1717
/docs-site/.wrangler/
18-
/CLAUDE.md
1918
node_modules/
2019
**.idea/
2120
.coverage*

CLAUDE.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Development Commands
6+
7+
### Core Development Tasks
8+
- **Install dependencies**: `make install` (requires uv, pre-commit, and deno)
9+
- **Run all checks**: `make` (format, lint, typecheck, test with coverage)
10+
- **Format code**: `make format`
11+
- **Lint code**: `make lint`
12+
- **Type checking**: `make typecheck` (uses pyright) or `make typecheck-both` (pyright + mypy)
13+
- **Run tests**: `make test` (with coverage) or `make test-fast` (parallel, no coverage)
14+
- **Build docs**: `make docs` or `make docs-serve` (local development)
15+
16+
### Single Test Commands
17+
- **Run specific test**: `uv run pytest tests/test_agent.py::test_function_name -v`
18+
- **Run test file**: `uv run pytest tests/test_agent.py -v`
19+
- **Run with debug**: `uv run pytest tests/test_agent.py -v -s`
20+
21+
### Multi-Python Testing
22+
- **Install all Python versions**: `make install-all-python`
23+
- **Test all Python versions**: `make test-all-python`
24+
25+
## Project Architecture
26+
27+
### Core Components
28+
29+
**Agent System (`pydantic_ai_slim/pydantic_ai/agent.py`)**
30+
- `Agent[AgentDepsT, OutputDataT]`: Main orchestrator class with generic types for dependency injection and output validation
31+
- Entry points: `run()`, `run_sync()`, `run_stream()` methods
32+
- Handles tool management, system prompts, and model interaction
33+
34+
**Model Integration (`pydantic_ai_slim/pydantic_ai/models/`)**
35+
- Unified interface across providers: OpenAI, Anthropic, Google, Groq, Cohere, Mistral, Bedrock, HuggingFace
36+
- Model strings: `"openai:gpt-4o"`, `"anthropic:claude-3-5-sonnet"`, `"google:gemini-1.5-pro"`
37+
- `ModelRequestParameters` for configuration, `StreamedResponse` for streaming
38+
39+
**Graph-based Execution (`pydantic_graph/` + `_agent_graph.py`)**
40+
- State machine execution through: `UserPromptNode``ModelRequestNode``CallToolsNode`
41+
- `GraphAgentState` maintains message history and usage tracking
42+
- `GraphRunContext` provides execution context
43+
44+
**Tool System (`tools.py`, `toolsets/`)**
45+
- `@agent.tool` decorator for function registration
46+
- `RunContext[AgentDepsT]` provides dependency injection in tools
47+
- Support for sync/async functions with automatic schema generation
48+
49+
**Output Handling**
50+
- `TextOutput`: Plain text responses
51+
- `ToolOutput`: Structured data via tool calls
52+
- `NativeOutput`: Provider-specific structured output
53+
- `PromptedOutput`: Prompt-based structured extraction
54+
55+
### Key Design Patterns
56+
57+
**Dependency Injection**
58+
```python
59+
@dataclass
60+
class MyDeps:
61+
database: DatabaseConn
62+
63+
agent = Agent('openai:gpt-4o', deps_type=MyDeps)
64+
65+
@agent.tool
66+
async def get_data(ctx: RunContext[MyDeps]) -> str:
67+
return await ctx.deps.database.fetch_data()
68+
```
69+
70+
**Type-Safe Agents**
71+
```python
72+
class OutputModel(BaseModel):
73+
result: str
74+
confidence: float
75+
76+
agent: Agent[MyDeps, OutputModel] = Agent(
77+
'openai:gpt-4o',
78+
deps_type=MyDeps,
79+
output_type=OutputModel
80+
)
81+
```
82+
83+
## Workspace Structure
84+
85+
This is a uv workspace with multiple packages:
86+
- **`pydantic_ai_slim/`**: Core framework (minimal dependencies)
87+
- **`pydantic_evals/`**: Evaluation system
88+
- **`pydantic_graph/`**: Graph execution engine
89+
- **`examples/`**: Example applications
90+
- **`clai/`**: CLI tool
91+
- **`mcp-run-python/`**: MCP server implementation (Deno/TypeScript)
92+
93+
## Testing Strategy
94+
95+
- **Unit tests**: `tests/` directory with comprehensive model and component coverage
96+
- **VCR cassettes**: `tests/cassettes/` for recorded LLM API interactions
97+
- **Test models**: Use `TestModel` for deterministic testing
98+
- **Examples testing**: `tests/test_examples.py` validates all documentation examples
99+
- **Multi-version testing**: Python 3.9-3.13 support
100+
101+
## Key Configuration Files
102+
103+
- **`pyproject.toml`**: Main workspace configuration with dependency groups
104+
- **`pydantic_ai_slim/pyproject.toml`**: Core package with model optional dependencies
105+
- **`Makefile`**: Development task automation
106+
- **`uv.lock`**: Locked dependencies for reproducible builds
107+
108+
## Important Implementation Notes
109+
110+
- **Model Provider Integration**: Each provider in `models/` directory implements the `Model` abstract base class
111+
- **Message System**: Vendor-agnostic message format in `messages.py` with rich content type support
112+
- **Streaming Architecture**: Real-time response processing with validation during streaming
113+
- **Error Handling**: Specific exception types with retry mechanisms at multiple levels
114+
- **OpenTelemetry Integration**: Built-in observability support
115+
116+
## Documentation Development
117+
118+
- **Local docs**: `make docs-serve` (serves at http://localhost:8000)
119+
- **Docs source**: `docs/` directory (MkDocs with Material theme)
120+
- **API reference**: Auto-generated from docstrings using mkdocstrings
121+
122+
## Dependencies Management
123+
124+
- **Package manager**: uv (fast Python package manager)
125+
- **Lock file**: `uv.lock` (commit this file)
126+
- **Sync command**: `make sync` to update dependencies
127+
- **Optional extras**: Define groups in `pyproject.toml` optional-dependencies

pydantic_ai_slim/pydantic_ai/_function_schema.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,20 @@ def function_schema( # noqa: C901
9696
config = ConfigDict(title=function.__name__, use_attribute_docstrings=True)
9797
config_wrapper = ConfigWrapper(config)
9898
gen_schema = _generate_schema.GenerateSchema(config_wrapper)
99+
errors: list[str] = []
99100

100-
sig = signature(function)
101+
try:
102+
sig = signature(function)
103+
except ValueError as e:
104+
errors.append(str(e))
105+
sig = signature(lambda: None)
101106

102107
type_hints = _typing_extra.get_function_type_hints(function)
103108

104109
var_kwargs_schema: core_schema.CoreSchema | None = None
105110
fields: dict[str, core_schema.TypedDictField] = {}
106111
positional_fields: list[str] = []
107112
var_positional_field: str | None = None
108-
errors: list[str] = []
109113
decorators = _decorators.DecoratorInfos()
110114

111115
description, field_descriptions = doc_descriptions(function, sig, docstring_format=docstring_format)
@@ -235,14 +239,19 @@ def _takes_ctx(function: TargetFunc[P, R]) -> TypeIs[WithCtx[P, R]]:
235239
Returns:
236240
`True` if the function takes a `RunContext` as first argument, `False` otherwise.
237241
"""
238-
sig = signature(function)
242+
try:
243+
sig = signature(function)
244+
except ValueError: # pragma: no cover
245+
return False # pragma: no cover
239246
try:
240247
first_param_name = next(iter(sig.parameters.keys()))
241248
except StopIteration:
242249
return False
243250
else:
244251
type_hints = _typing_extra.get_function_type_hints(function)
245-
annotation = type_hints[first_param_name]
252+
annotation = type_hints.get(first_param_name)
253+
if annotation is None:
254+
return False # pragma: no cover
246255
return True is not sig.empty and _is_call_ctx(annotation)
247256

248257

pydantic_ai_slim/pydantic_ai/agent.py

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from .models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model
3737
from .output import OutputDataT, OutputSpec
3838
from .profiles import ModelProfile
39-
from .result import FinalResult, StreamedRunResult
39+
from .result import AgentStream, FinalResult, StreamedRunResult
4040
from .settings import ModelSettings, merge_model_settings
4141
from .tools import (
4242
AgentDepsT,
@@ -1127,29 +1127,15 @@ async def main():
11271127
while True:
11281128
if self.is_model_request_node(node):
11291129
graph_ctx = agent_run.ctx
1130-
async with node._stream(graph_ctx) as streamed_response: # pyright: ignore[reportPrivateUsage]
1131-
1132-
async def stream_to_final(
1133-
s: models.StreamedResponse,
1134-
) -> FinalResult[models.StreamedResponse] | None:
1135-
output_schema = graph_ctx.deps.output_schema
1136-
async for maybe_part_event in streamed_response:
1137-
if isinstance(maybe_part_event, _messages.PartStartEvent):
1138-
new_part = maybe_part_event.part
1139-
if isinstance(new_part, _messages.TextPart) and isinstance(
1140-
output_schema, _output.TextOutputSchema
1141-
):
1142-
return FinalResult(s, None, None)
1143-
elif isinstance(new_part, _messages.ToolCallPart) and (
1144-
tool_def := graph_ctx.deps.tool_manager.get_tool_def(new_part.tool_name)
1145-
):
1146-
if tool_def.kind == 'output':
1147-
return FinalResult(s, new_part.tool_name, new_part.tool_call_id)
1148-
elif tool_def.kind == 'deferred':
1149-
return FinalResult(s, None, None)
1130+
async with node.stream(graph_ctx) as stream:
1131+
1132+
async def stream_to_final(s: AgentStream) -> FinalResult[AgentStream] | None:
1133+
async for event in stream:
1134+
if isinstance(event, _messages.FinalResultEvent):
1135+
return FinalResult(s, event.tool_name, event.tool_call_id)
11501136
return None
11511137

1152-
final_result = await stream_to_final(streamed_response)
1138+
final_result = await stream_to_final(stream)
11531139
if final_result is not None:
11541140
if yielded:
11551141
raise exceptions.AgentRunError('Agent run produced final results') # pragma: no cover
@@ -1184,14 +1170,8 @@ async def on_complete() -> None:
11841170
yield StreamedRunResult(
11851171
messages,
11861172
graph_ctx.deps.new_message_index,
1187-
graph_ctx.deps.usage_limits,
1188-
streamed_response,
1189-
graph_ctx.deps.output_schema,
1190-
_agent_graph.build_run_context(graph_ctx),
1191-
graph_ctx.deps.output_validators,
1192-
final_result.tool_name,
1173+
stream,
11931174
on_complete,
1194-
graph_ctx.deps.tool_manager,
11951175
)
11961176
break
11971177
next_node = await agent_run.next(node)

pydantic_ai_slim/pydantic_ai/models/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,7 +758,7 @@ async def download_item(
758758

759759
data_type = media_type
760760
if type_format == 'extension':
761-
data_type = data_type.split('/')[1]
761+
data_type = item.format
762762

763763
data = response.content
764764
if data_format in ('base64', 'base64_uri'):

pydantic_ai_slim/pydantic_ai/models/function.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@
1616
from .. import _utils, usage
1717
from .._utils import PeekableAsyncStream
1818
from ..messages import (
19-
AudioUrl,
2019
BinaryContent,
21-
ImageUrl,
2220
ModelMessage,
2321
ModelRequest,
2422
ModelResponse,
@@ -345,18 +343,19 @@ def _estimate_usage(messages: Iterable[ModelMessage]) -> usage.Usage:
345343
def _estimate_string_tokens(content: str | Sequence[UserContent]) -> int:
346344
if not content:
347345
return 0
346+
348347
if isinstance(content, str):
349-
return len(re.split(r'[\s",.:]+', content.strip()))
350-
else:
351-
tokens = 0
352-
for part in content:
353-
if isinstance(part, str):
354-
tokens += len(re.split(r'[\s",.:]+', part.strip()))
355-
# TODO(Marcelo): We need to study how we can estimate the tokens for these types of content.
356-
if isinstance(part, (AudioUrl, ImageUrl)):
357-
tokens += 0
358-
elif isinstance(part, BinaryContent):
359-
tokens += len(part.data)
360-
else:
361-
tokens += 0
362-
return tokens
348+
return len(_TOKEN_SPLIT_RE.split(content.strip()))
349+
350+
tokens = 0
351+
for part in content:
352+
if isinstance(part, str):
353+
tokens += len(_TOKEN_SPLIT_RE.split(part.strip()))
354+
elif isinstance(part, BinaryContent):
355+
tokens += len(part.data)
356+
# TODO(Marcelo): We need to study how we can estimate the tokens for AudioUrl or ImageUrl.
357+
358+
return tokens
359+
360+
361+
_TOKEN_SPLIT_RE = re.compile(r'[\s",.:]+')

pydantic_ai_slim/pydantic_ai/models/mistral.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
CompletionChunk as MistralCompletionChunk,
5353
Content as MistralContent,
5454
ContentChunk as MistralContentChunk,
55+
DocumentURLChunk as MistralDocumentURLChunk,
5556
FunctionCall as MistralFunctionCall,
5657
ImageURL as MistralImageURL,
5758
ImageURLChunk as MistralImageURLChunk,
@@ -539,10 +540,19 @@ def _map_user_prompt(self, part: UserPromptPart) -> MistralUserMessage:
539540
if item.is_image:
540541
image_url = MistralImageURL(url=f'data:{item.media_type};base64,{base64_encoded}')
541542
content.append(MistralImageURLChunk(image_url=image_url, type='image_url'))
543+
elif item.media_type == 'application/pdf':
544+
content.append(
545+
MistralDocumentURLChunk(
546+
document_url=f'data:application/pdf;base64,{base64_encoded}', type='document_url'
547+
)
548+
)
542549
else:
543-
raise RuntimeError('Only image binary content is supported for Mistral.')
550+
raise RuntimeError('BinaryContent other than image or PDF is not supported in Mistral.')
544551
elif isinstance(item, DocumentUrl):
545-
raise RuntimeError('DocumentUrl is not supported in Mistral.') # pragma: no cover
552+
if item.media_type == 'application/pdf':
553+
content.append(MistralDocumentURLChunk(document_url=item.url, type='document_url'))
554+
else:
555+
raise RuntimeError('DocumentUrl other than PDF is not supported in Mistral.')
546556
elif isinstance(item, VideoUrl):
547557
raise RuntimeError('VideoUrl is not supported in Mistral.')
548558
else: # pragma: no cover

0 commit comments

Comments
 (0)