mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
* fix(channels): wire channel→agent handler and fix Telegram send pipeline * format code * add supported tests
548 lines
20 KiB
Python
548 lines
20 KiB
Python
"""Tests for NativeReActAgent (formerly ReActAgent)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from openjarvis.agents._stubs import AgentContext
|
|
from openjarvis.agents.native_react import NativeReActAgent
|
|
from openjarvis.core.events import EventBus, EventType
|
|
from openjarvis.core.registry import AgentRegistry
|
|
from openjarvis.core.types import Conversation, Message, Role, ToolResult
|
|
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _CalculatorStub(BaseTool):
|
|
tool_id = "calculator"
|
|
|
|
@property
|
|
def spec(self) -> ToolSpec:
|
|
return ToolSpec(
|
|
name="calculator",
|
|
description="Math calculator.",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"expression": {"type": "string"}},
|
|
"required": ["expression"],
|
|
},
|
|
)
|
|
|
|
def execute(self, **params) -> ToolResult:
|
|
expr = params.get("expression", "0")
|
|
try:
|
|
val = eval(expr) # noqa: S307
|
|
except Exception as e:
|
|
return ToolResult(tool_name="calculator", content=str(e), success=False)
|
|
return ToolResult(tool_name="calculator", content=str(val), success=True)
|
|
|
|
|
|
class _ThinkStub(BaseTool):
|
|
tool_id = "think"
|
|
|
|
@property
|
|
def spec(self) -> ToolSpec:
|
|
return ToolSpec(
|
|
name="think",
|
|
description="Thinking tool.",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"thought": {"type": "string"}},
|
|
},
|
|
)
|
|
|
|
def execute(self, **params) -> ToolResult:
|
|
return ToolResult(
|
|
tool_name="think",
|
|
content=params.get("thought", ""),
|
|
success=True,
|
|
)
|
|
|
|
|
|
def _engine_response(content, **extra):
|
|
"""Helper to build an engine response dict."""
|
|
base = {
|
|
"content": content,
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
"model": "test-model",
|
|
"finish_reason": "stop",
|
|
}
|
|
base.update(extra)
|
|
return base
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNativeReActRegistration:
|
|
def test_registration(self):
|
|
AgentRegistry.register_value("native_react", NativeReActAgent)
|
|
assert AgentRegistry.contains("native_react")
|
|
|
|
def test_agent_id(self):
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
assert agent.agent_id == "native_react"
|
|
|
|
def test_accepts_tools(self):
|
|
assert NativeReActAgent.accepts_tools is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsing tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNativeReActParsing:
|
|
def _parser(self):
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
return agent._parse_response
|
|
|
|
def test_parse_thought_action(self):
|
|
parse = self._parser()
|
|
text = (
|
|
"Thought: I need to calculate 2+2.\n"
|
|
"Action: calculator\n"
|
|
'Action Input: {"expression": "2+2"}'
|
|
)
|
|
result = parse(text)
|
|
assert result["thought"] == "I need to calculate 2+2."
|
|
assert result["action"] == "calculator"
|
|
assert "expression" in result["action_input"]
|
|
assert result["final_answer"] == ""
|
|
|
|
def test_parse_final_answer(self):
|
|
parse = self._parser()
|
|
text = "Thought: I know the answer.\nFinal Answer: 42"
|
|
result = parse(text)
|
|
assert result["thought"] == "I know the answer."
|
|
assert result["final_answer"] == "42"
|
|
assert result["action"] == ""
|
|
|
|
def test_parse_no_structure(self):
|
|
parse = self._parser()
|
|
text = "Just a plain response with no structure."
|
|
result = parse(text)
|
|
assert result["thought"] == ""
|
|
assert result["action"] == ""
|
|
assert result["final_answer"] == ""
|
|
|
|
def test_parse_multiline_thought(self):
|
|
parse = self._parser()
|
|
text = (
|
|
"Thought: First I need to think.\n"
|
|
"Then consider options.\n"
|
|
"Final Answer: done"
|
|
)
|
|
result = parse(text)
|
|
assert result["final_answer"] == "done"
|
|
|
|
def test_parse_action_without_input(self):
|
|
parse = self._parser()
|
|
text = "Thought: Let me check.\nAction: calculator"
|
|
result = parse(text)
|
|
assert result["action"] == "calculator"
|
|
assert result["action_input"] == ""
|
|
|
|
def test_parse_case_insensitive_thought_action(self):
|
|
parse = self._parser()
|
|
text = (
|
|
"thought: I need to calculate 2+2.\n"
|
|
"action: calculator\n"
|
|
'action input: {"expression": "2+2"}'
|
|
)
|
|
result = parse(text)
|
|
assert result["thought"] == "I need to calculate 2+2."
|
|
assert result["action"] == "calculator"
|
|
assert "expression" in result["action_input"]
|
|
|
|
def test_parse_case_insensitive_final_answer(self):
|
|
parse = self._parser()
|
|
text = "thought: I know the answer.\nfinal answer: 42"
|
|
result = parse(text)
|
|
assert result["final_answer"] == "42"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent execution tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNativeReActAgent:
|
|
def test_simple_no_tool_response(self):
|
|
"""Engine returns Final Answer on first call."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Simple greeting.\nFinal Answer: Hello!"
|
|
)
|
|
bus = EventBus(record_history=True)
|
|
agent = NativeReActAgent(engine, "test-model", bus=bus)
|
|
result = agent.run("Hello")
|
|
assert result.content == "Hello!"
|
|
assert result.turns == 1
|
|
assert result.tool_results == []
|
|
|
|
def test_thought_action_observation(self):
|
|
"""Turn 1: action, Turn 2: final answer."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: I need to calculate.\n"
|
|
"Action: calculator\n"
|
|
'Action Input: {"expression": "2+2"}'
|
|
),
|
|
_engine_response("Thought: The result is 4.\nFinal Answer: 4"),
|
|
]
|
|
bus = EventBus(record_history=True)
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub()],
|
|
bus=bus,
|
|
)
|
|
result = agent.run("What is 2+2?")
|
|
assert result.content == "4"
|
|
assert result.turns == 2
|
|
assert len(result.tool_results) == 1
|
|
assert result.tool_results[0].tool_name == "calculator"
|
|
assert result.tool_results[0].content == "4"
|
|
|
|
def test_calculator_tool_use(self):
|
|
"""Verify calculator tool execution produces correct result."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Calculate.\nAction: calculator\n"
|
|
'Action Input: {"expression": "3*7"}'
|
|
),
|
|
_engine_response("Thought: Done.\nFinal Answer: 21"),
|
|
]
|
|
agent = NativeReActAgent(engine, "test-model", tools=[_CalculatorStub()])
|
|
result = agent.run("3 times 7")
|
|
assert result.tool_results[0].content == "21"
|
|
assert result.tool_results[0].success is True
|
|
|
|
def test_multi_tool_turns(self):
|
|
"""Three tool calls before final answer."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Step 1.\nAction: calculator\n"
|
|
'Action Input: {"expression": "1+1"}'
|
|
),
|
|
_engine_response(
|
|
"Thought: Step 2.\nAction: calculator\n"
|
|
'Action Input: {"expression": "2+2"}'
|
|
),
|
|
_engine_response(
|
|
"Thought: Step 3.\nAction: think\n"
|
|
'Action Input: {"thought": "combining results"}'
|
|
),
|
|
_engine_response("Thought: All done.\nFinal Answer: Complete."),
|
|
]
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub(), _ThinkStub()],
|
|
)
|
|
result = agent.run("Multi step")
|
|
assert result.turns == 4
|
|
assert len(result.tool_results) == 3
|
|
assert result.content == "Complete."
|
|
|
|
def test_max_turns_exceeded(self):
|
|
"""Engine always returns actions -- hits max_turns."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Keep going.\nAction: calculator\n"
|
|
'Action Input: {"expression": "1+1"}'
|
|
)
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub()],
|
|
max_turns=3,
|
|
)
|
|
result = agent.run("Loop forever")
|
|
assert result.turns == 3
|
|
assert result.metadata.get("max_turns_exceeded") is True
|
|
assert result.content == "Maximum turns reached without a final answer."
|
|
|
|
def test_unknown_tool_error(self):
|
|
"""Action references nonexistent tool -- ToolResult with success=False."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Use a tool.\nAction: nonexistent\nAction Input: {}"
|
|
),
|
|
_engine_response(
|
|
"Thought: Error occurred.\nFinal Answer: Could not run tool."
|
|
),
|
|
]
|
|
agent = NativeReActAgent(engine, "test-model", tools=[_CalculatorStub()])
|
|
result = agent.run("Do something")
|
|
assert len(result.tool_results) == 1
|
|
assert result.tool_results[0].success is False
|
|
assert "Unknown tool" in result.tool_results[0].content
|
|
|
|
def test_event_bus_emissions(self):
|
|
"""Verify AGENT_TURN_START and AGENT_TURN_END events."""
|
|
bus = EventBus(record_history=True)
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Quick.\nFinal Answer: Done."
|
|
)
|
|
agent = NativeReActAgent(engine, "test-model", bus=bus)
|
|
agent.run("Hello")
|
|
event_types = [e.event_type for e in bus.history]
|
|
assert EventType.AGENT_TURN_START in event_types
|
|
assert EventType.AGENT_TURN_END in event_types
|
|
|
|
def test_event_bus_tool_events(self):
|
|
"""Tool call should trigger TOOL_CALL_START and TOOL_CALL_END events."""
|
|
bus = EventBus(record_history=True)
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Calc.\nAction: calculator\n"
|
|
'Action Input: {"expression": "1+1"}'
|
|
),
|
|
_engine_response("Thought: Done.\nFinal Answer: 2"),
|
|
]
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub()],
|
|
bus=bus,
|
|
)
|
|
agent.run("Calc")
|
|
event_types = [e.event_type for e in bus.history]
|
|
assert EventType.TOOL_CALL_START in event_types
|
|
assert EventType.TOOL_CALL_END in event_types
|
|
|
|
def test_context_passing(self):
|
|
"""Pass AgentContext with conversation history."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Simple.\nFinal Answer: Hi!"
|
|
)
|
|
conv = Conversation()
|
|
conv.add(Message(role=Role.USER, content="Previous message"))
|
|
conv.add(Message(role=Role.ASSISTANT, content="Previous response"))
|
|
ctx = AgentContext(conversation=conv)
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
agent.run("Hello", context=ctx)
|
|
call_args = engine.generate.call_args
|
|
messages = call_args[0][0]
|
|
# System prompt + 2 context messages + user input
|
|
assert len(messages) == 4
|
|
assert messages[0].role == Role.SYSTEM
|
|
assert messages[1].role == Role.USER
|
|
assert messages[1].content == "Previous message"
|
|
assert messages[3].role == Role.USER
|
|
assert messages[3].content == "Hello"
|
|
|
|
def test_with_think_tool(self):
|
|
"""Use think tool for internal reasoning."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Let me reason.\nAction: think\n"
|
|
'Action Input: {"thought": "The user wants a greeting"}'
|
|
),
|
|
_engine_response("Thought: Now I know.\nFinal Answer: Greetings!"),
|
|
]
|
|
agent = NativeReActAgent(engine, "test-model", tools=[_ThinkStub()])
|
|
result = agent.run("Say hi")
|
|
assert result.content == "Greetings!"
|
|
assert result.tool_results[0].tool_name == "think"
|
|
assert result.tool_results[0].content == "The user wants a greeting"
|
|
|
|
def test_no_bus_works(self):
|
|
"""Agent runs correctly without an event bus."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Easy.\nFinal Answer: Works!"
|
|
)
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
result = agent.run("Hello")
|
|
assert result.content == "Works!"
|
|
|
|
def test_plain_response_no_structure(self):
|
|
"""If engine returns no ReAct structure, treat as final answer."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response("Just a plain answer.")
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
result = agent.run("Hello")
|
|
assert result.content == "Just a plain answer."
|
|
assert result.turns == 1
|
|
|
|
def test_observation_appended_to_messages(self):
|
|
"""Observation from tool result is sent back to the engine."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"Thought: Calc.\nAction: calculator\n"
|
|
'Action Input: {"expression": "5+5"}'
|
|
),
|
|
_engine_response("Thought: Got it.\nFinal Answer: 10"),
|
|
]
|
|
agent = NativeReActAgent(engine, "test-model", tools=[_CalculatorStub()])
|
|
agent.run("What is 5+5?")
|
|
# Check second call messages
|
|
second_call = engine.generate.call_args_list[1]
|
|
messages = second_call[0][0]
|
|
# Last message should be the observation
|
|
last_msg = messages[-1]
|
|
assert last_msg.role == Role.USER
|
|
assert "Observation:" in last_msg.content
|
|
assert "10" in last_msg.content
|
|
|
|
def test_system_prompt_includes_tool_names(self):
|
|
"""System prompt should list available tool names."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Done.\nFinal Answer: ok"
|
|
)
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub(), _ThinkStub()],
|
|
)
|
|
agent.run("Hello")
|
|
call_args = engine.generate.call_args
|
|
messages = call_args[0][0]
|
|
system_msg = messages[0]
|
|
assert "calculator" in system_msg.content
|
|
assert "think" in system_msg.content
|
|
|
|
def test_system_prompt_no_tools(self):
|
|
"""System prompt should say 'No tools available.' when no tools."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: No tools.\nFinal Answer: ok"
|
|
)
|
|
agent = NativeReActAgent(engine, "test-model")
|
|
agent.run("Hello")
|
|
call_args = engine.generate.call_args
|
|
messages = call_args[0][0]
|
|
assert "No tools available." in messages[0].content
|
|
|
|
def test_max_turns_1(self):
|
|
"""With max_turns=1 and an action, should stop after 1 turn."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
'Thought: Go.\nAction: calculator\nAction Input: {"expression": "1"}'
|
|
)
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub()],
|
|
max_turns=1,
|
|
)
|
|
result = agent.run("Calc")
|
|
assert result.turns == 1
|
|
assert result.metadata.get("max_turns_exceeded") is True
|
|
|
|
def test_event_data_agent_turn_start(self):
|
|
"""AGENT_TURN_START event data should include agent id and input."""
|
|
bus = EventBus(record_history=True)
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Quick.\nFinal Answer: Hi"
|
|
)
|
|
agent = NativeReActAgent(engine, "test-model", bus=bus)
|
|
agent.run("test input")
|
|
start_events = [
|
|
e for e in bus.history if e.event_type == EventType.AGENT_TURN_START
|
|
]
|
|
assert len(start_events) == 1
|
|
assert start_events[0].data["agent"] == "native_react"
|
|
assert start_events[0].data["input"] == "test input"
|
|
|
|
def test_system_prompt_enriched_descriptions(self):
|
|
"""System prompt should include parameter schemas, not just names."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Done.\nFinal Answer: ok"
|
|
)
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub(), _ThinkStub()],
|
|
)
|
|
agent.run("Hello")
|
|
call_args = engine.generate.call_args
|
|
messages = call_args[0][0]
|
|
system_content = messages[0].content
|
|
# Should contain tool name as header
|
|
assert "### calculator" in system_content
|
|
assert "### think" in system_content
|
|
# Should contain parameter info
|
|
assert "expression" in system_content
|
|
assert "string" in system_content
|
|
|
|
def test_case_insensitive_execution(self):
|
|
"""Agent handles lowercase action/thought/final answer from the LLM."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.side_effect = [
|
|
_engine_response(
|
|
"thought: I need to calculate.\n"
|
|
"action: calculator\n"
|
|
'action input: {"expression": "2+2"}'
|
|
),
|
|
_engine_response("thought: The result is 4.\nfinal answer: 4"),
|
|
]
|
|
agent = NativeReActAgent(
|
|
engine,
|
|
"test-model",
|
|
tools=[_CalculatorStub()],
|
|
)
|
|
result = agent.run("What is 2+2?")
|
|
assert result.content == "4"
|
|
assert result.turns == 2
|
|
|
|
|
|
@pytest.mark.parametrize("model", ["qwen3:8b", "gpt-oss:120b"])
|
|
def test_native_react_with_different_models(model):
|
|
"""NativeReActAgent works with different model names."""
|
|
engine = MagicMock()
|
|
engine.engine_id = "mock"
|
|
engine.generate.return_value = _engine_response(
|
|
"Thought: Responding.\nFinal Answer: Hello!"
|
|
)
|
|
agent = NativeReActAgent(engine, model)
|
|
result = agent.run("Hello")
|
|
assert result.content == "Hello!"
|
|
call_kwargs = engine.generate.call_args[1]
|
|
assert call_kwargs["model"] == model
|