@miller-tech/uap 1.76.3 → 1.76.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.76.3",
3
+ "version": "1.76.4",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1851,6 +1851,50 @@ PROXY_CONCURRENCY_QUEUE_TIMEOUT = float(
1851
1851
  upstream_semaphore: asyncio.Semaphore | None = None
1852
1852
 
1853
1853
 
1854
+ # ---------------------------------------------------------------------------
1855
+ # Session admission control — cap the number of DISTINCT "hot" sessions
1856
+ # ---------------------------------------------------------------------------
1857
+ # The semaphore above limits CONCURRENT REQUESTS. On a multi-slot llama.cpp
1858
+ # (--parallel N) llama's native prompt-cache keeps each session on its own slot
1859
+ # across turns — but only N slots exist. When MORE than N distinct sessions are
1860
+ # active, a returning session finds its slot reassigned to another session, its
1861
+ # KV gone, forcing a from-scratch reprocess of the whole context (brutal on an
1862
+ # SSM/Mamba model, which cannot restore partial KV: every eviction is a full
1863
+ # prefill). The per-request semaphore can't prevent this — it lets session A
1864
+ # finish + release, then admits session E, which evicts A's slot.
1865
+ #
1866
+ # Session admission caps the number of DISTINCT sessions holding a slot at once.
1867
+ # A new session over the limit WAITS (queues) until an admitted session goes
1868
+ # idle (no request for IDLE_TTL) and is pruned, instead of barging in and
1869
+ # evicting a hot session. Admission is STICKY across a session's turns; it is
1870
+ # released only by idle-TTL expiry (or, under sustained over-subscription, a
1871
+ # wait-timeout graceful-degrade that force-admits, evicting the LRU). Default
1872
+ # OFF — opt in via PROXY_SESSION_ADMISSION=on (set the LIMIT to the llama slot
1873
+ # count / --parallel value).
1874
+ PROXY_SESSION_ADMISSION = os.environ.get(
1875
+ "PROXY_SESSION_ADMISSION", "off"
1876
+ ).lower() not in {"", "0", "off", "false", "no"}
1877
+ # Max distinct hot sessions. Default = the concurrency limit (= llama slots).
1878
+ PROXY_SESSION_ADMISSION_LIMIT = int(
1879
+ os.environ.get("PROXY_SESSION_ADMISSION_LIMIT", str(PROXY_CONCURRENCY_LIMIT))
1880
+ )
1881
+ # Seconds a session may be idle (no request) before its admission is pruned,
1882
+ # freeing a slot for a waiting session. Tune ABOVE typical inter-turn gaps so an
1883
+ # actively-working session is never pruned mid-task. 0 = never prune on idle.
1884
+ PROXY_SESSION_ADMISSION_IDLE_TTL = float(
1885
+ os.environ.get("PROXY_SESSION_ADMISSION_IDLE_TTL", "90")
1886
+ )
1887
+ # Max seconds a new session waits for admission before graceful-degrade
1888
+ # (force-admit, evicting the LRU admitted session). 0 = wait indefinitely.
1889
+ PROXY_SESSION_ADMISSION_WAIT_TIMEOUT = float(
1890
+ os.environ.get("PROXY_SESSION_ADMISSION_WAIT_TIMEOUT", "300")
1891
+ )
1892
+ # How often a waiter re-checks for a freed slot (re-prunes idle admissions).
1893
+ PROXY_SESSION_ADMISSION_POLL = float(
1894
+ os.environ.get("PROXY_SESSION_ADMISSION_POLL", "3")
1895
+ )
1896
+
1897
+
1854
1898
  # ---------------------------------------------------------------------------
1855
1899
  # Slot save/restore — cross-session KV-cache preservation
1856
1900
  # ---------------------------------------------------------------------------
@@ -1902,6 +1946,12 @@ _current_request_session: contextvars.ContextVar[str | None] = contextvars.Conte
1902
1946
  "uap_current_request_session", default=None
1903
1947
  )
1904
1948
 
1949
+ # Session admission state. _admitted_sessions maps session_id -> last-seen
1950
+ # monotonic ts; OrderedDict insertion order is the LRU (oldest = front).
1951
+ # Guarded by _admission_cond's lock (created lazily on the running event loop).
1952
+ _admitted_sessions: "OrderedDict[str, float]" = OrderedDict()
1953
+ _admission_cond: "asyncio.Condition | None" = None
1954
+
1905
1955
 
