Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,991 @@
|
||||
"""Pure tool-call loop guardrail primitives.
|
||||
|
||||
The controller in this module is intentionally side-effect free: it tracks
|
||||
per-turn tool-call observations and returns decisions. Runtime code owns whether
|
||||
those decisions become warning guidance, synthetic tool results, or controlled
|
||||
turn halts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
|
||||
|
||||
IDEMPOTENT_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"read_file",
|
||||
"search_files",
|
||||
"web_search",
|
||||
"web_extract",
|
||||
"session_search",
|
||||
"skill_view",
|
||||
"skills_list",
|
||||
"browser_snapshot",
|
||||
"browser_console",
|
||||
"browser_get_images",
|
||||
"mcp_filesystem_read_file",
|
||||
"mcp_filesystem_read_text_file",
|
||||
"mcp_filesystem_read_multiple_files",
|
||||
"mcp_filesystem_list_directory",
|
||||
"mcp_filesystem_list_directory_with_sizes",
|
||||
"mcp_filesystem_directory_tree",
|
||||
"mcp_filesystem_get_file_info",
|
||||
"mcp_filesystem_search_files",
|
||||
}
|
||||
)
|
||||
|
||||
MUTATING_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"terminal",
|
||||
"execute_code",
|
||||
"write_file",
|
||||
"patch",
|
||||
"todo_list",
|
||||
"memory",
|
||||
"skill_manage",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_press",
|
||||
"browser_scroll",
|
||||
"browser_navigate",
|
||||
"send_message",
|
||||
"cronjob_manage",
|
||||
"delegate_task",
|
||||
"process_manage",
|
||||
}
|
||||
)
|
||||
|
||||
# Tools that are legitimately re-invoked with identical arguments and may
|
||||
# legitimately return an unchanged result while waiting on external progress —
|
||||
# background-process management and job pollers. The identical-call loop
|
||||
# notice (agent.stall_guards) never fires for these, so polling patterns like
|
||||
# ``process(action="poll")`` or repeatedly checking a generation job stay
|
||||
# unannotated.
|
||||
STALL_GUARD_REPEATABLE_TOOLS = frozenset(
|
||||
{
|
||||
"process_manage",
|
||||
}
|
||||
)
|
||||
|
||||
# Poller naming conventions (e.g. ``<vendor>_get_result``) used by generated /
|
||||
# MCP tool surfaces. Matched as suffixes so vendor-prefixed pollers are exempt
|
||||
# without enumerating every vendor.
|
||||
_STALL_GUARD_REPEATABLE_SUFFIXES = (
|
||||
"_get_result",
|
||||
"_poll",
|
||||
)
|
||||
|
||||
# The notice fires on the Nth consecutive identical call (same tool, same
|
||||
# canonical args, same result). 3 tolerates one legitimate double-check while
|
||||
# catching the observed re-issue loops (3x/4x identical calls in eval traces).
|
||||
STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3
|
||||
|
||||
# Result-reference stubbing (agent.stall_guards): from the 2nd consecutive
|
||||
# identical call whose FRESH result is byte-identical to the previous one,
|
||||
# the duplicate payload is replaced in context by a short reference stub.
|
||||
# Results under this size aren't worth stubbing (the stub itself plus the
|
||||
# lost locality outweigh the savings), and error results are never stubbed
|
||||
# (the model must see every fresh error verbatim).
|
||||
IDENTICAL_RESULT_STUB_MIN_CHARS = 512
|
||||
|
||||
# How much of the canonical args JSON the stub carries so the model still
|
||||
# knows WHAT the referenced call was even if context compression later
|
||||
# evicts the referenced result (cheap dangling-reference mitigation).
|
||||
_RESULT_STUB_ARGS_PREVIEW_CHARS = 120
|
||||
|
||||
|
||||
# Tools whose "failure" is a normal, informative outcome of legitimate work:
|
||||
# a red test run, a grep with no matches, a failing build during a fix loop, a
|
||||
# page that times out. Hard stops never fire on these from failure counts of
|
||||
# DIFFERENT commands (same_tool_failure) — only an exact-args replay with NO
|
||||
# intervening change, or an identical-result streak, can halt them.
|
||||
FAILURE_TOLERANT_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"terminal",
|
||||
"execute_code",
|
||||
"process_manage",
|
||||
"process",
|
||||
"browser_navigate",
|
||||
"web_extract",
|
||||
}
|
||||
)
|
||||
|
||||
# A landed mutation between two attempts means the retry is a NEW experiment
|
||||
# (edit -> re-run) rather than a replay. A successful call to one of these
|
||||
# marks progress for every failing signature still being counted this turn.
|
||||
PROGRESS_RESET_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"write_file",
|
||||
"patch",
|
||||
"terminal",
|
||||
"execute_code",
|
||||
"browser_click",
|
||||
"browser_type",
|
||||
"browser_press",
|
||||
"browser_navigate",
|
||||
"process_manage",
|
||||
"process",
|
||||
"delegate_task",
|
||||
"send_message",
|
||||
"cronjob",
|
||||
"cronjob_manage",
|
||||
"todo",
|
||||
"todo_list",
|
||||
"memory",
|
||||
"skill_manage",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_stall_guard_repeatable(tool_name: str) -> bool:
|
||||
"""Whether a tool is exempt from the identical-call loop notice."""
|
||||
if tool_name in STALL_GUARD_REPEATABLE_TOOLS:
|
||||
return True
|
||||
return tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallGuardrailConfig:
|
||||
"""Thresholds for per-turn tool-call loop detection.
|
||||
|
||||
Warnings are enabled by default and never prevent tool execution. Hard stops
|
||||
stay opt-in for interactive CLI/TUI/Desktop/ACP sessions, but default on for
|
||||
non-interactive gateway/cron platforms where nobody is present to interrupt
|
||||
a model that ignores loop warnings.
|
||||
"""
|
||||
|
||||
warnings_enabled: bool = True
|
||||
hard_stop_enabled: bool = False
|
||||
non_interactive_hard_stop_enabled: bool = True
|
||||
exact_failure_warn_after: int = 2
|
||||
exact_failure_block_after: int = 5
|
||||
same_tool_failure_warn_after: int = 3
|
||||
same_tool_failure_halt_after: int = 8
|
||||
no_progress_warn_after: int = 2
|
||||
no_progress_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls,
|
||||
data: Mapping[str, Any] | None,
|
||||
*,
|
||||
platform: str | None = None,
|
||||
) -> "ToolCallGuardrailConfig":
|
||||
"""Build config from the `tool_loop_guardrails` config.yaml section."""
|
||||
if not isinstance(data, Mapping):
|
||||
data = {}
|
||||
|
||||
warn_after = data.get("warn_after")
|
||||
if not isinstance(warn_after, Mapping):
|
||||
warn_after = {}
|
||||
hard_stop_after = data.get("hard_stop_after")
|
||||
if not isinstance(hard_stop_after, Mapping):
|
||||
hard_stop_after = {}
|
||||
|
||||
defaults = cls()
|
||||
hard_stop_enabled = _as_bool(data.get("hard_stop_enabled"), defaults.hard_stop_enabled)
|
||||
non_interactive_hard_stop_enabled = _as_bool(
|
||||
data.get("non_interactive_hard_stop_enabled"),
|
||||
defaults.non_interactive_hard_stop_enabled,
|
||||
)
|
||||
if _is_non_interactive_platform(platform) and non_interactive_hard_stop_enabled:
|
||||
hard_stop_enabled = True
|
||||
|
||||
return cls(
|
||||
warnings_enabled=_as_bool(data.get("warnings_enabled"), defaults.warnings_enabled),
|
||||
hard_stop_enabled=hard_stop_enabled,
|
||||
non_interactive_hard_stop_enabled=non_interactive_hard_stop_enabled,
|
||||
exact_failure_warn_after=_positive_int(
|
||||
warn_after.get("exact_failure", data.get("exact_failure_warn_after")),
|
||||
defaults.exact_failure_warn_after,
|
||||
),
|
||||
same_tool_failure_warn_after=_positive_int(
|
||||
warn_after.get("same_tool_failure", data.get("same_tool_failure_warn_after")),
|
||||
defaults.same_tool_failure_warn_after,
|
||||
),
|
||||
no_progress_warn_after=_positive_int(
|
||||
warn_after.get("idempotent_no_progress", data.get("no_progress_warn_after")),
|
||||
defaults.no_progress_warn_after,
|
||||
),
|
||||
exact_failure_block_after=_positive_int(
|
||||
hard_stop_after.get("exact_failure", data.get("exact_failure_block_after")),
|
||||
defaults.exact_failure_block_after,
|
||||
),
|
||||
same_tool_failure_halt_after=_positive_int(
|
||||
hard_stop_after.get("same_tool_failure", data.get("same_tool_failure_halt_after")),
|
||||
defaults.same_tool_failure_halt_after,
|
||||
),
|
||||
no_progress_block_after=_positive_int(
|
||||
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
|
||||
defaults.no_progress_block_after,
|
||||
),
|
||||
loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")),
|
||||
)
|
||||
|
||||
|
||||
# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop
|
||||
# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at
|
||||
# the start of every agent loop (reset_for_turn), so the limit is "within a
|
||||
# single turn" rather than cumulative over the whole session. A single loop
|
||||
# issuing dozens of web searches or spawning dozens of subagents is already
|
||||
# pathological, so the defaults are deliberately low.
|
||||
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
|
||||
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopCapConfig:
|
||||
"""Per-turn caps on runaway-prone tool calls.
|
||||
|
||||
Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on
|
||||
WebSearch calls and subagent spawns to stop runaway search / delegation
|
||||
loops. Here the caps count *within a single agent loop* (one turn): the
|
||||
counters reset in ``reset_for_turn`` at the start of every
|
||||
``run_conversation``, so a legitimate multi-turn session is never starved,
|
||||
but a single turn that spirals into an unbounded search / delegation loop
|
||||
is stopped.
|
||||
|
||||
Semantics differ from the per-turn loop *detector* above (which keys on
|
||||
repeated identical/failing calls): these caps are a hard ceiling on the
|
||||
total count of a tool within the turn and fire regardless of
|
||||
``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited).
|
||||
"""
|
||||
|
||||
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
defaults = cls()
|
||||
return cls(
|
||||
max_web_searches=_non_negative_int(
|
||||
data.get("max_web_searches"), defaults.max_web_searches
|
||||
),
|
||||
max_subagents=_non_negative_int(
|
||||
data.get("max_subagents"), defaults.max_subagents
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_INTERACTIVE_PLATFORMS = frozenset({"cli", "tui", "desktop", "acp"})
|
||||
|
||||
# Platforms that are not chat gateways but whose work is a bounded, supervised
|
||||
# task loop: a subagent inherits its parent's budget and is stopped by the
|
||||
# parent; api_server runs have a live client holding the request. Both do
|
||||
# real edit -> re-run work, so they keep the interactive (warn-only) default.
|
||||
_SUPERVISED_TASK_PLATFORMS = frozenset({"subagent", "api_server"})
|
||||
|
||||
|
||||
def _is_non_interactive_platform(platform: str | None) -> bool:
|
||||
"""Return true for gateway/cron sessions where tool loops are unattended."""
|
||||
if not isinstance(platform, str) or not platform.strip():
|
||||
return False
|
||||
key = platform.strip().lower()
|
||||
if key in _INTERACTIVE_PLATFORMS or key in _SUPERVISED_TASK_PLATFORMS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdenticalCallObservation:
|
||||
"""Outcome of observing one completed tool call for the stall guards.
|
||||
|
||||
``notice`` is the identical-call loop-breaker notice (appended after the
|
||||
result). ``stub`` is the result-reference replacement for a byte-identical
|
||||
duplicate result (replaces the result content). Both may be set on the
|
||||
same call (3rd+ identical call): the stub replaces the payload and the
|
||||
notice is appended after it.
|
||||
"""
|
||||
|
||||
notice: str | None = None
|
||||
stub: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallSignature:
|
||||
"""Stable, non-reversible identity for a tool name plus canonical args."""
|
||||
|
||||
tool_name: str
|
||||
args_hash: str
|
||||
|
||||
@classmethod
|
||||
def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> "ToolCallSignature":
|
||||
canonical = canonical_tool_args(args or {})
|
||||
return cls(tool_name=tool_name, args_hash=_sha256(canonical))
|
||||
|
||||
def to_metadata(self) -> dict[str, str]:
|
||||
"""Return public metadata without raw argument values."""
|
||||
return {"tool_name": self.tool_name, "args_hash": self.args_hash}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolGuardrailDecision:
|
||||
"""Decision returned by the tool-call guardrail controller."""
|
||||
|
||||
action: str = "allow" # allow | warn | block | halt
|
||||
code: str = "allow"
|
||||
message: str = ""
|
||||
tool_name: str = ""
|
||||
count: int = 0
|
||||
signature: ToolCallSignature | None = None
|
||||
|
||||
@property
|
||||
def allows_execution(self) -> bool:
|
||||
return self.action in {"allow", "warn"}
|
||||
|
||||
@property
|
||||
def should_halt(self) -> bool:
|
||||
return self.action in {"block", "halt"}
|
||||
|
||||
def to_metadata(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"action": self.action,
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"tool_name": self.tool_name,
|
||||
"count": self.count,
|
||||
}
|
||||
if self.signature is not None:
|
||||
data["signature"] = self.signature.to_metadata()
|
||||
return data
|
||||
|
||||
|
||||
def canonical_tool_args(args: Mapping[str, Any]) -> str:
|
||||
"""Return sorted compact JSON for parsed tool arguments."""
|
||||
if not isinstance(args, Mapping):
|
||||
raise TypeError(f"tool args must be a mapping, got {type(args).__name__}")
|
||||
return json.dumps(
|
||||
args,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
|
||||
"""Safety-fallback classifier used only when callers don't pass ``failed``.
|
||||
|
||||
Mirrors ``agent.display._detect_tool_failure`` exactly so the guardrail
|
||||
never disagrees with the CLI's user-visible ``[error]`` tag. Production
|
||||
callers in ``run_agent.py`` always pass an explicit ``failed=`` derived
|
||||
from ``_detect_tool_failure``; this function exists so standalone callers
|
||||
(tests, tooling) still get consistent behavior.
|
||||
"""
|
||||
if result is None:
|
||||
return False, ""
|
||||
if file_mutation_result_landed(tool_name, result):
|
||||
return False, ""
|
||||
|
||||
if tool_name == "terminal":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict):
|
||||
exit_code = data.get("exit_code")
|
||||
if exit_code is not None and exit_code != 0:
|
||||
return True, f" [exit {exit_code}]"
|
||||
return False, ""
|
||||
|
||||
if tool_name == "memory":
|
||||
data = safe_json_loads(result)
|
||||
if isinstance(data, dict):
|
||||
if data.get("success") is False and "exceed the limit" in data.get("error", ""):
|
||||
return True, " [full]"
|
||||
|
||||
lower = result[:500].lower()
|
||||
if '"error"' in lower or '"failed"' in lower or result.startswith("Error"):
|
||||
return True, " [error]"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
class ToolCallGuardrailController:
|
||||
"""Per-turn controller for repeated failed/non-progressing tool calls."""
|
||||
|
||||
def __init__(self, config: ToolCallGuardrailConfig | None = None):
|
||||
self.config = config or ToolCallGuardrailConfig()
|
||||
self.reset_for_turn()
|
||||
|
||||
def reset_for_turn(self) -> None:
|
||||
self._exact_failure_counts: dict[ToolCallSignature, int] = {}
|
||||
self._same_tool_failure_counts: dict[str, int] = {}
|
||||
# signature -> a mutating call succeeded since its last failure
|
||||
self._progress_since_failure: dict[ToolCallSignature, bool] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
# Identical-call loop-breaker state (agent.stall_guards): tracks the
|
||||
# CONSECUTIVE streak of identical (tool, canonical args) calls whose
|
||||
# results were also identical. Any different call — or a different
|
||||
# result — resets the streak, so legitimate re-reads after edits and
|
||||
# varied polling are never flagged. Per-turn, like everything else here.
|
||||
# NOTE: open PR #85352 (patrykkopycinski) tracks no-progress loops
|
||||
# ACROSS turns via a detection window — a different mechanism from
|
||||
# this per-turn consecutive streak. Coordinate future work there.
|
||||
self._identical_streak_sig: ToolCallSignature | None = None
|
||||
self._identical_streak_result_hash: str = ""
|
||||
self._identical_streak_count: int = 0
|
||||
# tool_call_id of the FIRST call in the current streak, so a
|
||||
# result-reference stub can point at the message that carries the
|
||||
# full payload.
|
||||
self._identical_streak_first_call_id: str = ""
|
||||
# tool_call_id -> spillover file path for results that were persisted
|
||||
# out of context (persisted-output preview). Lets a reference stub
|
||||
# carry the file path so the reference can't dangle when the first
|
||||
# occurrence entered context as a preview.
|
||||
self._persisted_result_paths: dict[str, str] = {}
|
||||
# Per-turn runaway-loop cap counters. Reset every turn (this method
|
||||
# runs at the start of each run_conversation), so the caps bound a
|
||||
# single agent loop rather than accumulating across the session.
|
||||
self._turn_web_search_count = 0
|
||||
self._turn_subagent_count = 0
|
||||
|
||||
@property
|
||||
def halt_decision(self) -> ToolGuardrailDecision | None:
|
||||
return self._halt_decision
|
||||
|
||||
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
|
||||
# ── Per-turn runaway-loop caps ──────────────────────────────────
|
||||
# These are hard ceilings on how many times a runaway-prone tool may
|
||||
# be called within a single agent loop (turn). They apply regardless
|
||||
# of hard_stop_enabled (which only governs the per-turn loop detector).
|
||||
# We block BEFORE the call runs once the count is already at the cap,
|
||||
# then increment for an allowed call so the (cap+1)-th is refused.
|
||||
cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature)
|
||||
if cap_block is not None:
|
||||
return cap_block
|
||||
|
||||
if not self.config.hard_stop_enabled:
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
exact_count = self._exact_failure_counts.get(signature, 0)
|
||||
if self._progress_since_failure.get(signature):
|
||||
# Something landed since this call last failed — let it run; the
|
||||
# streak restarts in after_call if it fails again.
|
||||
exact_count = 0
|
||||
if exact_count >= self.config.exact_failure_block_after:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="repeated_exact_failure_block",
|
||||
message=(
|
||||
f"Blocked {tool_name}: the same tool call failed {exact_count} "
|
||||
"times with identical arguments. Stop retrying it unchanged; "
|
||||
"change strategy or explain the blocker."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=exact_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
if self._is_idempotent(tool_name):
|
||||
record = self._no_progress.get(signature)
|
||||
if record is not None:
|
||||
_result_hash, repeat_count = record
|
||||
if repeat_count >= self.config.no_progress_block_after:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="idempotent_no_progress_block",
|
||||
message=(
|
||||
f"Blocked {tool_name}: this read-only call returned the same "
|
||||
f"result {repeat_count} times. Stop repeating it unchanged; "
|
||||
"use the result already provided or try a different query."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=repeat_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
def after_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
*,
|
||||
failed: bool | None = None,
|
||||
) -> ToolGuardrailDecision:
|
||||
args = _coerce_args(args)
|
||||
signature = ToolCallSignature.from_call(tool_name, args)
|
||||
if failed is None:
|
||||
failed, _ = classify_tool_failure(tool_name, result)
|
||||
|
||||
if failed:
|
||||
# An identical failing call is only a REPLAY if nothing landed in
|
||||
# between. If any mutating call succeeded since the previous
|
||||
# identical failure (edit -> re-run pytest, click -> re-snapshot),
|
||||
# the retry is a new experiment: restart the exact-args streak.
|
||||
if self._progress_since_failure.pop(signature, False):
|
||||
self._exact_failure_counts.pop(signature, None)
|
||||
exact_count = self._exact_failure_counts.get(signature, 0) + 1
|
||||
self._exact_failure_counts[signature] = exact_count
|
||||
self._no_progress.pop(signature, None)
|
||||
|
||||
same_count = self._same_tool_failure_counts.get(tool_name, 0) + 1
|
||||
self._same_tool_failure_counts[tool_name] = same_count
|
||||
|
||||
# same_tool_failure counts DIFFERENT args on one tool. For tools
|
||||
# whose non-zero exit is ordinary work output (terminal,
|
||||
# execute_code, pollers) a run of distinct red commands is
|
||||
# diagnosis, not a loop — warn, never halt. The exact-args replay
|
||||
# path still applies to them.
|
||||
same_tool_halt_eligible = tool_name not in FAILURE_TOLERANT_TOOL_NAMES
|
||||
if (
|
||||
self.config.hard_stop_enabled
|
||||
and same_tool_halt_eligible
|
||||
and same_count >= self.config.same_tool_failure_halt_after
|
||||
):
|
||||
decision = ToolGuardrailDecision(
|
||||
action="halt",
|
||||
code="same_tool_failure_halt",
|
||||
message=(
|
||||
f"Stopped {tool_name}: it failed {same_count} times this turn. "
|
||||
"Stop retrying the same failing tool path and choose a different approach."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=same_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
|
||||
if self.config.warnings_enabled and exact_count >= self.config.exact_failure_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="repeated_exact_failure_warning",
|
||||
message=(
|
||||
f"{tool_name} has failed {exact_count} times with identical arguments. "
|
||||
"This looks like a loop; inspect the error and change strategy "
|
||||
"instead of retrying it unchanged."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=exact_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
if self.config.warnings_enabled and same_count >= self.config.same_tool_failure_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="same_tool_failure_warning",
|
||||
message=_tool_failure_recovery_hint(tool_name, same_count),
|
||||
tool_name=tool_name,
|
||||
count=same_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=exact_count, signature=signature)
|
||||
|
||||
self._exact_failure_counts.pop(signature, None)
|
||||
self._same_tool_failure_counts.pop(tool_name, None)
|
||||
|
||||
# A successful mutation is progress for every failing signature still
|
||||
# being counted this turn: the next identical retry runs against
|
||||
# changed state, so it is a fresh attempt rather than a replay. Pure
|
||||
# loops never mutate anything between attempts, so the replay detector
|
||||
# keeps its teeth.
|
||||
if tool_name in PROGRESS_RESET_TOOL_NAMES or file_mutation_result_landed(tool_name, result):
|
||||
for sig in list(self._exact_failure_counts):
|
||||
self._progress_since_failure[sig] = True
|
||||
self._same_tool_failure_counts.clear()
|
||||
|
||||
if not self._is_idempotent(tool_name):
|
||||
self._no_progress.pop(signature, None)
|
||||
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
|
||||
|
||||
result_hash = _result_hash(result)
|
||||
previous = self._no_progress.get(signature)
|
||||
repeat_count = 1
|
||||
if previous is not None and previous[0] == result_hash:
|
||||
repeat_count = previous[1] + 1
|
||||
self._no_progress[signature] = (result_hash, repeat_count)
|
||||
|
||||
if self.config.warnings_enabled and repeat_count >= self.config.no_progress_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="idempotent_no_progress_warning",
|
||||
message=(
|
||||
f"{tool_name} returned the same result {repeat_count} times. "
|
||||
"Use the result already provided or change the query instead of "
|
||||
"repeating it unchanged."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=repeat_count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
return ToolGuardrailDecision(tool_name=tool_name, count=repeat_count, signature=signature)
|
||||
|
||||
def _is_idempotent(self, tool_name: str) -> bool:
|
||||
if tool_name in self.config.mutating_tools:
|
||||
return False
|
||||
return tool_name in self.config.idempotent_tools
|
||||
|
||||
def observe_identical_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
) -> str | None:
|
||||
"""Track consecutive identical calls; return a loop-breaker notice or None.
|
||||
|
||||
Back-compat wrapper around :meth:`observe_call` for callers that only
|
||||
care about the loop-breaker notice.
|
||||
"""
|
||||
return self.observe_call(tool_name, args, result).notice
|
||||
|
||||
def observe_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any] | None,
|
||||
result: str | None,
|
||||
*,
|
||||
tool_call_id: str = "",
|
||||
failed: bool = False,
|
||||
) -> "IdenticalCallObservation":
|
||||
"""Track consecutive identical calls; return notice + dedupe stub info.
|
||||
|
||||
Two independent outputs from the same consecutive-streak tracker:
|
||||
|
||||
- ``notice``: the compact loop-breaker notice, fired when the SAME
|
||||
tool is called with identical canonical arguments AND returns an
|
||||
identical result for the ``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th
|
||||
(and every subsequent) consecutive time within the turn. Purely
|
||||
observational — never blocks the call. Allowlisted pollers
|
||||
(``is_stall_guard_repeatable``) are exempt from the NOTICE.
|
||||
- ``stub``: a short reference replacement for the CURRENT result,
|
||||
produced from the 2nd consecutive identical call whose fresh result
|
||||
is byte-identical to the previous one. The tool still executed —
|
||||
only the context representation is deduplicated, so polling
|
||||
semantics are preserved (a changed result flows through whole and
|
||||
resets the streak). Pollers are NOT exempt from stubbing: for a
|
||||
poller, an identical result means nothing changed, which is exactly
|
||||
when the stub saves the most context and loses nothing. Results
|
||||
under ``IDENTICAL_RESULT_STUB_MIN_CHARS`` and failed/error results
|
||||
are never stubbed, and only plain-string results are considered.
|
||||
|
||||
Any intervening different call or changed result resets the streak.
|
||||
Callers substitute/append at tool RESULT construction time, which is
|
||||
cache-safe: tool results are append-only and never mutate
|
||||
already-sent context.
|
||||
"""
|
||||
is_plain_str = isinstance(result, str)
|
||||
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
|
||||
result_hash = _result_hash(result) if is_plain_str else ""
|
||||
|
||||
if (
|
||||
is_plain_str
|
||||
and self._identical_streak_sig == signature
|
||||
and self._identical_streak_result_hash == result_hash
|
||||
):
|
||||
self._identical_streak_count += 1
|
||||
else:
|
||||
# New streak (or non-string result, which never forms a streak —
|
||||
# multimodal content lists pass through untouched).
|
||||
self._identical_streak_sig = signature if is_plain_str else None
|
||||
self._identical_streak_result_hash = result_hash
|
||||
self._identical_streak_count = 1 if is_plain_str else 0
|
||||
self._identical_streak_first_call_id = tool_call_id or ""
|
||||
|
||||
count = self._identical_streak_count
|
||||
|
||||
notice = None
|
||||
if (
|
||||
not is_stall_guard_repeatable(tool_name)
|
||||
and count >= STALL_GUARD_IDENTICAL_CALL_THRESHOLD
|
||||
):
|
||||
ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}"
|
||||
notice = (
|
||||
f"[hermes note: this is the {ordinal} consecutive identical call to "
|
||||
f"{tool_name} with identical arguments returning the same result. "
|
||||
"Do not repeat it — change arguments, use a different tool, or "
|
||||
"proceed with what you have.]"
|
||||
)
|
||||
# Hard-stop widening (#89069 / #100849 bundle): the per-turn
|
||||
# no-progress BLOCK above only covers tools in idempotent_tools, so
|
||||
# a model replaying the same successful `terminal`/`skill_view`
|
||||
# call with a byte-identical result ran until the iteration budget.
|
||||
# The consecutive-identical streak is tool-agnostic; when hard
|
||||
# stops are enabled, halt at the same idempotent_no_progress
|
||||
# threshold. Pollers stay exempt (an unchanged poll is progress).
|
||||
if (
|
||||
self.config.hard_stop_enabled
|
||||
and count >= self.config.no_progress_block_after
|
||||
and self._halt_decision is None
|
||||
):
|
||||
self._halt_decision = ToolGuardrailDecision(
|
||||
action="halt",
|
||||
code="identical_call_streak_halt",
|
||||
message=(
|
||||
f"Stopped {tool_name}: the same call with identical arguments "
|
||||
f"returned the same result {count} times in a row. Stop "
|
||||
"repeating it unchanged; use the result already provided or "
|
||||
"change strategy."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=count,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
stub = None
|
||||
if (
|
||||
is_plain_str
|
||||
and count >= 2
|
||||
and not failed
|
||||
and len(result) >= IDENTICAL_RESULT_STUB_MIN_CHARS
|
||||
):
|
||||
stub = self._build_result_reference_stub(tool_name, args)
|
||||
|
||||
return IdenticalCallObservation(notice=notice, stub=stub)
|
||||
|
||||
def record_persisted_result(self, tool_call_id: str, file_path: str) -> None:
|
||||
"""Remember the spillover path a persisted result was saved to.
|
||||
|
||||
When the first occurrence of a result entered context as a
|
||||
persisted-output preview, a later reference stub must carry the
|
||||
spillover file path so the reference can't dangle.
|
||||
"""
|
||||
if tool_call_id and file_path:
|
||||
self._persisted_result_paths[tool_call_id] = file_path
|
||||
|
||||
def _build_result_reference_stub(
|
||||
self, tool_name: str, args: Mapping[str, Any] | None
|
||||
) -> str:
|
||||
"""Build the reference stub replacing a byte-identical duplicate result.
|
||||
|
||||
Carries the tool name + a canonical-args preview so that even if
|
||||
context compression later evicts the referenced result, the model
|
||||
still knows WHAT the call was (cheap dangling-reference mitigation).
|
||||
"""
|
||||
try:
|
||||
args_preview = canonical_tool_args(_coerce_args(args))
|
||||
except TypeError:
|
||||
args_preview = "{}"
|
||||
if len(args_preview) > _RESULT_STUB_ARGS_PREVIEW_CHARS:
|
||||
args_preview = args_preview[:_RESULT_STUB_ARGS_PREVIEW_CHARS] + "…"
|
||||
first_id = self._identical_streak_first_call_id
|
||||
ref = f" (tool_call_id {first_id})" if first_id else ""
|
||||
stub = (
|
||||
f"[hermes note: this result is byte-identical to the {tool_name} "
|
||||
f"result earlier this turn{ref}. Refer to that result; it has not "
|
||||
f"changed. Args: {args_preview}]"
|
||||
)
|
||||
spill_path = self._persisted_result_paths.get(first_id) if first_id else None
|
||||
if spill_path:
|
||||
stub += (
|
||||
f"\n[The referenced result was persisted to: {spill_path} — "
|
||||
"page through it with read_file if you need the full content.]"
|
||||
)
|
||||
return stub
|
||||
|
||||
def _check_loop_cap(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: Mapping[str, Any],
|
||||
signature: ToolCallSignature,
|
||||
) -> ToolGuardrailDecision | None:
|
||||
"""Enforce and advance the per-turn runaway-loop counters.
|
||||
|
||||
Returns a ``block`` decision when the cap is already reached, otherwise
|
||||
increments the relevant counter for the allowed call and returns
|
||||
``None``. A cap of 0 disables that limit entirely. Counters reset each
|
||||
turn via ``reset_for_turn``.
|
||||
"""
|
||||
caps = self.config.loop_caps
|
||||
|
||||
if tool_name == "web_search":
|
||||
cap = caps.max_web_searches
|
||||
if cap and self._turn_web_search_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_web_search_cap",
|
||||
message=(
|
||||
f"Blocked web_search: this turn has already made {cap} "
|
||||
"web searches, the per-turn limit. This looks like a "
|
||||
"runaway search loop. Work with the results you already "
|
||||
"have and give the user your answer."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_web_search_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_web_search_count += 1
|
||||
return None
|
||||
|
||||
if tool_name == "delegate_task":
|
||||
cap = caps.max_subagents
|
||||
if not cap:
|
||||
return None
|
||||
spawn_count = _subagent_spawn_count(args)
|
||||
if spawn_count == 0:
|
||||
# Control action (list/steer/stop) — spawns nothing. Never
|
||||
# block: once the spawn cap is hit, steering/stopping the
|
||||
# existing children is exactly what should still work.
|
||||
return None
|
||||
if self._turn_subagent_count >= cap:
|
||||
decision = ToolGuardrailDecision(
|
||||
action="block",
|
||||
code="loop_subagent_cap",
|
||||
message=(
|
||||
f"Blocked delegate_task: this turn has already spawned "
|
||||
f"{self._turn_subagent_count} subagents (limit {cap}). "
|
||||
"This looks like a runaway delegation loop. Finish the "
|
||||
"work with the results you have and answer the user."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=self._turn_subagent_count,
|
||||
signature=signature,
|
||||
)
|
||||
self._halt_decision = decision
|
||||
return decision
|
||||
self._turn_subagent_count += spawn_count
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
|
||||
"""Build a synthetic role=tool content string for a blocked tool call."""
|
||||
return json.dumps(
|
||||
{
|
||||
"error": decision.message,
|
||||
"guardrail": decision.to_metadata(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def append_toolguard_guidance(result: str, decision: ToolGuardrailDecision) -> str:
|
||||
"""Append runtime guidance to the current tool result content."""
|
||||
if decision.action not in {"warn", "halt"} or not decision.message:
|
||||
return result
|
||||
label = "Tool loop hard stop" if decision.action == "halt" else "Tool loop warning"
|
||||
suffix = (
|
||||
f"\n\n[{label}: "
|
||||
f"{decision.code}; count={decision.count}; {decision.message}]"
|
||||
)
|
||||
return (result or "") + suffix
|
||||
|
||||
|
||||
def _tool_failure_recovery_hint(tool_name: str, count: int) -> str:
|
||||
"""Action-oriented guidance for recovering from repeated tool failures."""
|
||||
common = (
|
||||
f"{tool_name} has failed {count} times this turn. This looks like a loop. "
|
||||
"Do not switch to text-only replies; keep using tools, but diagnose before retrying. "
|
||||
"First inspect the latest error/output and verify your assumptions. "
|
||||
)
|
||||
if tool_name == "terminal":
|
||||
return common + (
|
||||
"For terminal failures, run a small diagnostic such as `pwd && ls -la` "
|
||||
"in the same tool, then try an absolute path, a simpler command, a different "
|
||||
"working directory, or a different tool such as read_file/write_file/patch."
|
||||
)
|
||||
return common + (
|
||||
"Try different arguments, a narrower query/path, an absolute path when relevant, "
|
||||
"or a different tool that can make progress. If the blocker is external, report "
|
||||
"the blocker after one diagnostic attempt instead of repeating the same failing path."
|
||||
)
|
||||
|
||||
|
||||
def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||||
return args if isinstance(args, Mapping) else {}
|
||||
|
||||
|
||||
def _result_hash(result: str | None) -> str:
|
||||
parsed = safe_json_loads(result or "")
|
||||
if parsed is not None:
|
||||
try:
|
||||
canonical = json.dumps(
|
||||
parsed,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
except TypeError:
|
||||
canonical = str(parsed)
|
||||
else:
|
||||
canonical = result or ""
|
||||
return _sha256(canonical)
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _positive_int(value: Any, default: int) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 1 else default
|
||||
|
||||
|
||||
def _non_negative_int(value: Any, default: int) -> int:
|
||||
"""Parse a session-cap value. 0 is a valid (disable) value; negatives and
|
||||
junk fall back to the default."""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 0 else default
|
||||
|
||||
|
||||
def _subagent_spawn_count(args: Mapping[str, Any]) -> int:
|
||||
"""How many subagents a single delegate_task call spawns.
|
||||
|
||||
delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty
|
||||
list, one child per item) or a single task (``goal``). Count the batch size
|
||||
when present, otherwise 1, so the session subagent cap reflects real spawns
|
||||
rather than delegate_task invocations. Control actions (list/steer/stop)
|
||||
spawn nothing and must not consume the cap.
|
||||
"""
|
||||
if isinstance(args, Mapping):
|
||||
action = str(args.get("action") or "").strip().lower()
|
||||
if action in ("list", "steer", "stop"):
|
||||
return 0
|
||||
tasks = args.get("tasks") if isinstance(args, Mapping) else None
|
||||
if isinstance(tasks, list) and tasks:
|
||||
return len(tasks)
|
||||
return 1
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
# surrogatepass: tool results scraped from the web can carry unpaired
|
||||
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
|
||||
# encode raises and takes down the whole conversation loop. The hash only
|
||||
# needs deterministic bytes, not valid UTF-8.
|
||||
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
|
||||
Reference in New Issue
Block a user