Skip to content

Commit f4f79c0

Browse files
Merge pull request #278 from askui/fix/handle_emtpy_tool_use_content
fix: handle empty tool_use content in AgentSpeaker gracefully
2 parents 991ac09 + fdf8cff commit f4f79c0

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

src/askui/speaker/agent_speaker.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,34 @@ def handle_step(
104104
logger.exception("Agent stopped with error")
105105
return SpeakerResult(status="failed", messages_to_add=[response])
106106

107+
# Check for tool_use stop_reason with no actual tool calls
108+
if response.stop_reason == "tool_use" and not self._has_tool_calls(response):
109+
logger.warning(
110+
"Model returned stop_reason 'tool_use' but content "
111+
"contains no tool_use blocks"
112+
)
113+
messages_to_add: list[MessageParam] = []
114+
# Ensure the response has valid content for conversation history
115+
# (empty content would violate API alternating-role requirements)
116+
if isinstance(response.content, list) and not response.content:
117+
response = MessageParam(
118+
role="assistant",
119+
content="I need to use a tool for this task.",
120+
stop_reason=response.stop_reason,
121+
usage=response.usage,
122+
)
123+
messages_to_add.append(response)
124+
feedback_msg = (
125+
"No tool name was provided in your response. "
126+
"Please provide a valid tool call."
127+
)
128+
messages_to_add.append(MessageParam(role="user", content=feedback_msg))
129+
return SpeakerResult(
130+
status="continue",
131+
messages_to_add=messages_to_add,
132+
usage=response.usage,
133+
)
134+
107135
# Check for switch_speaker tool call
108136
switch_info = self._extract_switch_speaker(response)
109137
if switch_info:

tests/unit/speaker/__init__.py

Whitespace-only changes.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Unit tests for AgentSpeaker."""
2+
3+
from unittest.mock import MagicMock
4+
5+
from askui.models.shared.agent_message_param import (
6+
MessageParam,
7+
TextBlockParam,
8+
UsageParam,
9+
)
10+
from askui.speaker.agent_speaker import AgentSpeaker
11+
12+
13+
def _make_conversation(response: MessageParam) -> MagicMock:
14+
"""Create a mock Conversation that returns the given response."""
15+
conversation = MagicMock()
16+
conversation.get_messages.return_value = [
17+
MessageParam(role="user", content="do something")
18+
]
19+
truncation_strategy = MagicMock()
20+
truncation_strategy.truncated_messages = [
21+
MessageParam(role="user", content="do something")
22+
]
23+
conversation.get_truncation_strategy.return_value = truncation_strategy
24+
conversation.vlm_provider.create_message.return_value = response
25+
conversation.tools = []
26+
conversation.settings.messages.max_tokens = 4096
27+
conversation.settings.messages.system = None
28+
conversation.settings.messages.thinking = None
29+
conversation.settings.messages.tool_choice = None
30+
conversation.settings.messages.temperature = None
31+
conversation.settings.messages.provider_options = None
32+
return conversation
33+
34+
35+
class TestEmptyToolUseContent:
36+
"""Tests for handling stop_reason='tool_use' with no tool_use blocks."""
37+
38+
def test_empty_content_returns_continue(self) -> None:
39+
"""When content is an empty list, status should be 'continue'."""
40+
response = MessageParam(
41+
role="assistant",
42+
content=[],
43+
stop_reason="tool_use",
44+
usage=UsageParam(input_tokens=10, output_tokens=5),
45+
)
46+
conversation = _make_conversation(response)
47+
speaker = AgentSpeaker()
48+
49+
result = speaker.handle_step(conversation, cache_manager=None)
50+
51+
assert result.status == "continue"
52+
53+
def test_empty_content_adds_assistant_and_user_messages(self) -> None:
54+
"""Messages should contain a synthetic assistant msg and a user feedback msg."""
55+
response = MessageParam(
56+
role="assistant",
57+
content=[],
58+
stop_reason="tool_use",
59+
usage=UsageParam(input_tokens=10, output_tokens=5),
60+
)
61+
conversation = _make_conversation(response)
62+
speaker = AgentSpeaker()
63+
64+
result = speaker.handle_step(conversation, cache_manager=None)
65+
66+
assert len(result.messages_to_add) == 2
67+
assert result.messages_to_add[0].role == "assistant"
68+
assert (
69+
result.messages_to_add[0].content == "I need to use a tool for this task."
70+
)
71+
assert result.messages_to_add[1].role == "user"
72+
assert "No tool name was provided" in str(result.messages_to_add[1].content)
73+
74+
def test_text_only_content_returns_continue(self) -> None:
75+
"""When content has text blocks but no tool_use blocks, status is 'continue'."""
76+
response = MessageParam(
77+
role="assistant",
78+
content=[TextBlockParam(text="Let me think about this...")],
79+
stop_reason="tool_use",
80+
usage=UsageParam(input_tokens=10, output_tokens=5),
81+
)
82+
conversation = _make_conversation(response)
83+
speaker = AgentSpeaker()
84+
85+
result = speaker.handle_step(conversation, cache_manager=None)
86+
87+
assert result.status == "continue"
88+
89+
def test_text_only_content_preserves_original_response(self) -> None:
90+
"""When content has text (non-empty), the original response is preserved."""
91+
response = MessageParam(
92+
role="assistant",
93+
content=[TextBlockParam(text="Let me think about this...")],
94+
stop_reason="tool_use",
95+
usage=UsageParam(input_tokens=10, output_tokens=5),
96+
)
97+
conversation = _make_conversation(response)
98+
speaker = AgentSpeaker()
99+
100+
result = speaker.handle_step(conversation, cache_manager=None)
101+
102+
assert len(result.messages_to_add) == 2
103+
# Original response is kept as-is (not replaced)
104+
assert result.messages_to_add[0].content == [
105+
TextBlockParam(text="Let me think about this...")
106+
]
107+
assert result.messages_to_add[1].role == "user"
108+
109+
def test_usage_is_propagated(self) -> None:
110+
"""Usage from the response should be on the SpeakerResult."""
111+
usage = UsageParam(input_tokens=100, output_tokens=50)
112+
response = MessageParam(
113+
role="assistant",
114+
content=[],
115+
stop_reason="tool_use",
116+
usage=usage,
117+
)
118+
conversation = _make_conversation(response)
119+
speaker = AgentSpeaker()
120+
121+
result = speaker.handle_step(conversation, cache_manager=None)
122+
123+
assert result.usage == usage

0 commit comments

Comments
 (0)