diff --git a/mellea/stdlib/frameworks/react.py b/mellea/stdlib/frameworks/react.py index 810542295..b6a4bd8b6 100644 --- a/mellea/stdlib/frameworks/react.py +++ b/mellea/stdlib/frameworks/react.py @@ -105,9 +105,19 @@ async def react( if tool_res.name == MELLEA_FINALIZER_TOOL: is_final = True - if is_final: - assert len(tool_responses) == 1, "multiple tools were called with 'final'" + # Check if we should return: either finalizer was called or model gave direct answer + should_return = is_final or (step.tool_calls is None and step.value is not None) + if should_return: + if is_final: + assert len(tool_responses) == 1, ( + "multiple tools were called with 'final'" + ) + if format is None: + # The tool has already been called above. + step._underlying_value = str(tool_responses[0].content) + + # Apply format if requested (works for both finalizer and direct answer cases) if format is not None: step, next_context = await mfuncs.aact( action=ReactThought(), @@ -120,9 +130,7 @@ async def react( ) assert isinstance(next_context, ChatContext) context = next_context - else: - # The tool has already been called above. - step._underlying_value = str(tool_responses[0].content) + return step, context raise RuntimeError(f"could not complete react loop in {loop_budget} iterations") diff --git a/test/stdlib/test_react_direct_answer.py b/test/stdlib/test_react_direct_answer.py new file mode 100644 index 000000000..31f458896 --- /dev/null +++ b/test/stdlib/test_react_direct_answer.py @@ -0,0 +1,75 @@ +"""Test ReACT framework handling of direct answers without tool calls.""" + +import pytest + +from mellea.backends.tools import tool +from mellea.stdlib.context import ChatContext +from mellea.stdlib.frameworks.react import react +from mellea.stdlib.session import start_session + + +@pytest.mark.ollama +@pytest.mark.llm +async def test_react_direct_answer_without_tools(): + """Test that ReACT handles direct answers when model doesn't call tools. + + This tests the case where the model provides a direct answer in step.value + without making any tool calls. The fix ensures the loop terminates properly + instead of continuing until loop_budget is exhausted. + """ + m = start_session() + + # Ask a simple question that doesn't require tools + # The model should provide a direct answer without calling any tools + out, _ = await react( + goal="What is 2 + 2?", + context=ChatContext(), + backend=m.backend, + tools=[], # No tools provided + loop_budget=3, # Should complete in 1 iteration, not exhaust budget + ) + + # Verify we got an answer + assert out.value is not None + assert len(out.value) > 0 + + # The answer should contain "4" or "four" + answer_lower = out.value.lower() + assert "4" in answer_lower or "four" in answer_lower + + +@pytest.mark.ollama +@pytest.mark.llm +async def test_react_direct_answer_with_unused_tools(): + """Test that ReACT handles direct answers even when tools are available. + + This tests the case where tools are provided but the model chooses to + answer directly without using them. + """ + m = start_session() + + # Create a dummy tool that won't be needed + @tool + def search_web(query: str) -> str: + """Search the web for information.""" + return "Search results" + + # Ask a question that doesn't need the tool + out, _ = await react( + goal="What is the capital of France?", + context=ChatContext(), + backend=m.backend, + tools=[search_web], + loop_budget=3, + ) + + # Verify we got an answer + assert out.value is not None + assert len(out.value) > 0 + + # The answer should mention Paris + answer_lower = out.value.lower() + assert "paris" in answer_lower + + +# Made with Bob