@miller-tech/uap 1.13.11 → 1.13.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================
3
+ # UAP Loop Protection & Token Budget Circuit Breaker
4
+ # ============================================================
5
+ # Shared library sourced by all UAP hooks.
6
+ # Tracks hook invocation frequency and detects runaway loops
7
+ # that waste tokens (and money) by emitting the same system
8
+ # reminders, build-gate warnings, or compliance blocks
9
+ # hundreds of times in a single session.
10
+ #
11
+ # MECHANISM:
12
+ # - Maintains a lightweight state file per session
13
+ # - Counts hook invocations per type within sliding windows
14
+ # - Suppresses redundant output after thresholds are hit
15
+ # - Logs suppressed events for post-mortem analysis
16
+ # - Provides hard circuit-breaker after extreme loop counts
17
+ #
18
+ # USAGE (source from any hook script):
19
+ # source "$(dirname "$0")/loop-protection.sh"
20
+ # if lp_should_suppress "post-tool-use-edit-write"; then
21
+ # exit 0 # skip output, loop detected
22
+ # fi
23
+ # lp_record_invocation "post-tool-use-edit-write"
24
+ #
25
+ # CONFIGURATION (environment variables):
26
+ # UAP_LP_DISABLED=1 Disable loop protection entirely
27
+ # UAP_LP_SOFT_LIMIT=15 Warn after N invocations per hook (default: 15)
28
+ # UAP_LP_HARD_LIMIT=50 Suppress output after N (default: 50)
29
+ # UAP_LP_CIRCUIT_BREAK=200 Hard stop — emit circuit breaker msg (default: 200)
30
+ # UAP_LP_WINDOW_SECS=300 Sliding window in seconds (default: 300 = 5 min)
31
+ # UAP_LP_DEDUP_SECS=5 Min seconds between identical outputs (default: 5)
32
+ # ============================================================
33
+
34
+ # Guard against re-sourcing
35
+ if [ "${_UAP_LOOP_PROTECTION_LOADED:-}" = "1" ]; then
36
+ return 0 2>/dev/null || true
37
+ fi
38
+ _UAP_LOOP_PROTECTION_LOADED=1
39
+
40
+ # --- Configuration ---
41
+ LP_DISABLED="${UAP_LP_DISABLED:-0}"
42
+ LP_SOFT_LIMIT="${UAP_LP_SOFT_LIMIT:-15}"
43
+ LP_HARD_LIMIT="${UAP_LP_HARD_LIMIT:-50}"
44
+ LP_CIRCUIT_BREAK="${UAP_LP_CIRCUIT_BREAK:-200}"
45
+ LP_WINDOW_SECS="${UAP_LP_WINDOW_SECS:-300}"
46
+ LP_DEDUP_SECS="${UAP_LP_DEDUP_SECS:-5}"
47
+
48
+ # --- State file location ---
49
+ LP_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${FACTORY_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-.}}}"
50
+ LP_STATE_DIR="${LP_PROJECT_DIR}/.uap/loop-protection"
51
+ LP_SESSION_ID="${UAP_SESSION_ID:-${SESSION_ID:-default}}"
52
+ LP_STATE_FILE="${LP_STATE_DIR}/session-${LP_SESSION_ID}.state"
53
+ LP_LOG_FILE="${LP_STATE_DIR}/loop-events.log"
54
+
55
+ # --- Ensure state directory exists ---
56
+ _lp_init() {
57
+ if [ "$LP_DISABLED" = "1" ]; then
58
+ return 0
59
+ fi
60
+ mkdir -p "$LP_STATE_DIR" 2>/dev/null || true
61
+ }
62
+
63
+ # --- Get current epoch seconds (portable) ---
64
+ _lp_now() {
65
+ date +%s 2>/dev/null || echo "0"
66
+ }
67
+
68
+ # --- Read counter for a hook type from state file ---
69
+ # Format: hook_type|count|first_ts|last_ts|suppressed_count
70
+ _lp_read_state() {
71
+ local hook_type="$1"
72
+ if [ ! -f "$LP_STATE_FILE" ]; then
73
+ echo "0|0|0|0"
74
+ return
75
+ fi
76
+ local line
77
+ line=$(grep "^${hook_type}|" "$LP_STATE_FILE" 2>/dev/null | tail -1)
78
+ if [ -z "$line" ]; then
79
+ echo "0|0|0|0"
80
+ return
81
+ fi
82
+ echo "$line" | cut -d'|' -f2-5
83
+ }
84
+
85
+ # --- Write/update counter for a hook type ---
86
+ _lp_write_state() {
87
+ local hook_type="$1"
88
+ local count="$2"
89
+ local first_ts="$3"
90
+ local last_ts="$4"
91
+ local suppressed="$5"
92
+
93
+ # Atomic update: remove old line, append new
94
+ if [ -f "$LP_STATE_FILE" ]; then
95
+ grep -v "^${hook_type}|" "$LP_STATE_FILE" > "${LP_STATE_FILE}.tmp" 2>/dev/null || true
96
+ mv "${LP_STATE_FILE}.tmp" "$LP_STATE_FILE" 2>/dev/null || true
97
+ fi
98
+ echo "${hook_type}|${count}|${first_ts}|${last_ts}|${suppressed}" >> "$LP_STATE_FILE"
99
+ }
100
+
101
+ # --- Log a loop event for post-mortem ---
102
+ _lp_log_event() {
103
+ local level="$1"
104
+ local hook_type="$2"
105
+ local message="$3"
106
+ local now
107
+ now=$(_lp_now)
108
+ echo "${now}|${level}|${hook_type}|${message}" >> "$LP_LOG_FILE" 2>/dev/null || true
109
+ }
110
+
111
+ # ============================================================
112
+ # PUBLIC API
113
+ # ============================================================
114
+
115
+ # Check if a hook invocation should be suppressed.
116
+ # Returns 0 (true/suppress) if the hook has been called too many times.
117
+ # Returns 1 (false/allow) if the hook should proceed normally.
118
+ lp_should_suppress() {
119
+ local hook_type="${1:-unknown}"
120
+
121
+ if [ "$LP_DISABLED" = "1" ]; then
122
+ return 1 # don't suppress
123
+ fi
124
+
125
+ _lp_init
126
+
127
+ local state
128
+ state=$(_lp_read_state "$hook_type")
129
+ local count first_ts last_ts suppressed
130
+ count=$(echo "$state" | cut -d'|' -f1)
131
+ first_ts=$(echo "$state" | cut -d'|' -f2)
132
+ last_ts=$(echo "$state" | cut -d'|' -f3)
133
+ suppressed=$(echo "$state" | cut -d'|' -f4)
134
+
135
+ local now
136
+ now=$(_lp_now)
137
+
138
+ # Reset window if first_ts is too old
139
+ if [ "$first_ts" != "0" ] && [ $((now - first_ts)) -gt "$LP_WINDOW_SECS" ]; then
140
+ count=0
141
+ first_ts="$now"
142
+ suppressed=0
143
+ fi
144
+
145
+ # Dedup: if called within LP_DEDUP_SECS of last call, always suppress
146
+ if [ "$last_ts" != "0" ] && [ $((now - last_ts)) -lt "$LP_DEDUP_SECS" ]; then
147
+ suppressed=$((suppressed + 1))
148
+ _lp_write_state "$hook_type" "$count" "$first_ts" "$now" "$suppressed"
149
+ return 0 # suppress (dedup)
150
+ fi
151
+
152
+ # Check thresholds
153
+ if [ "$count" -ge "$LP_CIRCUIT_BREAK" ]; then
154
+ # Circuit breaker: emit ONE final warning then suppress everything
155
+ if [ "$suppressed" -eq 0 ] || [ $((count % LP_CIRCUIT_BREAK)) -eq 0 ]; then
156
+ _lp_log_event "CIRCUIT_BREAK" "$hook_type" "count=${count} in window"
157
+ fi
158
+ suppressed=$((suppressed + 1))
159
+ _lp_write_state "$hook_type" "$count" "$first_ts" "$now" "$suppressed"
160
+ return 0 # suppress
161
+ fi
162
+
163
+ if [ "$count" -ge "$LP_HARD_LIMIT" ]; then
164
+ # Hard limit: suppress output, log
165
+ if [ $((count % 10)) -eq 0 ]; then
166
+ _lp_log_event "HARD_LIMIT" "$hook_type" "count=${count} suppressed=${suppressed}"
167
+ fi
168
+ suppressed=$((suppressed + 1))
169
+ _lp_write_state "$hook_type" "$count" "$first_ts" "$now" "$suppressed"
170
+ return 0 # suppress
171
+ fi
172
+
173
+ return 1 # allow
174
+ }
175
+
176
+ # Record a hook invocation (call AFTER producing output).
177
+ lp_record_invocation() {
178
+ local hook_type="${1:-unknown}"
179
+
180
+ if [ "$LP_DISABLED" = "1" ]; then
181
+ return 0
182
+ fi
183
+
184
+ _lp_init
185
+
186
+ local state
187
+ state=$(_lp_read_state "$hook_type")
188
+ local count first_ts last_ts suppressed
189
+ count=$(echo "$state" | cut -d'|' -f1)
190
+ first_ts=$(echo "$state" | cut -d'|' -f2)
191
+ last_ts=$(echo "$state" | cut -d'|' -f3)
192
+ suppressed=$(echo "$state" | cut -d'|' -f4)
193
+
194
+ local now
195
+ now=$(_lp_now)
196
+
197
+ # Reset window if expired
198
+ if [ "$first_ts" = "0" ] || [ $((now - first_ts)) -gt "$LP_WINDOW_SECS" ]; then
199
+ count=1
200
+ first_ts="$now"
201
+ suppressed=0
202
+ else
203
+ count=$((count + 1))
204
+ fi
205
+
206
+ _lp_write_state "$hook_type" "$count" "$first_ts" "$now" "$suppressed"
207
+
208
+ # Emit warnings at soft limit
209
+ if [ "$count" -eq "$LP_SOFT_LIMIT" ]; then
210
+ _lp_log_event "SOFT_LIMIT" "$hook_type" "count=${count} - approaching rate limit"
211
+ echo "[UAP-LOOP-PROTECTION] Hook '${hook_type}' has fired ${count} times in ${LP_WINDOW_SECS}s. Output will be suppressed after ${LP_HARD_LIMIT} to prevent token waste." >&2
212
+ fi
213
+ }
214
+
215
+ # Get the circuit breaker warning message (for hooks that want to emit it).
216
+ lp_circuit_breaker_message() {
217
+ local hook_type="${1:-unknown}"
218
+ local state
219
+ state=$(_lp_read_state "$hook_type")
220
+ local count
221
+ count=$(echo "$state" | cut -d'|' -f1)
222
+ local suppressed
223
+ suppressed=$(echo "$state" | cut -d'|' -f4)
224
+
225
+ echo "[UAP-CIRCUIT-BREAKER] Hook '${hook_type}' triggered ${count} times (${suppressed} suppressed). This is a runaway loop. Review your approach — repeated hook warnings are consuming tokens without progress."
226
+ }
227
+
228
+ # Get loop protection stats as a summary string.
229
+ lp_stats() {
230
+ if [ ! -f "$LP_STATE_FILE" ]; then
231
+ echo "No loop protection data for this session."
232
+ return
233
+ fi
234
+ echo "=== UAP Loop Protection Stats ==="
235
+ while IFS='|' read -r hook count first_ts last_ts suppressed; do
236
+ echo " ${hook}: ${count} calls, ${suppressed} suppressed"
237
+ done < "$LP_STATE_FILE"
238
+ echo "================================="
239
+ }
240
+
241
+ # Reset state for a specific hook or all hooks.
242
+ lp_reset() {
243
+ local hook_type="${1:-}"
244
+ if [ -z "$hook_type" ]; then
245
+ rm -f "$LP_STATE_FILE" 2>/dev/null || true
246
+ elif [ -f "$LP_STATE_FILE" ]; then
247
+ grep -v "^${hook_type}|" "$LP_STATE_FILE" > "${LP_STATE_FILE}.tmp" 2>/dev/null || true
248
+ mv "${LP_STATE_FILE}.tmp" "$LP_STATE_FILE" 2>/dev/null || true
249
+ fi
250
+ }
@@ -5,6 +5,15 @@
5
5
  # Always exits 0 (never blocks).
