mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(MCP, microagent): MCP-support for Repo Microagent & add fetch as default tool (#8360)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
15
microagents/default-tools.md
Normal file
15
microagents/default-tools.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# This is a repo microagent that is always activated
|
||||
# to include necessary default tools implemented with MCP
|
||||
name: default-tools
|
||||
type: repo
|
||||
version: 1.0.0
|
||||
agent: CodeActAgent
|
||||
mcp_tools:
|
||||
stdio_servers:
|
||||
- name: "fetch"
|
||||
command: "uvx"
|
||||
args: ["mcp-server-fetch"]
|
||||
# We leave the body empty because MCP tools will automatically add the
|
||||
# tool description for LLMs in tool calls, so there's no need to add extra descriptions.
|
||||
---
|
||||
@@ -20,7 +20,6 @@ from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
|
||||
create_str_replace_editor_tool,
|
||||
)
|
||||
from openhands.agenthub.codeact_agent.tools.think import ThinkTool
|
||||
from openhands.agenthub.codeact_agent.tools.web_read import WebReadTool
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
@@ -123,7 +122,6 @@ class CodeActAgent(Agent):
|
||||
if sys.platform == 'win32':
|
||||
logger.warning('Windows runtime does not support browsing yet')
|
||||
else:
|
||||
tools.append(WebReadTool)
|
||||
tools.append(BrowserTool)
|
||||
if self.config.enable_jupyter:
|
||||
tools.append(IPythonTool)
|
||||
|
||||
@@ -15,7 +15,6 @@ from openhands.agenthub.codeact_agent.tools import (
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
ThinkTool,
|
||||
WebReadTool,
|
||||
create_cmd_run_tool,
|
||||
create_str_replace_editor_tool,
|
||||
)
|
||||
@@ -212,16 +211,6 @@ def response_to_actions(
|
||||
)
|
||||
action = BrowseInteractiveAction(browser_actions=arguments['code'])
|
||||
|
||||
# ================================================
|
||||
# WebReadTool (simplified browsing)
|
||||
# ================================================
|
||||
elif tool_call.function.name == WebReadTool['function']['name']:
|
||||
if 'url' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
|
||||
# ================================================
|
||||
# MCPAction (MCP)
|
||||
# ================================================
|
||||
|
||||
@@ -5,7 +5,6 @@ from .ipython import IPythonTool
|
||||
from .llm_based_edit import LLMBasedFileEditTool
|
||||
from .str_replace_editor import create_str_replace_editor_tool
|
||||
from .think import ThinkTool
|
||||
from .web_read import WebReadTool
|
||||
|
||||
__all__ = [
|
||||
'BrowserTool',
|
||||
@@ -14,6 +13,5 @@ __all__ = [
|
||||
'IPythonTool',
|
||||
'LLMBasedFileEditTool',
|
||||
'create_str_replace_editor_tool',
|
||||
'WebReadTool',
|
||||
'ThinkTool',
|
||||
]
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
|
||||
_WEB_DESCRIPTION = """Read (convert to markdown) content from a webpage. You should prefer using the `web_read` tool over the `browser` tool, but do use the `browser` tool if you need to interact with a webpage (e.g., click a button, fill out a form, etc.) OR read a webpage that contains images.
|
||||
|
||||
You may use the `web_read` tool to read text content from a webpage, and even search the webpage content using a Google search query (e.g., url=`https://www.google.com/search?q=YOUR_QUERY`).
|
||||
|
||||
Only the most recently read webpage will be available to read. This means you should not follow a link to a new page until you are done with the information on the current page.
|
||||
"""
|
||||
|
||||
WebReadTool = ChatCompletionToolParam(
|
||||
type='function',
|
||||
function=ChatCompletionToolParamFunctionChunk(
|
||||
name='web_read',
|
||||
description=_WEB_DESCRIPTION,
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'description': 'The URL of the webpage to read. You can also use a Google search query here (e.g., `https://www.google.com/search?q=YOUR_QUERY`).',
|
||||
}
|
||||
},
|
||||
'required': ['url'],
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -17,7 +17,6 @@ from openhands.agenthub.codeact_agent.function_calling import (
|
||||
from openhands.agenthub.codeact_agent.tools import (
|
||||
FinishTool,
|
||||
ThinkTool,
|
||||
WebReadTool,
|
||||
)
|
||||
from openhands.agenthub.readonly_agent.tools import (
|
||||
GlobTool,
|
||||
@@ -191,16 +190,6 @@ def response_to_actions(
|
||||
glob_cmd = glob_to_cmdrun(pattern, path)
|
||||
action = CmdRunAction(command=glob_cmd, is_input=False)
|
||||
|
||||
# ================================================
|
||||
# WebReadTool (simplified browsing)
|
||||
# ================================================
|
||||
elif tool_call.function.name == WebReadTool['function']['name']:
|
||||
if 'url' not in arguments:
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "url" in tool call {tool_call.function.name}'
|
||||
)
|
||||
action = BrowseURLAction(url=arguments['url'])
|
||||
|
||||
# ================================================
|
||||
# MCPAction (MCP)
|
||||
# ================================================
|
||||
@@ -249,7 +238,6 @@ def get_tools() -> list[ChatCompletionToolParam]:
|
||||
return [
|
||||
ThinkTool,
|
||||
FinishTool,
|
||||
WebReadTool,
|
||||
GrepTool,
|
||||
GlobTool,
|
||||
ViewTool,
|
||||
|
||||
@@ -232,7 +232,6 @@ async def run_session(
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
|
||||
|
||||
await runtime.connect()
|
||||
await add_mcp_tools_to_agent(agent, runtime, config.mcp)
|
||||
|
||||
# Initialize repository if needed
|
||||
repo_directory = None
|
||||
@@ -251,6 +250,9 @@ async def run_session(
|
||||
repo_directory=repo_directory,
|
||||
)
|
||||
|
||||
# Add MCP tools to the agent
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory, config.mcp)
|
||||
|
||||
# Clear loading animation
|
||||
is_loaded.set()
|
||||
|
||||
|
||||
@@ -116,8 +116,6 @@ async def run_controller(
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
)
|
||||
|
||||
await add_mcp_tools_to_agent(agent, runtime, config.mcp)
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
# when memory is created, it will load the microagents from the selected repository
|
||||
@@ -130,6 +128,9 @@ async def run_controller(
|
||||
repo_directory=repo_directory,
|
||||
)
|
||||
|
||||
# Add MCP tools to the agent
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory, config.mcp)
|
||||
|
||||
replay_events: list[Event] | None = None
|
||||
if config.replay_trajectory_path:
|
||||
logger.info('Trajectory replay is enabled')
|
||||
|
||||
@@ -10,6 +10,7 @@ from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
@@ -149,7 +150,7 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse
|
||||
|
||||
|
||||
async def add_mcp_tools_to_agent(
|
||||
agent: 'Agent', runtime: Runtime, mcp_config: MCPConfig
|
||||
agent: 'Agent', runtime: Runtime, memory: 'Memory', mcp_config: MCPConfig
|
||||
):
|
||||
"""
|
||||
Add MCP tools to an agent.
|
||||
@@ -165,8 +166,25 @@ async def add_mcp_tools_to_agent(
|
||||
'Runtime must be initialized before adding MCP tools'
|
||||
)
|
||||
|
||||
# Add microagent MCP tools if available
|
||||
microagent_mcp_configs = memory.get_microagent_mcp_tools()
|
||||
extra_stdio_servers = []
|
||||
for mcp_config in microagent_mcp_configs:
|
||||
if mcp_config.sse_servers:
|
||||
logger.warning(
|
||||
'Microagent MCP config contains SSE servers, it is not yet supported.'
|
||||
)
|
||||
|
||||
if mcp_config.stdio_servers:
|
||||
for stdio_server in mcp_config.stdio_servers:
|
||||
# Check if this stdio server is already in the config
|
||||
if stdio_server not in extra_stdio_servers:
|
||||
extra_stdio_servers.append(stdio_server)
|
||||
logger.info(f'Added microagent stdio server: {stdio_server.name}')
|
||||
|
||||
# Add the runtime as another MCP server
|
||||
updated_mcp_config = runtime.get_updated_mcp_config()
|
||||
updated_mcp_config = runtime.get_updated_mcp_config(extra_stdio_servers)
|
||||
|
||||
# Fetch the MCP tools
|
||||
mcp_tools = await fetch_mcp_tools_from_config(updated_mcp_config)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
import openhands
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import RecallAction
|
||||
from openhands.events.event import Event, EventSource, RecallType
|
||||
@@ -262,6 +263,25 @@ class Memory:
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
self.repo_microagents[name] = agent
|
||||
|
||||
def get_microagent_mcp_tools(self) -> list[MCPConfig]:
|
||||
"""
|
||||
Get MCP tools from all repo microagents (always active)
|
||||
|
||||
Returns:
|
||||
A list of MCP tools configurations from microagents
|
||||
"""
|
||||
mcp_configs: list[MCPConfig] = []
|
||||
|
||||
# Check all repo microagents for MCP tools (always active)
|
||||
for agent in self.repo_microagents.values():
|
||||
if agent.metadata.mcp_tools:
|
||||
mcp_configs.append(agent.metadata.mcp_tools)
|
||||
logger.debug(
|
||||
f'Found MCP tools in repo microagent {agent.name}: {agent.metadata.mcp_tools}'
|
||||
)
|
||||
|
||||
return mcp_configs
|
||||
|
||||
def set_repository_info(self, repo_name: str, repo_directory: str) -> None:
|
||||
"""Store repository info so we can reference it in an observation."""
|
||||
if repo_name or repo_directory:
|
||||
|
||||
@@ -64,6 +64,19 @@ class BaseMicroagent(BaseModel):
|
||||
|
||||
try:
|
||||
metadata = MicroagentMetadata(**metadata_dict)
|
||||
|
||||
# Validate MCP tools configuration if present
|
||||
if metadata.mcp_tools:
|
||||
if metadata.mcp_tools.sse_servers:
|
||||
logger.warning(
|
||||
f'Microagent {metadata.name} has SSE servers. Only stdio servers are currently supported.'
|
||||
)
|
||||
|
||||
if not metadata.mcp_tools.stdio_servers:
|
||||
raise MicroagentValidationError(
|
||||
f'Microagent {metadata.name} has MCP tools configuration but no stdio servers. '
|
||||
'Only stdio servers are currently supported.'
|
||||
)
|
||||
except Exception as e:
|
||||
# Provide more detailed error message for validation errors
|
||||
error_msg = f'Error validating microagent metadata in {path.name}: {str(e)}'
|
||||
@@ -81,13 +94,13 @@ class BaseMicroagent(BaseModel):
|
||||
}
|
||||
|
||||
# Infer the agent type:
|
||||
# 1. If triggers exist -> KNOWLEDGE
|
||||
# 2. Else (no triggers) -> REPO
|
||||
# 1. If triggers exist -> KNOWLEDGE (optional)
|
||||
# 2. Else (no triggers) -> REPO (always active)
|
||||
inferred_type: MicroagentType
|
||||
if metadata.triggers:
|
||||
inferred_type = MicroagentType.KNOWLEDGE
|
||||
else:
|
||||
# No triggers, default to REPO unless metadata explicitly says otherwise (which it shouldn't for REPO)
|
||||
# No triggers, default to REPO
|
||||
# This handles cases where 'type' might be missing or defaulted by Pydantic
|
||||
inferred_type = MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
@@ -130,6 +143,7 @@ class KnowledgeMicroagent(BaseMicroagent):
|
||||
for trigger in self.triggers:
|
||||
if trigger.lower() in message:
|
||||
return trigger
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -190,7 +204,9 @@ def load_microagents_from_dir(
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
knowledge_agents[agent.name] = agent
|
||||
logger.debug(f'Loaded agent {agent.name} from {file}')
|
||||
logger.debug(
|
||||
f'Loaded agent {agent.name} from {file}. Type: {type(agent)}'
|
||||
)
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
|
||||
@@ -2,12 +2,16 @@ from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPConfig,
|
||||
)
|
||||
|
||||
|
||||
class MicroagentType(str, Enum):
|
||||
"""Type of microagent."""
|
||||
|
||||
KNOWLEDGE = 'knowledge'
|
||||
REPO_KNOWLEDGE = 'repo'
|
||||
KNOWLEDGE = 'knowledge' # Optional microagent, triggered by keywords
|
||||
REPO_KNOWLEDGE = 'repo' # Always active microagent
|
||||
|
||||
|
||||
class MicroagentMetadata(BaseModel):
|
||||
@@ -18,3 +22,6 @@ class MicroagentMetadata(BaseModel):
|
||||
version: str = Field(default='1.0.0')
|
||||
agent: str = Field(default='CodeActAgent')
|
||||
triggers: list[str] = [] # optional, only exists for knowledge microagents
|
||||
mcp_tools: MCPConfig | None = (
|
||||
None # optional, for microagents that provide additional MCP tools
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig, MCPSSEServerConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentRuntimeTimeoutError,
|
||||
)
|
||||
@@ -351,7 +351,9 @@ class ActionExecutionClient(Runtime):
|
||||
def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
|
||||
return self.send_action_for_execution(action)
|
||||
|
||||
def get_updated_mcp_config(self) -> MCPConfig:
|
||||
def get_updated_mcp_config(
|
||||
self, extra_stdio_servers: list[MCPStdioServerConfig] | None = None
|
||||
) -> MCPConfig:
|
||||
# Add the runtime as another MCP server
|
||||
updated_mcp_config = self.config.mcp.model_copy()
|
||||
# Send a request to the action execution server to updated MCP config
|
||||
@@ -359,6 +361,10 @@ class ActionExecutionClient(Runtime):
|
||||
server.model_dump(mode='json')
|
||||
for server in updated_mcp_config.stdio_servers
|
||||
]
|
||||
if extra_stdio_servers:
|
||||
stdio_tools.extend(
|
||||
[server.model_dump(mode='json') for server in extra_stdio_servers]
|
||||
)
|
||||
|
||||
if len(stdio_tools) > 0:
|
||||
self.log('debug', f'Updating MCP server to: {stdio_tools}')
|
||||
|
||||
@@ -130,10 +130,27 @@ class AgentSession:
|
||||
selected_branch=selected_branch,
|
||||
)
|
||||
|
||||
repo_directory = None
|
||||
if self.runtime and runtime_connected and selected_repository:
|
||||
repo_directory = selected_repository.split('/')[-1]
|
||||
|
||||
if git_provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens=git_provider_tokens)
|
||||
await provider_handler.set_event_stream_secrets(self.event_stream)
|
||||
|
||||
if custom_secrets:
|
||||
custom_secrets_handler.set_event_stream_secrets(self.event_stream)
|
||||
|
||||
self.memory = await self._create_memory(
|
||||
selected_repository=selected_repository,
|
||||
repo_directory=repo_directory,
|
||||
custom_secrets_descriptions=custom_secrets_handler.get_custom_secrets_descriptions()
|
||||
)
|
||||
|
||||
# NOTE: this needs to happen before controller is created
|
||||
# so MCP tools can be included into the SystemMessageAction
|
||||
if self.runtime and runtime_connected:
|
||||
await add_mcp_tools_to_agent(agent, self.runtime, config.mcp)
|
||||
await add_mcp_tools_to_agent(agent, self.runtime, self.memory, config.mcp)
|
||||
|
||||
if replay_json:
|
||||
initial_message = self._run_replay(
|
||||
@@ -156,23 +173,6 @@ class AgentSession:
|
||||
agent_configs=agent_configs,
|
||||
)
|
||||
|
||||
repo_directory = None
|
||||
if self.runtime and runtime_connected and selected_repository:
|
||||
repo_directory = selected_repository.split('/')[-1]
|
||||
|
||||
self.memory = await self._create_memory(
|
||||
selected_repository=selected_repository,
|
||||
repo_directory=repo_directory,
|
||||
custom_secrets_descriptions=custom_secrets_handler.get_custom_secrets_descriptions()
|
||||
)
|
||||
|
||||
if git_provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens=git_provider_tokens)
|
||||
await provider_handler.set_event_stream_secrets(self.event_stream)
|
||||
|
||||
if custom_secrets:
|
||||
custom_secrets_handler.set_event_stream_secrets(self.event_stream)
|
||||
|
||||
if not self._closed:
|
||||
if initial_message:
|
||||
self.event_stream.add_event(initial_message, EventSource.USER)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Tests for microagent loading in runtime."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
_close_test_runtime,
|
||||
_load_runtime,
|
||||
)
|
||||
|
||||
from openhands.core.config import MCPConfig
|
||||
from openhands.core.config.mcp_config import MCPStdioServerConfig
|
||||
from openhands.mcp.utils import add_mcp_tools_to_agent
|
||||
from openhands.microagent import KnowledgeMicroagent, RepoMicroagent
|
||||
|
||||
|
||||
@@ -165,3 +171,91 @@ Repository-specific test instructions.
|
||||
|
||||
finally:
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_default_tools_microagent_exists():
|
||||
"""Test that the default-tools microagent exists in the global microagents directory."""
|
||||
# Get the path to the global microagents directory
|
||||
import openhands
|
||||
|
||||
project_root = os.path.dirname(openhands.__file__)
|
||||
parent_dir = os.path.dirname(project_root)
|
||||
microagents_dir = os.path.join(parent_dir, 'microagents')
|
||||
|
||||
# Check that the default-tools.md file exists
|
||||
default_tools_path = os.path.join(microagents_dir, 'default-tools.md')
|
||||
assert os.path.exists(default_tools_path), (
|
||||
f'default-tools.md not found at {default_tools_path}'
|
||||
)
|
||||
|
||||
# Read the file and check its content
|
||||
with open(default_tools_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Verify it's a repo microagent (always activated)
|
||||
assert 'type: repo' in content, 'default-tools.md should be a repo microagent'
|
||||
|
||||
# Verify it has the fetch tool configured
|
||||
assert 'name: "fetch"' in content, 'default-tools.md should have a fetch tool'
|
||||
assert 'command: "uvx"' in content, 'default-tools.md should use uvx command'
|
||||
assert 'args: ["mcp-server-fetch"]' in content, (
|
||||
'default-tools.md should use mcp-server-fetch'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_mcp_tools_from_microagents():
|
||||
"""Test that add_mcp_tools_to_agent adds tools from microagents."""
|
||||
# Import ActionExecutionClient for mocking
|
||||
from openhands.runtime.impl.action_execution.action_execution_client import (
|
||||
ActionExecutionClient,
|
||||
)
|
||||
|
||||
# Create mock objects
|
||||
mock_agent = MagicMock()
|
||||
mock_runtime = MagicMock(spec=ActionExecutionClient)
|
||||
mock_memory = MagicMock()
|
||||
mock_mcp_config = MCPConfig()
|
||||
|
||||
# Configure the mock memory to return a microagent MCP config
|
||||
mock_stdio_server = MCPStdioServerConfig(
|
||||
name='test-tool', command='test-command', args=['test-arg1', 'test-arg2']
|
||||
)
|
||||
mock_microagent_mcp_config = MCPConfig(stdio_servers=[mock_stdio_server])
|
||||
mock_memory.get_microagent_mcp_tools.return_value = [mock_microagent_mcp_config]
|
||||
|
||||
# Configure the mock runtime
|
||||
mock_runtime.runtime_initialized = True
|
||||
mock_runtime.get_updated_mcp_config.return_value = mock_microagent_mcp_config
|
||||
|
||||
# Mock the fetch_mcp_tools_from_config function to return a mock tool
|
||||
mock_tool = {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'test-tool',
|
||||
'description': 'Test tool description',
|
||||
'parameters': {},
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
'openhands.mcp.utils.fetch_mcp_tools_from_config',
|
||||
new=AsyncMock(return_value=[mock_tool]),
|
||||
):
|
||||
# Call the function
|
||||
await add_mcp_tools_to_agent(
|
||||
mock_agent, mock_runtime, mock_memory, mock_mcp_config
|
||||
)
|
||||
|
||||
# Verify that the memory's get_microagent_mcp_tools was called
|
||||
mock_memory.get_microagent_mcp_tools.assert_called_once()
|
||||
|
||||
# Verify that the runtime's get_updated_mcp_config was called with the extra stdio servers
|
||||
mock_runtime.get_updated_mcp_config.assert_called_once()
|
||||
args, kwargs = mock_runtime.get_updated_mcp_config.call_args
|
||||
assert len(args) == 1
|
||||
assert len(args[0]) == 1
|
||||
assert args[0][0].name == 'test-tool'
|
||||
|
||||
# Verify that the agent's set_mcp_tools was called with the mock tool
|
||||
mock_agent.set_mcp_tools.assert_called_once_with([mock_tool])
|
||||
|
||||
@@ -103,6 +103,8 @@ def mock_memory() -> Memory:
|
||||
spec=Memory,
|
||||
event_stream=test_event_stream,
|
||||
)
|
||||
# Add the get_microagent_mcp_tools method to the mock
|
||||
memory.get_microagent_mcp_tools.return_value = []
|
||||
return memory
|
||||
|
||||
|
||||
@@ -740,7 +742,7 @@ async def test_notify_on_llm_retry(mock_agent, mock_event_stream, mock_status_ca
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_window_exceeded_error_handling(
|
||||
mock_agent, mock_runtime, test_event_stream
|
||||
mock_agent, mock_runtime, test_event_stream, mock_memory
|
||||
):
|
||||
"""Test that context window exceeded errors are handled correctly by the controller, providing a smaller view but keeping the history intact."""
|
||||
max_iterations = 5
|
||||
|
||||
@@ -13,7 +13,6 @@ from openhands.agenthub.codeact_agent.tools import (
|
||||
IPythonTool,
|
||||
LLMBasedFileEditTool,
|
||||
ThinkTool,
|
||||
WebReadTool,
|
||||
create_cmd_run_tool,
|
||||
create_str_replace_editor_tool,
|
||||
)
|
||||
@@ -79,7 +78,6 @@ def test_agent_with_default_config_has_default_tools():
|
||||
'finish',
|
||||
'str_replace_editor',
|
||||
'think',
|
||||
'web_read',
|
||||
}.issubset(default_tool_names)
|
||||
|
||||
|
||||
@@ -179,13 +177,6 @@ def test_str_replace_editor_tool():
|
||||
]
|
||||
|
||||
|
||||
def test_web_read_tool():
|
||||
assert WebReadTool['type'] == 'function'
|
||||
assert WebReadTool['function']['name'] == 'web_read'
|
||||
assert 'url' in WebReadTool['function']['parameters']['properties']
|
||||
assert WebReadTool['function']['parameters']['required'] == ['url']
|
||||
|
||||
|
||||
def test_browser_tool():
|
||||
assert BrowserTool['type'] == 'function'
|
||||
assert BrowserTool['function']['name'] == 'browser'
|
||||
|
||||
@@ -132,7 +132,9 @@ def mock_settings_store():
|
||||
@patch('openhands.cli.main.add_mcp_tools_to_agent')
|
||||
@patch('openhands.cli.main.create_runtime')
|
||||
@patch('openhands.cli.main.create_controller')
|
||||
@patch('openhands.cli.main.create_memory')
|
||||
@patch(
|
||||
'openhands.cli.main.create_memory',
|
||||
)
|
||||
@patch('openhands.cli.main.run_agent_until_done')
|
||||
@patch('openhands.cli.main.cleanup_session')
|
||||
@patch('openhands.cli.main.initialize_repository_for_runtime')
|
||||
@@ -168,7 +170,8 @@ async def test_run_session_without_initial_action(
|
||||
mock_controller_task = MagicMock()
|
||||
mock_create_controller.return_value = (mock_controller, mock_controller_task)
|
||||
|
||||
mock_memory = AsyncMock()
|
||||
# Create a regular MagicMock for memory to avoid coroutine issues
|
||||
mock_memory = MagicMock()
|
||||
mock_create_memory.return_value = mock_memory
|
||||
|
||||
with patch(
|
||||
@@ -197,7 +200,7 @@ async def test_run_session_without_initial_action(
|
||||
mock_display_animation.assert_called_once()
|
||||
mock_create_agent.assert_called_once_with(mock_config)
|
||||
mock_add_mcp_tools.assert_called_once_with(
|
||||
mock_agent, mock_runtime, mock_config.mcp
|
||||
mock_agent, mock_runtime, mock_memory, mock_config.mcp
|
||||
)
|
||||
mock_create_runtime.assert_called_once()
|
||||
mock_create_controller.assert_called_once()
|
||||
@@ -220,7 +223,7 @@ async def test_run_session_without_initial_action(
|
||||
@patch('openhands.cli.main.add_mcp_tools_to_agent')
|
||||
@patch('openhands.cli.main.create_runtime')
|
||||
@patch('openhands.cli.main.create_controller')
|
||||
@patch('openhands.cli.main.create_memory')
|
||||
@patch('openhands.cli.main.create_memory', new_callable=AsyncMock)
|
||||
@patch('openhands.cli.main.run_agent_until_done')
|
||||
@patch('openhands.cli.main.cleanup_session')
|
||||
@patch('openhands.cli.main.initialize_repository_for_runtime')
|
||||
|
||||
@@ -10,7 +10,6 @@ from openhands.agenthub.codeact_agent.function_calling import response_to_action
|
||||
from openhands.core.exceptions import FunctionCallValidationError
|
||||
from openhands.events.action import (
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
CmdRunAction,
|
||||
FileEditAction,
|
||||
FileReadAction,
|
||||
@@ -189,23 +188,6 @@ def test_browser_missing_code():
|
||||
assert 'Missing required argument "code"' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_web_read_valid():
|
||||
"""Test web_read with valid arguments."""
|
||||
response = create_mock_response('web_read', {'url': 'https://example.com'})
|
||||
actions = response_to_actions(response)
|
||||
assert len(actions) == 1
|
||||
assert isinstance(actions[0], BrowseURLAction)
|
||||
assert actions[0].url == 'https://example.com'
|
||||
|
||||
|
||||
def test_web_read_missing_url():
|
||||
"""Test web_read with missing url argument."""
|
||||
response = create_mock_response('web_read', {})
|
||||
with pytest.raises(FunctionCallValidationError) as exc_info:
|
||||
response_to_actions(response)
|
||||
assert 'Missing required argument "url"' in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_json_arguments():
|
||||
"""Test handling of invalid JSON in arguments."""
|
||||
response = ModelResponse(
|
||||
|
||||
@@ -191,11 +191,23 @@ async def test_memory_with_microagents():
|
||||
assert isinstance(observation, RecallObservation)
|
||||
assert source == EventSource.ENVIRONMENT
|
||||
assert observation.recall_type == RecallType.KNOWLEDGE
|
||||
|
||||
# We should have at least one microagent: flarglebargle (triggered by keyword)
|
||||
# Note: The default-tools microagent might not be loaded in tests
|
||||
assert len(observation.microagent_knowledge) == 1
|
||||
|
||||
# Find the flarglebargle microagent in the list
|
||||
flarglebargle_knowledge = None
|
||||
for knowledge in observation.microagent_knowledge:
|
||||
if knowledge.name == derived_name:
|
||||
flarglebargle_knowledge = knowledge
|
||||
break
|
||||
|
||||
# Check against the derived name
|
||||
assert observation.microagent_knowledge[0].name == derived_name
|
||||
assert observation.microagent_knowledge[0].trigger == 'flarglebargle'
|
||||
assert 'magic word' in observation.microagent_knowledge[0].content
|
||||
assert flarglebargle_knowledge is not None
|
||||
assert flarglebargle_knowledge.name == derived_name
|
||||
assert flarglebargle_knowledge.trigger == 'flarglebargle'
|
||||
assert 'magic word' in flarglebargle_knowledge.content
|
||||
|
||||
|
||||
def test_memory_repository_info(prompt_dir, file_store):
|
||||
@@ -321,11 +333,23 @@ async def test_memory_with_agent_microagents():
|
||||
assert isinstance(observation, RecallObservation)
|
||||
assert source == EventSource.ENVIRONMENT
|
||||
assert observation.recall_type == RecallType.KNOWLEDGE
|
||||
|
||||
# We should have at least one microagent: flarglebargle (triggered by keyword)
|
||||
# Note: The default-tools microagent might not be loaded in tests
|
||||
assert len(observation.microagent_knowledge) == 1
|
||||
|
||||
# Find the flarglebargle microagent in the list
|
||||
flarglebargle_knowledge = None
|
||||
for knowledge in observation.microagent_knowledge:
|
||||
if knowledge.name == derived_name:
|
||||
flarglebargle_knowledge = knowledge
|
||||
break
|
||||
|
||||
# Check against the derived name
|
||||
assert observation.microagent_knowledge[0].name == derived_name
|
||||
assert observation.microagent_knowledge[0].trigger == 'flarglebargle'
|
||||
assert 'magic word' in observation.microagent_knowledge[0].content
|
||||
assert flarglebargle_knowledge is not None
|
||||
assert flarglebargle_knowledge.name == derived_name
|
||||
assert flarglebargle_knowledge.trigger == 'flarglebargle'
|
||||
assert 'magic word' in flarglebargle_knowledge.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user