Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Support functions as output_type, as well as lists of functions and other types#1785

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
DouweM merged 25 commits intomainfromoutput-type-callable
May 27, 2025

Conversation

DouweM
Copy link
Contributor

@DouweMDouweM commentedMay 20, 2025
edited
Loading

Consider reviewing the first commit by itself and then the rest as one diff!
Commit 1 brings in some output handling refactoring borrowed from#1628, to make sure we don't hard-code this against the tool-call output mode (as the original PR did). Also makes it less of a rebase hell for me :) This commit does not change any behavior.

Example:

deffoobar_ctx(ctx:RunContext[int],x:str,y:int)->str:returnf'{x}{y}'asyncdeffoobar_plain(x:int,y:int)->int:returnx*ymarker:ToolOutput[bool|tuple[str,int]]=ToolOutput(bool|tuple[str,int])# type: ignoreagent=Agent(output_type=[Foo,Bar,foobar_ctx,ToolOutput(foobar_plain),marker])

To do:

  • Handle the case where the function has an argument of typeRunContext; this value should be injected, not obtained from the model.
  • Support bound instance methods
  • Docs
    • Output
    • Tools (as this effectively lets you force a tool call)

phiweger reacted with eyes emoji
@github-actionsGitHub Actions
Copy link

github-actionsbot commentedMay 20, 2025
edited
Loading

Docs Preview

commit:6111ad1
Preview URL:https://157f12b8-pydantic-ai-previews.pydantic.workers.dev

@DouweMDouweM marked this pull request as ready for reviewMay 21, 2025 22:50
Copy link
Contributor

