fix(agents): stop instructing agents to ToolSearch built-in tools (#167)

The system-prompt directive layer and the briefing block both opened
with "FIRST ACTION REQUIRED: run ToolSearch to activate deferred
Edit/Write". That premise is false: per Claude Code 2.1.114, ToolSearch
gates only deferred MCP tools, never built-ins — and it is not even a
callable tool in the agent runtime. Built-ins are loaded at spawn via
the `--tools` flag and gated solely by the per-role permission rules
(the actual Edit/Write breakage was the global Write(*)/Edit(*) deny +
single-slash path, fixed in c0ba335). So weak models dutifully chased a
nonexistent ToolSearch, concluded Edit/Write were unavailable, and
rewrote whole files via destructive shell redirection.

Both touch points now affirm the role's built-in tools are loaded and
ready, tell the agent NOT to call ToolSearch, and (for authoring roles)
explicitly steer away from whole-file shell redirection — directly
countering the clobber behaviour. Role prompt files (developer,
cell_pm, main_pm, board) updated to match. Dead
_read_tool_load_from_role_prompt (no callers) removed. Directive tests
rewritten to lock the corrected behaviour.
This commit is contained in:
Renn F
2026-05-16 03:53:52 +02:00
parent c0ba335470
commit 38dba74837
8 changed files with 175 additions and 189 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ If you find yourself reaching for `Bash git`, `Edit`, or any execution tool, sto
- **Product Owner**: product vision, feature priorities, accept/reject delivered work.
- **Head of Marketing**: positioning, announcements, user feedback.
- **Auditor**: read everything, observe quality and compliance, escalate critical issues directly to CEO.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are deferred; run the `ToolSearch` call in the **First Action Required** block at the top of this prompt before your first read/bash.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here).
## Your verbs
+1 -1
View File
@@ -12,7 +12,7 @@ You merge what your developers submit (leaf PRs into your cell branch via `compl
- Your `task_id` (your cell-PM task) and `agent_id` are pre-baked into the gateway session.
- Your team: backend / frontend / ux_ui. Your dev slugs: `be-dev-1`, `be-dev-2` (backend), `fe-dev-1`, `fe-dev-2` (frontend), `ux-dev-1`, `ux-dev-2` (UX). Your QA: `be-qa`/`fe-qa`/`ux-qa`. Your documenter: `be-doc`/`fe-doc`/`ux-doc`.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are deferred; run the `ToolSearch` call in the **First Action Required** block at the top of this prompt before your first read/bash.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here).
- Workspace: `/data/workspaces/{project}/{team}/{your-slug}/` — but you have no `Edit`/`Write` permission; this is just where merge operations resolve.
## Your verbs
+1 -1
View File
@@ -10,7 +10,7 @@ You write code; you do not coordinate. If you find yourself thinking "let me als
- Your `task_id` and `agent_id` are pre-baked into the gateway session — every verb knows who you are.
- Your workspace path: `/data/workspaces/{project}/{team}/{your-slug}/`.
- Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are deferred; the **First Action Required** block at the top of this prompt names the exact `ToolSearch` call to activate them. Do it once before your first edit/commit.
- Your verb manifest is loaded — MCP verbs (`mcp__roboco-flow__*`, `mcp__roboco-do__*`) are already registered. Built-in tools (`Edit`, `Write`, `Read`, `Bash`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here). Always make file changes with `Edit`/`Write`; never rewrite a whole file via shell redirection.
- Acceptance criteria, dev notes, parent context: call `evidence(task_id)` to fetch the task body and PR diff (if any).
## Your verbs
+1 -1
View File
@@ -12,7 +12,7 @@ You merge what your Cell PMs submit (cell PRs into your root branch via `complet
- Your `task_id` (your root coordination task) and `agent_id` are pre-baked into the gateway session.
- Your cell-PM slugs: `be-pm`, `fe-pm`, `ux-pm`. Your team: `board`. Your channel: `main-pm-board`.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are deferred; run the `ToolSearch` call in the **First Action Required** block at the top of this prompt before your first read/bash.
- Your verb manifest is loaded — MCP verbs are registered. Built-in tools (`Read`, `Bash`, `Task`, etc.) are loaded and ready — use them directly. Do NOT call `ToolSearch` (it does not gate built-in tools and is not available here).
- Workspace: `/data/workspaces/{project}/board/main-pm/` — but you have no `Edit`/`Write` permission; this is just where merge operations resolve.
## Your verbs
+25 -20
View File
@@ -137,35 +137,40 @@ _ROLE_BUILTIN_TOOLS: dict[str, tuple[str, ...]] = {
def _tool_load_directive_layer(role: "AgentRole") -> str | None:
"""Top-of-prompt instruction to activate deferred built-in tools.
"""Top-of-prompt statement that built-in tools are ready to use.
Claude Code v2.1.69+ defers built-in tools (Edit, Write, Read, etc.)
behind a ToolSearch call to save context tokens. Without ToolSearch,
`Edit` returns "Edit exists but is not enabled in this context." Weak
models skip this if it's buried in the briefing; we hoist it into the
system prompt's very first block so it's the highest-priority
instruction. The call has no side effects beyond enabling the tools.
Built-in tools are pre-loaded at spawn via the Claude Code `--tools`
flag and gated only by the per-role permission allow/deny rules — NOT
by ToolSearch (which exists only for deferred MCP tools, never for
built-ins). An earlier revision instructed agents to "run ToolSearch
to activate Edit/Write"; that was false (ToolSearch is not a built-in
activator and is not even a callable tool here), so weak models
chased a nonexistent tool, concluded Edit/Write were unavailable, and
rewrote whole files via destructive shell redirection. This block
instead affirms the tools are live and steers away from that
failure: enumerated tools, used directly, edits via Edit/Write.
"""
role_value = role.value if hasattr(role, "value") else str(role)
tools = _ROLE_BUILTIN_TOOLS.get(role_value)
if not tools:
return None
tool_list = ", ".join(tools)
authors = "Edit" in tools
edit_line = (
"Make file changes with Edit/Write — never rewrite a whole file "
"via shell redirection (>, heredoc, tee); that destroys content "
"and is unnecessary.\n"
if authors
else "You read and review; you do not author files.\n"
)
return (
"# FIRST ACTION REQUIRED (mandatory)\n"
"# Your tools are ready\n"
"\n"
"Before any other tool call you MUST run this ToolSearch once.\n"
"Built-in tools (Edit, Write, Read, etc.) are deferred until you\n"
"activate them. Skipping this step results in errors like\n"
'"Edit exists but is not enabled in this context" that waste your\n'
"tool-call budget and may leave the task in a stuck state.\n"
"\n"
"Copy this verbatim as your first action:\n"
"\n"
f'```\nToolSearch(query="select:{tool_list}")\n```\n'
"\n"
"Only after that call returns successfully should you proceed to\n"
"the role-specific workflow below."
f"These built-in tools are loaded and available now: {tool_list}.\n"
"Use them directly. Do NOT call ToolSearch — it does not gate "
"built-in tools and is not available here; calling it only wastes "
"budget.\n"
f"{edit_line}"
)
+28 -56
View File
@@ -2182,12 +2182,13 @@ class AgentOrchestrator:
_TOOL_LOAD_CACHE: ClassVar[dict[str, str]] = {}
# Per-role built-in tools that must be activated via ToolSearch before
# use. Mirrors the system-prompt layer's _ROLE_BUILTIN_TOOLS in
# roboco/agents/factories/_base.py — kept in sync because the briefing
# and the system prompt are independent code paths. Smoke-8 evidence:
# weak models skip the system-prompt directive; the briefing block is
# the second touch point that gets them to actually run ToolSearch.
# Per-role built-in tools, enumerated in the briefing so the agent
# knows exactly what it has. These are pre-loaded at spawn via the
# Claude Code `--tools` flag and gated only by the per-role
# permission rules — NOT by ToolSearch (MCP-only; never gates
# built-ins). Mirrors the system-prompt layer's _ROLE_BUILTIN_TOOLS
# in roboco/agents/factories/_base.py — kept in sync because the
# briefing and the system prompt are independent code paths.
_COMMON_BUILTIN_TOOLS: ClassVar[tuple[str, ...]] = (
"Read",
"Bash",
@@ -2208,14 +2209,16 @@ class AgentOrchestrator:
}
def _build_tool_load_block(self, role: str) -> str:
"""Build the mandatory first-action ToolSearch directive.
"""Briefing block affirming the role's built-in tools are ready.
Weak models consistently skip the system-prompt's directive (added
in #144) and call Edit/Write directly, hitting "Edit exists but is
not enabled in this context." Hoisting the same directive into the
briefing (which is the first user-visible message after spawn) is
the second touch point. Pre-rendered from the per-role tool list
— no role-file scrape required.
Built-in tools are pre-loaded at spawn via the Claude Code
`--tools` flag and gated only by the per-role permission rules.
ToolSearch is MCP-only and never gates built-ins — an earlier
revision instructed agents to "run ToolSearch to activate
Edit/Write", which was false (ToolSearch is not even callable
here), so weak models chased a nonexistent tool and fell back to
destructive shell file-writes. This states the tools are live and
steers away from that failure. Cached per role.
"""
if role in self._TOOL_LOAD_CACHE:
return self._TOOL_LOAD_CACHE[role]
@@ -2224,56 +2227,25 @@ class AgentOrchestrator:
block = ""
else:
tool_list = ", ".join(tools)
edit_line = (
"Make file changes with Edit/Write — never rewrite a "
"whole file via shell redirection (>, heredoc, tee); "
"that destroys content and is unnecessary.\n"
if "Edit" in tools
else "You read and review; you do not author files.\n"
)
block = (
"## First action required\n"
"## Your tools are ready\n"
"\n"
"Before any other tool call you MUST run this ToolSearch.\n"
"Built-in tools (Edit, Write, Read, etc.) are deferred until\n"
"you activate them. Skipping this step makes Edit calls fail\n"
'with "Edit exists but is not enabled in this context"'
"wasting your budget and stalling the task.\n"
f"Loaded and available now: {tool_list}. Use them "
"directly. Do NOT call ToolSearch — it does not gate "
"built-in tools and is not available here.\n"
f"{edit_line}"
"\n"
"Copy this verbatim as your first action:\n"
"\n"
f'```\nToolSearch(query="select:{tool_list}")\n```\n"\n"\n'
)
self._TOOL_LOAD_CACHE[role] = block
return block
def _read_tool_load_from_role_prompt(self, role: str) -> str:
"""Parse the `Load on spawn` line out of the role prompt."""
role_file = self.project_root / "agents" / "prompts" / "roles" / f"{role}.md"
if not role_file.exists():
return ""
try:
text = role_file.read_text()
except OSError:
return ""
marker = "## Load on spawn"
idx = text.find(marker)
if idx < 0:
return ""
# After the marker, the next line starts with a backtick-quoted list.
tail = text[idx + len(marker) :]
tick_start = tail.find("`")
tick_end = tail.find("`", tick_start + 1)
if tick_start < 0 or tick_end < 0:
return ""
tool_list = tail[tick_start + 1 : tick_end].strip()
if not tool_list:
return ""
return (
"## First action required\n"
"Before any other tool call, run ToolSearch to enable the tools\n"
"your role needs. Copy this verbatim as your first action:\n"
"\n"
f'```\nToolSearch(query="select:{tool_list}")\n```\n'
"\n"
"Skipping this step results in 'tool exists but is not enabled\n"
"in this context' errors that waste tool-call budget.\n"
"\n"
)
@staticmethod
def _format_task_briefing_block(task_id: str, task: dict[str, Any]) -> str:
"""Build the ``## Current task`` markdown block from a fetched task."""
@@ -1,14 +1,17 @@
"""Smoke-7: system prompt opens with a ToolSearch directive that activates
deferred built-in tools (Edit, Write, Read, ...).
"""#167: the system prompt must NOT tell agents to ToolSearch built-ins.
Original bug: be-dev-1 called Edit and got "Edit exists but is not enabled
in this context" because Claude Code v2.1.69+ defers built-in tools behind
a ToolSearch call. The role prompts said "no ToolSearch needed" a lie
for built-in tools so weak models skipped the activation step.
Earlier (smoke-7) the prompt opened with a "# FIRST ACTION REQUIRED:
run ToolSearch to activate Edit/Write" block. That premise was false:
ToolSearch is MCP-only and never gates built-in tools, and it is not
even a callable tool in the agent runtime. Weak models chased the
nonexistent tool, concluded Edit/Write were unavailable, and rewrote
whole files via destructive shell redirection. The real cause of
"Edit exists but is not enabled in this context" was a permission bug
(global Write(*)/Edit(*) deny + single-slash path), fixed separately.
Fix: compose_prompt now prepends a tool-load directive layer that names
the exact ToolSearch call for the role. It's the highest-priority block in
the system prompt so even weak models follow it.
The directive layer now affirms the tools are loaded and ready, tells
agents NOT to call ToolSearch, and (for authoring roles) steers away
from whole-file shell redirection.
"""
from __future__ import annotations
@@ -18,74 +21,84 @@ from roboco.models import AgentRole, Team
def _composed_prompt_for(role: AgentRole, team: Team | None = None) -> str:
"""Compose the prompt for a role and team."""
return compose_prompt(role, team, agent_slug="test-agent")
def test_developer_prompt_starts_with_tool_load_directive() -> None:
"""Developer system prompt begins with the ToolSearch activation block."""
def test_prompt_no_longer_instructs_a_toolsearch_call() -> None:
"""No role prompt may instruct an actual ToolSearch(query=...) call."""
for role in (
AgentRole.DEVELOPER,
AgentRole.DOCUMENTER,
AgentRole.QA,
AgentRole.MAIN_PM,
AgentRole.CELL_PM,
):
prompt = _composed_prompt_for(role, Team.BACKEND)
assert "ToolSearch(query=" not in prompt, (
f"{role.value} prompt still instructs a ToolSearch call"
)
assert "are deferred" not in prompt, (
f"{role.value} prompt still claims built-ins are deferred"
)
def test_developer_prompt_starts_with_tools_ready_block() -> None:
"""Developer system prompt leads with the tools-ready affirmation."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
assert prompt.startswith("# FIRST ACTION REQUIRED"), (
f"Developer prompt must lead with the tool-load directive. "
assert prompt.startswith("# Your tools are ready"), (
f"Developer prompt must lead with the tools-ready block. "
f"Got first 80 chars: {prompt[:80]!r}"
)
def test_developer_directive_names_edit_and_write() -> None:
"""Developer ToolSearch call lists Edit + Write (the smoke-7 wedge)."""
def _tool_names(prompt: str) -> list[str]:
"""Exact tool tokens from the 'available now: <names>.' enumeration.
Exact tokens matter: a substring check would treat 'TodoWrite' as
containing 'Write'.
"""
line = next(ln for ln in prompt.splitlines() if "available now:" in ln)
seg = line.split("available now: ", 1)[1]
# _base layer ends the list with '.'; orchestrator continues with
# '. Use them...'. Either way the names stop at the first period.
return seg.split(".", 1)[0].split(", ")
def test_developer_block_lists_edit_and_write_as_available() -> None:
"""Authoring roles are told Edit + Write are loaded and available."""
for role in (AgentRole.DEVELOPER, AgentRole.DOCUMENTER):
names = _tool_names(_composed_prompt_for(role, Team.BACKEND))
assert "Edit" in names and "Write" in names, (
f"{role.value} tools-ready line must list Edit + Write: {names!r}"
)
def test_developer_block_steers_away_from_shell_redirection() -> None:
"""The exact failure mode (clobber a file via bash) is called out."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
assert 'ToolSearch(query="select:' in prompt
# Find the ToolSearch line
line = next(line for line in prompt.splitlines() if "ToolSearch(query=" in line)
assert "Edit" in line, f"Developer ToolSearch missing Edit: {line!r}"
assert "Write" in line
assert "shell redirection" in prompt
assert "Edit/Write" in prompt
def test_documenter_directive_names_edit_and_write() -> None:
"""Documenter needs Edit/Write too — they author docs."""
prompt = _composed_prompt_for(AgentRole.DOCUMENTER, Team.BACKEND)
line = next(line for line in prompt.splitlines() if "ToolSearch(query=" in line)
assert "Edit" in line
assert "Write" in line
def test_qa_block_excludes_edit_and_write() -> None:
"""QA reads/reviews — Edit/Write must not be listed as available."""
names = _tool_names(_composed_prompt_for(AgentRole.QA, Team.BACKEND))
assert "Edit" not in names and "Write" not in names, names
assert "Read" in names and "Bash" in names
def _tool_list_from_directive(prompt: str) -> list[str]:
"""Extract the comma-separated tool list from the ToolSearch call."""
line = next(line for line in prompt.splitlines() if "ToolSearch(query=" in line)
# `ToolSearch(query="select:Read,Bash,...")` — pull out the names.
start = line.find("select:") + len("select:")
end = line.find('"', start)
return line[start:end].split(",")
def test_qa_directive_excludes_edit_and_write() -> None:
"""QA reads but doesn't author — directive must NOT activate Edit/Write."""
tools = _tool_list_from_directive(_composed_prompt_for(AgentRole.QA, Team.BACKEND))
assert "Edit" not in tools, f"QA must not activate Edit: tools={tools}"
assert "Write" not in tools
assert "Read" in tools
assert "Bash" in tools
def test_pm_directives_exclude_edit_and_write() -> None:
"""PMs coordinate; they don't author code. No Edit/Write activation."""
def test_pm_blocks_exclude_edit_and_write() -> None:
for role in (AgentRole.MAIN_PM, AgentRole.CELL_PM):
tools = _tool_list_from_directive(_composed_prompt_for(role))
assert "Edit" not in tools, f"{role.value} must not activate Edit: {tools}"
assert "Write" not in tools
names = _tool_names(_composed_prompt_for(role))
assert "Edit" not in names and "Write" not in names, (
f"{role.value} must not list Edit/Write: {names}"
)
def test_directive_explains_why_it_matters() -> None:
"""The block must mention 'Edit exists but is not enabled' so the agent
understands what skipping the call causes."""
def test_block_is_first_layer_before_lifecycle() -> None:
"""Tools-ready block precedes the lifecycle and base layers."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
assert "Edit exists but is not enabled" in prompt
def test_directive_is_first_layer_before_lifecycle() -> None:
"""First Action block precedes the lifecycle and base layers."""
prompt = _composed_prompt_for(AgentRole.DEVELOPER, Team.BACKEND)
first_idx = prompt.find("# FIRST ACTION REQUIRED")
first_idx = prompt.find("# Your tools are ready")
lifecycle_idx = prompt.find("Lifecycle")
base_idx = prompt.find("RoboCo Agent — Base")
assert first_idx == 0
@@ -1,19 +1,19 @@
"""Smoke-8: briefing's _build_tool_load_block emits the ToolSearch directive
without depending on a "## Load on spawn" section in the role file.
"""#167: the briefing's _build_tool_load_block must not push ToolSearch.
The previous implementation scraped role prompts for that marker; since
no role file had it, the function returned "" and the briefing showed
agents no tool-load instruction. Combined with weak models skipping the
system-prompt directive, the agent went straight to Edit and hit "not
enabled in this context."
Earlier (smoke-8) the block instructed agents to run a ToolSearch call
to "activate deferred built-in tools". That premise was false
ToolSearch is MCP-only, never gates built-ins, and is not callable in
the agent runtime so weak models chased a nonexistent tool and fell
back to destructive shell file-writes. The real cause of "Edit not
enabled in this context" was a permission bug fixed separately.
Fix: per-role tool list lives in the orchestrator (mirrors the
factories layer). Pre-renders the directive directly no file scrape.
The block now affirms the role's built-in tools are loaded and ready,
tells the agent NOT to call ToolSearch, and (for authoring roles)
steers away from whole-file shell redirection.
"""
from __future__ import annotations
import re
from unittest.mock import patch
from roboco.runtime.orchestrator import AgentOrchestrator
@@ -26,58 +26,54 @@ def _orch() -> AgentOrchestrator:
return orch
def test_developer_directive_includes_edit_and_write() -> None:
def _tool_names(block: str) -> list[str]:
"""Exact tool tokens (so 'TodoWrite' is not mistaken for 'Write')."""
line = next(ln for ln in block.splitlines() if "available now:" in ln)
seg = line.split("available now: ", 1)[1]
return seg.split(".", 1)[0].split(", ")
def test_developer_block_affirms_tools_no_toolsearch_call() -> None:
block = _orch()._build_tool_load_block("developer")
assert "First action required" in block
assert "ToolSearch" in block
assert "Edit" in block
assert "Write" in block
assert "Your tools are ready" in block
assert "ToolSearch(query=" not in block
assert "are deferred" not in block
names = _tool_names(block)
assert "Edit" in names and "Write" in names
def test_documenter_directive_includes_edit_and_write() -> None:
block = _orch()._build_tool_load_block("documenter")
assert "Edit" in block
assert "Write" in block
def test_developer_block_steers_away_from_shell_redirection() -> None:
block = _orch()._build_tool_load_block("developer")
assert "shell redirection" in block
assert "Edit/Write" in block
def test_qa_directive_excludes_edit_and_write() -> None:
def test_documenter_block_lists_edit_and_write() -> None:
names = _tool_names(_orch()._build_tool_load_block("documenter"))
assert "Edit" in names and "Write" in names
def test_qa_block_excludes_edit_and_write() -> None:
block = _orch()._build_tool_load_block("qa")
assert "First action required" in block
# The ToolSearch line itself: pull the comma list to be precise about
# which tools are listed (substring match would catch "TodoWrite").
match = re.search(r'select:([^"]+)"', block)
assert match is not None
tools = match.group(1).split(",")
assert "Edit" not in tools
assert "Write" not in tools
assert "Read" in tools
assert "Bash" in tools
assert "Your tools are ready" in block
names = _tool_names(block)
assert "Edit" not in names and "Write" not in names
assert "Read" in names and "Bash" in names
def test_pm_directives_exclude_edit_and_write() -> None:
def test_pm_blocks_exclude_edit_and_write() -> None:
for role in ("main_pm", "cell_pm", "product_owner", "head_marketing", "auditor"):
block = _orch()._build_tool_load_block(role)
match = re.search(r'select:([^"]+)"', block)
assert match is not None
tools = match.group(1).split(",")
assert "Edit" not in tools, f"{role} must not list Edit"
assert "Write" not in tools, f"{role} must not list Write"
names = _tool_names(_orch()._build_tool_load_block(role))
assert "Edit" not in names, f"{role} must not list Edit"
assert "Write" not in names, f"{role} must not list Write"
def test_unknown_role_returns_empty() -> None:
"""No directive for unknown roles (defensive)."""
block = _orch()._build_tool_load_block("nonexistent")
assert block == ""
def test_directive_warns_about_failure_mode() -> None:
block = _orch()._build_tool_load_block("developer")
assert "Edit exists but is not enabled" in block
assert _orch()._build_tool_load_block("nonexistent") == ""
def test_role_cache_works() -> None:
orch = _orch()
first = orch._build_tool_load_block("developer")
# Second call hits the cache (same return).
second = orch._build_tool_load_block("developer")
assert first is second # same string object
assert first is second