Files
rune/docs/context-broker.md
T
Nick 770a66372c feat(context): ContextBroker — authoritative context assembly with admission, trace, compaction (#19)
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.
2026-09-05 05:04:08 +01:00

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) reads model_info["<arch>.context_length"] from /api/show, cached per model per process (the same response also feeds the existing _model_capabilities cache — 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) // 4 per 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):

  1. 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.
  2. 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).
  3. The last 5 turns stay verbatim.
  4. Summarizer failure = T2 record_failure("history_compaction") → no compaction that turn, history degrades to the last-8-turns verbatim window. Archive-write failure = T2 history_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

  1. Write a callback (task, session) -> ContributionBlock | None. Return None (or empty content) to contribute nothing; record your own failures with record_failure(<canonical-name>, exc, tier=1) and return None — never raise past the broker.
  2. 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).
  3. If it is memory-derived, gate it on memory_disabled() and add its name to MEMORY_BLOCK_NAMES so the absence proof covers it.
  4. 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: 8192 sites in 12 files): pinned follow-up per the innovation sequencing — the broker path now knows the real window (discovery + admission budget) but the runtime num_ctx options are untouched in this stage. Until that lands, admission may budget against a window larger than the runtime's num_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.