Skip to content

Commit 0cfa63b

Browse files
committed
Add metadata support to deferred tool exceptions
Enables CallDeferred and ApprovalRequired exceptions to carry arbitrary metadata via an optional `metadata` parameter. The metadata is accessible in DeferredToolRequests.metadata keyed by tool_call_id. This allows tools to: - Provide cost/time estimates for approval decisions - Include task IDs for external execution tracking - Store context about why approval is required - Attach priority or urgency information Backward compatible - metadata defaults to empty dict if not provided.
1 parent cf7ce9f commit 0cfa63b

File tree

9 files changed

+396
-63
lines changed

9 files changed

+396
-63
lines changed

docs/deferred-tools.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,111 @@ async def main():
320320

321321
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
322322

323+
## Attaching Metadata to Deferred Tools
324+
325+
Both [`CallDeferred`][pydantic_ai.exceptions.CallDeferred] and [`ApprovalRequired`][pydantic_ai.exceptions.ApprovalRequired] exceptions accept an optional `metadata` parameter that allows you to attach arbitrary context information to deferred tool calls. This metadata is then available in the [`DeferredToolRequests.metadata`][pydantic_ai.tools.DeferredToolRequests.metadata] dictionary, keyed by the tool call ID.
326+
327+
Common use cases for metadata include:
328+
329+
- Providing cost estimates or time estimates for approval decisions
330+
- Including task IDs or tracking information for external execution
331+
- Storing context about why approval is required
332+
- Attaching priority or urgency information
333+
334+
Here's an example showing how to use metadata with both approval-required and external tools:
335+
336+
```python {title="deferred_tools_with_metadata.py"}
337+
from pydantic_ai import (
338+
Agent,
339+
ApprovalRequired,
340+
CallDeferred,
341+
DeferredToolRequests,
342+
DeferredToolResults,
343+
RunContext,
344+
ToolApproved,
345+
ToolDenied,
346+
)
347+
348+
agent = Agent('openai:gpt-5', output_type=[str, DeferredToolRequests])
349+
350+
351+
@agent.tool
352+
def expensive_compute(ctx: RunContext, task_id: str) -> str:
353+
if not ctx.tool_call_approved:
354+
raise ApprovalRequired(
355+
metadata={
356+
'task_id': task_id,
357+
'estimated_cost_usd': 25.50,
358+
'estimated_time_minutes': 15,
359+
'reason': 'High compute cost',
360+
}
361+
)
362+
return f'Task {task_id} completed'
363+
364+
365+
@agent.tool
366+
async def external_api_call(ctx: RunContext, endpoint: str) -> str:
367+
# Schedule the external API call and defer execution
368+
task_id = f'api_call_{ctx.tool_call_id}'
369+
370+
raise CallDeferred(
371+
metadata={
372+
'task_id': task_id,
373+
'endpoint': endpoint,
374+
'priority': 'high',
375+
}
376+
)
377+
378+
379+
result = agent.run_sync('Run expensive task-123 and call the /data endpoint')
380+
messages = result.all_messages()
381+
382+
assert isinstance(result.output, DeferredToolRequests)
383+
requests = result.output
384+
385+
# Handle approvals with metadata
386+
for call in requests.approvals:
387+
metadata = requests.metadata.get(call.tool_call_id, {})
388+
print(f"Approval needed for {call.tool_name}")
389+
print(f" Cost: ${metadata.get('estimated_cost_usd')}")
390+
print(f" Time: {metadata.get('estimated_time_minutes')} minutes")
391+
print(f" Reason: {metadata.get('reason')}")
392+
393+
# Handle external calls with metadata
394+
for call in requests.calls:
395+
metadata = requests.metadata.get(call.tool_call_id, {})
396+
print(f"External call to {call.tool_name}")
397+
print(f" Task ID: {metadata.get('task_id')}")
398+
print(f" Priority: {metadata.get('priority')}")
399+
400+
# Build results with approvals and external results
401+
results = DeferredToolResults()
402+
for call in requests.approvals:
403+
metadata = requests.metadata.get(call.tool_call_id, {})
404+
cost = metadata.get('estimated_cost_usd', 0)
405+
406+
if cost < 50: # Approve if cost is under $50
407+
results.approvals[call.tool_call_id] = ToolApproved()
408+
else:
409+
results.approvals[call.tool_call_id] = ToolDenied('Cost too high')
410+
411+
for call in requests.calls:
412+
metadata = requests.metadata.get(call.tool_call_id, {})
413+
# Simulate getting result from external task
414+
task_id = metadata.get('task_id')
415+
results.calls[call.tool_call_id] = f'Result from {task_id}: success'
416+
417+
result = agent.run_sync(message_history=messages, deferred_tool_results=results)
418+
print(result.output)
419+
"""
420+
I completed task-123 and retrieved data from the /data endpoint.
421+
"""
422+
```
423+
424+
_(This example is complete, it can be run "as is")_
425+
426+
The metadata dictionary can contain any JSON-serializable values and is entirely application-defined. If no metadata is provided when raising the exception, the tool call ID will still be present in the `metadata` dictionary with an empty dict as the value for backward compatibility.
427+
323428
## See Also
324429

