@miller-tech/uap 1.46.4 → 1.47.0

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.
Files changed (37) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +8 -0
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/deliver.d.ts +14 -0
  5. package/dist/cli/deliver.d.ts.map +1 -1
  6. package/dist/cli/deliver.js +204 -11
  7. package/dist/cli/deliver.js.map +1 -1
  8. package/dist/delivery/agentic-executor.d.ts +8 -0
  9. package/dist/delivery/agentic-executor.d.ts.map +1 -1
  10. package/dist/delivery/agentic-executor.js +12 -2
  11. package/dist/delivery/agentic-executor.js.map +1 -1
  12. package/dist/delivery/applier.d.ts +8 -0
  13. package/dist/delivery/applier.d.ts.map +1 -1
  14. package/dist/delivery/applier.js +35 -1
  15. package/dist/delivery/applier.js.map +1 -1
  16. package/dist/delivery/auto-optimizer.d.ts +11 -0
  17. package/dist/delivery/auto-optimizer.d.ts.map +1 -1
  18. package/dist/delivery/auto-optimizer.js +11 -2
  19. package/dist/delivery/auto-optimizer.js.map +1 -1
  20. package/dist/delivery/ci-watcher.d.ts +72 -0
  21. package/dist/delivery/ci-watcher.d.ts.map +1 -0
  22. package/dist/delivery/ci-watcher.js +221 -0
  23. package/dist/delivery/ci-watcher.js.map +1 -0
  24. package/dist/delivery/deploy-dev-gate.d.ts +44 -0
  25. package/dist/delivery/deploy-dev-gate.d.ts.map +1 -0
  26. package/dist/delivery/deploy-dev-gate.js +175 -0
  27. package/dist/delivery/deploy-dev-gate.js.map +1 -0
  28. package/dist/delivery/verifier-ladder.d.ts +77 -0
  29. package/dist/delivery/verifier-ladder.d.ts.map +1 -1
  30. package/dist/delivery/verifier-ladder.js +225 -3
  31. package/dist/delivery/verifier-ladder.js.map +1 -1
  32. package/docs/INDEX.md +2 -2
  33. package/docs/guides/AUTOMATIC.md +255 -61
  34. package/docs/guides/AUTOMATIC_FEATURES.md +164 -0
  35. package/package.json +1 -1
  36. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  37. package/tools/agents/scripts/anthropic_proxy.py +201 -24
