feat(prompt): steer model away from disabled-tool workarounds

Port from nearai/ironclaw#5307 ("discourage disabled tool workarounds").

When a user disables a tool via `hermes tools` (or runs a restricted-toolset
session), the runtime already enforces that the tool can't be invoked — but the
model can still route around it by using a general-purpose tool (e.g. shelling
out via terminal) to do what the disabled dedicated tool would have done, or by
treating a never-enabled capability as something to work around silently.

Adds a short, universal DISABLED_TOOL_GUIDANCE block to the cached system
prompt telling the model: if the user names a capability with no available
tool, report it as unavailable/disabled rather than substituting another tool.
General-purpose tools remain fine for their own legitimate tasks.

Follows the existing universal-guidance pattern (TASK_COMPLETION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE): constant in prompt_builder, injected in
system_prompt gated on agent.valid_tool_names + config flag
agent.disabled_tool_guidance (default True), wired in agent_init and config
DEFAULT_CONFIG. Costs ~80 tokens once in the cached prefix.
This commit is contained in:
teknium1 2026-06-28 17:06:32 -07:00
parent 10043c6d0c
commit 3a90771f9c
No known key found for this signature in database
5 changed files with 111 additions and 0 deletions

View file

@ -1325,6 +1325,12 @@ def init_agent(
# single turn; the runtime already executes such batches concurrently.
agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True))
# Universal disabled/unavailable-capability guidance toggle. Default True.
# Separate flag because a user may want the other guidance blocks but not
# this one. Steers the model to report a named-but-unavailable capability
# as disabled rather than substituting a different tool to work around it.
agent._disabled_tool_guidance = bool(_agent_section.get("disabled_tool_guidance", True))
# Local Python toolchain probe toggle. Default True. When False,
# the probe is skipped entirely (no subprocess calls, no system-prompt
# line). Useful for users on exotic setups where the probe heuristics

View file

@ -366,6 +366,46 @@ PARALLEL_TOOL_CALL_GUIDANCE = (
"in doubt and the calls are independent, batch them."
)
# Universal disabled/unavailable-capability guidance — applied to ALL models.
#
# Why this matters: when a user disables a tool via `hermes tools` (or runs a
# restricted-toolset session — subagent, kanban worker, curated gateway), that
# tool is removed from the model's tool schema. The runtime already *enforces*
# this: a disabled tool cannot be invoked, including through the tool_search
# bridge (model_tools.py rejects out-of-scope underlying calls). What was
# missing is the *behavioral* steer. A model whose dedicated email tool is
# disabled will happily shell out via `terminal` to send the mail anyway, or
# curl an API to stand in for a disabled integration tool — silently routing
# around the user's explicit decision to turn that capability off. The user
# disabled it for a reason (cost, safety, privacy, scope); substituting a
# different tool defeats that intent without telling them.
#
# This block tells the model: if the user names a capability that isn't in your
# available tools, say it's unavailable/disabled — do NOT substitute another
# tool as a workaround. It does not stop the model from using general-purpose
# tools (terminal, etc.) for their own legitimate purposes; it stops the model
# from using them as a stand-in for a capability the user deliberately removed.
#
# Short on purpose — shipped in the cached system prompt to every user, every
# session. Token cost is paid once at install and amortised across all sessions
# via prefix caching. Keep it tight.
#
# Ported from nearai/ironclaw#5307 ("discourage disabled tool workarounds"),
# adapted from IronClaw's Rust capability-surface usage policy to hermes-agent's
# Python prompt-assembly architecture.
DISABLED_TOOL_GUIDANCE = (
"# Disabled or unavailable capabilities\n"
"Use only the tools available to you in this session. If the user asks you "
"to use a specific capability by name and no tool for it is available — "
"because it was disabled in this session's toolset, or never enabled — tell "
"the user that capability is unavailable or disabled. Do NOT route around it "
"by using a different tool as a substitute (e.g. shelling out via the "
"terminal to do what a disabled dedicated tool would have done). The user "
"turned it off deliberately; honor that rather than working around it. "
"General-purpose tools are still fine for their own legitimate tasks — just "
"don't use them as a stand-in for a capability the user removed."
)
# OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes
# where GPT models abandon work on partial results, skip prerequisite lookups,
# hallucinate instead of using tools, and declare "done" without verification.

View file

