mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 07:09:19 +03:00
refactor(agent): replace reflective runtime state access (#5319)
This commit is contained in:
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
@@ -34,6 +35,13 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=True,
|
||||
)
|
||||
|
||||
|
||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
assert loop.model_preset is None
|
||||
@@ -240,7 +248,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
||||
"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)
|
||||
tool = _my_tool(loop)
|
||||
output = tool._inspect_all()
|
||||
assert "model_preset: 'fast'" in output
|
||||
|
||||
@@ -250,7 +258,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model_preset", "fast")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset == "fast"
|
||||
@@ -263,7 +271,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
|
||||
"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)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "default")
|
||||
|
||||
@@ -280,7 +288,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = tool._modify("model_preset", "missing")
|
||||
|
||||
@@ -295,7 +303,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||
}
|
||||
loop = _make_loop(tmp_path, presets=presets)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -318,7 +326,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
||||
loop.set_session_model_preset = MagicMock(
|
||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||
)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
with request_context(RequestContext(
|
||||
channel="cli",
|
||||
@@ -343,7 +351,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
||||
value: object,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||
tool = _my_tool(loop)
|
||||
session = loop.sessions.get_or_create("cli:one")
|
||||
|
||||
with request_context(RequestContext(
|
||||
@@ -366,7 +374,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
||||
"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)
|
||||
tool = _my_tool(loop)
|
||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||
assert "Error" not in result
|
||||
assert loop.model_preset is None
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Contract and security regressions for the MyTool runtime boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import (
|
||||
RUNTIME_COMMAND_KEYS,
|
||||
RUNTIME_SNAPSHOT_KEYS,
|
||||
AgentRuntimeControl,
|
||||
RuntimeControl,
|
||||
)
|
||||
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
tools_config=tools_config,
|
||||
)
|
||||
|
||||
|
||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
||||
tool = loop.tools.get("my")
|
||||
assert isinstance(tool, MyTool)
|
||||
return tool
|
||||
|
||||
|
||||
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert isinstance(tool._runtime_control, RuntimeControl)
|
||||
assert isinstance(tool._runtime_control, AgentRuntimeControl)
|
||||
assert tool._runtime_control is not loop
|
||||
assert not hasattr(tool, "_runtime_state")
|
||||
|
||||
|
||||
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
loop.web_config.search.api_key = "search-secret"
|
||||
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
|
||||
loop.unlisted_secret = "loop-secret"
|
||||
|
||||
snapshot = _my_tool(loop)._runtime_control.snapshot()
|
||||
values = snapshot.as_mapping()
|
||||
|
||||
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
|
||||
assert RUNTIME_COMMAND_KEYS == frozenset({
|
||||
"model",
|
||||
"model_preset",
|
||||
"max_iterations",
|
||||
"context_window_tokens",
|
||||
"provider_retry_mode",
|
||||
"max_tool_result_chars",
|
||||
"workspace",
|
||||
})
|
||||
assert "provider" not in values
|
||||
assert "sessions" not in values
|
||||
assert "restrict_to_workspace" not in values
|
||||
assert "unlisted_secret" not in values
|
||||
rendered = repr(values)
|
||||
assert "search-secret" not in rendered
|
||||
assert "proxy-secret" not in rendered
|
||||
assert "loop-secret" not in rendered
|
||||
assert snapshot.web_config["proxy"] == "<configured>"
|
||||
|
||||
|
||||
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
control = _my_tool(loop)._runtime_control
|
||||
snapshot = control.snapshot()
|
||||
search = snapshot.web_config["search"]
|
||||
assert isinstance(search, dict)
|
||||
|
||||
search["provider"] = "mutated"
|
||||
snapshot.exec_config["allow_patterns"] = ["mutated"]
|
||||
snapshot.tool_names.append("mutated")
|
||||
|
||||
refreshed = control.snapshot()
|
||||
refreshed_search = refreshed.web_config["search"]
|
||||
assert isinstance(refreshed_search, dict)
|
||||
assert refreshed_search["provider"] == loop.web_config.search.provider
|
||||
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
|
||||
assert "mutated" not in refreshed.tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
loop.unlisted_control_plane = "internal-secret"
|
||||
original_workspace_root = loop.workspace_scopes.default_workspace
|
||||
tool = _my_tool(loop)
|
||||
|
||||
inspected = await tool.execute(action="check", key="unlisted_control_plane")
|
||||
modified = await tool.execute(
|
||||
action="set",
|
||||
key="unlisted_control_plane",
|
||||
value="scratch-value",
|
||||
)
|
||||
nested = await tool.execute(
|
||||
action="set",
|
||||
key="workspace_scopes.default_workspace",
|
||||
value="elsewhere",
|
||||
)
|
||||
|
||||
assert "internal-secret" not in inspected
|
||||
assert "not found" in inspected
|
||||
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
|
||||
assert loop.unlisted_control_plane == "internal-secret"
|
||||
assert "Error" in nested
|
||||
assert loop.workspace_scopes.default_workspace == original_workspace_root
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
assert ToolsConfig().my.allow_set is False
|
||||
assert tool.parameters == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["check", "set"],
|
||||
"description": "Action to perform",
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
|
||||
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
|
||||
"'request.sender_id' for current routing metadata. 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/model_preset)."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
}
|
||||
assert "READ-ONLY MODE" in tool.description
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert result == "Error: set is disabled (tools.my.allow_set is false)"
|
||||
assert loop.max_iterations != 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
max_iterations = await tool.execute(
|
||||
action="set",
|
||||
key="max_iterations",
|
||||
value=80,
|
||||
)
|
||||
retry_mode = await tool.execute(
|
||||
action="set",
|
||||
key="provider_retry_mode",
|
||||
value="persistent",
|
||||
)
|
||||
scratchpad = await tool.execute(
|
||||
action="set",
|
||||
key="preference",
|
||||
value={"concise": True},
|
||||
)
|
||||
|
||||
assert max_iterations == "Set max_iterations = 80 (was 200)"
|
||||
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
|
||||
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
|
||||
assert loop.max_iterations == 80
|
||||
assert loop.subagents.max_iterations == 80
|
||||
assert loop.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().scratchpad == {
|
||||
"preference": {"concise": True},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
|
||||
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
|
||||
changed = await loop.tools.execute(
|
||||
"my",
|
||||
{"action": "set", "key": "max_iterations", "value": 80},
|
||||
)
|
||||
|
||||
assert checked == "model: 'test-model'"
|
||||
assert changed == "Set max_iterations = 80 (was 200)"
|
||||
assert loop.max_iterations == 80
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
|
||||
loop = _make_loop(tmp_path, allow_set=True)
|
||||
tool = _my_tool(loop)
|
||||
|
||||
result = await tool.execute(action="set", key="workspace", value="elsewhere")
|
||||
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "elsewhere"
|
||||
assert loop.workspace == tmp_path
|
||||
assert loop.workspace_scopes.default_workspace == tmp_path
|
||||
@@ -8,10 +8,12 @@ from types import MappingProxyType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebSearchConfig, WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -27,13 +29,16 @@ def _make_mock_loop(**overrides):
|
||||
loop.workspace = Path("/tmp/workspace")
|
||||
loop.restrict_to_workspace = False
|
||||
loop._start_time = 1000.0
|
||||
loop.exec_config = MagicMock()
|
||||
loop.exec_config = ExecToolConfig()
|
||||
loop.channels_config = MagicMock()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
loop._runtime_vars = {}
|
||||
loop.last_usage = loop._last_usage
|
||||
loop._current_iteration = 0
|
||||
loop.current_iteration = loop._current_iteration
|
||||
loop.provider_retry_mode = "standard"
|
||||
loop.max_tool_result_chars = 16000
|
||||
loop.model_preset = None
|
||||
loop.model_presets = {}
|
||||
loop._concurrency_gate = None
|
||||
loop._unified_session = False
|
||||
loop._extra_hooks = []
|
||||
@@ -45,9 +50,7 @@ def _make_mock_loop(**overrides):
|
||||
)
|
||||
|
||||
# web_config mock — needed for check tests
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
loop.web_config.search = MagicMock()
|
||||
loop.web_config = WebToolsConfig()
|
||||
loop.web_config.search.api_key = "sk-secret-key-12345"
|
||||
|
||||
# Tools registry mock
|
||||
@@ -55,10 +58,13 @@ def _make_mock_loop(**overrides):
|
||||
loop.tools.tool_names = ["read_file", "write_file", "exec", "web_search", "self"]
|
||||
loop.tools.has.side_effect = lambda n: n in loop.tools.tool_names
|
||||
loop.tools.get.return_value = None
|
||||
loop.tool_names = loop.tools.tool_names
|
||||
|
||||
# SubagentManager mock
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents._running_tasks = {"abc123": MagicMock(done=MagicMock(return_value=False))}
|
||||
loop.subagents._task_statuses = {}
|
||||
loop.subagents.runtime_statuses.side_effect = lambda: loop.subagents._task_statuses
|
||||
loop.subagents.get_running_count = MagicMock(return_value=1)
|
||||
|
||||
for k, v in overrides.items():
|
||||
@@ -67,10 +73,10 @@ def _make_mock_loop(**overrides):
|
||||
return loop
|
||||
|
||||
|
||||
def _make_tool(runtime_state=None):
|
||||
if runtime_state is None:
|
||||
runtime_state = _make_mock_loop()
|
||||
return MyTool(runtime_state=runtime_state)
|
||||
def _make_tool(loop=None):
|
||||
if loop is None:
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,10 +93,10 @@ class TestInspectSummary:
|
||||
assert "context_window_tokens: 65536" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_includes_runtime_vars(self):
|
||||
async def test_inspect_includes_scratchpad(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {"task": "review"}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
tool._runtime_control.set_scratchpad("task", "review", max_keys=64)
|
||||
result = await tool.execute(action="check")
|
||||
assert "task" in result
|
||||
|
||||
@@ -150,9 +156,7 @@ class TestInspectPathNavigation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_config_subfield(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.enable = True
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="web_config.enable")
|
||||
assert "True" in result
|
||||
|
||||
@@ -160,7 +164,7 @@ class TestInspectPathNavigation:
|
||||
async def test_inspect_dict_key_via_dotpath(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="_last_usage.prompt_tokens")
|
||||
assert "100" in result
|
||||
|
||||
@@ -179,20 +183,16 @@ class TestInspectPathNavigation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_nested_config_redacts_sensitive_scalar_fields(self):
|
||||
class SearchConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
api_key: str = "sk-test-secret"
|
||||
base_url: str = ""
|
||||
max_results: int = 5
|
||||
|
||||
loop = _make_mock_loop()
|
||||
loop.web_config = MagicMock()
|
||||
loop.web_config.search = SearchConfig()
|
||||
loop.web_config.search = WebSearchConfig(
|
||||
provider="tavily",
|
||||
api_key="sk-test-secret",
|
||||
)
|
||||
tool = _make_tool(loop)
|
||||
|
||||
result = await tool.execute(action="check", key="web_config.search")
|
||||
|
||||
assert "provider='tavily'" in result
|
||||
assert "tavily" in result
|
||||
assert "sk-test-secret" not in result
|
||||
assert "api_key" not in result.lower()
|
||||
|
||||
@@ -209,14 +209,14 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
assert "Set max_iterations = 80" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_out_of_range(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value=0)
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_iterations == 40
|
||||
assert tool._runtime_control.snapshot().max_iterations == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_restricted_max_exceeded(self):
|
||||
@@ -241,12 +241,12 @@ class TestModifyRestricted:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||
assert "Set max_iterations" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
assert tool._runtime_control.snapshot().max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
loop = _make_mock_loop()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert "Set context_window_tokens" in result
|
||||
assert loop.context_window_tokens == 131072
|
||||
@@ -324,15 +324,15 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value="persistent")
|
||||
assert "Set provider_retry_mode" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "persistent"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "persistent"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_new_key_stores_in_runtime_vars(self):
|
||||
"""Modifying a non-existing attribute should store in _runtime_vars."""
|
||||
async def test_modify_new_key_stores_in_scratchpad(self):
|
||||
"""Modifying an unknown key should store it in the scratchpad."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="my_custom_var", value="hello")
|
||||
assert "my_custom_var" in result
|
||||
assert tool._runtime_state._runtime_vars["my_custom_var"] == "hello"
|
||||
assert tool._runtime_control.snapshot().scratchpad["my_custom_var"] == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_rejects_callable(self):
|
||||
@@ -351,14 +351,14 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||
assert tool._runtime_control.snapshot().scratchpad["items"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||
assert result == "Set scratchpad.data = {'a': 1}"
|
||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||
assert tool._runtime_control.snapshot().scratchpad["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_whitespace_key_rejected(self):
|
||||
@@ -396,7 +396,7 @@ class TestModifyFree:
|
||||
result = await tool.execute(action="set", key="provider_retry_mode", value=42)
|
||||
assert "Error" in result
|
||||
assert "str" in result
|
||||
assert tool._runtime_state.provider_retry_mode == "standard"
|
||||
assert tool._runtime_control.snapshot().provider_retry_mode == "standard"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_existing_int_attr_wrong_type_rejected(self):
|
||||
@@ -404,7 +404,7 @@ class TestModifyFree:
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_tool_result_chars", value="big")
|
||||
assert "Error" in result
|
||||
assert tool._runtime_state.max_tool_result_chars == 16000
|
||||
assert tool._runtime_control.snapshot().max_tool_result_chars == 16000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -486,11 +486,12 @@ class TestModifyOpen:
|
||||
assert "protected" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_workspace_allowed(self):
|
||||
"""workspace was READONLY in v1, now freely modifiable."""
|
||||
async def test_modify_workspace_preserves_display_compatibility(self):
|
||||
"""The compatibility value is isolated from filesystem security boundaries."""
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="workspace", value="/new/path")
|
||||
assert "Set workspace" in result
|
||||
assert tool._runtime_control.snapshot().workspace == "/new/path"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_mcp_servers_blocked(self):
|
||||
@@ -584,28 +585,28 @@ class TestUnknownAction:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime_vars limits (from code review)
|
||||
# scratchpad limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsLimits:
|
||||
class TestScratchpadLimits:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_rejects_at_max_keys(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_rejects_at_max_keys(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="overflow", value="data")
|
||||
assert "full" in result
|
||||
assert "overflow" not in loop._runtime_vars
|
||||
assert "overflow" not in tool._runtime_control.snapshot().scratchpad
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_vars_allows_update_existing_key_at_max(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._runtime_vars = {f"key_{i}": i for i in range(64)}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
async def test_scratchpad_allows_update_existing_key_at_max(self):
|
||||
tool = _make_tool()
|
||||
for i in range(64):
|
||||
tool._runtime_control.set_scratchpad(f"key_{i}", i, max_keys=64)
|
||||
result = await tool.execute(action="set", key="key_0", value="updated")
|
||||
assert "Error" not in result
|
||||
assert loop._runtime_vars["key_0"] == "updated"
|
||||
assert tool._runtime_control.snapshot().scratchpad["key_0"] == "updated"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -844,7 +845,7 @@ class TestInspectTaskStatuses:
|
||||
usage={"prompt_tokens": 500, "completion_tokens": 100},
|
||||
),
|
||||
}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses")
|
||||
assert "abc12345" in result
|
||||
assert "read logs" in result
|
||||
@@ -865,7 +866,7 @@ class TestInspectTaskStatuses:
|
||||
stop_reason="completed",
|
||||
)
|
||||
loop.subagents._task_statuses = {"xyz": status}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="subagents._task_statuses.xyz")
|
||||
assert "search code" in result
|
||||
assert "completed" in result
|
||||
@@ -879,7 +880,10 @@ class TestReadOnlyMode:
|
||||
|
||||
def _make_readonly_tool(self):
|
||||
loop = _make_mock_loop()
|
||||
return MyTool(runtime_state=loop, modify_allowed=False)
|
||||
return MyTool(
|
||||
runtime_control=AgentRuntimeControl(loop),
|
||||
modify_allowed=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_allowed_in_readonly(self):
|
||||
@@ -904,13 +908,13 @@ class TestReadOnlyMode:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runtime vars check fallback (Fix #1: cross-turn memory)
|
||||
# scratchpad inspection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRuntimeVarsInspectFallback:
|
||||
class TestScratchpadInspection:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_after_modify(self):
|
||||
async def test_inspect_scratchpad_value_after_modify(self):
|
||||
"""Design doc scenario: set then check should return the value."""
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="user_prefers_concise", value=True)
|
||||
@@ -918,14 +922,14 @@ class TestRuntimeVarsInspectFallback:
|
||||
assert "True" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_string(self):
|
||||
async def test_inspect_scratchpad_string(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="current_project", value="nanobot")
|
||||
result = await tool.execute(action="check", key="current_project")
|
||||
assert "nanobot" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_runtime_var_dict(self):
|
||||
async def test_inspect_scratchpad_dict(self):
|
||||
tool = _make_tool()
|
||||
await tool.execute(action="set", key="task_meta", value={"step": 2, "total": 5})
|
||||
result = await tool.execute(action="check", key="task_meta")
|
||||
@@ -958,7 +962,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
loop.some_config.password = "hunter2"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="some_config.password")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -967,7 +971,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.vault = MagicMock()
|
||||
loop.vault.secret = "classified"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="vault.secret")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -976,7 +980,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
loop = _make_mock_loop()
|
||||
loop.auth_data = MagicMock()
|
||||
loop.auth_data.token = "jwt-payload"
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check", key="auth_data.token")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -992,7 +996,7 @@ class TestSensitiveSubFieldBlocking:
|
||||
async def test_modify_password_blocked(self):
|
||||
loop = _make_mock_loop()
|
||||
loop.some_config = MagicMock()
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="set", key="some_config.password", value="evil")
|
||||
assert "not accessible" in result
|
||||
|
||||
@@ -1083,8 +1087,8 @@ class TestSecurityAttributeProtection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_model_presets_dotpath_blocked(self):
|
||||
"""The config-derived model preset catalog is inspectable but not mutable."""
|
||||
presets = {"fast": {"model": "fast-model"}}
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
presets = {"fast": ModelPresetConfig(model="fast-model")}
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(
|
||||
action="set",
|
||||
@@ -1093,14 +1097,14 @@ class TestSecurityAttributeProtection:
|
||||
)
|
||||
|
||||
assert "read-only" in result
|
||||
assert presets == {"fast": {"model": "fast-model"}}
|
||||
assert presets == {"fast": ModelPresetConfig(model="fast-model")}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inspect_read_only_model_preset_dotpath(self):
|
||||
presets = MappingProxyType({
|
||||
"fast": ModelPresetConfig(model="fast-model"),
|
||||
})
|
||||
tool = _make_tool(runtime_state=_make_mock_loop(model_presets=presets))
|
||||
tool = _make_tool(loop=_make_mock_loop(model_presets=presets))
|
||||
|
||||
result = await tool.execute(action="check", key="model_presets.fast.model")
|
||||
|
||||
@@ -1150,7 +1154,8 @@ class TestLastUsageInSummary:
|
||||
async def test_last_usage_not_shown_when_empty(self):
|
||||
loop = _make_mock_loop()
|
||||
loop._last_usage = {}
|
||||
tool = _make_tool(runtime_state=loop)
|
||||
loop.last_usage = loop._last_usage
|
||||
tool = _make_tool(loop=loop)
|
||||
result = await tool.execute(action="check")
|
||||
assert "_last_usage" not in result
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit() -> None:
|
||||
loop = MagicMock()
|
||||
loop.max_iterations = 40
|
||||
loop._runtime_vars = {}
|
||||
loop.subagents = MagicMock()
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
def _sync_subagent_runtime_limits() -> None:
|
||||
loop.subagents.max_iterations = loop.max_iterations
|
||||
|
||||
loop._sync_subagent_runtime_limits = _sync_subagent_runtime_limits
|
||||
|
||||
tool = MyTool(runtime_state=loop)
|
||||
async def test_my_tool_max_iterations_syncs_subagent_limit(tmp_path) -> None:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
max_iterations=40,
|
||||
)
|
||||
tool = MyTool(runtime_control=AgentRuntimeControl(loop))
|
||||
|
||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user