Skip to content

Commit d89cff1

Browse files
authored
feat(executor): narrow tool set to declared capabilities (#1425) (#1433)
* feat(executor): narrow subagent tools to declared filesystem set * test(executor): pin daemon toolset declaration parity * test(executor): pin prompt declaration to registered toolset * refactor(executor): share declared toolset between prompt and registry * test(executor): keep isolated prompt extraction compatible * fix(executor): keep bridge prompt independent of manager test doubles
1 parent 5a2857f commit d89cff1

5 files changed

Lines changed: 67 additions & 5 deletions

File tree

nanobot/agent/loop.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ def __init__(
9696
model=self.model,
9797
web_search_config=self.web_search_config,
9898
web_proxy=web_proxy,
99+
web_tools_enabled=True,
99100
exec_config=self.exec_config,
100101
restrict_to_workspace=restrict_to_workspace,
101102
max_iterations=self.max_iterations,

nanobot/agent/subagent.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
1616
from nanobot.agent.tools.registry import ToolRegistry
1717
from nanobot.agent.tools.shell import ExecTool
18-
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
18+
from nanobot.agent.tools.toolsets import EXECUTOR_TOOL_NAMES as EXECUTOR_TOOLSET
1919
from nanobot.bus.events import InboundMessage
2020
from nanobot.bus.queue import MessageBus
2121
from nanobot.config.schema import ExecToolConfig
@@ -126,6 +126,7 @@ def __init__(
126126
# Optional: names to exclude from the loop skills summary (Part E).
127127
excluded_skill_names: "list[str] | None" = None,
128128
telemetry_component: str = "",
129+
web_tools_enabled: bool = False,
129130
):
130131
from nanobot.config.schema import ExecToolConfig, WebSearchConfig
131132

@@ -163,6 +164,7 @@ def __init__(
163164
# #939 Part E: excluded skill names for the loop summary
164165
self._excluded_skill_names: list[str] = list(excluded_skill_names or [])
165166
self._telemetry_component = str(telemetry_component or "").strip()
167+
self.web_tools_enabled = bool(web_tools_enabled)
166168

167169
async def spawn(
168170
self,
@@ -271,9 +273,15 @@ def _on_skill_read(skill_path: Path) -> None: # noqa: E301
271273
restrict_to_workspace=self.restrict_to_workspace,
272274
path_append=self.exec_config.path_append,
273275
))
274-
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
275-
tools.register(WebFetchTool(proxy=self.web_proxy))
276+
if self.web_tools_enabled:
277+
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
276278

279+
tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy))
280+
tools.register(WebFetchTool(proxy=self.web_proxy))
281+
282+
assert tuple(tools.tool_names) == self.registered_tool_names(), (
283+
"registered tool set must match the configured declaration"
284+
)
277285
system_prompt = self._build_subagent_prompt()
278286
messages: list[dict[str, Any]] = [
279287
{"role": "system", "content": system_prompt},
@@ -660,6 +668,19 @@ def _write_subagent_telemetry(self, task_id: str, payload: dict[str, Any]) -> No
660668
# telemetry with `glob("*.json")` over this directory and started
661669
# picking up the sidecar instead. Never add a second `.json` here.
662670

671+
EXECUTOR_TOOL_NAMES = EXECUTOR_TOOLSET
672+
673+
@classmethod
674+
def declared_tool_names(cls) -> tuple[str, ...]:
675+
"""Names declared in the loop executor prompt and registered by it."""
676+
return cls.EXECUTOR_TOOL_NAMES
677+
678+
def registered_tool_names(self) -> tuple[str, ...]:
679+
"""Names registered for this manager's configured execution role."""
680+
if self.web_tools_enabled:
681+
return self.EXECUTOR_TOOL_NAMES + ("web_search", "web_fetch")
682+
return self.EXECUTOR_TOOL_NAMES
683+
663684
def _build_subagent_prompt(self) -> str:
664685
"""Build the system prompt for the subagent.
665686

nanobot/agent/tools/toolsets.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Shared toolset declarations for prompt and registration parity."""
2+
3+
from __future__ import annotations
4+
5+
EXECUTOR_TOOL_NAMES = ("read_file", "write_file", "edit_file", "list_dir", "exec")
6+
INTERACTIVE_WEB_TOOL_NAMES = ("web_search", "web_fetch")
7+
INTERACTIVE_TOOL_NAMES = EXECUTOR_TOOL_NAMES + INTERACTIVE_WEB_TOOL_NAMES

nanobot/runtime/bridge.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
install_promoted_overlay()
5151

5252
from nanobot.runtime import llm_proposer, demand # noqa: E402
53+
from nanobot.agent.tools.toolsets import EXECUTOR_TOOL_NAMES # noqa: E402
5354
from nanobot.runtime.cycle_ledger import ( # noqa: E402
5455
VALID_OUTCOMES,
5556
append_event,
@@ -1606,7 +1607,8 @@ def build_task(req: dict, goal_text: str, report_source: str,
16061607
repair_context: 'str | None' = None,
16071608
selfevo_repo_root: 'Path | None' = None,
16081609
max_iterations: int = 15,
1609-
charter_in_system: bool = False) -> str:
1610+
charter_in_system: bool = False,
1611+
declared_tool_names: tuple[str, ...] | None = None) -> str:
16101612
"""Build a concrete task prompt for the subagent from the request payload.
16111613
16121614
Args:
@@ -1832,6 +1834,7 @@ def build_task(req: dict, goal_text: str, report_source: str,
18321834
_verification_note = (
18331835
'' if _pytest_available else ' (pytest is not installed — use python3 -c imports as smoke tests)'
18341836
)
1837+
declared_tool_names = declared_tool_names or ("read_file", "write_file", "edit_file", "list_dir", "exec")
18351838
lines += [
18361839
'## Your instructions',
18371840
'You MUST take a concrete action in this session. Do not return a review only.',
@@ -1857,7 +1860,7 @@ def build_task(req: dict, goal_text: str, report_source: str,
18571860
' "findings": ["<observation1>", "<observation2>"]',
18581861
'}',
18591862
'',
1860-
'Use your tools: read_file, write_file, edit_file, list_dir, exec.',
1863+
'Use your tools: ' + ', '.join(declared_tool_names) + '.',
18611864
f'You have up to {max_iterations} tool iterations. Use them deliberately.',
18621865
]
18631866

@@ -2513,6 +2516,7 @@ async def _main_impl_body():
25132516
selfevo_repo_root=_selfevo_repo_check,
25142517
max_iterations=resolved_iterations,
25152518
charter_in_system=bool(_charter),
2519+
declared_tool_names=EXECUTOR_TOOL_NAMES,
25162520
)
25172521

25182522
# Extract backlog title for MEMORY.md safety-net update after execution
@@ -2849,6 +2853,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
28492853
model=config.agents.defaults.model,
28502854
web_search_config=config.tools.web.search,
28512855
web_proxy=config.tools.web.proxy,
2856+
web_tools_enabled=False,
28522857
exec_config=config.tools.exec,
28532858
subagent_config=config.tools.subagent,
28542859
restrict_to_workspace=False,
@@ -3127,6 +3132,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
31273132
req, goal_text, report_source,
31283133
state_dir=STATE_DIR,
31293134
repair_context=_smoke_output,
3135+
declared_tool_names=EXECUTOR_TOOL_NAMES,
31303136
)
31313137
# Spawn repair subagent
31323138
from nanobot.agent.subagent import SubagentManager as _SM2
@@ -3139,6 +3145,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
31393145
model=_repair_cfg.agents.defaults.model,
31403146
web_search_config=_repair_cfg.tools.web.search,
31413147
web_proxy=_repair_cfg.tools.web.proxy,
3148+
web_tools_enabled=False,
31423149
exec_config=_repair_cfg.tools.exec,
31433150
subagent_config=_repair_cfg.tools.subagent,
31443151
restrict_to_workspace=False,

tests/test_subagent_manager.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
import pytest
22

33

4+
def test_executor_declared_and_registered_tool_names_match():
5+
from nanobot.agent.subagent import SubagentManager
6+
7+
assert SubagentManager.declared_tool_names() == (
8+
"read_file", "write_file", "edit_file", "list_dir", "exec"
9+
)
10+
manager = object.__new__(SubagentManager)
11+
manager.web_tools_enabled = False
12+
assert manager.registered_tool_names() == manager.declared_tool_names()
13+
assert "web_search" not in manager.registered_tool_names()
14+
assert "web_fetch" not in manager.registered_tool_names()
15+
16+
from nanobot.runtime import bridge
17+
assert "Use your tools: " + ", ".join(SubagentManager.declared_tool_names()) + "." in bridge.build_task({}, "goal", "")
18+
19+
20+
def test_interactive_subagent_role_keeps_web_tools_available():
21+
from nanobot.agent.subagent import SubagentManager
22+
23+
manager = object.__new__(SubagentManager)
24+
manager.web_tools_enabled = True
25+
assert manager.registered_tool_names() == (
26+
"read_file", "write_file", "edit_file", "list_dir", "exec", "web_search", "web_fetch"
27+
)
28+
29+
430
def test_subagent_manager_accepts_deployed_bridge_compat_kwargs(tmp_path):
531
from nanobot.agent.subagent import SubagentManager
632
from nanobot.bus.queue import MessageBus

0 commit comments

Comments
 (0)