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
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.
606 lines
24 KiB
Python
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"
|