1906
1956
  def _slot_endpoint_base() -> str:
1907
1957
  """Base URL for llama-server's /slots endpoint (LLAMA_CPP_BASE without /v1)."""
@@ -2052,6 +2102,93 @@ def _prepare_slot_save_dir() -> None:
2052
2102
  logger.warning("SLOT SAVE/RESTORE: startup dir prep failed: %s", exc)
2053
2103
 
2054
2104
 
2105
+ def _prune_idle_admissions(now: float) -> list[str]:
2106
+ """Remove admitted sessions idle longer than the TTL; return their ids.
2107
+ Caller must hold the admission lock. 0 TTL disables idle pruning."""
2108
+ ttl = PROXY_SESSION_ADMISSION_IDLE_TTL
2109
+ if ttl <= 0:
2110
+ return []
2111
+ stale = [sid for sid, ts in _admitted_sessions.items() if now - ts > ttl]
2112
+ for sid in stale:
2113
+ _admitted_sessions.pop(sid, None)
2114
+ return stale
2115
+
2116
+
2117
+ def _try_admit_session(session_id: str, now: float) -> bool:
2118
+ """Synchronous admission core (caller holds the admission lock). Returns
2119
+ True if the session is admitted — already hot (refreshed) or newly admitted
2120
+ into a free slot — False if the hot set is full. Pure/deterministic given
2121
+ `now`, so it is unit-tested directly."""
2122
+ _prune_idle_admissions(now)
2123
+ if session_id in _admitted_sessions:
2124
+ _admitted_sessions[session_id] = now
2125
+ _admitted_sessions.move_to_end(session_id)
2126
+ return True
2127
+ if len(_admitted_sessions) < PROXY_SESSION_ADMISSION_LIMIT:
2128
+ _admitted_sessions[session_id] = now
2129
+ _admitted_sessions.move_to_end(session_id)
2130
+ return True
2131
+ return False
2132
+
2133
+
2134
+ async def _ensure_session_admitted(session_id: str | None) -> None:
2135
+ """Block until `session_id` is admitted to the hot set (size <= LIMIT).
2136
+
2137
+ Sticky: admission persists across a session's turns and is released only by
2138
+ idle-TTL pruning. A new session over the limit queues (waits) rather than
2139
+ evicting a hot session. On wait-timeout it force-admits, evicting the LRU,
2140
+ to avoid a hard stall (graceful degrade to pre-admission behaviour).
2141
+ No-op when disabled or when there is no session id."""
2142
+ global _admission_cond
2143
+ if not PROXY_SESSION_ADMISSION or not session_id:
2144
+ return
2145
+ if _admission_cond is None:
2146
+ _admission_cond = asyncio.Condition()
2147
+ cond = _admission_cond
2148
+ deadline = (
2149
+ time.monotonic() + PROXY_SESSION_ADMISSION_WAIT_TIMEOUT
2150
+ if PROXY_SESSION_ADMISSION_WAIT_TIMEOUT > 0
2151
+ else None
2152
+ )
2153
+ waited = False
2154
+ async with cond:
2155
+ while True:
2156
+ now = time.monotonic()
2157
+ if _try_admit_session(session_id, now):
2158
+ if waited:
2159
+ logger.info(
2160
+ "SESSION ADMISSION: admitted %s after wait (%d/%d hot)",
2161
+ session_id[:12], len(_admitted_sessions),
2162
+ PROXY_SESSION_ADMISSION_LIMIT,
2163
+ )
2164
+ # A refresh/new admit may have pruned idle sessions; wake peers.
2165
+ cond.notify_all()
2166
+ return
2167
+ # Hot set full and this session isn't in it.
2168
+ if deadline is not None and now >= deadline:
2169
+ evicted = "-"
2170
+ if _admitted_sessions:
2171
+ evicted = next(iter(_admitted_sessions)) # LRU = oldest front
2172
+ _admitted_sessions.pop(evicted, None)
2173
+ _admitted_sessions[session_id] = now
2174
+ logger.warning(
2175
+ "SESSION ADMISSION: wait timeout (%ds), force-admitted %s "
2176
+ "(evicted LRU %s) — sustained over-subscription (>%d hot sessions)",
2177
+ int(PROXY_SESSION_ADMISSION_WAIT_TIMEOUT), session_id[:12],
2178
+ evicted[:12], PROXY_SESSION_ADMISSION_LIMIT,
2179
+ )
2180
+ cond.notify_all()
2181
+ return
2182
+ waited = True
2183
+ timeout = PROXY_SESSION_ADMISSION_POLL
2184
+ if deadline is not None:
2185
+ timeout = max(0.0, min(timeout, deadline - now))
2186
+ try:
2187
+ await asyncio.wait_for(cond.wait(), timeout=timeout)
2188
+ except asyncio.TimeoutError:
2189
+ pass # re-loop: re-prune idle admissions, retry
2190
+
2191
+
2055
2192
  async def _acquire_upstream_slot() -> bool:
