mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-15 07:29:52 +00:00
feat(self-tool): add self_modify toggle to disable write operations
Add `self_modify: bool = True` config option. When disabled, the self tool operates in read-only mode — inspect, list_tools, and list_snapshots still work, but modify, call, manage_tool, snapshot, restore, and reset are blocked. Provides a safer middle ground between full self_evolution and disabling the tool entirely.
This commit is contained in:
parent
24f903b979
commit
6fb95b3328
@ -161,6 +161,7 @@ class AgentLoop:
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
self_evolution: bool = False,
|
||||
self_modify: bool = True,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
||||
|
||||
@ -248,7 +249,7 @@ class AgentLoop:
|
||||
)
|
||||
self._register_default_tools()
|
||||
if self_evolution:
|
||||
self.tools.register(SelfTool(loop=self))
|
||||
self.tools.register(SelfTool(loop=self, modify_allowed=self_modify))
|
||||
self._config_defaults: dict[str, Any] = {
|
||||
"max_iterations": self.max_iterations,
|
||||
"context_window_tokens": self.context_window_tokens,
|
||||
|
||||
@ -60,8 +60,9 @@ class SelfTool(Tool):
|
||||
_MAX_RUNTIME_KEYS = 64
|
||||
_CALL_TIMEOUT_SECONDS = 30
|
||||
|
||||
def __init__(self, loop: AgentLoop) -> None:
|
||||
def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None:
|
||||
self._loop = loop
|
||||
self._modify_allowed = modify_allowed
|
||||
self._channel = ""
|
||||
self._chat_id = ""
|
||||
|
||||
@ -70,6 +71,7 @@ class SelfTool(Tool):
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
result._loop = self._loop
|
||||
result._modify_allowed = self._modify_allowed
|
||||
result._channel = self._channel
|
||||
result._chat_id = self._chat_id
|
||||
return result
|
||||
@ -84,18 +86,24 @@ class SelfTool(Tool):
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
base = (
|
||||
"Inspect and modify your own runtime state, navigate related objects, "
|
||||
"invoke methods, and save/restore configuration snapshots. "
|
||||
"Use 'inspect' with dot-path to explore (e.g. 'subagents._running_tasks'), "
|
||||
"'modify' to change values, 'call' to invoke methods, "
|
||||
"'list_tools' to see registered tools, 'manage_tool' to register/unregister, "
|
||||
"'snapshot'/'restore' to save/load config templates.\n"
|
||||
"IMPORTANT: Before modifying state or invoking methods, predict the potential impact. "
|
||||
"If the operation could cause crashes, data loss, or instability "
|
||||
"(e.g. replacing tools, changing model, resetting critical config), "
|
||||
"warn the user about the risk BEFORE executing and ask for confirmation."
|
||||
"'snapshot'/'restore' to save/load config templates."
|
||||
)
|
||||
if not self._modify_allowed:
|
||||
base += "\nREAD-ONLY MODE: modify, call, manage_tool, snapshot, restore, and reset are disabled."
|
||||
else:
|
||||
base += (
|
||||
"\nIMPORTANT: Before modifying state or invoking methods, predict the potential impact. "
|
||||
"If the operation could cause crashes, data loss, or instability "
|
||||
"(e.g. replacing tools, changing model, resetting critical config), "
|
||||
"warn the user about the risk BEFORE executing and ask for confirmation."
|
||||
)
|
||||
return base
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
@ -297,20 +305,23 @@ class SelfTool(Tool):
|
||||
) -> str:
|
||||
if action == "inspect":
|
||||
return self._inspect(key)
|
||||
if action == "list_tools":
|
||||
return self._list_tools()
|
||||
if action == "list_snapshots":
|
||||
return self._list_snapshots()
|
||||
# Write operations — require modify_allowed
|
||||
if not self._modify_allowed and action in ("modify", "call", "manage_tool", "reset", "snapshot", "restore"):
|
||||
return "Error: modify actions are disabled (self_modify is False)"
|
||||
if action == "modify":
|
||||
return self._modify(key, value)
|
||||
if action == "call":
|
||||
return await self._call(method, args)
|
||||
if action == "list_tools":
|
||||
return self._list_tools()
|
||||
if action == "manage_tool":
|
||||
return self._manage_tool(manage_action, name)
|
||||
if action == "snapshot":
|
||||
return self._snapshot(name)
|
||||
if action == "restore":
|
||||
return self._restore(name)
|
||||
if action == "list_snapshots":
|
||||
return self._list_snapshots()
|
||||
if action == "reset":
|
||||
return self._reset(key)
|
||||
# Backward compat aliases
|
||||
|
||||
@ -594,6 +594,7 @@ def serve(
|
||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
||||
self_evolution=runtime_config.tools.self_evolution,
|
||||
self_modify=runtime_config.tools.self_modify,
|
||||
)
|
||||
|
||||
model_name = runtime_config.agents.defaults.model
|
||||
@ -689,6 +690,7 @@ def gateway(
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
self_evolution=config.tools.self_evolution,
|
||||
self_modify=config.tools.self_modify,
|
||||
)
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
@ -924,6 +926,7 @@ def agent(
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
self_evolution=config.tools.self_evolution,
|
||||
self_modify=config.tools.self_modify,
|
||||
)
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||
|
||||
@ -207,6 +207,7 @@ class ToolsConfig(Base):
|
||||
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)
|
||||
self_evolution: bool = False # enable the self tool (agent can inspect/modify its own runtime state)
|
||||
self_modify: bool = True # allow self tool to modify state/call methods (read-only if False)
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
|
||||
@ -85,6 +85,7 @@ class Nanobot:
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
self_evolution=config.tools.self_evolution,
|
||||
self_modify=config.tools.self_modify,
|
||||
)
|
||||
return cls(loop)
|
||||
|
||||
|
||||
@ -1056,3 +1056,77 @@ class TestInspectTaskStatuses:
|
||||
assert "xyz" in result
|
||||
assert "search code" in result
|
||||
assert "stop_reason: completed" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read-only mode (self_modify=False)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadOnlyMode:
|
||||
|
||||
def _make_readonly_tool(self):
|
||||
loop = _make_mock_loop()
|
||||
return SelfTool(loop=loop, modify_allowed=False)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_allowed_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="inspect", key="max_iterations")
|
||||
assert "40" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_allowed_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="list_tools")
|
||||
assert "read_file" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_snapshots_allowed_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="list_snapshots")
|
||||
assert "No snapshots" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="modify", key="max_iterations", value=80)
|
||||
assert "disabled" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="call", method="subagents.get_running_count")
|
||||
assert "disabled" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_tool_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="manage_tool", name="web_search", manage_action="unregister")
|
||||
assert "disabled" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="snapshot", name="test")
|
||||
assert "disabled" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="restore", name="test")
|
||||
assert "disabled" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_blocked_in_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
result = await tool.execute(action="reset", key="max_iterations")
|
||||
assert "disabled" in result
|
||||
|
||||
def test_description_shows_readonly(self):
|
||||
tool = self._make_readonly_tool()
|
||||
assert "READ-ONLY MODE" in tool.description
|
||||
|
||||
def test_description_shows_warning_when_modify_allowed(self):
|
||||
tool = _make_tool()
|
||||
assert "IMPORTANT" in tool.description
|
||||
assert "READ-ONLY" not in tool.description
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user