mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
10043c6d0c
commit
3a90771f9c
5 changed files with 111 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue