Skip to content
Open
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
18 changes: 13 additions & 5 deletions mellea/stdlib/frameworks/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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")
75 changes: 75 additions & 0 deletions test/stdlib/test_react_direct_answer.py
Original file line number Diff line number Diff line change
@@ -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
Loading