Skip to content

Commit ee38ed0

Browse files
committed
Handle function calls without text (#3)
1 parent d8c60c1 commit ee38ed0

File tree

3 files changed

+100
-107
lines changed

3 files changed

+100
-107
lines changed

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 65 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -143,66 +143,18 @@ def is_agent_node(
143143
return isinstance(node, AgentNode)
144144

145145

146-
def _is_retry_attempt(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]) -> bool:
147-
# Check if we've already attempted a thinking-only retry to prevent infinite loops
148-
recent_messages = (
149-
ctx.state.message_history[-3:] if len(ctx.state.message_history) >= 3 else ctx.state.message_history
150-
)
151-
for msg in recent_messages:
152-
if isinstance(msg, _messages.ModelRequest):
153-
for part in msg.parts:
154-
if (
155-
isinstance(part, _messages.UserPromptPart)
156-
and isinstance(part.content, str)
157-
and part.content.startswith('[THINKING_RETRY]')
158-
):
159-
return True
160-
return False
161-
162-
163146
async def _create_thinking_retry(
164147
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
165148
) -> ModelRequestNode[DepsT, NodeRunEndT]:
166149
# Create retry prompt
167-
retry_prompt = (
168-
'Based on your thinking above, you MUST now provide '
169-
'a specific answer or use the available tools to complete the task. '
170-
'Do not respond with only thinking content. Provide actionable output.'
171-
)
172-
173-
# Create the retry request using UserPromptPart for API compatibility
174-
# We'll use a special content marker to detect this is a thinking retry
175-
retry_part = _messages.UserPromptPart(f'[THINKING_RETRY] {retry_prompt}')
150+
retry_prompt = 'Responses without text or tool calls are not permitted.'
151+
retry_part = _messages.RetryPromptPart(retry_prompt)
176152
retry_request = _messages.ModelRequest(parts=[retry_part])
177153

178154
# Create new ModelRequestNode for retry (it will add the request to message history)
179155
return ModelRequestNode[DepsT, NodeRunEndT](request=retry_request)
180156

181157

182-
async def _process_response_parts(
183-
parts: list[_messages.ModelResponsePart], texts: list[str], tool_calls: list[_messages.ToolCallPart]
184-
) -> AsyncIterator[_messages.HandleResponseEvent]:
185-
for part in parts:
186-
if isinstance(part, _messages.TextPart):
187-
# ignore empty content for text parts, see #437
188-
if part.content:
189-
texts.append(part.content)
190-
elif isinstance(part, _messages.ToolCallPart):
191-
tool_calls.append(part)
192-
elif isinstance(part, _messages.BuiltinToolCallPart):
193-
yield _messages.BuiltinToolCallEvent(part)
194-
elif isinstance(part, _messages.BuiltinToolReturnPart):
195-
yield _messages.BuiltinToolResultEvent(part)
196-
elif isinstance(part, _messages.ThinkingPart):
197-
# We don't need to do anything with thinking parts in this tool-calling node.
198-
# We need to handle text parts in case there are no tool calls and/or the desired output comes
199-
# from the text, but thinking parts should not directly influence the execution of tools or
200-
# determination of the next node of graph execution here.
201-
pass
202-
else:
203-
assert_never(part)
204-
205-
206158
@dataclasses.dataclass
207159
class UserPromptNode(AgentNode[DepsT, NodeRunEndT]):
208160
"""The node that handles the user prompt and instructions."""
@@ -488,67 +440,77 @@ async def stream(
488440
async for _event in stream:
489441
pass
490442

491-
async def _run_stream(
443+
async def _run_stream( # noqa: C901
492444
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
493445
) -> AsyncIterator[_messages.HandleResponseEvent]:
494446
if self._events_iterator is None:
495447
# Ensure that the stream is only run once
496-
self._events_iterator = self._create_stream_iterator(ctx)
497448

498-
async for event in self._events_iterator:
499-
yield event
500-
501-
async def _create_stream_iterator(
502-
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
503-
) -> AsyncIterator[_messages.HandleResponseEvent]:
504-
texts: list[str] = []
505-
tool_calls: list[_messages.ToolCallPart] = []
449+
async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa: C901
450+
texts: list[str] = []
451+
tool_calls: list[_messages.ToolCallPart] = []
452+
453+
for part in self.model_response.parts:
454+
if isinstance(part, _messages.TextPart):
455+
# ignore empty content for text parts, see #437
456+
if part.content:
457+
texts.append(part.content)
458+
elif isinstance(part, _messages.ToolCallPart):
459+
tool_calls.append(part)
460+
elif isinstance(part, _messages.BuiltinToolCallPart):
461+
yield _messages.BuiltinToolCallEvent(part)
462+
elif isinstance(part, _messages.BuiltinToolReturnPart):
463+
yield _messages.BuiltinToolResultEvent(part)
464+
elif isinstance(part, _messages.ThinkingPart):
465+
# We don't need to do anything with thinking parts in this tool-calling node.
466+
# We need to handle text parts in case there are no tool calls and/or the desired output comes
467+
# from the text, but thinking parts should not directly influence the execution of tools or
468+
# determination of the next node of graph execution here.
469+
pass
470+
else:
471+
assert_never(part)
472+
473+
# At the moment, we prioritize at least executing tool calls if they are present.
474+
# In the future, we'd consider making this configurable at the agent or run level.
475+
# This accounts for cases like anthropic returns that might contain a text response
476+
# and a tool call response, where the text response just indicates the tool call will happen.
477+
if tool_calls:
478+
async for event in self._handle_tool_calls(ctx, tool_calls):
479+
yield event
480+
elif texts:
481+
# No events are emitted during the handling of text responses, so we don't need to yield anything
482+
self._next_node = await self._handle_text_response(ctx, texts)
483+
else:
484+
# we've got an empty response
485+
486+
thinking_parts = [p for p in self.model_response.parts if isinstance(p, _messages.ThinkingPart)]
487+
488+
if thinking_parts:
489+
# handle thinking-only responses (responses that contain only ThinkingPart instances)
490+
# this can happen with models that support thinking mode when they don't provide
491+
# actionable output alongside their thinking content.
492+
self._next_node = await _create_thinking_retry(ctx)
493+
else:
494+
# handle empty response with no thinking
495+
# this sometimes happens with anthropic (and perhaps other models)
496+
# when the model has already returned text along side tool calls
497+
# in this scenario, if text responses are allowed, we return text from the most recent model
498+
# response, if any
499+
if isinstance(ctx.deps.output_schema, _output.TextOutputSchema):
500+
for message in reversed(ctx.state.message_history):
501+
if isinstance(message, _messages.ModelResponse):
502+
last_texts = [p.content for p in message.parts if isinstance(p, _messages.TextPart)]
503+
if last_texts:
504+
self._next_node = await self._handle_text_response(ctx, last_texts)
505+
return
506+
507+
raise exceptions.UnexpectedModelBehavior('Received empty model response')
508+
509+
self._events_iterator = _run_stream()
506510

507-
# Process all parts in the model response
508-
async for event in _process_response_parts(self.model_response.parts, texts, tool_calls):
511+
async for event in self._events_iterator:
509512
yield event
510513

511-
# Handle the response based on what we found
512-
if tool_calls:
513-
async for event in self._handle_tool_calls(ctx, tool_calls):
514-
yield event
515-
elif texts:
516-
# No events are emitted during the handling of text responses, so we don't need to yield anything
517-
self._next_node = await self._handle_text_response(ctx, texts)
518-
else:
519-
self._next_node = await self._handle_empty_response(ctx)
520-
521-
async def _handle_empty_response(
522-
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
523-
) -> ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]]:
524-
# Handle thinking-only responses (responses that contain only ThinkingPart instances)
525-
# This can happen with models that support thinking mode when they don't provide
526-
# actionable output alongside their thinking content.
527-
thinking_parts = [p for p in self.model_response.parts if isinstance(p, _messages.ThinkingPart)]
528-
529-
if thinking_parts and not _is_retry_attempt(ctx):
530-
return await _create_thinking_retry(ctx)
531-
532-
# Original recovery logic - this sometimes happens with anthropic (and perhaps other models)
533-
# when the model has already returned text along side tool calls
534-
# in this scenario, if text responses are allowed, we return text from the most recent model
535-
# response, if any
536-
if isinstance(ctx.deps.output_schema, _output.TextOutputSchema):
537-
if next_node := await self._try_recover_from_history(ctx):
538-
return next_node
539-
540-
raise exceptions.UnexpectedModelBehavior('Received empty model response')
541-
542-
async def _try_recover_from_history(
543-
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
544-
) -> ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]] | None:
545-
for message in reversed(ctx.state.message_history):
546-
if isinstance(message, _messages.ModelResponse):
547-
last_texts = [p.content for p in message.parts if isinstance(p, _messages.TextPart)]
548-
if last_texts:
549-
return await self._handle_text_response(ctx, last_texts)
550-
return None
551-
552514
async def _handle_tool_calls(
553515
self,
554516
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],

