Skip to content

Commit 288639f

Browse files
feat(sdk): gate switch llm default tool
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 177edd2 commit 288639f

4 files changed

Lines changed: 93 additions & 10 deletions

File tree

openhands-sdk/openhands/sdk/settings/model.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,21 @@ class OpenHandsAgentSettings(AgentSettingsBase):
713713
).model_dump()
714714
},
715715
)
716+
enable_switch_llm_tool: bool = Field(
717+
default=True,
718+
description=(
719+
"Enable the built-in switch_llm tool when saved LLM profiles are "
720+
"available. The tool is omitted when no profiles exist."
721+
),
722+
json_schema_extra={
723+
SETTINGS_METADATA_KEY: SettingsFieldMetadata(
724+
label="Enable LLM switching tool",
725+
prominence=SettingProminence.MINOR,
726+
variant="openhands",
727+
).model_dump()
728+
},
729+
)
730+
716731
mcp_config: MCPConfig | None = Field(
717732
default=None,
718733
description="MCP server configuration for the agent.",
@@ -782,17 +797,24 @@ def create_agent(self) -> Agent:
782797
agent = settings.create_agent()
783798
"""
784799
from openhands.sdk.agent import Agent
800+
from openhands.sdk.tool.builtins import BUILT_IN_TOOLS, SwitchLLMTool
801+
from openhands.sdk.tool.builtins.switch_llm import has_llm_profiles
785802

786803
# Bypass ``_serialize_mcp_config``: MCP servers need real env/headers.
787804
mcp_config = (
788805
self.mcp_config.model_dump(exclude_none=True, exclude_defaults=True)
789806
if self.mcp_config is not None
790807
else {}
791808
)
809+
include_default_tools = [tool.__name__ for tool in BUILT_IN_TOOLS]
810+
if self.enable_switch_llm_tool and has_llm_profiles():
811+
include_default_tools.append(SwitchLLMTool.__name__)
812+
792813
return Agent(
793814
llm=self.llm,
794815
tools=self.tools,
795816
mcp_config=mcp_config,
817+
include_default_tools=include_default_tools,
796818
agent_context=self.agent_context,
797819
condenser=self.build_condenser(self.llm),
798820
critic=self.build_critic(),

openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ def visualize(self) -> Text:
7676
)
7777

7878

79+
def get_llm_profile_names() -> list[str]:
80+
"""Return saved LLM profile names that can be shown to the agent."""
81+
return [summary["name"] for summary in LLMProfileStore().list_summaries()]
82+
83+
84+
def has_llm_profiles() -> bool:
85+
return bool(get_llm_profile_names())
86+
87+
7988
def _format_profiles(profile_names: Sequence[str]) -> str:
8089
if not profile_names:
8190
return "- No saved LLM profiles are currently available."
@@ -134,9 +143,7 @@ def create(
134143
if params:
135144
raise ValueError("SwitchLLMTool doesn't accept parameters")
136145

137-
profile_names = [
138-
name.removesuffix(".json") for name in LLMProfileStore().list()
139-
]
146+
profile_names = get_llm_profile_names()
140147
return [
141148
cls(
142149
description=_DESCRIPTION_TEMPLATE.format(

tests/sdk/test_settings.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def test_llm_agent_settings_export_schema_groups_sections() -> None:
6666
"agent",
6767
"tools",
6868
"enable_sub_agents",
69+
"enable_switch_llm_tool",
6970
"mcp_config",
7071
}
7172
assert general_fields["agent"].default == "CodeActAgent"
@@ -76,6 +77,11 @@ def test_llm_agent_settings_export_schema_groups_sections() -> None:
7677
assert general_fields["enable_sub_agents"].value_type == "boolean"
7778
assert general_fields["enable_sub_agents"].default is False
7879
assert general_fields["enable_sub_agents"].prominence is SettingProminence.MAJOR
80+
assert general_fields["enable_switch_llm_tool"].value_type == "boolean"
81+
assert general_fields["enable_switch_llm_tool"].default is True
82+
assert (
83+
general_fields["enable_switch_llm_tool"].prominence is SettingProminence.MINOR
84+
)
7985

8086
# -- llm section --
8187
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:
255261
general = by_keyvariant.get(("general", None))
256262
assert general is not None
257263
general_keys = {f.key for f in general.fields}
258-
assert general_keys == {"agent", "tools", "enable_sub_agents", "mcp_config"}
264+
assert general_keys == {
265+
"agent",
266+
"tools",
267+
"enable_sub_agents",
268+
"enable_switch_llm_tool",
269+
"mcp_config",
270+
}
259271
# No agent_kind field — each variant has its own settings page and
260272
# injects the discriminator on save.
261273
assert "agent_kind" not in general_keys

tests/sdk/tool/test_switch_llm.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from openhands.sdk import LLM, LocalConversation
5+
from openhands.sdk import LLM, LocalConversation, OpenHandsAgentSettings
66
from openhands.sdk.agent import Agent
77
from openhands.sdk.llm import llm_profile_store
88
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
@@ -19,15 +19,20 @@ def _make_llm(model: str, usage_id: str) -> LLM:
1919

2020

2121
@pytest.fixture()
22-
def profile_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> LLMProfileStore:
22+
def empty_profile_store(
23+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
24+
) -> LLMProfileStore:
2325
profile_dir = tmp_path / "profiles"
2426
profile_dir.mkdir()
2527
monkeypatch.setattr(llm_profile_store, "_DEFAULT_PROFILE_DIR", profile_dir)
28+
return LLMProfileStore(base_dir=profile_dir)
2629

27-
store = LLMProfileStore(base_dir=profile_dir)
28-
store.save("fast", _make_llm("fast-model", "fast"))
29-
store.save("slow", _make_llm("slow-model", "slow"))
30-
return store
30+
31+
@pytest.fixture()
32+
def profile_store(empty_profile_store: LLMProfileStore) -> LLMProfileStore:
33+
empty_profile_store.save("fast", _make_llm("fast-model", "fast"))
34+
empty_profile_store.save("slow", _make_llm("slow-model", "slow"))
35+
return empty_profile_store
3136

3237

3338
def _make_conversation() -> LocalConversation:
@@ -49,6 +54,43 @@ def test_switch_llm_tool_description_lists_available_profiles(profile_store):
4954
assert "- slow" in tool.description
5055

5156

57+
def test_agent_settings_includes_switch_llm_tool_when_profiles_exist(profile_store):
58+
agent = OpenHandsAgentSettings(
59+
llm=_make_llm("default-model", "default")
60+
).create_agent()
61+
62+
assert "SwitchLLMTool" in agent.include_default_tools
63+
64+
conversation = LocalConversation(agent=agent, workspace=Path.cwd())
65+
conversation._ensure_agent_ready()
66+
assert "switch_llm" in agent.tools_map
67+
68+
69+
def test_agent_settings_omits_switch_llm_tool_when_disabled(profile_store):
70+
agent = OpenHandsAgentSettings(
71+
llm=_make_llm("default-model", "default"),
72+
enable_switch_llm_tool=False,
73+
).create_agent()
74+
75+
assert "SwitchLLMTool" not in agent.include_default_tools
76+
77+
conversation = LocalConversation(agent=agent, workspace=Path.cwd())
78+
conversation._ensure_agent_ready()
79+
assert "switch_llm" not in agent.tools_map
80+
81+
82+
def test_agent_settings_omits_switch_llm_tool_without_profiles(empty_profile_store):
83+
agent = OpenHandsAgentSettings(
84+
llm=_make_llm("default-model", "default")
85+
).create_agent()
86+
87+
assert "SwitchLLMTool" not in agent.include_default_tools
88+
89+
conversation = LocalConversation(agent=agent, workspace=Path.cwd())
90+
conversation._ensure_agent_ready()
91+
assert "switch_llm" not in agent.tools_map
92+
93+
5294
def test_switch_llm_tool_switches_conversation_profile(profile_store):
5395
conversation = _make_conversation()
5496

0 commit comments

Comments
 (0)