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:
chengyongru 2026-04-14 22:20:37 +08:00
parent 24f903b979
commit 6fb95b3328
6 changed files with 103 additions and 12 deletions

View File

@ -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,

View File

@ -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

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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