@dmontagudmontagu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Generally looks good, I think we should just confirm the changes to FunctionSchema are okay with Samuel (he'll probably just rubber stamp but still), and I'd like to get@Kludex's take on the crazy OutputType types but I'm okay with it if he is.

@dmontagu
Copy link
Contributor

Oh we also need to add some typing-related tests

@DouweMDouweMforce-pushed theoutput-type-callable branch froma1c793e to56e196dCompareMay 23, 2025 15:46
@DouweMDouweMforce-pushed theoutput-type-callable branch from56e196d to3ff6e74CompareMay 23, 2025 17:14
@DouweMDouweM requested a review fromdmontaguMay 23, 2025 18:05
@burningion
Copy link

burningion commentedMay 23, 2025
edited
Loading

One thing I'd also like to be able to do here is do asingle LLM call with my Agent.

Ideally it'd do the same behavior as direct model call:

https://ai.pydantic.dev/direct/

Except expose the MCP server, and (optionally) force a Pydantic model response in a single call.

Am I missing something or does that interface not exist in the Agent yet?

@DouweM
Copy link
ContributorAuthor

DouweM commentedMay 23, 2025
edited
Loading

What I've done here to makeoutput_type take types, functions, async functions,ToolOutput markers, and sequences of any of those, works perfectly with pyright's type inference, but unfortunately not with mypy, and not (yet) with pyrefly and ty.

Specifically, none of the three supportSequence[type[T]]: (issues formypy,pyrefly,ty)

from __future__importannotationsfromdataclassesimportdataclassfromtyping_extensionsimport (Generic,Sequence,TypeVar,assert_type,)T=TypeVar("T")@dataclassclassAgent(Generic[T]):output_type:Sequence[type[T]]classFoo:passclassBar:pass# pyright - works# mypy - error: Expression is of type "Agent[object]", not "Agent[Foo | Bar]"  [assert-type]# pyrefly - assert_type(Agent[Foo], Agent[Bar | Foo]) failed + Argument `list[type[Bar] | type[Foo]]` is not assignable to parameter `output_type` with type `Sequence[type[Foo]]` in function `Agent.__init__`# ty - `Agent[Foo | Bar]` and `Agent[Unknown]` are not equivalent typesassert_type(Agent([Foo,Bar]),Agent[Foo|Bar])# pyright - works# mypy - error: Expression is of type "Agent[Never]", not "Agent[int | str]"  [assert-type]# pyrefly - assert_type(Agent[int], Agent[str | int]) failed + Argument `list[type[str] | type[int]]` is not assignable to parameter `output_type` with type `Sequence[type[int]]` in function `Agent.__init__`# ty - `Agent[int | str]` and `Agent[Unknown]` are not equivalent typesassert_type(Agent([int,str]),Agent[int|str])# worksassert_type(Agent[Foo|Bar]([Foo,Bar]),Agent[Foo|Bar])# worksassert_type(Agent[int|str]([int,str]),Agent[int|str])

Ty doesn't supportCallable[..., T]: (issue)

fromdataclassesimportdataclassfromtyping_extensionsimport (Callable,Generic,TypeVar,assert_type,)T=TypeVar("T")@dataclassclassAgent(Generic[T]):output_type:Callable[...,T]deffunc()->int:return1# pyright, mypy, pyrefly - works# ty - `Agent[int]` and `Agent[Unknown]` are not equivalent types + Expected `((...) -> T) | ((...) -> Awaitable[T])`, found `def func() -> int`assert_type(Agent(func),Agent[int])# worksassert_type(Agent[int](func),Agent[int])

And mypy (and ty, because of the above issue) don't supportCallable[..., T] | Callable[..., Awaitable[T]]: (issue for mypy)

fromdataclassesimportdataclassfromtyping_extensionsimport (Awaitable,Callable,Generic,TypeVar,assert_type,)T=TypeVar("T")@dataclassclassAgent(Generic[T]):output_type:Callable[...,T]|Callable[...,Awaitable[T]]asyncdefcoro()->bool:returnTruedeffunc()->int:return1# pyright, mypy, pyrefly - works# ty - `Agent[int]` and `Agent[Unknown]` are not equivalent types + Expected `((...) -> T) | ((...) -> Awaitable[T])`, found `def func() -> int`assert_type(Agent(func),Agent[int])# mypy - error: Argument 1 to "Agent" has incompatible type "Callable[[], Coroutine[Any, Any, bool]]"; expected "Callable[..., Never] | Callable[..., Awaitable[Never]]"  [arg-type]coro_agent=Agent(coro)# pyright, pyrefly - works# mypy - error: Expression is of type "Agent[Any]", not "Agent[bool]"# ty - `Agent[bool]` and `Agent[Unknown]` are not equivalent typesassert_type(coro_agent,Agent[bool])# worksassert_type(Agent[bool](coro),Agent[bool])

The issue withSequence[type[T]] can possibly be worked around with one of these tricks, which will require users to use a helper function or builder instead of a simple list:

@dataclassclassOutput(Generic[T]):output_type:type[T]defor_[S](self,output_type:type[S])->Output[T|S]:returnOutput(self.output_type|output_type)# type: ignore# ordefoutput_type[T](*args:type[T])->Output[T]:raiseNotImplementedError

The issue withCallable[..., T] | Callable[..., Awaitable[T]] is less severe because pyright and pyrefly handle it correctly, and ty doesn't handleCallable[..., T] right at all, so it's possible they simply haven't implemented this yet. But it is tricky because if the return type of a Callable is an awaitable (meaning it's an async function), both sides of the union are a valid match, so it's not technically a bug in the typechecker to matchT to the coroutine itself instead of the coroutine's return type. A workaround here could look like a helper function or builder that has overloads that prefer theAwaitable[T] match over the simpleT match through the order in which they're defined. (I verified that swapping the union members to beCallable[..., Awaitable[T]] | Callable[..., T] doesn't change anything, so the order is not a factor)

We could also decide we're fine with all of this because it works correctly on all typecheckers when you explicitly specify the generic parameters withAgent[...](...). Documenting that people should do that feels similar to the workaround we already tell people to use withoutput_type=Union[...], which can't be typechecked untilhttps://peps.python.org/pep-0747/ lands. I've updated the docs to cover the new edge cases (with mypy in particular).

I've filed some issues with the type checkers to see what their teams say.

HamzaFarhan reacted with eyes emoji

@DouweM
Copy link
ContributorAuthor

