feat(agent): make model presets session-scoped (#4866)

This commit is contained in:
chengyongru
2026-07-23 00:38:49 +08:00
committed by GitHub
parent 66690fdb0c
commit c22efb5f7a
41 changed files with 1140 additions and 155 deletions
+77 -10
View File
@@ -16,6 +16,10 @@ from nanobot.command.builtin import (
)
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.config.schema import ModelPresetConfig
from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata,
)
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
@@ -29,7 +33,7 @@ def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
return provider
def _make_loop(tmp_path) -> AgentLoop:
def _make_loop(tmp_path, *, preset_snapshot_loader=None) -> AgentLoop:
return AgentLoop(
bus=MessageBus(),
provider=_provider("base-model", max_tokens=123),
@@ -48,6 +52,7 @@ def _make_loop(tmp_path) -> AgentLoop:
context_window_tokens=32_768,
),
},
preset_snapshot_loader=preset_snapshot_loader,
)
@@ -64,6 +69,11 @@ def _ctx_session(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
)
def _saved_model_preset(loop: AgentLoop, session_key: str = "cli:direct") -> str | None:
session = loop.sessions.get_or_create(session_key)
return model_preset_from_metadata(session.metadata)
@pytest.mark.asyncio
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
loop = _make_loop(tmp_path)
@@ -84,23 +94,28 @@ async def test_model_command_switches_preset(tmp_path) -> None:
out = await cmd_model(_ctx(loop, "/model fast", args="fast"))
assert "Switched model preset to `fast`." in out.content
assert "Scope: current session" in out.content
assert "Model: `openai/gpt-4.1`" in out.content
assert loop.model_preset == "fast"
assert loop.model == "openai/gpt-4.1"
assert not hasattr(loop.subagents, "model")
assert not hasattr(loop.consolidator, "model")
assert loop.llm_runtime().model == "openai/gpt-4.1"
assert _saved_model_preset(loop) == "fast"
assert loop.model_preset is None
assert loop.model == "base-model"
await loop.process_direct("/new", session_key="cli:direct")
assert _saved_model_preset(loop) == "fast"
status = await loop.process_direct("/status", session_key="cli:direct")
assert status is not None and "openai/gpt-4.1" in status.content
@pytest.mark.asyncio
async def test_model_command_switches_back_to_default(tmp_path) -> None:
loop = _make_loop(tmp_path)
loop.set_model_preset("fast")
await cmd_model(_ctx(loop, "/model fast", args="fast"))
out = await cmd_model(_ctx(loop, "/model default", args="default"))
assert "Switched model preset to `default`." in out.content
assert loop.model_preset == "default"
assert _saved_model_preset(loop) == "default"
assert loop.model_preset is None
assert loop.model == "base-model"
assert loop.context_window_tokens == 1000
@@ -118,6 +133,24 @@ async def test_model_command_unknown_preset_keeps_old_state(tmp_path) -> None:
assert loop.model == "base-model"
@pytest.mark.asyncio
async def test_model_command_reports_provider_configuration_errors(tmp_path) -> None:
def fail_preset(_name: str):
raise ValueError("No API key configured for provider 'openai'.")
loop = _make_loop(tmp_path, preset_snapshot_loader=fail_preset)
switched = await cmd_model(_ctx(loop, "/model fast", args="fast"))
session = loop.sessions.get_or_create("cli:direct")
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "fast"
status = await cmd_model(_ctx(loop, "/model"))
assert "Could not switch model preset" in switched.content
assert "No API key configured for provider 'openai'." in switched.content
assert "Current selection error" in status.content
assert "No API key configured for provider 'openai'." in status.content
@pytest.mark.asyncio
async def test_model_command_does_not_depend_on_my_allow_set(tmp_path) -> None:
loop = _make_loop(tmp_path)
@@ -125,7 +158,7 @@ async def test_model_command_does_not_depend_on_my_allow_set(tmp_path) -> None:
await cmd_model(_ctx(loop, "/model fast", args="fast"))
assert loop.model_preset == "fast"
assert _saved_model_preset(loop) == "fast"
@pytest.mark.asyncio
@@ -142,11 +175,45 @@ async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None:
assert out.metadata == {"render_as": "text"}
assert out.content == "\n".join([
"Switched model preset to `fast`.",
"- Scope: current session",
"- Model: `openai/gpt-4.1`",
"- Context window: 32768",
"- Max output tokens: 4096",
])
assert loop.model_preset == "fast"
assert _saved_model_preset(loop) == "fast"
@pytest.mark.asyncio
async def test_model_command_does_not_change_another_session(tmp_path) -> None:
loop = _make_loop(tmp_path)
await cmd_model(_ctx(loop, "/model fast", args="fast"))
other = InboundMessage(channel="cli", sender_id="user", chat_id="other", content="/model")
out = await cmd_model(
CommandContext(msg=other, session=None, key=other.session_key, raw="/model", loop=loop)
)
assert "Current preset: `default`" in out.content
assert _saved_model_preset(loop) == "fast"
@pytest.mark.asyncio
async def test_model_command_reports_and_recovers_removed_session_preset(tmp_path) -> None:
loop = _make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:direct")
session.metadata[SESSION_MODEL_PRESET_METADATA_KEY] = "removed"
loop.sessions.save(session)
status = await loop.process_direct("/model", session_key="cli:direct")
switched = await loop.process_direct("/model default", session_key="cli:direct")
assert status is not None
assert "model_preset 'removed' not found" in status.content
assert "Available presets: `default`, `fast`" in status.content
assert "Switch with `/model <preset>`" in status.content
assert switched is not None
assert "Switched model preset to `default`." in switched.content
assert _saved_model_preset(loop) == "default"
def test_model_command_in_help_and_palette() -> None: