Skip to content

Commit 3f0234f

Browse files
authored
allow adding tools via Agent __init__ (#128)
1 parent e4450cb commit 3f0234f

20 files changed

+519
-286
lines changed

docs/agents.md

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ print(result.data)
4949
```
5050

5151
1. Create an agent, which expects an integer dependency and returns a boolean result. This agent will have type `#!python Agent[int, bool]`.
52-
2. Define a tool that checks if the square is a winner. Here [`RunContext`][pydantic_ai.dependencies.RunContext] is parameterized with the dependency type `int`; if you got the dependency type wrong you'd get a typing error.
52+
2. Define a tool that checks if the square is a winner. Here [`RunContext`][pydantic_ai.tools.RunContext] is parameterized with the dependency type `int`; if you got the dependency type wrong you'd get a typing error.
5353
3. In reality, you might want to use a random number here e.g. `random.randint(0, 36)`.
5454
4. `result.data` will be a boolean indicating if the square is a winner. Pydantic performs the result validation, it'll be typed as a `bool` since its type is derived from the `result_type` generic parameter of the agent.
5555

@@ -161,7 +161,7 @@ def foobar(x: bytes) -> None:
161161
pass
162162

163163

164-
result = agent.run_sync('Does their name start with "A"?', deps=User('Adam'))
164+
result = agent.run_sync('Does their name start with "A"?', deps=User('Anne'))
165165
foobar(result.data) # (3)!
166166
```
167167

@@ -222,7 +222,7 @@ print(result.data)
222222

223223
1. The agent expects a string dependency.
224224
2. Static system prompt defined at agent creation time.
225-
3. Dynamic system prompt defined via a decorator with [`RunContext`][pydantic_ai.dependencies.RunContext], this is called just after `run_sync`, not when the agent is created, so can benefit from runtime information like the dependencies used on that run.
225+
3. Dynamic system prompt defined via a decorator with [`RunContext`][pydantic_ai.tools.RunContext], this is called just after `run_sync`, not when the agent is created, so can benefit from runtime information like the dependencies used on that run.
226226
4. Another dynamic system prompt, system prompts don't have to have the `RunContext` parameter.
227227

228228
_(This example is complete, it can be run "as is")_
@@ -238,12 +238,13 @@ They're useful when it is impractical or impossible to put all the context an ag
238238

239239
The main semantic difference between PydanticAI Tools and RAG is RAG is synonymous with vector search, while PydanticAI tools are more general-purpose. (Note: we may add support for vector search functionality in the future, particularly an API for generating embeddings. See [#58](https://github.com/pydantic/pydantic-ai/issues/58))
240240

241-
There are two different decorator functions to register tools:
241+
There are a number of ways to register tools with an agent:
242242

243-
1. [`@agent.tool`][pydantic_ai.Agent.tool] — for tools that need access to the agent [context][pydantic_ai.dependencies.RunContext]
244-
2. [`@agent.tool_plain`][pydantic_ai.Agent.tool_plain] — for tools that do not need access to the agent [context][pydantic_ai.dependencies.RunContext]
243+
* via the [`@agent.tool`][pydantic_ai.Agent.tool] decorator — for tools that need access to the agent [context][pydantic_ai.tools.RunContext]
244+
* via the [`@agent.tool_plain`][pydantic_ai.Agent.tool_plain] decorator — for tools that do not need access to the agent [context][pydantic_ai.tools.RunContext]
245+
* via the [`tools`][pydantic_ai.Agent.__init__] keyword argument to `Agent` which can take either plain functions, or instances of [`Tool`][pydantic_ai.tools.Tool]
245246

246-
`@agent.tool` is the default since in the majority of cases tools will need access to the agent context.
247+
`@agent.tool` is considered the default decorator since in the majority of cases tools will need access to the agent context.
247248

248249
Here's an example using both:
249250

@@ -275,9 +276,9 @@ def get_player_name(ctx: RunContext[str]) -> str:
275276
return ctx.deps
276277

277278

278-
dice_result = agent.run_sync('My guess is 4', deps='Adam') # (5)!
279+
dice_result = agent.run_sync('My guess is 4', deps='Anne') # (5)!
279280
print(dice_result.data)
280-
#> Congratulations Adam, you guessed correctly! You're a winner!
281+
#> Congratulations Anne, you guessed correctly! You're a winner!
281282
```
282283

283284
1. This is a pretty simple task, so we can use the fast and cheap Gemini flash model.
@@ -330,13 +331,13 @@ print(dice_result.all_messages())
330331
),
331332
ToolReturn(
332333
tool_name='get_player_name',
333-
content='Adam',
334+
content='Anne',
334335
tool_id=None,
335336
timestamp=datetime.datetime(...),
336337
role='tool-return',
337338
),
338339
ModelTextResponse(
339-
content="Congratulations Adam, you guessed correctly! You're a winner!",
340+
content="Congratulations Anne, you guessed correctly! You're a winner!",
340341
timestamp=datetime.datetime(...),
341342
role='model-text-response',
342343
),
@@ -370,16 +371,59 @@ sequenceDiagram
370371
deactivate LLM
371372
activate Agent
372373
Note over Agent: Retrieves player name
373-
Agent -->> LLM: ToolReturn<br>"Adam"
374+
Agent -->> LLM: ToolReturn<br>"Anne"
374375
deactivate Agent
375376
activate LLM
376377
Note over LLM: LLM constructs final response
377378
378-
LLM ->> Agent: ModelTextResponse<br>"Congratulations Adam, ..."
379+
LLM ->> Agent: ModelTextResponse<br>"Congratulations Anne, ..."
379380
deactivate LLM
380381
Note over Agent: Game session complete
381382
```
382383

384+
### Registering Function Tools via kwarg
385+
386+
As well as using the decorators, we can register tools via the `tools` argument to the [`Agent` constructor][pydantic_ai.Agent.__init__]. This is useful when you want to re-use tools, and can also give more fine-grained control over the tools.
387+
388+
```py title="dice_game_tool_kwarg.py"
389+
import random
390+
391+
from pydantic_ai import Agent, RunContext, Tool
392+
393+
394+
def roll_die() -> str:
395+
"""Roll a six-sided die and return the result."""
396+
return str(random.randint(1, 6))
397+
398+
399+
def get_player_name(ctx: RunContext[str]) -> str:
400+
"""Get the player's name."""
401+
return ctx.deps
402+
403+
404+
agent_a = Agent(
405+
'gemini-1.5-flash',
406+
deps_type=str,
407+
tools=[roll_die, get_player_name], # (1)!
408+
)
409+
agent_b = Agent(
410+
'gemini-1.5-flash',
411+
deps_type=str,
412+
tools=[ # (2)!
413+
Tool(roll_die, takes_ctx=False),
414+
Tool(get_player_name, takes_ctx=True),
415+
],
416+
)
417+
dice_result = agent_b.run_sync('My guess is 4', deps='Anne')
418+
print(dice_result.data)
419+
#> Congratulations Anne, you guessed correctly! You're a winner!
420+
```
421+
422+
1. The simplest way to register tools via the `Agent` constructor is to pass a list of functions, the function signature is inspected to determine if the tool takes [`RunContext`][pydantic_ai.tools.RunContext].
423+
2. `agent_a` and `agent_b` are identical — but we can use [`Tool`][pydantic_ai.tools.Tool] to give more fine-grained control over how tools are defined, e.g. setting their name or description.
424+
425+
_(This example is complete, it can be run "as is")_
426+
383427
### Function Tools vs. Structured Results
384428

385429
As the name suggests, function tools use the model's "tools" or "functions" API to let the model know what is available to call. Tools or functions are also used to define the schema(s) for structured responses, thus a model might have access to many tools, some of which call function tools while others end the run and return a result.
@@ -445,7 +489,7 @@ agent.run_sync('hello', model=FunctionModel(print_schema))
445489

446490
_(This example is complete, it can be run "as is")_
447491

448-
The return type of tool can be any valid JSON object ([`JsonData`][pydantic_ai.dependencies.JsonData]) as some models (e.g. Gemini) support semi-structured return values, some expect text (OpenAI) but seem to be just as good at extracting meaning from the data. If a Python object is returned and the model expects a string, the value will be serialized to JSON.
492+
The return type of tool can be any valid JSON object ([`JsonData`][pydantic_ai.tools.JsonData]) as some models (e.g. Gemini) support semi-structured return values, some expect text (OpenAI) but seem to be just as good at extracting meaning from the data. If a Python object is returned and the model expects a string, the value will be serialized to JSON.
449493

450494
If a tool has a single parameter that can be represented as an object in JSON schema (e.g. dataclass, TypedDict, pydantic model), the schema for the tool is simplified to be just that object. (TODO example)
451495

@@ -456,7 +500,7 @@ Validation errors from both function tool parameter validation and [structured r
456500
You can also raise [`ModelRetry`][pydantic_ai.exceptions.ModelRetry] from within a [tool](#function-tools) or [result validator function](results.md#result-validators-functions) to tell the model it should retry generating a response.
457501

458502
- The default retry count is **1** but can be altered for the [entire agent][pydantic_ai.Agent.__init__], a [specific tool][pydantic_ai.Agent.tool], or a [result validator][pydantic_ai.Agent.__init__].
459-
- You can access the current retry count from within a tool or result validator via [`ctx.retry`][pydantic_ai.dependencies.RunContext].
503+
- You can access the current retry count from within a tool or result validator via [`ctx.retry`][pydantic_ai.tools.RunContext].
460504

461505
Here's an example:
462506

docs/api/dependencies.md

Lines changed: 0 additions & 3 deletions
This file was deleted.

docs/api/tools.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# `pydantic_ai.tools`
2+
3+
::: pydantic_ai.tools

docs/dependencies.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ _(This example is complete, it can be run "as is")_
5151

5252
## Accessing Dependencies
5353

54-
Dependencies are accessed through the [`RunContext`][pydantic_ai.dependencies.RunContext] type, this should be the first parameter of system prompt functions etc.
54+
Dependencies are accessed through the [`RunContext`][pydantic_ai.tools.RunContext] type, this should be the first parameter of system prompt functions etc.
5555

5656

5757
```py title="system_prompt_dependencies.py" hl_lines="20-27"
@@ -92,10 +92,10 @@ async def main():
9292
#> Did you hear about the toothpaste scandal? They called it Colgate.
9393
```
9494

95-
1. [`RunContext`][pydantic_ai.dependencies.RunContext] may optionally be passed to a [`system_prompt`][pydantic_ai.Agent.system_prompt] function as the only argument.
96-
2. [`RunContext`][pydantic_ai.dependencies.RunContext] is parameterized with the type of the dependencies, if this type is incorrect, static type checkers will raise an error.
97-
3. Access dependencies through the [`.deps`][pydantic_ai.dependencies.RunContext.deps] attribute.
98-
4. Access dependencies through the [`.deps`][pydantic_ai.dependencies.RunContext.deps] attribute.
95+
1. [`RunContext`][pydantic_ai.tools.RunContext] may optionally be passed to a [`system_prompt`][pydantic_ai.Agent.system_prompt] function as the only argument.
96+
2. [`RunContext`][pydantic_ai.tools.RunContext] is parameterized with the type of the dependencies, if this type is incorrect, static type checkers will raise an error.
97+
3. Access dependencies through the [`.deps`][pydantic_ai.tools.RunContext.deps] attribute.
98+
4. Access dependencies through the [`.deps`][pydantic_ai.tools.RunContext.deps] attribute.
9999

100100
_(This example is complete, it can be run "as is")_
101101

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ async def main():
125125
2. Here we configure the agent to use [OpenAI's GPT-4o model](api/models/openai.md), you can also set the model when running the agent.
126126
3. The `SupportDependencies` dataclass is used to pass data, connections, and logic into the model that will be needed when running [system prompt](agents.md#system-prompts) and [tool](agents.md#function-tools) functions. PydanticAI's system of dependency injection provides a [type-safe](agents.md#static-type-checking) way to customise the behavior of your agents, and can be especially useful when running [unit tests](testing-evals.md) and evals.
127127
4. Static [system prompts](agents.md#system-prompts) can be registered with the [`system_prompt` keyword argument][pydantic_ai.Agent.__init__] to the agent.
128-
5. Dynamic [system prompts](agents.md#system-prompts) can be registered with the [`@agent.system_prompt`][pydantic_ai.Agent.system_prompt] decorator, and can make use of dependency injection. Dependencies are carried via the [`RunContext`][pydantic_ai.dependencies.RunContext] argument, which is parameterized with the `deps_type` from above. If the type annotation here is wrong, static type checkers will catch it.
129-
6. [`tool`](agents.md#function-tools) let you register functions which the LLM may call while responding to a user. Again, dependencies are carried via [`RunContext`][pydantic_ai.dependencies.RunContext], any other arguments become the tool schema passed to the LLM. Pydantic is used to validate these arguments, and errors are passed back to the LLM so it can retry.
128+
5. Dynamic [system prompts](agents.md#system-prompts) can be registered with the [`@agent.system_prompt`][pydantic_ai.Agent.system_prompt] decorator, and can make use of dependency injection. Dependencies are carried via the [`RunContext`][pydantic_ai.tools.RunContext] argument, which is parameterized with the `deps_type` from above. If the type annotation here is wrong, static type checkers will catch it.
129+
6. [`tool`](agents.md#function-tools) let you register functions which the LLM may call while responding to a user. Again, dependencies are carried via [`RunContext`][pydantic_ai.tools.RunContext], any other arguments become the tool schema passed to the LLM. Pydantic is used to validate these arguments, and errors are passed back to the LLM so it can retry.
130130
7. The docstring of a tool is also passed to the LLM as the description of the tool. Parameter descriptions are [extracted](agents.md#function-tools-and-schema) from the docstring and added to the parameter schema sent to the LLM.
131131
8. [Run the agent](agents.md#running-agents) asynchronously, conducting a conversation with the LLM until a final response is reached. Even in this fairly simple case, the agent will exchange multiple messages with the LLM as tools are called to retrieve a result.
132132
9. The response from the agent will, be guaranteed to be a `SupportResult`, if validation fails [reflection](agents.md#reflection-and-self-correction) will mean the agent is prompted to try again.

docs/install.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ To run the examples, follow instructions in the [examples docs](examples/index.m
4040

4141
## Slim Install
4242

43-
If you know which model you're going to use and want to avoid installing superfluous package, you can use the [`pydantic-ai-slim`](https://pypi.org/project/pydantic-ai-slim/) package.
43+
If you know which model you're going to use and want to avoid installing superfluous packages, you can use the [`pydantic-ai-slim`](https://pypi.org/project/pydantic-ai-slim/) package.
4444

4545
If you're using just [`OpenAIModel`][pydantic_ai.models.openai.OpenAIModel], run:
4646

mkdocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ nav:
3131
- examples/chat-app.md
3232
- API Reference:
3333
- api/agent.md
34+
- api/tools.md
3435
- api/result.md
3536
- api/messages.md
36-
- api/dependencies.md
3737
- api/exceptions.md
3838
- api/models/base.md
3939
- api/models/openai.md
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from importlib.metadata import version
22

33
from .agent import Agent
4-
from .dependencies import RunContext
54
from .exceptions import ModelRetry, UnexpectedModelBehavior, UserError
5+
from .tools import RunContext, Tool
66

7-
__all__ = 'Agent', 'RunContext', 'ModelRetry', 'UnexpectedModelBehavior', 'UserError', '__version__'
7+
__all__ = 'Agent', 'Tool', 'RunContext', 'ModelRetry', 'UnexpectedModelBehavior', 'UserError', '__version__'
88
__version__ = version('pydantic_ai_slim')

pydantic_ai_slim/pydantic_ai/_pydantic.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations as _annotations
77

88
from inspect import Parameter, signature
9-
from typing import TYPE_CHECKING, Any, TypedDict, cast, get_origin
9+
from typing import TYPE_CHECKING, Any, Callable, TypedDict, cast, get_origin
1010

1111
from pydantic import ConfigDict, TypeAdapter
1212
from pydantic._internal import _decorators, _generate_schema, _typing_extra
@@ -20,8 +20,7 @@
2020
from ._utils import ObjectJsonSchema, check_object_json_schema, is_model_like
2121

2222
if TYPE_CHECKING:
23-
from . import _tool
24-
from .dependencies import AgentDeps, ToolParams
23+
pass
2524

2625

2726
__all__ = 'function_schema', 'LazyTypeAdapter'
@@ -39,17 +38,16 @@ class FunctionSchema(TypedDict):
3938
var_positional_field: str | None
4039

4140

42-
def function_schema(either_function: _tool.ToolEitherFunc[AgentDeps, ToolParams]) -> FunctionSchema: # noqa: C901
41+
def function_schema(function: Callable[..., Any], takes_ctx: bool) -> FunctionSchema: # noqa: C901
4342
"""Build a Pydantic validator and JSON schema from a tool function.
4443
4544
Args:
46-
either_function: The function to build a validator and JSON schema for.
45+
function: The function to build a validator and JSON schema for.
46+
takes_ctx: Whether the function takes a `RunContext` first argument.
4747
4848
Returns:
4949
A `FunctionSchema` instance.
5050
"""
51-
function = either_function.whichever()
52-
takes_ctx = either_function.is_left()
5351
config = ConfigDict(title=function.__name__)
5452
config_wrapper = ConfigWrapper(config)
5553
gen_schema = _generate_schema.GenerateSchema(config_wrapper)
@@ -78,13 +76,13 @@ def function_schema(either_function: _tool.ToolEitherFunc[AgentDeps, ToolParams]
7876

7977
if index == 0 and takes_ctx:
8078
if not _is_call_ctx(annotation):
81-
errors.append('First argument must be a RunContext instance when using `.tool`')
79+
errors.append('First parameter of tools that take context must be annotated with RunContext[...]')
8280
continue
8381
elif not takes_ctx and _is_call_ctx(annotation):
84-
errors.append('RunContext instance can only be used with `.tool`')
82+
errors.append('RunContext annotations can only be used with tools that take context')
8583
continue
8684
elif index != 0 and _is_call_ctx(annotation):
87-
errors.append('RunContext instance can only be used as the first argument')
85+
errors.append('RunContext annotations can only be used as the first argument')
8886
continue
8987

9088
field_name = p.name
@@ -159,6 +157,24 @@ def function_schema(either_function: _tool.ToolEitherFunc[AgentDeps, ToolParams]
159157
)
160158

161159

160+
def takes_ctx(function: Callable[..., Any]) -> bool:
161+
"""Check if a function takes a `RunContext` first argument.
162+
163+
Args:
164+
function: The function to check.
165+
166+
Returns:
167+
`True` if the function takes a `RunContext` as first argument, `False` otherwise.
168+
"""
169+
sig = signature(function)
170+
try:
171+
_, first_param = next(iter(sig.parameters.items()))
172+
except StopIteration:
173+
return False
174+
else:
175+
return first_param.annotation is not sig.empty and _is_call_ctx(first_param.annotation)
176+
177+
162178
def _build_schema(
163179
fields: dict[str, core_schema.TypedDictField],
164180
var_kwargs_schema: core_schema.CoreSchema | None,
@@ -191,7 +207,7 @@ def _build_schema(
191207

192208

193209
def _is_call_ctx(annotation: Any) -> bool:
194-
from .dependencies import RunContext
210+
from .tools import RunContext
195211

196212
return annotation is RunContext or (
197213
_typing_extra.is_generic_alias(annotation) and get_origin(annotation) is RunContext

pydantic_ai_slim/pydantic_ai/_result.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111
from typing_extensions import Self, TypeAliasType, TypedDict
1212

1313
from . import _utils, messages
14-
from .dependencies import AgentDeps, ResultValidatorFunc, RunContext
1514
from .exceptions import ModelRetry
1615
from .messages import ModelStructuredResponse, ToolCall
1716
from .result import ResultData
17+
from .tools import AgentDeps, ResultValidatorFunc, RunContext
1818

1919

2020
@dataclass

0 commit comments

Comments
 (0)