@miller-tech/uap 1.101.0 → 1.101.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.101.0",
3
+ "version": "1.101.1",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -205,6 +205,32 @@ PROXY_FORCED_HARD_RELEASE = int(os.environ.get("PROXY_FORCED_HARD_RELEASE", "30"
205
205
  PROXY_CONTEXT_RELEASE_THRESHOLD = float(
206
206
  os.environ.get("PROXY_CONTEXT_RELEASE_THRESHOLD", "0.90")
207
207
  )
208
+ # ---------------------------------------------------------------------------
209
+ # STUCK-BREAK guardrail: a small model can recognize it is looping ("I've been
210
+ # stuck in a loop, let me break out") yet keep repeating the SAME failing tool
211
+ # call -- meta-cognition without an exit. Two signals, both observed live on a
212
+ # qwen3.6 session that looped ~18min fetching a rate-limited GitHub API:
213
+ # (a) repeated self-reported "stuck" assistant text, and
214
+ # (b) repeated tool calls hitting a known rate-limited API host.
215
+ # When either streak crosses its threshold the proxy forces a TERMINAL turn:
216
+ # tool_choice back to auto + a firm directive to stop retrying and either
217
+ # proceed without the unreachable resource or ask the operator. Default on;
218
+ # PROXY_STUCK_BREAK=off to disable.
219
+ PROXY_STUCK_BREAK = os.environ.get("PROXY_STUCK_BREAK", "on").lower() not in {
220
+ "0", "false", "off", "no",
221
+ }
222
+ # Self-reported-stuck phrases (lowercased match). Deliberately narrow.
223
+ _STUCK_PHRASE_RE = re.compile(
224
+ r"stuck in a loop|been stuck|break out of (?:this|the) loop|going in circles|"
225
+ r"repeating myself|same (?:thing|error) (?:again|repeatedly)",
226
+ re.IGNORECASE,
227
+ )
228
+ # Tool args reaching into a rate-limited REST API host (the wrong channel; the
229
+ # hint steers to the browser tool / git clone, which are not rate-limited).
230
+ _RATE_LIMITED_API_RE = re.compile(r"api\.github\.com", re.IGNORECASE)
231
+ PROXY_STUCK_TEXT_THRESHOLD = int(os.environ.get("PROXY_STUCK_TEXT_THRESHOLD", "2"))
232
+ PROXY_STUCK_API_THRESHOLD = int(os.environ.get("PROXY_STUCK_API_THRESHOLD", "3"))
233
+
208
234
  PROXY_TOOL_STATE_MACHINE = os.environ.get(
209
235
  "PROXY_TOOL_STATE_MACHINE", "on"
210
236
  ).lower() not in {
@@ -1128,6 +1154,9 @@ class SessionMonitor:
1128
1154
  recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
1129
1155
  catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
1130
1156
  unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
1157
+ self_stuck_streak: int = 0 # consecutive assistant texts self-reporting a loop
1158
+ rate_limited_api_streak: int = 0 # consecutive tool calls hitting a rate-limited API host
1159
+ stuck_break_fires: int = 0 # monotonic count of forced stuck-breaks
1131
1160
  tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
1132
1161
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
1133
1162
  invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
@@ -1363,6 +1392,40 @@ class SessionMonitor:
1363
1392
  by_tool = self.tool_target_history.setdefault(name, {})
1364
1393
  by_tool[target] = by_tool.get(target, 0) + 1
1365
1394
 
1395
+ def note_assistant_text(self, text: str) -> None:
1396
+ """Track the model self-reporting that it is stuck (STUCK-BREAK signal
1397
+ (a)). A matching turn increments the streak; a non-matching turn resets
1398
+ it, so only SUSTAINED self-reported looping trips the break."""
1399
+ if not PROXY_STUCK_BREAK or not text:
1400
+ self.self_stuck_streak = 0
1401
+ return
1402
+ if _STUCK_PHRASE_RE.search(text):
1403
+ self.self_stuck_streak += 1
1404
+ else:
1405
+ self.self_stuck_streak = 0
1406
+
1407
+ def note_tool_arg_hosts(self, arg_blobs: list) -> None:
1408
+ """Track repeated tool calls into a rate-limited API host (STUCK-BREAK
1409
+ signal (b)). Reset when a turn uses none, so only a sustained wrong-
1410
+ channel loop trips the hint."""
1411
+ if not PROXY_STUCK_BREAK:
1412
+ return
1413
+ blob = " ".join(a for a in arg_blobs if isinstance(a, str))
1414
+ if _RATE_LIMITED_API_RE.search(blob):
1415
+ self.rate_limited_api_streak += 1
1416
+ else:
1417
+ self.rate_limited_api_streak = 0
1418
+
1419
+ def should_force_stuck_break(self) -> tuple[bool, str]:
1420
+ """True + reason when a terminal break should be forced this turn."""
1421
+ if not PROXY_STUCK_BREAK:
1422
+ return False, ""
1423
+ if self.self_stuck_streak >= PROXY_STUCK_TEXT_THRESHOLD:
1424
+ return True, f"self-reported stuck x{self.self_stuck_streak}"
1425
+ if self.rate_limited_api_streak >= PROXY_STUCK_API_THRESHOLD:
1426
+ return True, f"rate-limited-API retries x{self.rate_limited_api_streak}"
1427
+ return False, ""
1428
+
1366
1429
  def has_duplicate_read_target(self, threshold: int = 2) -> tuple[bool, str]:
1367
1430
  """Check if any read-only tool has re-read the same target >= threshold times.
1368
1431
 
@@ -4067,6 +4130,36 @@ def _writes_are_gated(openai_body: dict) -> bool:
4067
4130
  return False
4068
4131
 
4069
4132
 
4133
+ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> None:
4134
+ """Force a terminal turn when the model is looping self-awarely or hammering
4135
+ a rate-limited API. Unlike the cycle-breaker (which narrows tools), this
4136
+ STOPS tool coercion and tells the model to synthesize / ask / route around
4137
+ the unreachable resource -- converting the model's own "I'm stuck" into an
4138
+ actual exit. Fires at most escalating; monotonic counter for telemetry."""
4139
+ should, reason = monitor.should_force_stuck_break()
4140
+ if not should:
4141
+ return
4142
+ monitor.stuck_break_fires += 1
4143
+ # Release the tool-choice coercion so a plain text turn is allowed.
4144
+ if openai_body.get("tool_choice") == "required":
4145
+ openai_body["tool_choice"] = "auto"
4146
+ directive = (
4147
+ "\n\nSTOP — you are repeating a failing action (" + reason + "). Do NOT "
4148
+ "retry the same tool or fetch again. If a resource is unreachable (e.g. a "
4149
+ "rate-limited GitHub REST API), switch channel: use the browser tool or "
4150
+ "`git clone` (git protocol), NOT api.github.com. If it is still "
4151
+ "unavailable, proceed WITHOUT it using what you already have, or ask the "
4152
+ "operator the single blocking question in one sentence. Take a DIFFERENT "
4153
+ "action now."
4154
+ )
4155
+ msgs = openai_body.get("messages") or []
4156
+ if msgs and msgs[0].get("role") == "system":
4157
+ msgs[0]["content"] = (msgs[0].get("content") or "") + directive
4158
+ else:
4159
+ msgs.insert(0, {"role": "system", "content": directive.strip()})
4160
+ logger.warning("STUCK-BREAK: forced terminal turn (%s, fires=%d)", reason, monitor.stuck_break_fires)
4161
+
4162
+
4070
4163
  def _maybe_inject_recon_convergence(
4071
4164
  openai_body: dict,
4072
4165
  monitor: "SessionMonitor",
@@ -4846,6 +4939,8 @@ def build_openai_request(
4846
4939
  # pre-narrowing toolset so it can restore a dropped write tool.
4847
4940
  _maybe_inject_recon_convergence(openai_body, monitor, full_openai_tools)
4848
4941
 
4942
+ _maybe_inject_stuck_break(openai_body, monitor)
4943
+
4849
4944
  _apply_thinking_grammar(openai_body)
4850
4945
 
4851
4946
  _apply_json_response_grammar(openai_body, anthropic_body)
@@ -8612,6 +8707,12 @@ async def stream_anthropic_response(
8612
8707
  tc_names,
8613
8708
  [a[:200] for a in tc_args],
8614
8709
  )
8710
+ # STUCK-BREAK signals: feed the assistant text + tool args to the monitor.
8711
+ try:
8712
+ monitor.note_assistant_text(accumulated_text)
8713
+ monitor.note_tool_arg_hosts(list(tc_args))
8714
+ except Exception:
8715
+ pass
8615
8716
 
8616
8717
  # -------------------------------------------------------------------
8617
8718
  # Post-stream: recover <tool_call> XML from accumulated text