pydantic_ai_slim/pydantic_ai/models/google.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,7 @@ async def _map_messages(self, messages: list[ModelMessage]) -> tuple[ContentDict
457457
message_parts = [{'text': ''}]
458458
contents.append({'role': 'user', 'parts': message_parts})
459459
elif isinstance(m, ModelResponse):
460-
model_content = _content_model_response(m)
461-
# Skip model responses with empty parts (e.g., thinking-only responses)
462-
if model_content.get('parts'):
463-
contents.append(model_content)
460+
contents.append(_content_model_response(m))
464461
else:
465462
assert_never(m)
466463
if instructions := self._get_instructions(messages):

tests/test_agent.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4087,3 +4087,37 @@ def bar() -> str:
40874087
assert run.result.output == snapshot(Foo(a=0, b='a'))
40884088
assert test_model.last_model_request_parameters is not None
40894089
assert [t.name for t in test_model.last_model_request_parameters.function_tools] == snapshot(['bar'])
4090+
4091+
4092+
async def test_thinking_only_response_retry():
4093+
"""Test that thinking-only responses trigger a retry mechanism."""
4094+
from pydantic_ai.messages import ThinkingPart
4095+
from pydantic_ai.models.function import FunctionModel
4096+
4097+
call_count = 0
4098+
4099+
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
4100+
nonlocal call_count
4101+
call_count += 1
4102+
4103+
if call_count == 1:
4104+
# First call: return thinking-only response
4105+
return ModelResponse(
4106+
parts=[ThinkingPart(content='Let me think about this...')],
4107+
model_name='thinking-test-model',
4108+
)
4109+
else:
4110+
# Second call: return proper response
4111+
return ModelResponse(
4112+
parts=[TextPart(content='Final answer')],
4113+
model_name='thinking-test-model',
4114+
)
4115+
4116+
model = FunctionModel(model_function)
4117+
agent = Agent(model, system_prompt='You are a helpful assistant.')
4118+
4119+
result = await agent.run('Hello')
4120+
4121+
# Should have made exactly 2 calls and produced the expected result
4122+
assert call_count == 2
4123+
assert result.output == 'Final answer'

0 commit comments

Comments
 (0)