feat(agent): First-class Search API support via MCP (#8638)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang 2025-05-24 00:07:48 +08:00 committed by GitHub
parent 20983a2128
commit a40443f5f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 254 additions and 34 deletions

View File

@ -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");

View File

@ -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) {

View File

@ -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,
});

View File

@ -25,6 +25,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
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);

View File

@ -18,6 +18,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
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<Settings> => {
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,

View File

@ -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",

View File

@ -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": "カスタムモデル",

View File

@ -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,

View File

@ -73,6 +73,7 @@ function AppSettingsScreen() {
setLanguageInputHasChanged(false);
setAnalyticsSwitchHasChanged(false);
setSoundNotificationsSwitchHasChanged(false);
setProactiveConversationsSwitchHasChanged(false);
},
},
);

View File

@ -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"
/>
<SettingsInput
testId="search-api-key-input"
name="search-api-key-input"
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-[680px]"
defaultValue={settings.SEARCH_API_KEY || ""}
onChange={handleSearchApiKeyIsDirty}
placeholder="sk-tavily-..."
startContent={
settings.SEARCH_API_KEY_SET && (
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
)
}
/>
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
</div>
)}
@ -338,12 +376,35 @@ function LlmSettingsScreen() {
}
/>
<HelpLink
testId="llm-api-key-help-anchor"
testId="llm-api-key-help-anchor-advanced"
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
/>
<SettingsInput
testId="search-api-key-input"
name="search-api-key-input"
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-[680px]"
defaultValue={settings.SEARCH_API_KEY || ""}
onChange={handleSearchApiKeyIsDirty}
placeholder="tvly-..."
startContent={
settings.SEARCH_API_KEY_SET && (
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
)
}
/>
<HelpLink
testId="search-api-key-help-anchor"
text={t(I18nKey.SETTINGS$SEARCH_API_KEY_OPTIONAL)}
linkText={t(I18nKey.SETTINGS$SEARCH_API_KEY_INSTRUCTIONS)}
href="https://tavily.com/"
/>
<SettingsDropdownInput
testId="agent-input"
name="agent-input"

View File

@ -8,6 +8,7 @@ export const DEFAULT_SETTINGS: Settings = {
AGENT: "CodeActAgent",
LANGUAGE: "en",
LLM_API_KEY_SET: false,
SEARCH_API_KEY_SET: false,
CONFIRMATION_MODE: false,
SECURITY_ANALYZER: "",
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
@ -16,6 +17,7 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
SEARCH_API_KEY: "",
IS_NEW_USER: true,
MCP_CONFIG: {
sse_servers: [],

View File

@ -33,6 +33,7 @@ export type Settings = {
AGENT: string;
LANGUAGE: string;
LLM_API_KEY_SET: boolean;
SEARCH_API_KEY_SET: boolean;
CONFIRMATION_MODE: boolean;
SECURITY_ANALYZER: string;
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
@ -41,6 +42,7 @@ export type Settings = {
ENABLE_SOUND_NOTIFICATIONS: boolean;
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
SEARCH_API_KEY?: string;
IS_NEW_USER?: boolean;
MCP_CONFIG?: MCPConfig;
};
@ -52,6 +54,7 @@ export type ApiSettings = {
language: string;
llm_api_key: string | null;
llm_api_key_set: boolean;
search_api_key_set: boolean;
confirmation_mode: boolean;
security_analyzer: string;
remote_runtime_resource_factor: number | null;
@ -59,6 +62,7 @@ export type ApiSettings = {
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
user_consents_to_analytics: boolean | null;
search_api_key?: string;
provider_tokens_set: Partial<Record<Provider, string | null>>;
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;
};

View File

@ -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()

View File

@ -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)

View File

@ -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(

View File

@ -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:

View File

@ -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(

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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 (