@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.
- package/config/capacity-policy.json +33 -0
- package/config/model-profiles/qwen38.json +32 -33
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +57 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +32 -1
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +123 -4
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts +26 -0
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +84 -3
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/inference.d.ts +4 -0
- package/dist/cli/inference.d.ts.map +1 -1
- package/dist/cli/inference.js +74 -8
- package/dist/cli/inference.js.map +1 -1
- package/dist/cli/merge-gate.d.ts +59 -0
- package/dist/cli/merge-gate.d.ts.map +1 -0
- package/dist/cli/merge-gate.js +238 -0
- package/dist/cli/merge-gate.js.map +1 -0
- package/dist/cli/merge-queue.d.ts +13 -0
- package/dist/cli/merge-queue.d.ts.map +1 -1
- package/dist/cli/merge-queue.js +48 -2
- package/dist/cli/merge-queue.js.map +1 -1
- package/dist/cli/mission.d.ts +25 -0
- package/dist/cli/mission.d.ts.map +1 -0
- package/dist/cli/mission.js +225 -0
- package/dist/cli/mission.js.map +1 -0
- package/dist/cli/orch.d.ts +27 -0
- package/dist/cli/orch.d.ts.map +1 -0
- package/dist/cli/orch.js +70 -0
- package/dist/cli/orch.js.map +1 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +44 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/delivery/merge-gate.d.ts +168 -0
- package/dist/delivery/merge-gate.d.ts.map +1 -0
- package/dist/delivery/merge-gate.js +309 -0
- package/dist/delivery/merge-gate.js.map +1 -0
- package/dist/inference/analysis.d.ts +69 -3
- package/dist/inference/analysis.d.ts.map +1 -1
- package/dist/inference/analysis.js +123 -7
- package/dist/inference/analysis.js.map +1 -1
- package/dist/inference/probe.d.ts +38 -1
- package/dist/inference/probe.d.ts.map +1 -1
- package/dist/inference/probe.js +303 -30
- package/dist/inference/probe.js.map +1 -1
- package/dist/inference/strata.d.ts +95 -0
- package/dist/inference/strata.d.ts.map +1 -0
- package/dist/inference/strata.js +167 -0
- package/dist/inference/strata.js.map +1 -0
- package/dist/mission/database.d.ts +29 -0
- package/dist/mission/database.d.ts.map +1 -0
- package/dist/mission/database.js +131 -0
- package/dist/mission/database.js.map +1 -0
- package/dist/mission/ledger.d.ts +109 -0
- package/dist/mission/ledger.d.ts.map +1 -0
- package/dist/mission/ledger.js +294 -0
- package/dist/mission/ledger.js.map +1 -0
- package/dist/mission/poll-cycle.d.ts +73 -0
- package/dist/mission/poll-cycle.d.ts.map +1 -0
- package/dist/mission/poll-cycle.js +100 -0
- package/dist/mission/poll-cycle.js.map +1 -0
- package/dist/mission/poll-executor.d.ts +91 -0
- package/dist/mission/poll-executor.d.ts.map +1 -0
- package/dist/mission/poll-executor.js +328 -0
- package/dist/mission/poll-executor.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +33 -27
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +17 -18
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/types/mission.d.ts +80 -0
- package/dist/types/mission.d.ts.map +1 -0
- package/dist/types/mission.js +12 -0
- package/dist/types/mission.js.map +1 -0
- package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
- package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
- package/docs/guides/CAPACITY_POLICY.md +49 -0
- package/docs/guides/INFERENCE_HEALTH.md +65 -8
- package/docs/guides/MISSIONS.md +170 -0
- package/docs/guides/PROXY.md +2 -0
- package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
- package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
- package/docs/visual/merge-gate.html +404 -0
- package/docs/visual/mission-orchestrator.html +472 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/plane_admin_protect.py +133 -0
- package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +33 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +493 -40
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
- package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
- package/tools/agents/tests/test_plane_admin_protect.py +124 -0
- 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 #
|
|
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
|
-
|
|
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
|
-
-
|
|
10084
|
-
|
|
10085
|
-
-
|
|
10086
|
-
|
|
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
|
-
#
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
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 (
|
|
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
|
-
|
|
14429
|
-
|
|
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 —
|
|
14531
|
-
#
|
|
14532
|
-
|
|
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
|
|
14535
|
-
"Forcing finalize to prevent death spiral.",
|
|
14536
|
-
monitor.
|
|
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)
|