6
6
  set -euo pipefail
7
7
 
8
+ # --- Loop Protection: suppress if compaction is happening in rapid succession ---
9
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
11
+ source "${HOOK_DIR}/loop-protection.sh"
12
+ if lp_should_suppress "post-compact"; then
13
+ exit 0
14
+ fi
15
+ fi
16
+
8
17
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${FACTORY_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-.}}}"
9
18
  DB_PATH="${PROJECT_DIR}/agents/data/memory/short_term.db"
10
19
  COORD_DB="${PROJECT_DIR}/agents/data/coordination/coordination.db"
@@ -108,4 +117,9 @@ fi
108
117
 
109
118
  output+="</system-reminder>"$'\n'
110
119
 
120
+ # --- Record invocation for loop tracking ---
121
+ if type lp_record_invocation &>/dev/null; then
122
+ lp_record_invocation "post-compact"
123
+ fi
124
+
111
125
  echo "$output"
@@ -6,6 +6,16 @@
6
6
  # Always exits 0 (never blocks).
7
7
  set -euo pipefail
8
8
 
9
+ # --- Loop Protection: suppress output if firing too fast ---
10
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
12
+ source "${HOOK_DIR}/loop-protection.sh"
13
+ if lp_should_suppress "post-tool-edit-write"; then
14
+ cat > /dev/null # drain stdin
15
+ exit 0
16
+ fi
17
+ fi
18
+
9
19
  # Read tool input from stdin (JSON)