In discussion with@dmontagu we decided to merge this as is, withoutput_type taking a sequence of types (e.g.[Foo, Bar], despite this not being inferred correctly by mypy, because:

  1. alternative APIs (e.g.Output(Foo).or_(Bar)) are significantly more clunky to the point of people likely preferring to just use a sequences and manually annotate the type
  2. we can always add an alternative mypy-type-safe alternative later, if the demand is there

@DouweMDouweM merged commit45d0ff2 intomainMay 27, 2025
19 checks passed
@DouweMDouweM deleted the output-type-callable branchMay 27, 2025 22:48
@HamzaFarhan
Copy link
Contributor

HamzaFarhan commentedMay 29, 2025
edited
Loading

When would this be released?

@DouweM
Copy link
ContributorAuthor

@HamzaFarhan Done!https://github.com/pydantic/pydantic-ai/releases/tag/v0.2.12

HamzaFarhan and droidnoob reacted with rocket emoji

Kludex pushed a commit that referenced this pull requestMay 30, 2025
@bootloop-noah
Copy link

bootloop-noah commentedJun 4, 2025
edited
Loading

@DouweM in the case of using output_type for agent delegation like in the router scenario (as described in the docshere andhere), we'd expect it to operate like a directed graph but with a "decision point".

The finalresult.output reflects this but callingresult.all_messages() only shows theUserPromptPart,ToolCallPart and theToolReturnPart from the router agent which just says "Final result processed.".

Is there way to add the results of the delegated agent back into the main message history so it's something more likeModelRequest (router)->ModelRequest (output agent)->ModelResponse (output agent response) and matches the underlying graph?

@HamzaFarhan
Copy link
Contributor

fromdotenvimportload_dotenvfrompydantic_aiimportAgent,RunContextload_dotenv()maths_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a maths tutor. Given a question, you will provide a step by step solution.",)asyncdefhand_off_to_maths_agent(ctx:RunContext,query:str)->str:res=awaitmaths_agent.run(query)ctx.messages+=res.new_messages()returnres.outputpoet_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a poet. Given a topic, you will provide a poem.",)asyncdefhand_off_to_poet_agent(ctx:RunContext,query:str)->str:res=awaitpoet_agent.run(query)ctx.messages+=res.new_messages()returnres.outputrouter_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a router. Given a user query, you will route it to the appropriate agent.",output_type=[hand_off_to_maths_agent,hand_off_to_poet_agent],)asyncdefmain():query="Calculate 10 + 10"result=awaitrouter_agent.run(query)formessageinresult.all_messages():print(message,"\n")print(result.output)if__name__=="__main__":importasyncioasyncio.run(main())
bootloop-noah reacted with heart emoji

@bootloop-noah
Copy link

bootloop-noah commentedJun 4, 2025
edited
Loading

@DouweM@HamzaFarhan great thank-you! After implementing this, there seems to be an error if we want to maintain state across multiple runs of the router in a case like:

...async def hand_off_to_maths_agent(ctx: RunContext, query: str) -> str:    res = await maths_agent.run(query)    ctx.messages += res.new_messages()    return res.output...async def main():    query = "Calculate 10 + 10"    result = await router_agent.run(query)        query = "Write a poem about your answer"    result = await router_agent.run(query, message_history=result.all_messages())

