@miller-tech/uap 2.16.7 → 2.19.5

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 (103) hide show
  1. package/config/capacity-policy.json +33 -0
  2. package/config/model-profiles/qwen38.json +32 -33
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/bin/cli.js +57 -0
  5. package/dist/bin/cli.js.map +1 -1
  6. package/dist/capacity/policy.d.ts +32 -1
  7. package/dist/capacity/policy.d.ts.map +1 -1
  8. package/dist/capacity/policy.js +123 -4
  9. package/dist/capacity/policy.js.map +1 -1
  10. package/dist/capacity/probe.d.ts +26 -0
  11. package/dist/capacity/probe.d.ts.map +1 -1
  12. package/dist/capacity/probe.js +84 -3
  13. package/dist/capacity/probe.js.map +1 -1
  14. package/dist/cli/inference.d.ts +4 -0
  15. package/dist/cli/inference.d.ts.map +1 -1
  16. package/dist/cli/inference.js +74 -8
  17. package/dist/cli/inference.js.map +1 -1
  18. package/dist/cli/merge-gate.d.ts +59 -0
  19. package/dist/cli/merge-gate.d.ts.map +1 -0
  20. package/dist/cli/merge-gate.js +238 -0
  21. package/dist/cli/merge-gate.js.map +1 -0
  22. package/dist/cli/merge-queue.d.ts +13 -0
  23. package/dist/cli/merge-queue.d.ts.map +1 -1
  24. package/dist/cli/merge-queue.js +48 -2
  25. package/dist/cli/merge-queue.js.map +1 -1
  26. package/dist/cli/mission.d.ts +25 -0
  27. package/dist/cli/mission.d.ts.map +1 -0
  28. package/dist/cli/mission.js +225 -0
  29. package/dist/cli/mission.js.map +1 -0
  30. package/dist/cli/orch.d.ts +27 -0
  31. package/dist/cli/orch.d.ts.map +1 -0
  32. package/dist/cli/orch.js +70 -0
  33. package/dist/cli/orch.js.map +1 -0
  34. package/dist/cli/systemd-services.d.ts.map +1 -1
  35. package/dist/cli/systemd-services.js +44 -0
  36. package/dist/cli/systemd-services.js.map +1 -1
  37. package/dist/delivery/merge-gate.d.ts +168 -0
  38. package/dist/delivery/merge-gate.d.ts.map +1 -0
  39. package/dist/delivery/merge-gate.js +309 -0
  40. package/dist/delivery/merge-gate.js.map +1 -0
  41. package/dist/inference/analysis.d.ts +69 -3
  42. package/dist/inference/analysis.d.ts.map +1 -1
  43. package/dist/inference/analysis.js +123 -7
  44. package/dist/inference/analysis.js.map +1 -1
  45. package/dist/inference/probe.d.ts +38 -1
  46. package/dist/inference/probe.d.ts.map +1 -1
  47. package/dist/inference/probe.js +303 -30
  48. package/dist/inference/probe.js.map +1 -1
  49. package/dist/inference/strata.d.ts +95 -0
  50. package/dist/inference/strata.d.ts.map +1 -0
  51. package/dist/inference/strata.js +167 -0
  52. package/dist/inference/strata.js.map +1 -0
  53. package/dist/mission/database.d.ts +29 -0
  54. package/dist/mission/database.d.ts.map +1 -0
  55. package/dist/mission/database.js +131 -0
  56. package/dist/mission/database.js.map +1 -0
  57. package/dist/mission/ledger.d.ts +109 -0
  58. package/dist/mission/ledger.d.ts.map +1 -0
  59. package/dist/mission/ledger.js +294 -0
  60. package/dist/mission/ledger.js.map +1 -0
  61. package/dist/mission/poll-cycle.d.ts +73 -0
  62. package/dist/mission/poll-cycle.d.ts.map +1 -0
  63. package/dist/mission/poll-cycle.js +100 -0
  64. package/dist/mission/poll-cycle.js.map +1 -0
  65. package/dist/mission/poll-executor.d.ts +91 -0
  66. package/dist/mission/poll-executor.d.ts.map +1 -0
  67. package/dist/mission/poll-executor.js +328 -0
  68. package/dist/mission/poll-executor.js.map +1 -0
  69. package/dist/models/types.d.ts.map +1 -1
  70. package/dist/models/types.js +33 -27
  71. package/dist/models/types.js.map +1 -1
  72. package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
  73. package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
  74. package/dist/self-tuning/profiles/qwen38.js +17 -18
  75. package/dist/self-tuning/profiles/qwen38.js.map +1 -1
  76. package/dist/types/mission.d.ts +80 -0
  77. package/dist/types/mission.d.ts.map +1 -0
  78. package/dist/types/mission.js +12 -0
  79. package/dist/types/mission.js.map +1 -0
  80. package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
  81. package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
  82. package/docs/guides/CAPACITY_POLICY.md +49 -0
  83. package/docs/guides/INFERENCE_HEALTH.md +65 -8
  84. package/docs/guides/MISSIONS.md +170 -0
  85. package/docs/guides/PROXY.md +2 -0
  86. package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
  87. package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
  88. package/docs/visual/merge-gate.html +404 -0
  89. package/docs/visual/mission-orchestrator.html +472 -0
  90. package/package.json +2 -2
  91. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  92. package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
  93. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  94. package/src/policies/enforcers/plane_admin_protect.py +133 -0
  95. package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
  96. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  97. package/templates/hooks/uap-policy-gate.sh +33 -0
  98. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  99. package/tools/agents/scripts/anthropic_proxy.py +493 -40
  100. package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
  101. package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
  102. package/tools/agents/tests/test_plane_admin_protect.py +124 -0
  103. package/tools/agents/tests/test_stream_heartbeat.py +77 -0
@@ -64,6 +64,37 @@ Configuration (Environment Variables)
64
64
  conversation pruning activates (0.0-1.0)
65
65
  Default: 0.85
66
66
 
67
+ PROXY_PRUNE_SPIRAL_STREAK Consecutive above-threshold prunes with a
68
+ RISING post-prune floor required before the
69
+ prune circuit breaker fires. A tool-heavy
70
+ client's fixed overhead (system + tool
71
+ schemas) can sit above the threshold
72
+ permanently; a flat floor is NOT a death
73
+ spiral. Default: 3
74
+
75
+ PROXY_PRUNE_SPIRAL_EPSILON Minimum post-prune utilization rise
76
+ (fraction of window, e.g. 0.01 = 1 point)
77
+ for a prune to count toward the spiral
78
+ streak. Default: 0.01
79
+
80
+ PROXY_FINAL_ANSWER_CHARS Content reaching this many characters
81
+ is a legitimate final answer: the
82
+ unexpected-end-turn retry is skipped
83
+ and the client's loop ends naturally,
84
+ a `required` tool turn returning such
85
+ content is exempt from the
86
+ required_tool_miss streak, retries, and
87
+ forced-tool dampener, and the
88
+ recon-convergence no-write streak does
89
+ not advance on it (a finished model's
90
+ summary is a completion, not a miss).
91
+ Content matching the deferral
92
+ phrases stays retried even when long —
93
+ a capitulation is not a final answer.
94
+ Env alias: PROXY_END_TURN_FINAL_CHARS
95
+ (deprecated spelling, same knob).
96
+ 0 = always retry. Default: 800
97
+
67
98
  Usage