@@ -150,6 +150,15 @@ PROXY_LOOP_REPEAT_THRESHOLD = int(os.environ.get("PROXY_LOOP_REPEAT_THRESHOLD",
150
150
  PROXY_CYCLE_TRIGGER_REPEAT = int(os.environ.get("PROXY_CYCLE_TRIGGER_REPEAT", "3"))
151
151
  PROXY_FORCED_THRESHOLD = int(os.environ.get("PROXY_FORCED_THRESHOLD", "15"))
152
152
  PROXY_NO_PROGRESS_THRESHOLD = int(os.environ.get("PROXY_NO_PROGRESS_THRESHOLD", "3"))
153
+ # Fix D: streak-independent escape hatch. `no_progress_streak` resets to 0 on
154
+ # every turn whose last user message carries a tool_result (line ~3835) — i.e.
155
+ # every turn of a normal agentic loop — so the no_progress-gated LOOP BREAKER
156
+ # patterns can never accumulate. After this many *consecutive* forced-'required'
157
+ # turns (which DOES accumulate across an agentic loop via consecutive_forced_count),
158
+ # release tool_choice to 'auto' regardless of no_progress_streak so the model can
159
+ # emit a terminating response. Set well above any healthy run length (healthy
160
+ # loops hit auto/finalize/review phases that reset the count). 0 disables.
161
+ PROXY_FORCED_HARD_RELEASE = int(os.environ.get("PROXY_FORCED_HARD_RELEASE", "30"))
153
162
  PROXY_CONTEXT_RELEASE_THRESHOLD = float(
154
163
  os.environ.get("PROXY_CONTEXT_RELEASE_THRESHOLD", "0.90")
155
164
  )
@@ -258,6 +267,34 @@ PROXY_FINALIZE_SESSION_HARD_CAP = int(
258
267
  PROXY_RECON_CONVERGENCE_THRESHOLD = int(
259
268
  os.environ.get("PROXY_RECON_CONVERGENCE_THRESHOLD", "40")
260
269
  )
270
+ # Fix E: the recon hard tier (streak >= 2x threshold) fires a directive + flips
271
+ # tool_choice to 'auto', but `consecutive_no_write_turns` resets to 0 whenever
272
+ # the model emits any write tool — so a loop that periodically writes sawtooths
273
+ # the streak (observed: 90 -> 0 -> climb again), re-triggering the hard tier
274
+ # forever and never actually terminating. This counts how many times the hard
275
+ # tier has fired across the whole session (monotonic, never reset). Once it
276
+ # reaches this cap, the guard escalates: it strips tools for the turn so the
277
+ # model is forced to emit a terminal prose summary, breaking the sawtooth. 0
278
+ # disables the escalation (hard tier still flips to 'auto' each time).
279
+ PROXY_RECON_SESSION_HARD_CAP = int(
280
+ os.environ.get("PROXY_RECON_SESSION_HARD_CAP", "3")
281
+ )
282
+ # Fix F: context death-spiral breaker. When the *raw* (pre-prune) incoming
283
+ # context stays catastrophically over the window for several consecutive turns,
284
+ # releasing tool_choice to 'auto' (Fix B / LOOP BREAKER) is NOT enough — the
285
+ # model keeps voluntarily emitting tool calls and the client keeps resending an
286
+ # ever-growing transcript (observed: ctx 936%, model emits tool_calls 18/min
287
+ # despite tool_choice=auto). After this many consecutive turns at/above the
288
+ # ratio, strip tools entirely so the only possible response is a terminal text
289
+ # summary (end_turn), which ends the client's agentic loop. Ratio is set high
290
+ # enough that only a true runaway trips it — a merely-full session tops out near
291
+ # 100-130%, never 300%. 0 disables.
292
+ PROXY_RAW_CTX_FINALIZE_RATIO = float(
293
+ os.environ.get("PROXY_RAW_CTX_FINALIZE_RATIO", "3.0")
294
+ )
295
+ PROXY_RAW_CTX_FINALIZE_STREAK = int(
296
+ os.environ.get("PROXY_RAW_CTX_FINALIZE_STREAK", "2")
297
+ )
261
298
  PROXY_STREAM_REASONING_FALLBACK = (
262
299
  os.environ.get("PROXY_STREAM_REASONING_FALLBACK", "off").strip().lower()
263
300
  )
@@ -800,6 +837,13 @@ class SessionMonitor:
800
837
  last_input_tokens: int = 0 # Estimated input tokens of last request
801
838
  last_output_tokens: int = 0 # Actual output tokens of last response
802
839
  peak_input_tokens: int = 0 # High-water mark
840
+ # Fix B: the incoming (pre-prune) token count for the current request. The
841
+ # proxy prunes the conversation and then calls record_request() again with
842
+ # the post-prune total, so last_input_tokens / get_utilization() reflect the
843
+ # *pruned* size (~30%) by the time the tool_choice guards run — masking the
844
+ # fact that the client just sent e.g. 800% of the window. This preserves the
845
+ # raw size so LOOP BREAKER pattern 3 can release on real context blow-up.
846
+ pre_prune_input_tokens: int = 0
803
847
  prune_count: int = 0 # How many times pruning was triggered
804
848
  overflow_count: int = 0 # How many context overflow errors caught
805
849
  prune_drop_count: int = 0 # monotonic: # of oldest middle msgs pruned (B3)
@@ -818,6 +862,8 @@ class SessionMonitor:
818
862
  loop_warnings_emitted: int = 0 # How many loop warnings sent to the model
819
863
  no_progress_streak: int = 0 # Forced tool turns without new tool_result
820
864
  consecutive_no_write_turns: int = 0 # turns exploring with no write tool (B1)
865
+ recon_hard_fires: int = 0 # Fix E: monotonic count of recon hard-tier firings
866
+ catastrophic_ctx_streak: int = 0 # Fix F: consecutive turns raw ctx >= finalize ratio
821
867
  unexpected_end_turn_count: int = 0 # end_turn without tool_use in active loop
822
868
  tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
823
869
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
@@ -878,6 +924,18 @@ class SessionMonitor:
878
924
  return 0.0
879
925
  return self.last_input_tokens / self.context_window
880
926
 
927
+ def get_raw_utilization(self) -> float:
928
+ """Pre-prune context utilization for the current request (Fix B).
929
+
930
+ Reflects what the client actually sent this turn, before the proxy
931
+ pruned it. Used by the loop breaker so a runaway client that resends
932
+ 800% of the window each turn is detected even though post-prune
933
+ utilization reads ~30%. Returns 0.0 until the first request is recorded.
934
+ """
935
+ if self.context_window <= 0:
936
+ return 0.0
937
+ return self.pre_prune_input_tokens / self.context_window
938
+
881
939
  def get_warning_level(self) -> str | None:
882
940
  """Return warning level based on context utilization.
883
941
  Returns None if no warning needed."""
@@ -1238,12 +1296,37 @@ class SessionMonitor:
1238
1296
  self.loop_warnings_emitted += 1
1239
1297
  return True
1240
1298
 
1241
- # Pattern 3: Context almost full -- let model wrap up naturally
1242
- if self.get_utilization() >= PROXY_CONTEXT_RELEASE_THRESHOLD:
1299
+ # Pattern 2b (Fix D): streak-independent forced-count ceiling. In an
1300
+ # agentic loop no_progress_streak resets every turn (tool_result always
1301
+ # present), so Pattern 2 never fires. consecutive_forced_count, however,
1302
+ # accumulates across the loop. Release once it crosses the hard ceiling
1303
+ # regardless of no_progress_streak so the model can terminate.
1304
+ if (
1305
+ PROXY_FORCED_HARD_RELEASE > 0
1306
+ and self.consecutive_forced_count >= PROXY_FORCED_HARD_RELEASE
1307
+ ):
1308
+ logger.warning(
1309
+ "LOOP BREAKER: %d consecutive forced tool_choice requests (hard ceiling %d) -- "
1310
+ "releasing to 'auto' regardless of progress streak.",
1311
+ self.consecutive_forced_count,
1312
+ PROXY_FORCED_HARD_RELEASE,
1313
+ )
1314
+ self.loop_warnings_emitted += 1
1315
+ return True
1316
+
1317
+ # Pattern 3: Context almost full -- let model wrap up naturally.
1318
+ # Fix B: check BOTH post-prune utilization and the raw pre-prune size.
1319
+ # The proxy prunes before this runs, so get_utilization() reads ~30%
1320
+ # even when the client just sent 800% of the window; get_raw_utilization()
1321
+ # exposes the real blow-up so a runaway client is actually released.
1322
+ eff_util = max(self.get_utilization(), self.get_raw_utilization())
1323
+ if eff_util >= PROXY_CONTEXT_RELEASE_THRESHOLD:
1243
1324
  logger.warning(
1244
- "LOOP BREAKER: Context utilization %.1f%% -- releasing "
1245
- "tool_choice to let model wrap up.",
1325
+ "LOOP BREAKER: Context utilization %.1f%% (post-prune %.1f%%, raw %.1f%%) -- "
1326
+ "releasing tool_choice to let model wrap up.",
1327
+ eff_util * 100,
1246
1328
  self.get_utilization() * 100,
1329
+ self.get_raw_utilization() * 100,
1247
1330
  )
1248
1331
  return True
1249
1332
 
@@ -3397,8 +3480,32 @@ def _maybe_inject_recon_convergence(
3397
3480
  streak = monitor.consecutive_no_write_turns
3398
3481
  if streak < PROXY_RECON_CONVERGENCE_THRESHOLD:
3399
3482
  return
3400
- util = monitor.get_utilization()
3401
- if streak >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD:
3483
+ # Report the *raw* (pre-prune) utilization — post-prune util understates the
3484
+ # blow-up (~30%) and makes the directive's "context is at X%" misleading.
3485
+ util = max(monitor.get_utilization(), monitor.get_raw_utilization())
3486
+ hard = streak >= 2 * PROXY_RECON_CONVERGENCE_THRESHOLD
3487
+ escalate = False
3488
+ if hard:
3489
+ monitor.recon_hard_fires += 1 # Fix E: monotonic, never reset
3490
+ escalate = (
3491
+ PROXY_RECON_SESSION_HARD_CAP > 0
3492
+ and monitor.recon_hard_fires >= PROXY_RECON_SESSION_HARD_CAP
3493
+ )
3494
+
3495
+ if escalate:
3496
+ # Fix E: the hard tier has fired repeatedly this session — the model
3497
+ # keeps writing just enough to reset consecutive_no_write_turns, then
3498
+ # re-diverges, sawtoothing the streak and re-triggering the hard tier
3499
+ # forever. Stop negotiating: strip tools so the model MUST emit a
3500
+ # terminal plain-text summary, breaking the sawtooth for good.
3501
+ directive = (
3502
+ f"STOP. You have hit the exploration limit {monitor.recon_hard_fires} "
3503
+ f"times in this session and context is at {util * 100:.0f}%. No tools "
3504
+ "are available this turn. Reply NOW with a plain-text summary of what "
3505
+ "you found and what remains — this ends the task."
3506
+ )
3507
+ tier = "hard-escalated"
3508
+ elif hard:
3402
3509
  directive = (
3403
3510
  f"STOP exploring. You have run {streak} consecutive turns of "
3404
3511
  f"exploration without producing a deliverable and context is at "
@@ -3421,27 +3528,43 @@ def _maybe_inject_recon_convergence(
3421
3528
  msgs.append({"role": "user", "content": directive})
3422
3529
  openai_body["messages"] = msgs
3423
3530
 
3424
- # Re-inject any write/deliverable tool that narrowing dropped, so the
3425
- # "write your deliverable" directive is actually satisfiable. Without
3426
- # this the model is told to write but has no write tool to call, picks
3427
- # another read tool, and the streak climbs unbounded.
3428
3531
  restored: list[str] = []
3429
- if full_tools:
3430
- present = {
3431
- (t.get("function", {}).get("name", "") or "").lower()
3432
- for t in openai_body.get("tools", [])
3433
- }
3434
- for tool in full_tools:
3435
- name = (tool.get("function", {}).get("name", "") or "")
3436
- if name.lower() in _WRITE_TOOL_CLASS and name.lower() not in present:
3437
- openai_body.setdefault("tools", []).append(tool)
3438
- present.add(name.lower())
3439
- restored.append(name)
3532
+ if escalate:
3533
+ # Strip tools entirely so the only possible response is terminal prose.
3534
+ openai_body.pop("tools", None)
3535
+ openai_body.pop("tool_choice", None)
3536
+ openai_body.pop("grammar", None)
3537
+ else:
3538
+ if hard:
3539
+ # Fix C: at the hard tier, drop the structural requirement to call a
3540
+ # tool. Earlier logic forced tool_choice='required' for the active
3541
+ # agentic loop, which directly contradicts "produce your deliverable
3542
+ # NOW / do not run anything else" — the model is forbidden from
3543
+ # terminating and must emit yet another tool call, so the streak
3544
+ # climbs unbounded. Releasing to 'auto' lets it actually write/stop.
3545
+ openai_body["tool_choice"] = "auto"
3546
+ openai_body.pop("grammar", None)
3547
+ # Re-inject any write/deliverable tool that narrowing dropped, so the
3548
+ # "write your deliverable" directive is actually satisfiable. Without
3549
+ # this the model is told to write but has no write tool to call, picks
3550
+ # another read tool, and the streak climbs unbounded.
3551
+ if full_tools:
3552
+ present = {
3553
+ (t.get("function", {}).get("name", "") or "").lower()
3554
+ for t in openai_body.get("tools", [])
3555
+ }
3556
+ for tool in full_tools:
3557
+ name = (tool.get("function", {}).get("name", "") or "")
3558
+ if name.lower() in _WRITE_TOOL_CLASS and name.lower() not in present:
3559
+ openai_body.setdefault("tools", []).append(tool)
3560
+ present.add(name.lower())
3561
+ restored.append(name)
3440
3562
 
3441
3563
  logger.warning(
3442
- "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, ctx=%.0f%%, "
3443
- "restored_write_tools=%s)",
3444
- tier, streak, util * 100, restored or "none",
3564
+ "RECON CONVERGENCE: injected %s directive (no_write_streak=%d, hard_fires=%d, "
3565
+ "ctx=%.0f%%, tool_choice=%s, restored_write_tools=%s)",
3566
+ tier, streak, monitor.recon_hard_fires, util * 100,
3567
+ openai_body.get("tool_choice", "stripped"), restored or "none",
3445
3568
  )
3446
3569
 
3447
3570
 
@@ -3751,6 +3874,46 @@ def build_openai_request(
3751
3874
  last_user_has_tool_result,
3752
3875
  )
3753
3876
 
3877
+ # CONTEXT DEATH-SPIRAL BREAKER (Fix F): raw incoming context has been
3878
+ # catastrophically over the window for several consecutive turns. The
3879
+ # LOOP BREAKER already released tool_choice to 'auto', but the model
3880
+ # keeps voluntarily emitting tool calls and the client keeps resending a
3881
+ # growing transcript, so the loop never ends. Strip tools entirely so
3882
+ # the only possible output is a terminal text summary (end_turn), which
3883
+ # ends the client's agentic loop. Gated high (raw ctx >= 300% for >= N
3884
+ # turns) so only a true runaway trips it, never a merely-full session.
3885
+ if (
3886
+ PROXY_RAW_CTX_FINALIZE_STREAK > 0
3887
+ and monitor.catastrophic_ctx_streak >= PROXY_RAW_CTX_FINALIZE_STREAK
3888
+ ):
3889
+ openai_body.pop("tool_choice", None)
3890
+ openai_body.pop("tools", None)
3891
+ openai_body.pop("grammar", None)
3892
+ msgs = openai_body.get("messages", [])
3893
+ msgs.append({
3894
+ "role": "user",
3895
+ "content": (
3896
+ "The conversation has exceeded the context window "
3897
+ f"({monitor.get_raw_utilization() * 100:.0f}%) and cannot "
3898
+ "continue. No tools are available. Reply with a brief "
3899
+ "plain-text summary of what was accomplished and what "
3900
+ "remains, then stop."
3901
+ ),
3902
+ })
3903
+ openai_body["messages"] = msgs
3904
+ monitor.reset_tool_turn_state(reason="context_death_spiral_breaker")
3905
+ logger.error(
3906
+ "CONTEXT DEATH-SPIRAL BREAKER: raw ctx %.0f%% for %d consecutive "
3907
+ "turns -- stripped tools to force terminal summary (end_turn).",
3908
+ monitor.get_raw_utilization() * 100,
3909
+ monitor.catastrophic_ctx_streak,
3910
+ )
3911
+ if PROXY_DISABLE_THINKING_ON_TOOL_TURNS:
3912
+ openai_body["enable_thinking"] = False
3913
+ if PROXY_DISABLE_SPEC_ON_TOOL_TURNS:
3914
+ openai_body["speculative.n_max"] = 0
3915
+ return openai_body
3916
+
3754
3917
  # TOOL STARVATION BREAKER: if model repeatedly fails to produce tool
3755
3918
  # calls despite required, strip tools to let it generate text and break
3756
3919
  # the forcing loop.
@@ -7834,6 +7997,20 @@ async def messages(request: Request):
7834
7997
  estimated_tokens,
7835
7998
  )
7836
7999
  utilization = effective_tokens / ctx_window
8000
+ # Fix B: preserve the raw incoming size before any pruning rewrites
8001
+ # last_input_tokens to the post-prune total, so the loop breaker can
8002
+ # see the true blow-up at build_openai_request time.
8003
+ monitor.pre_prune_input_tokens = effective_tokens
8004
+ # Fix F: track consecutive turns whose raw incoming context is
8005
+ # catastrophically over the window (a death spiral the per-request
8006
+ # pruner can mask but not cure). build_openai_request acts on this.
8007
+ if (
8008
+ PROXY_RAW_CTX_FINALIZE_RATIO > 0
8009
+ and utilization >= PROXY_RAW_CTX_FINALIZE_RATIO
8010
+ ):
8011
+ monitor.catastrophic_ctx_streak += 1
8012
+ else:
8013
+ monitor.catastrophic_ctx_streak = 0
7837
8014
  if utilization >= PROXY_CONTEXT_PRUNE_THRESHOLD:
7838
8015
  logger.warning(
7839
8016
  "Context utilization %.1f%% exceeds threshold %.1f%% -- pruning conversation",