c4: polykv_api doc
Browse files- docs/features/polykv_api.md +573 -0
docs/features/polykv_api.md
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PolyKV Control-Plane API β Design (F5 M6-S5 / #712)
|
| 2 |
+
|
| 3 |
+
**Status:** design (2026-07-16). Implementation is phased (Β§10); nothing here
|
| 4 |
+
is shipped yet.
|
| 5 |
+
**Inputs:** [polykv_api_research.md](polykv_api_research.md) (design-space
|
| 6 |
+
survey), [poly_kv.md](poly_kv.md) (the KV mechanism this sits on top of),
|
| 7 |
+
[llamafile-usage.md](../llamafile-usage.md) Β§3.4 (SharedKVPool user surface).
|
| 8 |
+
**Single rule:** additive / opt-in. Every endpoint and field lives under the
|
| 9 |
+
existing `opencoti` JSON namespace or new `/polykv/*` routes; no upstream
|
| 10 |
+
behavior changes when PolyKV control is unused.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 1. Goals
|
| 15 |
+
|
| 16 |
+
A rich, opt-in control plane that satisfies every multi-agent serving journey
|
| 17 |
+
on top of the existing SharedKVPool (`shared_pool_slot` +
|
| 18 |
+
`shared_prefix_n_tokens`, server-context.cpp:3085). Four requirements, plus
|
| 19 |
+
two the requirements imply:
|
| 20 |
+
|
| 21 |
+
1. **Full status** of pools + the sessions attached to each.
|
| 22 |
+
2. **tps reporting** β aggregate system throughput *and* per-session
|
| 23 |
+
generation tps. (All-new: upstream llama.cpp reports per-*response*
|
| 24 |
+
`timings` but has no per-session/slot running instrumentation.)
|
| 25 |
+
3. **Autonomous capacity/admission loop** β a main session sets a per-session
|
| 26 |
+
gen-tps target (e.g. 5 tps); when the average across active sessions
|
| 27 |
+
approaches the floor the orchestrator holds off spawning sub-agents.
|
| 28 |
+
Behavioral knobs: reject a new session vs. warn.
|
| 29 |
+
4. **LangGraph extensions** β a custom `BaseChatModel`, the pool-control API
|
| 30 |
+
exposed as LangGraph tools, and PolyKV-aware agent-state routing.
|
| 31 |
+
5. **Nesting** (implied by 3): a sub-agent that discovers fan-out work can
|
| 32 |
+
become a pool parent for its own sub-agents, so parallelizable tasks run
|
| 33 |
+
in parallel instead of being forced sequential.
|
| 34 |
+
6. **Fork / branch** (COW): a sub-agent group that shares an *ancestor* prefix
|
| 35 |
+
but then diverges (different toolset/context) gets its own shared pool
|
| 36 |
+
branched at the divergence point.
|
| 37 |
+
|
| 38 |
+
## 2. Decisions locked (2026-07-16)
|
| 39 |
+
|
| 40 |
+
| # | Decision | Choice |
|
| 41 |
+
|---|----------|--------|
|
| 42 |
+
| API home | where the control plane lives | **Self-contained in the C++ engine** β extends `/props`, `/slots`, adds `/polykv/*`. Works standalone; no `opencoti-server` in the path. |
|
| 43 |
+
| Admission | enforce the tps floor or advise | **Both** β advisory (report) by default; optional per-pool enforced mode with `reject`\|`warn`\|`queue`. |
|
| 44 |
+
| Pool model | how pools are created | **Explicit first-class objects** β `create`/`fork`/`attach`/`release`, immutable from birth (Β§3.3). Contiguous-prefix contract checked at fork. Boilerplate hidden by the opencoti plugin. |
|
| 45 |
+
| Scope | deliverable shape | **Design the full surface now; implement in gated phases** (Β§10). |
|
| 46 |
+
| Fork | divergent child prefix | **First-class in v1** (copy-free COW, Β§3.2 / Β§7) β *not* deferred. |
|
| 47 |
+
|
| 48 |
+
**Defaulted (override any):** transport = HTTP on the engine + SSE for the
|
| 49 |
+
live tps stream (poll fallback); tenancy = single trust domain (no per-pool
|
| 50 |
+
ACL); tps = per-session *experienced* decode tps (EWMA) + aggregate decode
|
| 51 |
+
throughput, floor = mean of active sessions' experienced tps (prefill
|
| 52 |
+
excluded); nesting depth uncapped; interior pools pinned while any descendant
|
| 53 |
+
is attached + grace TTL; seq-ids for pools+leaves sized at boot.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## 3. Data model
|
| 58 |
+
|
| 59 |
+
### 3.1 A pool is a tree node
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
Pool {
|
| 63 |
+
id : int32 // seq_id in the unified cache
|
| 64 |
+
parent : int32 | ROOT // -1 for a root pool
|
| 65 |
+
branch_pos D : int32 // shared-ancestor length inherited from parent
|
| 66 |
+
own_range : [D, L) // tokens this pool materialized itself
|
| 67 |
+
prefix_len L : int32 // total shared prefix = D + |own_range|
|
| 68 |
+
tokens : [llama_token] // full prefix token array [0, L), host-side (auto-P + fork validation)
|
| 69 |
+
prefix_hash : [u64] // cumulative rolling hash per position (validates any D; ~8 B/token host RAM)
|
| 70 |
+
pin : bool // resident-pinned (auto while descendants attached)
|
| 71 |
+
children : [Pool] // interior pools branched/extended from this one
|
| 72 |
+
sessions : [Session] // leaf sharers attached at this pool's prefix
|
| 73 |
+
last_access : ts
|
| 74 |
+
}
|
| 75 |
+
Session { // a leaf = an ordinary decoding slot
|
| 76 |
+
id : int32 // seq_id
|
| 77 |
+
pool : int32 // the pool it attached to
|
| 78 |
+
suffix_range : [L, L+S) // its own private, mutable continuation
|
| 79 |
+
tps_ewma : f32 // experienced decode tps
|
| 80 |
+
ttft_ms, state, ...
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
The **root** pool (`parent = ROOT`, `D = 0`) is what today's
|
| 85 |
+
`shared_pool_slot` request already produces. Everything else is new.
|
| 86 |
+
|
| 87 |
+
### 3.2 Three edge types, one mechanism
|
| 88 |
+
|
| 89 |
+
A child pool inherits `[0, D)` from its parent and owns `[D, L)`. `D` selects
|
| 90 |
+
the edge type:
|
| 91 |
+
|
| 92 |
+
- **extend** β `D = parent.L`. Child = parent prefix **+** appended shared
|
| 93 |
+
tokens. This is *nesting*: a sub-agent adds its own shared context and hands
|
| 94 |
+
it to its sub-agents.
|
| 95 |
+
- **branch (COW fork)** β `D < parent.L`. Child shares the common ancestor
|
| 96 |
+
`[0, D)` and diverges with its own `[D, L)`. This is the fork: same system
|
| 97 |
+
prefix, different toolset after `D`.
|
| 98 |
+
- **root** β `parent = ROOT`, `D = 0`.
|
| 99 |
+
|
| 100 |
+
**Why the fork is copy-free.** `seq_cp` in the unified cache is additive
|
| 101 |
+
membership: for each cell where `seq_has(cell, parent)` in `[0, D)` it calls
|
| 102 |
+
`seq_add(cell, child)` (llama-kv-cache.cpp:2007-2044). No cell is duplicated β
|
| 103 |
+
the ancestor cells simply gain the child in their sequence set. Because
|
| 104 |
+
prefixes are **immutable** (FROZEN, Β§3.3), the shared range is never mutated,
|
| 105 |
+
so the "copy" a classic COW would eventually pay never happens. The child only
|
| 106 |
+
computes new KV for `[D, L)`, prefilled at RoPE position `D` attending to the
|
| 107 |
+
shared `[0, D)` already in cache β exactly how attaching with a shorter
|
| 108 |
+
`shared_prefix_n_tokens` works today. **Extend and branch are the same code
|
| 109 |
+
path parameterized by `D`.**
|
| 110 |
+
|
| 111 |
+
### 3.3 Immutability & versioning
|
| 112 |
+
|
| 113 |
+
**Pools are immutable from birth.** A pool's prefix is fully materialized at
|
| 114 |
+
`create`/`fork` and never changes afterwards β there is no OPEN/extend-in-place
|
| 115 |
+
state. "Extending" a pool *always* means creating a child (`fork` with
|
| 116 |
+
`D = parent.L`). This is simpler than a freeze-on-first-attach state machine
|
| 117 |
+
and loses nothing: the "build context incrementally" journey is covered by the
|
| 118 |
+
main agent decoding in its **own session** and then forking `from_session`
|
| 119 |
+
(Β§4.1), which snapshots its current context as a new pool in one call β no
|
| 120 |
+
in-place mutation ever needed.
|
| 121 |
+
|
| 122 |
+
This makes "add a tool to the shared prefix after agents attached"
|
| 123 |
+
well-defined β it is a *new versioned pool* (a fork), and the old one keeps
|
| 124 |
+
serving its sharers unchanged. (This is the precise answer to the HuggingFace
|
| 125 |
+
question: the prefix is static per pool; a tool/MCP added later is a new pool
|
| 126 |
+
version, valid only for sessions that attach to it β never mutated under
|
| 127 |
+
sessions already on the old one.)
|
| 128 |
+
|
| 129 |
+
### 3.4 Lifetime
|
| 130 |
+
|
| 131 |
+
Cell freeing is implicit-refcounted: a cell is reclaimed only when its sequence
|
| 132 |
+
set empties, so an ancestor's cells physically survive as long as *any*
|
| 133 |
+
descendant references them β even if the ancestor's own slot is torn down. On
|
| 134 |
+
top of that:
|
| 135 |
+
|
| 136 |
+
- **Pin policy.** Interior pools are auto-pinned (`--no-clear-idle` semantics,
|
| 137 |
+
per-node) while any descendant is attached. Without this a new leaf attaching
|
| 138 |
+
to an idle-cleared interior pool hits the residency guard
|
| 139 |
+
(server-context.cpp:3116) and silently falls back to full reprocess β
|
| 140 |
+
correct but slow. A **grace TTL** delays reclaim after the last descendant
|
| 141 |
+
detaches (cheap re-attach for bursty fan-out).
|
| 142 |
+
- **seq-id budget.** Every pool *and* every leaf consumes one `n_seq_max` slot.
|
| 143 |
+
A deep/wide tree spends seq-ids on interior nodes too; the boot-time reserve
|
| 144 |
+
accounts for both. Exposed in `/capacity`.
|
| 145 |
+
|
| 146 |
+
### 3.5 Materialization β the slot/seq decoupling (P2's real work)
|
| 147 |
+
|
| 148 |
+
Today a "pool" is just an ordinary server **slot** that happened to serve a
|
| 149 |
+
request: `shared_pool_slot` names a slot index, the pool's tokens live in
|
| 150 |
+
`slots[pool].prompt.tokens`, and the pool seq **is** the slot's seq. First-class
|
| 151 |
+
pools break that coupling, and this is the largest single implementation lift
|
| 152 |
+
in the milestone:
|
| 153 |
+
|
| 154 |
+
- **seq-ids beyond slots.** Pool seq-ids come from a boot-time reserve above
|
| 155 |
+
the slot range: `n_seq_max = n_parallel + --polykv-max-pools` (default
|
| 156 |
+
proposed: 16). A pool occupies a seq-id but **no slot** once materialized.
|
| 157 |
+
- **Prefill-only internal task.** `create`/`fork` materializes `[D, L)` via an
|
| 158 |
+
internal task that runs through the normal batching path (so it composes
|
| 159 |
+
with every KV feature) but emits **no logits and no generation**; it
|
| 160 |
+
transiently borrows a slot for the prefill, then the KV stays on the pool's
|
| 161 |
+
seq and the slot is returned. `seq_cp(parent β pool, 0, D)` runs first for
|
| 162 |
+
fork/extend.
|
| 163 |
+
- **Host-side pool state.** The Pool object (tokens, cumulative hash, tree
|
| 164 |
+
links, pin/TTL) lives in server memory beside the slot table β *not* in a
|
| 165 |
+
slot β and is what `/pools` reports and attach validates against.
|
| 166 |
+
- **Unified KV required.** The split-stream `seq_cp` overload hard-asserts
|
| 167 |
+
`is_full` (llama-kv-cache.cpp:2063); pool sharing is only defined under
|
| 168 |
+
`--kv-unified` β already the SharedKVPool prerequisite, now stated as a
|
| 169 |
+
boot-time check when `--polykv-max-pools > 0`.
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
## 4. API surface
|
| 174 |
+
|
| 175 |
+
All additive. Reuses the `opencoti` JSON namespace (`get_opencoti_props()`
|
| 176 |
+
:3787) and the `/slots` accumulator precedent (:183). Endpoint shapes follow
|
| 177 |
+
research Β§6.
|
| 178 |
+
|
| 179 |
+
### 4.1 Pool lifecycle (imperative, explicit)
|
| 180 |
+
|
| 181 |
+
| Verb | Endpoint | Body β result |
|
| 182 |
+
|------|----------|---------------|
|
| 183 |
+
| create root | `POST /polykv/pools` | `{tokens? \| from_session?}` β `{pool_id, prefix_len}`. `from_session` snapshots a live session's current context as the pool prefix β the **primary** creation path for agents (no token round-trip). |
|
| 184 |
+
| fork (extend or COW branch) | `POST /polykv/pools/{id}/fork` | `{branch_pos D, tokens[D:L] \| from_session?}` β new child `{pool_id, parent:id, branch_pos, prefix_len}`. `D == parent.L` β extend (nesting); `D < parent.L` β COW branch. |
|
| 185 |
+
| attach | *(request path, unchanged mechanism)* | per-request `pool_id` (legacy `shared_pool_slot` kept). **`shared_prefix_n_tokens` becomes optional**: the server auto-computes `P` as the longest hash-match between the request prompt and the pool's stored tokens (**auto-P**) β killing the mis-set-P footgun and the HF-question confusion. An explicit `P` is still accepted and validated. |
|
| 186 |
+
| pin/evict/flush | `POST /polykv/pools/{id}/{pin\|evict\|flush}` | retention control (research Β§6 Req 1) |
|
| 187 |
+
| release | `DELETE /polykv/pools/{id}` | detach + reclaim when refcount hits 0 (respects grace TTL) |
|
| 188 |
+
|
| 189 |
+
Contiguous-prefix contract is checked at `fork`: the child's declared
|
| 190 |
+
`tokens[0:D]` must hash-match the parent's `[0:D)` (cumulative `prefix_hash`
|
| 191 |
+
at `D`), else `409 Conflict` β the caller then either re-roots or
|
| 192 |
+
full-prefills. All routes live under a `/polykv/` prefix so a future upstream
|
| 193 |
+
llamafile/llama.cpp bump can never collide with an unprefixed `/pools`.
|
| 194 |
+
|
| 195 |
+
### 4.2 Status (research Β§6 Req 1)
|
| 196 |
+
|
| 197 |
+
- `GET /polykv/pools` β `{pools:[{pool_id, parent, branch_pos, prefix_len,
|
| 198 |
+
own_kv_bytes, subtree_kv_bytes, residency, pinned, last_access_ts,
|
| 199 |
+
children:[pool_id], sessions:[{session_id, suffix_n_tokens,
|
| 200 |
+
marginal_kv_bytes, tps_ewma, ttft_ms, state}]}], tree_depth, seq_ids_used,
|
| 201 |
+
seq_ids_max}`. The `parent`/`children` fields make the **tree** explicit.
|
| 202 |
+
**KV attribution is by ownership, never by membership**: a shared cell
|
| 203 |
+
belongs to many seqs, so summing per-seq bytes would double-count β a pool
|
| 204 |
+
reports `own_kv_bytes` = its `[D, L)` range only, plus a `subtree_kv_bytes`
|
| 205 |
+
rollup (own + all descendants + attached sessions' suffixes). Ξ£ own over the
|
| 206 |
+
tree == physical bytes, by construction.
|
| 207 |
+
- `GET /polykv/pools/{id}` β one subtree.
|
| 208 |
+
- `/slots` gains `pool_id` + `tps_ewma` per slot (extends #677).
|
| 209 |
+
- `/props` `opencoti` block gains a `polykv` sub-object (config: floor,
|
| 210 |
+
admission mode, pin/TTL policy).
|
| 211 |
+
|
| 212 |
+
### 4.3 tps (research Β§6 Req 2)
|
| 213 |
+
|
| 214 |
+
- **Per-response** (already partly present): `timings{predicted_per_second,
|
| 215 |
+
prompt_per_second, cache_n}`.
|
| 216 |
+
- **Headers** (TGI model): `x-session-tps`, `x-cached-prefix-tokens`,
|
| 217 |
+
`x-pool-id`. **Deferred to P2+**: HTTP headers must be emitted before the
|
| 218 |
+
body, but the slot (and thus the live tps value) is assigned only after the
|
| 219 |
+
task is queued β a header set at request time would always be stale, and
|
| 220 |
+
trailers aren't portable. The per-response `timings` JSON already carries the
|
| 221 |
+
authoritative per-response rate; headers add value only once pool attach
|
| 222 |
+
happens at request routing (P2), where `x-pool-id`/`x-cached-prefix-tokens`
|
| 223 |
+
are known up front.
|
| 224 |
+
- **Live stream:** `GET /polykv/tps` (SSE) β periodic
|
| 225 |
+
`{ts, aggregate_tps, sessions:[{session_id, pool_id, tps_ewma}], mean_active_tps}`.
|
| 226 |
+
This is the signal the admission loop subscribes to.
|
| 227 |
+
- **`/metrics`** (Prometheus, low-cardinality per research Β§4): gauges
|
| 228 |
+
`opencoti_polykv:gen_throughput_tps{pool}`, `pool_kv_bytes{pool}`,
|
| 229 |
+
`pool_sessions{pool}`; counters `prefix_cache_hits_total{pool}`,
|
| 230 |
+
`queries_total{pool}`. Label = pool only (never session β cardinality).
|
| 231 |
+
|
| 232 |
+
### 4.4 Admission (research Β§6 Req 3; decision: both modes)
|
| 233 |
+
|
| 234 |
+
- `POST /polykv/pools/{id}/admission` `{target_tps_per_session, mode:"advisory"|
|
| 235 |
+
"enforced", on_saturation:"reject"|"warn"|"queue"}` β per-pool policy.
|
| 236 |
+
- `GET /polykv/pools/{id}/capacity` β
|
| 237 |
+
`{can_admit, projected_mean_tps_if_admitted, headroom_sessions, kv_headroom_pct, reason}`.
|
| 238 |
+
`headroom_sessions` is computed against **marginal** per-session KV
|
| 239 |
+
(`S_iΒ·bpt`), not full context β the basis of the ~7Γ reduction (research Β§5).
|
| 240 |
+
- **Advisory (default):** endpoints report; the orchestrator decides. Nothing
|
| 241 |
+
is rejected server-side.
|
| 242 |
+
- **Enforced (opt-in):** on a new attach with projected `mean_active_tps <
|
| 243 |
+
floor` OR `kv_headroom` exhausted β `reject` = `429`/`503` + `Retry-After`
|
| 244 |
+
(RFC 6585/9110); `warn` = admit + `X-Sessions-Remaining` header; `queue` =
|
| 245 |
+
hold until headroom returns.
|
| 246 |
+
- **Throttle-toward-floor is explicitly NOT v1.** Andes' insight (over-floor
|
| 247 |
+
generation is wasted GPU β throttle fast sessions back toward the floor to
|
| 248 |
+
reclaim capacity) requires per-slot rate control inside the continuous-
|
| 249 |
+
batching decode loop β i.e. skipping a slot in some decode batches. That is
|
| 250 |
+
scheduler surgery with its own correctness/perf gates, not an API feature;
|
| 251 |
+
it is banked as a P4-follow candidate, and v1 `warn` is headers-only.
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
## 5. tps instrumentation (all-new, C++ server-side)
|
| 256 |
+
|
| 257 |
+
Upstream has no per-session running tps. Design:
|
| 258 |
+
|
| 259 |
+
- Extend the `/slots` lifetime accumulators (server-context.cpp:183) with a
|
| 260 |
+
per-slot decode-token counter + wall-clock, updated at each decode-batch
|
| 261 |
+
completion for the tokens attributable to that slot.
|
| 262 |
+
- **Experienced tps** per session = EWMA over a rolling window (default: last
|
| 263 |
+
`W` decoded tokens or `T` ms, whichever first) of that slot's decode
|
| 264 |
+
throughput. This is the quantity that *falls as concurrency rises* even while
|
| 265 |
+
aggregate climbs (attention doesn't batch-amortize β research Β§5), so it is
|
| 266 |
+
the correct SLO signal.
|
| 267 |
+
- **Aggregate** = system decode throughput (all slots' decode tokens / wall).
|
| 268 |
+
- **`mean_active_tps`** = mean of experienced tps across currently-decoding
|
| 269 |
+
sessions β the value the floor compares against (matches "average sessions
|
| 270 |
+
gen tps").
|
| 271 |
+
- Prefill tokens are excluded from "gen tps" (counted separately as
|
| 272 |
+
`prompt_per_second`).
|
| 273 |
+
- Cost: a handful of integer counters + one EWMA update per slot per batch β
|
| 274 |
+
negligible; gated behind the PolyKV block so off-path is byte-identical.
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
## 6. Admission control
|
| 279 |
+
|
| 280 |
+
Two coupled signals (research Β§5):
|
| 281 |
+
|
| 282 |
+
1. **Soft / low-lag:** `mean_active_tps` vs the floor. Directly measures the
|
| 283 |
+
SLO; leads `num_requests_waiting` (which lags).
|
| 284 |
+
2. **Hard wall:** pool KV-occupancy %. Reject *before* this forces
|
| 285 |
+
preempt-oldest eviction.
|
| 286 |
+
|
| 287 |
+
`projected_mean_tps_if_admitted` estimates the post-admit floor from the
|
| 288 |
+
`B_sat` knee model (below the knee admitting is ~free; above it every admit
|
| 289 |
+
costs everyone's TPOT ~linearly). On high-VRAM cards (RTX 6000 96 GB) the tps
|
| 290 |
+
knee is hit **before** the KV wall, so signal (1) dominates on opencoti's
|
| 291 |
+
hardware β which is exactly why the floor, not occupancy, is the headline.
|
| 292 |
+
|
| 293 |
+
Framing: "5 tok/s/session" = **TPOT SLO 200 ms/token; maximize goodput s.t.
|
| 294 |
+
TPOT β₯ floor** (a floor, not a maximize β Andes). The default is advisory: the
|
| 295 |
+
LangGraph capacity-gate node (Β§8) reads `/capacity` and decides. Enforced mode
|
| 296 |
+
is the same math with server-side `reject`/`warn`/`queue`.
|
| 297 |
+
|
| 298 |
+
---
|
| 299 |
+
|
| 300 |
+
## 7. Nesting & COW-fork β worked journeys
|
| 301 |
+
|
| 302 |
+
**Journey A β sub-agent parallelization (nesting / extend).** Main agent M
|
| 303 |
+
runs on root pool `P0 = [system+tools]` (len N0, FROZEN). M builds task context
|
| 304 |
+
and `POST /pools/P0/fork {branch_pos:N0, tokens:[N0:N1]}` β interior pool `P1 =
|
| 305 |
+
[system+tools+task]`. M spawns k sub-agents that each attach to `P1` and decode
|
| 306 |
+
their own suffix in parallel. Physically, `P1`'s `[0:N0)` cells are P0's cells
|
| 307 |
+
(now in seq-set {P0,P1,leafβ¦}); `[N0:N1)` is prefilled once. The k sub-agents
|
| 308 |
+
cost only their marginal suffixes. The fan-out work that used to run
|
| 309 |
+
sequentially now runs concurrently under one shared branch.
|
| 310 |
+
|
| 311 |
+
**Journey B β divergent toolset (COW fork).** Two sub-agent groups share the
|
| 312 |
+
system preamble `[0:D)` but need different toolsets. Group B does
|
| 313 |
+
`POST /pools/P0/fork {branch_pos:D, tokens:[D:D']}` (D < N0) β `P1' =
|
| 314 |
+
[system+toolsetB]`, sharing `[0:D)` zero-copy, prefilling `[D:D')` fresh. Its
|
| 315 |
+
sub-agents attach to `P1'`. No physical copy of the shared preamble.
|
| 316 |
+
|
| 317 |
+
**Invariants enforced.** (a) contiguous-prefix hash check at `fork`; (b)
|
| 318 |
+
immutability from birth β a pool's prefix is fully materialized before any
|
| 319 |
+
descendant can attach (Β§3.3), so no attach ever races a prefix change; (c)
|
| 320 |
+
recursive pin β interior pools stay resident while descendants attached; (d)
|
| 321 |
+
seq-id budget counts interior nodes. Divergence that is *not* a clean prefix of any
|
| 322 |
+
existing pool β new root (or full prefill) β reported, never silently wrong.
|
| 323 |
+
|
| 324 |
+
**Hybrid/recurrent guard (bug-2203) is recursive.** Every attach/fork on a
|
| 325 |
+
recurrent or hybrid model still hits the exact-full-state guard
|
| 326 |
+
(server-context.cpp:3116) at every level.
|
| 327 |
+
|
| 328 |
+
---
|
| 329 |
+
|
| 330 |
+
## 8. LangGraph integration (research Β§6 Req 4)
|
| 331 |
+
|
| 332 |
+
Package home: a new **top-level `opencoti/` non-bun root** (per the repo's
|
| 333 |
+
"new non-bun roots" rule) β Python package `opencoti-langgraph` (pip). Kept out
|
| 334 |
+
of the bun workspace. Pin `BaseChatModel` signatures to a specific
|
| 335 |
+
`langchain-core` version tag (the API is mid-migration β research caveat).
|
| 336 |
+
|
| 337 |
+
- **`OpencotiChatModel(BaseChatModel)`** β implement `_generate` + `_llm_type`
|
| 338 |
+
(required), `_stream`/`_agenerate`/`_astream`; override `bind_tools`.
|
| 339 |
+
Populate `usage_metadata` and
|
| 340 |
+
`response_metadata{pool_id, session_tps, backpressure}` from the headers/Β§4.3.
|
| 341 |
+
- **Pool-control tools** β `create_pool`, `fork_pool`, `pool_status`,
|
| 342 |
+
`session_tps`, `capacity` exposed as LangChain tools via `bind_tools` /
|
| 343 |
+
`ToolNode`, so an agent can manage its own pool tree.
|
| 344 |
+
- **Capacity-gate node** β reads `/capacity`; returns
|
| 345 |
+
`Command(update={"capacity_ok":ok, "headroom_sessions":n}, goto=...)` to
|
| 346 |
+
route the graph (spawn vs. hold). `interrupt()` for block-until-free.
|
| 347 |
+
`InMemoryRateLimiter` is explicitly insufficient β it can't see per-session
|
| 348 |
+
tps or KV headroom.
|
| 349 |
+
- **Agent-state routing** β a reducer surfaces `headroom_sessions` /
|
| 350 |
+
`mean_active_tps` into graph state so conditional edges branch on live PolyKV
|
| 351 |
+
capacity.
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## 9. Composition with the KV stack
|
| 356 |
+
|
| 357 |
+
PolyKV control is orthogonal to the KV *mechanics* and must not perturb them:
|
| 358 |
+
|
| 359 |
+
- **rolling-KV / DCA / turbo / TCQ / quant-KV** β pool sharing is `seq_cp`
|
| 360 |
+
membership on whatever cell type is configured; the tps/admission plane is
|
| 361 |
+
read-only over the existing decode loop. Off-path byte-identical (gate).
|
| 362 |
+
- **MTP / speculative** β per-session tps counts *accepted* tokens (the user-
|
| 363 |
+
visible generation rate), not draft steps.
|
| 364 |
+
- **`--parallel β₯ 2` / `--kv-unified`** β the multi-slot requirement is already
|
| 365 |
+
the SharedKVPool baseline; the tree just adds interior seq-ids.
|
| 366 |
+
- **hybrid/recurrent** β bug-2203 guard, recursive (Β§7).
|
| 367 |
+
|
| 368 |
+
## 10. Phased implementation plan (gated)
|
| 369 |
+
|
| 370 |
+
Full surface designed here; implementation lands in gated phases, each with a
|
| 371 |
+
correctness + off-path-identity gate before the next:
|
| 372 |
+
|
| 373 |
+
- **P1 β tps instrumentation** (Β§5). Per-slot counters + EWMA + `/slots`/`/props`
|
| 374 |
+
exposure + `/polykv/tps` SSE + `/metrics`. Prereq for everything.
|
| 375 |
+
**β
IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
|
| 376 |
+
gates green β sustained EWMA β3.2% vs `timings` ground truth; 2-session
|
| 377 |
+
concurrency shows correct `aggregate_tps` β Ξ£ sessions + `mean_active_tps`;
|
| 378 |
+
`/metrics` 3 series render. bug-2205: aggregate MUST come from
|
| 379 |
+
Ξ£ `n_decoded_cum` (per-token), not `n_tokens_predicted_total`
|
| 380 |
+
(completion-time only).
|
| 381 |
+
- **P2 β pool objects + status API** (Β§3, Β§4.1-4.2). The slot/seq decoupling
|
| 382 |
+
(Β§3.5): seq-id reserve, prefill-only materialization task, host-side Pool
|
| 383 |
+
state, **auto-P attach**; `GET /polykv/pools`. Root pools only.
|
| 384 |
+
**β
IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
|
| 385 |
+
gate 23/23 on Qwen3-4B β boot check (`--polykv-max-pools` without
|
| 386 |
+
`--kv-unified` fails clean; NOTE `--parallel` must be explicit, unset
|
| 387 |
+
auto-sets `kv_unified=true`), create-from-prompt prefill-only P=95,
|
| 388 |
+
auto-P exact attach `cache_n=95`, divergence attach `cache_n=42<95`,
|
| 389 |
+
`from_session` zero-copy `seq_cp` snapshot (affinity is evict-on-reuse β
|
| 390 |
+
only the most-recent session per slot resolves), pin/unpin/GET-one,
|
| 391 |
+
release + released-id clean fallback, disabled path inert, pooled vs
|
| 392 |
+
unpooled greedy text IDENTICAL (bit-shared cells). Mutations only on the
|
| 393 |
+
server thread via `SERVER_TASK_TYPE_POLYKV`; recurrent guard per bug-2203.
|
| 394 |
+
- **P3 β nesting & COW-fork** (Β§7). `fork` with arbitrary `D` (extend + COW
|
| 395 |
+
branch), cumulative-hash contract check, recursive pin, seq-id accounting.
|
| 396 |
+
**β
IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending):
|
| 397 |
+
gate 18/18 substantive on Qwen3-4B β extend fork (D=parent.L, own=22),
|
| 398 |
+
COW branch (D=47<95, zero-copy `seq_cp` of `[0,D)` + fresh prefill),
|
| 399 |
+
contract violation β clean 4xx, auto-P attach to a child (cache_n=117),
|
| 400 |
+
tree reporting (parent/children/branch_pos/own_len/tree_depth), release
|
| 401 |
+
ordering enforced (parent-with-children refused, leaves-first drains),
|
| 402 |
+
seq exhaustion at max_pools, `from_session` fork. The Β§4.1 prefix
|
| 403 |
+
contract is enforced **token-exact** (`std::equal` against the parent's
|
| 404 |
+
stored tokens β strictly subsumes the cumulative-hash check; `prefix_hash`
|
| 405 |
+
kept as identity fingerprint). Pooled-vs-unpooled greedy divergence on the
|
| 406 |
+
branch was classified benign by first-token probe (same top-5 ranking,
|
| 407 |
+
max |Ξlogprob| 0.112 β batch-shape numerics, #495 class). Recurrent
|
| 408 |
+
bug-2203 guard is recursive: hybrid/recurrent forks must extend at
|
| 409 |
+
D == parent.L.
|
| 410 |
+
- **P4 β admission** (Β§6). `/capacity`, advisory metrics, then enforced mode
|
| 411 |
+
(`reject`/`warn`/`queue`).
|
| 412 |
+
**β
IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture pending;
|
| 413 |
+
`queue` deferred per Β§11 default): gate 18/18 on Qwen3-4B β /capacity shape
|
| 414 |
+
(idle: can_admit, projected null, kv cells by OWNERSHIP: pools' own ranges +
|
| 415 |
+
slots' marginal suffixes via new `slot.n_pool_shared`), advisory floor never
|
| 416 |
+
rejects, **enforced+reject β 429 + Retry-After** under an active decoding
|
| 417 |
+
session (tps-floor arm needs n_active β₯ 1), **enforced+warn β 200 +
|
| 418 |
+
X-Sessions-Remaining**, off-path (no pool_id / unknown pool_id) untouched.
|
| 419 |
+
Projection = conservative fully-saturated knee: `mean_active Β· n/(n+1)`
|
| 420 |
+
(never over-admits below the real B_sat knee). Fast path: an atomic
|
| 421 |
+
enforced-pool count β ordinary traffic pays zero; the gate itself is a
|
| 422 |
+
high-priority OP_CAPACITY control task from `handle_completions_impl`
|
| 423 |
+
(same pattern as /slots' METRICS task).
|
| 424 |
+
- **P5 β LangGraph package** (Β§8). `opencoti-langgraph` under top-level
|
| 425 |
+
`opencoti/`. Runs in the first-class **`opencoti` conda env** (py3.11,
|
| 426 |
+
langchain-core 1.4.x + langgraph 1.2.x pinned; the same env will later
|
| 427 |
+
host the consultants council alongside opencoti-server). Includes the P6d
|
| 428 |
+
`CompactionNode` (Β§13).
|
| 429 |
+
**P5 β
IMPLEMENTED 2026-07-16**: offline 21/21 (pytest, httpx MockTransport
|
| 430 |
+
FakeServer), live gate **19/19** on Qwen3-4B `:8231 --parallel 2 --kv-unified
|
| 431 |
+
--polykv-max-pools 4` β pool tree (token-path fork D=65), `OpencotiChatModel`
|
| 432 |
+
invoke + streaming with pool attach proven (`cache_n=68 β₯ root prefix 65`),
|
| 433 |
+
`session_tps`/usage metadata, capacity context arm + gate-node routing,
|
| 434 |
+
enforced 429 β `PoolSaturatedError(retry_after)` β advisory recovery,
|
| 435 |
+
**P6c compact-by-refork e2e** (old pool released, migrated session coherent
|
| 436 |
+
on the new pool, `cache_n=71`), tps `ctx_*` fields. Contract lessons
|
| 437 |
+
(bug-2208): fork `prompt`/`tokens` = the child's **FULL prefix [0,L)**
|
| 438 |
+
token-exact vs the parent (never suffix-only β build via `/tokenize`:
|
| 439 |
+
parent tokens + suffix `add_special=false`); a pool prefix meant for
|
| 440 |
+
`/v1/chat/completions` attach must be the **chat-templated** system block
|
| 441 |
+
(raw text shares 0 tokens β `cache_n=0`); Qwen3 thinking models need
|
| 442 |
+
`chat_template_kwargs: {enable_thinking: false}` for content-based gates.
|
| 443 |
+
Client accordingly ships `tokenize()` + `compact_by_refork(compacted_tokens=β¦)`.
|
| 444 |
+
- **P6 β context exhaustion & compaction** (Β§13). **P6a (ctx-shift pool
|
| 445 |
+
guard) + P6b (context visibility + /capacity context arm) land BEFORE P5**
|
| 446 |
+
per user decision 2026-07-16; P6c/P6d ship with P5.
|
| 447 |
+
**P6a+P6b β
IMPLEMENTED 2026-07-16** (live in vendored tree, patch capture
|
| 448 |
+
pending): gate 23/23 on Qwen3-4B at `-c 4096 --parallel 1 --context-shift`
|
| 449 |
+
(the exact Β§13.1-regime-3 configuration) β boot WARN; pooled session forced
|
| 450 |
+
past n_ctx = graceful `truncated=true` stop at 4021 tokens (position wall
|
| 451 |
+
fires one token before unified-cell exhaustion when the prefix is
|
| 452 |
+
pool-shared), NO shift, pool provably coherent afterwards (fresh attach
|
| 453 |
+
cache_n=60, needle answered); non-pooled ctx-shift still engages and
|
| 454 |
+
completes after pools released (off-path intact); `?expected_tokens` context
|
| 455 |
+
arm rejects with `"context headroom exhausted"`; `compaction_pressure`
|
| 456 |
+
0 at idle, β[0,1]; `orphaned_pin` true for a pinned never-attached leaf;
|
| 457 |
+
`/polykv/tps` sessions carry `ctx_used/ctx_total/ctx_headroom_tokens`.
|
| 458 |
+
Guard bookkeeping: `slot.n_pool_shared` is clamped to the retained prefix at
|
| 459 |
+
prompt-cache decision time (`min(n_pool_shared, n_past)`) β NOT blind-reset,
|
| 460 |
+
because cache reuse keeps pool-shared cells across later non-pool requests
|
| 461 |
+
on the same session. NOTE the geometry lesson: with `--kv-unified` the slot
|
| 462 |
+
n_ctx is the FULL `-c` (not divided by `--parallel`), and multi-slot cell
|
| 463 |
+
exhaustion precedes the positional wall β gates must use `--parallel 1`.
|
| 464 |
+
|
| 465 |
+
## 11. Open sub-decisions (defaulted; flag to change)
|
| 466 |
+
|
| 467 |
+
- EWMA window `W`/`T` defaults (proposed: 128 tokens or 2 s).
|
| 468 |
+
- Grace TTL for interior-pool reclaim (proposed: reuse `--slot-shrink-idle-ms`).
|
| 469 |
+
- `/metrics` opt-in flag name + default-off.
|
| 470 |
+
- Whether `queue` (admission) is v1 or P4-follow (proposed: `reject`+`warn` v1,
|
| 471 |
+
`queue` follow).
|
| 472 |
+
- `--polykv-max-pools` default (proposed: 16; each reserved seq-id costs KV
|
| 473 |
+
bookkeeping but no cells until materialized).
|
| 474 |
+
- Hash algorithm for the cumulative `prefix_hash` (proposed: xxhash64 rolling;
|
| 475 |
+
8 B/token host RAM β ~800 KB for a 100k-token prefix, negligible).
|
| 476 |
+
- SSE tps-stream cadence (proposed: 500 ms, configurable; stream is opt-in so
|
| 477 |
+
off-path cost is zero).
|
| 478 |
+
|
| 479 |
+
## 12. Verification
|
| 480 |
+
|
| 481 |
+
- Off-path identity: PolyKV-control-unused boot is byte-identical (DSO + decode).
|
| 482 |
+
- tps accuracy: instrumented per-session tps vs. an external wall-clock
|
| 483 |
+
token-rate measurement, Β±few %.
|
| 484 |
+
- Fork correctness: a branched pool's decode is logit-equivalent to the same
|
| 485 |
+
session full-prefilled (real_frac=0 / KLD), never greedy-needle.
|
| 486 |
+
- Tree lifetime: ancestor cells survive descendant-only references; reclaim on
|
| 487 |
+
refcount 0; recursive pin prevents the residency-miss fallback.
|
| 488 |
+
- Admission: projected vs. realized `mean_active_tps` under a spawn ramp;
|
| 489 |
+
`reject`/`warn` behavior + `Retry-After`/`X-Sessions-Remaining` headers.
|
| 490 |
+
- Composition gates: rolling-KV / DCA / turbo / MTP / `--parallel 2` unchanged.
|
| 491 |
+
|
| 492 |
+
## 13. Context exhaustion & compaction orchestration (P6)
|
| 493 |
+
|
| 494 |
+
*Added 2026-07-16 on user review: the design above admits sessions by tps floor
|
| 495 |
+
and marginal KV, but had no story for what happens when the shared context
|
| 496 |
+
actually fills β and continuous agentic frameworks WILL fill it. Decision:
|
| 497 |
+
P6a+P6b land BEFORE P5 (the LangGraph capacity gate builds on the context arm);
|
| 498 |
+
P6c/P6d ship with/after P5.*
|
| 499 |
+
|
| 500 |
+
### 13.1 What the engine does today (verified, three regimes)
|
| 501 |
+
|
| 502 |
+
1. **Prompt too long at admission** β clean `ERROR_TYPE_EXCEED_CONTEXT_SIZE`.
|
| 503 |
+
2. **Generation reaches `n_ctx`, `--ctx-shift` off (default)** β clean stop
|
| 504 |
+
with `truncated=true` (`server-context.cpp:1898`). Not corrupting, but
|
| 505 |
+
silent mid-thought truncation is exactly the "fails badly" mode for agents:
|
| 506 |
+
no advance warning, no orchestration signal.
|
| 507 |
+
3. **`--ctx-shift` on + pool-attached session β CORRUPTION.** The shift does
|
| 508 |
+
`seq_rm` + `seq_add(β¦, -n_discard)` (`server-context.cpp:3261-3262`); in
|
| 509 |
+
the unified cache `seq_add` mutates the **per-cell position**, and
|
| 510 |
+
pool-shared cells are members of many seqs β the shift re-RoPEs the pool's
|
| 511 |
+
prefix for every other session and every descendant pool. Upstream guards
|
| 512 |
+
this for parent/child shared prompts (`server-context.cpp:3192`) but a
|
| 513 |
+
pool attach is neither, so the guard does not fire. The rest-kv-eviction
|
| 514 |
+
variant uses the same primitives and has the same hazard.
|
| 515 |
+
|
| 516 |
+
The "safe" naive alternative β COW-copy shared cells before shifting β
|
| 517 |
+
allocates a duplicate of the prefix at the exact moment the cache is full.
|
| 518 |
+
Both intuited failure modes (corruption / usage inflation) are real.
|
| 519 |
+
|
| 520 |
+
### 13.2 Design principle: compaction is a prompt REWRITE, never an in-place KV op
|
| 521 |
+
|
| 522 |
+
LangGraph (and every agentic framework) compacts at the prompt-assembly
|
| 523 |
+
level: `trim_messages`, `RemoveMessage` reducers, or a summarization
|
| 524 |
+
`pre_model_hook` that replaces old messages with a running summary. The
|
| 525 |
+
compacted context is **new text** β its tokens cannot match the old cells, so
|
| 526 |
+
cell-level "compaction" is not even meaningful. Therefore the server must
|
| 527 |
+
never mutate pooled cells; the correct primitive already exists in the tree:
|
| 528 |
+
|
| 529 |
+
> **Compaction = re-root via fork.** The system prompt + tool definitions
|
| 530 |
+
> survive compaction verbatim β that is the stable ancestor pool
|
| 531 |
+
> `[0, D_sys)`. Compacted context = `fork(ancestor, D=D_sys,
|
| 532 |
+
> tokens = summary + recent tail)` β a new sibling branch, prefilled once.
|
| 533 |
+
> Sessions migrate to the new pool; the old working subtree is `release`d.
|
| 534 |
+
> Pool immutability is preserved by construction, no cell position is ever
|
| 535 |
+
> touched, and net cells go **down** (old subtree reclaimed) instead of up
|
| 536 |
+
> (COW duplication).
|
| 537 |
+
|
| 538 |
+
Pinned pools under this model are a **leak hazard, not a corruption hazard**:
|
| 539 |
+
a pin left on the abandoned subtree blocks reclaim. Discipline is
|
| 540 |
+
unpin-after-migrate; the server reports orphans (pinned + zero sessions +
|
| 541 |
+
zero children) so the orchestrator can sweep.
|
| 542 |
+
|
| 543 |
+
### 13.3 Phases
|
| 544 |
+
|
| 545 |
+
- **P6a β corruption guard (before P5).** Extend the ctx-shift refusal to
|
| 546 |
+
pool-attached slots (`slot.n_pool_shared > 0` β clean error, same as the
|
| 547 |
+
`:3192` shared-prompt refusal) + boot WARN when `--ctx-shift` is combined
|
| 548 |
+
with `--polykv-max-pools`. Rest-kv-eviction path included. Off-path inert.
|
| 549 |
+
- **P6b β visibility + spawn-gate parity (before P5).** Per-session
|
| 550 |
+
`ctx_used`/`ctx_headroom_tokens` in `/polykv/tps` + `/slots`; `/capacity`
|
| 551 |
+
gains `?expected_tokens=N` and a **context arm** to `can_admit`
|
| 552 |
+
(reason `"context headroom exhausted"`) so sub-agent spawns are gated on
|
| 553 |
+
context exactly like the tps floor; plus a `compaction_pressure` field
|
| 554 |
+
(0..1, driven by free-cell fraction and largest-session share) so the
|
| 555 |
+
orchestrator compacts BEFORE the wall.
|
| 556 |
+
- **P6c β compact-by-refork protocol (with P5).** Documented two-step:
|
| 557 |
+
summarize via normal completion β `fork` the stable ancestor at `D_sys`
|
| 558 |
+
with the compacted suffix β migrate sessions β `release` old subtree.
|
| 559 |
+
Orphaned-pin flag in `/polykv/pools`. Atomic convenience endpoint only if
|
| 560 |
+
the two-step proves racy in practice.
|
| 561 |
+
- **P6d β LangGraph side (inside opencoti-langgraph).** `CompactionNode`
|
| 562 |
+
(pre_model_hook pattern: watch `response_metadata` headroom /
|
| 563 |
+
`compaction_pressure` β summarize β re-root β swap `pool_id` in graph
|
| 564 |
+
state); the capacity-gate node checks the context arm from day 1.
|
| 565 |
+
|
| 566 |
+
### 13.4 Non-goals
|
| 567 |
+
|
| 568 |
+
- In-place KV compaction/merging of pooled cells (meaningless under rewrite
|
| 569 |
+
semantics; corrupting under sharing).
|
| 570 |
+
- Server-side self-summarization (the orchestrator owns the summary β it has
|
| 571 |
+
the conversation semantics; the server only has tokens).
|
| 572 |
+
- Growing `n_ctx` at runtime (allocation is boot-time; capacity planning is
|
| 573 |
+
the admission plane's job).
|