68
99
  -----
69
100
  # Basic usage (connects to llama.cpp on default port):
@@ -299,6 +330,54 @@ PROXY_CONTEXT_WINDOW = int(os.environ.get("PROXY_CONTEXT_WINDOW", "0"))
299
330
  PROXY_CONTEXT_PRUNE_THRESHOLD = float(
300
331
  os.environ.get("PROXY_CONTEXT_PRUNE_THRESHOLD", "0.85")
301
332
  )
333
+ # Prune circuit-breaker floor-trend discrimination (2026-09-30): a tool-heavy
334
+ # client's FIXED overhead (system prompt + tool schemas — observed live:
335
+ # opencode's 99 tools ≈ 66k est tokens in a 114,688-token window) can sit
336
+ # permanently ABOVE PROXY_CONTEXT_PRUNE_THRESHOLD (0.42 in production), so
337
+ # post-prune utilization can never get below the threshold no matter how many
338
+ # messages are dropped. The breaker's old condition — "3+ consecutive prunes
339
+ # and still above threshold" — read that permanent flat floor as a death
340
+ # spiral and force-finalized healthy ~44% sessions every few turns (observed
341
+ # live: 8 breaker fires in 10 minutes, each one ending the client's turn; the
342
+ # operator symptom was "the client keeps stopping"). A REAL death spiral shows
343
+ # a RISING post-prune floor turn over turn; a fixed-overhead floor is flat.
344
+ # The breaker now fires only after PROXY_PRUNE_SPIRAL_STREAK consecutive
345
+ # above-threshold prunes whose post-prune utilization rose by more than
346
+ # PROXY_PRUNE_SPIRAL_EPSILON (fraction of the window). Slow sub-epsilon
347
+ # spirals do not trip this breaker; they escalate to the >=90% critical
348
+ # prune and the catastrophic raw-ctx finalize backstops instead.
349
+ PROXY_PRUNE_SPIRAL_STREAK = int(
350
+ os.environ.get("PROXY_PRUNE_SPIRAL_STREAK", "3")
351
+ )
352
+ PROXY_PRUNE_SPIRAL_EPSILON = float(
353
+ os.environ.get("PROXY_PRUNE_SPIRAL_EPSILON", "0.01")
354
+ )
355
+ # Substantive-final-answer exemption for the unexpected-end-turn retry
356
+ # (2026-10-03): the retry exists for thinking-runaway stalls — an end_turn
357
+ # with EMPTY or near-empty content mid-loop (reproduced 2026-09-17: 5917
358
+ # reasoning chars, finish=length, 0 tool_calls). It had NO completion signal,
359
+ # so a model that GENUINELY finished and wrote its final answer was also
360
+ # "unexpected": the retry coerced it back into a tool call, and a finished
361
+ # model under coercion emits a degenerate no-op call (observed live
362
+ # 2026-10-03 01:00-03:00: "Task complete — recap delivered and one concrete
363
+ # defect fixed", 2211 chars, followed by 96 retry cycles each producing the
364
+ # identical `git status` no-op — two hours of churn the operator saw as
365
+ # "keeps stopping or looping"). Content reaching this many characters is a
366
+ # legitimate final answer: let the client's loop end. The runaway case has
367
+ # empty content, so the threshold separates the two classes cleanly. The
368
+ # threshold now gates a family of exemptions — the end-turn retry, the
369
+ # stream-path malformed-streak counter, the required-tool-miss classification
370
+ # (2026-10-03 13:24-13:40 incident), and the recon-convergence no-write streak
371
+ # — hence the rename from PROXY_END_TURN_FINAL_CHARS (still accepted as a
372
+ # deprecated env alias). 0 restores the old always-retry behavior.
373
+ PROXY_FINAL_ANSWER_CHARS = int(
374
+ os.environ.get("PROXY_FINAL_ANSWER_CHARS")
375
+ or os.environ.get("PROXY_END_TURN_FINAL_CHARS")
376
+ or "800"
377
+ )
378
+ # Deprecated alias (2026-10-03): existing env files and older tooling still
379
+ # use the end-turn spelling; both knobs control the same threshold.
380
+ PROXY_END_TURN_FINAL_CHARS = PROXY_FINAL_ANSWER_CHARS
302
381
  # Verbatim decision compaction (uplift 1.2): when context pressure triggers a
303
382
  # prune, first try per-tool-call keep/truncate/drop decisions that keep all
304
383
  # retained content VERBATIM; fall back to the lossy breadcrumb pruner only
@@ -1748,6 +1827,21 @@ try:
1748
1827
  )
1749
1828
  except ValueError:
1750
1829
  PROXY_STREAM_HEARTBEAT_SECS = 0.0
