diff --git a/agent/agent_init.py b/agent/agent_init.py index 41f7cc11bbb..28a7e1cfad6 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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 diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 319be7255e2..c80a525632f 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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. diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b9b26e07abc..c92b0dc32c1 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index cf3cce9f89e..7171b3fbf5d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a2d8ec56d7e..bdb12f2fd85 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -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 # =========================================================================