Single authority over operator prompt context: registered contributors with deterministic budgeted admission, measured blocks, per-block trace, real context-length discovery from /api/show (conservative fallback 8192 + T1 record_failure), log-only calibration vs served prompt_eval_count, and 80%-threshold compaction that ARCHIVES raw history before lossy compression (last 5 turns verbatim, rolling structured summary, T2 degrade). AttentionCapsule gains additive blocks ledger + to_messages(); consolidation's _save_knowledge runs exactly once per turn at collect time; safety override decorates the final user block in place; approval resume embeds the capsule in pending_state; token accounting is trace-backed (no more static=0). 12 frozen tests + canary canary_context_broker.py registered in ci.yml (henxels gate). Pinned follow-ups: num_ctx per-site right-sizing, ambient bridge decision. Deviations recorded in the plan tail.
9.1 KiB
ContextBroker (P0-03, #19) — Authoritative Context Assembly
The ContextBroker is the single authority over prompt context
assembly on the operator path (rune_operator._execute_on_model).
Every injected context block is a registered contributor; admission is
budgeted against the model's real context length; every block is
measured; a trace records what was admitted, dropped, and why.
Authority invariant (docs/v1/AUTHORITY_MAP.md, context_construction):
no subsystem may inject model context outside the ContextBroker.
Architecture
task + session
│
▼
ContextBroker.assemble(task, session, model_id)
1. COLLECT — run every registered contributor once (in registration
order). Side effects (consolidation's _save_knowledge)
run here, exactly once per turn, BEFORE admission.
2. MEASURE — every block measured with the deterministic estimator
(len(text) // 4 — conservative for English prose).
3. ADMIT — deterministic: non-evictable blocks first (in order),
then evictable blocks by priority desc (tie: recency of
registration) while the input budget lasts. Dropped
blocks are recorded with token counts. A single
mandatory block over the whole budget raises
ContextOverflowError — never silently truncate.
4. RENDER — fixed order: system → tools → memory notes → history →
user. Returns (AttentionCapsule, trace).
The capsule carries the typed blocks ledger; capsule.to_messages()
re-derives [{system}, *history, {user}] exactly as
LocalToolRuntime.run consumes — so in-place mutation of a block (the
safety override decorating the final user message) always flows through.
Contributor table (operator registration, bucket = assembly order)
| # | Contributor | Priority | Evictable | Source | Content |
|---|---|---|---|---|---|
| 1 | persona |
100 | no | rune_operator._PERSONA_PROMPT |
static identity block (verbatim move) |
| 2 | tools |
90 | no | tool_executor.TOOLS |
## Available tools (names only) |
| 3 | memory |
70 | yes | memory_store.cached_recall(db, task, budget=3) |
## Context |
| 4 | persona_memories |
68 | yes | cached_recall(db, "Rune personality…", budget=3) |
## Identity context |
| 5 | consolidated |
60 | yes | MemoryConsolidation pipeline |
knowledge block; _save_knowledge() runs at collect time |
| 6 | neurons |
55 | yes | warm_neuron_store.list_neurons() |
## My neurons (warm, firing) top-5 |
| 7 | graphs |
50 | yes | graph_memory.resolve_entity |
## My graph connections top-5 |
| 8 | abstraction |
45 | yes | abstraction.connect() facts LIKE |
## My learned knowledge top-3 |
| 9 | model_profile |
40 | yes | model_profiles.get_prompt_context |
## Model |
| 10 | history |
30 | yes | rune_operator._conversation_messages |
last 8 completed pairs (compaction target) |
| 11 | user |
100 | no | task text | final user message (safety override decorates in place) |
Contributor failures are P0-06 T1 events: the selector records
record_failure(canonical_name, exc, tier=1) and returns nothing — the
turn proceeds, the failure is visible in the registry, the journal, and
response.degraded. An unexpected contributor crash is caught by the
broker itself and recorded under the contributor's name.
Budget math
window = context_length(model) # /api/show, never hardcoded
budget = window - max(2048, window // 4) # completion reserve
- window // 20 # 5% safety margin
- Discovery (
local_tool_runtime.context_length) readsmodel_info["<arch>.context_length"]from/api/show, cached per model per process (the same response also feeds the existing_model_capabilitiescache — one request, two facts). - On discovery failure: conservative fallback 8192 AND a T1
record_failure("context_discovery")— the assumption is observable, never silent. - Estimator:
len(text) // 4per block — deterministic by construction (tiktoken is deliberately not used so the number cannot drift between environments).
Compaction (80% threshold)
When the measured history exceeds 80% of the window
(COMPACTION_THRESHOLD = 0.8, LangChain deepagents semantics):
- ARCHIVE FIRST — the raw history is appended verbatim to
<rune_home>/state/history_archive.jsonl(one JSON object per event: timestamp, session, source, messages). Lossy compression without an external archive is forbidden. - All turns except the last 5 are replaced by ONE structured rolling
summary — session intent / decisions / open items / next steps — via
a compact summarization call (
num_predict 256). - The last 5 turns stay verbatim.
- Summarizer failure = T2
record_failure("history_compaction")→ no compaction that turn, history degrades to the last-8-turns verbatim window. Archive-write failure = T2history_archive→ compression refused (raw was not secured).
Compaction events ride the trace (trace["compaction"]).
Calibration (log-only)
After each operator request, the broker's measured total is compared
with the served prompt_eval_count (captured by the transport) and the
drift is logged on rune.context_broker:
calibration: measured=… served=… drift=+… model=… (log-only, admission never auto-adjusts)
Pinned decision: drift is never fed back into admission.
Absence tests (memory-off)
RUNE_MEMORY_OFF=1 (truthy values: 1/true/yes/on) zeroes the memory
contributors (memory, persona_memories, consolidated) at collect
time. The trace then proves the absence: trace["memory_tokens"] == 0
and no memory-named block appears in trace["blocks"]. The canary pins
this (check_memory_off_proves_zero_memory_tokens).
Trace shape
trace = {
"model_id", "window", "budget",
"blocks": [{name, priority, tokens, source, evictable, admitted, reason}, ...],
"admitted": [names...],
"admitted_total": int,
"dropped": [{name, tokens, priority, reason}, ...],
"memory_tokens": int, # 0 under the memory-off gate
"degraded": [subsystems...],
"compaction": [events...],
"elapsed_ms": float,
}
_log_token_usage is trace-backed (the old static=0 ghost accounting
is gone): static = admitted minus history minus user, dynamic =
history, user = user block, total = admitted_total.
Approval resume
When a turn ends in a pending approval, the capsule is embedded in
pending_state["context_capsule"] (JSON-serializable: token_count +
block ledger). The replayed pending_state["messages"] are the exact
assembled messages, so a resumed turn has identical context; the
embedded ledger documents what that context was.
Fallback tool prompt (runtime-owned, traced)
For models without native tools, LocalToolRuntime injects its
~2.5k-token fallback prompt into the system message. It stays
runtime-owned but is now traced: one log line per session per model
on rune.context_broker (fallback tool prompt injected …).
How to add a contributor
- Write a callback
(task, session) -> ContributionBlock | None. ReturnNone(or empty content) to contribute nothing; record your own failures withrecord_failure(<canonical-name>, exc, tier=1)and returnNone— never raise past the broker. - Register it on
RuneOperator._context_broker()with the next bucket and a priority that places it in the assembly order (higher wins budget; ties break by registration recency). - If it is memory-derived, gate it on
memory_disabled()and add its name toMEMORY_BLOCK_NAMESso the absence proof covers it. - Extend the frozen tests (
tests/test_context_broker.py) and the canary (src/rune/canary_context_broker.py) — the trace must show the new block admitted or dropped with token counts.
Pinned follow-ups
- num_ctx right-sizing per call site (14 hardcoded
num_ctx: 8192sites in 12 files): pinned follow-up per the innovation sequencing — the broker path now knows the real window (discovery + admission budget) but the runtimenum_ctxoptions are untouched in this stage. Until that lands, admission may budget against a window larger than the runtime'snum_ctx=8192— keep the follow-up tight. - Ambient-context bridge: the old
task._ambient_*reads in the operator were DEAD CODE (the TUI stores_ambient_*on itself; the operator read them off the task — always False). The broker path leaves ambient un-injected by decision; bridging it (or removing the TUI side) belongs to #20 / a broker decision follow-up, not here.