1830
+ # Journal progress cadence for the same buffered wait. The heartbeat keeps the
1831
+ # CLIENT alive; this keeps the OPERATOR alive: a no-tool streaming turn served
1832
+ # via the guarded path (confidence-escalation buffering, tools-stripped breaker
1833
+ # turns, forced non-stream) can generate for minutes with zero journal output —
1834
+ # live 2026-10-04, opencode's client-side compaction summary (msgs=1, tools=0,
1835
+ # stream=True) buffered 11,099 output tokens over 281 s: the proxy looked idle,
1836
+ # the client looked hung, and nothing in the log distinguished a bug from a
1837
+ # long generation until the RESP line finally landed. One INFO line per this
1838
+ # many seconds of waiting. 0 disables.
1839
+ try:
1840
+ PROXY_GUARDED_WAIT_LOG_SECS = float(
1841
+ os.environ.get("PROXY_GUARDED_WAIT_LOG_SECS", "60")
1842
+ )
1843
+ except ValueError:
1844
+ PROXY_GUARDED_WAIT_LOG_SECS = 60.0
1751
1845
  PROXY_FORCED_TOOL_DAMPENER = os.environ.get(
1752
1846
  "PROXY_FORCED_TOOL_DAMPENER", "on"
1753
1847
  ).lower() not in {
@@ -2410,6 +2504,14 @@ class SessionMonitor:
2410
2504
  pre_prune_input_tokens: int = 0
2411
2505
  prune_count: int = 0 # How many times pruning was triggered
2412
2506
  overflow_count: int = 0 # How many context overflow errors caught
2507
+ # Prune circuit-breaker floor trend (2026-09-30): consecutive
2508
+ # above-threshold prunes whose post-prune utilization ROSE (a real death
2509
+ # spiral regrows faster than the pruner can cut), and the last post-prune
2510
+ # utilization seen, to compute the delta. A flat floor is fixed overhead,
2511
+ # not a spiral — see _prune_breaker_should_fire.
2512
+ prune_spiral_streak: int = 0
2513
+ last_post_prune_util: float = -1.0
2514
+ prune_floor_logged: bool = False # one-shot "floor is flat" operator notice
2413
2515
  # Monotonic: # of oldest middle msgs pruned (B3). Only meaningful for
2414
2516
  # legacy lossy-path turns — a successful verbatim compaction (uplift 1.2)
2415
2517
  # re-indexes the middle non-contiguously and RESETS this to 0, since a
@@ -2457,7 +2559,7 @@ class SessionMonitor:
2457
2559
  session_id: str = "" # owning session id (set by get_session_monitor)
2458
2560
  mandate_deliver_fires: int = 0 # monotonic count of forced deliver-routings (mandate)
2459
2561
  mandate_deliver_active: bool = False # THIS turn is pinned to deliver (beats recon convergence)
2460
- tool_starvation_streak: int = 0 # Consecutive forced turns with no tool_calls produced
2562
+ tool_starvation_streak: int = 0 # breaker FIRES since the last compaction reset (not per turn)
2461
2563
  last_request_msg_count: int = 0 # Message count of the previous request (compaction-boundary detection)
2462
2564
  malformed_tool_streak: int = 0 # consecutive malformed pseudo tool payloads
2463
2565
  invalid_tool_call_streak: int = 0 # consecutive invalid tool arg payloads
@@ -3038,6 +3140,61 @@ class SessionMonitor:
3038
3140
  """Highest current streak among malformed/invalid tool outputs."""
3039
3141
  return max(self.malformed_tool_streak, self.invalid_tool_call_streak)
3040
3142
 
3143
+ def reset_forcing_loop(self) -> None:
3144
+ """Reset the forcing-loop family to a clean per-turn baseline.
3145
+
3146
+ 2026-10-04 follow-up to the starvation-breaker fix (PR #830): the
3147
+ membership of this family lives HERE, not in four hand-maintained
3148
+ reset blocks (fresh user text, no-tool-result conversations, the
3149
+ compaction boundary, the contamination hard reset). Members and why:
3150
+
3151
+ consecutive_forced_count, no_progress_streak -- the forcing loop
3152
+ is PER-TURN state; a stale count crossing a fresh user text
3153
+ false-fired the TOOL STARVATION BREAKER ~once per user turn for
3154
+ hours (fixed 2026-10-04).
3155
+
3156
+ Deliberately NOT members (each with its own lifecycle):
3157
+ consecutive_no_write_turns -- PER-TASK state (recon exploration
3158
+ streak), reset at the same four boundary sites INLINE, not here:
3159
+ it is not forcing-loop state, and an ack-shaped fresh text must
3160
+ not clear it (see the fresh_user_text branch). Its four reset
3161
+ sites and rationale are documented at those sites.
3162
+ malformed/invalid/required_tool_miss streaks -- output QUALITY is
3163
+ cross-turn evidence; a new user message says nothing about the
3164
+ model's tool-call formatting.
3165
+ forced_auto_cooldown_turns -- the dampener is a REMEDY for the
3166
+ quality streaks, and quality is cross-turn, so the cooldown
3167
+ carries across mid-episode user acks ("keep going" must not cut
3168
+ the recovery window short). It also cannot re-arm right after a
3169
+ reset: activation needs consecutive_forced_count >= min_forced,
3170
+ which the reset just zeroed. It clears only at a brand-new
3171
+ conversation (the n_msgs <= 1 block) and the emergency
3172
+ contamination reset.
3173
+ deferral_streak -- the capitulation loop it breaks SPANS user
3174
+ acks (model asks for more cycles, user says yes, model asks
3175
+ again), so a per-turn reset would make that breaker unreachable
3176
+ in exactly its true-positive case.
3177
+ tool_starvation_streak -- per-EPOCH session telemetry: counts
3178
+ breaker fires since the last compaction reset (the compaction
3179
+ boundary clears it), not per-turn severity.
3180
+ recon_hard_fires -- monotonic session counter, never reset.
3181
+ catastrophic_ctx_streak -- consecutive-turn utilization streak;
3182
+ resets the moment utilization drops below the finalize ratio,
3183
+ so it is per-turn-consecutive, not monotonic.
3184
+
3185
+ Two more semantic classes reset the member pair INLINE by design,
3186
+ and a grep for "consecutive_forced_count = 0" will find them:
3187
+ consequence/consume-on-fire clears (breaker fire, dampener
3188
+ release, finalize paths, analysis routing) zero the pair after an
3189
+ ACTION was taken, which is a different event from a boundary reset.
3190
+ And the four boundary call sites here are gated behind
3191
+ PROXY_TOOL_STATE_MACHINE (pre-existing posture, unchanged): with
3192
+ the state machine off, the consumers still run and the stale-state
3193
+ false-fire class returns.
3194
+ """
3195
+ self.consecutive_forced_count = 0
3196
+ self.no_progress_streak = 0
3197
+
3041
3198
  def consume_forced_auto_turn(self) -> bool:
3042
3199
  """Consume one dampener turn that temporarily sets tool_choice=auto."""
3043
3200
  if self.forced_auto_cooldown_turns <= 0:
@@ -3529,6 +3686,39 @@ def _prune_overhead_tokens(anthropic_body: dict) -> int:
3529
3686
  return int(overhead_tokens * 1.5) # Safety factor for template overhead
3530
3687
 
3531
3688
 
3689
+ def _prune_breaker_should_fire(post_util: float, monitor) -> bool:
3690
+ """Death-spiral discriminator for the prune circuit breaker (2026-09-30).
3691
+
3692
+ Called once per request AFTER all prune passes, with the post-prune
3693
+ utilization. Updates the monitor's floor-trend streak and returns whether
3694
+ the breaker should fire.
3695
+
3696
+ Why trend, not just "still above threshold": a tool-heavy client's fixed
3697
+ overhead (system + tool schemas; observed: ~66k est tokens of a 114,688
3698
+ window against a 42% threshold) puts the post-prune floor PERMANENTLY
3699
+ above the threshold. The old breaker condition (3+ consecutive prunes and
3700
+ still above threshold) fired every few turns on that flat floor, forcing
3701
+ finalize on healthy ~44% sessions — the operator-visible "the client
3702
+ keeps stopping". A real death spiral (auto-compact transcript regrowth,
3703
+ exploding tool results) shows a post-prune floor that RISES turn over turn
3704
+ even as the pruner cuts; only that counts. Flat or falling floors reset
3705
+ the streak.
3706
+ """
3707
+ prev = monitor.last_post_prune_util
3708
+ monitor.last_post_prune_util = post_util
3709
+ if post_util < PROXY_CONTEXT_PRUNE_THRESHOLD:
3710
+ # Back under the threshold (real headroom recovered): reset the trend.
3711
+ monitor.prune_spiral_streak = 0
3712
+ return False
3713
+ if prev >= 0 and post_util > prev + PROXY_PRUNE_SPIRAL_EPSILON:
3714
+ monitor.prune_spiral_streak += 1
3715
+ else:
3716
+ # Flat (within epsilon), falling, or the first observation above the
3717
+ # threshold: not spiral evidence.
3718
+ monitor.prune_spiral_streak = 0
3719
+ return monitor.prune_spiral_streak >= PROXY_PRUNE_SPIRAL_STREAK
3720
+
3721
+
3532
3722
  def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> bool:
3533
3723
  """Truncate the largest message content in-place until the total fits
3534
3724
  budget_tokens. Used when message-DROPPING cannot reduce below the window —
@@ -6837,10 +7027,35 @@ def _resolve_state_machine_tool_choice(
6837
7027
  if latest_user_text and not last_user_has_tool_result:
6838
7028
  monitor.tool_call_history = []
6839
7029
  monitor.reset_doubling()
7030
+ # 2026-10-04 fix (recurring false TOOL STARVATION BREAKER, fired ~once
7031
+ # per user turn for hours): the forcing loop is PER-TURN state. The
7032
+ # family membership and each member's lifecycle live in
7033
+ # SessionMonitor.reset_forcing_loop -- reset there, never here.
7034
+ monitor.reset_forcing_loop()
7035
+ # The recon exploration streak is PER-TASK, not per-turn (added
7036
+ # 2026-10-04 with the reset_forcing_loop extraction): the previous
7037
+ # task's read-forever streak is not evidence about the NEW
7038
+ # deliverable, and a stale streak fired the recon convergence
7039
+ # directive on the new task's first request -- the same
7040
+ # stale-state-crossing-a-boundary class as the breaker fix above.
7041
+ # Agent-driven resends carry tool_results and cannot reach this
7042
+ # branch, so genuine same-task recon loops keep counting. One
7043
+ # accepted trade-off: a MID-TASK human ack ("ok", "standing by")
7044
+ # also lands here and would clear a same-task streak — so the
7045
+ # existing _is_no_task_user_text classifier gates the reset: only
7046
+ # fresh text that plausibly starts NEW work clears the streak.
7047
+ # Reviewer P3 (security, 2026-10-04): without the gate, a model
7048
+ # could end each threshold-1 turn soliciting an ack and keep the
7049
+ # convergence directive unreachable forever.
7050
+ if not _is_no_task_user_text(latest_user_text):
7051
+ monitor.consecutive_no_write_turns = 0
6840
7052
  if n_msgs <= 1:
7053
+ # Brand-new conversation only. forced_auto_cooldown_turns is
7054
+ # sticky across mid-episode acks on purpose (see
7055
+ # reset_forcing_loop's docstring) but a 1-message request is a
7056
+ # fresh conversation surface, where the old cooldown describes
7057
+ # a model state that no longer applies.
6841
7058
  monitor.forced_auto_cooldown_turns = 0
6842
- monitor.consecutive_forced_count = 0
6843
- monitor.no_progress_streak = 0
6844
7059
  monitor.malformed_tool_streak = 0
6845
7060
  monitor.invalid_tool_call_streak = 0
6846
7061
  monitor.required_tool_miss_streak = 0
@@ -6870,10 +7085,24 @@ def _resolve_state_machine_tool_choice(
6870
7085
  if not has_tool_results:
6871
7086
  monitor.tool_call_history = []
6872
7087
  monitor.reset_doubling()
7088
+ # Same per-turn reset as fresh_user_text (see the 2026-10-04 fix
7089
+ # there, and SessionMonitor.reset_forcing_loop for the family
7090
+ # membership): a conversation with no tool results cannot host a
7091
+ # MID-LOOP starvation episode -- genuine ones resend with
7092
+ # tool_results and never reach this branch -- so a stale forcing
7093
+ # count from a prior turn carries no signal here, only the
7094
+ # false-fire. The recon streak resets under the same ack gate as
7095
+ # fresh_user_text (latest_user_text is empty in most
7096
+ # no-tool-result conversations, and an empty text is not an ack,
7097
+ # so the gate usually passes here). Output-quality streaks stay
7098
+ # sticky under the n_msgs guard.
7099
+ monitor.reset_forcing_loop()
7100
+ if not _is_no_task_user_text(latest_user_text):
7101
+ monitor.consecutive_no_write_turns = 0
6873
7102
  if n_msgs <= 1:
7103
+ # Brand-new conversation only (see the fresh_user_text
7104
+ # branch for why the cooldown is otherwise sticky).
6874
7105
  monitor.forced_auto_cooldown_turns = 0
6875
- monitor.consecutive_forced_count = 0
6876
- monitor.no_progress_streak = 0
6877
7106
  monitor.malformed_tool_streak = 0
6878
7107
  monitor.invalid_tool_call_streak = 0
6879
7108
  monitor.required_tool_miss_streak = 0
@@ -8657,7 +8886,7 @@ def build_openai_request(
8657
8886
  monitor.no_progress_streak = 0
8658
8887
  monitor.reset_tool_turn_state(reason="tool_starvation_breaker")
8659
8888
  logger.warning(
8660
- "TOOL STARVATION BREAKER: stripped tools after %d forced turns with no tool output (starvation_streak=%d)",
8889
+ "TOOL STARVATION BREAKER: stripped tools after %d forced turns with no tool output (starvation_streak=%d; the streak counts fires since the last compaction reset, not per-turn severity)",
8661
8890
  PROXY_TOOL_STARVATION_THRESHOLD,
8662
8891
  monitor.tool_starvation_streak,
8663
8892
  )
@@ -9121,12 +9350,14 @@ def _record_last_assistant_tool_calls(
9121
9350
  tool_fingerprints = []
9122
9351
  tool_targets: dict[str, str] = {}
9123
9352
  assistant_had_text = False # Fix B: did the last assistant turn emit prose?
9353
+ assistant_prose = "" # raw text of that prose, for the completion gate
9124
9354
  for msg in reversed(messages):
9125
9355
  if msg.get("role") != "assistant":
9126
9356
  continue
9127
9357
  content = msg.get("content")
9128
9358
  if isinstance(content, str) and content.strip():
9129
9359
  assistant_had_text = True
9360
+ assistant_prose = content
9130
9361
  if isinstance(content, list):
9131
9362
  for block in content:
9132
9363
  if (
@@ -9135,6 +9366,11 @@ def _record_last_assistant_tool_calls(
9135
9366
  and str(block.get("text", "")).strip()
9136
9367
  ):
9137
9368
  assistant_had_text = True
9369
+ # Accumulate, never overwrite: a completion split across
9370
+ # several sub-threshold text blocks is still one
9371
+ # completion (parity with _final_answer_content_exempt's
9372
+ # own list handling, which joins text parts).
9373
+ assistant_prose += str(block.get("text", ""))
9138
9374
  if isinstance(block, dict) and block.get("type") == "tool_use":
9139
9375
  tool_fingerprints.append(_tool_call_fingerprint(block))
9140
9376
  # Extract target key for read-only dedup (Option 3)
@@ -9165,8 +9401,12 @@ def _record_last_assistant_tool_calls(
9165
9401
  # Fix B: no tool call in the last assistant turn. A plain-text turn is still
9166
9402
  # a non-write turn, so advance the recon-convergence streak (previously it
9167
9403
  # only moved on tool turns, freezing the counter through prose-only stalls).
9168
- # Guard on real prose so an empty/absent assistant turn never inflates it.
9169
- if assistant_had_text:
9404
+ # Guard on real prose so an empty/absent assistant turn never inflates it —
9405
+ # EXCEPT a substantive final answer (architect follow-up 2026-10-03): a
9406
+ # finished model's completion is not a stall signal, and counting it was
9407
+ # the last site in the false-positive family that treats a completion as a
9408
+ # miss. Same gate as the end-turn and required-miss exemptions.
9409
+ if assistant_had_text and not _final_answer_content_exempt(assistant_prose):
9170
9410
  monitor.note_no_tool_turn()
9171
9411
  monitor.note_doubling_signal(
9172
9412
  "", _latest_tr, msg_count=len(messages), result_error=_latest_err
@@ -9174,6 +9414,77 @@ def _record_last_assistant_tool_calls(
9174
9414
  return ""
9175
9415
 
9176
9416
 
9417
+ def _final_answer_content_exempt(content) -> bool:
9418
+ """True when an end_turn's content is a substantive final answer.
9419
+
9420
+ Shared by the unexpected-end-turn classifier (skip the retry so the
9421
+ client's loop ends naturally), the stream-path malformed_tool_streak
9422
+ counter (a legitimate completion must not feed the forced-tool dampener
9423
+ via the `end_turn + long text` branch), and the required-tool-miss
9424
+ classifier (a finished model's summary is a completion, not a miss).
9425
+ 2026-10-03.
9426
+ """
9427
+ if PROXY_FINAL_ANSWER_CHARS <= 0:
9428
+ return False
9429
+ if not isinstance(content, str):
9430
+ # OpenAI content-parts list: count only the text parts, never the
9431
+ # repr of the list (which would always exceed the threshold). A
9432
+ # part whose "text" is not a str is skipped, not joined — a
9433
+ # malformed backend payload must not abort the turn with a
9434
+ # TypeError mid-classification.
9435
+ if not isinstance(content, list):
9436
+ return False
9437
+ content = " ".join(
9438
+ part.get("text", "")
9439
+ for part in content
9440
+ if isinstance(part, dict)
9441
+ and isinstance(part.get("text", ""), str)
9442
+ )
9443
+ if len(content.strip()) < PROXY_FINAL_ANSWER_CHARS:
9444
+ return False
9445
+ # BUT a LONG DEFERRAL is not a final answer: a model that wrote 800+ chars
9446
+ # of "I need more cycles before acting" is stalling, not finishing, and
9447
+ # must still be herded back to work. The deferral regex already exists
9448
+ # (and already accepts its false-positive risk on no-tool turns), so the
9449
+ # exemption is gated on it rather than on new vocabulary.
9450
+ return not _DEFERRAL_PHRASE_RE.search(content)
9451
+
9452
+
9453
+ def _required_tool_miss_exempt(openai_resp: dict) -> bool:
9454
+ """True when a required tool turn returned no tool calls but the response
9455
+ is a substantive final answer, not a miss.
9456
+
9457
+ Same false-positive family as the unexpected-end-turn retry (mechanism #3)
9458
+ and the garbled-args classifier (mechanism #4), observed live
9459
+ 2026-10-03 13:24-13:40: the tool state machine had forced
9460
+ tool_choice=required for 32 consecutive turns; the model had FINISHED the
9461
+ task (session summaries, "234 tests passing") and kept emitting exactly
9462
+ that. Each summary was counted as required_tool_miss, retries demanded a
9463
+ tool call, the streak fed the contamination breaker (3 resets), and the
9464
+ forced-finalize branch — which clears malformed/invalid streaks but did
9465
+ not clear required_tool_miss_streak — re-fired finalize on EVERY
9466
+ subsequent request (contamination_resets 3→6, ~4 min of GPU burn) until
9467
+ the client gave up. A finished model must be allowed to hand its summary
9468
+ to the client: the loop ends naturally, exactly as with the end-turn
9469
+ exemption.
9470
+ """
9471
+ if _openai_has_tool_calls(openai_resp):
9472
+ return False
9473
+ # A response truncated by max_tokens did not FINISH — its length says
9474
+ # nothing about intent, and the truncation paths (truncated_tool_args
9475
+ # downgrade, empty-max-tokens recovery) own that class. Mirrors
9476
+ # _is_unexpected_end_turn, which only accepts stop/end_turn finishes.
9477
+ choice, _ = _extract_openai_choice(openai_resp)
9478
+ if (choice.get("finish_reason") or "").lower() == "length":
9479
+ return False
9480
+ # Pass the RAW content (not _openai_message_text, whose str() fallback
9481
+ # would stringify a list-parts content to its repr and defeat
9482
+ # _final_answer_content_exempt's count-only-text-parts guard — a 400-char
9483
+ # list payload measured as a 700-char repr). Mirrors _is_unexpected_end_turn.
9484
+ _, message = _extract_openai_choice(openai_resp)
9485
+ return _final_answer_content_exempt(message.get("content", ""))
9486
+
9487
+
9177
9488
  def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
9178
9489
  choices = openai_resp.get("choices") or []
9179
9490
  if not choices:
@@ -9191,6 +9502,16 @@ def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
9191
9502
  if "tools" not in anthropic_body:
9192
9503
  return False
9193
9504
 
9505
+ # Substantive-final-answer exemption (2026-10-03): an end_turn that CARRIES
9506
+ # a real final answer is the agentic loop's natural end, not a stall. The
9507
+ # thinking-runaway this retry exists for produces empty content, so content
9508
+ # length separates the classes. Without this, a finished model gets coerced
9509
+ # back into a degenerate no-op tool call every turn (observed live:
9510
+ # "Task complete ... Summary:" 2211 chars -> 96 retry cycles of the
9511
+ # identical `git status` no-op over two hours).
9512
+ if _final_answer_content_exempt(msg.get("content", "")):
9513
+ return False
9514
+
9194
9515
  has_tool_results = _conversation_has_tool_results(anthropic_body)
9195
9516
 
9196
9517
  return has_tool_results or _last_assistant_was_text_only(anthropic_body)
@@ -10080,16 +10401,14 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
10080
10401
  """Detect garbled/degenerate tool call arguments.
10081
10402
 
10082
10403
  Returns True if the arguments string shows signs of degenerate generation:
10083
- - Runaway closing braces (}}}}})
10084
- - Repetitive digit patterns (000000, 398859738398859738)
10085
- - Extremely long digit strings
10086
- - Unbalanced braces suggesting truncated/corrupt JSON
10404
+ - Repetitive digit patterns (000000, 398859738398859738) and long digit
10405
+ runs (degenerate CONTENT, meaningful even inside valid JSON values)
10406
+ - Structural corruption — runaway closing braces (}}}}}) and unbalanced
10407
+ braces — only when the payload does not parse as JSON
10087
10408
  """
10088
10409
  if not arguments_str or arguments_str == "{}":
10089
10410
  return False
10090
10411
 
10091
- if _GARBLED_RUNAWAY_BRACES_RE.search(arguments_str):
10092
- return True
10093
10412
  if _GARBLED_REPETITIVE_DIGITS_RE.search(arguments_str):
10094
10413
  return True
10095
10414
  if _GARBLED_ZEROS_RE.search(arguments_str):
@@ -10097,11 +10416,31 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
10097
10416
  if _GARBLED_LONG_DIGITS_RE.search(arguments_str):
10098
10417
  return True
10099
10418
 
10100
- # Check brace balance — more than 2 unmatched braces suggests corruption
10101
- open_count = arguments_str.count("{")
10102
- close_count = arguments_str.count("}")
10103
- if abs(open_count - close_count) > 2:
10104
- return True
10419
+ # Structural checks — ONLY meaningful for a payload that does not parse
10420
+ # as JSON: a clean json.loads proves the arguments are not truncated, and
10421
+ # braces inside JSON string VALUES are payload content, not structure.
10422
+ # edit/write tool args legitimately carry partial code excerpts with
10423
+ # unbalanced braces (observed live 2026-10-03: a Rust tail snippet with
10424
+ # 3 closing braces and no opening braces in oldString tripped the raw
10425
+ # balance check on a perfectly valid payload; the reject-retry loop that
10426
+ # followed burned three session-contamination resets and a forced
10427
+ # finalize mid-task). Runaway `}}}}}` gets the same gate: four or more
10428
+ # consecutive closing braces are common inside minified JS/JSON string
10429
+ # values, while the classic degenerate form (runaway closers appended
10430
+ # after the JSON) does not parse and is still caught here.
10431
+ try:
10432
+ json.loads(arguments_str)
10433
+ except (ValueError, TypeError, RecursionError):
10434
+ # Unparseable: brace imbalance and runaway closers are genuine
10435
+ # truncation/corruption signals here. RecursionError covers deeply
10436
+ # nested degenerate input (the exact artifact family this classifier
10437
+ # absorbs) that the C scanner rejects without raising ValueError.
10438
+ if _GARBLED_RUNAWAY_BRACES_RE.search(arguments_str):
10439
+ return True
10440
+ open_count = arguments_str.count("{")
10441
+ close_count = arguments_str.count("}")
10442
+ if abs(open_count - close_count) > 2:
10443
+ return True
10105
10444
 
10106
10445
  return False
10107
10446
 
@@ -10373,6 +10712,11 @@ class ToolResponseIssue:
10373
10712
  kind: str = ""
10374
10713
  reason: str = ""
10375
10714
  retry_hint: str = ""
10715
+ # True when the response was exempted from required_tool_miss by the
10716
+ # substantive-completion gate — set by _classify_tool_response_issue so
10717
+ # the streak/dampener/retry sites read ONE evaluation instead of re-running
10718
+ # the gate and risking site drift (2026-10-03, code-review follow-up).
10719
+ exempt: bool = False
10376
10720
 
10377
10721
  def has_issue(self) -> bool:
10378
10722
  return bool(self.kind)
@@ -11195,6 +11539,14 @@ def _classify_tool_response_issue(
11195
11539
  has_tool_calls = _openai_has_tool_calls(openai_resp)
11196
11540
  if not has_tool_calls:
11197
11541
  if required_tool_choice:
11542
+ # Substantive-final-answer exemption (2026-10-03): a required turn
11543
+ # that carries a real completion is the loop's natural end, not a
11544
+ # miss — see _required_tool_miss_exempt. Passing it through lets
11545
+ # the client finish instead of retrying the model into the
11546
+ # contamination breaker. exempt=True carries the single evaluation
11547
+ # to the streak/dampener/retry sites.
11548
+ if _required_tool_miss_exempt(openai_resp):
11549
+ return ToolResponseIssue(exempt=True)
11198
11550
  return ToolResponseIssue(
11199
11551
  kind="required_tool_miss",
11200
11552
  reason="required tool turn returned no tool calls",
@@ -11948,16 +12300,24 @@ async def _apply_malformed_tool_guardrail(
11948
12300
 
11949
12301
  required_tool_choice = openai_body.get("tool_choice") == "required"
11950
12302
  has_tool_calls = _openai_has_tool_calls(working_resp)
11951
- if required_tool_choice and not has_tool_calls:
11952
- monitor.required_tool_miss_streak += 1
11953
12303
 
11954
12304
  issue = _classify_tool_response_issue(
11955
12305
  working_resp,
11956
12306
  anthropic_body,
11957
12307
  required_tool_choice=required_tool_choice,
11958
12308
  )
12309
+ # The streak and dampener gates read issue.exempt — the ONE evaluation of
12310
+ # the substantive-completion gate, done inside classification. Re-running
12311
+ # the gate at each site risks evaluation drift, which is how this
12312
+ # false-positive family propagates (one site fixed, another missed).
12313
+ if required_tool_choice and not has_tool_calls and not issue.exempt:
12314
+ monitor.required_tool_miss_streak += 1
12315
+
11959
12316
  if not issue.has_issue():
11960
- if required_tool_choice and not has_tool_calls:
12317
+ # A substantive completion is exempt from the miss classification, so
12318
+ # it must not activate the forced-tool dampener either: relaxing
12319
+ # tool_choice for a finished model is at best noise.
12320
+ if required_tool_choice and not has_tool_calls and not issue.exempt:
11961
12321
  monitor.maybe_activate_forced_tool_dampener("required_tool_miss")
11962
12322
  if has_tool_calls:
11963
12323
  monitor.malformed_tool_streak = 0
@@ -12099,14 +12459,20 @@ async def _apply_malformed_tool_guardrail(
12099
12459
 
12100
12460
  retry_has_tool_calls = _openai_has_tool_calls(retry_working)
12101
12461
  retry_required = retry_body.get("tool_choice") == "required"
12102
- if retry_required and not retry_has_tool_calls:
12103
- monitor.required_tool_miss_streak += 1
12104
-
12105
12462
  retry_issue = _classify_tool_response_issue(
12106
12463
  retry_working,
12107
12464
  anthropic_body,
12108
12465
  required_tool_choice=retry_required,
12109
12466
  )
12467
+ # Same substantive-completion exemption as the primary path, read from
12468
+ # the single classification evaluation (retry_issue.exempt) — a retry
12469
+ # that produced a real final answer is not a miss.
12470
+ if (
12471
+ retry_required
12472
+ and not retry_has_tool_calls
12473
+ and not retry_issue.exempt
12474
+ ):
12475
+ monitor.required_tool_miss_streak += 1
12110
12476
 
12111
12477
  if not retry_issue.has_issue():
12112
12478
  # 2026-05-12: Fix #2 — do NOT reset malformed/invalid/miss streaks
@@ -12365,6 +12731,13 @@ def _maybe_apply_session_contamination_breaker(
12365
12731
  monitor.contamination_resets += 1
12366
12732
  monitor.malformed_tool_streak = 0
12367
12733
  monitor.invalid_tool_call_streak = 0
12734
+ # 2026-10-03: the finalize branch cleared the malformed/invalid streaks
12735
+ # but NOT required_tool_miss_streak — and that very streak is a
12736
+ # should_reset trigger, so once condemned, EVERY subsequent request
12737
+ # re-forced finalize (observed: contamination_resets 3→6, four
12738
+ # finalize turns in a row, each burning a full generation). Clear it
12739
+ # so finalize fires ONCE per contamination episode.
12740
+ monitor.required_tool_miss_streak = 0
12368
12741
  # Remove tools to force text-only response
12369
12742
  updated = dict(anthropic_body)
12370
12743
  updated.pop("tools", None)
@@ -12478,12 +12851,21 @@ def _maybe_apply_session_contamination_breaker(
12478
12851
  forced_before = monitor.consecutive_forced_count
12479
12852
  required_miss_before = monitor.required_tool_miss_streak
12480
12853
  monitor.contamination_resets += 1
12854
+ # Emergency tier: the conversation itself is rewritten, so BOTH the
12855
+ # forcing-loop family (via its one policy point) and the output-quality
12856
+ # streaks clear -- the n_msgs stickiness that protects quality streaks
12857
+ # at ordinary turn boundaries does not apply to a hard reset. The
12858
+ # dampener cooldown clears with them: the model state it was remedying
12859
+ # belonged to the collapsed transcript.
12860
+ monitor.reset_forcing_loop()
12861
+ monitor.forced_auto_cooldown_turns = 0
12481
12862
  monitor.malformed_tool_streak = 0
12482
12863
  monitor.invalid_tool_call_streak = 0
12483
12864
  monitor.required_tool_miss_streak = 0
12484
- monitor.no_progress_streak = 0
12485
- monitor.consecutive_forced_count = 0
12486
- monitor.forced_auto_cooldown_turns = 0
12865
+ # The recon streak belongs to the collapsed transcript (per-TASK state,
12866
+ # reset at turn boundaries since 2026-10-04); the rewritten conversation
12867
+ # is a fresh task surface.
12868
+ monitor.consecutive_no_write_turns = 0
12487
12869
  monitor.reset_tool_turn_state(reason=f"contamination_guardrail_reset_{log_reason}")
12488
12870
  if attractor_detected:
12489
12871
  logger.warning(
@@ -13232,6 +13614,12 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
13232
13614
  interval = PROXY_STREAM_HEARTBEAT_SECS if PROXY_STREAM_HEARTBEAT_SECS > 0 else 15.0
13233
13615
  thinking_heartbeat = PROXY_STREAM_THINKING_DELTAS and PROXY_PREFILL_HEARTBEAT_SECS > 0
13234
13616
  hb_thinking_open = False
13617
+ # Operator-visible progress for the same wait the client heartbeats mask
13618
+ # (2026-10-04 incident: a 281 s buffered compaction summary left the
13619
+ # journal silent from REQ to RESP, reading as a hung proxy). One INFO line
13620
+ # per PROXY_GUARDED_WAIT_LOG_SECS boundary crossed, never more.
13621
+ waited_secs = 0.0
13622
+ last_wait_log_boundary = 0
13235
13623
  task = asyncio.ensure_future(produce_coro)
13236
13624
  try:
13237
13625
  while True:
@@ -13241,6 +13629,17 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
13241
13629
  produced = await asyncio.wait_for(asyncio.shield(task), timeout=interval)
13242
13630
  break
13243
13631
  except asyncio.TimeoutError:
13632
+ waited_secs += interval
13633
+ if PROXY_GUARDED_WAIT_LOG_SECS > 0:
13634
+ boundary = int(waited_secs // PROXY_GUARDED_WAIT_LOG_SECS)
13635
+ if boundary > last_wait_log_boundary:
13636
+ last_wait_log_boundary = boundary
13637
+ logger.info(
13638
+ "GUARDED-BUFFER WAIT: buffered non-stream turn still "
13639
+ "generating after %ds (model=%s input_tokens=%d) — "
13640
+ "client is receiving heartbeats, not hung",
13641
+ int(waited_secs), model, input_tokens,
13642
+ )
13244
13643
  if thinking_heartbeat:
13245
13644
  if not hb_thinking_open:
13246
13645
  hb_thinking_open = True
@@ -13942,7 +14341,14 @@ async def stream_anthropic_response(
13942
14341
  and not tool_calls_by_index
13943
14342
  and (
13944
14343
  finish_reason == "max_tokens"
13945
- or (finish_reason == "end_turn" and len(accumulated_text) > 512)
14344
+ or (
14345
+ finish_reason == "end_turn"
14346
+ and len(accumulated_text) > 512
14347
+ # A substantive final answer is a completion, not a malformed
14348
+ # payload: don't feed it to the forced-tool dampener (the
14349
+ # same exemption the unexpected-end-turn retry uses).
14350
+ and not _final_answer_content_exempt(accumulated_text)
14351
+ )
13946
14352
  )
13947
14353
  ):
13948
14354
  monitor.malformed_tool_streak += 1
@@ -14425,10 +14831,23 @@ async def messages(request: Request):
14425
14831
  prev_msg_count = getattr(monitor, "last_request_msg_count", 0)
14426
14832
  if prev_msg_count >= 8 and n_messages <= prev_msg_count // 2:
14427
14833
  monitor.reset_tool_turn_state(reason="compaction_boundary")
14428
- monitor.consecutive_forced_count = 0
14429
- monitor.no_progress_streak = 0
14834
+ # The forcing-loop family via its one policy point (2026-10-04
14835
+ # extraction). forced_auto_cooldown_turns deliberately does NOT
14836
+ # clear here -- the dampener remedies cross-turn output quality,
14837
+ # which survives a compaction (see reset_forcing_loop's
14838
+ # docstring). Test coverage note: this site is inline in the
14839
+ # request handler and unreachable from the build_openai_request
14840
+ # test seam, so its wiring is inspection-verified -- the method
14841
+ # it calls IS pinned by test_reset_forcing_loop_membership_is_exact.
14842
+ monitor.reset_forcing_loop()
14430
14843
  monitor.tool_starvation_streak = 0
14431
14844
  monitor.consecutive_no_write_turns = 0
14845
+ # Prune-breaker floor trend too (2026-09-30, reviewer P2-1): a stale
14846
+ # pre-compaction floor baseline must not contribute a phantom "rise"
14847
+ # to the fresh epoch — the exact breaker false-positive class
14848
+ # _prune_breaker_should_fire exists to prevent.
14849
+ monitor.prune_spiral_streak = 0
14850
+ monitor.last_post_prune_util = -1.0
14432
14851
  logger.info(
14433
14852
  "COMPACTION BOUNDARY: message count collapsed %d -> %d; reset "
14434
14853
  "tool-turn/anti-spin state for the fresh epoch",
@@ -14527,18 +14946,42 @@ async def messages(request: Request):
14527
14946
  n_messages,
14528
14947
  post_util * 100,
14529
14948
  )
14530
- # Option 2: Circuit breaker — if 3+ consecutive prunes and still above,
14531
- # force finalize (drop tools, let model wrap up)
14532
- if monitor.prune_count >= 3 and post_util >= PROXY_CONTEXT_PRUNE_THRESHOLD:
14949
+ # Option 2: Circuit breaker — fires only on a RISING post-prune
14950
+ # floor (_prune_breaker_should_fire, 2026-09-30). The old "3+
14951
+ # consecutive prunes and still above threshold" condition fired
14952
+ # every few turns on tool-heavy clients whose fixed system+tools
14953
+ # overhead alone exceeds the threshold (observed live: 66k est
14954
+ # tokens of a 114,688 window at a 42% threshold), force-finalizing
14955
+ # healthy ~44% sessions.
14956
+ if _prune_breaker_should_fire(post_util, monitor):
14533
14957
  logger.error(
14534
- "PRUNE CIRCUIT BREAKER: %d consecutive prunes, still at %.1f%%. "
14535
- "Forcing finalize to prevent death spiral.",
14536
- monitor.prune_count,
14958
+ "PRUNE CIRCUIT BREAKER: post-prune floor rising for %d consecutive prunes "
14959
+ "(now %.1f%%, threshold %.1f%%). Forcing finalize to prevent death spiral.",
14960
+ monitor.prune_spiral_streak,
14537
14961
  post_util * 100,
14962
+ PROXY_CONTEXT_PRUNE_THRESHOLD * 100,
14538
14963
  )
14539
14964
  monitor.set_tool_turn_phase("finalize", reason="prune_circuit_breaker")
14540
14965
  monitor.tool_state_auto_budget_remaining = 1
14541
14966
  monitor.reset_completion_recovery()
14967
+ elif (
14968
+ post_util >= PROXY_CONTEXT_PRUNE_THRESHOLD
14969
+ and monitor.prune_spiral_streak == 0
14970
+ and not monitor.prune_floor_logged
14971
+ ):
14972
+ # Above threshold with a FLAT floor (no rise this turn): fixed
14973
+ # overhead, not a spiral. Tell the operator ONCE per session —
14974
+ # the per-turn prune above still logs its own warning lines.
14975
+ monitor.prune_floor_logged = True
14976
+ logger.warning(
14977
+ "Post-prune utilization %.1f%% stays above the %.1f%% threshold "
14978
+ "with a FLAT floor — fixed system+tools overhead, not a death "
14979
+ "spiral; circuit breaker held. Consider raising "
14980
+ "PROXY_CONTEXT_PRUNE_THRESHOLD above the overhead floor for "
14981
+ "this client.",
14982
+ post_util * 100,
14983
+ PROXY_CONTEXT_PRUNE_THRESHOLD * 100,
14984
+ )
14542
14985
 
14543
14986
  # Whether this upstream accepts chat_template_kwargs decides what
14544
14987
  # _set_thinking is allowed to write, so it must be answered BEFORE the body
@@ -14587,6 +15030,13 @@ async def messages(request: Request):
14587
15030
  use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
14588
15031
  if use_guarded_non_stream:
14589
15032
  async def _produce_guarded():
15033
+ # Covers the full buffered lifetime including any concurrency-queue
15034
+ # wait: from the first upstream attempt to the served response.
15035
+ # Reported in the "served ... (buffered %ds)" log line below so a
15036
+ # long buffered turn is legible after the fact (2026-10-04: a
15037
+ # client-compaction summary buffered 281 s and the only end-line
15038
+ # evidence was the RESP timestamp).
15039
+ t_guarded = time.monotonic()
14590
15040
  strict_body = dict(openai_body)
14591
15041
  strict_body["stream"] = False
14592
15042
 
@@ -14813,15 +15263,18 @@ async def messages(request: Request):
14813
15263
  )
14814
15264
  if PROXY_FORCE_NON_STREAM:
14815
15265
  logger.info(
14816
- "FORCED NON-STREAM: served stream response via guarded non-stream path"
15266
+ "FORCED NON-STREAM: served stream response via guarded non-stream path (buffered %ds)",
15267
+ int(time.monotonic() - t_guarded),
14817
15268
  )
14818
15269
  elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
14819
15270
  logger.info(
14820
- "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path"
15271
+ "STRICT STREAM GUARDRAIL: served stream response via guarded non-stream path (buffered %ds)",
15272
+ int(time.monotonic() - t_guarded),
14821
15273
  )
14822
15274
  else:
14823
15275
  logger.info(
14824
- "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path"
15276
+ "REQUIRED TOOL STREAM GUARDRAIL: served stream response via guarded non-stream path (buffered %ds)",
15277
+ int(time.monotonic() - t_guarded),
14825
15278
  )
14826
15279
 
14827
15280
  anthropic_resp = await _maybe_apply_recipe(anthropic_resp, body, openai_body, client)