10
20
  INPUT=$(cat)
11
21
 
@@ -35,4 +45,9 @@ if [ ! -f "${BACKUP_DIR}/${RELATIVE_PATH}" ] 2>/dev/null; then
35
45
  fi
36
46
  fi
37
47
 
48
+ # --- Record invocation for loop tracking ---
49
+ if type lp_record_invocation &>/dev/null; then
50
+ lp_record_invocation "post-tool-edit-write"
51
+ fi
52
+
38
53
  exit 0
@@ -7,6 +7,15 @@
7
7
  # Fails safely - never blocks the agent.
8
8
  set -euo pipefail
9
9
 
10
+ # --- Loop Protection: suppress if compaction is looping ---
11
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
13
+ source "${HOOK_DIR}/loop-protection.sh"
14
+ if lp_should_suppress "pre-compact"; then
15
+ exit 0
16
+ fi
17
+ fi
18
+
10
19
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${FACTORY_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-.}}}"
11
20
  DB_PATH="${PROJECT_DIR}/agents/data/memory/short_term.db"
12
21
  COORD_DB="${PROJECT_DIR}/agents/data/coordination/coordination.db"
@@ -5,6 +5,12 @@
5
5
  # Enforces: iac-pipeline-enforcement, worktree-enforcement, git safety policies.
