-
Notifications
You must be signed in to change notification settings - Fork 5
feat(cache): per-session cache affinity — cache_hot + route_cache + session #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| +13 −7 | docs/SIGMA-POL.md | |
| +10 −0 | llm_policy/elaborate.lua | |
| +7 −0 | llm_policy/interp.lua | |
| +2 −0 | llm_policy/sig.lua | |
| +3 −2 | llm_policy/term.lua | |
| +19 −0 | tests/golden/gen_vectors.lua | |
| +2 −0 | tests/golden/sigma_pol_v2.json | |
| +8 −0 | tests/unit/ir_elaborate.lua | |
| +13 −0 | tests/unit/ir_interp.lua | |
| +9 −0 | tests/unit/ir_term.lua |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| Feature: Agent cache affinity — cache_hot session stickiness | ||
| An agent loop re-sends a large, stable prefix every turn. To make the | ||
| provider's prompt-cache discount actually land, the router must keep the | ||
| conversation pinned to the peer that already holds that prefix hot. This proves | ||
| the session -> route_cache -> cache_hot pipeline end to end over /v1: a turn | ||
| carrying a session teaches the router which peer served it, and the next turn's | ||
| ranking gives that peer a decisive affinity bonus. Provider-health independent: | ||
| the assertions read the RANKING from x_router.decision_trace, which is present | ||
| even when execution later exhausts. | ||
|
|
||
| Background: | ||
| Given the stack is healthy | ||
| And I have a caller token | ||
|
|
||
| @p0 @api @agent @cache | ||
| Scenario: An agent's session keeps its working route cache-hot across turns | ||
| When an agent establishes session "agent-cache-1" with a free turn | ||
| Then the agent's turn routed to a concrete peer | ||
| When the agent re-ranks its turn with the same session as "hot" | ||
| And the agent re-ranks the same turn with no session as "cold" | ||
| Then the agent's route scores higher in "hot" than in "cold" | ||
|
|
||
| @p1 @api @agent @cache | ||
| Scenario: A brand-new session gets no phantom affinity | ||
| When an agent establishes session "agent-cache-2" with a free turn | ||
| And the agent re-ranks the same turn with unknown session "never-seen-zzz" as "fresh" | ||
| And the agent re-ranks the same turn with no session as "none" | ||
| Then the rankings "fresh" and "none" are identical |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,3 +214,126 @@ def step_every_has(context, path, key): | |
| @then('the response text contains "{sub}"') | ||
| def step_text_contains(context, sub): | ||
| assert sub in context.resp_text, f'response text missing {sub!r}' | ||
|
|
||
|
|
||
| # ---- agent cache-affinity steps (10_agent_cache.feature) ------------------- | ||
|
|
||
| # A simple, stable family the agent loop runs on (codex gpt-5.3-codex-spark, | ||
| # a $0 subscription route). Kept fixed and boring on purpose — the test is about | ||
| # cache stickiness, not model choice. | ||
| AGENT_FAMILY = "gpt-5.3-codex-spark" | ||
|
|
||
| # Seed policy: cheapest within the agent's family. Routes to the $0 codex peer | ||
| # when healthy; the override failplan rolls to the next peer of the same family | ||
| # on a transient flake, so the seeding turn can SUCCEED and fold route_cache. | ||
| _SEED_POLICY = [ | ||
| "policy", | ||
| ["and", ["meets_req"], ["not", ["is", "disabled"]], ["family_eq", AGENT_FAMILY]], | ||
| ["neg", ["normalize", ["field", "price_in"]]], | ||
| ["argmax"], ["id"], | ||
| ["override", ["always", {"action": "next_candidate"}], | ||
| "provider_down", {"action": "next_candidate"}], | ||
| ] | ||
|
|
||
|
|
||
| def _cache_aware_policy(family): | ||
| # cheapest WITHIN the agent's family + a decisive cache_hot affinity bonus. | ||
| # family_eq (a filter predicate) keeps the scorer running on the survivors, | ||
| # so each candidate's row carries a real score (unlike a requirements pin). | ||
| return ["policy", | ||
| ["and", ["meets_req"], ["not", ["is", "disabled"]], | ||
| ["family_eq", family]], | ||
| ["add", ["neg", ["normalize", ["field", "price_in"]]], | ||
| ["scale", 10, ["gate", ["is", "cache_hot"], ["lit", 1]]]], | ||
| ["argmax"], ["id"], ["always", {"action": "next_candidate"}]] | ||
|
|
||
|
|
||
| @when('an agent establishes session "{sid}" with a free turn') | ||
| def step_agent_seed(context, sid): | ||
| # A real agent turn on the session, on the fixed AGENT_FAMILY. Retry a | ||
| # transient provider flake (a 429) until the call SUCCEEDS — that success is | ||
| # what folds the session's hot route into route_cache. | ||
| body = {"model": "", "max_tokens": 8, "session": sid, | ||
| "messages": [{"role": "user", "content": "hi"}], | ||
| "policy_ir": _SEED_POLICY} | ||
| chosen = None | ||
| for _ in range(6): | ||
| _do(context, "POST", "/v1/chat/completions", auth="consumer", body=body) | ||
| xr = (context.json or {}).get("x_router") or {} | ||
| if context.resp.status_code == 200 and xr.get("served_model_id"): | ||
| chosen = xr | ||
| break | ||
| assert chosen, (f"the {AGENT_FAMILY} route did not succeed to seed the session " | ||
| f"in 6 tries; last status {context.resp.status_code}: " | ||
| f"{context.resp_text[:200]}") | ||
| context.agent = {"sid": sid, "provider": chosen.get("provider"), | ||
| "family": AGENT_FAMILY, | ||
| "served": chosen.get("served_model_id")} | ||
| context.ranks = {} | ||
|
|
||
|
|
||
| @then('the agent\'s turn routed to a concrete peer') | ||
| def step_agent_routed(context): | ||
| a = getattr(context, "agent", None) | ||
| assert a and a.get("served") and a.get("family"), f"no route captured: {a}" | ||
|
|
||
|
|
||
| def _rerank(context, sid): | ||
| # Re-issue the agent's turn with a cache-aware policy pinned to its family. | ||
| # We read the RANKING from x_router.decision_trace (present even if execution | ||
| # later exhausts), so the assertion is independent of provider health. | ||
| body = {"model": "", "max_tokens": 8, | ||
| "messages": [{"role": "user", "content": "hi"}], | ||
| "policy_ir": _cache_aware_policy(context.agent["family"])} | ||
| if sid is not None: | ||
| body["session"] = sid | ||
| _do(context, "POST", "/v1/chat/completions", auth="consumer", body=body) | ||
| tr = jpath(context.json or {}, "x_router.decision_trace") | ||
| ranked = tr.get("ranked") if isinstance(tr, dict) else None | ||
| assert ranked, f"no ranked candidates in trace (status {context.resp.status_code}): {context.resp_text[:200]}" | ||
| return ranked | ||
|
|
||
|
|
||
| @when('the agent re-ranks its turn with the same session as "{label}"') | ||
| def step_rerank_session(context, label): | ||
| context.ranks[label] = _rerank(context, context.agent["sid"]) | ||
|
|
||
|
|
||
| @when('the agent re-ranks the same turn with no session as "{label}"') | ||
| def step_rerank_nosession(context, label): | ||
| context.ranks[label] = _rerank(context, None) | ||
|
|
||
|
|
||
| @when('the agent re-ranks the same turn with unknown session "{sid}" as "{label}"') | ||
| def step_rerank_unknown(context, sid, label): | ||
| context.ranks[label] = _rerank(context, sid) | ||
|
|
||
|
|
||
| def _max_score(ranked): | ||
| # The /v1 decision-trace rows carry model_family + score (the candidate's | ||
| # provider/served_model_id are not echoed there), and family_eq has isolated | ||
| # the agent's family — so the family's top score is the right discriminator: | ||
| # without affinity every row is a price score in [0,1]; the cache_hot bonus | ||
| # lifts the session's route well above that, so only a real bonus makes the | ||
| # "hot" top exceed the "cold" top. | ||
| scores = [r.get("score") for r in ranked if r.get("score") is not None] | ||
| return max(scores) if scores else None | ||
|
|
||
|
|
||
| @then('the agent\'s route scores higher in "{hot}" than in "{cold}"') | ||
| def step_scores_higher(context, hot, cold): | ||
| sh = _max_score(context.ranks[hot]) | ||
| sc = _max_score(context.ranks[cold]) | ||
| assert sh is not None and sc is not None, \ | ||
| f"missing scores: {hot}={sh} {cold}={sc}" | ||
| assert sh > sc, (f"cache affinity did not lift the agent's route: " | ||
| f"top score {hot}={sh} is not > {cold}={sc} " | ||
| f"(family {context.agent['family']!r})") | ||
|
|
||
|
|
||
| @then('the rankings "{a}" and "{b}" are identical') | ||
| def step_rankings_identical(context, a, b): | ||
| def key(rows): | ||
| return [(r.get("served_model_id"), r.get("score")) for r in rows] | ||
| ka, kb = key(context.ranks[a]), key(context.ranks[b]) | ||
| assert ka == kb, f"rankings differ:\n {a}={ka}\n {b}={kb}" | ||
|
Comment on lines
+334
to
+339
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Don’t collapse ranked-row identity to The nearby comment says Fail closed when ranked rows lack route identity `@then`('the rankings "{a}" and "{b}" are identical')
def step_rankings_identical(context, a, b):
+ def route_id(r):
+ provider = r.get("provider_id") or jpath(r, "candidate.provider_id")
+ family = r.get("model_family") or jpath(r, "candidate.model_family")
+ served = r.get("served_model_id") or jpath(r, "candidate.served_model_id")
+ peer = (r.get("peer_id") or jpath(r, "offer.peer_id") or
+ jpath(r, "candidate.peer_id") or jpath(r, "candidate.offer.peer_id"))
+ assert served or peer, f"ranked row lacks route identity: {r}"
+ return (provider, family, peer or served)
+
def key(rows):
- return [(r.get("served_model_id"), r.get("score")) for r in rows]
+ return [(route_id(r), r.get("score")) for r in rows]
ka, kb = key(context.ranks[a]), key(context.ranks[b])
assert ka == kb, f"rankings differ:\n {a}={ka}\n {b}={kb}"🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Host-side per-session cache affinity (the host half of the cache_hot field). | ||
|
|
||
| Prompt caching is STATE between calls: a provider discounts the reused prefix | ||
| only if the SAME peer serves the session again, so the host must remember which | ||
| route last served each session and steer the next turn back to it. That memory | ||
| cannot live in the algebra (a policy is a pure function of one call) nor in a | ||
| source's `offers_sync` (request-blind, shared across sessions) — it is | ||
| per-session host state, exactly like `route_latency` is per-route host state. | ||
|
|
||
| The measurement is the simplest honest one: the route that most recently served | ||
| a session SUCCESSFULLY is its hot route (it holds the KV prefix). `observe` | ||
| folds each call outcome; `hot_route` returns the session's current hot route key | ||
| (or None). Only successful calls fold, exactly like `route_latency` — a failure | ||
| carries no honest "this peer holds the prefix" signal. | ||
|
|
||
| The algebra never sees the route key: per request the host resolves | ||
| `hot_route(session)` into `ctx.request.cache_hot_route`, and the `cache_hot` | ||
| field getter (declared host-side in `LLMRouterHost`) reconstructs each | ||
| candidate's route key the same way `_fold_route_outcome` does and compares, | ||
| exposing only a Bool. Route identity stays 100% host-internal. | ||
|
|
||
| In-process (resets on restart), exactly like `route_latency` / | ||
| `route_reliability`; a new or unknown session has no hot route -> no candidate | ||
| is cache_hot -> the policy routes purely on its other terms (no phantom | ||
| affinity for a fresh session). Reuses `route_reliability.route_key` so a | ||
| session's hot route shares the one route identity. Fleet-scale (multi-process) | ||
| affinity needs a shared store with TTL + eviction — the same in-process-state | ||
| debt the sibling forms already carry, not new debt. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import threading | ||
|
|
||
| from route_reliability import route_key # shared route identity # noqa: F401 (re-exported) | ||
|
|
||
| _lock = threading.Lock() | ||
| # session -> route_key of the last SUCCESSFUL call on that session. | ||
| _hot: dict[str, str] = {} | ||
|
|
||
|
|
||
| def observe(session: "str | None", key: str, ok: bool) -> None: | ||
| """Fold one call outcome. A successful call makes its route the session's hot | ||
| route (it now holds the prefix). Failures are ignored (no honest signal), and | ||
| a missing session is a no-op — affinity only exists for sessions the caller | ||
| names.""" | ||
| if not session or not ok: | ||
| return | ||
| with _lock: | ||
| _hot[session] = key | ||
|
Comment on lines
+37
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Bound session-state growth to prevent memory exhaustion.
Suggested bounded-retention approach+from collections import OrderedDict
+import time
+
_lock = threading.Lock()
-# session -> route_key of the last SUCCESSFUL call on that session.
-_hot: dict[str, str] = {}
+# session -> (route_key, last_seen_epoch_s)
+_hot: "OrderedDict[str, tuple[str, float]]" = OrderedDict()
+_MAX_HOT_SESSIONS = 100_000
+_HOT_TTL_S = 3600
def observe(session: "str | None", key: str, ok: bool) -> None:
@@
if not session or not ok:
return
with _lock:
- _hot[session] = key
+ _hot[session] = (key, time.time())
+ _hot.move_to_end(session)
+ while len(_hot) > _MAX_HOT_SESSIONS:
+ _hot.popitem(last=False)
def hot_route(session: "str | None") -> "str | None":
@@
if not session:
return None
- return _hot.get(session)
+ now = time.time()
+ with _lock:
+ v = _hot.get(session)
+ if v is None:
+ return None
+ key, ts = v
+ if now - ts > _HOT_TTL_S:
+ _hot.pop(session, None)
+ return None
+ return key🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def hot_route(session: "str | None") -> "str | None": | ||
| """The route key holding this session's prefix hot, or None if unknown.""" | ||
| if not session: | ||
| return None | ||
| return _hot.get(session) | ||
|
|
||
|
|
||
| def snapshot() -> dict[str, str]: | ||
| with _lock: | ||
| return dict(_hot) | ||
|
|
||
|
|
||
| def reset() -> None: | ||
| """Test hook.""" | ||
| with _lock: | ||
| _hot.clear() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the “unknown session” one-shot per scenario run.
_rerank()sends this session through/v1; after a successful call,route_cache.observe()can make the fixed"never-seen-zzz"ID hot, so reruns against the same stack no longer test an unknown session.Use a unique unknown session ID
+import uuid`@when`('the agent re-ranks the same turn with unknown session "{sid}" as "{label}"') def step_rerank_unknown(context, sid, label): - context.ranks[label] = _rerank(context, sid) + unknown_sid = f"{sid}-{uuid.uuid4().hex}" + context.ranks[label] = _rerank(context, unknown_sid)🤖 Prompt for AI Agents