diff --git a/microagents/default-tools.md b/microagents/default-tools.md new file mode 100644 index 0000000000..028e467e17 --- /dev/null +++ b/microagents/default-tools.md @@ -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. +--- diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 2262b107f2..b33586139c 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -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) diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index 61a56a1a67..a66eb28a13 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -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) # ================================================ diff --git a/openhands/agenthub/codeact_agent/tools/__init__.py b/openhands/agenthub/codeact_agent/tools/__init__.py index 49dcba2ebb..683533082f 100644 --- a/openhands/agenthub/codeact_agent/tools/__init__.py +++ b/openhands/agenthub/codeact_agent/tools/__init__.py @@ -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', ] diff --git a/openhands/agenthub/codeact_agent/tools/web_read.py b/openhands/agenthub/codeact_agent/tools/web_read.py deleted file mode 100644 index a7fbf140c0..0000000000 --- a/openhands/agenthub/codeact_agent/tools/web_read.py +++ /dev/null @@ -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'], - }, - ), -) diff --git a/openhands/agenthub/readonly_agent/function_calling.py b/openhands/agenthub/readonly_agent/function_calling.py index a0b938e8bf..0ef0f97b87 100644 --- a/openhands/agenthub/readonly_agent/function_calling.py +++ b/openhands/agenthub/readonly_agent/function_calling.py @@ -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, diff --git a/openhands/cli/main.py b/openhands/cli/main.py index b40f79dc3f..bed9a731f2 100644 --- a/openhands/cli/main.py +++ b/openhands/cli/main.py @@ -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() diff --git a/openhands/core/main.py b/openhands/core/main.py index 9f6326336a..80a7a0144b 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -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') diff --git a/openhands/mcp/utils.py b/openhands/mcp/utils.py index ad30b0150f..3ec6e2d851 100644 --- a/openhands/mcp/utils.py +++ b/openhands/mcp/utils.py @@ -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) diff --git a/openhands/memory/memory.py b/openhands/memory/memory.py index 0407047b59..b34e263196 100644 --- a/openhands/memory/memory.py +++ b/openhands/memory/memory.py @@ -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: diff --git a/openhands/microagent/microagent.py b/openhands/microagent/microagent.py index 6f5722fd53..fbff3a2365 100644 --- a/openhands/microagent/microagent.py +++ b/openhands/microagent/microagent.py @@ -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)}' diff --git a/openhands/microagent/types.py b/openhands/microagent/types.py index fab42b7e08..c5adbaafb4 100644 --- a/openhands/microagent/types.py +++ b/openhands/microagent/types.py @@ -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 + ) diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py index e7b18815cb..048a2eb509 100644 --- a/openhands/runtime/impl/action_execution/action_execution_client.py +++ b/openhands/runtime/impl/action_execution/action_execution_client.py @@ -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}') diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index aaf7c2f63b..e1a11dc3b7 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -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) diff --git a/tests/runtime/test_microagent.py b/tests/runtime/test_microagent.py index 02a178d84f..6321c86ddd 100644 --- a/tests/runtime/test_microagent.py +++ b/tests/runtime/test_microagent.py @@ -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]) diff --git a/tests/unit/test_agent_controller.py b/tests/unit/test_agent_controller.py index 051c5f061d..79998cd0b8 100644 --- a/tests/unit/test_agent_controller.py +++ b/tests/unit/test_agent_controller.py @@ -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 diff --git a/tests/unit/test_agents.py b/tests/unit/test_agents.py index 01b4bb0a6f..ffd2ab3498 100644 --- a/tests/unit/test_agents.py +++ b/tests/unit/test_agents.py @@ -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' diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 310eddc67f..a24a5ef496 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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') diff --git a/tests/unit/test_function_calling.py b/tests/unit/test_function_calling.py index ae19b619e8..2da8da9858 100644 --- a/tests/unit/test_function_calling.py +++ b/tests/unit/test_function_calling.py @@ -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( diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index 023e3bfdb8..3589e4c142 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -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