This raises a ModelHTTPError for both Gemini and OpenAI models (haven't tried any others). It looks like a validator maybe isn't looking ahead? The messages definitely contain a matchingToolReturnPart. Is there a way to extend the context messagesafter the agent has returned? Here's the error:

pydantic_ai.exceptions.ModelHTTPError: status_code: 400, model_name: gpt-4o, body: {'message': "An assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'. The following tool_call_ids did not have response messages: call_KNSIrALYz0K6cTvpEIVVV7lW", 'type': 'invalid_request_error', 'param': 'messages.[3].role', 'code': None}

result.all_messages() =

ModelRequest(parts=[UserPromptPart(content='Calculate 10 + 10', timestamp=datetime.datetime(2025, 6, 4, 0, 33, 24, 203261, tzinfo=datetime.timezone.utc))], instructions='You are a router. Given a user query, you will route it to the appropriate agent.') ModelResponse(parts=[ToolCallPart(tool_name='final_result_hand_off_to_maths_agent', args='{"query":"Calculate 10 + 10"}', tool_call_id='call_KNSIrALYz0K6cTvpEIVVV7lW')], usage=Usage(requests=1, request_tokens=118, response_tokens=25, total_tokens=143, details={'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0, 'cached_tokens': 0}), model_name='gpt-4o-2024-08-06', timestamp=datetime.datetime(2025, 6, 4, 0, 33, 24, tzinfo=datetime.timezone.utc), vendor_id='chatcmpl-BeWMGkev6eeWK9arEZ17o3OVyWUan') ModelRequest(parts=[UserPromptPart(content='Calculate 10 + 10', timestamp=datetime.datetime(2025, 6, 4, 0, 33, 25, 554116, tzinfo=datetime.timezone.utc))], instructions='You are a maths tutor. Given a question, you will provide a step by step solution.') ModelResponse(parts=[TextPart(content='To calculate \\(10 + 10\\), simply add the two numbers together:\n\n\\[ \n10 + 10 = 20 \n\\]\n\nSo, the answer is \\(20\\).')], usage=Usage(requests=1, request_tokens=46, response_tokens=37, total_tokens=83, details={'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0, 'cached_tokens': 0}), model_name='gpt-4o-2024-08-06', timestamp=datetime.datetime(2025, 6, 4, 0, 33, 25, tzinfo=datetime.timezone.utc), vendor_id='chatcmpl-BeWMHmqsk9Nip3TEfkAbHZevvLe4V') ModelRequest(parts=[ToolReturnPart(tool_name='final_result_hand_off_to_maths_agent', content='Final result processed.', tool_call_id='call_KNSIrALYz0K6cTvpEIVVV7lW', timestamp=datetime.datetime(2025, 6, 4, 0, 33, 26, 305492, tzinfo=datetime.timezone.utc))])
laifi reacted with thumbs up emojilaifi reacted with confused emoji

@HamzaFarhan
Copy link
Contributor

Ooh right
So it looks like this is how a "handoff" is meant to be.

@HamzaFarhan
Copy link
Contributor

For what it's worth, here's a hack:

fromdataclassesimportreplacefromdotenvimportload_dotenvfrompydantic_aiimportAgent,RunContextfrompydantic_ai.messagesimportModelMessage,ModelRequest,ToolCallPart,ToolReturnPartload_dotenv()maths_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a maths tutor. Given a question, you will provide a step by step solution.",)asyncdefhand_off_to_maths_agent(ctx:RunContext,query:str)->str:res=awaitmaths_agent.run(query)ctx.messages+=res.new_messages()returnres.outputpoet_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a poet. Given a topic, you will provide a poem.",)asyncdefhand_off_to_poet_agent(ctx:RunContext,query:str)->str:res=awaitpoet_agent.run(query)ctx.messages+=res.new_messages()returnres.outputrouter_agent=Agent(model="google-gla:gemini-2.0-flash",instructions="You are a router. Given a user query, you will route it to the appropriate agent.",output_type=[hand_off_to_maths_agent,hand_off_to_poet_agent],)deffilter_tool_parts(messages:list[ModelMessage],filter_str:str)->list[ModelMessage]:filtered_messages:list[ModelMessage]= []formessageinmessages:ifisinstance(message,ModelRequest):filtered_parts= [partforpartinmessage.partsifnot (isinstance(part,ToolReturnPart)andfilter_strinpart.tool_name)            ]iffiltered_parts:filtered_messages.append(replace(message,parts=filtered_parts))else:filtered_parts= [partforpartinmessage.partsifnot (isinstance(part,ToolCallPart)andfilter_strinpart.tool_name)            ]iffiltered_parts:filtered_messages.append(replace(message,parts=filtered_parts))returnfiltered_messagesasyncdefmain():query="Calculate 10 + 10"result=awaitrouter_agent.run(query)message_history=filter_tool_parts(result.all_messages(),"hand_off")sep="\n"+"-"*100+"\n"print(sep)formessageinmessage_history:print(message,"\n")print(f"{sep}{result.output}{sep}")query="Write a poem about your answer"result=awaitrouter_agent.run(query,message_history=message_history)print(result.output)if__name__=="__main__":importasyncioasyncio.run(main())
bootloop-noah and DouweM reacted with heart emoji

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment
Reviewers

@dmontagudmontaguAwaiting requested review from dmontagu

Assignees

@DouweMDouweM

Labels
None yet
Projects
None yet
Milestone
No milestone
Development

Successfully merging this pull request may close these issues.

Support for returning response directly from tool
5 participants
@DouweM@dmontagu@burningion@HamzaFarhan@bootloop-noah

[8]ページ先頭

©2009-2025 Movatter.jp