In previous posts, we explored function calling and how it enables models to interact with external tools. However, manually defining schemas and managing the request/response loop can get tedious as an application grows. Agent frameworks can help here.
In this post, let’s experiment with PydanticAI Agents. We’ll define a simple agent similar to our earlier examples, but with less boilerplate. Then, we can inspect the underlying requests to see how the framework orchestrates tool calls. Finally, we’ll test swapping the backend model for a local instance running via Ollama.
cactify_name Agent with gpt-4o-mini
To understand the syntax, let’s revisit the cactify_name example from previous posts. We can register the function directly to the agent. This bypasses the need to manually define the schema, as the framework handles the request/response loop:
from pydantic_ai import Agent
agent = Agent(
name="Name Cactifier",
model="openai:gpt-4o-mini",
instructions="You are a friendly agent that transforms people's names to make them more cactus-like using specific rules.",
)
@agent.tool_plain
def cactify_name(name: str) -> str:
"""Makes a name more cactus-like."""
base_name = name
if base_name.lower().endswith(("s", "x")):
base_name = base_name[:-1]
if base_name and base_name.lower()[-1] in "aeiou":
base_name = base_name[:-1]
return base_name + "actus"
result1 = await agent.run(
"What would the name Alice be if it were cactus-ified?"
)
print("Response:", result1.output)
When we run this, the agent recognizes it needs to use the tool and returns the result:
Response: The cactus-ified version of the name Alice is "Alicactus."
This requires significantly less code. Let’s look at how it works.
We can inspect result1. The .all_messages() method returns the conversation history as a list of ModelMessage objects. To improve readability, we removed some fields:
1[
2 ModelRequest(
3 parts=[
4 UserPromptPart(
5 content="What would the name Alice be if it were cactus-ified?",
6 )
7 ],
8 instructions="You are a friendly agent that transforms people's names to make them more cactus-like using specific rules.",
9 ),
10 ModelResponse(
11 parts=[
12 ToolCallPart(
13 tool_name="cactify_name",
14 args='{"name":"Alice"}',
15 )
16 ],
17 ),
18 ModelRequest(
19 parts=[
20 ToolReturnPart(
21 tool_name="cactify_name",
22 content="Alicactus",
23 )
24 ],
25 instructions="You are a friendly agent that transforms people's names to make them more cactus-like using specific rules.",
26 ),
27 ModelResponse(
28 parts=[
29 TextPart(
30 content='Your cactus-ified name would be "Alicactus"!'
31 )
32 ],
33 ),
34]
The output displays the conversation flow:
| Line | Type | Description |
|---|---|---|
| 2 | ModelRequest | The user’s prompt (“What would the name Alice be if it were cactus-ified?”) and the model’s instructions |
| 10 | ModelResponse | The model’s decision to call the cactify_name tool with {"name": "Alice"} |
| 18 | ModelRequest | The tool’s return value ("Alicactus") sent back to the model after running the function |
| 27 | ModelResponse | The model’s final text response incorporating the tool result |
This illustrates the agent loop: user prompt → tool call → tool execution → final response.
Llama 3.2 via Ollama
PydanticAI supports various model backends, including local models via Ollama. To switch to a local model, we can change the model parameter when creating or running the agent.
result2 = await agent.run(
"What would the name Alice be if it were cactus-ified?",
model="ollama:llama3.2",
)
print("Response:", result2.output)
Running the agent again with the same prompt, it uses the local llama3.2:3b (about 2GB) model:
Response: If the name Alice were to be cactus-ified, the resulting name would be Alicactus. The "-icus" suffix at the end of Alice suggests a formal or classical style, which is fitting for a cactus-based transformation. The addition of "cactus" before the original surname creates a unique and prickly twist on the classic name.
The response is more verbose and not entirely accurate, but it shows the framework can orchestrate tool calls with a smaller local model.
OpenAI-compatible Providers
Inspecting all_messages() is helpful, but the abstraction can hide details. Let’s look at what is sent to the model provider.
Pydantic offers Logfire for monitoring and debugging LLM interactions. For this example, however, let’s view the HTTP requests and responses directly.
We added a helper that taps into httpx event hooks to print request and response data, using it with our Ollama model:
from notebooks.pydantic_models import get_model
llama32_model_with_logging = get_model(
"ollama:llama3.2", debug_http=True
)
result3 = await agent.run(
"What would the name Alice be if it were cactus-ified?",
model=llama32_model_with_logging,
)
print("Response:", result3.output)
The log output is verbose, so let’s break it down:
1# >>> REQUEST POST http://localhost:11434/v1/chat/completions
2{
3 "messages": [
4 {
5 "content": "You are a friendly agent that transforms people's names to make them more cactus-like using specific rules.",
6 "role": "system"
7 },
8 {
9 "role": "user",
10 "content": "What would the name Alice be if it were cactus-ified?"
11 }
12 ],
13 "model": "llama3.2",
14 "stream": false,
15 "tool_choice": "auto",
16 "tools": [
17 {
18 "type": "function",
19 "function": {
20 "name": "cactify_name",
21 "description": "Makes a name more cactus-like.",
22 "parameters": {
23 "additionalProperties": false,
24 "properties": {"name": {"type": "string"}},
25 "required": ["name"],
26 "type": "object"
27 },
28 "strict": true
29 }
30 }
31 ]
32}
33# <<< RESPONSE 200
34{
35 "id": "chatcmpl-686",
36 "object": "chat.completion",
37 "created": 1764877097,
38 "model": "llama3.2",
39 "system_fingerprint": "fp_ollama",
40 "choices": [
41 {
42 "index": 0,
43 "message": {
44 "role": "assistant",
45 "content": "",
46 "tool_calls": [
47 {
48 "id": "call_64x06nvw",
49 "index": 0,
50 "type": "function",
51 "function": {
52 "name": "cactify_name",
53 "arguments": "{\"name\":\"Alice\"}"
54 }
55 }
56 ]
57 },
58 "finish_reason": "tool_calls"
59 }
60 ],
61 "usage": {}
62}
The first request/response pair shows the initial tool call, starting with a POST request to the Ollama endpoint /v1/chat/completions, an OpenAI-compatible API endpoint for Chat Completions.
| Line | Part | Description |
|---|---|---|
| 3-11 | messages | System instructions and the user’s prompt sent to the model |
| 16 | tools | The cactify_name tool definition with its JSON schema |
| 46 | tool_calls | Model returns a tool_calls array instead of text content in the response |
| 53 | function.arguments | The model’s structured call arguments: {"name":"Alice"} |
| 58 | finish_reason | Set to "tool_calls" indicating the model wants to invoke a tool rather than respond directly |
The second request/response pair shows the tool return value sent back to the model, followed by the final response:
1# >>> REQUEST POST http://localhost:11434/v1/chat/completions
2{
3 "messages": [
4 {
5 "content": "You are a friendly agent that transforms people's names to make them more cactus-like using specific rules.",
6 "role": "system"
7 },
8 {
9 "role": "user",
10 "content": "What would the name Alice be if it were cactus-ified?"
11 },
12 {
13 "role": "assistant",
14 "content": null,
15 "tool_calls": [
16 {
17 "id": "call_64x06nvw",
18 "type": "function",
19 "function": {
20 "name": "cactify_name",
21 "arguments": "{\"name\":\"Alice\"}"
22 }
23 }
24 ]
25 },
26 {
27 "role": "tool",
28 "tool_call_id": "call_64x06nvw",
29 "content": "Alicactus"
30 }
31 ],
32 "model": "llama3.2",
33 "stream": false,
34 "tool_choice": "auto",
35 "tools": [
36 {
37 "type": "function",
38 "function": {
39 "name": "cactify_name",
40 "description": "Makes a name more cactus-like.",
41 "parameters": {
42 "additionalProperties": false,
43 "properties": {"name": {"type": "string"}},
44 "required": ["name"],
45 "type": "object"
46 },
47 "strict": true
48 }
49 }
50 ]
51}
52# <<< RESPONSE 200
53{
54 "id": "chatcmpl-387",
55 "object": "chat.completion",
56 "created": 1764877097,
57 "model": "llama3.2",
58 "system_fingerprint": "fp_ollama",
59 "choices": [
60 {
61 "index": 0,
62 "message": {
63 "role": "assistant",
64 "content": "The cactus-ified version of the name \"Alice\" is indeed \"Alicactus\"."
65 },
66 "finish_reason": "stop"
67 }
68 ],
69 "usage": {}
70}
This request includes the full conversation history, including the tool call and its result:
| Line | Part | Description |
|---|---|---|
| 12-30 | messages | Messages now contains the full conversation history, including the tool result message with role: "tool", matching tool_call_id, and content Alicactus |
| 64 | content | The model’s final text response: “The cactus-ified version of the name “Alice” is indeed “Alicactus”.” |
| 66 | finish_reason | Set to "stop" indicating the model has completed its response |
This logging helps us understand how PydanticAI Agents orchestrate tool calls and manage conversation flow.
Conclusion
PydanticAI Agents provide a framework for building AI agents with less boilerplate. This allows us to focus on defining tools and business logic instead of managing the request/response loop.
