mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-30 10:52:15 +00:00
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:
co-authored by
Claude Opus 4.6
parent
323d7ff032
commit
68091dd90b
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user