Refactor agent hierarchy: extract BaseAgent/ToolUsingAgent helpers, add native ReAct/OpenHands

- Pull shared boilerplate (event emission, message building, generation,
  think-tag stripping) into BaseAgent concrete helpers and ToolUsingAgent
  intermediate base class with tool-call loop
- Add NativeReActAgent and NativeOpenHandsAgent as clean implementations
  built on the new base classes
- Simplify SimpleAgent, OrchestratorAgent, ReActAgent, OpenHandsAgent,
  and RLMAgent to use inherited helpers instead of duplicated logic
- Remove CustomAgent (superseded by BaseAgent subclassing)
- Add backward-compat tests, base agent tests, native agent tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-02-24 03:59:24 +00:00
co-authored by Claude Opus 4.6
parent 323d7ff032
commit 68091dd90b
25 changed files with 3132 additions and 1794 deletions
+1
View File
@@ -54,6 +54,7 @@ server = [
"pydantic>=2.0",
]
agents = []
openhands = ["openhands-sdk>=1.0; python_version >= '3.12'"]
learning = []
orchestrator-training = ["torch>=2.0", "transformers>=4.40"]
channel-telegram = ["python-telegram-bot>=21.0"]
+23 -4
View File
@@ -2,7 +2,12 @@
from __future__ import annotations
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.agents._stubs import (
AgentContext,
AgentResult,
BaseAgent,
ToolUsingAgent,
)
# Import agent modules to trigger @AgentRegistry.register() decorators
try:
@@ -16,12 +21,17 @@ except ImportError:
pass
try:
import openjarvis.agents.custom # noqa: F401
import openjarvis.agents.native_react # noqa: F401
except ImportError:
pass
try:
import openjarvis.agents.react # noqa: F401
import openjarvis.agents.native_openhands # noqa: F401
except ImportError:
pass
try:
import openjarvis.agents.react # noqa: F401 -- backward-compat shim
except ImportError:
pass
@@ -35,4 +45,13 @@ try:
except ImportError:
pass
__all__ = ["AgentContext", "AgentResult", "BaseAgent"]
# Registry alias: "react" -> NativeReActAgent (for backward compat)
try:
from openjarvis.core.registry import AgentRegistry
if AgentRegistry.contains("native_react") and not AgentRegistry.contains("react"):
AgentRegistry.register_value("react", AgentRegistry.get("native_react"))
except Exception:
pass
__all__ = ["AgentContext", "AgentResult", "BaseAgent", "ToolUsingAgent"]
+145 -3
View File
@@ -1,16 +1,21 @@
"""ABC for agent implementations.
Adapted from IPW's ``BaseAgent`` at ``src/agents/base.py``.
Phase 3 will provide concrete implementations (SimpleAgent, OrchestratorAgent, etc.).
Provides ``BaseAgent`` with concrete helper methods for event emission,
message building, and generation, plus ``ToolUsingAgent`` intermediate
base for agents that accept tools.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from openjarvis.core.types import Conversation, ToolResult
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Conversation, Message, Role, ToolResult
from openjarvis.engine._stubs import InferenceEngine
@dataclass(slots=True)
@@ -38,9 +43,115 @@ class BaseAgent(ABC):
Subclasses must be registered via
``@AgentRegistry.register("name")`` to become discoverable.
Provides concrete helper methods that eliminate boilerplate in
subclasses:
- :meth:`_emit_turn_start` / :meth:`_emit_turn_end` -- event bus
- :meth:`_build_messages` -- conversation + system prompt assembly
- :meth:`_generate` -- delegates to engine with stored defaults
- :meth:`_max_turns_result` -- standard max-turns-exceeded result
- :meth:`_strip_think_tags` -- remove ``<think>`` blocks
"""
agent_id: str
accepts_tools: bool = False
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
self._engine = engine
self._model = model
self._bus = bus
self._temperature = temperature
self._max_tokens = max_tokens
# ------------------------------------------------------------------
# Concrete helpers
# ------------------------------------------------------------------
def _emit_turn_start(self, input: str) -> None:
"""Publish ``AGENT_TURN_START`` if an event bus is available."""
if self._bus:
self._bus.publish(
EventType.AGENT_TURN_START,
{"agent": self.agent_id, "input": input},
)
def _emit_turn_end(self, **data: Any) -> None:
"""Publish ``AGENT_TURN_END`` if an event bus is available."""
if self._bus:
payload: Dict[str, Any] = {"agent": self.agent_id}
payload.update(data)
self._bus.publish(EventType.AGENT_TURN_END, payload)
def _build_messages(
self,
input: str,
context: Optional[AgentContext] = None,
*,
system_prompt: Optional[str] = None,
) -> list[Message]:
"""Assemble the message list for a generate call.
Optionally prepends a system prompt, then appends any context
conversation messages, and finally the user input.
"""
messages: list[Message] = []
if system_prompt:
messages.append(Message(role=Role.SYSTEM, content=system_prompt))
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
return messages
def _generate(self, messages: list[Message], **extra_kwargs: Any) -> dict:
"""Call ``engine.generate()`` with stored defaults.
Extra kwargs (e.g. ``tools``) are forwarded to the engine.
"""
return self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
**extra_kwargs,
)
def _max_turns_result(
self,
tool_results: list[ToolResult],
turns: int,
content: str = "",
) -> AgentResult:
"""Build the standard result for when ``max_turns`` is exceeded."""
self._emit_turn_end(turns=turns, max_turns_exceeded=True)
return AgentResult(
content=content or "Maximum turns reached without a final answer.",
tool_results=tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove ``<think>...</think>`` blocks from model output.
Handles both ``<think>...</think>`` and the common distilled-model
pattern where the opening ``<think>`` is absent and the response
begins directly with reasoning text followed by ``</think>``.
"""
# Full <think>...</think> blocks
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
# Leading content before a bare </think> (no opening tag)
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL)
return text.strip()
@abstractmethod
def run(
@@ -52,4 +163,35 @@ class BaseAgent(ABC):
"""Execute the agent on *input* and return an ``AgentResult``."""
__all__ = ["AgentContext", "AgentResult", "BaseAgent"]
class ToolUsingAgent(BaseAgent):
"""Intermediate base for agents that accept and use tools.
Sets ``accepts_tools = True`` for CLI/SDK introspection, and
initialises a :class:`ToolExecutor` from the provided tools.
"""
accepts_tools: bool = True
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
tools: Optional[List["BaseTool"]] = None, # noqa: F821
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
super().__init__(
engine, model, bus=bus,
temperature=temperature, max_tokens=max_tokens,
)
from openjarvis.tools._stubs import ToolExecutor
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus)
self._max_turns = max_turns
__all__ = ["AgentContext", "AgentResult", "BaseAgent", "ToolUsingAgent"]
-34
View File
@@ -1,34 +0,0 @@
"""CustomAgent — template for user-defined agents."""
from __future__ import annotations
from typing import Any, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.registry import AgentRegistry
@AgentRegistry.register("custom")
class CustomAgent(BaseAgent):
"""Template for user-defined agents.
Subclass this agent and override ``run()`` to implement custom behavior.
Register your subclass with ``@AgentRegistry.register("my-agent")``.
"""
agent_id = "custom"
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
raise NotImplementedError(
"CustomAgent is a template. Subclass it and override run() "
"to implement your custom agent logic. Register with "
"@AgentRegistry.register('your-agent-name')."
)
__all__ = ["CustomAgent"]
+312
View File
@@ -0,0 +1,312 @@
"""NativeOpenHandsAgent -- code-execution-centric agent.
Renamed from ``OpenHandsAgent`` to clarify this is OpenJarvis's native
CodeAct-style implementation. The ``OpenHandsAgent`` name is now used
for the real openhands-sdk integration in ``openhands.py``.
"""
from __future__ import annotations
import json as _json
import re
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool
OPENHANDS_SYSTEM_PROMPT = """\
You are an AI assistant with access to tools. You MUST use tools when they would help answer the user's question.
## How to use tools
To call a tool, write on its own lines:
Action: <tool_name>
Action Input: <json_arguments>
You will receive the result, then continue your response.
## Available tools
{tool_descriptions}
## Important rules
- When the user asks you to look up, search, fetch, or summarize a URL or topic, you MUST use web_search. Do NOT say you cannot browse the web.
- When the user provides a URL, pass the FULL URL (including https://) as the query to web_search. Do NOT rewrite URLs into search keywords.
- When the user asks a math question, use calculator.
- When the user asks to read a file, use file_read.
- You CAN write Python code in ```python blocks and it will be executed. Use this for computation, data processing, or when no specific tool fits.
- If no tool or code is needed, respond directly with your answer.
- Do NOT include <think> tags or internal reasoning in your response. Respond directly.\
"""
def _build_tool_descriptions(tools: list) -> str:
"""Build detailed tool descriptions from ToolSpec objects."""
if not tools:
return "No tools available."
lines = []
for t in tools:
s = t.spec
params = s.parameters.get("properties", {})
required = s.parameters.get("required", [])
param_parts = []
for pname, pinfo in params.items():
req_mark = " (required)" if pname in required else ""
param_parts.append(
f" - {pname}{req_mark}: {pinfo.get('description', pinfo.get('type', ''))}"
)
param_str = "\n".join(param_parts) if param_parts else " (no parameters)"
lines.append(f"### {s.name}\n{s.description}\nParameters:\n{param_str}")
return "\n\n".join(lines)
@AgentRegistry.register("native_openhands")
class NativeOpenHandsAgent(ToolUsingAgent):
"""Native CodeAct agent -- generates and executes Python code."""
agent_id = "native_openhands"
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 3,
temperature: float = 0.7,
max_tokens: int = 2048,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
max_tokens=max_tokens,
)
@staticmethod
def _expand_urls(text: str) -> tuple[str, bool]:
"""If the user message contains a URL, fetch it and inline the content.
Returns (possibly_expanded_text, was_expanded).
"""
import re as _re
url_match = _re.search(r"https?://[^\s,;\"'<>]+", text)
if not url_match:
return text, False
url = url_match.group(0).rstrip(".,;)")
try:
from openjarvis.tools.web_search import WebSearchTool
content = WebSearchTool._fetch_url(url, max_chars=4000)
expanded = text.replace(url, f"\n\n--- Content from {url} ---\n{content}\n--- End of content ---\n")
return expanded, True
except Exception:
return text, False
def _truncate_if_needed(self, messages: list[Message], max_prompt_tokens: int = 3000) -> list[Message]:
"""Truncate messages if estimated token count exceeds limit."""
total_chars = sum(len(m.content) for m in messages)
estimated_tokens = total_chars // 4
if estimated_tokens <= max_prompt_tokens:
return messages
# Find the last user message and truncate its content
for i in range(len(messages) - 1, -1, -1):
if messages[i].role == Role.USER:
excess_tokens = estimated_tokens - max_prompt_tokens
excess_chars = excess_tokens * 4
original = messages[i].content
if len(original) > excess_chars + 200:
truncated = original[: len(original) - excess_chars]
messages[i] = Message(
role=Role.USER,
content=truncated + "\n\n[Input truncated to fit context window]",
)
break
return messages
def _extract_code(self, text: str) -> str | None:
"""Extract Python code from markdown code blocks."""
match = re.search(r"```python\n(.*?)```", text, re.DOTALL)
if match:
return match.group(1).strip()
return None
def _extract_tool_call(self, text: str) -> tuple[str, str] | None:
"""Extract tool call from structured output.
Supports two formats:
1. Action: tool_name / Action Input: {"key": "value"}
2. <tool_call>tool_name\\n$key=value</tool_call> (XML-style)
"""
# Format 1: Action / Action Input
action_match = re.search(r"Action:\s*(.+)", text)
input_match = re.search(
r"Action Input:\s*(.+?)(?=\n\n|\Z)", text, re.DOTALL
)
if action_match:
return (
action_match.group(1).strip(),
input_match.group(1).strip() if input_match else "{}",
)
# Format 2: <tool_call>tool_name ... </tool_call> or </tool_name>
xml_match = re.search(
r"<tool_call>\s*(\w+)\s*(.*?)</\w+>",
text,
re.DOTALL,
)
if xml_match:
tool_name = xml_match.group(1).strip()
raw_params = xml_match.group(2).strip()
# Parse $key=value or <key>value</key> params into JSON
params: dict[str, Any] = {}
# $key=value format
for m in re.finditer(r"\$(\w+)=(.+?)(?=\$|\n<|</|$)", raw_params, re.DOTALL):
params[m.group(1)] = m.group(2).strip().rstrip("</>\n")
# <key>value</key> format
for m in re.finditer(r"<(\w+)>(.*?)</\1>", raw_params, re.DOTALL):
key, val = m.group(1), m.group(2).strip()
# Try to parse as int
try:
params[key] = int(val)
except ValueError:
params[key] = val
if params:
return (tool_name, _json.dumps(params))
return (tool_name, "{}")
return None
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
self._emit_turn_start(input)
tool_descriptions = _build_tool_descriptions(self._tools)
system_prompt = OPENHANDS_SYSTEM_PROMPT.format(tool_descriptions=tool_descriptions)
# Pre-fetch any URLs in the input so the LLM gets the content directly
input, url_expanded = self._expand_urls(input)
# If URL content was inlined, skip the tool loop -- just summarize directly
if url_expanded:
direct_messages: list[Message] = [
Message(role=Role.SYSTEM, content="You are a helpful assistant. Respond directly to the user's request using the provided content. Do NOT include <think> tags."),
Message(role=Role.USER, content=input),
]
direct_messages = self._truncate_if_needed(direct_messages)
try:
result = self._generate(direct_messages)
content = self._strip_think_tags(result.get("content", ""))
self._emit_turn_end(turns=1)
return AgentResult(content=content, tool_results=[], turns=1)
except Exception as exc:
error_str = str(exc)
error_msg = (
"The input is too long for the model's context window. Please try a shorter message."
if "400" in error_str
else f"The model returned an error: {error_str}"
)
self._emit_turn_end(turns=1, error=True)
return AgentResult(content=error_msg, tool_results=[], turns=1, metadata={"error": True})
messages = self._build_messages(input, context, system_prompt=system_prompt)
messages = self._truncate_if_needed(messages)
all_tool_results: list[ToolResult] = []
turns = 0
last_content = ""
for _turn in range(self._max_turns):
turns += 1
# Truncate before every generate call -- tool results may have
# expanded the context beyond what the model supports.
messages = self._truncate_if_needed(messages)
try:
result = self._generate(messages)
except Exception as exc:
error_str = str(exc)
if "400" in error_str:
error_msg = (
"The input is too long for the model's context window. "
"Please try a shorter message."
)
else:
error_msg = f"The model returned an error: {error_str}"
self._emit_turn_end(turns=turns, error=True)
return AgentResult(
content=error_msg,
tool_results=all_tool_results,
turns=turns,
metadata={"error": True},
)
content = result.get("content", "")
# Strip think tags so they don't interfere with parsing
content = self._strip_think_tags(content)
last_content = content
# Try to extract code
code = self._extract_code(content)
if code:
messages.append(Message(role=Role.ASSISTANT, content=content))
# Execute via code_interpreter tool if available
tool_call = ToolCall(
id=f"code_{turns}",
name="code_interpreter",
arguments=_json.dumps({"code": code}),
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
observation = f"Output:\n{obs_text}"
messages.append(Message(role=Role.USER, content=observation))
continue
# Try tool call
tool_info = self._extract_tool_call(content)
if tool_info:
action, action_input = tool_info
messages.append(Message(role=Role.ASSISTANT, content=content))
tool_call = ToolCall(
id=f"tool_{turns}", name=action, arguments=action_input
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
observation = f"Result: {obs_text}"
messages.append(Message(role=Role.USER, content=observation))
continue
# No code or tool call -- this is the final answer
content = self._strip_think_tags(content)
self._emit_turn_end(turns=turns)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
)
# Max turns
final = self._strip_think_tags(last_content) or "Maximum turns reached."
return self._max_turns_result(all_tool_results, turns, content=final)
__all__ = ["NativeOpenHandsAgent"]
+148
View File
@@ -0,0 +1,148 @@
"""NativeReActAgent -- Thought-Action-Observation loop agent.
Renamed from ``ReActAgent`` to clarify this is OpenJarvis's native
implementation, not an integration with an external project.
"""
from __future__ import annotations
import re
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool
REACT_SYSTEM_PROMPT = """\
You are a ReAct agent. For each step, respond with exactly one of:
1. To think and act:
Thought: <your reasoning>
Action: <tool_name>
Action Input: <json arguments>
2. To give a final answer:
Thought: <your reasoning>
Final Answer: <your answer>
Available tools: {tool_names}"""
@AgentRegistry.register("native_react")
class NativeReActAgent(ToolUsingAgent):
"""ReAct agent: Thought -> Action -> Observation loop."""
agent_id = "native_react"
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
max_tokens=max_tokens,
)
def _parse_response(self, text: str) -> dict:
"""Parse ReAct structured output."""
result = {"thought": "", "action": "", "action_input": "", "final_answer": ""}
# Extract Thought
thought_match = re.search(
r"Thought:\s*(.+?)(?=\nAction:|\nFinal Answer:|\Z)", text, re.DOTALL
)
if thought_match:
result["thought"] = thought_match.group(1).strip()
# Check for Final Answer
final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL)
if final_match:
result["final_answer"] = final_match.group(1).strip()
return result
# Extract Action and Action Input
action_match = re.search(r"Action:\s*(.+)", text)
if action_match:
result["action"] = action_match.group(1).strip()
input_match = re.search(
r"Action Input:\s*(.+?)(?=\n\n|\nThought:|\Z)", text, re.DOTALL
)
if input_match:
result["action_input"] = input_match.group(1).strip()
return result
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
self._emit_turn_start(input)
# Build system prompt with available tools
tool_names = (
", ".join(t.spec.name for t in self._tools) if self._tools else "none"
)
system_prompt = REACT_SYSTEM_PROMPT.format(tool_names=tool_names)
messages = self._build_messages(input, context, system_prompt=system_prompt)
all_tool_results: list[ToolResult] = []
turns = 0
for _turn in range(self._max_turns):
turns += 1
result = self._generate(messages)
content = result.get("content", "")
parsed = self._parse_response(content)
# Final answer?
if parsed["final_answer"]:
self._emit_turn_end(turns=turns)
return AgentResult(
content=parsed["final_answer"],
tool_results=all_tool_results,
turns=turns,
)
# No action? Treat content as final answer
if not parsed["action"]:
self._emit_turn_end(turns=turns)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
)
# Execute action
messages.append(Message(role=Role.ASSISTANT, content=content))
tool_call = ToolCall(
id=f"react_{turns}",
name=parsed["action"],
arguments=parsed["action_input"] or "{}",
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
observation = f"Observation: {tool_result.content}"
messages.append(Message(role=Role.USER, content=observation))
# Max turns exceeded
return self._max_turns_result(all_tool_results, turns)
__all__ = ["NativeReActAgent", "REACT_SYSTEM_PROMPT"]
+40 -329
View File
@@ -1,69 +1,28 @@
"""OpenHandsAgent -- code-execution-centric agent."""
"""OpenHandsAgent -- wraps the real openhands-sdk for AI-driven development.
Requires the ``openhands-sdk`` package (``pip install openjarvis[openhands]``).
For the native CodeAct-style agent, see :mod:`openjarvis.agents.native_openhands`.
"""
from __future__ import annotations
import json as _json
import re
from typing import Any, List, Optional
import os
from typing import Any, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, ToolExecutor
OPENHANDS_SYSTEM_PROMPT = """\
You are an AI assistant with access to tools. You MUST use tools when they would help answer the user's question.
## How to use tools
To call a tool, write on its own lines:
Action: <tool_name>
Action Input: <json_arguments>
You will receive the result, then continue your response.
## Available tools
{tool_descriptions}
## Important rules
- When the user asks you to look up, search, fetch, or summarize a URL or topic, you MUST use web_search. Do NOT say you cannot browse the web.
- When the user provides a URL, pass the FULL URL (including https://) as the query to web_search. Do NOT rewrite URLs into search keywords.
- When the user asks a math question, use calculator.
- When the user asks to read a file, use file_read.
- You CAN write Python code in ```python blocks and it will be executed. Use this for computation, data processing, or when no specific tool fits.
- If no tool or code is needed, respond directly with your answer.
- Do NOT include <think> tags or internal reasoning in your response. Respond directly.\
"""
def _build_tool_descriptions(tools: list) -> str:
"""Build detailed tool descriptions from ToolSpec objects."""
if not tools:
return "No tools available."
lines = []
for t in tools:
s = t.spec
params = s.parameters.get("properties", {})
required = s.parameters.get("required", [])
param_parts = []
for pname, pinfo in params.items():
req_mark = " (required)" if pname in required else ""
param_parts.append(
f" - {pname}{req_mark}: {pinfo.get('description', pinfo.get('type', ''))}"
)
param_str = "\n".join(param_parts) if param_parts else " (no parameters)"
lines.append(f"### {s.name}\n{s.description}\nParameters:\n{param_str}")
return "\n\n".join(lines)
@AgentRegistry.register("openhands")
class OpenHandsAgent(BaseAgent):
"""OpenHands CodeAct agent -- generates and executes Python code."""
"""Agent that wraps the real openhands-sdk package.
This is a thin adapter that delegates to the ``openhands-sdk``
library for AI-driven software development tasks. Requires
``openhands-sdk`` to be installed.
"""
agent_id = "openhands"
@@ -72,129 +31,18 @@ class OpenHandsAgent(BaseAgent):
engine: InferenceEngine,
model: str,
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 3,
temperature: float = 0.7,
max_tokens: int = 2048,
max_tokens: int = 1024,
workspace: Optional[str] = None,
api_key: Optional[str] = None,
) -> None:
self._engine = engine
self._model = model
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus)
self._bus = bus
self._max_turns = max_turns
self._temperature = temperature
self._max_tokens = max_tokens
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove thinking blocks from model output.
Handles both ``<think>...</think>`` and the common distilled-model
pattern where the opening ``<think>`` is absent and the response
begins directly with reasoning text followed by ``</think>``.
"""
# Full <think>...</think> blocks
text = re.sub(r"<think>.*?</think>\s*", "", text, flags=re.DOTALL)
# Leading content before a bare </think> (no opening tag)
text = re.sub(r"^.*?</think>\s*", "", text, flags=re.DOTALL)
return text.strip()
@staticmethod
def _expand_urls(text: str) -> tuple[str, bool]:
"""If the user message contains a URL, fetch it and inline the content.
Returns (possibly_expanded_text, was_expanded).
"""
import re as _re
url_match = _re.search(r"https?://[^\s,;\"'<>]+", text)
if not url_match:
return text, False
url = url_match.group(0).rstrip(".,;)")
try:
from openjarvis.tools.web_search import WebSearchTool
content = WebSearchTool._fetch_url(url, max_chars=4000)
expanded = text.replace(url, f"\n\n--- Content from {url} ---\n{content}\n--- End of content ---\n")
return expanded, True
except Exception:
return text, False
def _truncate_if_needed(self, messages: list[Message], max_prompt_tokens: int = 3000) -> list[Message]:
"""Truncate messages if estimated token count exceeds limit."""
total_chars = sum(len(m.content) for m in messages)
estimated_tokens = total_chars // 4
if estimated_tokens <= max_prompt_tokens:
return messages
# Find the last user message and truncate its content
for i in range(len(messages) - 1, -1, -1):
if messages[i].role == Role.USER:
excess_tokens = estimated_tokens - max_prompt_tokens
excess_chars = excess_tokens * 4
original = messages[i].content
if len(original) > excess_chars + 200:
truncated = original[: len(original) - excess_chars]
messages[i] = Message(
role=Role.USER,
content=truncated + "\n\n[Input truncated to fit context window]",
)
break
return messages
def _extract_code(self, text: str) -> str | None:
"""Extract Python code from markdown code blocks."""
match = re.search(r"```python\n(.*?)```", text, re.DOTALL)
if match:
return match.group(1).strip()
return None
def _extract_tool_call(self, text: str) -> tuple[str, str] | None:
"""Extract tool call from structured output.
Supports two formats:
1. Action: tool_name / Action Input: {"key": "value"}
2. <tool_call>tool_name\\n$key=value</tool_call> (XML-style)
"""
# Format 1: Action / Action Input
action_match = re.search(r"Action:\s*(.+)", text)
input_match = re.search(
r"Action Input:\s*(.+?)(?=\n\n|\Z)", text, re.DOTALL
super().__init__(
engine, model, bus=bus,
temperature=temperature, max_tokens=max_tokens,
)
if action_match:
return (
action_match.group(1).strip(),
input_match.group(1).strip() if input_match else "{}",
)
# Format 2: <tool_call>tool_name ... </tool_call> or </tool_name>
xml_match = re.search(
r"<tool_call>\s*(\w+)\s*(.*?)</\w+>",
text,
re.DOTALL,
)
if xml_match:
tool_name = xml_match.group(1).strip()
raw_params = xml_match.group(2).strip()
# Parse $key=value or <key>value</key> params into JSON
params: dict[str, Any] = {}
# $key=value format
for m in re.finditer(r"\$(\w+)=(.+?)(?=\$|\n<|</|$)", raw_params, re.DOTALL):
params[m.group(1)] = m.group(2).strip().rstrip("</>\n")
# <key>value</key> format
for m in re.finditer(r"<(\w+)>(.*?)</\1>", raw_params, re.DOTALL):
key, val = m.group(1), m.group(2).strip()
# Try to parse as int
try:
params[key] = int(val)
except ValueError:
params[key] = val
if params:
return (tool_name, _json.dumps(params))
return (tool_name, "{}")
return None
self._workspace = workspace or os.getcwd()
self._api_key = api_key or os.environ.get("LLM_API_KEY", "")
def run(
self,
@@ -202,165 +50,28 @@ class OpenHandsAgent(BaseAgent):
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
bus = self._bus
try:
from openhands.sdk import Agent, Conversation, LLM # type: ignore[import-untyped]
except ImportError:
raise ImportError(
"OpenHandsAgent requires the openhands-sdk package. "
"Install it with: pip install openjarvis[openhands]"
) from None
if bus:
bus.publish(
EventType.AGENT_TURN_START,
{"agent": self.agent_id, "input": input},
)
self._emit_turn_start(input)
tool_descriptions = _build_tool_descriptions(self._tools)
system_prompt = OPENHANDS_SYSTEM_PROMPT.format(tool_descriptions=tool_descriptions)
llm = LLM(model=self._model, api_key=self._api_key)
agent = Agent(llm=llm)
conversation = Conversation(agent=agent, workspace=self._workspace)
conversation.send_message(input)
conversation.run()
# Pre-fetch any URLs in the input so the LLM gets the content directly
input, url_expanded = self._expand_urls(input)
# Extract result from conversation
messages = conversation.get_messages()
content = messages[-1].content if messages else ""
# If URL content was inlined, skip the tool loop — just summarize directly
if url_expanded:
direct_messages: list[Message] = [
Message(role=Role.SYSTEM, content="You are a helpful assistant. Respond directly to the user's request using the provided content. Do NOT include <think> tags."),
Message(role=Role.USER, content=input),
]
direct_messages = self._truncate_if_needed(direct_messages)
try:
result = self._engine.generate(
direct_messages, model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
content = self._strip_think_tags(result.get("content", ""))
if bus:
bus.publish(EventType.AGENT_TURN_END, {"agent": self.agent_id, "turns": 1})
return AgentResult(content=content, tool_results=[], turns=1)
except Exception as exc:
error_str = str(exc)
error_msg = (
"The input is too long for the model's context window. Please try a shorter message."
if "400" in error_str
else f"The model returned an error: {error_str}"
)
if bus:
bus.publish(EventType.AGENT_TURN_END, {"agent": self.agent_id, "turns": 1, "error": True})
return AgentResult(content=error_msg, tool_results=[], turns=1, metadata={"error": True})
messages: list[Message] = [Message(role=Role.SYSTEM, content=system_prompt)]
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
messages = self._truncate_if_needed(messages)
all_tool_results: list[ToolResult] = []
turns = 0
last_content = ""
for _turn in range(self._max_turns):
turns += 1
# Truncate before every generate call — tool results may have
# expanded the context beyond what the model supports.
messages = self._truncate_if_needed(messages)
try:
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
except Exception as exc:
error_str = str(exc)
if "400" in error_str:
error_msg = (
"The input is too long for the model's context window. "
"Please try a shorter message."
)
else:
error_msg = f"The model returned an error: {error_str}"
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns, "error": True},
)
return AgentResult(
content=error_msg,
tool_results=all_tool_results,
turns=turns,
metadata={"error": True},
)
content = result.get("content", "")
# Strip think tags so they don't interfere with parsing
content = self._strip_think_tags(content)
last_content = content
# Try to extract code
code = self._extract_code(content)
if code:
messages.append(Message(role=Role.ASSISTANT, content=content))
# Execute via code_interpreter tool if available
tool_call = ToolCall(
id=f"code_{turns}",
name="code_interpreter",
arguments=_json.dumps({"code": code}),
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
observation = f"Output:\n{obs_text}"
messages.append(Message(role=Role.USER, content=observation))
continue
# Try tool call
tool_info = self._extract_tool_call(content)
if tool_info:
action, action_input = tool_info
messages.append(Message(role=Role.ASSISTANT, content=content))
tool_call = ToolCall(
id=f"tool_{turns}", name=action, arguments=action_input
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
obs_text = tool_result.content
if len(obs_text) > 4000:
obs_text = obs_text[:4000] + "\n\n[Output truncated]"
observation = f"Result: {obs_text}"
messages.append(Message(role=Role.USER, content=observation))
continue
# No code or tool call -- this is the final answer
content = self._strip_think_tags(content)
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
)
# Max turns
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{
"agent": self.agent_id,
"turns": turns,
"max_turns_exceeded": True,
},
)
return AgentResult(
content=self._strip_think_tags(last_content) or "Maximum turns reached.",
tool_results=all_tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
self._emit_turn_end(turns=1)
return AgentResult(content=content, turns=1)
__all__ = ["OpenHandsAgent"]
+25 -102
View File
@@ -15,16 +15,16 @@ from __future__ import annotations
import re
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, ToolExecutor
from openjarvis.tools._stubs import BaseTool
@AgentRegistry.register("orchestrator")
class OrchestratorAgent(BaseAgent):
class OrchestratorAgent(ToolUsingAgent):
"""Multi-turn agent that routes between tools and the LLM.
Implements a tool-calling loop:
@@ -54,14 +54,11 @@ class OrchestratorAgent(BaseAgent):
mode: str = "function_calling",
system_prompt: Optional[str] = None,
) -> None:
self._engine = engine
self._model = model
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus)
self._bus = bus
self._max_turns = max_turns
self._temperature = temperature
self._max_tokens = max_tokens
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
max_tokens=max_tokens,
)
self._mode = mode
self._system_prompt = system_prompt
@@ -85,13 +82,7 @@ class OrchestratorAgent(BaseAgent):
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
bus = self._bus
if bus:
bus.publish(
EventType.AGENT_TURN_START,
{"agent": self.agent_id, "input": input},
)
self._emit_turn_start(input)
# Build system prompt
if self._system_prompt:
@@ -103,12 +94,7 @@ class OrchestratorAgent(BaseAgent):
tool_names = [t.spec.name for t in self._tools]
sys_prompt = build_system_prompt(tool_names)
messages: list[Message] = [
Message(role=Role.SYSTEM, content=sys_prompt),
]
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
messages = self._build_messages(input, context, system_prompt=sys_prompt)
all_tool_results: list[ToolResult] = []
turns = 0
@@ -116,30 +102,21 @@ class OrchestratorAgent(BaseAgent):
for _turn in range(self._max_turns):
turns += 1
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
result = self._generate(messages)
content = result.get("content", "")
parsed = self._parse_structured_response(content)
# FINAL_ANSWER done
# FINAL_ANSWER -> done
if parsed["final_answer"]:
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
self._emit_turn_end(turns=turns)
return AgentResult(
content=parsed["final_answer"],
tool_results=all_tool_results,
turns=turns,
)
# TOOL execute
# TOOL -> execute
if parsed["tool"]:
messages.append(
Message(role=Role.ASSISTANT, content=content)
@@ -161,12 +138,8 @@ class OrchestratorAgent(BaseAgent):
)
continue
# Neither treat content as final answer
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
# Neither -> treat content as final answer
self._emit_turn_end(turns=turns)
return AgentResult(
content=content,
tool_results=all_tool_results,
@@ -174,21 +147,7 @@ class OrchestratorAgent(BaseAgent):
)
# Max turns exceeded
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{
"agent": self.agent_id,
"turns": turns,
"max_turns_exceeded": True,
},
)
return AgentResult(
content="Maximum turns reached without a final answer.",
tool_results=all_tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
return self._max_turns_result(all_tool_results, turns)
@staticmethod
def _parse_structured_response(text: str) -> dict:
@@ -243,20 +202,10 @@ class OrchestratorAgent(BaseAgent):
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
bus = self._bus
# Emit agent start
if bus:
bus.publish(EventType.AGENT_TURN_START, {
"agent": self.agent_id,
"input": input,
})
self._emit_turn_start(input)
# Build initial messages
messages: list[Message] = []
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
messages = self._build_messages(input, context)
# Get OpenAI-format tool definitions
openai_tools = self._executor.get_openai_tools() if self._tools else []
@@ -272,25 +221,14 @@ class OrchestratorAgent(BaseAgent):
if openai_tools:
gen_kwargs["tools"] = openai_tools
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
**gen_kwargs,
)
result = self._generate(messages, **gen_kwargs)
content = result.get("content", "")
raw_tool_calls = result.get("tool_calls", [])
# No tool calls final answer
# No tool calls -> final answer
if not raw_tool_calls:
if bus:
bus.publish(EventType.AGENT_TURN_END, {
"agent": self.agent_id,
"turns": turns,
"content_length": len(content),
})
self._emit_turn_end(turns=turns, content_length=len(content))
return AgentResult(
content=content,
tool_results=all_tool_results,
@@ -328,23 +266,8 @@ class OrchestratorAgent(BaseAgent):
))
# Max turns exceeded
if bus:
bus.publish(EventType.AGENT_TURN_END, {
"agent": self.agent_id,
"turns": turns,
"max_turns_exceeded": True,
})
# Try to provide last content or a warning
final_content = content if content else (
"Maximum turns reached without a final answer."
)
return AgentResult(
content=final_content,
tool_results=all_tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
final_content = content if content else ""
return self._max_turns_result(all_tool_results, turns, content=final_content)
__all__ = ["OrchestratorAgent"]
+4 -182
View File
@@ -1,184 +1,6 @@
"""ReActAgent -- Thought-Action-Observation loop agent."""
"""Backward-compat shim -- canonical location is agents.native_react."""
from __future__ import annotations
from openjarvis.agents.native_react import NativeReActAgent as ReActAgent # noqa: F401
from openjarvis.agents.native_react import REACT_SYSTEM_PROMPT # noqa: F401
import re
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.tools._stubs import BaseTool, ToolExecutor
REACT_SYSTEM_PROMPT = """\
You are a ReAct agent. For each step, respond with exactly one of:
1. To think and act:
Thought: <your reasoning>
Action: <tool_name>
Action Input: <json arguments>
2. To give a final answer:
Thought: <your reasoning>
Final Answer: <your answer>
Available tools: {tool_names}"""
@AgentRegistry.register("react")
class ReActAgent(BaseAgent):
"""ReAct agent: Thought -> Action -> Observation loop."""
agent_id = "react"
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
tools: Optional[List[BaseTool]] = None,
bus: Optional[EventBus] = None,
max_turns: int = 10,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
self._engine = engine
self._model = model
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus)
self._bus = bus
self._max_turns = max_turns
self._temperature = temperature
self._max_tokens = max_tokens
def _parse_response(self, text: str) -> dict:
"""Parse ReAct structured output."""
result = {"thought": "", "action": "", "action_input": "", "final_answer": ""}
# Extract Thought
thought_match = re.search(
r"Thought:\s*(.+?)(?=\nAction:|\nFinal Answer:|\Z)", text, re.DOTALL
)
if thought_match:
result["thought"] = thought_match.group(1).strip()
# Check for Final Answer
final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL)
if final_match:
result["final_answer"] = final_match.group(1).strip()
return result
# Extract Action and Action Input
action_match = re.search(r"Action:\s*(.+)", text)
if action_match:
result["action"] = action_match.group(1).strip()
input_match = re.search(
r"Action Input:\s*(.+?)(?=\n\n|\nThought:|\Z)", text, re.DOTALL
)
if input_match:
result["action_input"] = input_match.group(1).strip()
return result
def run(
self,
input: str,
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
bus = self._bus
if bus:
bus.publish(
EventType.AGENT_TURN_START,
{"agent": self.agent_id, "input": input},
)
# Build system prompt with available tools
tool_names = (
", ".join(t.spec.name for t in self._tools) if self._tools else "none"
)
system_prompt = REACT_SYSTEM_PROMPT.format(tool_names=tool_names)
messages: list[Message] = [Message(role=Role.SYSTEM, content=system_prompt)]
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
all_tool_results: list[ToolResult] = []
turns = 0
for _turn in range(self._max_turns):
turns += 1
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
content = result.get("content", "")
parsed = self._parse_response(content)
# Final answer?
if parsed["final_answer"]:
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
return AgentResult(
content=parsed["final_answer"],
tool_results=all_tool_results,
turns=turns,
)
# No action? Treat content as final answer
if not parsed["action"]:
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
return AgentResult(
content=content, tool_results=all_tool_results, turns=turns
)
# Execute action
messages.append(Message(role=Role.ASSISTANT, content=content))
tool_call = ToolCall(
id=f"react_{turns}",
name=parsed["action"],
arguments=parsed["action_input"] or "{}",
)
tool_result = self._executor.execute(tool_call)
all_tool_results.append(tool_result)
observation = f"Observation: {tool_result.content}"
messages.append(Message(role=Role.USER, content=observation))
# Max turns exceeded
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{
"agent": self.agent_id,
"turns": turns,
"max_turns_exceeded": True,
},
)
return AgentResult(
content="Maximum turns reached without a final answer.",
tool_results=all_tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
__all__ = ["ReActAgent"]
__all__ = ["ReActAgent", "REACT_SYSTEM_PROMPT"]
+22 -65
View File
@@ -11,9 +11,9 @@ from __future__ import annotations
import re
from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.agents.rlm_repl import RLMRepl
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.events import EventBus
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall, ToolResult
from openjarvis.engine._stubs import InferenceEngine
@@ -72,7 +72,7 @@ RLM_SYSTEM_PROMPT = (
@AgentRegistry.register("rlm")
class RLMAgent(BaseAgent):
class RLMAgent(ToolUsingAgent):
"""Recursive Language Model agent using a persistent REPL.
The agent generates Python code that runs in a sandboxed REPL with
@@ -100,14 +100,14 @@ class RLMAgent(BaseAgent):
max_output_chars: int = 10000,
system_prompt: Optional[str] = None,
) -> None:
self._engine = engine
self._model = model
self._tools = tools or []
self._executor = ToolExecutor(self._tools, bus=bus) if self._tools else None
self._bus = bus
self._max_turns = max_turns
self._temperature = temperature
self._max_tokens = max_tokens
super().__init__(
engine, model, tools=tools, bus=bus,
max_turns=max_turns, temperature=temperature,
max_tokens=max_tokens,
)
# Override executor: RLM only creates one if tools are provided
if not self._tools:
self._executor = None # type: ignore[assignment]
self._sub_model = sub_model or model
self._sub_temperature = sub_temperature
self._sub_max_tokens = sub_max_tokens
@@ -124,14 +124,7 @@ class RLMAgent(BaseAgent):
context: Optional[AgentContext] = None,
**kwargs: Any,
) -> AgentResult:
bus = self._bus
# Emit agent start
if bus:
bus.publish(
EventType.AGENT_TURN_START,
{"agent": self.agent_id, "input": input},
)
self._emit_turn_start(input)
# Create REPL with sub-LM callbacks
repl = RLMRepl(
@@ -146,12 +139,9 @@ class RLMAgent(BaseAgent):
repl.set_variable("context", ctx_text)
# Build conversation
messages: list[Message] = [
Message(role=Role.SYSTEM, content=self._system_prompt),
]
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
messages = self._build_messages(
input, context, system_prompt=self._system_prompt,
)
all_tool_results: list[ToolResult] = []
turns = 0
@@ -159,12 +149,7 @@ class RLMAgent(BaseAgent):
for _turn in range(self._max_turns):
turns += 1
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
result = self._generate(messages)
content = result.get("content", "")
# Strip <think> tags
@@ -173,13 +158,9 @@ class RLMAgent(BaseAgent):
# Extract code block
code = self._extract_code(content)
# No code block return content as final answer
# No code block -> return content as final answer
if code is None:
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
self._emit_turn_end(turns=turns)
return AgentResult(
content=content,
tool_results=all_tool_results,
@@ -204,11 +185,7 @@ class RLMAgent(BaseAgent):
if repl.is_terminated:
final = repl.final_answer
final_str = str(final) if final is not None else ""
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{"agent": self.agent_id, "turns": turns},
)
self._emit_turn_end(turns=turns)
return AgentResult(
content=final_str,
tool_results=all_tool_results,
@@ -224,29 +201,14 @@ class RLMAgent(BaseAgent):
)
messages.append(Message(role=Role.USER, content=feedback))
# Max turns exceeded check answer dict for partial result
# Max turns exceeded -- check answer dict for partial result
answer = repl.get_variable("answer")
if isinstance(answer, dict) and answer.get("value") is not None:
final_content = str(answer["value"])
else:
final_content = "Maximum turns reached without a final answer."
final_content = ""
if bus:
bus.publish(
EventType.AGENT_TURN_END,
{
"agent": self.agent_id,
"turns": turns,
"max_turns_exceeded": True,
},
)
return AgentResult(
content=final_content,
tool_results=all_tool_results,
turns=turns,
metadata={"max_turns_exceeded": True},
)
return self._max_turns_result(all_tool_results, turns, content=final_content)
# ------------------------------------------------------------------
# Sub-LM callbacks
@@ -330,11 +292,6 @@ class RLMAgent(BaseAgent):
return m.group(1).strip()
return None
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove ``<think>...</think>`` blocks from model output."""
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
@staticmethod
def _resolve_context(context: Optional[AgentContext]) -> Optional[str]:
"""Resolve context text from AgentContext metadata or memory_results."""
+5 -47
View File
@@ -5,33 +5,15 @@ from __future__ import annotations
from typing import Any, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role
from openjarvis.engine._stubs import InferenceEngine
@AgentRegistry.register("simple")
class SimpleAgent(BaseAgent):
"""Single-turn agent: query model response. No tool calling."""
"""Single-turn agent: query -> model -> response. No tool calling."""
agent_id = "simple"
def __init__(
self,
engine: InferenceEngine,
model: str,
*,
bus: Optional[EventBus] = None,
temperature: float = 0.7,
max_tokens: int = 1024,
) -> None:
self._engine = engine
self._model = model
self._bus = bus
self._temperature = temperature
self._max_tokens = max_tokens
def run(
self,
input: str,
@@ -39,37 +21,13 @@ class SimpleAgent(BaseAgent):
**kwargs: Any,
) -> AgentResult:
"""Single-turn: build messages, call engine, return result."""
bus = self._bus
# Emit turn start
if bus:
bus.publish(EventType.AGENT_TURN_START, {
"agent": self.agent_id,
"input": input,
})
# Build messages from context conversation + user input
messages: list[Message] = []
if context and context.conversation.messages:
messages.extend(context.conversation.messages)
messages.append(Message(role=Role.USER, content=input))
# Generate — telemetry is handled by InstrumentedEngine wrapper
result = self._engine.generate(
messages,
model=self._model,
temperature=self._temperature,
max_tokens=self._max_tokens,
)
self._emit_turn_start(input)
messages = self._build_messages(input, context)
result = self._generate(messages)
content = result.get("content", "")
# Emit turn end
if bus:
bus.publish(EventType.AGENT_TURN_END, {
"agent": self.agent_id,
"content_length": len(content),
})
self._emit_turn_end(content_length=len(content))
return AgentResult(content=content, turns=1)
+1 -1
View File
@@ -116,7 +116,7 @@ def _run_agent(
"temperature": temperature,
"max_tokens": max_tokens,
}
if agent_name == "orchestrator":
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["tools"] = tools
agent_kwargs["max_turns"] = config.agent.max_turns
+1 -1
View File
@@ -363,7 +363,7 @@ class Jarvis:
"temperature": temperature,
"max_tokens": max_tokens,
}
if agent_name == "orchestrator":
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["tools"] = tool_objects
agent_kwargs["max_turns"] = self._config.agent.max_turns
+1 -4
View File
@@ -122,10 +122,7 @@ class JarvisSystem:
"temperature": temperature,
"max_tokens": max_tokens,
}
if agent_name == "orchestrator":
agent_kwargs["tools"] = agent_tools
agent_kwargs["max_turns"] = self.config.agent.max_turns
elif agent_name == "rlm":
if getattr(agent_cls, "accepts_tools", False):
agent_kwargs["tools"] = agent_tools
agent_kwargs["max_turns"] = self.config.agent.max_turns
+13 -13
View File
@@ -7,9 +7,9 @@ from unittest.mock import MagicMock
import pytest
from openjarvis.agents._stubs import AgentResult
from openjarvis.agents.openhands import OpenHandsAgent
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.agents.orchestrator import OrchestratorAgent
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.simple import SimpleAgent
from openjarvis.core.events import EventBus, EventType
@@ -40,15 +40,15 @@ _ORCHESTRATOR_RESPONSE = "Hello!"
AGENT_FACTORIES = {
"simple": lambda e, m, bus: SimpleAgent(e, m, bus=bus),
"orchestrator": lambda e, m, bus: OrchestratorAgent(e, m, bus=bus),
"react": lambda e, m, bus: ReActAgent(e, m, bus=bus),
"openhands": lambda e, m, bus: OpenHandsAgent(e, m, bus=bus),
"native_react": lambda e, m, bus: NativeReActAgent(e, m, bus=bus),
"native_openhands": lambda e, m, bus: NativeOpenHandsAgent(e, m, bus=bus),
}
AGENT_RESPONSES = {
"simple": _SIMPLE_RESPONSE,
"orchestrator": _ORCHESTRATOR_RESPONSE,
"react": _REACT_RESPONSE,
"openhands": _OPENHANDS_RESPONSE,
"native_react": _REACT_RESPONSE,
"native_openhands": _OPENHANDS_RESPONSE,
}
@@ -57,7 +57,7 @@ AGENT_RESPONSES = {
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "react", "openhands"])
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "native_react", "native_openhands"])
class TestAgentCommon:
def test_runs_with_mock_engine(self, agent_key):
"""Each agent type can run with a mock engine."""
@@ -114,8 +114,8 @@ class TestAgentCommon:
[
("simple", "simple"),
("orchestrator", "orchestrator"),
("react", "react"),
("openhands", "openhands"),
("native_react", "native_react"),
("native_openhands", "native_openhands"),
],
)
def test_agent_id(agent_key, expected_id):
@@ -129,7 +129,7 @@ def test_agent_id(agent_key, expected_id):
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "react", "openhands"])
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "native_react", "native_openhands"])
@pytest.mark.parametrize("model", ["qwen3:8b", "llama3:70b", "gpt-oss:120b"])
def test_model_passthrough(agent_key, model):
"""Each agent passes the model name through to the engine."""
@@ -145,7 +145,7 @@ def test_model_passthrough(agent_key, model):
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "react", "openhands"])
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "native_react", "native_openhands"])
def test_no_bus(agent_key):
"""All agents work without an event bus."""
engine = _make_mock_engine(AGENT_RESPONSES[agent_key])
@@ -160,7 +160,7 @@ def test_no_bus(agent_key):
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "react", "openhands"])
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "native_react", "native_openhands"])
def test_single_turn_count(agent_key):
"""All agents report at least 1 turn for a simple query."""
engine = _make_mock_engine(AGENT_RESPONSES[agent_key])
@@ -174,7 +174,7 @@ def test_single_turn_count(agent_key):
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "react", "openhands"])
@pytest.mark.parametrize("agent_key", ["simple", "orchestrator", "native_react", "native_openhands"])
def test_no_tool_results_for_simple_query(agent_key):
"""When no tools are used, tool_results should be empty."""
engine = _make_mock_engine(AGENT_RESPONSES[agent_key])
+43
View File
@@ -0,0 +1,43 @@
"""Tests for backward compatibility of renamed agents."""
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.core.registry import AgentRegistry
class TestReActBackwardCompat:
def test_old_import_path(self):
"""Old import ``from openjarvis.agents.react import ReActAgent`` works."""
from openjarvis.agents.react import ReActAgent
# ReActAgent is actually NativeReActAgent
assert ReActAgent is NativeReActAgent
def test_registry_alias(self):
"""``AgentRegistry.get("react")`` returns NativeReActAgent."""
# Ensure registration
AgentRegistry.register_value("native_react", NativeReActAgent)
if not AgentRegistry.contains("react"):
AgentRegistry.register_value("react", NativeReActAgent)
react_cls = AgentRegistry.get("react")
native_cls = AgentRegistry.get("native_react")
assert react_cls is native_cls
def test_old_class_instantiates(self):
"""ReActAgent (alias) can be instantiated and has correct agent_id."""
from openjarvis.agents.react import ReActAgent
engine = MagicMock()
engine.engine_id = "mock"
agent = ReActAgent(engine, "test-model")
assert agent.agent_id == "native_react"
def test_react_system_prompt_importable(self):
"""REACT_SYSTEM_PROMPT can be imported from old path."""
from openjarvis.agents.react import REACT_SYSTEM_PROMPT
assert "ReAct" in REACT_SYSTEM_PROMPT
+275
View File
@@ -0,0 +1,275 @@
"""Tests for BaseAgent helpers and ToolUsingAgent."""
from __future__ import annotations
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
from openjarvis.agents._stubs import (
AgentContext,
AgentResult,
BaseAgent,
ToolUsingAgent,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.types import Conversation, Message, Role, ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
# ---------------------------------------------------------------------------
# Concrete subclass for testing
# ---------------------------------------------------------------------------
class _ConcreteAgent(BaseAgent):
agent_id = "test_agent"
def run(self, input, context=None, **kwargs):
return AgentResult(content="test", turns=1)
class _ConcreteToolAgent(ToolUsingAgent):
agent_id = "test_tool_agent"
def run(self, input, context=None, **kwargs):
return AgentResult(content="test", turns=1)
class _DummyTool(BaseTool):
tool_id = "dummy"
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="dummy",
description="Dummy tool.",
parameters={"type": "object", "properties": {}},
)
def execute(self, **params) -> ToolResult:
return ToolResult(tool_name="dummy", content="ok", success=True)
# ---------------------------------------------------------------------------
# BaseAgent tests
# ---------------------------------------------------------------------------
class TestBaseAgentInit:
def test_stores_engine_and_model(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "test-model")
assert agent._engine is engine
assert agent._model == "test-model"
def test_default_params(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
assert agent._temperature == 0.7
assert agent._max_tokens == 1024
assert agent._bus is None
def test_custom_params(self):
bus = EventBus()
engine = MagicMock()
agent = _ConcreteAgent(
engine, "m", bus=bus, temperature=0.1, max_tokens=256,
)
assert agent._temperature == 0.1
assert agent._max_tokens == 256
assert agent._bus is bus
class TestAcceptsTools:
def test_base_agent_no_tools(self):
assert _ConcreteAgent.accepts_tools is False
def test_tool_using_agent_accepts_tools(self):
assert _ConcreteToolAgent.accepts_tools is True
class TestEmitTurnStart:
def test_with_bus(self):
bus = EventBus(record_history=True)
engine = MagicMock()
agent = _ConcreteAgent(engine, "m", bus=bus)
agent._emit_turn_start("hello")
events = [e for e in bus.history if e.event_type == EventType.AGENT_TURN_START]
assert len(events) == 1
assert events[0].data["agent"] == "test_agent"
assert events[0].data["input"] == "hello"
def test_without_bus(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
# Should not raise
agent._emit_turn_start("hello")
class TestEmitTurnEnd:
def test_with_bus(self):
bus = EventBus(record_history=True)
engine = MagicMock()
agent = _ConcreteAgent(engine, "m", bus=bus)
agent._emit_turn_end(turns=3, custom="val")
events = [e for e in bus.history if e.event_type == EventType.AGENT_TURN_END]
assert len(events) == 1
assert events[0].data["agent"] == "test_agent"
assert events[0].data["turns"] == 3
assert events[0].data["custom"] == "val"
def test_without_bus(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
agent._emit_turn_end(turns=1)
class TestBuildMessages:
def test_basic(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
messages = agent._build_messages("hello")
assert len(messages) == 1
assert messages[0].role == Role.USER
assert messages[0].content == "hello"
def test_with_system_prompt(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
messages = agent._build_messages("hello", system_prompt="Be helpful.")
assert len(messages) == 2
assert messages[0].role == Role.SYSTEM
assert messages[0].content == "Be helpful."
assert messages[1].role == Role.USER
def test_with_context(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
conv = Conversation()
conv.add(Message(role=Role.USER, content="prev"))
conv.add(Message(role=Role.ASSISTANT, content="reply"))
ctx = AgentContext(conversation=conv)
messages = agent._build_messages("new", ctx)
assert len(messages) == 3
assert messages[0].content == "prev"
assert messages[1].content == "reply"
assert messages[2].content == "new"
def test_with_system_prompt_and_context(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
conv = Conversation()
conv.add(Message(role=Role.USER, content="prev"))
ctx = AgentContext(conversation=conv)
messages = agent._build_messages(
"new", ctx, system_prompt="System.",
)
assert len(messages) == 3
assert messages[0].role == Role.SYSTEM
assert messages[1].content == "prev"
assert messages[2].content == "new"
class TestGenerate:
def test_delegates_to_engine(self):
engine = MagicMock()
engine.generate.return_value = {"content": "hi"}
agent = _ConcreteAgent(engine, "m", temperature=0.5, max_tokens=100)
result = agent._generate([Message(role=Role.USER, content="hi")])
assert result["content"] == "hi"
engine.generate.assert_called_once()
call_kwargs = engine.generate.call_args[1]
assert call_kwargs["model"] == "m"
assert call_kwargs["temperature"] == 0.5
assert call_kwargs["max_tokens"] == 100
def test_extra_kwargs(self):
engine = MagicMock()
engine.generate.return_value = {"content": "hi"}
agent = _ConcreteAgent(engine, "m")
agent._generate([Message(role=Role.USER, content="hi")], tools=["t"])
call_kwargs = engine.generate.call_args[1]
assert call_kwargs["tools"] == ["t"]
class TestMaxTurnsResult:
def test_default_message(self):
bus = EventBus(record_history=True)
engine = MagicMock()
agent = _ConcreteAgent(engine, "m", bus=bus)
tr = [ToolResult(tool_name="t", content="x", success=True)]
result = agent._max_turns_result(tr, turns=5)
assert result.metadata["max_turns_exceeded"] is True
assert result.turns == 5
assert "Maximum turns" in result.content
assert result.tool_results == tr
# Should also emit turn end
events = [e for e in bus.history if e.event_type == EventType.AGENT_TURN_END]
assert len(events) == 1
assert events[0].data["max_turns_exceeded"] is True
def test_custom_content(self):
engine = MagicMock()
agent = _ConcreteAgent(engine, "m")
result = agent._max_turns_result([], turns=3, content="custom msg")
assert result.content == "custom msg"
class TestStripThinkTags:
def test_full_think_block(self):
text = "<think>internal reasoning</think>Answer here."
assert BaseAgent._strip_think_tags(text) == "Answer here."
def test_bare_closing_tag(self):
text = "some reasoning</think>Answer here."
assert BaseAgent._strip_think_tags(text) == "Answer here."
def test_no_tags(self):
text = "Just normal text."
assert BaseAgent._strip_think_tags(text) == "Just normal text."
def test_multiline_think(self):
text = "<think>\nline1\nline2\n</think>\nFinal."
assert BaseAgent._strip_think_tags(text) == "Final."
def test_empty_after_strip(self):
text = "<think>all thinking</think>"
assert BaseAgent._strip_think_tags(text) == ""
# ---------------------------------------------------------------------------
# ToolUsingAgent tests
# ---------------------------------------------------------------------------
class TestToolUsingAgent:
def test_creates_executor(self):
engine = MagicMock()
agent = _ConcreteToolAgent(engine, "m", tools=[_DummyTool()])
assert agent._executor is not None
assert len(agent._tools) == 1
def test_default_max_turns(self):
engine = MagicMock()
agent = _ConcreteToolAgent(engine, "m")
assert agent._max_turns == 10
def test_custom_max_turns(self):
engine = MagicMock()
agent = _ConcreteToolAgent(engine, "m", max_turns=5)
assert agent._max_turns == 5
def test_empty_tools(self):
engine = MagicMock()
agent = _ConcreteToolAgent(engine, "m")
assert agent._tools == []
def test_inherits_base_helpers(self):
bus = EventBus(record_history=True)
engine = MagicMock()
agent = _ConcreteToolAgent(engine, "m", bus=bus)
agent._emit_turn_start("hi")
events = [e for e in bus.history if e.event_type == EventType.AGENT_TURN_START]
assert len(events) == 1
-28
View File
@@ -1,28 +0,0 @@
"""Tests for the CustomAgent stub."""
from __future__ import annotations
import pytest
from openjarvis.agents.custom import CustomAgent
class TestCustomAgent:
def test_agent_id(self):
agent = CustomAgent()
assert agent.agent_id == "custom"
def test_run_raises(self):
agent = CustomAgent()
with pytest.raises(NotImplementedError, match="template"):
agent.run("Hello")
def test_error_message_helpful(self):
agent = CustomAgent()
with pytest.raises(NotImplementedError, match="Subclass"):
agent.run("test")
def test_error_mentions_register(self):
agent = CustomAgent()
with pytest.raises(NotImplementedError, match="register"):
agent.run("test")
+527
View File
@@ -0,0 +1,527 @@
"""Tests for NativeOpenHandsAgent (formerly OpenHandsAgent)."""
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.agents._stubs import AgentContext
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
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 _CodeInterpreterStub(BaseTool):
"""Stub code_interpreter tool for testing."""
tool_id = "code_interpreter"
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="code_interpreter",
description="Execute Python code.",
parameters={
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
)
def execute(self, **params) -> ToolResult:
code = params.get("code", "")
# Simple simulation: if it contains print(), capture the content
if "print(" in code:
import re
match = re.search(r"print\((.+?)\)", code)
if match:
try:
val = eval(match.group(1)) # noqa: S307
return ToolResult(
tool_name="code_interpreter",
content=str(val),
success=True,
)
except Exception:
pass
return ToolResult(
tool_name="code_interpreter",
content=f"Executed: {code}",
success=True,
)
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)
def _engine_response(content, **extra):
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 TestNativeOpenHandsRegistration:
def test_registration(self):
AgentRegistry.register_value("native_openhands", NativeOpenHandsAgent)
assert AgentRegistry.contains("native_openhands")
def test_agent_id(self):
engine = MagicMock()
engine.engine_id = "mock"
agent = NativeOpenHandsAgent(engine, "test-model")
assert agent.agent_id == "native_openhands"
def test_accepts_tools(self):
assert NativeOpenHandsAgent.accepts_tools is True
# ---------------------------------------------------------------------------
# Agent execution tests
# ---------------------------------------------------------------------------
class TestNativeOpenHandsAgent:
def test_simple_response(self):
"""No code -> direct answer."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("The answer is 42.")
bus = EventBus(record_history=True)
agent = NativeOpenHandsAgent(engine, "test-model", bus=bus)
result = agent.run("What is the meaning of life?")
assert result.content == "The answer is 42."
assert result.turns == 1
assert result.tool_results == []
def test_code_generation_execution(self):
"""Turn 1: returns code block -> code_interpreter executed. Turn 2: final."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
"Let me calculate:\n```python\nprint(2+2)\n```"
),
_engine_response("The result is 4."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
result = agent.run("What is 2+2?")
assert result.content == "The result is 4."
assert result.turns == 2
assert len(result.tool_results) == 1
assert result.tool_results[0].tool_name == "code_interpreter"
def test_multi_step_code(self):
"""Multiple code blocks across turns."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("Step 1:\n```python\nprint(1+1)\n```"),
_engine_response("Step 2:\n```python\nprint(3*3)\n```"),
_engine_response("First was 2, second was 9."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
result = agent.run("Two calculations")
assert result.turns == 3
assert len(result.tool_results) == 2
assert result.content == "First was 2, second was 9."
def test_max_turns(self):
"""Engine keeps generating code -> hits max_turns."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"More code:\n```python\nprint('hello')\n```"
)
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
max_turns=3,
)
result = agent.run("Keep coding")
assert result.turns == 3
assert result.metadata.get("max_turns_exceeded") is True
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("Direct answer.")
agent = NativeOpenHandsAgent(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 execution should trigger TOOL_CALL_START/END events."""
bus = EventBus(record_history=True)
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("Code:\n```python\nprint(1)\n```"),
_engine_response("Done."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
bus=bus,
)
agent.run("Run code")
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("Hello!")
conv = Conversation()
conv.add(Message(role=Role.USER, content="Previous"))
conv.add(Message(role=Role.ASSISTANT, content="Previous response"))
ctx = AgentContext(conversation=conv)
agent = NativeOpenHandsAgent(engine, "test-model")
agent.run("Hello", context=ctx)
call_args = engine.generate.call_args
messages = call_args[0][0]
# System prompt + 2 context + user input
assert len(messages) == 4
assert messages[0].role == Role.SYSTEM
assert messages[1].content == "Previous"
assert messages[3].content == "Hello"
def test_tool_fallback(self):
"""Non-code tool use via Action: syntax."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'Action: calculator\nAction Input: {"expression": "7*6"}'
),
_engine_response("The answer is 42."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CalculatorStub()],
)
result = agent.run("What is 7 times 6?")
assert result.content == "The answer is 42."
assert result.turns == 2
assert len(result.tool_results) == 1
assert result.tool_results[0].tool_name == "calculator"
assert result.tool_results[0].content == "42"
def test_no_code_interpreter_tool(self):
"""Agent has code but no code_interpreter -> tool not found."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("```python\nprint(1)\n```"),
_engine_response("Could not run code."),
]
# No tools at all
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("Run code")
assert result.turns == 2
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_no_bus_works(self):
"""Agent runs correctly without an event bus."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Works!")
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("Hello")
assert result.content == "Works!"
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("Ok")
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub(), _CalculatorStub()],
)
agent.run("Hello")
call_args = engine.generate.call_args
messages = call_args[0][0]
system_msg = messages[0]
assert "code_interpreter" in system_msg.content
assert "calculator" in system_msg.content
def test_max_turns_content_preserved(self):
"""When max turns exceeded, last content should be preserved."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Still working:\n```python\nx = 1\n```"
)
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
max_turns=2,
)
result = agent.run("Loop")
assert result.metadata.get("max_turns_exceeded") is True
# Should have the last content, not the fallback message
assert "Still working" in result.content
def test_observation_appended_to_messages(self):
"""Code output is sent back to the engine as observation."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("```python\nprint(42)\n```"),
_engine_response("Got 42."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
agent.run("Print 42")
second_call = engine.generate.call_args_list[1]
messages = second_call[0][0]
last_msg = messages[-1]
assert last_msg.role == Role.USER
assert "Output:" in last_msg.content
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("Hi")
agent = NativeOpenHandsAgent(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_openhands"
assert start_events[0].data["input"] == "test input"
def test_empty_input(self):
"""Agent handles empty input."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Empty input received.")
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("")
assert result.content == "Empty input received."
def test_error_400_handling(self):
"""Agent catches 400 errors and returns friendly message."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = RuntimeError("HTTP 400 Bad Request")
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("Hello")
assert "too long" in result.content
assert result.metadata.get("error") is True
def test_xml_tool_call_extraction(self):
"""Agent parses XML-style tool calls."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'<tool_call>calculator\n$expression=7*6</calculator>'
),
_engine_response("The answer is 42."),
]
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[_CalculatorStub()],
)
result = agent.run("What is 7 times 6?")
assert result.content == "The answer is 42."
assert len(result.tool_results) == 1
assert result.tool_results[0].content == "42"
def test_observation_truncation(self):
"""Long tool results are truncated in observations."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'Action: calculator\nAction Input: {"expression": "1+1"}'
),
_engine_response("Done."),
]
# Make calculator return very long output
long_calc = _CalculatorStub()
orig_execute = long_calc.execute
def _long_execute(**params):
r = orig_execute(**params)
return ToolResult(
tool_name=r.tool_name,
content="x" * 10000,
success=True,
)
long_calc.execute = _long_execute
agent = NativeOpenHandsAgent(engine, "test-model", tools=[long_calc])
agent.run("Compute")
# Check the observation message sent to the engine
second_call = engine.generate.call_args_list[1]
messages = second_call[0][0]
last_msg = messages[-1]
assert len(last_msg.content) < 5000
assert "[Output truncated]" in last_msg.content
# ---------------------------------------------------------------------------
# Truncation tests
# ---------------------------------------------------------------------------
class TestTruncation:
def test_short_messages_unchanged(self):
"""Messages under limit are not modified."""
engine = MagicMock()
engine.engine_id = "mock"
agent = NativeOpenHandsAgent(engine, "test-model")
messages = [
Message(role=Role.SYSTEM, content="System prompt"),
Message(role=Role.USER, content="Short query"),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert result[1].content == "Short query"
def test_long_messages_truncated(self):
"""Messages over limit get the last user message truncated."""
engine = MagicMock()
engine.engine_id = "mock"
agent = NativeOpenHandsAgent(engine, "test-model")
messages = [
Message(role=Role.SYSTEM, content="System prompt"),
Message(role=Role.USER, content="x" * 20000),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert len(result[1].content) < 20000
assert "[Input truncated to fit context window]" in result[1].content
def test_truncation_preserves_system_prompt(self):
"""Truncation only modifies user message, not system prompt."""
engine = MagicMock()
engine.engine_id = "mock"
agent = NativeOpenHandsAgent(engine, "test-model")
system = "Important system prompt"
messages = [
Message(role=Role.SYSTEM, content=system),
Message(role=Role.USER, content="y" * 20000),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert result[0].content == system
# ---------------------------------------------------------------------------
# URL expansion tests
# ---------------------------------------------------------------------------
class TestUrlExpansion:
def test_no_url_returns_false(self):
text, expanded = NativeOpenHandsAgent._expand_urls("What is 2+2?")
assert text == "What is 2+2?"
assert expanded is False
def test_url_detected_returns_true(self, monkeypatch):
import httpx
mock_resp = MagicMock()
mock_resp.text = "<html><body>Page content</body></html>"
mock_resp.headers = {"content-type": "text/html"}
mock_resp.raise_for_status = MagicMock()
monkeypatch.setattr(httpx, "get", MagicMock(return_value=mock_resp))
text, expanded = NativeOpenHandsAgent._expand_urls(
"Summarize: https://example.com/article"
)
assert expanded is True
assert "Page content" in text
assert "Content from" in text
def test_url_expansion_failure_returns_false(self, monkeypatch):
import httpx
monkeypatch.setattr(
httpx, "get",
MagicMock(side_effect=Exception("Connection error")),
)
text, expanded = NativeOpenHandsAgent._expand_urls(
"Read https://example.com/broken"
)
assert expanded is False
def test_url_expanded_uses_direct_path(self, monkeypatch):
"""When URL is expanded, agent bypasses tool loop."""
import httpx
mock_resp = MagicMock()
mock_resp.text = "<html><body>Article text here</body></html>"
mock_resp.headers = {"content-type": "text/html"}
mock_resp.raise_for_status = MagicMock()
monkeypatch.setattr(httpx, "get", MagicMock(return_value=mock_resp))
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Summary of article.")
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("Summarize: https://example.com/article")
assert result.content == "Summary of article."
assert result.turns == 1
# Only one generate call (direct, no tool loop)
assert engine.generate.call_count == 1
# The message should contain the fetched content, not tool descriptions
call_messages = engine.generate.call_args[0][0]
system_msg = call_messages[0].content
assert "tool" not in system_msg.lower()
+477
View File
@@ -0,0 +1,477 @@
"""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"] == ""
# ---------------------------------------------------------------------------
# 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\n'
'Action Input: {}'
),
_engine_response(
"Thought: Error occurred.\n"
"Final 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 'none' when no tools are available."""
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 "none" 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\n'
'Action 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"
@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
+26 -497
View File
@@ -1,103 +1,18 @@
"""Tests for OpenHands agent."""
"""Tests for OpenHandsAgent (real openhands-sdk wrapper)."""
from __future__ import annotations
from unittest.mock import MagicMock
from openjarvis.agents._stubs import AgentContext
import pytest
from openjarvis.agents._stubs import BaseAgent
from openjarvis.agents.openhands import OpenHandsAgent
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 _CodeInterpreterStub(BaseTool):
"""Stub code_interpreter tool for testing."""
tool_id = "code_interpreter"
@property
def spec(self) -> ToolSpec:
return ToolSpec(
name="code_interpreter",
description="Execute Python code.",
parameters={
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
)
def execute(self, **params) -> ToolResult:
code = params.get("code", "")
# Simple simulation: if it contains print(), capture the content
if "print(" in code:
import re
match = re.search(r"print\((.+?)\)", code)
if match:
try:
val = eval(match.group(1)) # noqa: S307
return ToolResult(
tool_name="code_interpreter",
content=str(val),
success=True,
)
except Exception:
pass
return ToolResult(
tool_name="code_interpreter",
content=f"Executed: {code}",
success=True,
)
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)
def _engine_response(content, **extra):
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 TestOpenHandsRegistration:
def test_registration(self):
class TestOpenHandsAgentRegistration:
def test_registered(self):
AgentRegistry.register_value("openhands", OpenHandsAgent)
assert AgentRegistry.contains("openhands")
@@ -107,422 +22,36 @@ class TestOpenHandsRegistration:
agent = OpenHandsAgent(engine, "test-model")
assert agent.agent_id == "openhands"
def test_does_not_accept_tools(self):
"""Real OpenHandsAgent doesn't use ToolUsingAgent base."""
assert OpenHandsAgent.accepts_tools is False
# ---------------------------------------------------------------------------
# Agent execution tests
# ---------------------------------------------------------------------------
def test_is_base_agent(self):
assert issubclass(OpenHandsAgent, BaseAgent)
class TestOpenHandsAgent:
def test_simple_response(self):
"""No code -> direct answer."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("The answer is 42.")
bus = EventBus(record_history=True)
agent = OpenHandsAgent(engine, "test-model", bus=bus)
result = agent.run("What is the meaning of life?")
assert result.content == "The answer is 42."
assert result.turns == 1
assert result.tool_results == []
def test_code_generation_execution(self):
"""Turn 1: returns code block -> code_interpreter executed. Turn 2: final."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
"Let me calculate:\n```python\nprint(2+2)\n```"
),
_engine_response("The result is 4."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
result = agent.run("What is 2+2?")
assert result.content == "The result is 4."
assert result.turns == 2
assert len(result.tool_results) == 1
assert result.tool_results[0].tool_name == "code_interpreter"
def test_multi_step_code(self):
"""Multiple code blocks across turns."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("Step 1:\n```python\nprint(1+1)\n```"),
_engine_response("Step 2:\n```python\nprint(3*3)\n```"),
_engine_response("First was 2, second was 9."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
result = agent.run("Two calculations")
assert result.turns == 3
assert len(result.tool_results) == 2
assert result.content == "First was 2, second was 9."
def test_max_turns(self):
"""Engine keeps generating code -> hits max_turns."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"More code:\n```python\nprint('hello')\n```"
)
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
max_turns=3,
)
result = agent.run("Keep coding")
assert result.turns == 3
assert result.metadata.get("max_turns_exceeded") is True
def test_event_bus_emissions(self):
"""Verify AGENT_TURN_START and AGENT_TURN_END events.
INFERENCE_START/END are now published by InstrumentedEngine,
not by agents directly.
"""
bus = EventBus(record_history=True)
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Direct answer.")
agent = OpenHandsAgent(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 execution should trigger TOOL_CALL_START/END events."""
bus = EventBus(record_history=True)
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("Code:\n```python\nprint(1)\n```"),
_engine_response("Done."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
bus=bus,
)
agent.run("Run code")
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("Hello!")
conv = Conversation()
conv.add(Message(role=Role.USER, content="Previous"))
conv.add(Message(role=Role.ASSISTANT, content="Previous response"))
ctx = AgentContext(conversation=conv)
agent = OpenHandsAgent(engine, "test-model")
agent.run("Hello", context=ctx)
call_args = engine.generate.call_args
messages = call_args[0][0]
# System prompt + 2 context + user input
assert len(messages) == 4
assert messages[0].role == Role.SYSTEM
assert messages[1].content == "Previous"
assert messages[3].content == "Hello"
def test_tool_fallback(self):
"""Non-code tool use via Action: syntax."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'Action: calculator\nAction Input: {"expression": "7*6"}'
),
_engine_response("The answer is 42."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CalculatorStub()],
)
result = agent.run("What is 7 times 6?")
assert result.content == "The answer is 42."
assert result.turns == 2
assert len(result.tool_results) == 1
assert result.tool_results[0].tool_name == "calculator"
assert result.tool_results[0].content == "42"
def test_no_code_interpreter_tool(self):
"""Agent has code but no code_interpreter -> tool not found."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("```python\nprint(1)\n```"),
_engine_response("Could not run code."),
]
# No tools at all
agent = OpenHandsAgent(engine, "test-model")
result = agent.run("Run code")
assert result.turns == 2
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_no_bus_works(self):
"""Agent runs correctly without an event bus."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Works!")
agent = OpenHandsAgent(engine, "test-model")
result = agent.run("Hello")
assert result.content == "Works!"
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("Ok")
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub(), _CalculatorStub()],
)
agent.run("Hello")
call_args = engine.generate.call_args
messages = call_args[0][0]
system_msg = messages[0]
assert "code_interpreter" in system_msg.content
assert "calculator" in system_msg.content
def test_max_turns_content_preserved(self):
"""When max turns exceeded, last content should be preserved."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Still working:\n```python\nx = 1\n```"
)
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
max_turns=2,
)
result = agent.run("Loop")
assert result.metadata.get("max_turns_exceeded") is True
# Should have the last content, not the fallback message
assert "Still working" in result.content
def test_observation_appended_to_messages(self):
"""Code output is sent back to the engine as observation."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response("```python\nprint(42)\n```"),
_engine_response("Got 42."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CodeInterpreterStub()],
)
agent.run("Print 42")
second_call = engine.generate.call_args_list[1]
messages = second_call[0][0]
last_msg = messages[-1]
assert last_msg.role == Role.USER
assert "Output:" in last_msg.content
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("Hi")
agent = OpenHandsAgent(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"] == "openhands"
assert start_events[0].data["input"] == "test input"
def test_empty_input(self):
"""Agent handles empty input."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Empty input received.")
agent = OpenHandsAgent(engine, "test-model")
result = agent.run("")
assert result.content == "Empty input received."
def test_error_400_handling(self):
"""Agent catches 400 errors and returns friendly message."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = RuntimeError("HTTP 400 Bad Request")
agent = OpenHandsAgent(engine, "test-model")
result = agent.run("Hello")
assert "too long" in result.content
assert result.metadata.get("error") is True
def test_xml_tool_call_extraction(self):
"""Agent parses XML-style tool calls."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'<tool_call>calculator\n$expression=7*6</calculator>'
),
_engine_response("The answer is 42."),
]
agent = OpenHandsAgent(
engine, "test-model",
tools=[_CalculatorStub()],
)
result = agent.run("What is 7 times 6?")
assert result.content == "The answer is 42."
assert len(result.tool_results) == 1
assert result.tool_results[0].content == "42"
def test_observation_truncation(self):
"""Long tool results are truncated in observations."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.side_effect = [
_engine_response(
'Action: calculator\nAction Input: {"expression": "1+1"}'
),
_engine_response("Done."),
]
# Make calculator return very long output
long_calc = _CalculatorStub()
orig_execute = long_calc.execute
def _long_execute(**params):
r = orig_execute(**params)
return ToolResult(
tool_name=r.tool_name,
content="x" * 10000,
success=True,
)
long_calc.execute = _long_execute
agent = OpenHandsAgent(engine, "test-model", tools=[long_calc])
agent.run("Compute")
# Check the observation message sent to the engine
second_call = engine.generate.call_args_list[1]
messages = second_call[0][0]
last_msg = messages[-1]
assert len(last_msg.content) < 5000
assert "[Output truncated]" in last_msg.content
# ---------------------------------------------------------------------------
# Truncation tests
# ---------------------------------------------------------------------------
class TestTruncation:
def test_short_messages_unchanged(self):
"""Messages under limit are not modified."""
class TestOpenHandsAgentImportError:
def test_run_without_sdk_raises(self):
"""Running without openhands-sdk installed raises ImportError."""
engine = MagicMock()
engine.engine_id = "mock"
agent = OpenHandsAgent(engine, "test-model")
messages = [
Message(role=Role.SYSTEM, content="System prompt"),
Message(role=Role.USER, content="Short query"),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert result[1].content == "Short query"
with pytest.raises(ImportError, match="openhands-sdk"):
agent.run("Hello")
def test_long_messages_truncated(self):
"""Messages over limit get the last user message truncated."""
class TestOpenHandsAgentConstructor:
def test_default_workspace(self):
engine = MagicMock()
engine.engine_id = "mock"
agent = OpenHandsAgent(engine, "test-model")
messages = [
Message(role=Role.SYSTEM, content="System prompt"),
Message(role=Role.USER, content="x" * 20000),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert len(result[1].content) < 20000
assert "[Input truncated to fit context window]" in result[1].content
assert agent._workspace # should be cwd
def test_truncation_preserves_system_prompt(self):
"""Truncation only modifies user message, not system prompt."""
def test_custom_workspace(self):
engine = MagicMock()
engine.engine_id = "mock"
agent = OpenHandsAgent(engine, "test-model")
system = "Important system prompt"
messages = [
Message(role=Role.SYSTEM, content=system),
Message(role=Role.USER, content="y" * 20000),
]
result = agent._truncate_if_needed(messages, max_prompt_tokens=1000)
assert result[0].content == system
# ---------------------------------------------------------------------------
# URL expansion tests
# ---------------------------------------------------------------------------
class TestUrlExpansion:
def test_no_url_returns_false(self):
text, expanded = OpenHandsAgent._expand_urls("What is 2+2?")
assert text == "What is 2+2?"
assert expanded is False
def test_url_detected_returns_true(self, monkeypatch):
import httpx
mock_resp = MagicMock()
mock_resp.text = "<html><body>Page content</body></html>"
mock_resp.headers = {"content-type": "text/html"}
mock_resp.raise_for_status = MagicMock()
monkeypatch.setattr(httpx, "get", MagicMock(return_value=mock_resp))
text, expanded = OpenHandsAgent._expand_urls(
"Summarize: https://example.com/article"
)
assert expanded is True
assert "Page content" in text
assert "Content from" in text
def test_url_expansion_failure_returns_false(self, monkeypatch):
import httpx
monkeypatch.setattr(
httpx, "get",
MagicMock(side_effect=Exception("Connection error")),
)
text, expanded = OpenHandsAgent._expand_urls(
"Read https://example.com/broken"
)
assert expanded is False
def test_url_expanded_uses_direct_path(self, monkeypatch):
"""When URL is expanded, agent bypasses tool loop."""
import httpx
mock_resp = MagicMock()
mock_resp.text = "<html><body>Article text here</body></html>"
mock_resp.headers = {"content-type": "text/html"}
mock_resp.raise_for_status = MagicMock()
monkeypatch.setattr(httpx, "get", MagicMock(return_value=mock_resp))
agent = OpenHandsAgent(engine, "test-model", workspace="/tmp/test")
assert agent._workspace == "/tmp/test"
def test_custom_api_key(self):
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response("Summary of article.")
agent = OpenHandsAgent(engine, "test-model")
result = agent.run("Summarize: https://example.com/article")
assert result.content == "Summary of article."
assert result.turns == 1
# Only one generate call (direct, no tool loop)
assert engine.generate.call_count == 1
# The message should contain the fetched content, not tool descriptions
call_messages = engine.generate.call_args[0][0]
system_msg = call_messages[0].content
assert "tool" not in system_msg.lower()
agent = OpenHandsAgent(engine, "test-model", api_key="sk-test")
assert agent._api_key == "sk-test"
+18 -452
View File
@@ -1,478 +1,44 @@
"""Tests for ReAct agent."""
"""Backward-compat tests: ensure old import paths still work.
The canonical tests are in test_native_react.py. This file verifies
that ``from openjarvis.agents.react import ReActAgent`` still works
and produces a working agent.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from openjarvis.agents._stubs import AgentContext
from openjarvis.agents.react import ReActAgent
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
# ---------------------------------------------------------------------------
from openjarvis.agents.native_react import NativeReActAgent
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 = {
def _engine_response(content):
return {
"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 TestReActShim:
def test_is_native_react(self):
"""ReActAgent imported from old path is NativeReActAgent."""
assert ReActAgent is NativeReActAgent
class TestReActRegistration:
def test_registration(self):
AgentRegistry.register_value("react", ReActAgent)
assert AgentRegistry.contains("react")
def test_agent_id(self):
def test_can_instantiate(self):
engine = MagicMock()
engine.engine_id = "mock"
agent = ReActAgent(engine, "test-model")
assert agent.agent_id == "react"
assert agent.agent_id == "native_react"
# ---------------------------------------------------------------------------
# Parsing tests
# ---------------------------------------------------------------------------
class TestReActParsing:
def _parser(self):
engine = MagicMock()
engine.engine_id = "mock"
agent = ReActAgent(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"] == ""
# ---------------------------------------------------------------------------
# Agent execution tests
# ---------------------------------------------------------------------------
class TestReActAgent:
def test_simple_no_tool_response(self):
"""Engine returns Final Answer on first call."""
def test_can_run(self):
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Thought: Simple greeting.\nFinal Answer: Hello!"
"Thought: Simple.\nFinal Answer: Hello!"
)
bus = EventBus(record_history=True)
agent = ReActAgent(engine, "test-model", bus=bus)
agent = ReActAgent(engine, "test-model")
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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(
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 = ReActAgent(
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\n'
'Action Input: {}'
),
_engine_response(
"Thought: Error occurred.\n"
"Final Answer: Could not run tool."
),
]
agent = ReActAgent(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.
INFERENCE_START/END are now published by InstrumentedEngine,
not by agents directly.
"""
bus = EventBus(record_history=True)
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Thought: Quick.\nFinal Answer: Done."
)
agent = ReActAgent(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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(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 = ReActAgent(
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 'none' when no tools are available."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Thought: No tools.\nFinal Answer: ok"
)
agent = ReActAgent(engine, "test-model")
agent.run("Hello")
call_args = engine.generate.call_args
messages = call_args[0][0]
assert "none" 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\n'
'Action Input: {"expression": "1"}'
)
agent = ReActAgent(
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 = ReActAgent(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"] == "react"
assert start_events[0].data["input"] == "test input"
@pytest.mark.parametrize("model", ["qwen3:8b", "gpt-oss:120b"])
def test_react_with_different_models(model):
"""ReAct agent works with different model names."""
engine = MagicMock()
engine.engine_id = "mock"
engine.generate.return_value = _engine_response(
"Thought: Responding.\nFinal Answer: Hello!"
)
agent = ReActAgent(engine, model)
result = agent.run("Hello")
assert result.content == "Hello!"
call_kwargs = engine.generate.call_args[1]
assert call_kwargs["model"] == model
-2
View File
@@ -30,7 +30,6 @@ def _mock_engine(content="Hello from engine"):
def _register_agents():
"""Re-register agents after registry clear."""
from openjarvis.agents.custom import CustomAgent
from openjarvis.agents.orchestrator import OrchestratorAgent
from openjarvis.agents.simple import SimpleAgent
from openjarvis.core.registry import AgentRegistry
@@ -38,7 +37,6 @@ def _register_agents():
for name, cls in [
("simple", SimpleAgent),
("orchestrator", OrchestratorAgent),
("custom", CustomAgent),
]:
if not AgentRegistry.contains(name):
AgentRegistry.register_value(name, cls)
+28 -28
View File
@@ -22,14 +22,14 @@ from openjarvis.core.types import (
def _register_all():
"""Ensure agents and tools are registered."""
from openjarvis.agents.openhands import OpenHandsAgent
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.tools.calculator import CalculatorTool
from openjarvis.tools.think import ThinkTool
for key, cls in [
("react", ReActAgent),
("openhands", OpenHandsAgent),
("native_react", NativeReActAgent),
("native_openhands", NativeOpenHandsAgent),
]:
if not AgentRegistry.contains(key):
AgentRegistry.register_value(key, cls)
@@ -78,7 +78,7 @@ class TestReActPipeline:
def test_react_with_calculator_e2e(self):
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.tools.calculator import CalculatorTool
responses = [
@@ -94,7 +94,7 @@ class TestReActPipeline:
]
engine = _make_engine(responses)
bus = EventBus(record_history=True)
agent = ReActAgent(
agent = NativeReActAgent(
engine, "test-model",
tools=[CalculatorTool()], bus=bus,
)
@@ -108,7 +108,7 @@ class TestReActPipeline:
def test_react_with_think_tool(self):
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.tools.think import ThinkTool
responses = [
@@ -123,7 +123,7 @@ class TestReActPipeline:
),
]
engine = _make_engine(responses)
agent = ReActAgent(
agent = NativeReActAgent(
engine, "test-model", tools=[ThinkTool()],
)
result = agent.run("Analyze this.")
@@ -133,7 +133,7 @@ class TestReActPipeline:
def test_react_direct_answer(self):
"""ReAct returns immediately when no tool use is needed."""
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
engine = _make_engine(
_simple_response(
@@ -141,7 +141,7 @@ class TestReActPipeline:
"Final Answer: Hello!"
)
)
agent = ReActAgent(engine, "test-model")
agent = NativeReActAgent(engine, "test-model")
result = agent.run("Say hello")
assert result.content == "Hello!"
assert result.turns == 1
@@ -153,7 +153,7 @@ class TestReActPipeline:
not by agents directly.
"""
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
engine = _make_engine(
_simple_response(
@@ -161,7 +161,7 @@ class TestReActPipeline:
)
)
bus = EventBus(record_history=True)
agent = ReActAgent(
agent = NativeReActAgent(
engine, "test-model", bus=bus,
)
agent.run("Test")
@@ -181,7 +181,7 @@ class TestOpenHandsPipeline:
def test_openhands_code_execution_e2e(self):
_register_all()
from openjarvis.agents.openhands import OpenHandsAgent
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
from openjarvis.tools.code_interpreter import (
CodeInterpreterTool,
)
@@ -199,7 +199,7 @@ class TestOpenHandsPipeline:
_simple_response("The result is 4."),
]
engine = _make_engine(responses)
agent = OpenHandsAgent(
agent = NativeOpenHandsAgent(
engine, "test-model",
tools=[CodeInterpreterTool()],
)
@@ -214,12 +214,12 @@ class TestOpenHandsPipeline:
def test_openhands_direct_answer(self):
"""OpenHands returns directly when no code is needed."""
_register_all()
from openjarvis.agents.openhands import OpenHandsAgent
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
engine = _make_engine(
_simple_response("Hello! How can I help?")
)
agent = OpenHandsAgent(engine, "test-model")
agent = NativeOpenHandsAgent(engine, "test-model")
result = agent.run("Say hello")
assert result.content == "Hello! How can I help?"
assert result.turns == 1
@@ -227,13 +227,13 @@ class TestOpenHandsPipeline:
def test_openhands_event_chain(self):
"""Verify event chain through OpenHands run."""
_register_all()
from openjarvis.agents.openhands import OpenHandsAgent
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
engine = _make_engine(
_simple_response("Direct answer.")
)
bus = EventBus(record_history=True)
agent = OpenHandsAgent(
agent = NativeOpenHandsAgent(
engine, "test-model", bus=bus,
)
agent.run("Test")
@@ -344,7 +344,7 @@ class TestCrossEngineConsistency:
def test_same_query_same_format(self):
"""All engines return the same result dict shape."""
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
for engine_name in ["vllm", "ollama", "mock"]:
engine = _make_engine(
@@ -354,7 +354,7 @@ class TestCrossEngineConsistency:
)
)
engine.engine_id = engine_name
agent = ReActAgent(engine, "test-model")
agent = NativeReActAgent(engine, "test-model")
result = agent.run("Test query")
assert isinstance(result, AgentResult)
assert result.content == "Result"
@@ -362,7 +362,7 @@ class TestCrossEngineConsistency:
def test_tool_calls_across_engines(self):
"""Tool calling works regardless of engine mock."""
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
from openjarvis.tools.calculator import CalculatorTool
for engine_name in ["vllm", "ollama"]:
@@ -379,7 +379,7 @@ class TestCrossEngineConsistency:
]
engine = _make_engine(responses)
engine.engine_id = engine_name
agent = ReActAgent(
agent = NativeReActAgent(
engine, "test-model",
tools=[CalculatorTool()],
)
@@ -496,7 +496,7 @@ class TestAgentRoutingMatrix:
"""Agents run consistently across different configurations."""
@pytest.mark.parametrize(
"agent_key", ["react", "openhands"],
"agent_key", ["native_react", "native_openhands"],
)
def test_agent_returns_valid_result(self, agent_key):
_register_all()
@@ -504,7 +504,7 @@ class TestAgentRoutingMatrix:
engine = _make_engine(
_simple_response(
"Thought: done.\nFinal Answer: ok"
if agent_key == "react"
if agent_key == "native_react"
else "The answer is ok"
)
)
@@ -517,7 +517,7 @@ class TestAgentRoutingMatrix:
assert len(result.content) > 0
@pytest.mark.parametrize(
"agent_key", ["react", "openhands"],
"agent_key", ["native_react", "native_openhands"],
)
def test_agent_emits_events(self, agent_key):
_register_all()
@@ -525,7 +525,7 @@ class TestAgentRoutingMatrix:
engine = _make_engine(
_simple_response(
"Thought: done.\nFinal Answer: ok"
if agent_key == "react"
if agent_key == "native_react"
else "Direct answer"
)
)
@@ -541,7 +541,7 @@ class TestAgentRoutingMatrix:
def test_context_passing(self):
"""Agents accept and use AgentContext."""
_register_all()
from openjarvis.agents.react import ReActAgent
from openjarvis.agents.native_react import NativeReActAgent
engine = _make_engine(
_simple_response(
@@ -555,7 +555,7 @@ class TestAgentRoutingMatrix:
content="You are helpful.",
))
ctx = AgentContext(conversation=conv)
agent = ReActAgent(engine, "test-model")
agent = NativeReActAgent(engine, "test-model")
result = agent.run("Hello", context=ctx)
assert result.content == "Got context."
Generated
+997 -2
View File
File diff suppressed because it is too large Load Diff