From 6dba02e794701401b3d6adeaabafa700cd091042 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 10 May 2026 15:29:28 +0000 Subject: [PATCH] feat(sdk): gate switch llm default tool Co-authored-by: openhands --- openhands-sdk/openhands/sdk/settings/model.py | 22 ++++++++ .../openhands/sdk/tool/builtins/switch_llm.py | 13 +++-- tests/sdk/test_settings.py | 14 ++++- tests/sdk/tool/test_switch_llm.py | 54 ++++++++++++++++--- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/openhands-sdk/openhands/sdk/settings/model.py b/openhands-sdk/openhands/sdk/settings/model.py index 7e1949b2fd..61a7bba697 100644 --- a/openhands-sdk/openhands/sdk/settings/model.py +++ b/openhands-sdk/openhands/sdk/settings/model.py @@ -713,6 +713,21 @@ class OpenHandsAgentSettings(AgentSettingsBase): ).model_dump() }, ) + enable_switch_llm_tool: bool = Field( + default=True, + description=( + "Enable the built-in switch_llm tool when saved LLM profiles are " + "available. The tool is omitted when no profiles exist." + ), + json_schema_extra={ + SETTINGS_METADATA_KEY: SettingsFieldMetadata( + label="Enable LLM switching tool", + prominence=SettingProminence.MINOR, + variant="openhands", + ).model_dump() + }, + ) + mcp_config: MCPConfig | None = Field( default=None, description="MCP server configuration for the agent.", @@ -789,6 +804,8 @@ def create_agent(self) -> Agent: agent = settings.create_agent() """ from openhands.sdk.agent import Agent + from openhands.sdk.tool.builtins import BUILT_IN_TOOLS, SwitchLLMTool + from openhands.sdk.tool.builtins.switch_llm import has_llm_profiles # Bypass ``_serialize_mcp_config``: MCP servers need real env/headers. mcp_config = ( @@ -796,10 +813,15 @@ def create_agent(self) -> Agent: if self.mcp_config is not None else {} ) + include_default_tools = [tool.__name__ for tool in BUILT_IN_TOOLS] + if self.enable_switch_llm_tool and has_llm_profiles(): + include_default_tools.append(SwitchLLMTool.__name__) + return Agent( llm=self.llm, tools=self.tools, mcp_config=mcp_config, + include_default_tools=include_default_tools, agent_context=self.agent_context, condenser=self.build_condenser(self.llm), critic=self.build_critic(), diff --git a/openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py b/openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py index 5338f4baa5..3ebe56b8f0 100644 --- a/openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py +++ b/openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py @@ -83,6 +83,15 @@ def visualize(self) -> Text: ) +def get_llm_profile_names() -> list[str]: + """Return saved LLM profile names that can be shown to the agent.""" + return [summary["name"] for summary in LLMProfileStore().list_summaries()] + + +def has_llm_profiles() -> bool: + return bool(get_llm_profile_names()) + + def _format_profiles(profile_names: Sequence[str]) -> str: if not profile_names: return "- No saved LLM profiles are currently available." @@ -155,9 +164,7 @@ def create( if params: raise ValueError("SwitchLLMTool doesn't accept parameters") - profile_names = [ - name.removesuffix(".json") for name in LLMProfileStore().list() - ] + profile_names = get_llm_profile_names() return [ cls( description=_DESCRIPTION_TEMPLATE.format( diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index 9364634133..4d2205ac29 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -66,6 +66,7 @@ def test_llm_agent_settings_export_schema_groups_sections() -> None: "agent", "tools", "enable_sub_agents", + "enable_switch_llm_tool", "mcp_config", } assert general_fields["agent"].default == "CodeActAgent" @@ -76,6 +77,11 @@ def test_llm_agent_settings_export_schema_groups_sections() -> None: assert general_fields["enable_sub_agents"].value_type == "boolean" assert general_fields["enable_sub_agents"].default is False assert general_fields["enable_sub_agents"].prominence is SettingProminence.MAJOR + assert general_fields["enable_switch_llm_tool"].value_type == "boolean" + assert general_fields["enable_switch_llm_tool"].default is True + assert ( + general_fields["enable_switch_llm_tool"].prominence is SettingProminence.MINOR + ) # -- llm section -- llm_fields = {f.key: f for f in sections["llm"].fields} @@ -255,7 +261,13 @@ def test_export_agent_settings_schema_emits_variant_tagged_sections() -> None: general = by_keyvariant.get(("general", None)) assert general is not None general_keys = {f.key for f in general.fields} - assert general_keys == {"agent", "tools", "enable_sub_agents", "mcp_config"} + assert general_keys == { + "agent", + "tools", + "enable_sub_agents", + "enable_switch_llm_tool", + "mcp_config", + } # No agent_kind field — each variant has its own settings page and # injects the discriminator on save. assert "agent_kind" not in general_keys diff --git a/tests/sdk/tool/test_switch_llm.py b/tests/sdk/tool/test_switch_llm.py index c951eb7101..0cd8bbd09a 100644 --- a/tests/sdk/tool/test_switch_llm.py +++ b/tests/sdk/tool/test_switch_llm.py @@ -2,7 +2,7 @@ import pytest -from openhands.sdk import LLM, LocalConversation +from openhands.sdk import LLM, LocalConversation, OpenHandsAgentSettings from openhands.sdk.agent import Agent from openhands.sdk.llm import llm_profile_store from openhands.sdk.llm.llm_profile_store import LLMProfileStore @@ -19,15 +19,20 @@ def _make_llm(model: str, usage_id: str) -> LLM: @pytest.fixture() -def profile_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> LLMProfileStore: +def empty_profile_store( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> LLMProfileStore: profile_dir = tmp_path / "profiles" profile_dir.mkdir() monkeypatch.setattr(llm_profile_store, "_DEFAULT_PROFILE_DIR", profile_dir) + return LLMProfileStore(base_dir=profile_dir) - store = LLMProfileStore(base_dir=profile_dir) - store.save("fast", _make_llm("fast-model", "fast")) - store.save("slow", _make_llm("slow-model", "slow")) - return store + +@pytest.fixture() +def profile_store(empty_profile_store: LLMProfileStore) -> LLMProfileStore: + empty_profile_store.save("fast", _make_llm("fast-model", "fast")) + empty_profile_store.save("slow", _make_llm("slow-model", "slow")) + return empty_profile_store def _make_conversation() -> LocalConversation: @@ -49,6 +54,43 @@ def test_switch_llm_tool_description_lists_available_profiles(profile_store): assert "- slow" in tool.description +def test_agent_settings_includes_switch_llm_tool_when_profiles_exist(profile_store): + agent = OpenHandsAgentSettings( + llm=_make_llm("default-model", "default") + ).create_agent() + + assert "SwitchLLMTool" in agent.include_default_tools + + conversation = LocalConversation(agent=agent, workspace=Path.cwd()) + conversation._ensure_agent_ready() + assert "switch_llm" in agent.tools_map + + +def test_agent_settings_omits_switch_llm_tool_when_disabled(profile_store): + agent = OpenHandsAgentSettings( + llm=_make_llm("default-model", "default"), + enable_switch_llm_tool=False, + ).create_agent() + + assert "SwitchLLMTool" not in agent.include_default_tools + + conversation = LocalConversation(agent=agent, workspace=Path.cwd()) + conversation._ensure_agent_ready() + assert "switch_llm" not in agent.tools_map + + +def test_agent_settings_omits_switch_llm_tool_without_profiles(empty_profile_store): + agent = OpenHandsAgentSettings( + llm=_make_llm("default-model", "default") + ).create_agent() + + assert "SwitchLLMTool" not in agent.include_default_tools + + conversation = LocalConversation(agent=agent, workspace=Path.cwd()) + conversation._ensure_agent_ready() + assert "switch_llm" not in agent.tools_map + + def test_switch_llm_tool_switches_conversation_profile(profile_store): conversation = _make_conversation()