Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/askui/speaker/agent_speaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ def handle_step(
logger.exception("Agent stopped with error")
return SpeakerResult(status="failed", messages_to_add=[response])

# Check for tool_use stop_reason with no actual tool calls
if response.stop_reason == "tool_use" and not self._has_tool_calls(response):
logger.warning(
"Model returned stop_reason 'tool_use' but content "
"contains no tool_use blocks"
)
messages_to_add: list[MessageParam] = []
# Ensure the response has valid content for conversation history
# (empty content would violate API alternating-role requirements)
if isinstance(response.content, list) and not response.content:
response = MessageParam(
role="assistant",
content="I need to use a tool for this task.",
stop_reason=response.stop_reason,
usage=response.usage,
)
messages_to_add.append(response)
feedback_msg = (
"No tool name was provided in your response. "
"Please provide a valid tool call."
)
messages_to_add.append(MessageParam(role="user", content=feedback_msg))
return SpeakerResult(
status="continue",
messages_to_add=messages_to_add,
usage=response.usage,
)

# Check for switch_speaker tool call
switch_info = self._extract_switch_speaker(response)
if switch_info:
Expand Down
Empty file added tests/unit/speaker/__init__.py
Empty file.
123 changes: 123 additions & 0 deletions tests/unit/speaker/test_agent_speaker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Unit tests for AgentSpeaker."""

from unittest.mock import MagicMock

from askui.models.shared.agent_message_param import (
MessageParam,
TextBlockParam,
UsageParam,
)
from askui.speaker.agent_speaker import AgentSpeaker


def _make_conversation(response: MessageParam) -> MagicMock:
"""Create a mock Conversation that returns the given response."""
conversation = MagicMock()
conversation.get_messages.return_value = [
MessageParam(role="user", content="do something")
]
truncation_strategy = MagicMock()
truncation_strategy.truncated_messages = [
MessageParam(role="user", content="do something")
]
conversation.get_truncation_strategy.return_value = truncation_strategy
conversation.vlm_provider.create_message.return_value = response
conversation.tools = []
conversation.settings.messages.max_tokens = 4096
conversation.settings.messages.system = None
conversation.settings.messages.thinking = None
conversation.settings.messages.tool_choice = None
conversation.settings.messages.temperature = None
conversation.settings.messages.provider_options = None
return conversation


class TestEmptyToolUseContent:
"""Tests for handling stop_reason='tool_use' with no tool_use blocks."""

def test_empty_content_returns_continue(self) -> None:
"""When content is an empty list, status should be 'continue'."""
response = MessageParam(
role="assistant",
content=[],
stop_reason="tool_use",
usage=UsageParam(input_tokens=10, output_tokens=5),
)
conversation = _make_conversation(response)
speaker = AgentSpeaker()

result = speaker.handle_step(conversation, cache_manager=None)

assert result.status == "continue"

def test_empty_content_adds_assistant_and_user_messages(self) -> None:
"""Messages should contain a synthetic assistant msg and a user feedback msg."""
response = MessageParam(
role="assistant",
content=[],
stop_reason="tool_use",
usage=UsageParam(input_tokens=10, output_tokens=5),
)
conversation = _make_conversation(response)
speaker = AgentSpeaker()

result = speaker.handle_step(conversation, cache_manager=None)

assert len(result.messages_to_add) == 2
assert result.messages_to_add[0].role == "assistant"
assert (
result.messages_to_add[0].content == "I need to use a tool for this task."
)
assert result.messages_to_add[1].role == "user"
assert "No tool name was provided" in str(result.messages_to_add[1].content)

def test_text_only_content_returns_continue(self) -> None:
"""When content has text blocks but no tool_use blocks, status is 'continue'."""
response = MessageParam(
role="assistant",
content=[TextBlockParam(text="Let me think about this...")],
stop_reason="tool_use",
usage=UsageParam(input_tokens=10, output_tokens=5),
)
conversation = _make_conversation(response)
speaker = AgentSpeaker()

result = speaker.handle_step(conversation, cache_manager=None)

assert result.status == "continue"

def test_text_only_content_preserves_original_response(self) -> None:
"""When content has text (non-empty), the original response is preserved."""
response = MessageParam(
role="assistant",
content=[TextBlockParam(text="Let me think about this...")],
stop_reason="tool_use",
usage=UsageParam(input_tokens=10, output_tokens=5),
)
conversation = _make_conversation(response)
speaker = AgentSpeaker()

result = speaker.handle_step(conversation, cache_manager=None)

assert len(result.messages_to_add) == 2
# Original response is kept as-is (not replaced)
assert result.messages_to_add[0].content == [
TextBlockParam(text="Let me think about this...")
]
assert result.messages_to_add[1].role == "user"

def test_usage_is_propagated(self) -> None:
"""Usage from the response should be on the SpeakerResult."""
usage = UsageParam(input_tokens=100, output_tokens=50)
response = MessageParam(
role="assistant",
content=[],
stop_reason="tool_use",
usage=usage,
)
conversation = _make_conversation(response)
speaker = AgentSpeaker()

result = speaker.handle_step(conversation, cache_manager=None)

assert result.usage == usage
Loading