6
6
  set -euo pipefail
7
7
 
8
+ # --- Loop Protection: track frequency of blocking events ---
9
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
11
+ source "${HOOK_DIR}/loop-protection.sh"
12
+ fi
13
+
8
14
  # Read tool input from stdin (JSON)
9
15
  INPUT=$(cat)
10
16
 
@@ -5,6 +5,12 @@
5
5
  # Enforces: worktree-file-guard, worktree-enforcement policies.
6
6
  set -euo pipefail
7
7
 
8
+ # --- Loop Protection: track frequency of blocking events ---
9
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
11
+ source "${HOOK_DIR}/loop-protection.sh"
12
+ fi
13
+
8
14
  # Read tool input from stdin (JSON)
9
15
  INPUT=$(cat)
10
16
 
@@ -40,5 +46,9 @@ if echo "$FILE_PATH" | grep -q '\.worktrees/'; then
40
46
  fi
41
47
 
42
48
  # BLOCK: path is outside worktrees and not exempt
49
+ # Record the block event for loop detection
50
+ if type lp_record_invocation &>/dev/null; then
51
+ lp_record_invocation "pre-tool-edit-block"
52
+ fi
43
53
  echo '{"decision":"block","reason":"WORKTREE POLICY VIOLATION: File path is outside .worktrees/. All edits must target files inside a worktree. Run: uap worktree create <slug> then edit files in .worktrees/NNN-<slug>/. See policies/worktree-file-guard.md"}' >&2
