@miller-tech/uap 1.136.1 → 1.137.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/benchmarks/paired/report.d.ts +13 -0
  3. package/dist/benchmarks/paired/report.d.ts.map +1 -1
  4. package/dist/benchmarks/paired/report.js +29 -3
  5. package/dist/benchmarks/paired/report.js.map +1 -1
  6. package/dist/benchmarks/paired/types.d.ts +59 -0
  7. package/dist/benchmarks/paired/types.d.ts.map +1 -1
  8. package/dist/benchmarks/paired/types.js +60 -0
  9. package/dist/benchmarks/paired/types.js.map +1 -1
  10. package/dist/bin/cli.js +30 -0
  11. package/dist/bin/cli.js.map +1 -1
  12. package/dist/cli/deliver.d.ts.map +1 -1
  13. package/dist/cli/deliver.js +111 -7
  14. package/dist/cli/deliver.js.map +1 -1
  15. package/dist/cli/react.d.ts.map +1 -1
  16. package/dist/cli/react.js +18 -0
  17. package/dist/cli/react.js.map +1 -1
  18. package/dist/cli/self-tuning.d.ts +33 -0
  19. package/dist/cli/self-tuning.d.ts.map +1 -0
  20. package/dist/cli/self-tuning.js +139 -0
  21. package/dist/cli/self-tuning.js.map +1 -0
  22. package/dist/cli/tune-adapter.d.ts +8 -0
  23. package/dist/cli/tune-adapter.d.ts.map +1 -0
  24. package/dist/cli/tune-adapter.js +26 -0
  25. package/dist/cli/tune-adapter.js.map +1 -0
  26. package/dist/config/settings-registry.d.ts.map +1 -1
  27. package/dist/config/settings-registry.js +10 -0
  28. package/dist/config/settings-registry.js.map +1 -1
  29. package/dist/coordination/adaptation-signal.d.ts +19 -0
  30. package/dist/coordination/adaptation-signal.d.ts.map +1 -0
  31. package/dist/coordination/adaptation-signal.js +36 -0
  32. package/dist/coordination/adaptation-signal.js.map +1 -0
  33. package/dist/delivery/agentic-executor.d.ts +16 -1
  34. package/dist/delivery/agentic-executor.d.ts.map +1 -1
  35. package/dist/delivery/agentic-executor.js +45 -4
  36. package/dist/delivery/agentic-executor.js.map +1 -1
  37. package/dist/delivery/decompose.d.ts +27 -0
  38. package/dist/delivery/decompose.d.ts.map +1 -1
  39. package/dist/delivery/decompose.js +31 -1
  40. package/dist/delivery/decompose.js.map +1 -1
  41. package/dist/delivery/repair-escalation.d.ts +49 -0
  42. package/dist/delivery/repair-escalation.d.ts.map +1 -0
  43. package/dist/delivery/repair-escalation.js +83 -0
  44. package/dist/delivery/repair-escalation.js.map +1 -0
  45. package/dist/delivery/verifier-ladder.d.ts +47 -0
  46. package/dist/delivery/verifier-ladder.d.ts.map +1 -1
  47. package/dist/delivery/verifier-ladder.js +87 -1
  48. package/dist/delivery/verifier-ladder.js.map +1 -1
  49. package/dist/self-harness/mods.d.ts +20 -1
  50. package/dist/self-harness/mods.d.ts.map +1 -1
  51. package/dist/self-harness/mods.js +41 -0
  52. package/dist/self-harness/mods.js.map +1 -1
  53. package/dist/self-harness/orchestrator.js +4 -0
  54. package/dist/self-harness/orchestrator.js.map +1 -1
  55. package/dist/self-harness/transfer.d.ts.map +1 -1
  56. package/dist/self-harness/transfer.js +2 -0
  57. package/dist/self-harness/transfer.js.map +1 -1
  58. package/dist/self-tuning/flag-writer.d.ts +48 -0
  59. package/dist/self-tuning/flag-writer.d.ts.map +1 -0
  60. package/dist/self-tuning/flag-writer.js +102 -0
  61. package/dist/self-tuning/flag-writer.js.map +1 -0
  62. package/dist/self-tuning/flags.d.ts +88 -0
  63. package/dist/self-tuning/flags.d.ts.map +1 -0
  64. package/dist/self-tuning/flags.js +189 -0
  65. package/dist/self-tuning/flags.js.map +1 -0
  66. package/dist/self-tuning/index.d.ts +25 -0
  67. package/dist/self-tuning/index.d.ts.map +1 -0
  68. package/dist/self-tuning/index.js +25 -0
  69. package/dist/self-tuning/index.js.map +1 -0
  70. package/dist/self-tuning/judge.d.ts +66 -0
  71. package/dist/self-tuning/judge.d.ts.map +1 -0
  72. package/dist/self-tuning/judge.js +145 -0
  73. package/dist/self-tuning/judge.js.map +1 -0
  74. package/dist/self-tuning/llm-tuner.d.ts +83 -0
  75. package/dist/self-tuning/llm-tuner.d.ts.map +1 -0
  76. package/dist/self-tuning/llm-tuner.js +191 -0
  77. package/dist/self-tuning/llm-tuner.js.map +1 -0
  78. package/dist/self-tuning/orchestrator.d.ts +90 -0
  79. package/dist/self-tuning/orchestrator.d.ts.map +1 -0
  80. package/dist/self-tuning/orchestrator.js +126 -0
  81. package/dist/self-tuning/orchestrator.js.map +1 -0
  82. package/dist/self-tuning/paired-validator.d.ts +50 -0
  83. package/dist/self-tuning/paired-validator.d.ts.map +1 -0
  84. package/dist/self-tuning/paired-validator.js +78 -0
  85. package/dist/self-tuning/paired-validator.js.map +1 -0
  86. package/dist/self-tuning/profiles/index.d.ts +11 -0
  87. package/dist/self-tuning/profiles/index.d.ts.map +1 -0
  88. package/dist/self-tuning/profiles/index.js +15 -0
  89. package/dist/self-tuning/profiles/index.js.map +1 -0
  90. package/dist/self-tuning/profiles/opus48.d.ts +11 -0
  91. package/dist/self-tuning/profiles/opus48.d.ts.map +1 -0
  92. package/dist/self-tuning/profiles/opus48.js +27 -0
  93. package/dist/self-tuning/profiles/opus48.js.map +1 -0
  94. package/dist/self-tuning/profiles/qwen36.d.ts +11 -0
  95. package/dist/self-tuning/profiles/qwen36.d.ts.map +1 -0
  96. package/dist/self-tuning/profiles/qwen36.js +33 -0
  97. package/dist/self-tuning/profiles/qwen36.js.map +1 -0
  98. package/dist/self-tuning/quality-scorer.d.ts +90 -0
  99. package/dist/self-tuning/quality-scorer.d.ts.map +1 -0
  100. package/dist/self-tuning/quality-scorer.js +180 -0
  101. package/dist/self-tuning/quality-scorer.js.map +1 -0
  102. package/dist/self-tuning/realtime-adaptor.d.ts +78 -0
  103. package/dist/self-tuning/realtime-adaptor.d.ts.map +1 -0
  104. package/dist/self-tuning/realtime-adaptor.js +127 -0
  105. package/dist/self-tuning/realtime-adaptor.js.map +1 -0
  106. package/dist/self-tuning/run.d.ts +61 -0
  107. package/dist/self-tuning/run.d.ts.map +1 -0
  108. package/dist/self-tuning/run.js +122 -0
  109. package/dist/self-tuning/run.js.map +1 -0
  110. package/dist/self-tuning/search-reducer.d.ts +76 -0
  111. package/dist/self-tuning/search-reducer.d.ts.map +1 -0
  112. package/dist/self-tuning/search-reducer.js +349 -0
  113. package/dist/self-tuning/search-reducer.js.map +1 -0
  114. package/dist/self-tuning/tuning-profile.d.ts +72 -0
  115. package/dist/self-tuning/tuning-profile.d.ts.map +1 -0
  116. package/dist/self-tuning/tuning-profile.js +134 -0
  117. package/dist/self-tuning/tuning-profile.js.map +1 -0
  118. package/dist/telemetry/session-telemetry.d.ts +25 -0
  119. package/dist/telemetry/session-telemetry.d.ts.map +1 -1
  120. package/dist/telemetry/session-telemetry.js +32 -0
  121. package/dist/telemetry/session-telemetry.js.map +1 -1
  122. package/dist/types/config.d.ts +13 -0
  123. package/dist/types/config.d.ts.map +1 -1
  124. package/dist/types/config.js +3 -0
  125. package/dist/types/config.js.map +1 -1
  126. package/docs/design/LLM_SELF_TUNING_ANALYSIS.md +617 -0
  127. package/docs/guides/SELF_TUNING.md +137 -0
  128. package/package.json +1 -1
  129. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  130. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  131. package/tools/agents/scripts/anthropic_proxy.py +355 -29
  132. package/tools/agents/scripts/confidence_escalation.py +60 -4
  133. package/tools/agents/tests/test_confidence_escalation.py +77 -0
