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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "spoon-ai-sdk"
version = "0.4.9"
version = "0.4.10"
authors = [
{ name="XSpoon Team", email="team@xspoon.ai" },
]
Expand Down
6 changes: 4 additions & 2 deletions spoon_ai/agents/spoon_react.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ def __init__(self, **kwargs):
"""Initialize SpoonReactAI with both ToolCallAgent and MCPClientMixin initialization"""
# Track whether the caller supplied custom prompts so _refresh_prompts()
# does not overwrite them on init or before run().
self._custom_system_prompt = "system_prompt" in kwargs and kwargs["system_prompt"] is not None
self._custom_next_step_prompt = "next_step_prompt" in kwargs and kwargs["next_step_prompt"] is not None
custom_system_prompt = "system_prompt" in kwargs and kwargs["system_prompt"] is not None
custom_next_step_prompt = "next_step_prompt" in kwargs and kwargs["next_step_prompt"] is not None

# Call parent class initializers
ToolCallAgent.__init__(self, **kwargs)
object.__setattr__(self, "_custom_system_prompt", custom_system_prompt)
object.__setattr__(self, "_custom_next_step_prompt", custom_next_step_prompt)

# Initialize MCP client mixin
MCPClientMixin.__init__(self, self.mcp_transport)
Expand Down
5 changes: 3 additions & 2 deletions spoon_ai/agents/toolcall.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,13 @@ def convert_mcp_tool(tool: MCPTool) -> dict:

