Skip to content

Commit 9079102

Browse files
authored
Merge pull request #36 from FluffyAIcode/AgentMemory/v030-pr7-6-bench-long-session-v2-8e7f
PR 7-6 (ADR 0007): bench_long_session scrapes path-selection metrics
2 parents 9eb0036 + 0a31ee9 commit 9079102

1 file changed

Lines changed: 202 additions & 31 deletions

File tree

scripts/bench_agentic/bench_long_session.py

Lines changed: 202 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,48 @@
3636
session can run for hours without OOM. A single agent driven for
3737
4 hours is therefore the cleanest evidence.
3838
39-
A note on what this bench measures and what it doesn't (per the
40-
analysis of the 2026-05-30 Mac M4 run, ``bench_long_session_mac_
41-
1780130542.aborted.json``):
42-
43-
* **KV memory** stays bounded across hours (the §2.3 claim). The
44-
``scheduler_kv_live_bytes`` gauge is what proves it.
45-
* **Per-turn latency** does NOT stay bounded. The OpenAI
46-
chat-completions protocol is stateless: every turn the client
47-
sends the full history, the server tokenizes it from scratch
48-
and the verifier prefills the entire prompt, so prefill cost
49-
grows linearly with history length. Sink+window only bounds
50-
*generation-phase* memory, not prefill cost. A 30-min run on
51-
Mac M4 showed p50 turn latency growing from ~15 s to ~55 s as
52-
history grew from ~50 to ~3700 tokens. This is a **protocol-
53-
level limitation**, not a memory-stability failure. Cross-
54-
request KV reuse (a v0.4 feature) is the eventual fix; until
55-
then, agent applications should manage prompt length via
56-
summarization or sliding windows.
57-
58-
The bench reports both metrics independently — KV bounded check is
59-
a hard claim, latency drift is a measurement, not a gate.
39+
What changed in PR 7-6 (ADR 0007 cross-request KV reuse)
40+
--------------------------------------------------------
41+
42+
Before ADR 0007 (the 2026-05-30 4h Mac M4 run produced 58 useful
43+
turns then 3.5h of timeout/recovery): the bench was driving the
44+
v0.3.0-rc1 server, which reset the verifier cache on every
45+
chat-completions request. Per-turn prefill cost grew O(history),
46+
exceeded the 120s client timeout around turn 58, and the run
47+
degenerated.
48+
49+
After ADR 0007 (PRs 7-1 to 7-5): the server now takes the
50+
continuation path on prompts that extend the cached state. Per-
51+
turn prefill cost is O(new_user_message) instead of O(history).
52+
The 4h re-run with the same workload should now produce
53+
hundreds of useful turns and never enter the timeout/recovery
54+
regime.
55+
56+
This bench scrapes the new ADR §2.10 metrics on every turn:
57+
58+
* ``path_selection_total{path=continuation}`` Counter
59+
* ``path_selection_total{path=new_session}`` Counter
60+
* ``continuation_tokens_skipped_total`` Counter
61+
* ``cache_invariant_violations_total{kind=...}`` Counter (must be 0)
62+
63+
The aggregate report now includes an ``adr_0007`` block with:
64+
65+
continuation_decisions int (count this run)
66+
new_session_decisions int
67+
total_decisions int
68+
continuation_rate float in [0, 1]
69+
tokens_skipped int (total prefill tokens reused)
70+
cache_invariant_inv1_total int (must be 0)
71+
cache_invariant_inv2_total int (must be 0)
72+
73+
GA gate criteria (per ADR 0007 §6, applied to the 4h Mac M4 re-run):
74+
75+
1. ``agg.kv_bounded`` is True (memory bound holds across 4h)
76+
2. ``agg.n_errors`` < 5 (no sustained timeout/recovery loop)
77+
3. ``agg.n_turns`` >= 200 (vs 58 in v0.3.0-rc1)
78+
4. ``agg.latency_drift_p50_s`` <= 5 seconds (vs +39.74s in v0.3.0-rc1)
79+
5. ``adr_0007.continuation_rate`` >= 0.95
80+
6. ``adr_0007.cache_invariant_inv1_total + inv2_total`` == 0
6081
6182
Usage
6283
-----
@@ -166,6 +187,20 @@ def _client_rss_bytes() -> Optional[int]:
166187
"scheduler_kv_live_bytes",
167188
)
168189