@@ -180,6 +180,19 @@ PROXY_SLOT_HANG_TIMEOUT = float(os.environ.get("PROXY_SLOT_HANG_TIMEOUT", "120")
180
180
  PROXY_UPSTREAM_RETRY_MAX = int(os.environ.get("PROXY_UPSTREAM_RETRY_MAX", "3"))
181
181
  PROXY_UPSTREAM_RETRY_DELAY_SECS = float(os.environ.get("PROXY_UPSTREAM_RETRY_DELAY_SECS", "5"))
182
182
  PROXY_MAX_CONNECTIONS = int(os.environ.get("PROXY_MAX_CONNECTIONS", "20"))
183
+ # CLOSE-WAIT reaper: abandoned upstream connections (a cancelled request
184
+ # whose httpx connection is never closed — llama's response sits unread in
185
+ # the socket, CLOSE-WAIT) accrue and saturate the pool. The reaper reads
186
+ # /proc/net/tcp, counts CLOSE-WAIT sockets to the upstream, and triggers a
187
+ # (safe, stream-preserving) pool self-heal when they exceed the threshold.
188
+ PROXY_CLOSEWAIT_REAP_THRESHOLD = int(os.environ.get("PROXY_CLOSEWAIT_REAP_THRESHOLD", "30"))
189
+ # Reaper is OPT-IN (default 0 = off): live 2026-07-12 under a SATURATED
190
+ # upstream (llama at 3/3 slots), its pool-swap mechanism turned a slow
191
+ # abandoned-connection leak into ReadError->500 bursts (74 500s/16min) —
192
+ # constant swaps churned in-flight connections. The graceful path
193
+ # (PoolTimeout -> 529 backoff + a large PROXY_MAX_CONNECTIONS) handles the
194
+ # leak without client-visible errors. Set >0 to re-enable the reaper.
195
+ PROXY_CLOSEWAIT_REAP_INTERVAL = float(os.environ.get("PROXY_CLOSEWAIT_REAP_INTERVAL", "0"))
183
196
  PROXY_CONTEXT_WINDOW = int(os.environ.get("PROXY_CONTEXT_WINDOW", "0"))
184
197
  PROXY_CONTEXT_PRUNE_THRESHOLD = float(
185
198
  os.environ.get("PROXY_CONTEXT_PRUNE_THRESHOLD", "0.85")
@@ -2283,8 +2296,28 @@ def prune_conversation(
2283
2296
  # Budget for messages
2284
2297
  message_budget = target_tokens - overhead_tokens
2285
2298
  if message_budget <= 0:
2286
- logger.error("System prompt + tools alone exceed target budget!")
2287
- return anthropic_body
2299
+ # Tool-heavy clients (observed: 37 MCP tools whose schema estimate x1.5
2300
+ # safety factor exceeds the whole 40% critical-prune target) used to
2301
+ # ERROR-RETURN here with the body untouched — no pruning at all, so the
2302
+ # session pinned at 115%+, Option E clamped every output to 1024 tokens,
2303
+ # and all substantial writes truncated forever (24+ consecutive no-op
2304
+ # prunes + circuit breaker every turn). The schemas are client-fixed
2305
+ # and non-droppable; give MESSAGES a minimal floor and prune into it
2306
+ # best-effort instead of giving up.
2307
+ message_budget = max(1, int(context_window * 0.20))
2308
+ # The condition is PERMANENT for a tool-heavy client (fires per
2309
+ # request, observed every ~2s) — warn once per session, then debug.
2310
+ already = bool(monitor is not None and getattr(monitor, "floor_budget_logged", False))
2311
+ log = logger.debug if already else logger.warning
2312
+ if monitor is not None:
2313
+ monitor.floor_budget_logged = True
2314
+ log(
2315
+ "System+tools overhead (~%d est tokens) consumes the %.0f%% prune "
2316
+ "target — pruning messages into a floor budget of %d tokens instead",
2317
+ overhead_tokens,
2318
+ target_fraction * 100,
2319
+ message_budget,
2320
+ )
2288
2321
 
2289
2322
  # Always keep the first user message and the last N messages
2290
2323
  KEEP_LAST = keep_last
@@ -2386,6 +2419,180 @@ def prune_conversation(
2386
2419
  # Module-level httpx.AsyncClient for connection reuse + keep-alive.
2387
2420
  # Granular timeouts: short connect, long read for streaming LLM output.
2388
2421
  http_client: httpx.AsyncClient | None = None
2422
+ _last_pool_reset_ts: float = 0.0
2423
+
2424
+ # Transient upstream failures that are safe to retry (a fresh POST re-runs
2425
+ # the request). Single source of truth — used by BOTH the buffered
2426
+ # _post_with_retry_inner AND the streaming send loop, which previously had
2427
+ # divergent copies (the streaming copy lacked ReadError, so a mid-setup
2428
+ # connection reset on a streamed turn 500'd; live 2026-07-11).
2429
+ _UPSTREAM_RETRY_EXCEPTIONS = (
2430
+ httpx.ConnectError,
2431
+ httpx.ConnectTimeout,
2432
+ httpx.RemoteProtocolError,
2433
+ httpx.ReadTimeout,
2434
+ httpx.ReadError,
2435
+ httpx.WriteError,
2436
+ )
2437
+
2438
+
2439
+ def _upstream_port() -> int:
2440
+ """The upstream (llama) TCP port, for CLOSE-WAIT accounting."""
2441
+ try:
2442
+ return int(LLAMA_CPP_BASE.rsplit(":", 1)[1].split("/")[0])
2443
+ except Exception:
2444
+ return 8080
2445
+
2446
+
2447
+ def _count_upstream_close_wait() -> int:
2448
+ """Count CLOSE-WAIT sockets to the upstream port via /proc/net/tcp.
2449
+
2450
+ Cheap (one file read), no subprocess. TCP state 08 == CLOSE_WAIT; the
2451
+ remote port is hex in the 3rd column's ':PORT' suffix.
2452
+ """
2453
+ port_hex = f"{_upstream_port():04X}"
2454
+ n = 0
2455
+ try:
2456
+ with open("/proc/net/tcp", "r") as fh:
2457
+ next(fh, None)
2458
+ for line in fh:
2459
+ parts = line.split()
2460
+ if len(parts) < 4:
2461
+ continue
2462
+ if parts[3] == "08" and parts[2].endswith(":" + port_hex):
2463
+ n += 1
2464
+ except Exception:
2465
+ return 0
2466
+ return n
2467
+
2468
+
2469
+ async def _closewait_reaper() -> None:
2470
+ """Periodic: reap abandoned upstream CLOSE-WAIT connections.
2471
+
2472
+ When they exceed the threshold, replace the pool (safe self-heal) — the
2473
+ retired pool's aclose (once its legit in-flight drains) force-closes the
2474
+ abandoned connections, which have no in-flight coroutine of their own.
2475
+ """
2476
+ if PROXY_CLOSEWAIT_REAP_INTERVAL <= 0:
2477
+ return
2478
+ while True:
2479
+ try:
2480
+ await asyncio.sleep(PROXY_CLOSEWAIT_REAP_INTERVAL)
2481
+ cw = _count_upstream_close_wait()
2482
+ if cw >= PROXY_CLOSEWAIT_REAP_THRESHOLD:
2483
+ _maybe_reset_http_client(f"CLOSE-WAIT reaper ({cw} abandoned upstream connections)")
2484
+ except asyncio.CancelledError:
2485
+ raise
2486
+ except Exception as exc:
2487
+ logger.warning("CLOSE-WAIT reaper error: %s", exc)
2488
+
2489
+
2490
+ def _inflight_inc(client) -> None:
2491
+ try:
2492
+ client._uap_inflight += 1
2493
+ except Exception:
2494
+ pass
2495
+
2496
+
2497
+ def _inflight_dec(client) -> None:
2498
+ try:
2499
+ client._uap_inflight -= 1
2500
+ except Exception:
2501
+ pass
2502
+
2503
+
2504
+ def _detach_aclose(closeable) -> None:
2505
+ """Close an upstream stream/response on a DETACHED task so it completes
2506
+ even when the requesting ASGI task is being cancelled (client disconnect).
2507
+ An in-line ``await x.aclose()`` in a streaming finally can itself be
2508
+ cancelled before it closes the socket, leaking the upstream connection —
2509
+ it accrues as CLOSE-WAIT and eventually saturates the pool (live
2510
+ 2026-07-11: CW climbed 26→52 with zero client errors after the cascade was
2511
+ fixed). ensure_future schedules the close independently of the dying task.
2512
+ """
2513
+ async def _run() -> None:
2514
+ try:
2515
+ await closeable.aclose()
2516
+ except Exception:
2517
+ pass
2518
+ try:
2519
+ asyncio.ensure_future(_run())
2520
+ except RuntimeError:
2521
+ pass # no running loop — nothing to detach onto
2522
+
2523
+
2524
+ def _build_http_client() -> httpx.AsyncClient:
2525
+ """Upstream client factory — used at startup AND by pool self-healing."""
2526
+ c = httpx.AsyncClient(
2527
+ timeout=httpx.Timeout(
2528
+ connect=10.0, # 10s to establish connection
2529
+ read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
2530
+ write=30.0, # 30s to send the request body
2531
+ pool=10.0, # 10s to acquire a pool connection
2532
+ ),
2533
+ limits=httpx.Limits(
2534
+ max_connections=PROXY_MAX_CONNECTIONS,
2535
+ # No upstream keepalive: llama.cpp closes idle keepalive
2536
+ # connections faster than a 120s expiry, so they piled as
2537
+ # CLOSE-WAIT and exhausted the pool (live: 51 zombies, PoolTimeout
2538
+ # 500-storm). The upstream is localhost at low concurrency —
2539
+ # connection reuse saves microseconds and is not worth the leak
2540
+ # class. Each request opens and cleanly closes its own connection.
2541
+ max_keepalive_connections=0,
2542
+ keepalive_expiry=0,
2543
+ ),
2544
+ )
2545
+ # Per-CLIENT in-flight counter: a retired pool must drain on ITS OWN
2546
+ # requests (a global counter can't isolate it — new-pool traffic keeps a
2547
+ # global high, so the retire waited the full backstop and never reaped;
2548
+ # live 2026-07-11: reaper fired 7x, CLOSE-WAIT still climbed 30→50).
2549
+ c._uap_inflight = 0 # type: ignore[attr-defined]
2550
+ return c
2551
+
2552
+
2553
+ def _maybe_reset_http_client(reason: str) -> bool:
2554
+ """Pool self-healing: swap in a fresh AsyncClient, retiring the old one.
2555
+
2556
+ Rare cancellation races (asyncio.wait_for cutting an httpx send mid-
2557
+ response — the guarded non-stream path every tool turn uses) leak
2558
+ connections the pool counts as busy forever; observed live as 12
2559
+ CLOSE-WAIT zombies out of a 20-connection pool and a 205-request
2560
+ PoolTimeout 500-storm. There is no public API to reap them, so on
2561
+ saturation we REPLACE the client: new requests get a fresh pool
2562
+ immediately (handlers snapshot the global per request), in-flight
2563
+ requests finish on the old object, which is closed after a grace period.
2564
+ Cooldown-guarded so a burst cannot thrash clients.
2565
+ """
2566
+ global http_client, _last_pool_reset_ts
2567
+ now = time.time()
2568
+ if now - _last_pool_reset_ts < 60:
2569
+ return False
2570
+ if http_client is None:
2571
+ return False
2572
+ _last_pool_reset_ts = now
2573
+ old_client = http_client
2574
+ http_client = _build_http_client()
2575
+ logger.error(
2576
+ "POOL SELF-HEAL: replaced upstream connection pool (%s); old pool closes after grace period",
2577
+ reason,
2578
+ )
2579
+
2580
+ async def _retire() -> None:
2581
+ # Close the old pool once ITS OWN in-flight requests (per-client
2582
+ # counter — streaming + buffered) drain to zero: no live request
2583
+ # remains on the old client, so aclose() force-closes only its
2584
+ # ABANDONED CLOSE-WAIT connections and never a live stream. Capped at
2585
+ # GENERATION_TIMEOUT as a backstop.
2586
+ deadline = time.time() + max(60.0, PROXY_GENERATION_TIMEOUT)
2587
+ while time.time() < deadline and getattr(old_client, "_uap_inflight", 0) > 0:
2588
+ await asyncio.sleep(1.0)
2589
+ try:
2590
+ await old_client.aclose()
2591
+ except Exception:
2592
+ pass
2593
+
2594
+ asyncio.ensure_future(_retire())
2595
+ return True
2389
2596
 
2390
2597
  # ---------------------------------------------------------------------------
2391
2598
  # Concurrency Control
@@ -2870,7 +3077,11 @@ async def _post_with_retry_inner(
2870
3077
  last_exc: Exception | None = None
2871
3078
  for attempt in range(PROXY_UPSTREAM_RETRY_MAX):
2872
3079
  try:
2873
- resp = await client.post(url, json=payload, headers=headers)
3080
+ _inflight_inc(client)
3081
+ try:
3082
+ resp = await client.post(url, json=payload, headers=headers)
3083
+ finally:
3084
+ _inflight_dec(client)
2874
3085
  # Cycle 19 Option 1: if 503 "Loading model", wait for health then retry
2875
3086
  if _is_loading_model_503(resp):
2876
3087
  logger.warning(
@@ -2883,11 +3094,11 @@ async def _post_with_retry_inner(
2883
3094
  continue # retry the request now that upstream is healthy
2884
3095
  return resp # return the 503 if health wait timed out
2885
3096
  return resp
2886
- except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
3097
+ except _UPSTREAM_RETRY_EXCEPTIONS as exc:
2887
3098
  last_exc = exc
2888
3099
  if attempt < PROXY_UPSTREAM_RETRY_MAX - 1:
2889
3100
  logger.warning(
2890
- "Upstream connect failed (attempt %d/%d): %s – retrying in %.0fs",
3101
+ "Upstream transient error (attempt %d/%d): %s – retrying in %.0fs",
2891
3102
  attempt + 1,
2892
3103
  PROXY_UPSTREAM_RETRY_MAX,
2893
3104
  type(exc).__name__,
@@ -2996,19 +3207,7 @@ async def lifespan(app: FastAPI):
2996
3207
  PROXY_CONCURRENCY_QUEUE_TIMEOUT,
2997
3208
  )
2998
3209
  _prepare_slot_save_dir()
2999
- http_client = httpx.AsyncClient(
3000
- timeout=httpx.Timeout(
3001
- connect=10.0, # 10s to establish connection
3002
- read=PROXY_READ_TIMEOUT, # configurable (default 10 min)
3003
- write=30.0, # 30s to send the request body
3004
- pool=10.0, # 10s to acquire a pool connection
3005
- ),
3006
- limits=httpx.Limits(
3007
- max_connections=PROXY_MAX_CONNECTIONS,
3008
- max_keepalive_connections=PROXY_MAX_CONNECTIONS // 2,
3009
- keepalive_expiry=120,
3010
- ),
3011
- )
3210
+ http_client = _build_http_client()
3012
3211
  logger.info(
3013
3212
  "Proxy started: listening on %s:%d -> upstream %s",
3014
3213
  PROXY_HOST,
@@ -3016,6 +3215,8 @@ async def lifespan(app: FastAPI):
3016
3215
  LLAMA_CPP_BASE,
3017
3216
  )
3018
3217
 
3218
+ _reaper_task = asyncio.ensure_future(_closewait_reaper())
3219
+
3019
3220
  # Auto-detect context window from upstream server
3020
3221
  default_context_window = await detect_context_window(http_client)
3021
3222
  for mon in session_monitors.values():
@@ -3082,6 +3283,7 @@ async def lifespan(app: FastAPI):
3082
3283
  )
3083
3284
 
3084
3285
  yield
3286
+ _reaper_task.cancel()
3085
3287
  await http_client.aclose()
3086
3288
  http_client = None
3087
3289
  if upstream_semaphore is not None:
@@ -3098,6 +3300,37 @@ app = FastAPI(
3098
3300
  lifespan=lifespan,
3099
3301
  )
3100
3302
 
3303
+ @app.exception_handler(httpx.PoolTimeout)
3304
+ async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
3305
+ """Saturated upstream pool: answer 529 overloaded (Anthropic semantics —
3306
+ clients back off with jitter instead of fast-retrying a 500 traceback,
3307
+ which amplified the storm).
3308
+
3309
+ NOTE: this deliberately does NOT swap the pool. Under a saturated upstream
3310
+ (llama at 3/3 slots) the pool-swap self-heal churned in-flight connections
3311
+ into ReadError→500 bursts (live 2026-07-12) — the same mechanism the
3312
+ reaper was disabled for. Pure backpressure (529 + a large
3313
+ PROXY_MAX_CONNECTIONS) is the graceful posture; leaked connections are
3314
+ cleared by the periodic proxy restart, not by mid-load pool churn. Set
3315
+ PROXY_POOL_SWAP_ON_SATURATION=1 to restore the swap."""
3316
+ if os.environ.get("PROXY_POOL_SWAP_ON_SATURATION") == "1":
3317
+ _maybe_reset_http_client("PoolTimeout on " + request.url.path)
3318
+ return Response(
3319
+ content=json.dumps(
3320
+ {
3321
+ "type": "error",
3322
+ "error": {
3323
+ "type": "overloaded_error",
3324
+ "message": "Upstream connection pool saturated — retry shortly.",
3325
+ },
3326
+ }
3327
+ ),
3328
+ status_code=529,
3329
+ media_type="application/json",
3330
+ headers={"retry-after": "10"},
3331
+ )
3332
+
3333
+
3101
3334
 
3102
3335
  # Open paths that never require the shared secret (liveness / discovery), so a
3103
3336
  # LAN health check or an SDK model-list probe works without the token.
@@ -5881,6 +6114,52 @@ def _openai_has_tool_calls(openai_resp: dict) -> bool:
5881
6114
  return bool(_extract_openai_tool_calls(openai_resp))
5882
6115
 
5883
6116
 
6117
+ def _json_close_suffix(fragment: str) -> str:
6118
+ """Minimal suffix that closes a truncated JSON fragment so it parses.
6119
+
6120
+ Streamed tool-call arguments cut off by the token limit (finish_reason=
6121
+ length) used to reach the client as unterminated JSON — Claude Code then
6122
+ throws InputValidationError ("could not be parsed as JSON ... first 200 of
6123
+ N bytes") and retries into the same wall. Scanning with the same
6124
+ in-string/escape/nesting rules a JSON parser uses, this returns the
6125
+ characters needed to terminate the fragment: a visible truncation marker +
6126
+ closing quote when cut mid-string, then closers for every open object /
6127
+ array. Returns '' when the fragment already parses (or is empty).
6128
+ """
6129
+ if not fragment:
6130
+ return ""
6131
+ try:
6132
+ json.loads(fragment)
6133
+ return ""
6134
+ except Exception:
6135
+ pass
6136
+ stack: list[str] = []
6137
+ in_string = False
6138
+ escaped = False
6139
+ for ch in fragment:
6140
+ if in_string:
6141
+ if escaped:
6142
+ escaped = False
6143
+ elif ch == "\\":
6144
+ escaped = True
6145
+ elif ch == '"':
6146
+ in_string = False
6147
+ continue
6148
+ if ch == '"':
6149
+ in_string = True
6150
+ elif ch in "{[":
6151
+ stack.append("}" if ch == "{" else "]")
6152
+ elif ch in "}]" and stack:
6153
+ stack.pop()
6154
+ suffix = ""
6155
+ if in_string:
6156
+ if escaped:
6157
+ suffix += "\\" # complete the dangling escape as a literal backslash
6158
+ suffix += "\u2026[TRUNCATED BY TOKEN LIMIT]\""
6159
+ suffix += "".join(reversed(stack))
6160
+ return suffix
6161
+
6162
+
5884
6163
  def _parse_openai_function_arguments(raw_args) -> tuple[dict | None, str | None]:
5885
6164
  if isinstance(raw_args, dict):
5886
6165
  return raw_args, None
@@ -9110,8 +9389,17 @@ async def _heartbeat_then_buffered(produce_coro, model: str, input_tokens: int =
9110
9389
  except asyncio.TimeoutError:
9111
9390
  yield 'event: ping\ndata: {"type": "ping"}\n\n'
9112
9391
  except asyncio.CancelledError:
9113
- # Client disconnected — cancel the in-flight produce and propagate.
9392
+ # Client disconnected — cancel the in-flight produce AND await its
9393
+ # cleanup so httpx returns/closes the upstream connection before we
9394
+ # unwind. Without the await, the cancel is merely requested and the
9395
+ # socket strands in CLOSE-WAIT (the connection-leak → PoolTimeout 500
9396
+ # storm root cause). Shielded + time-boxed so a stuck close can't hang
9397
+ # the disconnect path.
9114
9398
  task.cancel()
9399
+ try:
9400
+ await asyncio.wait_for(asyncio.shield(task), timeout=5.0)
9401
+ except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
9402
+ pass
9115
9403
  raise
9116
9404
  except Exception as exc:
9117
9405
  logger.error("heartbeat produce failed: %s", exc)
@@ -9232,6 +9520,8 @@ async def stream_anthropic_response(
9232
9520
  - Graceful error recovery on upstream connection drops
9233
9521
  - Proper upstream response closure on client disconnect
9234
9522
  """
9523
+ _sr_client = getattr(openai_stream, "_uap_client", None)
9524
+ _inflight_inc(_sr_client) if _sr_client is not None else None
9235
9525
  msg_id = f"msg_{uuid.uuid4().hex[:24]}"
9236
9526
  stream_started = time.monotonic()
9237
9527
 
@@ -9375,14 +9665,36 @@ async def stream_anthropic_response(
9375
9665
  logger.error("Unexpected stream error: %s: %s", type(exc).__name__, exc)
9376
9666
  finish_reason = "end_turn"
9377
9667
  finally:
9378
- # Always close the upstream response to stop LLM generation
9379
- await openai_stream.aclose()
9668
+ if _sr_client is not None:
9669
+ _inflight_dec(_sr_client)
9670
+ # Detached close: a bare 'await aclose()' here is itself cancellable
9671
+ # when the client disconnected (the common case), leaving the upstream
9672
+ # connection un-closed → CLOSE-WAIT leak. Detaching guarantees it runs.
9673
+ _detach_aclose(openai_stream)
9380
9674
 
9381
9675
  # Close any open tool call blocks (skip if XML recovery already emitted them)
9382
9676
  xml_recovered = tool_calls_by_index.pop("_xml_recovered", False)
9383
9677
  if tool_calls_by_index and not xml_recovered:
9384
9678
  for tc in tool_calls_by_index.values():
9385
9679
  if isinstance(tc, dict) and "block_index" in tc:
9680
+ # Truncation repair: if the accumulated arguments do not parse
9681
+ # (token-limit cut mid-JSON), stream one final fragment that
9682
+ # closes the JSON validly — with a visible marker when cut
9683
+ # inside a string — so the client can always parse the block.
9684
+ # stop_reason still reports max_tokens; the marker makes the
9685
+ # truncation visible in whatever the tool writes.
9686
+ repair = _json_close_suffix(tc.get("arguments", ""))
9687
+ if repair:
9688
+ tc["arguments"] = tc.get("arguments", "") + repair
9689
+ logger.warning(
9690
+ "STREAM TOOL-CALL REPAIR: closed truncated '%s' arguments (+%d chars) — client would otherwise fail to parse",
9691
+ tc.get("name", "?"),
9692
+ len(repair),
9693
+ )
9694
+ yield (
9695
+ f"event: content_block_delta\n"
9696
+ f"data: {json.dumps({'type': 'content_block_delta', 'index': tc['block_index'], 'delta': {'type': 'input_json_delta', 'partial_json': repair}})}\n\n"
9697
+ )
9386
9698
  yield (
9387
9699
  f"event: content_block_stop\n"
9388
9700
  f"data: {json.dumps({'type': 'content_block_stop', 'index': tc['block_index']})}\n\n"
@@ -9633,9 +9945,16 @@ def _build_passthrough_headers(request: Request) -> dict | None:
9633
9945
 
9634
9946
 
9635
9947
  async def _stream_passthrough(resp: httpx.Response):
9636
- async for chunk in resp.aiter_bytes():
9637
- yield chunk
9638
- await resp.aclose()
9948
+ _pt_client = getattr(resp, "_uap_client", None)
9949
+ if _pt_client is not None:
9950
+ _inflight_inc(_pt_client)
9951
+ try:
9952
+ async for chunk in resp.aiter_bytes():
9953
+ yield chunk
9954
+ finally:
9955
+ if _pt_client is not None:
9956
+ _inflight_dec(_pt_client)
9957
+ _detach_aclose(resp)
9639
9958
 
9640
9959
 
9641
9960
  async def _passthrough_anthropic_request(
@@ -9676,6 +9995,7 @@ async def _passthrough_anthropic_request(
9676
9995
  resp = await client.send(
9677
9996
  client.build_request("POST", url, json=body, headers=headers, timeout=pt_timeout)
9678
9997
  )
9998
+ setattr(resp, "_uap_client", client)
9679
9999
  if resp.status_code != 200:
9680
10000
  return Response(
9681
10001
  content=resp.text,
@@ -10235,12 +10555,14 @@ async def messages(request: Request):
10235
10555
  # Without tools (finalize): retry with capped max_tokens for clean text.
10236
10556
  has_tools = bool(strict_body.get("tools"))
10237
10557
  retry_body = dict(strict_body)
10238
- retry_body["max_tokens"] = 2048
10558
+ # Tool retries need room for Qwen's mandatory thinking PLUS
10559
+ # complete tool-call arguments — 2048 produced truncated JSON.
10560
+ retry_body["max_tokens"] = 8192 if has_tools else 2048
10239
10561
  retry_body["temperature"] = 0.1
10240
10562
  retry_body["stream"] = False
10241
10563
  if has_tools:
10242
10564
  retry_body["tool_choice"] = "required"
10243
- logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=2048")
10565
+ logger.warning("DEGENERATE RETRY: retrying with tool_choice=required max_tokens=8192")
10244
10566
  else:
10245
10567
  logger.warning("DEGENERATE RETRY: retrying text-only with max_tokens=2048 temp=0.1")
10246
10568
  try:
@@ -10353,10 +10675,11 @@ async def messages(request: Request):
10353
10675
  ),
10354
10676
  stream=True,
10355
10677
  )
10678
+ setattr(resp, "_uap_client", client)
10356
10679
  # Connection succeeded – break out of retry loop
10357
10680
  last_exc = None
10358
10681
  break
10359
- except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.ReadTimeout) as exc:
10682
+ except _UPSTREAM_RETRY_EXCEPTIONS as exc:
10360
10683
  last_exc = exc
10361
10684
  if attempt < MAX_UPSTREAM_RETRIES - 1:
10362
10685
  logger.warning(
@@ -10427,6 +10750,7 @@ async def messages(request: Request):
10427
10750
  ),
10428
10751
  stream=True,
10429
10752
  )
10753
+ setattr(resp, "_uap_client", client)
10430
10754
  if resp.status_code == 200:
10431
10755
  return StreamingResponse(
10432
10756
  stream_anthropic_response(resp, model, monitor, body),
@@ -10451,6 +10775,7 @@ async def messages(request: Request):
10451
10775
  ),
10452
10776
  stream=True,
10453
10777
  )
10778
+ setattr(resp, "_uap_client", client)
10454
10779
  if resp.status_code == 200:
10455
10780
  return StreamingResponse(
10456
10781
  stream_anthropic_response(resp, model, monitor, body),
@@ -10689,10 +11014,11 @@ async def messages(request: Request):
10689
11014
  openai_resp, was_degenerate = _detect_and_truncate_degenerate_repetition(openai_resp)
10690
11015
  # Degenerate retry for non-guarded stream path
10691
11016
  if was_degenerate and openai_body.get("tools"):
10692
- logger.warning("DEGENERATE RETRY (stream): retrying with tool_choice=required max_tokens=2048")
11017
+ logger.warning("DEGENERATE RETRY (stream): retrying with tool_choice=required max_tokens=8192")
10693
11018
  retry_body = dict(openai_body)
10694
11019
  retry_body["tool_choice"] = "required"
10695
- retry_body["max_tokens"] = 2048
11020
+ # See non-stream site: 2048 starves thinking + tool args.
11021
+ retry_body["max_tokens"] = 8192
10696
11022
  retry_body["temperature"] = 0.1
10697
11023
  retry_body["stream"] = False
10698
11024
  try:
@@ -239,6 +239,54 @@ def load_reactor_signal(prompt_text, signal_dir=None, ttl=180.0, now=None):
239
239
  return None
240
240
 
241
241
 
242
+ # ---- cross-process: consume the LLM-Self-Tuning real-time adaptor (P4) --------
243
+ # The offline tuner finds a good STATIC config; the real-time adaptor writes a
244
+ # per-session adjustment (escalate / recipe / recon threshold / force-synthesis)
245
+ # from LIVE signals it observes out-of-band (tool-failure rate, per-turn quality,
246
+ # context pressure). The proxy freezes PROXY_* at startup and has no reload
247
+ # endpoint, so — exactly like the recipe signal — this is a file-signal read per
248
+ # request. OPT-IN via PROXY_REALTIME_ADAPT so default behavior is unchanged.
249
+ def _adaptation_signal_dir() -> str:
250
+ return os.environ.get("UAP_ADAPTATION_SIGNAL_DIR") or os.path.join(
251
+ os.path.expanduser("~"), ".cache", "uap", "adaptation-signals"
252
+ )
253
+
254
+
255
+ def realtime_adapt_enabled() -> bool:
256
+ # Auto-on: honor a fresh adaptation signal by default. Opt out with
257
+ # PROXY_REALTIME_ADAPT=0/false/off. (The emitter is the real master switch —
258
+ # with it off, no signal exists to honor.)
259
+ return str(os.environ.get("PROXY_REALTIME_ADAPT", "")).strip().lower() not in (
260
+ "0",
261
+ "false",
262
+ "off",
263
+ "no",
264
+ )
265
+
266
+
267
+ def load_adaptation_signal(session_id=None, signal_dir=None, ttl=180.0, now=None):
268
+ """Return the fresh real-time adaptation signal dict, or None. Per-session
269
+ file first when session_id is given, then a rolling latest.json (single-
270
+ session common case). Fresh within ttl seconds. Never raises."""
271
+ d = signal_dir or _adaptation_signal_dir()
272
+ now = time.time() if now is None else now
273
+ candidates = []
274
+ if session_id:
275
+ safe = "".join(c if (c.isalnum() or c in "_-") else "_" for c in str(session_id)) or "session"
276
+ candidates.append(os.path.join(d, safe + ".json"))
277
+ candidates.append(os.path.join(d, "latest.json"))
278
+ for path in candidates:
279
+ try:
280
+ if os.path.exists(path):
281
+ with open(path) as f:
282
+ sig = json.load(f)
283
+ if now - float(sig.get("ts", 0)) <= ttl:
284
+ return sig
285
+ except Exception:
286
+ continue
287
+ return None
288
+
289
+
242
290
  def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) -> str:
243
291
  if not settings.enabled or has_tools:
244
292
  return "single"
@@ -261,10 +309,18 @@ def select_recipe(anthropic_body: dict, settings: Settings, has_tools: bool) ->
261
309
  complexity = query_complexity(text)
262
310
  shape = task_shape(text)
263
311
  if settings.judge_available(pm) and (complexity == "complex" or shape == "reasoning"):
264
- return "fusion"
265
- if settings.judge_available(pm) and len(text) >= settings.auto_fusion_chars:
266
- return "fusion"
267
- return "confidence"
312
+ chosen = "fusion"
313
+ elif settings.judge_available(pm) and len(text) >= settings.auto_fusion_chars:
314
+ chosen = "fusion"
315
+ else:
316
+ chosen = "confidence"
317
+ # Real-time adaptation (opt-in): a fresh per-session signal may escalate this
318
+ # turn to the judge (e.g. tool-failure spike or a quality dip observed live).
319
+ if realtime_adapt_enabled() and settings.judge_available(pm):
320
+ asig = load_adaptation_signal()
321
+ if asig and (asig.get("escalate") or asig.get("recipe") == "fusion"):
322
+ chosen = "fusion"
323
+ return chosen
268
324
 
269
325
 
270
326
  # ---- #1/#5 confidence signal ----------------------------------------------