325430
- [Function Tools](tools.md) - Basic tool concepts and registration

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ async def process_tool_calls( # noqa: C901
879879
calls_to_run = [call for call in calls_to_run if call.tool_call_id in calls_to_run_results]
880880

881881
deferred_calls: dict[Literal['external', 'unapproved'], list[_messages.ToolCallPart]] = defaultdict(list)
882+
deferred_metadata: dict[str, dict[str, Any]] = {}
882883

883884
if calls_to_run:
884885
async for event in _call_tools(
@@ -890,6 +891,7 @@ async def process_tool_calls( # noqa: C901
890891
usage_limits=ctx.deps.usage_limits,
891892
output_parts=output_parts,
892893
output_deferred_calls=deferred_calls,
894+
output_deferred_metadata=deferred_metadata,
893895
):
894896
yield event
895897

@@ -923,6 +925,7 @@ async def process_tool_calls( # noqa: C901
923925
deferred_tool_requests = _output.DeferredToolRequests(
924926
calls=deferred_calls['external'],
925927
approvals=deferred_calls['unapproved'],
928+
metadata=deferred_metadata,
926929
)
927930

928931
final_result = result.FinalResult(cast(NodeRunEndT, deferred_tool_requests), None, None)
@@ -940,10 +943,12 @@ async def _call_tools(
940943
usage_limits: _usage.UsageLimits,
941944
output_parts: list[_messages.ModelRequestPart],
942945
output_deferred_calls: dict[Literal['external', 'unapproved'], list[_messages.ToolCallPart]],
946+
output_deferred_metadata: dict[str, dict[str, Any]],
943947
) -> AsyncIterator[_messages.HandleResponseEvent]:
944948
tool_parts_by_index: dict[int, _messages.ModelRequestPart] = {}
945949
user_parts_by_index: dict[int, _messages.UserPromptPart] = {}
946950
deferred_calls_by_index: dict[int, Literal['external', 'unapproved']] = {}
951+
deferred_metadata_by_index: dict[int, dict[str, Any]] = {}
947952

948953
if usage_limits.tool_calls_limit is not None:
949954
projected_usage = deepcopy(usage)
@@ -978,10 +983,12 @@ async def handle_call_or_result(
978983
tool_part, tool_user_content = (
979984
(await coro_or_task) if inspect.isawaitable(coro_or_task) else coro_or_task.result()
980985
)
981-
except exceptions.CallDeferred:
986+
except exceptions.CallDeferred as e:
982987
deferred_calls_by_index[index] = 'external'
983-
except exceptions.ApprovalRequired:
988+
deferred_metadata_by_index[index] = e.metadata
989+
except exceptions.ApprovalRequired as e:
984990
deferred_calls_by_index[index] = 'unapproved'
991+
deferred_metadata_by_index[index] = e.metadata
985992
else:
986993
tool_parts_by_index[index] = tool_part
987994
if tool_user_content:
@@ -1020,7 +1027,10 @@ async def handle_call_or_result(
10201027
output_parts.extend([user_parts_by_index[k] for k in sorted(user_parts_by_index)])
10211028

10221029
for k in sorted(deferred_calls_by_index):
1023-
output_deferred_calls[deferred_calls_by_index[k]].append(tool_calls[k])
1030+
call = tool_calls[k]
1031+
output_deferred_calls[deferred_calls_by_index[k]].append(call)
1032+
if k in deferred_metadata_by_index:
1033+
output_deferred_metadata[call.tool_call_id] = deferred_metadata_by_index[k]
10241034

10251035

10261036
async def _call_tool(

pydantic_ai_slim/pydantic_ai/exceptions.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,30 @@ class CallDeferred(Exception):
6767
"""Exception to raise when a tool call should be deferred.
6868
6969
See [tools docs](../deferred-tools.md#deferred-tools) for more information.
70+
71+
Args:
72+
metadata: Optional dictionary of metadata to attach to the deferred tool call.
73+
This metadata will be available in `DeferredToolRequests.metadata` keyed by `tool_call_id`.
7074
"""
7175

72-
pass
76+
def __init__(self, metadata: dict[str, Any] | None = None):
77+
self.metadata = metadata or {}
78+
super().__init__()
7379

7480

7581
class ApprovalRequired(Exception):
7682
"""Exception to raise when a tool call requires human-in-the-loop approval.
7783
7884
See [tools docs](../deferred-tools.md#human-in-the-loop-tool-approval) for more information.
85+
86+
Args:
87+
metadata: Optional dictionary of metadata to attach to the deferred tool call.
88+
This metadata will be available in `DeferredToolRequests.metadata` keyed by `tool_call_id`.
7989
"""
8090

81-
pass
91+
def __init__(self, metadata: dict[str, Any] | None = None):
92+
self.metadata = metadata or {}
93+
super().__init__()
8294

8395

8496
class UserError(RuntimeError):

pydantic_ai_slim/pydantic_ai/tools.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ class DeferredToolRequests:
147147
"""Tool calls that require external execution."""
148148
approvals: list[ToolCallPart] = field(default_factory=list)
149149
"""Tool calls that require human-in-the-loop approval."""
150+
metadata: dict[str, dict[str, Any]] = field(default_factory=dict)
151+
"""Metadata for deferred tool calls, keyed by tool_call_id.
152+
153+
This contains any metadata that was provided when raising [`CallDeferred`][pydantic_ai.exceptions.CallDeferred]
154+
or [`ApprovalRequired`][pydantic_ai.exceptions.ApprovalRequired] exceptions.
155+
"""
150156

151157

152158
@dataclass(kw_only=True)

0 commit comments

Comments
 (0)