feat(config): add ModelPresetConfig and runtime preset switching

- Add `ModelPresetConfig` schema for named model presets
- Add `model_presets` dict to `Config` and `model_preset` field to `AgentDefaults`
- Add `resolve_preset()` to return effective model params from preset or defaults
- Add `@model_validator` to reject unknown preset names
- Update `_match_provider()` to use resolved preset model/provider
- Update `make_provider()` and `provider_signature()` to use `resolve_preset()`
- Add `model_preset` property to `AgentLoop` for atomic runtime switching
- Update `AgentLoop.from_config()` to inject a runtime `default` preset
- Wire self-tool to inspect/clear preset state
- Update CLI display strings to show active preset
This commit is contained in:
chengyongru
2026-05-12 20:06:22 +08:00
committed by Xubin Ren
parent 1175420339
commit 6f78267c82
8 changed files with 348 additions and 33 deletions
+134
View File
@@ -0,0 +1,134 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.self import MyTool
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ModelPresetConfig
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
provider = MagicMock()
provider.get_default_model.return_value = default_model
provider.generation = SimpleNamespace(
max_tokens=max_tokens, temperature=0.1, reasoning_effort=None
)
return provider
def _make_loop(tmp_path, presets=None, active_preset=None):
provider = _provider("base-model")
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
model_presets=presets or {},
model_preset=active_preset,
)
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
loop = _make_loop(tmp_path)
assert loop.model_preset is None
def test_model_preset_setter_updates_state(tmp_path) -> None:
presets = {
"fast": ModelPresetConfig(
model="openai/gpt-4.1",
provider="openai",
max_tokens=4096,
context_window_tokens=32_768,
temperature=0.5,
reasoning_effort="low",
)
}
loop = _make_loop(tmp_path, presets=presets)
loop.model_preset = "fast"
assert loop.model_preset == "fast"
assert loop.model == "openai/gpt-4.1"
assert loop.context_window_tokens == 32_768
assert loop.provider.generation.temperature == 0.5
assert loop.provider.generation.max_tokens == 4096
assert loop.provider.generation.reasoning_effort == "low"
def test_model_preset_setter_raises_on_unknown(tmp_path) -> None:
loop = _make_loop(tmp_path)
with pytest.raises(KeyError, match="model_preset 'missing' not found"):
loop.model_preset = "missing"
def test_model_preset_setter_raises_on_empty_string(tmp_path) -> None:
loop = _make_loop(tmp_path)
with pytest.raises(ValueError, match="model_preset must be a non-empty string"):
loop.model_preset = ""
def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
presets = {
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = MyTool(runtime_state=loop, modify_allowed=True)
output = tool._inspect_all()
assert "model_preset: 'fast'" in output
def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
presets = {
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets)
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model_preset", "fast")
assert "Error" not in result
assert loop.model_preset == "fast"
assert loop.model == "openai/gpt-4.1"
def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
presets = {
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
}
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
tool = MyTool(runtime_state=loop, modify_allowed=True)
result = tool._modify("model", "anthropic/claude-opus-4-5")
assert "Error" not in result
assert loop._active_preset is None
assert loop.model == "anthropic/claude-opus-4-5"
def test_from_config_injects_default_preset(tmp_path) -> None:
from unittest.mock import patch
from nanobot.config.schema import Config
config = Config.model_validate({
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
})
fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
loop = AgentLoop.from_config(config)
assert "default" in loop.model_presets
assert loop.model_presets["default"].model == "openai/gpt-4.1"
def test_from_config_preserves_existing_default_preset(tmp_path) -> None:
from unittest.mock import patch
from nanobot.config.schema import Config
config = Config.model_validate({
"agents": {"defaults": {"model": "openai/gpt-4.1", "workspace": str(tmp_path)}},
"model_presets": {
"default": {"model": "custom-model"}
},
})
fake_provider = _provider("openai/gpt-4.1")
with patch("nanobot.providers.factory.make_provider", return_value=fake_provider):
loop = AgentLoop.from_config(config)
assert loop.model_presets["default"].model == "custom-model"
+93
View File
@@ -0,0 +1,93 @@
from nanobot.config.schema import Config, ModelPresetConfig
def test_resolve_preset_returns_defaults_when_no_preset() -> None:
config = Config()
resolved = config.resolve_preset()
assert resolved.model == config.agents.defaults.model
assert resolved.provider == config.agents.defaults.provider
assert resolved.max_tokens == config.agents.defaults.max_tokens
assert resolved.context_window_tokens == config.agents.defaults.context_window_tokens
assert resolved.temperature == config.agents.defaults.temperature
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
def test_resolve_preset_returns_active_preset() -> None:
config = Config.model_validate({
"model_presets": {
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
"maxTokens": 4096,
"contextWindowTokens": 32_768,
"temperature": 0.5,
"reasoningEffort": "low",
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
}
},
})
resolved = config.resolve_preset()
assert resolved.model == "openai/gpt-4.1"
assert resolved.provider == "openai"
assert resolved.max_tokens == 4096
assert resolved.context_window_tokens == 32_768
assert resolved.temperature == 0.5
assert resolved.reasoning_effort == "low"
def test_validator_rejects_unknown_preset() -> None:
import pytest
with pytest.raises(ValueError, match="model_preset 'unknown' not found in model_presets"):
Config.model_validate({
"agents": {
"defaults": {
"modelPreset": "unknown",
}
}
})
def test_match_provider_uses_preset_model() -> None:
config = Config.model_validate({
"providers": {
"openai": {"apiKey": "sk-test"},
},
"model_presets": {
"fast": {
"model": "openai/gpt-4.1",
"provider": "openai",
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
}
},
})
name = config.get_provider_name()
assert name == "openai"
def test_match_provider_uses_preset_provider_when_forced() -> None:
config = Config.model_validate({
"providers": {
"anthropic": {"apiKey": "sk-test"},
},
"model_presets": {
"fast": {
"model": "anthropic/claude-opus-4-5",
"provider": "anthropic",
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
}
},
})
name = config.get_provider_name()
assert name == "anthropic"