From a40443f5f46a22e431a96d5ee93d9857efde372c Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Sat, 24 May 2025 00:07:48 +0800 Subject: [PATCH] feat(agent): First-class Search API support via MCP (#8638) Co-authored-by: openhands --- .../__tests__/routes/llm-settings.test.tsx | 2 +- .../scripts/check-unlocalized-strings.cjs | 1 + .../shared/modals/settings/settings-form.tsx | 1 + .../src/hooks/mutation/use-save-settings.ts | 1 + frontend/src/hooks/query/use-settings.ts | 2 + frontend/src/i18n/declaration.ts | 3 + frontend/src/i18n/translation.json | 48 ++++++++++++++ frontend/src/mocks/handlers.ts | 1 + frontend/src/routes/app-settings.tsx | 1 + frontend/src/routes/llm-settings.tsx | 63 ++++++++++++++++++- frontend/src/services/settings.ts | 2 + frontend/src/types/settings.ts | 6 ++ openhands/cli/main.py | 14 ++++- openhands/core/config/app_config.py | 12 ++-- openhands/core/config/mcp_config.py | 42 ++++++++++--- openhands/core/main.py | 14 ++++- openhands/mcp/utils.py | 14 +++-- openhands/server/routes/settings.py | 9 ++- openhands/server/session/agent_session.py | 2 +- openhands/server/session/session.py | 14 +++-- openhands/server/settings.py | 1 + openhands/storage/data_models/settings.py | 15 +++-- tests/runtime/test_microagent.py | 9 ++- tests/unit/test_cli.py | 10 ++- tests/unit/test_config.py | 1 + 25 files changed, 254 insertions(+), 34 deletions(-) diff --git a/frontend/__tests__/routes/llm-settings.test.tsx b/frontend/__tests__/routes/llm-settings.test.tsx index 68cb15a2cd..4037ff89ed 100644 --- a/frontend/__tests__/routes/llm-settings.test.tsx +++ b/frontend/__tests__/routes/llm-settings.test.tsx @@ -105,7 +105,7 @@ describe("Content", () => { within(advancedForm).getByTestId("llm-custom-model-input"); within(advancedForm).getByTestId("base-url-input"); within(advancedForm).getByTestId("llm-api-key-input"); - within(advancedForm).getByTestId("llm-api-key-help-anchor"); + within(advancedForm).getByTestId("llm-api-key-help-anchor-advanced"); within(advancedForm).getByTestId("agent-input"); within(advancedForm).getByTestId("enable-confirmation-mode-switch"); within(advancedForm).getByTestId("enable-memory-condenser-switch"); diff --git a/frontend/scripts/check-unlocalized-strings.cjs b/frontend/scripts/check-unlocalized-strings.cjs index 31dbfab109..2623f2360e 100755 --- a/frontend/scripts/check-unlocalized-strings.cjs +++ b/frontend/scripts/check-unlocalized-strings.cjs @@ -113,6 +113,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [ "GitHub API", // Git provider specific terminology "add-secret-form", // Test ID for secret form "edit-secret-form", // Test ID for secret form + "search-api-key-input", // Input name for search API key ]; function isExcludedTechnicalString(str) { diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index 1c4e223421..95cea3eb5b 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -42,6 +42,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) { posthog.capture("settings_saved", { LLM_MODEL: newSettings.LLM_MODEL, LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET", + SEARCH_API_KEY_SET: newSettings.SEARCH_API_KEY ? "SET" : "UNSET", REMOTE_RUNTIME_RESOURCE_FACTOR: newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR, }); diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 0b8685c252..6a86ace9f6 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -25,6 +25,7 @@ const saveSettingsMutationFn = async (settings: Partial) => { mcp_config: settings.MCP_CONFIG, enable_proactive_conversation_starters: settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS, + search_api_key: settings.SEARCH_API_KEY?.trim() || "", }; await OpenHands.saveSettings(apiSettings); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 2065c08d7f..3fa916c958 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -18,6 +18,7 @@ const getSettingsQueryFn = async (): Promise => { CONFIRMATION_MODE: apiSettings.confirmation_mode, SECURITY_ANALYZER: apiSettings.security_analyzer, LLM_API_KEY_SET: apiSettings.llm_api_key_set, + SEARCH_API_KEY_SET: apiSettings.search_api_key_set, REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor, PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set, ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser, @@ -25,6 +26,7 @@ const getSettingsQueryFn = async (): Promise => { ENABLE_PROACTIVE_CONVERSATION_STARTERS: apiSettings.enable_proactive_conversation_starters, USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics, + SEARCH_API_KEY: apiSettings.search_api_key || "", MCP_CONFIG: apiSettings.mcp_config, IS_NEW_USER: false, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index cde698c8f4..cb3d638c1d 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -117,6 +117,9 @@ export enum I18nKey { SETTINGS$GIT_SETTINGS = "SETTINGS$GIT_SETTINGS", SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS", SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS", + SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY", + SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL", + SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS", SETTINGS$CUSTOM_MODEL = "SETTINGS$CUSTOM_MODEL", GITHUB$CODE_NOT_IN_GITHUB = "GITHUB$CODE_NOT_IN_GITHUB", GITHUB$START_FROM_SCRATCH = "GITHUB$START_FROM_SCRATCH", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index e464f0ee38..c3a861f900 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -1871,6 +1871,54 @@ "tr": "GitHub'da Görevler Öner", "uk": "Запропонувати завдання на GitHub" }, + "SETTINGS$SEARCH_API_KEY": { + "en": "Search API Key (Tavily)", + "ja": "検索APIキー (Tavily)", + "zh-CN": "搜索API密钥 (Tavily)", + "zh-TW": "搜索API密鑰 (Tavily)", + "ko-KR": "검색 API 키 (Tavily)", + "de": "Such-API-Schlüssel (Tavily)", + "no": "Søk API-nøkkel (Tavily)", + "it": "Chiave API di ricerca (Tavily)", + "pt": "Chave de API de pesquisa (Tavily)", + "es": "Clave API de búsqueda (Tavily)", + "ar": "مفتاح API للبحث (Tavily)", + "fr": "Clé API de recherche (Tavily)", + "tr": "Arama API Anahtarı (Tavily)", + "uk": "Ключ API пошуку (Tavily)" + }, + "SETTINGS$SEARCH_API_KEY_OPTIONAL": { + "en": "This field is optional. We use Tavily as our default search engine provider.", + "ja": "このフィールドは任意です。デフォルトの検索エンジンプロバイダーとしてTavilyを使用しています。", + "zh-CN": "此字段为可选项。我们使用Tavily作为默认搜索引擎提供商。", + "zh-TW": "此字段為可選項。我們使用Tavily作為默認搜索引擎提供商。", + "ko-KR": "이 필드는 선택 사항입니다. 기본 검색 엔진 제공업체로 Tavily를 사용합니다.", + "de": "Dieses Feld ist optional. Wir verwenden Tavily als unseren Standard-Suchmaschinenanbieter.", + "no": "Dette feltet er valgfritt. Vi bruker Tavily som vår standard søkemotorleverandør.", + "it": "Questo campo è opzionale. Utilizziamo Tavily come nostro fornitore di motori di ricerca predefinito.", + "pt": "Este campo é opcional. Usamos o Tavily como nosso provedor de mecanismo de pesquisa padrão.", + "es": "Este campo es opcional. Utilizamos Tavily como nuestro proveedor de motor de búsqueda predeterminado.", + "ar": "هذا الحقل اختياري. نستخدم Tavily كمزود محرك البحث الافتراضي.", + "fr": "Ce champ est facultatif. Nous utilisons Tavily comme fournisseur de moteur de recherche par défaut.", + "tr": "Bu alan isteğe bağlıdır. Varsayılan arama motoru sağlayıcısı olarak Tavily'yi kullanıyoruz.", + "uk": "Це поле є необов'язковим. Ми використовуємо Tavily як нашого типового постачальника пошукової системи." + }, + "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS": { + "en": "Get your API key from Tavily", + "ja": "TavilyからAPIキーを取得する", + "zh-CN": "从Tavily获取您的API密钥", + "zh-TW": "從Tavily獲取您的API密鑰", + "ko-KR": "Tavily에서 API 키 받기", + "de": "Holen Sie sich Ihren API-Schlüssel von Tavily", + "no": "Få API-nøkkelen din fra Tavily", + "it": "Ottieni la tua chiave API da Tavily", + "pt": "Obtenha sua chave de API do Tavily", + "es": "Obtenga su clave API de Tavily", + "ar": "احصل على مفتاح API الخاص بك من Tavily", + "fr": "Obtenez votre clé API de Tavily", + "tr": "API anahtarınızı Tavily'den alın", + "uk": "Отримайте свій ключ API від Tavily" + }, "SETTINGS$CUSTOM_MODEL": { "en": "Custom Model", "ja": "カスタムモデル", diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 9e50cb1796..8036623621 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -17,6 +17,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = { llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL, llm_api_key: null, llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET, + search_api_key_set: DEFAULT_SETTINGS.SEARCH_API_KEY_SET, agent: DEFAULT_SETTINGS.AGENT, language: DEFAULT_SETTINGS.LANGUAGE, confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE, diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index ed03aa7e69..9a375563b4 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -73,6 +73,7 @@ function AppSettingsScreen() { setLanguageInputHasChanged(false); setAnalyticsSwitchHasChanged(false); setSoundNotificationsSwitchHasChanged(false); + setProactiveConversationsSwitchHasChanged(false); }, }, ); diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx index 92d8f6b08b..7422c9b1b5 100644 --- a/frontend/src/routes/llm-settings.tsx +++ b/frontend/src/routes/llm-settings.tsx @@ -40,6 +40,7 @@ function LlmSettingsScreen() { const [dirtyInputs, setDirtyInputs] = React.useState({ model: false, apiKey: false, + searchApiKey: false, baseUrl: false, agent: false, confirmationMode: false, @@ -77,6 +78,7 @@ function LlmSettingsScreen() { setDirtyInputs({ model: false, apiKey: false, + searchApiKey: false, baseUrl: false, agent: false, confirmationMode: false, @@ -94,6 +96,7 @@ function LlmSettingsScreen() { const provider = formData.get("llm-provider-input")?.toString(); const model = formData.get("llm-model-input")?.toString(); const apiKey = formData.get("llm-api-key-input")?.toString(); + const searchApiKey = formData.get("search-api-key-input")?.toString(); const fullLlmModel = provider && model && `${provider}/${model}`.toLowerCase(); @@ -102,6 +105,7 @@ function LlmSettingsScreen() { { LLM_MODEL: fullLlmModel, llm_api_key: apiKey || null, + SEARCH_API_KEY: searchApiKey || "", // reset advanced settings LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL, @@ -121,6 +125,7 @@ function LlmSettingsScreen() { const model = formData.get("llm-custom-model-input")?.toString(); const baseUrl = formData.get("base-url-input")?.toString(); const apiKey = formData.get("llm-api-key-input")?.toString(); + const searchApiKey = formData.get("search-api-key-input")?.toString(); const agent = formData.get("agent-input")?.toString(); const confirmationMode = formData.get("enable-confirmation-mode-switch")?.toString() === "on"; @@ -135,6 +140,7 @@ function LlmSettingsScreen() { LLM_MODEL: model, LLM_BASE_URL: baseUrl, llm_api_key: apiKey || null, + SEARCH_API_KEY: searchApiKey || "", AGENT: agent, CONFIRMATION_MODE: confirmationMode, ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser, @@ -158,6 +164,7 @@ function LlmSettingsScreen() { setDirtyInputs({ model: false, apiKey: false, + searchApiKey: false, baseUrl: false, agent: false, confirmationMode: false, @@ -184,6 +191,14 @@ function LlmSettingsScreen() { })); }; + const handleSearchApiKeyIsDirty = (searchApiKey: string) => { + const searchApiKeyIsDirty = searchApiKey !== settings?.SEARCH_API_KEY; + setDirtyInputs((prev) => ({ + ...prev, + searchApiKey: searchApiKeyIsDirty, + })); + }; + const handleCustomModelIsDirty = (model: string) => { const modelIsDirty = model !== settings?.LLM_MODEL && model !== ""; setDirtyInputs((prev) => ({ @@ -291,6 +306,29 @@ function LlmSettingsScreen() { linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)} href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key" /> + + + ) + } + /> + + )} @@ -338,12 +376,35 @@ function LlmSettingsScreen() { } /> + + ) + } + /> + + + >; mcp_config?: { sse_servers: (string | MCPSSEServer)[]; @@ -69,10 +73,12 @@ export type ApiSettings = { export type PostSettings = Settings & { user_consents_to_analytics: boolean | null; llm_api_key?: string | null; + search_api_key?: string; mcp_config?: MCPConfig; }; export type PostApiSettings = ApiSettings & { user_consents_to_analytics: boolean | null; + search_api_key?: string; mcp_config?: MCPConfig; }; diff --git a/openhands/cli/main.py b/openhands/cli/main.py index 2d0028c3fd..fea0e0ea2a 100644 --- a/openhands/cli/main.py +++ b/openhands/cli/main.py @@ -35,6 +35,7 @@ from openhands.core.config import ( setup_config_from_args, ) from openhands.core.config.condenser_config import NoOpCondenserConfig +from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl from openhands.core.logger import openhands_logger as logger from openhands.core.loop import run_agent_until_done from openhands.core.schema import AgentState @@ -261,7 +262,18 @@ async def run_session( # Add MCP tools to the agent if agent.config.enable_mcp: - await add_mcp_tools_to_agent(agent, runtime, memory, config.mcp) + # Add OpenHands' MCP server by default + openhands_mcp_server, openhands_mcp_stdio_servers = ( + OpenHandsMCPConfigImpl.create_default_mcp_server_config( + config.mcp_host, config, None + ) + ) + # FIXME: OpenHands' SSE server may not be running when CLI mode is started + # if openhands_mcp_server: + # config.mcp.sse_servers.append(openhands_mcp_server) + config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers) + + await add_mcp_tools_to_agent(agent, runtime, memory, config) # Clear loading animation is_loaded.set() diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py index cad6181c20..3cd8246572 100644 --- a/openhands/core/config/app_config.py +++ b/openhands/core/config/app_config.py @@ -32,10 +32,11 @@ class AppConfig(BaseModel): save_trajectory_path: Either a folder path to store trajectories with auto-generated filenames, or a designated trajectory file path. save_screenshots_in_trajectory: Whether to save screenshots in trajectory (in encoded image format). replay_trajectory_path: Path to load trajectory and replay. If provided, trajectory would be replayed first before user's instruction. - workspace_base: Base path for the workspace. Defaults to `./workspace` as absolute path. - workspace_mount_path: Path to mount the workspace. Defaults to `workspace_base`. - workspace_mount_path_in_sandbox: Path to mount the workspace in sandbox. Defaults to `/workspace`. - workspace_mount_rewrite: Path to rewrite the workspace mount path. + search_api_key: API key for Tavily search engine (https://tavily.com/). + workspace_base (deprecated): Base path for the workspace. Defaults to `./workspace` as absolute path. + workspace_mount_path (deprecated): Path to mount the workspace. Defaults to `workspace_base`. + workspace_mount_path_in_sandbox (deprecated): Path to mount the workspace in sandbox. Defaults to `/workspace`. + workspace_mount_rewrite (deprecated): Path to rewrite the workspace mount path. cache_dir: Path to cache directory. Defaults to `/tmp/cache`. run_as_openhands: Whether to run as openhands. max_iterations: Maximum number of iterations allowed. @@ -64,12 +65,15 @@ class AppConfig(BaseModel): save_trajectory_path: str | None = Field(default=None) save_screenshots_in_trajectory: bool = Field(default=False) replay_trajectory_path: str | None = Field(default=None) + search_api_key: SecretStr | None = Field(default=None, description="API key for Tavily search engine (https://tavily.com/). Required for search functionality.") # Deprecated parameters - will be removed in a future version workspace_base: str | None = Field(default=None, deprecated=True) workspace_mount_path: str | None = Field(default=None, deprecated=True) workspace_mount_path_in_sandbox: str = Field(default='/workspace', deprecated=True) workspace_mount_rewrite: str | None = Field(default=None, deprecated=True) + # End of deprecated parameters + cache_dir: str = Field(default='/tmp/cache') run_as_openhands: bool = Field(default=True) max_iterations: int = Field(default=OH_MAX_ITERATIONS) diff --git a/openhands/core/config/mcp_config.py b/openhands/core/config/mcp_config.py index 344727efda..9e286e5cac 100644 --- a/openhands/core/config/mcp_config.py +++ b/openhands/core/config/mcp_config.py @@ -1,8 +1,12 @@ import os from urllib.parse import urlparse - +from typing import TYPE_CHECKING from pydantic import BaseModel, Field, ValidationError, model_validator +if TYPE_CHECKING: + from openhands.core.config.app_config import AppConfig + +from openhands.core.logger import openhands_logger as logger from openhands.utils.import_utils import get_impl @@ -142,21 +146,45 @@ class MCPConfig(BaseModel): class OpenHandsMCPConfig: + @staticmethod + def add_search_engine(app_config: "AppConfig") -> MCPStdioServerConfig | None: + """Add search engine to the MCP config""" + if ( + app_config.search_api_key + and app_config.search_api_key.get_secret_value().startswith('tvly-') + ): + logger.info('Adding search engine to MCP config') + return MCPStdioServerConfig( + name='tavily', + command='npx', + args=['-y', 'tavily-mcp@0.1.4'], + env={'TAVILY_API_KEY': app_config.search_api_key.get_secret_value()}, + ) + else: + logger.warning('No search engine API key found, skipping search engine') + # Do not add search engine to MCP config in SaaS mode since it will be added by the OpenHands server + return None + + @staticmethod def create_default_mcp_server_config( - host: str, user_id: str | None = None - ) -> MCPSSEServerConfig | None: + host: str, config: "AppConfig", user_id: str | None = None + ) -> tuple[MCPSSEServerConfig, list[MCPStdioServerConfig]]: """ Create a default MCP server configuration. Args: host: Host string - + config: AppConfig Returns: - MCPSSEServerConfig: A default SSE server configuration + tuple[MCPSSEServerConfig, list[MCPStdioServerConfig]]: A tuple containing the default SSE server configuration and a list of MCP stdio server configurations """ - - return MCPSSEServerConfig(url=f'http://{host}/mcp/sse', api_key=None) + sse_server = MCPSSEServerConfig(url=f'http://{host}/mcp/sse', api_key=None) + stdio_servers = [] + search_engine_stdio_server = OpenHandsMCPConfig.add_search_engine(config) + if search_engine_stdio_server: + stdio_servers.append(search_engine_stdio_server) + return sse_server, stdio_servers openhands_mcp_config_cls = os.environ.get( diff --git a/openhands/core/main.py b/openhands/core/main.py index 0484ce14fc..9703867cbd 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -13,6 +13,7 @@ from openhands.core.config import ( parse_arguments, setup_config_from_args, ) +from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl from openhands.core.logger import openhands_logger as logger from openhands.core.loop import run_agent_until_done from openhands.core.schema import AgentState @@ -132,7 +133,18 @@ async def run_controller( # Add MCP tools to the agent if agent.config.enable_mcp: - await add_mcp_tools_to_agent(agent, runtime, memory, config.mcp) + # Add OpenHands' MCP server by default + openhands_mcp_server, openhands_mcp_stdio_servers = ( + OpenHandsMCPConfigImpl.create_default_mcp_server_config( + config.mcp_host, config, None + ) + ) + # FIXME: OpenHands' SSE server may not be running when headless mode is started + # if openhands_mcp_server: + # config.mcp.sse_servers.append(openhands_mcp_server) + config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers) + + await add_mcp_tools_to_agent(agent, runtime, memory, config) replay_events: list[Event] | None = None if config.replay_trajectory_path: diff --git a/openhands/mcp/utils.py b/openhands/mcp/utils.py index 40f334a819..f81d7371b8 100644 --- a/openhands/mcp/utils.py +++ b/openhands/mcp/utils.py @@ -4,7 +4,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from openhands.controller.agent import Agent -from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig +from openhands.core.config.app_config import AppConfig +from openhands.core.config.mcp_config import ( + MCPConfig, + MCPSSEServerConfig, +) from openhands.core.logger import openhands_logger as logger from openhands.events.action.mcp import MCPAction from openhands.events.observation.mcp import MCPObservation @@ -156,7 +160,7 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse async def add_mcp_tools_to_agent( - agent: 'Agent', runtime: Runtime, memory: 'Memory', mcp_config: MCPConfig + agent: 'Agent', runtime: Runtime, memory: 'Memory', app_config: AppConfig ): """ Add MCP tools to an agent. @@ -166,9 +170,11 @@ 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 = [] + + # Add microagent MCP tools if available + mcp_config: MCPConfig = app_config.mcp + microagent_mcp_configs = memory.get_microagent_mcp_tools() for mcp_config in microagent_mcp_configs: if mcp_config.sse_servers: logger.warning( diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index 00a7957d19..5ed58f8cd0 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -65,9 +65,12 @@ async def load_settings( **settings.model_dump(exclude='secrets_store'), llm_api_key_set=settings.llm_api_key is not None and bool(settings.llm_api_key), + search_api_key_set=settings.search_api_key is not None + and bool(settings.search_api_key), provider_tokens_set=provider_tokens_set, ) settings_with_token_data.llm_api_key = None + settings_with_token_data.search_api_key = None return settings_with_token_data except Exception as e: logger.warning(f'Invalid token: {e}') @@ -116,6 +119,9 @@ async def store_llm_settings( settings.llm_model = existing_settings.llm_model if settings.llm_base_url is None: settings.llm_base_url = existing_settings.llm_base_url + # Keep existing search API key if not provided + if settings.search_api_key is None: + settings.search_api_key = existing_settings.search_api_key return settings @@ -180,8 +186,9 @@ def convert_to_settings(settings_with_token_data: Settings) -> Settings: if key in Settings.model_fields # Ensures only `Settings` fields are included } - # Convert the `llm_api_key` to a `SecretStr` instance + # Convert the API keys to `SecretStr` instances filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key + filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key # Create a new Settings instance settings = Settings(**filtered_settings_data) diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 83d35f16c0..010fd3ff3f 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -153,7 +153,7 @@ class AgentSession: # NOTE: this needs to happen before controller is created # so MCP tools can be included into the SystemMessageAction if self.runtime and runtime_connected and agent.config.enable_mcp: - await add_mcp_tools_to_agent(agent, self.runtime, self.memory, config.mcp) + await add_mcp_tools_to_agent(agent, self.runtime, self.memory, config) if replay_json: initial_message = self._run_replay( diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py index 3db070a221..8fc17d6516 100644 --- a/openhands/server/session/session.py +++ b/openhands/server/session/session.py @@ -116,11 +116,6 @@ class Session: or settings.sandbox_runtime_container_image else self.config.sandbox.runtime_container_image ) - self.config.mcp = settings.mcp_config or MCPConfig(sse_servers=[], stdio_servers=[]) - # Add OpenHands' MCP server by default - openhands_mcp_server = OpenHandsMCPConfigImpl.create_default_mcp_server_config(self.config.mcp_host, self.user_id) - if openhands_mcp_server: - self.config.mcp.sse_servers.append(openhands_mcp_server) max_iterations = settings.max_iterations or self.config.max_iterations # This is a shallow copy of the default LLM config, so changes here will @@ -130,6 +125,15 @@ class Session: default_llm_config.model = settings.llm_model or '' default_llm_config.api_key = settings.llm_api_key default_llm_config.base_url = settings.llm_base_url + self.config.search_api_key = settings.search_api_key + + # NOTE: this need to happen AFTER the config is updated with the search_api_key + self.config.mcp = settings.mcp_config or MCPConfig(sse_servers=[], stdio_servers=[]) + # Add OpenHands' MCP server by default + openhands_mcp_server, openhands_mcp_stdio_servers = OpenHandsMCPConfigImpl.create_default_mcp_server_config(self.config.mcp_host, self.config, self.user_id) + if openhands_mcp_server: + self.config.mcp.sse_servers.append(openhands_mcp_server) + self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers) # TODO: override other LLM config & agent config groups (#2075) diff --git a/openhands/server/settings.py b/openhands/server/settings.py index 3be513b6e7..b3f4dee784 100644 --- a/openhands/server/settings.py +++ b/openhands/server/settings.py @@ -37,6 +37,7 @@ class GETSettingsModel(Settings): None # provider + base_domain key-value pair ) llm_api_key_set: bool + search_api_key_set: bool = False model_config = {'use_enum_values': True} diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py index 5967e310e4..1f3b872b8a 100644 --- a/openhands/storage/data_models/settings.py +++ b/openhands/storage/data_models/settings.py @@ -39,23 +39,27 @@ class Settings(BaseModel): sandbox_base_container_image: str | None = None sandbox_runtime_container_image: str | None = None mcp_config: MCPConfig | None = None + search_api_key: SecretStr | None = None model_config = { 'validate_assignment': True, } - @field_serializer('llm_api_key') - def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo): - """Custom serializer for the LLM API key. + @field_serializer('llm_api_key', 'search_api_key') + def api_key_serializer(self, api_key: SecretStr | None, info: SerializationInfo): + """Custom serializer for API keys. To serialize the API key instead of ********, set expose_secrets to True in the serialization context. """ + if api_key is None: + return None + context = info.context if context and context.get('expose_secrets', False): - return llm_api_key.get_secret_value() + return api_key.get_secret_value() - return pydantic_encoder(llm_api_key) if llm_api_key else None + return pydantic_encoder(api_key) @model_validator(mode='before') @classmethod @@ -125,5 +129,6 @@ class Settings(BaseModel): llm_base_url=llm_config.base_url, remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor, mcp_config=mcp_config, + search_api_key=app_config.search_api_key, ) return settings diff --git a/tests/runtime/test_microagent.py b/tests/runtime/test_microagent.py index 16e146765e..f73ef77679 100644 --- a/tests/runtime/test_microagent.py +++ b/tests/runtime/test_microagent.py @@ -207,6 +207,8 @@ def test_default_tools_microagent_exists(): 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.core.config.app_config import AppConfig from openhands.runtime.impl.action_execution.action_execution_client import ( ActionExecutionClient, ) @@ -217,6 +219,9 @@ async def test_add_mcp_tools_from_microagents(): mock_memory = MagicMock() mock_mcp_config = MCPConfig() + # Create a mock AppConfig with the MCP config + mock_app_config = AppConfig(mcp=mock_mcp_config, search_api_key=None) + # 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'] @@ -242,9 +247,9 @@ async def test_add_mcp_tools_from_microagents(): 'openhands.mcp.utils.fetch_mcp_tools_from_config', new=AsyncMock(return_value=[mock_tool]), ): - # Call the function + # Call the function with the AppConfig instead of MCPConfig await add_mcp_tools_to_agent( - mock_agent, mock_runtime, mock_memory, mock_mcp_config + mock_agent, mock_runtime, mock_memory, mock_app_config ) # Verify that the memory's get_microagent_mcp_tools was called diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 8c3a745110..6bbe6ad4b1 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -117,6 +117,14 @@ def mock_config(): config.runtime = 'local' config.cli_multiline_input = False config.workspace_base = '/test/dir' + + # Mock search_api_key with get_secret_value method + search_api_key_mock = MagicMock() + search_api_key_mock.get_secret_value.return_value = ( + '' # Empty string, not starting with 'tvly-' + ) + config.search_api_key = search_api_key_mock + return config @@ -201,7 +209,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_memory, mock_config.mcp + mock_agent, mock_runtime, mock_memory, mock_config ) mock_create_runtime.assert_called_once() mock_create_controller.assert_called_once() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 1454458b25..6fd4b0c6a0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -995,6 +995,7 @@ def test_api_keys_repr_str(): 'modal_api_token_secret', 'runloop_api_key', 'daytona_api_key', + 'search_api_key', ] for attr_name in AppConfig.model_fields.keys(): if (