From ef6da0a94e7160e3cb8ac160f23e3cf064b4bb57 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 15 Jun 2026 17:42:11 +0800 Subject: [PATCH] Fix my tool model preset switching --- docs/my-tool.md | 14 ++++++++--- nanobot/agent/tools/self.py | 32 +++++++++++++++++++++--- nanobot/skills/my/SKILL.md | 4 ++- nanobot/skills/my/references/examples.md | 11 +++++++- tests/agent/test_self_model_preset.py | 32 ++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) diff --git a/docs/my-tool.md b/docs/my-tool.md index 8a72645e..483213df 100644 --- a/docs/my-tool.md +++ b/docs/my-tool.md @@ -57,6 +57,9 @@ my(action="check", key="_last_usage.prompt_tokens") my(action="check", key="model") # → What model I'm currently running on +my(action="check", key="model_preset") +# → Which named model preset is active, or None/default + my(action="check", key="web_config.enable") # → Whether web search is enabled ``` @@ -66,6 +69,7 @@ my(action="check", key="web_config.enable") | Scenario | How | |----------|-----| | "What model are you using?" | `check("model")` | +| "Which model preset is active?" | `check("model_preset")` | | "How many more tool calls can you make?" | `check("max_iterations")` minus `check("_current_iteration")` | | "How many tokens has this conversation used?" | `check("_last_usage")` — cumulative across all turns | | "Where is your working directory?" | `check("workspace")` | @@ -82,8 +86,11 @@ Changes take effect immediately, no restart required. my(action="set", key="max_iterations", value=80) # → Bump iteration limit from 40 to 80 +my(action="set", key="model_preset", value="fast") +# → Switch to a configured model preset + my(action="set", key="model", value="fast-model") -# → Switch to a faster model +# → Switch to a raw model and clear the active preset my(action="set", key="context_window_tokens", value=131072) # → Expand context window for long documents @@ -107,6 +114,7 @@ These parameters have type and range validation — invalid values are rejected: | `max_iterations` | int | 1–100 | Max tool calls per conversation turn | | `context_window_tokens` | int | 4,096–1,000,000 | Context window size | | `model` | str | non-empty | LLM model to use | +| `model_preset` | str | configured preset name | Named preset to use | Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe. @@ -124,8 +132,8 @@ Agent: This codebase is large, let me expand my context window to handle it. ### "Simple question, don't waste compute" ```text -Agent: This is a straightforward question, let me switch to a faster model. -→ my(action="set", key="model", value="fast-model") +Agent: This is a straightforward question, let me switch to the fast preset. +→ my(action="set", key="model_preset", value="fast") ``` ### "Remember user preferences across turns" diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index 1e60b345..96fc6d80 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -148,6 +148,7 @@ class MyTool(Tool, ContextAware): "\n" "When to use:\n" "- User asks about your model, settings, or token usage → check that key.\n" + "- User asks to switch to a named model preset → set model_preset to that preset name.\n" "- A tool fails or behaves unexpectedly → check the related config to diagnose.\n" "- User asks you to remember a preference for this session → set to store it in your scratchpad.\n" "- About to start a large task → check context_window_tokens and max_iterations first." @@ -175,9 +176,9 @@ class MyTool(Tool, ContextAware): "key": { "type": "string", "description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. " - "For check without key, shows all config values.", + "Use 'model_preset' to switch named model presets. For check without key, shows all config values.", }, - "value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."}, + "value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model/model_preset)."}, }, "required": ["action"], } @@ -399,10 +400,28 @@ class MyTool(Tool, ContextAware): setattr(parent, leaf, value) self._audit("modify", f"{key} = {value!r}") return f"Set {key} = {value!r}" + if key == "model_preset": + return self._modify_model_preset(value) if key in self.RESTRICTED: return self._modify_restricted(key, value) return self._modify_free(key, value) + def _modify_model_preset(self, value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + return "Error: 'model_preset' must be a non-empty string" + name = value.strip() + result = self._modify_free("model_preset", name) + if result.startswith("Error:"): + return result if result.endswith((".", "!", "?")) else f"{result}." + model = getattr(self._runtime_state, "model", None) + context_window = getattr(self._runtime_state, "context_window_tokens", None) + details = [result] + if model is not None: + details.append(f"model is now {model!r}") + if context_window is not None: + details.append(f"context_window_tokens is now {context_window!r}") + return "; ".join(details) + def _modify_restricted(self, key: str, value: Any) -> str: spec = self.RESTRICTED[key] expected = spec["type"] @@ -444,8 +463,9 @@ class MyTool(Tool, ContextAware): try: setattr(self._runtime_state, key, value) except (ValueError, KeyError) as e: - self._audit("modify", f"REJECTED {key}: {e}") - return f"Error: {e}" + message = self._exception_message(e) + self._audit("modify", f"REJECTED {key}: {message}") + return f"Error: {message}" self._audit("modify", f"{key}: {old!r} -> {value!r}") return f"Set {key} = {value!r} (was {old!r})" if callable(value): @@ -463,6 +483,10 @@ class MyTool(Tool, ContextAware): self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}") return f"Set scratchpad.{key} = {value!r}" + @staticmethod + def _exception_message(exc: Exception) -> str: + return str(exc.args[0] if isinstance(exc, KeyError) and exc.args else exc).strip('"') + @classmethod def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None: if depth > 10: diff --git a/nanobot/skills/my/SKILL.md b/nanobot/skills/my/SKILL.md index 2c06566d..be1e4846 100644 --- a/nanobot/skills/my/SKILL.md +++ b/nanobot/skills/my/SKILL.md @@ -36,7 +36,8 @@ always: true | Situation | Command | |-----------|---------| | Large codebase analysis | `my(action="set", key="context_window_tokens", value=131072)` | -| Repetitive simple tasks | `my(action="set", key="model", value="")` | +| Switch to a named model preset | `my(action="set", key="model_preset", value="")` | +| Repetitive simple tasks without a preset | `my(action="set", key="model", value="")` | | Long multi-step task | `my(action="set", key="max_iterations", value=80)` | **Tradeoff:** Bias toward stability. Only set when defaults are genuinely insufficient. @@ -58,6 +59,7 @@ always: true ## Constraints - All modifications in-memory only — restart resets everything +- Prefer `model_preset` for configured model choices. Direct `model` changes clear the active preset and should only be used when no preset exists. - Protected params have type/range validation: `max_iterations` (1–100), `context_window_tokens` (4096–1M), `model` (non-empty str) - If `tools.my.allow_set` is false, check only diff --git a/nanobot/skills/my/references/examples.md b/nanobot/skills/my/references/examples.md index 9f8e8d08..aecd6881 100644 --- a/nanobot/skills/my/references/examples.md +++ b/nanobot/skills/my/references/examples.md @@ -24,6 +24,8 @@ Concrete scenarios showing when and how to use the my tool effectively. ``` → my(action="check", key="model") → 'anthropic/claude-sonnet-4-20250514' +→ my(action="check", key="model_preset") + → 'deep' ``` ## Adaptive Behavior @@ -37,7 +39,14 @@ Concrete scenarios showing when and how to use the my tool effectively. → "I've expanded my context window to handle this large codebase." ``` -### Switching to a faster model for repetitive tasks +### Switching to a configured model preset +``` +→ my(action="set", key="model_preset", value="fast") + → "Set model_preset = 'fast' (was 'deep'); model is now 'openai/gpt-4.1-mini'" +→ "Switched to the fast preset for these batch tasks." +``` + +### Switching to a raw model when no preset exists ``` → my(action="set", key="model", value="anthropic/claude-haiku-4-5-20251001") → "Set model = 'anthropic/claude-haiku-4-5-20251001' (was 'anthropic/claude-sonnet-4-20250514')" diff --git a/tests/agent/test_self_model_preset.py b/tests/agent/test_self_model_preset.py index 1ba6f42e..4fa25af2 100644 --- a/tests/agent/test_self_model_preset.py +++ b/tests/agent/test_self_model_preset.py @@ -247,6 +247,38 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None: assert loop.model == "openai/gpt-4.1" +def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None: + presets = { + "default": ModelPresetConfig(model="base-model", context_window_tokens=1000), + "fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768), + } + loop = _make_loop(tmp_path, presets=presets, active_preset="fast") + tool = MyTool(runtime_state=loop, modify_allowed=True) + + result = tool._modify("model_preset", "default") + + assert "Error" not in result + assert "model is now 'base-model'" in result + assert loop.model_preset == "default" + assert loop.model == "base-model" + assert loop.context_window_tokens == 1000 + + +def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None: + presets = { + "default": ModelPresetConfig(model="base-model"), + "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", "missing") + + assert result == "Error: model_preset 'missing' not found. Available: default, fast." + assert loop.model_preset is None + assert loop.model == "base-model" + + def test_self_tool_set_model_clears_active_preset(tmp_path) -> None: presets = { "fast": ModelPresetConfig(model="openai/gpt-4.1"),