@ -28,6 +28,7 @@ from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
DEFAULT_AGENT_IDENTITY,
DISABLED_TOOL_GUIDANCE,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
HERMES_AGENT_HELP_GUIDANCE,
KANBAN_GUIDANCE,
@ -184,6 +185,18 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if getattr(agent, "_parallel_tool_call_guidance", True) and agent.valid_tool_names:
stable_parts.append(PARALLEL_TOOL_CALL_GUIDANCE)
# Universal disabled/unavailable-capability guidance. Tells the model that
# when the user names a capability with no available tool (disabled in this
# session's toolset, or never enabled), it should say so rather than
# substituting a different tool (e.g. shelling out via the terminal) to work
# around the user's explicit decision to turn that capability off. The
# runtime already *enforces* that disabled tools can't be invoked; this
# supplies the behavioral steer so the model doesn't silently route around
# the missing tool. Gated by config.yaml ``agent.disabled_tool_guidance``
# (default True) and only injected when tools are actually loaded.
if getattr(agent, "_disabled_tool_guidance", True) and agent.valid_tool_names:
stable_parts.append(DISABLED_TOOL_GUIDANCE)
# Tool-aware behavioral guidance: only inject when the tools are loaded
tool_guidance = []
if "memory" in agent.valid_tool_names:

View file

@ -969,6 +969,16 @@ DEFAULT_CONFIG = {
# compounds over a long conversation. Costs ~70 tokens in the cached
# system prompt. Set False to disable globally.
"parallel_tool_call_guidance": True,
# Universal disabled/unavailable-capability guidance — short prompt
# block applied to all models that tells the model: when the user names
# a capability that has no available tool (disabled in this session's
# toolset, or never enabled), report it as unavailable/disabled instead
# of substituting a different tool (e.g. shelling out via the terminal)
# to route around the user's explicit decision to turn it off. The
# runtime already enforces that disabled tools can't be invoked; this is
# the behavioral steer. Costs ~80 tokens in the cached system prompt.
# Set False to disable globally.
"disabled_tool_guidance": True,
# Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668
# state in the system prompt when something non-default is detected
# (e.g. python3 has no pip module, pip→python version mismatch, PEP

View file

@ -28,6 +28,7 @@ from agent.prompt_builder import (
TOOL_USE_ENFORCEMENT_MODELS,
OPENAI_MODEL_EXECUTION_GUIDANCE,
PARALLEL_TOOL_CALL_GUIDANCE,
DISABLED_TOOL_GUIDANCE,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
MEMORY_GUIDANCE,
SESSION_SEARCH_GUIDANCE,
@ -1604,6 +1605,47 @@ class TestParallelToolCallGuidance:
assert "parallel tool call" not in GOOGLE_MODEL_OPERATIONAL_GUIDANCE.lower()
class TestDisabledToolGuidance:
"""Behavior contracts for the disabled/unavailable-capability guidance.
Asserts the invariants the block must satisfy (steer reporting a
named-but-unavailable capability instead of substituting another tool, stay
short for the cached prompt) rather than freezing its exact wording.
Ported from nearai/ironclaw#5307.
"""
def test_is_nonempty_string(self):
assert isinstance(DISABLED_TOOL_GUIDANCE, str)
assert DISABLED_TOOL_GUIDANCE.strip()
def test_steers_reporting_unavailable(self):
text = DISABLED_TOOL_GUIDANCE.lower()
# Must tell the model to surface that a named capability is off rather
# than silently working around it — accept any phrasing meaning that.
assert "unavailable" in text or "disabled" in text
def test_forbids_substituting_another_tool(self):
text = DISABLED_TOOL_GUIDANCE.lower()
# The core steer: do NOT route around a disabled capability with a
# different tool. Must mention substitution/working-around and a
# negation.
assert "substitut" in text or "stand-in" in text or "work around" in text or "route around" in text
assert "not" in text or "don't" in text or "do not" in text
def test_preserves_general_purpose_tools(self):
# Must not over-rotate into telling the model to stop using terminal
# etc. for their own legitimate purposes — only as a stand-in.
text = DISABLED_TOOL_GUIDANCE.lower()
assert "general-purpose" in text or "legitimate" in text
def test_stays_short_for_cached_prompt(self):
# Shipped in every cached system prompt — keep it tight.
assert len(DISABLED_TOOL_GUIDANCE) < 900
def test_has_a_heading(self):
assert DISABLED_TOOL_GUIDANCE.lstrip().startswith("#")
# =========================================================================
# Budget warning history stripping
# =========================================================================