Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions openhands-sdk/openhands/sdk/settings/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,21 @@ class OpenHandsAgentSettings(AgentSettingsBase):
).model_dump()
},
)
enable_switch_llm_tool: bool = Field(
default=True,
Comment thread
neubig marked this conversation as resolved.
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.",
Expand Down Expand Up @@ -789,17 +804,24 @@ 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 = (
self.mcp_config.model_dump(exclude_none=True, exclude_defaults=True)
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(),
Expand Down
13 changes: 10 additions & 3 deletions openhands-sdk/openhands/sdk/tool/builtins/switch_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 13 additions & 1 deletion tests/sdk/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand Down
54 changes: 48 additions & 6 deletions tests/sdk/tool/test_switch_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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()

Expand Down
Loading