2056
2193
  """Acquire a semaphore slot for an upstream request.
2057
2194
 
@@ -2136,6 +2273,11 @@ async def _post_with_retry(
2136
2273
  rather than all hammering llama.cpp at once. Slot is released in a
2137
2274
  finally block so it's always returned to the pool even on error.
2138
2275
  """
2276
+ # Session admission: cap DISTINCT hot sessions to <= the slot count so they
2277
+ # don't evict each other's KV (sticky, released by idle-TTL). No-op when
2278
+ # PROXY_SESSION_ADMISSION is off. Runs BEFORE the per-request semaphore so a
2279
+ # queued new session waits without holding a concurrency slot.
2280
+ await _ensure_session_admitted(_current_request_session.get())
2139
2281
  acquired = await _acquire_upstream_slot()
2140
2282
  if not acquired:
2141
2283
  logger.warning(
@@ -2272,7 +2414,19 @@ async def lifespan(app: FastAPI):
2272
2414
  global http_client
2273
2415
  global default_context_window
2274
2416
  global upstream_semaphore
2417
+ global _admission_cond
2275
2418
  upstream_semaphore = asyncio.Semaphore(PROXY_CONCURRENCY_LIMIT)
2419
+ # Bind the admission condition to THIS event loop and clear stale state.
2420
+ _admission_cond = asyncio.Condition()
2421
+ _admitted_sessions.clear()
2422
+ if PROXY_SESSION_ADMISSION:
2423
+ logger.info(
2424
+ "SESSION ADMISSION: on (limit=%d hot sessions, idle_ttl=%.0fs, "
2425
+ "wait_timeout=%.0fs)",
2426
+ PROXY_SESSION_ADMISSION_LIMIT,
2427
+ PROXY_SESSION_ADMISSION_IDLE_TTL,
2428
+ PROXY_SESSION_ADMISSION_WAIT_TIMEOUT,
2429
+ )
2276
2430
  logger.info(
2277
2431
  "CONCURRENCY: upstream semaphore initialized limit=%d queue_timeout=%.0fs",
2278
2432
  PROXY_CONCURRENCY_LIMIT,
@@ -2369,6 +2523,8 @@ async def lifespan(app: FastAPI):
2369
2523
  http_client = None
2370
2524
  if upstream_semaphore is not None:
2371
2525
  upstream_semaphore = None
2526
+ _admission_cond = None
2527
+ _admitted_sessions.clear()
2372
2528
  logger.info("Proxy shut down")
2373
2529
 
2374
2530
 
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ """Session-admission control: cap the number of DISTINCT hot sessions to <= the
3
+ slot count so they don't evict each other's KV.
4
+
5
+ The per-request semaphore limits CONCURRENT REQUESTS; admission limits DISTINCT
6
+ SESSIONS. A new session over the limit queues until an admitted session goes
7
+ idle (TTL) and is pruned, instead of barging in and evicting a hot session
8
+ (every eviction = a full reprocess on the SSM model). Admission is sticky across
9
+ a session's turns; on a wait-timeout it force-admits (graceful degrade).
10
+
11
+ The synchronous core `_try_admit_session` / `_prune_idle_admissions` is pure
12
+ given `now`, so it's tested directly. The async wrapper is tested for the
13
+ no-op-when-disabled and force-admit-on-timeout paths.
14
+ """
15
+
16
+ import asyncio
17
+ import importlib.util
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+
22
+ def _load():
23
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
24
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
25
+ m = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(m)
27
+ return m
28
+
29
+
30
+ proxy = _load()
31
+
32
+
33
+ class TestAdmissionCore(unittest.TestCase):
34
+ def setUp(self):
35
+ proxy._admitted_sessions.clear()
36
+ proxy.PROXY_SESSION_ADMISSION_LIMIT = 2
37
+ proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 90.0
38
+
39
+ def test_admit_up_to_limit_then_full(self):
40
+ self.assertTrue(proxy._try_admit_session("s1", now=0))
41
+ self.assertTrue(proxy._try_admit_session("s2", now=1))
42
+ # 3rd distinct session: hot set full -> rejected
43
+ self.assertFalse(proxy._try_admit_session("s3", now=2))
44
+ self.assertEqual(set(proxy._admitted_sessions), {"s1", "s2"})
45
+
46
+ def test_already_hot_session_refreshes_and_is_admitted(self):
47
+ proxy._try_admit_session("s1", now=0)
48
+ proxy._try_admit_session("s2", now=1)
49
+ # s1 returns — must stay admitted (no eviction of itself) and refresh ts
50
+ self.assertTrue(proxy._try_admit_session("s1", now=50))
51
+ self.assertEqual(proxy._admitted_sessions["s1"], 50)
52
+
53
+ def test_idle_session_pruned_after_ttl_frees_a_slot(self):
54
+ proxy._try_admit_session("s1", now=0)
55
+ proxy._try_admit_session("s2", now=1)
56
+ self.assertFalse(proxy._try_admit_session("s3", now=2)) # full
57
+ # advance past TTL: s1 (ts0) and s2 (ts1) are now idle > 90s -> pruned,
58
+ # s3 admits into the freed slot
59
+ self.assertTrue(proxy._try_admit_session("s3", now=200))
60
+ self.assertIn("s3", proxy._admitted_sessions)
61
+ self.assertNotIn("s1", proxy._admitted_sessions)
62
+
63
+ def test_zero_ttl_disables_idle_pruning(self):
64
+ proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 0.0
65
+ proxy._try_admit_session("s1", now=0)
66
+ proxy._try_admit_session("s2", now=1)
67
+ # even far in the future, no idle prune -> s3 stays blocked
68
+ self.assertFalse(proxy._try_admit_session("s3", now=10_000))
69
+
70
+ def test_lru_is_front_of_ordereddict(self):
71
+ proxy._try_admit_session("s1", now=0)
72
+ proxy._try_admit_session("s2", now=1)
73
+ proxy._try_admit_session("s1", now=5) # refresh moves s1 to back
74
+ # s2 is now the LRU (front) -> first to be force-evicted
75
+ self.assertEqual(next(iter(proxy._admitted_sessions)), "s2")
76
+
77
+
78
+ class TestAdmissionAsync(unittest.TestCase):
79
+ def setUp(self):
80
+ proxy._admitted_sessions.clear()
81
+ proxy._admission_cond = None
82
+
83
+ def test_noop_when_disabled(self):
84
+ proxy.PROXY_SESSION_ADMISSION = False
85
+ asyncio.run(proxy._ensure_session_admitted("anything"))
86
+ self.assertEqual(len(proxy._admitted_sessions), 0) # never tracked
87
+
88
+ def test_noop_when_no_session_id(self):
89
+ proxy.PROXY_SESSION_ADMISSION = True
90
+ asyncio.run(proxy._ensure_session_admitted(None))
91
+ self.assertEqual(len(proxy._admitted_sessions), 0)
92
+
93
+ def test_admits_when_room(self):
94
+ proxy.PROXY_SESSION_ADMISSION = True
95
+ proxy.PROXY_SESSION_ADMISSION_LIMIT = 2
96
+ asyncio.run(proxy._ensure_session_admitted("s1"))
97
+ self.assertIn("s1", proxy._admitted_sessions)
98
+
99
+ def test_force_admit_on_wait_timeout(self):
100
+ # Over-subscribed + no idle prune + ~0 wait timeout -> force-admit,
101
+ # evicting the LRU (graceful degrade, never hard-stalls).
102
+ proxy.PROXY_SESSION_ADMISSION = True
103
+ proxy.PROXY_SESSION_ADMISSION_LIMIT = 1
104
+ proxy.PROXY_SESSION_ADMISSION_IDLE_TTL = 0.0 # never prune
105
+ proxy.PROXY_SESSION_ADMISSION_WAIT_TIMEOUT = 0.01
106
+ proxy.PROXY_SESSION_ADMISSION_POLL = 0.01
107
+
108
+ async def run():
109
+ await proxy._ensure_session_admitted("hot") # fills the 1 slot
110
+ await proxy._ensure_session_admitted("waiter") # times out -> force
111
+ asyncio.run(run())
112
+ self.assertIn("waiter", proxy._admitted_sessions)
113
+ self.assertNotIn("hot", proxy._admitted_sessions) # LRU evicted
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()