@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/token-throughput.d.ts +46 -46
- package/dist/bin/cli.js +0 -0
- package/dist/bin/llama-server-optimize.js +0 -0
- package/dist/bin/policy.js +0 -0
- package/dist/cli/hooks.js +1 -0
- package/dist/cli/hooks.js.map +1 -1
- package/dist/dashboard/data-seeder.d.ts +8 -7
- package/dist/dashboard/data-seeder.d.ts.map +1 -1
- package/dist/dashboard/data-seeder.js +13 -320
- package/dist/dashboard/data-seeder.js.map +1 -1
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +8 -21
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/models/types.d.ts +12 -12
- package/dist/policies/schemas/policy.d.ts +12 -12
- package/dist/types/config.d.ts +24 -24
- package/package.json +1 -1
- package/templates/hooks/loop-protection.sh +250 -0
- package/templates/hooks/post-compact.sh +14 -0
- package/templates/hooks/post-tool-use-edit-write.sh +15 -0
- package/templates/hooks/pre-compact.sh +9 -0
- package/templates/hooks/pre-tool-use-bash.sh +6 -0
- package/templates/hooks/pre-tool-use-edit-write.sh +10 -0
- package/templates/hooks/session-start.sh +60 -45
- package/templates/hooks/stop.sh +9 -0
- package/tools/agents/scripts/anthropic_proxy.py +129 -1
- package/tools/agents/scripts/__pycache__/anthropic_proxy.cpython-313.pyc +0 -0
- package/tools/agents/scripts/__pycache__/tool_call_wrapper.cpython-313.pyc +0 -0
|
@@ -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
|
package/templates/hooks/stop.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
Binary file
|
|
Binary file
|