diff --git a/docs/MY_TOOL.md b/docs/MY_TOOL.md index caac563e9..a8a273d17 100644 --- a/docs/MY_TOOL.md +++ b/docs/MY_TOOL.md @@ -18,11 +18,15 @@ Enabled by default (read-only mode). The agent can check its state but not set i ```yaml tools: - my_enabled: true # default: true - my_set: false # default: false (read-only) + my: + enable: true # default: true + allow_set: false # default: false (read-only) ``` -To allow the agent to set its configuration (e.g. switch models, adjust parameters), set `my_set: true`. +To allow the agent to set its configuration (e.g. switch models, adjust parameters), set `tools.my.allow_set: true`. + +Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and +rewritten in-place the next time `nanobot onboard` refreshes the config. All modifications are held in memory only — restart restores defaults. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4b5d80e6a..28b6131b5 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -248,8 +248,8 @@ class AgentLoop: model=self.model, ) self._register_default_tools() - if _tc.my_enabled: - self.tools.register(MyTool(loop=self, modify_allowed=_tc.my_set)) + if _tc.my.enable: + self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set)) self._runtime_vars: dict[str, Any] = {} self._current_iteration: int = 0 self.commands = CommandRouter() diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index 6d863fea7..20fffa9d1 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -284,7 +284,7 @@ class MyTool(Tool): if action in ("inspect", "check"): return self._inspect(key) if not self._modify_allowed: - return "Error: set is disabled (my_set is False)" + return "Error: set is disabled (tools.my.allow_set is false)" if action in ("modify", "set"): return self._modify(key, value) return f"Unknown action: {action}" diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 618334c1c..4281b9316 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -117,4 +117,19 @@ def _migrate_config(data: dict) -> dict: exec_cfg = tools.get("exec", {}) if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") + + # Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}. + # The old flat keys shipped in the initial MyTool landing; wrapping them in a + # sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow. + if "myEnabled" in tools or "mySet" in tools: + my_cfg = tools.setdefault("my", {}) + if "myEnabled" in tools and "enable" not in my_cfg: + my_cfg["enable"] = tools.pop("myEnabled") + else: + tools.pop("myEnabled", None) + if "mySet" in tools and "allowSet" not in my_cfg: + my_cfg["allowSet"] = tools.pop("mySet") + else: + tools.pop("mySet", None) + return data diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 3e7ba89c0..f6179c597 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -200,16 +200,22 @@ class MCPServerConfig(Base): tool_timeout: int = 30 # seconds before a tool call is cancelled enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp__ names; ["*"] = all tools; [] = no tools +class MyToolConfig(Base): + """Self-inspection tool configuration.""" + + enable: bool = True # register the `my` tool (agent runtime state inspection) + allow_set: bool = False # let `my` modify loop state (read-only if False) + + class ToolsConfig(Base): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) + my: MyToolConfig = Field(default_factory=MyToolConfig) restrict_to_workspace: bool = False # restrict all tool access to workspace directory mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) - my_enabled: bool = True # enable the my tool (agent runtime state inspection) - my_set: bool = False # allow my tool to set state (read-only if False) class Config(BaseSettings): diff --git a/nanobot/skills/my/SKILL.md b/nanobot/skills/my/SKILL.md index 6f83e8e4b..2c06566d8 100644 --- a/nanobot/skills/my/SKILL.md +++ b/nanobot/skills/my/SKILL.md @@ -59,7 +59,7 @@ always: true - All modifications in-memory only — restart resets everything - Protected params have type/range validation: `max_iterations` (1–100), `context_window_tokens` (4096–1M), `model` (non-empty str) -- If `my_set` is false, check only +- If `tools.my.allow_set` is false, check only ## Related tools diff --git a/tests/agent/tools/test_self_tool.py b/tests/agent/tools/test_self_tool.py index 50eb5feaa..f6ae4727f 100644 --- a/tests/agent/tools/test_self_tool.py +++ b/tests/agent/tools/test_self_tool.py @@ -835,7 +835,7 @@ class TestInspectTaskStatuses: # --------------------------------------------------------------------------- -# read-only mode (my_set=False) +# read-only mode (tools.my.allow_set=False) # --------------------------------------------------------------------------- class TestReadOnlyMode: diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index add602c51..b27926ec0 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -140,6 +140,71 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) assert saved["channels"]["qq"]["msgFormat"] == "plain" +def test_load_config_migrates_legacy_my_tool_keys(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": True, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.tools.my.enable is False + assert config.tools.my.allow_set is True + + +def test_save_config_rewrites_legacy_my_tool_keys(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": True, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + save_config(config, config_path) + saved = json.loads(config_path.read_text(encoding="utf-8")) + + tools = saved["tools"] + assert "myEnabled" not in tools + assert "mySet" not in tools + assert tools["my"] == {"enable": False, "allowSet": True} + + +def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps( + { + "tools": { + "myEnabled": False, + "mySet": False, + "my": {"enable": True, "allowSet": True}, + } + } + ), + encoding="utf-8", + ) + + config = load_config(config_path) + + assert config.tools.my.enable is True + assert config.tools.my.allow_set is True + + def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None: whitelisted = tmp_path / "whitelisted.json" whitelisted.write_text(