From 6f78267c825823329b34d5a1c2df5c239e5061a0 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Sat, 9 May 2026 15:30:47 +0800 Subject: [PATCH] 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 --- nanobot/agent/loop.py | 35 ++++++- nanobot/agent/tools/runtime_state.py | 5 + nanobot/agent/tools/self.py | 9 +- nanobot/cli/commands.py | 18 +++- nanobot/config/schema.py | 47 ++++++++- nanobot/providers/factory.py | 40 ++++---- tests/agent/test_self_model_preset.py | 134 ++++++++++++++++++++++++++ tests/config/test_model_presets.py | 93 ++++++++++++++++++ 8 files changed, 348 insertions(+), 33 deletions(-) create mode 100644 tests/agent/test_self_model_preset.py create mode 100644 tests/config/test_model_presets.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bb33868db..9b97ab378 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -34,7 +34,7 @@ from nanobot.agent.tools.self import MyTool from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.command import CommandContext, CommandRouter, register_builtin_commands -from nanobot.config.schema import AgentDefaults +from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot from nanobot.session.manager import Session, SessionManager @@ -291,6 +291,8 @@ class AgentLoop: image_generation_provider_configs: dict[str, ProviderConfig] | None = None, provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None, provider_signature: tuple[object, ...] | None = None, + model_presets: dict[str, ModelPresetConfig] | None = None, + model_preset: str | None = None, ): from nanobot.config.schema import ToolsConfig @@ -395,6 +397,8 @@ class AgentLoop: provider=provider, model=self.model, ) + self.model_presets: dict[str, ModelPresetConfig] = model_presets or {} + self._active_preset: str | None = model_preset if model_presets and model_preset in model_presets else None self._register_default_tools() self._runtime_vars: dict[str, Any] = {} self._current_iteration: int = 0 @@ -420,8 +424,12 @@ class AgentLoop: bus = MessageBus() defaults = config.agents.defaults provider = extra.pop("provider", None) or make_provider(config) - model = extra.pop("model", None) or defaults.model - context_window_tokens = extra.pop("context_window_tokens", None) or defaults.context_window_tokens + resolved = config.resolve_preset() + model = extra.pop("model", None) or resolved.model + context_window_tokens = extra.pop("context_window_tokens", None) or resolved.context_window_tokens + model_presets = dict(config.model_presets) + if "default" not in model_presets: + model_presets["default"] = resolved return cls( bus=bus, provider=provider, @@ -443,6 +451,8 @@ class AgentLoop: consolidation_ratio=defaults.consolidation_ratio, max_messages=defaults.max_messages, tools_config=config.tools, + model_presets=model_presets, + model_preset=defaults.model_preset, **extra, ) @@ -480,6 +490,25 @@ class AgentLoop: return self._apply_provider_snapshot(snapshot) + # -- model_preset property -- + + @property + def model_preset(self) -> str | None: + return self._active_preset + + @model_preset.setter + def model_preset(self, name: str | None) -> None: + """Resolve a preset by name and apply all fields atomically.""" + if not isinstance(name, str) or not name.strip(): + raise ValueError("model_preset must be a non-empty string") + if name not in self.model_presets: + raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(self.model_presets) or '(none)'}") + p = self.model_presets[name] + self.model = p.model + self.context_window_tokens = p.context_window_tokens + self.provider.generation = p.to_generation_settings() + self._active_preset = name + def _register_default_tools(self) -> None: """Register the default set of tools via plugin loader.""" from nanobot.agent.tools.context import ToolContext diff --git a/nanobot/agent/tools/runtime_state.py b/nanobot/agent/tools/runtime_state.py index f98c3f737..b3c24ac46 100644 --- a/nanobot/agent/tools/runtime_state.py +++ b/nanobot/agent/tools/runtime_state.py @@ -52,3 +52,8 @@ class RuntimeState(Protocol): def _last_usage(self) -> Any: ... def _sync_subagent_runtime_limits(self) -> None: ... + + @property + def model_preset(self) -> str | None: ... + + _active_preset: str | None diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index 2b69d84d5..2712df0dc 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -347,6 +347,7 @@ class MyTool(Tool, ContextAware): # RESTRICTED keys for k in self.RESTRICTED: parts.append(self._format_value(getattr(state, k, None), k)) + parts.append(self._format_value(state.model_preset, "model_preset")) # Other useful top-level keys shown in description for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"): if _has_real_attr(state, k): @@ -411,6 +412,8 @@ class MyTool(Tool, ContextAware): if "min_len" in spec and len(str(value)) < spec["min_len"]: return f"Error: '{key}' must be at least {spec['min_len']} characters" setattr(self._runtime_state, key, value) + if key == "model": + self._runtime_state._active_preset = None if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"): self._runtime_state._sync_subagent_runtime_limits() self._audit("modify", f"{key}: {old!r} -> {value!r}") @@ -429,7 +432,11 @@ class MyTool(Tool, ContextAware): f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}", ) return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}" - setattr(self._runtime_state, key, value) + try: + setattr(self._runtime_state, key, value) + except (ValueError, KeyError) as e: + self._audit("modify", f"REJECTED {key}: {e}") + return f"Error: {e}" self._audit("modify", f"{key}: {old!r} -> {value!r}") return f"Set {key} = {value!r} (was {old!r})" if callable(value): diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index df3f5beaf..da829f62e 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -448,6 +448,14 @@ def _onboard_plugins(config_path: Path) -> None: json.dump(data, f, indent=2, ensure_ascii=False) +def _model_display(config: Config) -> tuple[str, str]: + """Return (resolved_model_name, preset_tag) for display strings.""" + resolved = config.resolve_preset() + name = config.agents.defaults.model_preset + tag = f" (preset: {name})" if name else "" + return resolved.model, tag + + def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: """Load config and optionally override the active workspace.""" from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path @@ -556,10 +564,10 @@ def serve( console.print(f"[red]Error: {exc}[/red]") raise typer.Exit(1) from exc - model_name = runtime_config.agents.defaults.model + model_name, preset_tag = _model_display(runtime_config) console.print(f"{__logo__} Starting OpenAI-compatible API server") console.print(f" [cyan]Endpoint[/cyan] : http://{host}:{port}/v1/chat/completions") - console.print(f" [cyan]Model[/cyan] : {model_name}") + console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}") console.print(" [cyan]Session[/cyan] : api:default") console.print(f" [cyan]Timeout[/cyan] : {timeout}s") if host in {"0.0.0.0", "::"}: @@ -1086,7 +1094,8 @@ def agent( # Interactive mode — route through bus like other channels from nanobot.bus.events import InboundMessage _init_prompt_session() - console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n") + _model, _preset_tag = _model_display(config) + console.print(f"{__logo__} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n") if ":" in session_id: cli_channel, cli_chat_id = session_id.split(":", 1) @@ -1448,7 +1457,8 @@ def status(): if config_path.exists(): from nanobot.providers.registry import PROVIDERS - console.print(f"Model: {config.agents.defaults.model}") + _model, _preset_tag = _model_display(config) + console.print(f"Model: {_model}{_preset_tag}") # Check API keys from registry for spec in PROVIDERS: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ee61cf849..b688c820e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING, Any, Literal -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings @@ -73,10 +73,30 @@ class DreamConfig(Base): return f"every {hours}h" +class ModelPresetConfig(Base): + """A named set of model + generation parameters for quick switching.""" + + model: str + provider: str = "auto" + max_tokens: int = 8192 + context_window_tokens: int = 65_536 + temperature: float = 0.1 + reasoning_effort: str | None = None + + def to_generation_settings(self) -> Any: + from nanobot.providers.base import GenerationSettings + return GenerationSettings( + temperature=self.temperature, + max_tokens=self.max_tokens, + reasoning_effort=self.reasoning_effort, + ) + + class AgentDefaults(Base): """Default agent configuration.""" workspace: str = "~/.nanobot/workspace" + model_preset: str | None = None # Active preset name — takes precedence over fields below model: str = "anthropic/claude-opus-4-5" provider: str = ( "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection @@ -254,6 +274,26 @@ class Config(BaseSettings): api: ApiConfig = Field(default_factory=ApiConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) + model_presets: dict[str, ModelPresetConfig] = Field(default_factory=dict) + + @model_validator(mode="after") + def _validate_model_preset(self) -> "Config": + name = self.agents.defaults.model_preset + if name and name not in self.model_presets: + raise ValueError(f"model_preset {name!r} not found in model_presets") + return self + + def resolve_preset(self) -> ModelPresetConfig: + """Return effective model params: from active preset, or individual defaults.""" + name = self.agents.defaults.model_preset + if name: + return self.model_presets[name] + d = self.agents.defaults + return ModelPresetConfig( + model=d.model, provider=d.provider, max_tokens=d.max_tokens, + context_window_tokens=d.context_window_tokens, + temperature=d.temperature, reasoning_effort=d.reasoning_effort, + ) @property def workspace_path(self) -> Path: @@ -266,7 +306,8 @@ class Config(BaseSettings): """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS, find_by_name - forced = self.agents.defaults.provider + resolved = self.resolve_preset() + forced = resolved.provider if forced != "auto": spec = find_by_name(forced) if spec: @@ -274,7 +315,7 @@ class Config(BaseSettings): return (p, spec.name) if p else (None, None) return None, None - model_lower = (model or self.agents.defaults.model).lower() + model_lower = (model or resolved.model).lower() model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index d71390940..1257eb3a5 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from pathlib import Path from nanobot.config.schema import Config -from nanobot.providers.base import GenerationSettings, LLMProvider +from nanobot.providers.base import LLMProvider from nanobot.providers.registry import find_by_name @@ -20,7 +20,8 @@ class ProviderSnapshot: def make_provider(config: Config) -> LLMProvider: """Create the LLM provider implied by config.""" - model = config.agents.defaults.model + resolved = config.resolve_preset() + model = resolved.model provider_name = config.get_provider_name(model) p = config.get_provider(model) spec = find_by_name(provider_name) if provider_name else None @@ -83,42 +84,37 @@ def make_provider(config: Config) -> LLMProvider: extra_body=p.extra_body if p else None, ) - defaults = config.agents.defaults - provider.generation = GenerationSettings( - temperature=defaults.temperature, - max_tokens=defaults.max_tokens, - reasoning_effort=defaults.reasoning_effort, - ) + provider.generation = resolved.to_generation_settings() return provider def provider_signature(config: Config) -> tuple[object, ...]: """Return the config fields that affect the primary LLM provider.""" - model = config.agents.defaults.model - defaults = config.agents.defaults - p = config.get_provider(model) + resolved = config.resolve_preset() + p = config.get_provider(resolved.model) return ( - model, - defaults.provider, - config.get_provider_name(model), - config.get_api_key(model), - config.get_api_base(model), + resolved.model, + resolved.provider, + config.get_provider_name(resolved.model), + config.get_api_key(resolved.model), + config.get_api_base(resolved.model), p.extra_headers if p else None, p.extra_body if p else None, getattr(p, "region", None) if p else None, getattr(p, "profile", None) if p else None, - defaults.max_tokens, - defaults.temperature, - defaults.reasoning_effort, - defaults.context_window_tokens, + resolved.max_tokens, + resolved.temperature, + resolved.reasoning_effort, + resolved.context_window_tokens, ) def build_provider_snapshot(config: Config) -> ProviderSnapshot: + resolved = config.resolve_preset() return ProviderSnapshot( provider=make_provider(config), - model=config.agents.defaults.model, - context_window_tokens=config.agents.defaults.context_window_tokens, + model=resolved.model, + context_window_tokens=resolved.context_window_tokens, signature=provider_signature(config), ) diff --git a/tests/agent/test_self_model_preset.py b/tests/agent/test_self_model_preset.py new file mode 100644 index 000000000..fa81ab8e6 --- /dev/null +++ b/tests/agent/test_self_model_preset.py @@ -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" diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py new file mode 100644 index 000000000..44713acb6 --- /dev/null +++ b/tests/config/test_model_presets.py @@ -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"