Files
rune/tests/test_context_broker.py
T
Nick a087a0b5e7
CI Pipeline / best-practices (push) Successful in 9s
CI Pipeline / documentation (push) Successful in 8s
Auto-version and Release / release (push) Successful in 39s
CI Pipeline / canary (push) Successful in 35s
CI Pipeline / lint (push) Successful in 42s
CI Pipeline / gitea-offline (push) Successful in 17s
CI Pipeline / unit-tests (push) Failing after 1m0s
CI Pipeline / type-check (push) Successful in 33s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 3m32s
fix(tests): isolate operator storage to per-test tempdirs — fixed /tmp paths collide on the CI runner
test_context_broker parity + new transport-selection tests used fixed /tmp
storage paths; the derived hot-store dirs (/tmp/hot etc.) are root-owned on
the CI runner → PermissionError on every write (observed live: 5 CI test
failures on 58af0b0). All operator storage now uses per-test tempdirs.
2026-09-12 04:30:18 +01:00

606 lines
24 KiB
Python

"""FROZEN tests for the ContextBroker (P0-03, #19).
These tests are the spec — implement verbatim. They pin:
1. typed-block assembly in the fixed order system -> tools -> memory ->
history -> user, within budget;
2. deterministic admission (priority desc, tie: registration recency)
with dropped blocks recorded with token counts;
3. fail-loud ContextOverflowError when a single mandatory block exceeds
the whole input budget (never silently truncate);
4. a per-block trace (name/tokens/source/admitted);
5. the memory-off absence proof (trace sums 0 memory tokens);
6. compaction: raw history archived externally BEFORE compression, last
5 turns verbatim, structured rolling summary present;
7. context-length discovery from /api/show (32000, not 8192);
8. log-only calibration drift against the served prompt_eval_count;
9. approval resume embedding the capsule in pending_state;
10. contributor failure recorded in the subsystem-health registry (T1);
11. thread-safe concurrent assembly;
12. operator characterization parity through the broker path.
"""
from __future__ import annotations
import copy
import json
import sys
import threading
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import context_broker, rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ( # noqa: E402
ContributionBlock,
ContextBroker,
ContextOverflowError,
compact_history,
default_budget,
log_calibration,
memory_disabled,
)
from rune.local_tool_runtime import ( # noqa: E402
OllamaChatTransport,
reset_context_length_cache,
)
from rune.subsystem_health import registry # noqa: E402
from rune.tool_protocol import OperatorResponse, ToolRequest # noqa: E402
def _block(name, priority, content, source="test", evictable=True):
return ContributionBlock(name, priority, content, 0, source, evictable=evictable)
def test_assembles_typed_blocks_in_order():
"""system -> tools -> memory -> history -> user; budget respected."""
broker = ContextBroker()
broker.register_contributor(
"persona",
lambda task, session: _block("persona", 100, "I am Rune.", evictable=False),
0,
)
broker.register_contributor(
"tools",
lambda task, session: _block("tools", 90, "## Available tools\nread_file", evictable=False),
1,
)
broker.register_contributor(
"memory",
lambda task, session: _block("memory", 70, "## Context\n- fact"),
2,
)
broker.register_contributor(
"history",
lambda task, session: _block("history", 30, [{"role": "assistant", "content": "prior answer"}]),
3,
)
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "current task", evictable=False),
4,
)
capsule, trace = broker.assemble(object(), None, budget_tokens=10_000)
assert isinstance(capsule, AttentionCapsule)
messages = capsule.to_messages()
assert [message["role"] for message in messages] == ["system", "assistant", "user"]
system = messages[0]["content"]
assert system.index("I am Rune.") < system.index("## Available tools")
assert system.index("## Available tools") < system.index("## Context")
assert messages[1] == {"role": "assistant", "content": "prior answer"}
assert messages[-1] == {"role": "user", "content": "current task"}
assert trace["admitted_total"] <= 10_000
assert set(trace["admitted"]) == {"persona", "tools", "memory", "history", "user"}
def test_drops_lowest_priority_over_budget():
"""Admission ledger records the drop with token counts."""
broker = ContextBroker()
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task", evictable=False),
4,
)
broker.register_contributor("high", lambda task, session: _block("high", 80, "h" * 40), 2)
broker.register_contributor("low", lambda task, session: _block("low", 20, "l" * 40), 2)
capsule, trace = broker.assemble(object(), None, budget_tokens=12)
# user (1 tok) + high (10 tok) fit; low (10 tok) would exceed the budget.
assert trace["admitted_total"] <= 12
assert "high" in trace["admitted"]
assert "low" not in trace["admitted"]
assert [dropped["name"] for dropped in trace["dropped"]] == ["low"]
assert trace["dropped"][0]["tokens"] == 10
assert [block.name for block in capsule.blocks] == ["user", "high"]
def test_single_block_over_budget_fails_loud():
"""ContextOverflowError — no silent truncation of a mandatory block."""
broker = ContextBroker()
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "x" * 400, evictable=False),
4,
)
with pytest.raises(ContextOverflowError):
broker.assemble(object(), None, budget_tokens=50)
def test_trace_records_per_block_tokens():
"""Every block: name/tokens/source/admitted — admitted and dropped."""
broker = ContextBroker()
broker.register_contributor(
"persona",
lambda task, session: _block("persona", 100, "I am Rune.", source="persona"),
0,
)
broker.register_contributor("memory", lambda task, session: _block("memory", 70, "m" * 400, source="recall"), 2)
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task", source="user", evictable=False),
4,
)
_, trace = broker.assemble(object(), None, budget_tokens=30)
by_name = {record["name"]: record for record in trace["blocks"]}
assert set(by_name) == {"persona", "memory", "user"}
for record in by_name.values():
assert set(record) >= {"name", "tokens", "source", "admitted"}
assert by_name["persona"]["source"] == "persona"
assert by_name["persona"]["tokens"] == len("I am Rune.") // 4
assert by_name["user"]["admitted"] is True
assert by_name["memory"]["admitted"] is False
assert by_name["memory"]["tokens"] == 100
def test_memory_off_proves_zero_memory_tokens(monkeypatch):
"""Memory-off gate zeroes recall/persona/consolidation; trace proves it."""
monkeypatch.setenv("RUNE_MEMORY_OFF", "1")
assert memory_disabled() is True
def gated(name, priority, source):
def contributor(task, session):
if memory_disabled():
return None
return _block(name, priority, "## Context\n- remembered", source=source)
return contributor
broker = ContextBroker()
broker.register_contributor("memory", gated("memory", 70, "recall"), 2)
broker.register_contributor("persona_memories", gated("persona_memories", 68, "recall"), 2)
broker.register_contributor("consolidated", gated("consolidated", 60, "consolidation"), 2)
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task", evictable=False),
4,
)
capsule, trace = broker.assemble(object(), None, budget_tokens=10_000)
assert trace["memory_tokens"] == 0
assert not any(block["name"] in context_broker.MEMORY_BLOCK_NAMES for block in trace["blocks"])
assert [block.name for block in capsule.blocks] == ["user"]
def test_compaction_archives_raw_and_keeps_last5(tmp_path):
"""Raw archived to file BEFORE compressing; summary block; last 5 verbatim."""
archive = tmp_path / "state" / "history_archive.jsonl"
summarized = []
def summarizer(messages):
summarized.append(list(messages))
return (
"Session intent: fix the broker\n"
"Decisions: admission is deterministic\n"
"Open items: none\n"
"Next steps: ship it"
)
messages = []
for index in range(8):
messages.append({"role": "user", "content": f"question {index} " + "w" * 1000})
messages.append({"role": "assistant", "content": f"answer {index} " + "w" * 1000})
compacted, event = compact_history(
messages,
window=2048,
archive_path=archive,
summarizer=summarizer,
source="test",
session_id="task-1",
)
assert event["triggered"] is True
assert event["tokens_after"] < event["tokens_before"]
assert archive.exists()
raw = json.loads(archive.read_text(encoding="utf-8").splitlines()[0])
assert raw["messages"] == messages # raw archived BEFORE any lossy step
assert raw["session_id"] == "task-1"
assert len(summarized) == 1 # one compact summarization call
assert len(compacted) == 11 # summary message + last 5 turns (10 messages)
assert compacted[1:] == messages[-10:] # last 5 turns verbatim
assert "## Conversation summary" in compacted[0]["content"]
assert "Session intent: fix the broker" in compacted[0]["content"]
def test_discovery_uses_real_context_length(monkeypatch):
"""/api/show reports 32000 — budget math uses 32000, not 8192."""
reset_context_length_cache()
context_broker._DISCOVERED.clear()
def fake_post(path, payload):
assert path == "/api/show"
return {"model_info": {"qwen2.context_length": 32000}, "capabilities": ["tools"]}
transport = OllamaChatTransport()
transport._post = fake_post # repo mock contract (instance-level)
assert transport.context_length("test-model") == 32000
broker = ContextBroker()
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task", evictable=False),
4,
)
capsule, trace = broker.assemble(object(), None, model_id="test-model")
assert trace["window"] == 32000
assert trace["budget"] == default_budget(32000) == 22400
assert trace["budget"] != default_budget(8192)
assert capsule.token_count <= trace["budget"]
def test_calibration_logs_drift(caplog):
"""Served prompt_eval_count vs broker estimate -> log record (LOG ONLY)."""
with caplog.at_level("INFO", logger="rune.context_broker"):
drift = log_calibration(measured_tokens=1000, prompt_eval_count=1250, model="qwen3:8b")
assert drift == 250
messages = [record.getMessage() for record in caplog.records]
assert any("calibration" in message and "drift=+250" in message for message in messages)
def test_approval_resume_embeds_capsule(tmp_path):
"""pending_state carries the capsule; the replayed messages are identical."""
operator = rune_operator.RuneOperator(str(tmp_path / "operator.json"))
request = ToolRequest("call-1", "write_file", {"path": "/tmp/a", "content": "new"}, "ollama-native")
captured = []
def fake_run(messages, **kwargs):
captured.append(copy.deepcopy(messages))
return OperatorResponse(
success=True,
tool_events=[],
pending_approval=request,
pending_state={
"messages": copy.deepcopy(messages),
"native_tools": True,
},
model="qwen3:8b",
)
runtime = Mock()
runtime.run.side_effect = fake_run
profile = SimpleNamespace(safety_tier=0)
with (
patch("rune.local_tool_runtime.context_length", return_value=8192),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime, create=True),
patch.object(rune_operator, "OllamaChatTransport", return_value=Mock(), create=True),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "get_prompt_context", return_value=""),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch("rune.memory_store.connect", return_value=Mock()),
patch("rune.memory_store.cached_recall", return_value=[]),
patch.object(operator, "_log_token_usage"),
):
response = operator._execute_on_model(
rune_operator.RuneTask("task-1", "inspect this", assigned_model="qwen3:8b"),
"qwen3:8b",
)
pending = response.pending_state
assert pending is not None
assert "context_capsule" in pending
capsule_state = pending["context_capsule"]
assert capsule_state["token_count"] > 0
assert any(block["name"] == "user" for block in capsule_state["blocks"])
assert any(block["name"] == "persona" for block in capsule_state["blocks"])
# Resume identity: the replayed messages are exactly what was assembled.
assert pending["messages"] == captured[0]
assert pending["messages"][-1]["role"] == "user"
assert "inspect this" in pending["messages"][-1]["content"]
assert "I am Rune" in pending["messages"][0]["content"]
def test_contributor_failure_records_health():
"""Broken selector -> record_failure tier-1 entry in the registry."""
subsystem = "failing_selector_frozen_test"
def broken(task, session):
raise RuntimeError("selector exploded")
broker = ContextBroker()
broker.register_contributor(subsystem, broken, 2)
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task", evictable=False),
4,
)
capsule, trace = broker.assemble(object(), None, budget_tokens=10_000)
entry = registry().snapshot()[subsystem]
assert entry["tier"] == 1
assert entry["failures"] >= 1
assert "selector exploded" in entry["last_error"]
assert subsystem not in [block["name"] for block in trace["blocks"]]
assert [block.name for block in capsule.blocks] == ["user"]
def test_thread_safety_concurrent_assemble():
"""2 threads x 10 assemblies each — no corruption."""
broker = ContextBroker()
broker.register_contributor("memory", lambda task, session: _block("memory", 70, "## Context\n- fact"), 2)
broker.register_contributor(
"user",
lambda task, session: _block("user", 100, "task text", evictable=False),
4,
)
results = []
errors = []
def worker():
try:
for _ in range(10):
capsule, trace = broker.assemble(object(), None, budget_tokens=10_000)
results.append((len(capsule.blocks), trace["admitted_total"], trace["budget"]))
except Exception as exc: # pragma: no cover - surfaced via assertion
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert errors == []
assert len(results) == 20
assert all(blocks == 2 and total > 0 and budget == 10_000 for blocks, total, budget in results)
assert len(broker.trace_log) == 20
def test_operator_characterization_parity():
"""Existing role-message assertions still hold via the broker path."""
import tempfile
operator = rune_operator.RuneOperator(str(Path(tempfile.mkdtemp()) / "parity-operator.json"))
runtime = Mock()
runtime.run.return_value = OperatorResponse(success=True, user_text="Final answer", model="qwen3:8b")
profile = SimpleNamespace(safety_tier=2)
memory_db = Mock()
with (
patch("rune.local_tool_runtime.context_length", return_value=8192),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime, create=True),
patch.object(rune_operator, "OllamaChatTransport", return_value=Mock(), create=True),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "get_prompt_context", return_value="model context"),
patch.object(rune_operator.model_profiles, "record_interaction") as record,
patch("rune.memory_store.connect", return_value=memory_db),
# P1-03 (#26): memory selection authority moved to the selector —
# script the store SOURCE there (same injected memory, same count).
patch.dict(
"rune.memory_selector.DEFAULT_SOURCES",
{
"store": lambda query, types, budget, min_score: [
{"text": "remembered fact", "score": 0.9, "kind": "store"}
]
},
clear=True,
),
patch.object(operator, "_log_token_usage"),
):
response = operator._execute_on_model(
rune_operator.RuneTask("task-1", "inspect this", assigned_model="qwen3:8b"),
"qwen3:8b",
)
assert response.success
assert response.recall_count == 1
messages = runtime.run.call_args.args[0]
assert [message["role"] for message in messages] == ["system", "user"]
assert "I am Rune" in messages[0]["content"]
assert "remembered fact" in messages[0]["content"]
assert "model context" in messages[0]["content"]
assert 'To use a tool, respond with: {"tool":' not in messages[0]["content"]
assert "inspect this" in messages[1]["content"]
record.assert_called_once_with("qwen3:8b", True, "inspect this")
# --- Quality-gate discharges (general-172) — each pins the fixed defect ---
def test_c3_fallback_window_never_cached():
"""C3: a transient discovery failure (8192 fallback) must NOT enter the
discovered-window registry — otherwise it poisons the process lifetime."""
from rune import context_broker as cb
cb._DISCOVERED.clear()
try:
with patch("rune.local_tool_runtime.context_length", return_value=8192):
window = cb.default_window("poisoned-model")
assert window == 8192, "fallback still returned"
assert "poisoned-model" not in cb._DISCOVERED, "fallback poisoned the registry"
# and a genuine discovery DOES register
with patch("rune.local_tool_runtime.context_length", return_value=32768):
assert cb.default_window("poisoned-model") == 32768
assert cb._DISCOVERED["poisoned-model"] == 32768
finally:
cb._DISCOVERED.clear()
def test_c1_rolling_summary_persists_and_archives_once():
"""C1: consecutive compactions reuse the rolled summary — one archive
write per NEW message batch, no duplicate re-archiving per turn."""
import tempfile
from rune import context_broker as cb
cb._ROLLING.clear()
messages = []
for i in range(20):
messages.append({"role": "user", "content": f"turn {i} question " + "x" * 200})
messages.append({"role": "assistant", "content": f"turn {i} answer " + "y" * 200})
calls = {"n": 0}
def fake_summarizer(msgs):
calls["n"] += 1
return f"summary v{calls['n']}: " + " ".join(m["content"][:20] for m in msgs[:2])
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / "history_archive.jsonl"
first, event1 = cb.compact_history(
messages,
8192,
archive_path=archive,
summarizer=fake_summarizer,
source="operator",
session_id="s1",
)
assert event1["triggered"] and event1["persisted"]
archived_first = archive.read_text().count("\n")
# second compaction over the SAME history (next turn re-derives it):
second, event2 = cb.compact_history(
messages,
8192,
archive_path=archive,
summarizer=fake_summarizer,
source="operator",
session_id="s1",
)
# Nothing new: summary reused, NO new archive line, NO new summarizer call.
assert event2["persisted"] and event2["turns_summarized"] == 0
assert archive.read_text().count("\n") == archived_first, "duplicate archive lines (C1 regression)"
assert calls["n"] == 1, "re-summarized the same window (C1 regression)"
# history GROWS by two turns -> only the new pair is archived+summarized
messages2 = messages + [
{"role": "user", "content": "new turn question " + "z" * 200},
{"role": "assistant", "content": "new turn answer " + "z" * 200},
]
third, event3 = cb.compact_history(
messages2,
8192,
archive_path=archive,
summarizer=fake_summarizer,
source="operator",
session_id="s1",
)
assert event3["turns_summarized"] == 2, "should summarize only the NEW turns"
assert calls["n"] == 2
assert archive.read_text().count("\n") == archived_first + 1
cb._ROLLING.clear()
def test_c2_no_dead_zone_history_compacts_at_budget():
"""C2: history between budget and 0.8*window must compact inside
assemble (the old window-based trigger left that band silently dropped
whole — no compaction, no archive, no history in the prompt)."""
import tempfile
from rune import context_broker as cb
cb._ROLLING.clear()
window = 32768
budget = cb.default_budget(window) # ~22938
messages = []
for i in range(30):
messages.append({"role": "user", "content": f"q{i} " + "a" * 1600})
messages.append({"role": "assistant", "content": f"a{i} " + "b" * 1600})
tokens = sum(cb.default_estimator(m["content"]) for m in messages)
assert budget < tokens < 0.8 * window, f"probe mis-sized: {tokens}"
broker = ContextBroker()
broker.register_contributor(
"history",
lambda t, s: ContributionBlock("history", 30, [dict(m) for m in messages], 0, "t"),
3,
)
with tempfile.TemporaryDirectory() as tmp:
broker._archive_path = Path(tmp) / "a.jsonl"
broker._summarizer = lambda msgs: "summary"
capsule, trace = broker.assemble(SimpleNamespace(description="t"), {}, budget_tokens=budget, session_id="dz")
assert trace["compaction"], "dead zone: over-budget history never compacted (C2)"
assert any(b["name"] == "history" for b in trace["blocks"] if b.get("admitted")), (
"history dropped whole despite compaction (C2)"
)
cb._ROLLING.clear()
def test_c2_summarizer_failure_keeps_fitting_tail():
"""C2: on summarizer failure the degrade returns a tail that FITS the
budget — not the whole over-budget history (which admission would drop)."""
from rune import context_broker as cb
window = 8192
budget = cb.default_budget(window)
messages = [{"role": "user", "content": "x" * 4000} for _ in range(10)] # ~10k tokens
compacted, event = cb.compact_history(
messages,
window,
summarizer=lambda m: (_ for _ in ()).throw(RuntimeError("no model")),
source="operator",
session_id="deg",
)
assert event["failed"]
tail_tokens = sum(cb.default_estimator(m["content"]) for m in compacted)
assert tail_tokens <= budget, f"degraded tail {tail_tokens} still over budget {budget}"
assert len(compacted) < len(messages)
def test_i1_collective_mandatory_overflow_raises():
"""I1: several mandatory blocks that individually fit but collectively
exceed the budget must raise ContextOverflowError."""
from rune.context_broker import ContextBroker, ContributionBlock, ContextOverflowError
broker = ContextBroker()
broker.register_contributor(
"big_user", lambda t, s: ContributionBlock("user", 100, "u" * 400, 0, "t", evictable=False), 4
)
broker.register_contributor(
"big_persona", lambda t, s: ContributionBlock("persona", 100, "p" * 400, 0, "t", evictable=False), 0
)
try:
broker.assemble(SimpleNamespace(description="task"), {}, budget_tokens=150)
raise AssertionError("collective overflow was silent (I1 regression)")
except ContextOverflowError:
pass
def test_i5_malformed_contributor_content_is_t1_failure():
"""I5: a contributor returning malformed content (None content / int) is
a recorded T1 failure + degrade, not a raw TypeError crash."""
from rune.context_broker import ContextBroker, ContributionBlock
broker = ContextBroker()
broker.register_contributor("broken", lambda t, s: ContributionBlock("broken", 50, None, 0, "t"), 1)
broker.register_contributor("ok", lambda t, s: ContributionBlock("ok", 50, "fine", 0, "t"), 1)
capsule, trace = broker.assemble(SimpleNamespace(description="t"), {}, budget_tokens=1000)
assert "broken" in trace["degraded"], "malformed content not recorded as T1 degrade"
assert "ok" in trace["admitted"], "healthy contributor lost"
from rune.subsystem_health import registry as get_registry
assert get_registry().snapshot()["broken"]["failures"] >= 1, "no registry entry for malformed content"