@miller-tech/uap 2.16.6 → 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/README.md +1 -1
- 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 +522 -45
- 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_repeat_call_guard.py +64 -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
|
|
@@ -2494,6 +2596,15 @@ class SessionMonitor:
|
|
|
2494
2596
|
# back into a structured tool_use, which would continue the very loop the
|
|
2495
2597
|
# breaker is ending. Reset to False at the start of every request.
|
|
2496
2598
|
suppress_text_tool_extraction: bool = False
|
|
2599
|
+
# Bounded snippet of the most recent tool_result text seen in the request.
|
|
2600
|
+
# Feeds the STUCK-BREAK repeat-call directive so the model is SHOWN the
|
|
2601
|
+
# unchanged output instead of only being told it "already has" it — live
|
|
2602
|
+
# 2026-09-29, a model re-ran `uap memory query "plane workspace slug pay2u"`
|
|
2603
|
+
# 7 times ignoring the directive, because a "No results" output is not an
|
|
2604
|
+
# answer a weak model recognises as final; it believes the missing value
|
|
2605
|
+
# must exist and keeps re-asking. Quoting the output makes the futility
|
|
2606
|
+
# concrete. Refreshed on every request from the conversation.
|
|
2607
|
+
last_tool_result_snippet: str = ""
|
|
2497
2608
|
# Tool-turn count at which the TURN-COUNT FINALIZE BREAKER last fired. The
|
|
2498
2609
|
# count is derived from the (only-growing) conversation, so without this the
|
|
2499
2610
|
# breaker would re-fire on EVERY turn once the ceiling is first crossed —
|
|
@@ -3029,6 +3140,61 @@ class SessionMonitor:
|
|
|
3029
3140
|
"""Highest current streak among malformed/invalid tool outputs."""
|
|
3030
3141
|
return max(self.malformed_tool_streak, self.invalid_tool_call_streak)
|
|
3031
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
|
+
|
|
3032
3198
|
def consume_forced_auto_turn(self) -> bool:
|
|
3033
3199
|
"""Consume one dampener turn that temporarily sets tool_choice=auto."""
|
|
3034
3200
|
if self.forced_auto_cooldown_turns <= 0:
|
|
@@ -3520,6 +3686,39 @@ def _prune_overhead_tokens(anthropic_body: dict) -> int:
|
|
|
3520
3686
|
return int(overhead_tokens * 1.5) # Safety factor for template overhead
|
|
3521
3687
|
|
|
3522
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
|
+
|
|
3523
3722
|
def _truncate_oversized_message_content(messages: list, budget_tokens: int) -> bool:
|
|
3524
3723
|
"""Truncate the largest message content in-place until the total fits
|
|
3525
3724
|
budget_tokens. Used when message-DROPPING cannot reduce below the window —
|
|
@@ -6828,10 +7027,35 @@ def _resolve_state_machine_tool_choice(
|
|
|
6828
7027
|
if latest_user_text and not last_user_has_tool_result:
|
|
6829
7028
|
monitor.tool_call_history = []
|
|
6830
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
|
|
6831
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.
|
|
6832
7058
|
monitor.forced_auto_cooldown_turns = 0
|
|
6833
|
-
monitor.consecutive_forced_count = 0
|
|
6834
|
-
monitor.no_progress_streak = 0
|
|
6835
7059
|
monitor.malformed_tool_streak = 0
|
|
6836
7060
|
monitor.invalid_tool_call_streak = 0
|
|
6837
7061
|
monitor.required_tool_miss_streak = 0
|
|
@@ -6861,10 +7085,24 @@ def _resolve_state_machine_tool_choice(
|
|
|
6861
7085
|
if not has_tool_results:
|
|
6862
7086
|
monitor.tool_call_history = []
|
|
6863
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
|
|
6864
7102
|
if n_msgs <= 1:
|
|
7103
|
+
# Brand-new conversation only (see the fresh_user_text
|
|
7104
|
+
# branch for why the cooldown is otherwise sticky).
|
|
6865
7105
|
monitor.forced_auto_cooldown_turns = 0
|
|
6866
|
-
monitor.consecutive_forced_count = 0
|
|
6867
|
-
monitor.no_progress_streak = 0
|
|
6868
7106
|
monitor.malformed_tool_streak = 0
|
|
6869
7107
|
monitor.invalid_tool_call_streak = 0
|
|
6870
7108
|
monitor.required_tool_miss_streak = 0
|
|
@@ -7265,15 +7503,27 @@ def _maybe_inject_stuck_break(openai_body: dict, monitor: "SessionMonitor") -> N
|
|
|
7265
7503
|
if reason.startswith(_REPEAT_CALL_REASON):
|
|
7266
7504
|
# The call SUCCEEDS every time, so "stop retrying a failing action" and
|
|
7267
7505
|
# the switch-channel advice would both be nonsense here. Name the real
|
|
7268
|
-
# problem: the answer is already in hand and re-asking cannot change
|
|
7506
|
+
# problem: the answer is already in hand and re-asking cannot change
|
|
7507
|
+
# it. QUOTE the actual output — live 2026-09-29, "You already have that
|
|
7508
|
+
# output" was ignored 7 times on a `uap memory query` returning "No
|
|
7509
|
+
# results": the model does not recognise an empty result as an answer,
|
|
7510
|
+
# so the directive must show it that the output IS the output.
|
|
7511
|
+
snippet = (monitor.last_tool_result_snippet or "").strip()
|
|
7512
|
+
quoted = (
|
|
7513
|
+
f' It returns EXACTLY this every time: "{snippet[:300]}"' if snippet else ""
|
|
7514
|
+
)
|
|
7269
7515
|
directive = (
|
|
7270
7516
|
"\n\nSTOP — you have issued the same tool call with the same "
|
|
7271
7517
|
"arguments " + reason.rsplit("x", 1)[-1] + " times in a row. It "
|
|
7272
7518
|
"SUCCEEDED each time and the result will not change by asking again. "
|
|
7273
|
-
"You already have that output. Do NOT repeat it
|
|
7274
|
-
"
|
|
7275
|
-
"
|
|
7276
|
-
"
|
|
7519
|
+
"You already have that output." + quoted + " Do NOT repeat it — "
|
|
7520
|
+
"re-asking cannot produce new information, and an empty or "
|
|
7521
|
+
"'no results' output IS this source's final answer. Either take the "
|
|
7522
|
+
"NEXT concrete action using what it told you (proceeding WITHOUT "
|
|
7523
|
+
"the missing information and stating your assumption if necessary), "
|
|
7524
|
+
"or — if you genuinely cannot proceed — state the single blocking "
|
|
7525
|
+
"question in one sentence and stop. Answer in plain text now if "
|
|
7526
|
+
"the work is done."
|
|
7277
7527
|
)
|
|
7278
7528
|
else:
|
|
7279
7529
|
directive = (
|
|
@@ -8636,7 +8886,7 @@ def build_openai_request(
|
|
|
8636
8886
|
monitor.no_progress_streak = 0
|
|
8637
8887
|
monitor.reset_tool_turn_state(reason="tool_starvation_breaker")
|
|
8638
8888
|
logger.warning(
|
|
8639
|
-
"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)",
|
|
8640
8890
|
PROXY_TOOL_STARVATION_THRESHOLD,
|
|
8641
8891
|
monitor.tool_starvation_streak,
|
|
8642
8892
|
)
|
|
@@ -9093,16 +9343,21 @@ def _record_last_assistant_tool_calls(
|
|
|
9093
9343
|
_latest_err = any(_flags)
|
|
9094
9344
|
break
|
|
9095
9345
|
monitor.note_tool_result_error(_latest_tr, _latest_err)
|
|
9346
|
+
# Bounded raw snippet of the same result, for the STUCK-BREAK repeat-call
|
|
9347
|
+
# directive (see SessionMonitor.last_tool_result_snippet).
|
|
9348
|
+
monitor.last_tool_result_snippet = (_latest_tr or "")[:400]
|
|
9096
9349
|
_seed_tool_history_from_request(monitor, messages)
|
|
9097
9350
|
tool_fingerprints = []
|
|
9098
9351
|
tool_targets: dict[str, str] = {}
|
|
9099
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
|
|
9100
9354
|
for msg in reversed(messages):
|
|
9101
9355
|
if msg.get("role") != "assistant":
|
|
9102
9356
|
continue
|
|
9103
9357
|
content = msg.get("content")
|
|
9104
9358
|
if isinstance(content, str) and content.strip():
|
|
9105
9359
|
assistant_had_text = True
|
|
9360
|
+
assistant_prose = content
|
|
9106
9361
|
if isinstance(content, list):
|
|
9107
9362
|
for block in content:
|
|
9108
9363
|
if (
|
|
@@ -9111,6 +9366,11 @@ def _record_last_assistant_tool_calls(
|
|
|
9111
9366
|
and str(block.get("text", "")).strip()
|
|
9112
9367
|
):
|
|
9113
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", ""))
|
|
9114
9374
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
9115
9375
|
tool_fingerprints.append(_tool_call_fingerprint(block))
|
|
9116
9376
|
# Extract target key for read-only dedup (Option 3)
|
|
@@ -9141,8 +9401,12 @@ def _record_last_assistant_tool_calls(
|
|
|
9141
9401
|
# Fix B: no tool call in the last assistant turn. A plain-text turn is still
|
|
9142
9402
|
# a non-write turn, so advance the recon-convergence streak (previously it
|
|
9143
9403
|
# only moved on tool turns, freezing the counter through prose-only stalls).
|
|
9144
|
-
# Guard on real prose so an empty/absent assistant turn never inflates it
|
|
9145
|
-
|
|
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):
|
|
9146
9410
|
monitor.note_no_tool_turn()
|
|
9147
9411
|
monitor.note_doubling_signal(
|
|
9148
9412
|
"", _latest_tr, msg_count=len(messages), result_error=_latest_err
|
|
@@ -9150,6 +9414,77 @@ def _record_last_assistant_tool_calls(
|
|
|
9150
9414
|
return ""
|
|
9151
9415
|
|
|
9152
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
|
+
|
|
9153
9488
|
def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
|
|
9154
9489
|
choices = openai_resp.get("choices") or []
|
|
9155
9490
|
if not choices:
|
|
@@ -9167,6 +9502,16 @@ def _is_unexpected_end_turn(openai_resp: dict, anthropic_body: dict) -> bool:
|
|
|
9167
9502
|
if "tools" not in anthropic_body:
|
|
9168
9503
|
return False
|
|
9169
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
|
+
|
|
9170
9515
|
has_tool_results = _conversation_has_tool_results(anthropic_body)
|
|
9171
9516
|
|
|
9172
9517
|
return has_tool_results or _last_assistant_was_text_only(anthropic_body)
|
|
@@ -10056,16 +10401,14 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
|
|
|
10056
10401
|
"""Detect garbled/degenerate tool call arguments.
|
|
10057
10402
|
|
|
10058
10403
|
Returns True if the arguments string shows signs of degenerate generation:
|
|
10059
|
-
-
|
|
10060
|
-
|
|
10061
|
-
-
|
|
10062
|
-
|
|
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
|
|
10063
10408
|
"""
|
|
10064
10409
|
if not arguments_str or arguments_str == "{}":
|
|
10065
10410
|
return False
|
|
10066
10411
|
|
|
10067
|
-
if _GARBLED_RUNAWAY_BRACES_RE.search(arguments_str):
|
|
10068
|
-
return True
|
|
10069
10412
|
if _GARBLED_REPETITIVE_DIGITS_RE.search(arguments_str):
|
|
10070
10413
|
return True
|
|
10071
10414
|
if _GARBLED_ZEROS_RE.search(arguments_str):
|
|
@@ -10073,11 +10416,31 @@ def _is_garbled_tool_arguments(arguments_str: str) -> bool:
|
|
|
10073
10416
|
if _GARBLED_LONG_DIGITS_RE.search(arguments_str):
|
|
10074
10417
|
return True
|
|
10075
10418
|
|
|
10076
|
-
#
|
|
10077
|
-
|
|
10078
|
-
|
|
10079
|
-
|
|
10080
|
-
|
|
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
|
|
10081
10444
|
|
|
10082
10445
|
return False
|
|
10083
10446
|
|
|
@@ -10349,6 +10712,11 @@ class ToolResponseIssue:
|
|
|
10349
10712
|
kind: str = ""
|
|
10350
10713
|
reason: str = ""
|
|
10351
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
|
|
10352
10720
|
|
|
10353
10721
|
def has_issue(self) -> bool:
|
|
10354
10722
|
return bool(self.kind)
|
|
@@ -11171,6 +11539,14 @@ def _classify_tool_response_issue(
|
|
|
11171
11539
|
has_tool_calls = _openai_has_tool_calls(openai_resp)
|
|
11172
11540
|
if not has_tool_calls:
|
|
11173
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)
|
|
11174
11550
|
return ToolResponseIssue(
|
|
11175
11551
|
kind="required_tool_miss",
|
|
11176
11552
|
reason="required tool turn returned no tool calls",
|
|
@@ -11924,16 +12300,24 @@ async def _apply_malformed_tool_guardrail(
|
|
|
11924
12300
|
|
|
11925
12301
|
required_tool_choice = openai_body.get("tool_choice") == "required"
|
|
11926
12302
|
has_tool_calls = _openai_has_tool_calls(working_resp)
|
|
11927
|
-
if required_tool_choice and not has_tool_calls:
|
|
11928
|
-
monitor.required_tool_miss_streak += 1
|
|
11929
12303
|
|
|
11930
12304
|
issue = _classify_tool_response_issue(
|
|
11931
12305
|
working_resp,
|
|
11932
12306
|
anthropic_body,
|
|
11933
12307
|
required_tool_choice=required_tool_choice,
|
|
11934
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
|
+
|
|
11935
12316
|
if not issue.has_issue():
|
|
11936
|
-
|
|
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:
|
|
11937
12321
|
monitor.maybe_activate_forced_tool_dampener("required_tool_miss")
|
|
11938
12322
|
if has_tool_calls:
|
|
11939
12323
|
monitor.malformed_tool_streak = 0
|
|
@@ -12075,14 +12459,20 @@ async def _apply_malformed_tool_guardrail(
|
|
|
12075
12459
|
|
|
12076
12460
|
retry_has_tool_calls = _openai_has_tool_calls(retry_working)
|
|
12077
12461
|
retry_required = retry_body.get("tool_choice") == "required"
|
|
12078
|
-
if retry_required and not retry_has_tool_calls:
|
|
12079
|
-
monitor.required_tool_miss_streak += 1
|
|
12080
|
-
|
|
12081
12462
|
retry_issue = _classify_tool_response_issue(
|
|
12082
12463
|
retry_working,
|
|
12083
12464
|
anthropic_body,
|
|
12084
12465
|
required_tool_choice=retry_required,
|
|
12085
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
|
|
12086
12476
|
|
|
12087
12477
|
if not retry_issue.has_issue():
|
|
12088
12478
|
# 2026-05-12: Fix #2 — do NOT reset malformed/invalid/miss streaks
|
|
@@ -12341,6 +12731,13 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
12341
12731
|
monitor.contamination_resets += 1
|
|
12342
12732
|
monitor.malformed_tool_streak = 0
|
|
12343
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
|
|
12344
12741
|
# Remove tools to force text-only response
|
|
12345
12742
|
updated = dict(anthropic_body)
|
|
12346
12743
|
updated.pop("tools", None)
|
|
@@ -12454,12 +12851,21 @@ def _maybe_apply_session_contamination_breaker(
|
|
|
12454
12851
|
forced_before = monitor.consecutive_forced_count
|
|
12455
12852
|
required_miss_before = monitor.required_tool_miss_streak
|
|
12456
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
|
|
12457
12862
|
monitor.malformed_tool_streak = 0
|
|
12458
12863
|
monitor.invalid_tool_call_streak = 0
|
|
12459
12864
|
monitor.required_tool_miss_streak = 0
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
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
|
|
12463
12869
|
monitor.reset_tool_turn_state(reason=f"contamination_guardrail_reset_{log_reason}")
|
|
12464
12870
|
if attractor_detected:
|
|
12465
12871
|
logger.warning(
|
|
@@ -13208,6 +13614,12 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
|
|
|
13208
13614
|
interval = PROXY_STREAM_HEARTBEAT_SECS if PROXY_STREAM_HEARTBEAT_SECS > 0 else 15.0
|
|
13209
13615
|
thinking_heartbeat = PROXY_STREAM_THINKING_DELTAS and PROXY_PREFILL_HEARTBEAT_SECS > 0
|
|
13210
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
|
|
13211
13623
|
task = asyncio.ensure_future(produce_coro)
|
|
13212
13624
|
try:
|
|
13213
13625
|
while True:
|
|
@@ -13217,6 +13629,17 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
|
|
|
13217
13629
|
produced = await asyncio.wait_for(asyncio.shield(task), timeout=interval)
|
|
13218
13630
|
break
|
|
13219
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
|
+
)
|
|
13220
13643
|
if thinking_heartbeat:
|
|
13221
13644
|
if not hb_thinking_open:
|
|
13222
13645
|
hb_thinking_open = True
|
|
@@ -13918,7 +14341,14 @@ async def stream_anthropic_response(
|
|
|
13918
14341
|
and not tool_calls_by_index
|
|
13919
14342
|
and (
|
|
13920
14343
|
finish_reason == "max_tokens"
|
|
13921
|
-
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
|
+
)
|
|
13922
14352
|
)
|
|
13923
14353
|
):
|
|
13924
14354
|
monitor.malformed_tool_streak += 1
|
|
@@ -14401,10 +14831,23 @@ async def messages(request: Request):
|
|
|
14401
14831
|
prev_msg_count = getattr(monitor, "last_request_msg_count", 0)
|
|
14402
14832
|
if prev_msg_count >= 8 and n_messages <= prev_msg_count // 2:
|
|
14403
14833
|
monitor.reset_tool_turn_state(reason="compaction_boundary")
|
|
14404
|
-
|
|
14405
|
-
|
|
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()
|
|
14406
14843
|
monitor.tool_starvation_streak = 0
|
|
14407
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
|
|
14408
14851
|
logger.info(
|
|
14409
14852
|
"COMPACTION BOUNDARY: message count collapsed %d -> %d; reset "
|
|
14410
14853
|
"tool-turn/anti-spin state for the fresh epoch",
|
|
@@ -14503,18 +14946,42 @@ async def messages(request: Request):
|
|
|
14503
14946
|
n_messages,
|
|
14504
14947
|
post_util * 100,
|
|
14505
14948
|
)
|
|
14506
|
-
# Option 2: Circuit breaker —
|
|
14507
|
-
#
|
|
14508
|
-
|
|
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):
|
|
14509
14957
|
logger.error(
|
|
14510
|
-
"PRUNE CIRCUIT BREAKER: %d consecutive prunes
|
|
14511
|
-
"Forcing finalize to prevent death spiral.",
|
|
14512
|
-
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,
|
|
14513
14961
|
post_util * 100,
|
|
14962
|
+
PROXY_CONTEXT_PRUNE_THRESHOLD * 100,
|
|
14514
14963
|
)
|
|
14515
14964
|
monitor.set_tool_turn_phase("finalize", reason="prune_circuit_breaker")
|
|
14516
14965
|
monitor.tool_state_auto_budget_remaining = 1
|
|
14517
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
|
+
)
|
|
14518
14985
|
|
|
14519
14986
|
# Whether this upstream accepts chat_template_kwargs decides what
|
|
14520
14987
|
# _set_thinking is allowed to write, so it must be answered BEFORE the body
|
|
@@ -14563,6 +15030,13 @@ async def messages(request: Request):
|
|
|
14563
15030
|
use_guarded_non_stream = _should_buffer_turn(is_stream, body, openai_body, monitor)
|
|
14564
15031
|
if use_guarded_non_stream:
|
|
14565
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()
|
|
14566
15040
|
strict_body = dict(openai_body)
|
|
14567
15041
|
strict_body["stream"] = False
|
|
14568
15042
|
|
|
@@ -14789,15 +15263,18 @@ async def messages(request: Request):
|
|
|
14789
15263
|
)
|
|
14790
15264
|
if PROXY_FORCE_NON_STREAM:
|
|
14791
15265
|
logger.info(
|
|
14792
|
-
"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),
|
|
14793
15268
|
)
|
|
14794
15269
|
elif PROXY_MALFORMED_TOOL_STREAM_STRICT and _has_tool_definitions(body):
|
|
14795
15270
|
logger.info(
|
|
14796
|
-
"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),
|
|
14797
15273
|
)
|
|
14798
15274
|
else:
|
|
14799
15275
|
logger.info(
|
|
14800
|
-
"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),
|
|
14801
15278
|
)
|
|
14802
15279
|
|
|
14803
15280
|
anthropic_resp = await _maybe_apply_recipe(anthropic_resp, body, openai_body, client)
|