mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
feat(agent): First-class Search API support via MCP (#8638)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
20983a2128
commit
a40443f5f4
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "カスタムモデル",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -73,6 +73,7 @@ function AppSettingsScreen() {
|
||||
setLanguageInputHasChanged(false);
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
setProactiveConversationsSwitchHasChanged(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user