44
54
  exit 2
@@ -5,6 +5,15 @@
5
5
  # Fails safely - never blocks the agent.
6
6
  set -euo pipefail
7
7
 
8
+ # --- Loop Protection ---
9
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
11
+ source "${HOOK_DIR}/loop-protection.sh"
12
+ if lp_should_suppress "session-start"; then
13
+ exit 0
14
+ fi
15
+ fi
16
+
8
17
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${FACTORY_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-.}}}"
9
18
  DB_PATH="${PROJECT_DIR}/agents/data/memory/short_term.db"
10
19
  COORD_DB="${PROJECT_DIR}/agents/data/coordination/coordination.db"
@@ -101,12 +110,59 @@ if [ -f "$COORD_DB" ]; then
101
110
  " 2>/dev/null || true
102
111
  fi
103
112
 
113
+ # ============================================================
114
+ # MANDATORY: Auto-register this agent + start heartbeat
115
+ # ============================================================
116
+ AGENT_ID="claude-${SESSION_ID:-$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n')}"
117
+ AGENT_NAME="claude-code"
118
+
119
+ if [ -f "$COORD_DB" ]; then
120
+ # Register this agent
121
+ sqlite3 "$COORD_DB" "
122
+ INSERT OR REPLACE INTO agent_registry (id, name, session_id, status, capabilities, started_at, last_heartbeat)
123
+ VALUES ('${AGENT_ID}', '${AGENT_NAME}', '${AGENT_ID}', 'active', '[]', datetime('now'), datetime('now'));
124
+ " 2>/dev/null || true
125
+
126
+ # Check for other active agents and their work
127
+ OTHER_AGENTS=$(sqlite3 "$COORD_DB" "
128
+ SELECT id || ': ' || COALESCE(current_task, 'idle')
129
+ FROM agent_registry
130
+ WHERE status='active' AND id != '${AGENT_ID}'
131
+ ORDER BY last_heartbeat DESC LIMIT 5;
132
+ " 2>/dev/null || true)
133
+
134
+ ACTIVE_WORK=$(sqlite3 "$COORD_DB" "
135
+ SELECT agent_id || ' -> ' || resources
136
+ FROM work_announcements
137
+ WHERE completed_at IS NULL
138
+ ORDER BY announced_at DESC LIMIT 5;
139
+ " 2>/dev/null || true)
140
+ fi
141
+
142
+ # Export agent ID for downstream tools
143
+ export UAP_AGENT_ID="${AGENT_ID}"
144
+
145
+ # Start background heartbeat (every 30s, auto-stops when shell exits)
146
+ if [ -f "$COORD_DB" ]; then
147
+ (
148
+ while true; do
149
+ sleep 30
150
+ sqlite3 "$COORD_DB" "UPDATE agent_registry SET last_heartbeat=datetime('now') WHERE id='${AGENT_ID}';" 2>/dev/null || break
151
+ done
152
+ ) &
153
+ HEARTBEAT_PID=$!
154
+ # Ensure heartbeat stops and agent deregisters on exit
155
+ trap "kill $HEARTBEAT_PID 2>/dev/null; sqlite3 \"$COORD_DB\" \"UPDATE agent_registry SET status='completed' WHERE id='${AGENT_ID}';\" 2>/dev/null" EXIT
156
+ fi
157
+
104
158
  # ============================================================
105
159
  # WORKTREE ENFORCEMENT GATE
106
160
  # Detects if running on master/main outside a worktree and
107
161
  # emits a blocking system-reminder to prevent direct edits.
108
162
  # ============================================================
109
163
  CURRENT_BRANCH=$(git -C "$PROJECT_DIR" branch --show-current 2>/dev/null || echo "unknown")
164
+
165
+ # Detect worktree via git-dir vs git-common-dir comparison
110
166
  GIT_DIR_VAL=$(git -C "$PROJECT_DIR" rev-parse --git-dir 2>/dev/null || echo "")
111
167
  GIT_COMMON_DIR_VAL=$(git -C "$PROJECT_DIR" rev-parse --git-common-dir 2>/dev/null || echo "")
112
168
  IS_IN_WORKTREE="false"
@@ -150,51 +206,6 @@ if [ "$IS_IN_WORKTREE" = "false" ] && { [ "$CURRENT_BRANCH" = "master" ] || [ "$
150
206
  echo "$worktree_output"
151
207
  fi
152
208
 
153
- # ============================================================
154
- # MANDATORY: Auto-register this agent + start heartbeat
155
- # ============================================================
156
- AGENT_ID="claude-${SESSION_ID:-$(head -c 6 /dev/urandom | od -An -tx1 | tr -d ' \n')}"
157
- AGENT_NAME="claude-code"
158
-
159
- if [ -f "$COORD_DB" ]; then
160
- # Register this agent
161
- sqlite3 "$COORD_DB" "
162
- INSERT OR REPLACE INTO agent_registry (id, name, session_id, status, capabilities, started_at, last_heartbeat)
163
- VALUES ('${AGENT_ID}', '${AGENT_NAME}', '${AGENT_ID}', 'active', '[]', datetime('now'), datetime('now'));
164
- " 2>/dev/null || true
165
-
166
- # Check for other active agents and their work
167
- OTHER_AGENTS=$(sqlite3 "$COORD_DB" "
168
- SELECT id || ': ' || COALESCE(current_task, 'idle')
169
- FROM agent_registry
170
- WHERE status='active' AND id != '${AGENT_ID}'
171
- ORDER BY last_heartbeat DESC LIMIT 5;
172
- " 2>/dev/null || true)
173
-
174
- ACTIVE_WORK=$(sqlite3 "$COORD_DB" "
175
- SELECT agent_id || ' -> ' || resources
176
- FROM work_announcements
177
- WHERE completed_at IS NULL
178
- ORDER BY announced_at DESC LIMIT 5;
179
- " 2>/dev/null || true)
180
- fi
181
-
182
- # Export agent ID for downstream tools
183
- export UAP_AGENT_ID="${AGENT_ID}"
184
-
185
- # Start background heartbeat (every 30s, auto-stops when shell exits)
186
- if [ -f "$COORD_DB" ]; then
187
- (
188
- while true; do
189
- sleep 30
190
- sqlite3 "$COORD_DB" "UPDATE agent_registry SET last_heartbeat=datetime('now') WHERE id='${AGENT_ID}';" 2>/dev/null || break
191
- done
192
- ) &
193
- HEARTBEAT_PID=$!
194
- # Ensure heartbeat stops and agent deregisters on exit
195
- trap "kill $HEARTBEAT_PID 2>/dev/null; sqlite3 \"$COORD_DB\" \"UPDATE agent_registry SET status='completed' WHERE id='${AGENT_ID}';\" 2>/dev/null" EXIT
196
- fi
197
-
198
209
  output=""
199
210
 
200
211
  # ============================================================
@@ -412,4 +423,8 @@ fi
412
423
 
413
424
  if [ -n "$output" ]; then
414
425
  echo "$output"
426
+ # Record invocation for loop tracking
427
+ if type lp_record_invocation &>/dev/null; then
428
+ lp_record_invocation "session-start"
429
+ fi
415
430
  fi
@@ -6,6 +6,15 @@
6
6
  # Enforces: completion-gate, mandatory-testing-deployment policies.
7
7
  set -euo pipefail
8
8
 
9
+ # --- Loop Protection ---
10
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ if [ -f "${HOOK_DIR}/loop-protection.sh" ]; then
12
+ source "${HOOK_DIR}/loop-protection.sh"
13
+ if lp_should_suppress "stop"; then
14
+ exit 0
15
+ fi
16
+ fi
17
+
9
18
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-${FACTORY_PROJECT_DIR:-${CURSOR_PROJECT_DIR:-.}}}"
10
19
  DB_PATH="${PROJECT_DIR}/agents/data/memory/short_term.db"
11
20
  COORD_DB="${PROJECT_DIR}/agents/data/coordination/coordination.db"
@@ -130,6 +130,11 @@ class SessionMonitor:
130
130
  overflow_count: int = 0 # How many context overflow errors caught
131
131
  context_history: list = field(default_factory=list) # Recent token counts
132
132
 
133
+ # --- Token Loop Protection ---
134
+ tool_call_history: list = field(default_factory=list) # Recent tool call fingerprints
135
+ consecutive_forced_count: int = 0 # How many times tool_choice was forced consecutively
136
+ loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
137
+
133
138
  def record_request(self, estimated_tokens: int):
134
139
  """Record an outgoing request's estimated token count."""
135
140
  self.total_requests += 1
@@ -213,6 +218,85 @@ class SessionMonitor:
213
218
  turns_str,
214
219
  )
215
220
 
221
+ # --- Token Loop Protection Methods ---
222
+
223
+ def record_tool_calls(self, tool_names: list[str]):
224
+ """Record tool call names for loop detection."""
225
+ fingerprint = "|".join(sorted(tool_names)) if tool_names else ""
226
+ self.tool_call_history.append(fingerprint)
227
+ # Keep last 30 entries
228
+ if len(self.tool_call_history) > 30:
229
+ self.tool_call_history = self.tool_call_history[-30:]
230
+
231
+ def detect_tool_loop(self, window: int = 6) -> tuple[bool, int]:
232
+ """Detect if the model is stuck in a tool call loop.
233
+
234
+ Checks if the last `window` tool call fingerprints are identical.
235
+ Returns (is_looping, repeat_count).
236
+ """
237
+ if len(self.tool_call_history) < window:
238
+ return False, 0
239
+
240
+ recent = self.tool_call_history[-window:]
241
+ if not recent[0]:
242
+ return False, 0
243
+
244
+ # Check if all recent entries are the same fingerprint
245
+ if all(fp == recent[0] for fp in recent):
246
+ # Count total consecutive repeats from the end
247
+ count = 0
248
+ target = recent[0]
249
+ for fp in reversed(self.tool_call_history):
250
+ if fp == target:
251
+ count += 1
252
+ else:
253
+ break
254
+ return True, count
255
+
256
+ return False, 0
257
+
258
+ def should_release_tool_choice(self) -> bool:
259
+ """Determine if tool_choice should be relaxed to 'auto' to break a loop.
260
+
261
+ Returns True if the model appears stuck and forcing tool_choice=required
262
+ is making it worse. Thresholds:
263
+ - 8+ consecutive forced requests with same tool pattern -> release
264
+ - 15+ consecutive forced requests regardless -> release
265
+ - Context utilization > 90% -> release (let model wrap up)
266
+ """
267
+ is_looping, repeat_count = self.detect_tool_loop(window=6)
268
+
269
+ # Pattern 1: Detected tool call loop
270
+ if is_looping and repeat_count >= 8:
271
+ logger.warning(
272
+ "LOOP BREAKER: Same tool pattern repeated %d times. "
273
+ "Releasing tool_choice to 'auto'.",
274
+ repeat_count,
275
+ )
276
+ self.loop_warnings_emitted += 1
277
+ return True
278
+
279
+ # Pattern 2: Too many consecutive forced requests
280
+ if self.consecutive_forced_count >= 15:
281
+ logger.warning(
282
+ "LOOP BREAKER: %d consecutive forced tool_choice requests. "
283
+ "Releasing to 'auto'.",
284
+ self.consecutive_forced_count,
285
+ )
286
+ self.loop_warnings_emitted += 1
287
+ return True
288
+
289
+ # Pattern 3: Context almost full -- let model wrap up naturally
290
+ if self.get_utilization() >= 0.90:
291
+ logger.warning(
292
+ "LOOP BREAKER: Context utilization %.1f%% -- releasing "
293
+ "tool_choice to let model wrap up.",
294
+ self.get_utilization() * 100,
295
+ )
296
+ return True
297
+
298
+ return False
299
+
216
300
 
217
301
  session_monitor = SessionMonitor()
218
302
 
@@ -684,6 +768,10 @@ def build_openai_request(anthropic_body: dict) -> dict:
684
768
  # - More than 1 message (conversation is in progress)
685
769
  # - Last assistant was text-only (would cause premature stop)
686
770
  # - OR conversation has tool_result messages (active agentic loop)
771
+ #
772
+ # LOOP PROTECTION: Release to "auto" if the session monitor detects
773
+ # a tool call loop (same tools called repeatedly), to prevent
774
+ # runaway token consumption.
687
775
  n_msgs = len(anthropic_body.get("messages", []))
688
776
  has_tool_results = any(
689
777
  isinstance(m.get("content"), list) and any(
@@ -692,16 +780,47 @@ def build_openai_request(anthropic_body: dict) -> dict:
692
780
  )
693
781
  for m in anthropic_body.get("messages", [])
694
782
  )
695
- if _last_assistant_was_text_only(anthropic_body):
783
+
784
+ # Record tool calls from the last assistant message for loop detection
785
+ _record_last_assistant_tool_calls(anthropic_body)
786
+
787
+ # Check if loop breaker should override tool_choice
788
+ if session_monitor.should_release_tool_choice():
789
+ openai_body["tool_choice"] = "auto"
790
+ session_monitor.consecutive_forced_count = 0
791
+ logger.warning("tool_choice set to 'auto' by LOOP BREAKER")
792
+ elif _last_assistant_was_text_only(anthropic_body):
696
793
  openai_body["tool_choice"] = "required"
794
+ session_monitor.consecutive_forced_count += 1
697
795
  logger.info("tool_choice forced to 'required' (last assistant was text-only)")
698
796
  elif has_tool_results and n_msgs > 2:
699
797
  openai_body["tool_choice"] = "required"
798
+ session_monitor.consecutive_forced_count += 1
700
799
  logger.info("tool_choice forced to 'required' (active agentic loop with tool results)")
800
+ else:
801
+ session_monitor.consecutive_forced_count = 0
701
802
 
702
803
  return openai_body
703
804
 
704
805
 
806
+ def _record_last_assistant_tool_calls(anthropic_body: dict):
807
+ """Extract tool call names from the last assistant message and record
808
+ them in the session monitor for loop detection."""
809
+ messages = anthropic_body.get("messages", [])
810
+ tool_names = []
811
+ for msg in reversed(messages):
812
+ if msg.get("role") != "assistant":
813
+ continue
814
+ content = msg.get("content")
815
+ if isinstance(content, list):
816
+ for block in content:
817
+ if isinstance(block, dict) and block.get("type") == "tool_use":
818
+ tool_names.append(block.get("name", "unknown"))
819
+ break
820
+ if tool_names:
821
+ session_monitor.record_tool_calls(tool_names)
822
+
823
+
705
824
  def _last_assistant_was_text_only(anthropic_body: dict) -> bool:
706
825
  """Check if the last assistant message in the conversation was text-only
707
826
  (no tool_use blocks). This indicates the model may be prematurely ending
@@ -1281,6 +1400,15 @@ async def context_status():
1281
1400
  "overflow_count": session_monitor.overflow_count,
1282
1401
  "prune_threshold": PROXY_CONTEXT_PRUNE_THRESHOLD,
1283
1402
  "recent_history": session_monitor.context_history[-10:],
1403
+ # Loop protection stats
1404
+ "loop_protection": {
1405
+ "consecutive_forced_count": session_monitor.consecutive_forced_count,
1406
+ "loop_warnings_emitted": session_monitor.loop_warnings_emitted,
1407
+ "tool_call_history_len": len(session_monitor.tool_call_history),
1408
+ "is_looping": session_monitor.detect_tool_loop()[0],
1409
+ "loop_repeat_count": session_monitor.detect_tool_loop()[1],
1410
+ "recent_tool_patterns": session_monitor.tool_call_history[-5:],
1411
+ },
1284
1412
  }
1285
1413
 
1286
1414