butler/tool_guardrails.py
Rama main · réplica de lectura del repositorio original.
"""Tool-call loop guardrail primitives for Butler v3.
Two-phase (before_call + after_call) loop detector that tracks repeated
failures and non-progressing idempotent reads within a single agent turn.
Actions: allow, warn, block, halt.
Ported from Butler v1, adapted for Hermes tool naming.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
from collections import deque
from dataclasses import dataclass
from typing import Any, Mapping, cast
import logging
from butler.defaults.env_defaults import DOOM_LOOP_MODE_DEFAULT
logger = logging.getLogger(__name__)
IDEMPOTENT_TOOLS = frozenset({
"read_file",
"search_code",
"search_files",
"list_directory",
"git_status",
"git_diff",
"git_log",
"skills_list",
"skill_view",
"butler_recall",
"list_runtime_jobs", # 仍为 registry 工具名(非仅 CLI `butler runtime list`)
})
MUTATING_TOOLS = frozenset({
"run_shell",
"terminal",
"write_file",
"edit_file",
"patch",
"git_add",
"git_commit",
"run_runtime_job", # registry 工具;mutating job 须 /批准运行
"download_file",
})
@dataclass(frozen=True)
class GuardrailConfig:
warnings_enabled: bool = True
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
@dataclass(frozen=True)
class ToolCallSignature:
tool_name: str
args_hash: str
@classmethod
def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> ToolCallSignature:
canonical = _canonical_tool_args(_coerce_args(args))
return cls(tool_name=tool_name, args_hash=_sha256(canonical))
@dataclass(frozen=True)
class GuardrailDecision:
action: str = "allow"
code: str = "allow"
message: str = ""
tool_name: str = ""
count: int = 0
@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 _canonical_tool_args(args: Mapping[str, Any]) -> str:
return json.dumps(args, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def _coerce_args(args: Mapping[str, Any] | None) -> Mapping[str, Any]:
return args if isinstance(args, Mapping) else {}
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def doom_loop_threshold() -> int:
"""Consecutive identical tool calls before block (OpenCode processor; 0=off)."""
from butler.utilities.env_parse import int_env
return int(int_env("BUTLER_DOOM_LOOP_THRESHOLD", 3, min=0))
def doom_loop_mode() -> str:
"""``block`` (default, D3) or ``ask`` (Owner /批准一次 via permission cache)."""
mode = os.getenv("BUTLER_DOOM_LOOP_MODE", DOOM_LOOP_MODE_DEFAULT).strip().lower()
return mode if mode in ("block", "ask") else "block"
def doom_loop_soft_nudge_enabled() -> bool:
from butler.utilities.env_parse import env_truthy
return bool(env_truthy("BUTLER_DOOM_LOOP_SOFT_NUDGE", default=True))
def _safe_json_loads(text: str) -> Any:
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError, ValueError):
return None
def classify_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]:
"""Return (failed, suffix_hint) inferred from serialized tool output."""
if result is None:
return False, ""
from butler.tool_guardrails_ops import mutation_classification_hint
hint = mutation_classification_hint(tool_name, result or "")
if hint is not None:
return cast(tuple[bool, str], hint)
if tool_name in {"run_shell", "terminal"}:
data = _safe_json_loads(result)
if isinstance(data, dict):
if data.get("error"):
return True, " [error]"
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.startswith("git_"):
data = _safe_json_loads(result)
if isinstance(data, dict):
if data.get("error"):
return True, " [error]"
exit_code = data.get("exit_code")
if exit_code is not None and exit_code != 0:
return True, f" [exit {exit_code}]"
return False, ""
data = _safe_json_loads(result)
if isinstance(data, dict):
if data.get("success") is False:
return True, " [error]"
if data.get("error"):
return True, " [error]"
lower = result[:500].lower()
if '"error"' in lower or '"failed"' in lower or result.startswith("Error"):
return True, " [error]"
return False, ""
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)
class ToolCallGuardrailController:
"""Per-turn controller for repeated failed or non-progressing tool calls."""
def __init__(self, config: GuardrailConfig | None = None):
self.config = config or GuardrailConfig()
self._lock = threading.RLock()
self.reset_for_turn()
def reset_for_turn(self) -> None:
from butler.tool_guardrails_ops import reset_tool_loop_detector_safe
reset_tool_loop_detector_safe()
with self._lock:
self._exact_failure_counts: dict[ToolCallSignature, int] = {}
self._same_tool_failure_counts: dict[str, int] = {}
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
self._halt_decision: GuardrailDecision | None = None
self._call_history: deque[ToolCallSignature] = deque(maxlen=16)
self._doom_soft_nudged: set[ToolCallSignature] = set()
@property
def halt_decision(self) -> GuardrailDecision | None:
with self._lock:
return self._halt_decision
def set_halt_decision(self, decision: GuardrailDecision) -> None:
"""Record a halt/block decision from tool execution (thread-safe)."""
with self._lock:
if self._halt_decision is None:
self._halt_decision = decision
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> GuardrailDecision:
with self._lock:
return self._before_call_locked(tool_name, args)
def _before_call_locked(
self, tool_name: str, args: Mapping[str, Any] | None
) -> GuardrailDecision:
signature = ToolCallSignature.from_call(tool_name, args)
from butler.tool_guardrails_ops import check_tool_loop_before_call_safe
loop_dec = check_tool_loop_before_call_safe(tool_name, args)
if loop_dec is not None and loop_dec.stuck:
decision = GuardrailDecision(
action="block" if loop_dec.level == "critical" else "warn",
code=loop_dec.detector,
message=loop_dec.message,
tool_name=tool_name,
count=loop_dec.count,
)
if loop_dec.level == "critical":
self._halt_decision = decision
return decision
if not self.config.hard_stop_enabled:
return GuardrailDecision(tool_name=tool_name)
threshold = doom_loop_threshold()
if threshold > 0:
self._call_history.append(signature)
tail = list(self._call_history)[-threshold:]
soft_at = max(1, threshold - 1)
if (
doom_loop_soft_nudge_enabled()
and threshold > 1
and len(tail) >= soft_at
and all(s == tail[0] for s in tail)
and signature not in self._doom_soft_nudged
):
self._doom_soft_nudged.add(signature)
return GuardrailDecision(
action="warn",
code="doom_loop_soft_nudge",
message=(
f"{tool_name} 已连续 {len(tail)} 次相同参数调用"
f"(硬拦截阈值 {threshold})。请换策略或先向用户确认,"
"避免无效重复。"
),
tool_name=tool_name,
count=len(tail),
)
if len(tail) >= threshold and all(s == tail[0] for s in tail):
mode = doom_loop_mode()
if mode == "ask":
decision = GuardrailDecision(
action="ask",
code="doom_loop",
message=(
f"Doom loop detected for {tool_name} "
f"({threshold} identical calls). "
"Owner: /批准一次 或 /始终允许 doom_loop 后重试。"
),
tool_name=tool_name,
count=threshold,
)
return decision
decision = GuardrailDecision(
action="block",
code="doom_loop",
message=(
f"Blocked {tool_name}: same tool+arguments repeated "
f"{threshold} times in a row. Change strategy or ask the user."
),
tool_name=tool_name,
count=threshold,
)
self._halt_decision = decision
return decision
exact_count = self._exact_failure_counts.get(signature, 0)
if exact_count >= self.config.exact_failure_block_after:
decision = GuardrailDecision(
action="block",
code="repeated_exact_failure_block",
message=(
f"Blocked {tool_name}: same call failed {exact_count} times "
"with identical arguments. Change strategy."
),
tool_name=tool_name,
count=exact_count,
)
self._halt_decision = decision
return decision
if self._is_idempotent(tool_name):
record = self._no_progress.get(signature)
if record is not None:
_rh, repeat_count = record
if repeat_count >= self.config.no_progress_block_after:
decision = GuardrailDecision(
action="block",
code="idempotent_no_progress_block",
message=(
f"Blocked {tool_name}: returned same result {repeat_count} "
"times. Use the result already provided."
),
tool_name=tool_name,
count=repeat_count,
)
self._halt_decision = decision
return decision
return GuardrailDecision(tool_name=tool_name)
def after_call(
self,
tool_name: str,
args: Mapping[str, Any] | None,
result: str | None,
*,
failed: bool | None = None,
) -> GuardrailDecision:
with self._lock:
return self._after_call_locked(tool_name, args, result, failed=failed)
def _after_call_locked(
self,
tool_name: str,
args: Mapping[str, Any] | None,
result: str | None,
*,
failed: bool | None = None,
) -> GuardrailDecision:
args = _coerce_args(args)
signature = ToolCallSignature.from_call(tool_name, args)
if failed is None:
failed, _ = classify_tool_failure(tool_name, result)
from butler.tool_guardrails_ops import record_tool_loop_after_call_safe
record_tool_loop_after_call_safe(
tool_name,
signature.args_hash,
result or "",
)
if failed:
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
if self.config.hard_stop_enabled and same_count >= self.config.same_tool_failure_halt_after:
decision = GuardrailDecision(
action="halt",
code="same_tool_failure_halt",
message=(
f"Stopped {tool_name}: failed {same_count} times this turn. "
"Choose a different approach."
),
tool_name=tool_name,
count=same_count,
)
self._halt_decision = decision
return decision
if self.config.warnings_enabled and exact_count >= self.config.exact_failure_warn_after:
return GuardrailDecision(
action="warn",
code="repeated_exact_failure_warning",
message=f"{tool_name} failed {exact_count} times with identical args.",
tool_name=tool_name,
count=exact_count,
)
if self.config.warnings_enabled and same_count >= self.config.same_tool_failure_warn_after:
return GuardrailDecision(
action="warn",
code="same_tool_failure_warning",
message=f"{tool_name} failed {same_count} times this turn.",
tool_name=tool_name,
count=same_count,
)
return GuardrailDecision(tool_name=tool_name, count=exact_count)
self._exact_failure_counts.pop(signature, None)
self._same_tool_failure_counts.pop(tool_name, None)
if not self._is_idempotent(tool_name):
self._no_progress.pop(signature, None)
return GuardrailDecision(tool_name=tool_name)
rh = _result_hash(result)
previous = self._no_progress.get(signature)
repeat_count = 1
if previous is not None and previous[0] == rh:
repeat_count = previous[1] + 1
self._no_progress[signature] = (rh, repeat_count)
if self.config.warnings_enabled and repeat_count >= self.config.no_progress_warn_after:
return GuardrailDecision(
action="warn",
code="idempotent_no_progress_warning",
message=f"{tool_name} returned the same result {repeat_count} times.",
tool_name=tool_name,
count=repeat_count,
)
return GuardrailDecision(tool_name=tool_name, count=repeat_count)
def _is_idempotent(self, tool_name: str) -> bool:
if tool_name in MUTATING_TOOLS:
return False
return tool_name in IDEMPOTENT_TOOLS
def guardrail_metadata(decision: GuardrailDecision) -> dict[str, Any]:
"""Structured guardrail payload for tool result envelopes."""
payload: dict[str, Any] = {
"action": decision.action,
"code": decision.code,
"count": decision.count,
"message": decision.message,
}
if decision.tool_name:
payload["tool_name"] = decision.tool_name
return payload
def synthetic_result(decision: GuardrailDecision) -> str:
"""Build a synthetic tool result JSON for blocked calls."""
return json.dumps({
"error": decision.message,
"guardrail": guardrail_metadata(decision),
}, ensure_ascii=False)
def append_guidance(result: str, decision: GuardrailDecision) -> str:
"""Merge guardrail guidance into a tool result without breaking JSON envelopes."""
if decision.action not in {"warn", "block", "halt"} or not decision.message:
return result
parsed = _safe_json_loads(result or "")
if isinstance(parsed, dict):
payload = dict(parsed)
payload["guardrail"] = guardrail_metadata(decision)
if decision.action in {"block", "halt"}:
payload.setdefault("error", decision.message)
return json.dumps(payload, ensure_ascii=False, default=str)
label = "Tool loop hard stop" if decision.action in {"block", "halt"} else "Tool loop warning"
return (result or "") + f"\n\n[{label}: {decision.code}; count={decision.count}; {decision.message}]"