190+
# ADR 0007 §2.10 path-selection metrics — labeled, parsed separately
191+
# from the unlabeled scheduler gauges. continuation_rate is the
192+
# headline KPI for the cross-request KV reuse fix: ≥95% on a healthy
193+
# long-session agent run.
194+
_PATH_SELECTION_METRIC = "path_selection_total"
195+
_CONTINUATION_TOKENS_SKIPPED_METRIC = "continuation_tokens_skipped_total"
196+
_CACHE_INVARIANT_VIOLATIONS_METRIC = "cache_invariant_violations_total"
197+
198+
# Match a Prometheus exposition line with labels: e.g.
199+
# `path_selection_total{path="continuation"} 5.0`
200+
_LABELED_METRIC_LINE = re.compile(
201+
r'^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*)\{(?P<labels>[^}]*)\}\s+(?P<value>[-+0-9eE.\.NaNinf]+)\s*$'
202+
)
203+
169204

170205
_METRIC_LINE = re.compile(
171206
r"^(?P<name>[a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+(?P<value>[-+0-9eE.\.NaNinf]+)\s*$"
@@ -175,28 +210,75 @@ def _client_rss_bytes() -> Optional[int]:
175210
def _parse_prom_text(body: str) -> dict[str, float]:
176211
"""Tiny Prometheus text parser.
177212
178-
Only the four metrics we care about are extracted; everything
179-
else is skipped. Values are coerced to float; ``NaN`` and ``inf``
180-
are preserved as floats. We do not try to handle multi-label
181-
series — these are gauges with no labels in our exporter.
213+
Returns a flat dict where unlabeled metrics keep their bare
214+
name and labeled ADR 0007 §2.10 metrics get a synthesized name
215+
of the form ``"{base}__{label_value}"``:
216+
217+
* ``path_selection_total{path="continuation"}`` →
218+
``"path_selection_total__continuation"``
219+
* ``path_selection_total{path="new_session"}`` →
220+
``"path_selection_total__new_session"``
221+
* ``cache_invariant_violations_total{kind="inv1"}`` →
222+
``"cache_invariant_violations_total__inv1"``
223+
* ``continuation_tokens_skipped_total`` (no label) →
224+
``"continuation_tokens_skipped_total"``
225+
226+
Values are coerced to float; ``NaN`` and ``inf`` are preserved.
182227
"""
183228
out: dict[str, float] = {}
184229
for line in body.splitlines():
185230
if not line or line.startswith("#"):
186231
continue
232+
# Try labeled match first (more specific).
233+
labeled = _LABELED_METRIC_LINE.match(line)
234+
if labeled is not None:
235+
name = labeled.group("name")
236+
label_str = labeled.group("labels")
237+
try:
238+
value = float(labeled.group("value"))
239+
except ValueError: # pragma: no cover - malformed exporter
240+
continue
241+
if name == _PATH_SELECTION_METRIC:
242+
# Extract the path label (continuation|new_session)
243+
path = _extract_label(label_str, "path")
244+
if path:
245+
out[f"{name}__{path}"] = value
246+
elif name == _CACHE_INVARIANT_VIOLATIONS_METRIC:
247+
kind = _extract_label(label_str, "kind")
248+
if kind:
249+
out[f"{name}__{kind}"] = value
250+
continue
251+
# Unlabeled.
187252
m = _METRIC_LINE.match(line)
188253
if m is None:
189254
continue
190255
name = m.group("name")
191-
if name not in _METRIC_NAMES:
192-
continue
193-
try:
194-
out[name] = float(m.group("value"))
195-
except ValueError: # pragma: no cover - malformed exporter
196-
continue
256+
if name in _METRIC_NAMES or name == _CONTINUATION_TOKENS_SKIPPED_METRIC:
257+
try:
258+
out[name] = float(m.group("value"))
259+
except ValueError: # pragma: no cover - malformed exporter
260+
continue
197261
return out
198262

199263

264+
def _extract_label(label_str: str, key: str) -> Optional[str]:
265+
"""Pull a single label value out of a Prometheus label segment.
266+
267+
Input is the part between the curly braces, e.g.
268+
``path="continuation"`` or
269+
``path="continuation",result="ok"``. Returns the value (without
270+
quotes) for the requested key, or ``None`` if absent.
271+
"""
272+
for fragment in label_str.split(","):
273+
fragment = fragment.strip()
274+
if "=" not in fragment:
275+
continue
276+
k, v = fragment.split("=", 1)
277+
if k.strip() == key:
278+
return v.strip().strip('"')
279+
return None
280+
281+
200282
async def _scrape_metrics(
201283
client: httpx.AsyncClient,
202284
*,
@@ -669,6 +751,58 @@ def _aggregate(
669751
}
670752

671753

754+
def _adr_0007_summary(turns: list[dict[str, Any]]) -> dict[str, Any]:
755+
"""ADR 0007 §2.10 path-selection summary from the per-turn idle
756+
scrapes (`metrics_idle`).
757+
758+
The path_selection_total counters are CUMULATIVE across the
759+
server's lifetime, so we take the LAST observed value (= final
760+
counter at end of run) and the FIRST observed value (= counter
761+
at start of run, may be > 0 if the server already handled
762+
requests before this bench started). Difference = decisions
763+
this run made.
764+
765+
Returns a dict with:
766+
continuation_decisions int
767+
new_session_decisions int
768+
total_decisions int
769+
continuation_rate float in [0, 1] (None if 0 decisions)
770+
tokens_skipped int (delta of the counter)
771+
cache_invariant_inv1_total int (last - first; should be 0)
772+
cache_invariant_inv2_total int (last - first; should be 0)
773+
"""
774+
def _delta(name: str) -> int:
775+
first: Optional[float] = None
776+
last: Optional[float] = None
777+
for t in turns:
778+
idle = t.get("metrics_idle") or {}
779+
v = idle.get(name)
780+
if v is None:
781+
continue
782+
if first is None:
783+
first = v
784+
last = v
785+
if first is None or last is None:
786+
return 0
787+
return int(round(last - first))
788+
789+
cont = _delta(f"{_PATH_SELECTION_METRIC}__continuation")
790+
news = _delta(f"{_PATH_SELECTION_METRIC}__new_session")
791+
skipped = _delta(_CONTINUATION_TOKENS_SKIPPED_METRIC)
792+
inv1 = _delta(f"{_CACHE_INVARIANT_VIOLATIONS_METRIC}__inv1")
793+
inv2 = _delta(f"{_CACHE_INVARIANT_VIOLATIONS_METRIC}__inv2")
794+
total = cont + news
795+
return {
796+
"continuation_decisions": cont,
797+
"new_session_decisions": news,
798+
"total_decisions": total,
799+
"continuation_rate": (cont / total) if total > 0 else None,
800+
"tokens_skipped": skipped,
801+
"cache_invariant_inv1_total": inv1,
802+
"cache_invariant_inv2_total": inv2,
803+
}
804+
805+
672806
def _build_payload(
673807
*,
674808
turns: list[dict[str, Any]],
@@ -687,6 +821,7 @@ def _build_payload(
687821
"turns": turns,
688822
"errors": errors,
689823
"agg": _aggregate(turns, errors, duration_s),
824+
"adr_0007": _adr_0007_summary(turns),
690825
}
691826

692827

@@ -745,6 +880,42 @@ def _fmt_bytes(b: Optional[float]) -> str:
745880
f"{b['p95_latency_s']:>7.3f}s "
746881
f"{(mkb / (1024 * 1024)) if mkb is not None else float('nan'):>7.1f} MiB"
747882
)
883+
# ADR 0007 §2.10 path-selection block (only emitted when the
884+
# payload actually carries the data — backward-compat with old
885+
# checkpoints that pre-date PR 7-6).
886+
adr_0007 = payload.get("adr_0007")
887+
if adr_0007 is not None:
888+
rate = adr_0007.get("continuation_rate")
889+
rate_str = (
890+
f"{rate * 100:.2f}%" if rate is not None else "n/a (no decisions)"
891+
)
892+
lines.append("")
893+
lines.append(" ADR 0007 §2.10 — cross-request KV reuse")
894+
lines.append(
895+
f" continuation decisions = "
896+
f"{adr_0007['continuation_decisions']}"
897+
)
898+
lines.append(
899+
f" new-session decisions = "
900+
f"{adr_0007['new_session_decisions']}"
901+
)
902+
lines.append(f" continuation rate = {rate_str}")
903+
lines.append(
904+
f" total tokens skipped = "
905+
f"{adr_0007['tokens_skipped']:,}"
906+
)
907+
inv1 = adr_0007["cache_invariant_inv1_total"]
908+
inv2 = adr_0007["cache_invariant_inv2_total"]
909+
inv_marker = "" if (inv1 == 0 and inv2 == 0) else " ← CRITICAL"
910+
lines.append(
911+
f" INV-1 violations = {inv1}"
912+
+ (inv_marker if inv1 > 0 else "")
913+
)
914+
lines.append(
915+
f" INV-2 violations = {inv2}"
916+
+ (inv_marker if inv2 > 0 else "")
917+
)
918+
748919
lines.append("=" * 78)
749920
return "\n".join(lines)
750921

0 commit comments

Comments
 (0)