if self.output_queue:
if response.content and not streamed_content:
pre_tool_content = bool(self.tool_calls)
self.output_queue.put_nowait(
build_output_queue_event(
event_type="content",
event_type="thinking" if pre_tool_content else "content",
delta=response.content,
metadata={
"phase": "progress",
"phase": "pre_tool" if pre_tool_content else "final",
"source": "toolcall_agent",
},
)
Expand Down
139 changes: 97 additions & 42 deletions tests/test_agent_llm_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async def test_toolcall_agent_with_manager(self, mock_chatbot_manager, tool_mana
agent = ToolCallAgent(
name="test_agent",
llm=mock_chatbot_manager,
available_tools=tool_manager
available_tools=tool_manager,
)

# Test agent run
Expand Down Expand Up @@ -132,6 +132,31 @@ async def test_toolcall_agent_forwards_reasoning_effort_to_llm(self, mock_chatbo
assert mock_chatbot_manager.ask_tool.await_args.kwargs["thinking"] is True
assert mock_chatbot_manager.ask_tool.await_args.kwargs["reasoning_effort"] == "high"

@pytest.mark.asyncio
async def test_toolcall_agent_treats_openai_responses_completed_as_terminal(
self,
mock_chatbot_manager,
tool_manager,
):
mock_chatbot_manager.ask_tool.return_value = LLMResponse(
content="Done.",
tool_calls=[],
finish_reason="stop",
native_finish_reason="completed",
)

agent = ToolCallAgent(
name="test_agent",
llm=mock_chatbot_manager,
available_tools=tool_manager,
max_steps=3,
)

result = await agent.run("Test request")

assert result == "Done."
assert mock_chatbot_manager.ask_tool.await_count == 1

@pytest.mark.asyncio
async def test_toolcall_agent_omits_disabled_thinking_flag_for_llm(self, mock_chatbot_manager, tool_manager):
mock_chatbot_manager.ask_tool.return_value = LLMResponse(
Expand Down Expand Up @@ -196,6 +221,7 @@ async def test_toolcall_agent_with_tools(self, mock_chatbot_manager, tool_manage
native_finish_reason="tool_calls"
)
mock_chatbot_manager.ask_tool.return_value = mock_response
mock_chatbot_manager.ask.return_value = "Tool executed successfully"

# Mock tool execution
tool_manager.execute = AsyncMock(return_value="Tool executed successfully")
Expand All @@ -205,7 +231,8 @@ async def test_toolcall_agent_with_tools(self, mock_chatbot_manager, tool_manage
agent = ToolCallAgent(
name="test_agent",
llm=mock_chatbot_manager,
available_tools=tool_manager
available_tools=tool_manager,
max_steps=1,
)

# Test agent run
Expand All @@ -218,6 +245,40 @@ async def test_toolcall_agent_with_tools(self, mock_chatbot_manager, tool_manage
)
assert "Tool executed successfully" in result

@pytest.mark.asyncio
async def test_toolcall_agent_streams_text_with_tool_calls_as_thinking(self, mock_chatbot_manager, tool_manager):
mock_tool_call = ToolCall(
id="call_123",
type="function",
function=Function(
name="test_tool",
arguments='{"param": "value"}',
),
)
mock_chatbot_manager.ask_tool.return_value = LLMResponse(
content="Need to inspect the file first.",
tool_calls=[mock_tool_call],
finish_reason="tool_calls",
native_finish_reason="tool_calls",
)

agent = ToolCallAgent(
name="test_agent",
llm=mock_chatbot_manager,
available_tools=tool_manager,
)
await agent.add_message("user", "Use a tool")

should_act = await agent.think()

assert should_act is True
pre_tool_event = agent.output_queue.get_nowait()
assert pre_tool_event["type"] == "thinking"
assert pre_tool_event["delta"] == "Need to inspect the file first."
assert pre_tool_event["metadata"]["phase"] == "pre_tool"
tool_event = agent.output_queue.get_nowait()
assert tool_event["tool_calls"] == [mock_tool_call]

@pytest.mark.asyncio
async def test_toolcall_agent_preserves_tool_call_metadata_in_memory(self, mock_chatbot_manager, tool_manager):
agent = ToolCallAgent(
Expand Down Expand Up @@ -266,20 +327,14 @@ def test_spoon_react_run_signatures_accept_reasoning_kwargs(self):
assert "reasoning_effort" in inspect.signature(SpoonReactSkill.run).parameters

@pytest.mark.asyncio
async def test_spoon_react_ai_fallback_to_legacy(self):
"""Test SpoonReactAI fallback to legacy mode on initialization failure."""
with patch('spoon_ai.agents.spoon_react.create_configured_chatbot') as mock_create:
# First call fails (new architecture), second succeeds (legacy)
mock_create.side_effect = [
Exception("LLM manager initialization failed"),
Mock(spec=ChatBot, use_llm_manager=False)
]

# This should not raise an exception due to fallback
agent = SpoonReactAI(name="spoon_agent")

# Verify it fell back to legacy mode
assert agent.llm.use_llm_manager is False
async def test_spoon_react_ai_accepts_legacy_llm_instance(self):
"""Test SpoonReactAI can still be constructed with a legacy ChatBot."""
mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = False

agent = SpoonReactAI(name="spoon_agent", llm=mock_chatbot)

assert agent.llm.use_llm_manager is False

@pytest.mark.asyncio
async def test_agent_backward_compatibility(self, mock_chatbot_legacy, tool_manager):
Expand Down Expand Up @@ -383,7 +438,7 @@ async def test_streamed_tool_response_does_not_enqueue_full_content_twice(self,
assert all(call.args != ({"content": "already streamed full text"},) for call in put_calls)

@pytest.mark.asyncio
async def test_toolcall_agent_emits_progress_content_for_non_streamed_pre_tool_content(self, mock_chatbot_manager, tool_manager):
async def test_toolcall_agent_emits_thinking_for_non_streamed_pre_tool_content(self, mock_chatbot_manager, tool_manager):
mock_tool_call = ToolCall(
id="call_123",
type="function",
Expand Down Expand Up @@ -416,11 +471,11 @@ async def test_toolcall_agent_emits_progress_content_for_non_streamed_pre_tool_c
assert should_continue is True
put_calls = mock_queue.put_nowait.call_args_list
assert put_calls[0].args[0] == {
"type": "content",
"type": "thinking",
"delta": "First I will inspect the workspace.",
"content": "First I will inspect the workspace.",
"metadata": {
"phase": "progress",
"phase": "pre_tool",
"source": "toolcall_agent",
},
}
Expand Down Expand Up @@ -576,31 +631,31 @@ async def test_agent_performance_with_manager(self, mock_chatbot_manager, tool_m
def test_agent_configuration_compatibility(self):
"""Test that agent configuration works with both architectures."""
# Test with manager architecture
with patch('spoon_ai.agents.spoon_react.create_configured_chatbot') as mock_create:
mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = True
mock_create.return_value = mock_chatbot

mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = True

with patch('spoon_ai.agents.spoon_react.create_configured_chatbot'):
agent_manager = SpoonReactAI(
name="manager_agent",
max_steps=5,
system_prompt="Custom system prompt"
system_prompt="Custom system prompt",
llm=mock_chatbot,
)

assert agent_manager.max_steps == 5
assert agent_manager.system_prompt == "Custom system prompt"
assert agent_manager.llm.use_llm_manager is True

# Test with legacy architecture
with patch('spoon_ai.agents.spoon_react.create_configured_chatbot') as mock_create:
mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = False
mock_create.return_value = mock_chatbot

mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = False

with patch('spoon_ai.agents.spoon_react.create_configured_chatbot'):
agent_legacy = SpoonReactAI(
name="legacy_agent",
max_steps=3,
system_prompt="Legacy system prompt"
system_prompt="Legacy system prompt",
llm=mock_chatbot,
)

assert agent_legacy.max_steps == 3
Expand All @@ -617,19 +672,19 @@ async def test_existing_agent_code_compatibility(self):
# This test ensures that existing agent implementations
# continue to work without any code changes

with patch('spoon_ai.agents.spoon_react.create_configured_chatbot') as mock_create:
mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = True
mock_chatbot.ask_tool = AsyncMock(return_value=LLMResponse(
content="Compatibility test",
tool_calls=[],
finish_reason="stop",
native_finish_reason="stop"
))
mock_create.return_value = mock_chatbot
mock_chatbot = Mock(spec=ChatBot)
mock_chatbot.use_llm_manager = True
mock_chatbot.ask_tool = AsyncMock(return_value=LLMResponse(
content="Compatibility test",
tool_calls=[],
finish_reason="stop",
native_finish_reason="stop"
))

with patch('spoon_ai.agents.spoon_react.create_configured_chatbot'):

# Create agent using existing pattern
agent = SpoonReactAI(name="compat_agent")
agent = SpoonReactAI(name="compat_agent", llm=mock_chatbot)

# Run using existing pattern
result = await agent.run("Test compatibility")
Expand Down
Loading