2026-04-21 17:48:45 +02:00
#!/usr/bin/env bash
# PostToolUse: per-session budget counter + loop detector.
#
# Runs after every tool call. Posts a (tool, args_hash) pair to the SDK
# server, which tracks cumulative counts and a rolling window of identical
# calls. Emits a short reminder line to stdout when thresholds are hit so
# Claude sees it in the next turn:
#
# [Budget] — soft warning (past warn threshold)
2026-05-11 02:15:47 +02:00
# [Loop] — same tool+args ≥ loop_threshold times in the window.
# When the SDK reports loop_action="halt" (foundation default
# BudgetPolicy.loop_action), the hook exits 1 to deny the
# wrapping tool call. Operators can soften with
# ROBOCO_AGENT_LOOP_ACTION=warn.
2026-04-21 17:48:45 +02:00
# [Halt] — hard cap breached; orchestrator kill-switch will terminate
# the container on its next sweep. Hook also fires the
# auto-escalate on the agent's behalf.
#
2026-05-11 02:15:47 +02:00
# Default: exit 0 (reminder). Exit 1 only on loop+halt.
2026-04-21 17:48:45 +02:00
set -u
SDK_URL = " ${ ROBOCO_SDK_URL :- http ://localhost: 9000 } "
input = $( cat 2>/dev/null || true )
[[ -z " $input " ]] && exit 0
# Strip MCP prefix, keep tool_input deterministic for hash.
read -r TOOL ARGS_HASH <<< " $( printf '%s' " $input " | python3 - <<'PY'
import json, sys, hashlib
try:
d = json.loads(sys.stdin.read())
tool = d.get("tool_name", "")
ti = d.get("tool_input") or {}
blob = json.dumps(ti, sort_keys=True, separators=(",", ":"), default=str)
h = hashlib.sha256(blob.encode("utf-8", errors="ignore")).hexdigest()[:16]
print(f"{tool} {h}")
except Exception:
print("unknown unknown")
PY
) "
2026-05-15 04:54:08 +02:00
# Record the tool name on the SDK so the stop-hook can recognize a
# graceful terminal call (i_am_idle / i_am_done / pass / fail / etc.).
# Fire-and-forget — never block Claude on this.
curl -sf -m 2 -X POST " $SDK_URL /terminal/tool_recorded" \
-H "Content-Type: application/json" \
-d "{\"tool\":\" $TOOL \"}" >/dev/null 2>& 1 || true
2026-04-21 17:48:45 +02:00
# Ask the SDK to record + return status. 2s timeout — we never block Claude.
resp = $( curl -sf -m 2 -X POST " $SDK_URL /budget/tool_called" \
-H "Content-Type: application/json" \
-d "{\"tool\":\" $TOOL \",\"args_hash\":\" $ARGS_HASH \"}" 2>/dev/null)
[[ -z " $resp " ]] && exit 0
total = $( echo " $resp " | jq -r '.total // 0' )
warn = $( echo " $resp " | jq -r '.warn // false' )
halt = $( echo " $resp " | jq -r '.halt // false' )
loop = $( echo " $resp " | jq -r '.loop // false' )
2026-05-11 02:15:47 +02:00
loop_action = $( echo " $resp " | jq -r '.loop_action // "warn"' )
2026-04-21 17:48:45 +02:00
halt_threshold = $( echo " $resp " | jq -r '.halt_threshold // 150' )
if [[ " $halt " == "true" ]] ; then
echo "[Halt] Budget exceeded: ${ total } / ${ halt_threshold } tool calls. Auto-escalating; stop now."
# Fire-and-forget the substitute so the task gets released even if the
# agent ignores the message. Orchestrator sweep will terminate the
# container within agent_budget_sweep_interval_seconds anyway.
curl -sf -m 2 -X POST " $SDK_URL /terminal/force_substitute" >/dev/null 2>& 1 || true
elif [[ " $loop " == "true" ]] ; then
2026-05-11 02:15:47 +02:00
if [[ " $loop_action " == "halt" ]] ; then
# Foundation BudgetPolicy.loop_action="halt": deny the wrapping tool
# call so the agent cannot keep retrying the same (tool,args) pair.
# Only fires when the SDK explicitly reports loop_action=="halt";
# if the field is missing (older SDK / partial deploy), falls
# through to the legacy warn-only branch below.
2026-06-03 06:35:03 +02:00
echo "[Loop] Same tool+args repeated in window — halting (BudgetPolicy.loop_action=halt). Release the task with unclaim() or stop with i_am_idle()." >& 2
2026-05-11 02:15:47 +02:00
exit 1
fi
2026-06-03 06:35:03 +02:00
echo "[Loop] Same tool+args repeated in window. Stop looping — release the task with unclaim() or stop with i_am_idle()."
2026-04-21 17:48:45 +02:00
elif [[ " $warn " == "true" ]] ; then
echo "[Budget] ${ total } / ${ halt_threshold } tool calls used. Plan your remaining work carefully."
fi
exit 0