refactor: retire the KB-recall bridge — WT #383 native reference_knowledge (b167)

Worldtree #383 shipped native Tier-3 reference_knowledge (v1.0.0b167, live on :8081 +
demo): every Tier-3 agent context now carries the tool automatically, with evidence
packets (note_id + path provenance, confidence bucket) and a server-side grounding
rule. That supersedes the interim consumer-side memory_context pinning bridge (slice
3), so it is deleted per its INV-KB-1 retire seam.

Removed:
- src/ratatoskr/kb_bridge.py + tests/test_kb_bridge.py (the whole module).
- server.py: the pin_kb_context import + the single turn-path call-site (reverted to
  the pre-bridge wt.stream_turn call), the SSE keepalive that only covered the consult
  delay, and the bridge-only agent_id plumbing (TurnHandle.agent_id + the submit read).
- index.html: agent_id dropped from the turn POST body.
- test_web_server.py: TestKbBridgeWiring (tested the removed call-site).

Kept:
- wt.stream_turn's memory_context param (inert SDK-parity passthrough; worldtree-dev
  concurred it stays) + its forwarding tests.
- the non-str content 400 guard (general input hygiene, not bridge-specific).

Retirement LIVE-VERIFIED before deletion: a Donut session on :8081/b167 carries
builtin_tools=['reference_knowledge']; she called it and grounded in the DCC Collapse
content fully in-voice, degrading gracefully on absent content. 524 green.

Contract marks slice-3 RETIRED (historical record retained). #383 closed.
This commit is contained in:
2026-08-01 23:21:44 -07:00
parent f537416f15
commit 09e425787b
6 changed files with 18 additions and 449 deletions
@@ -40,6 +40,19 @@ confidence: 0.8
# Contract: Donut voiced interview (auto-TTS + KB-recall bridge)
> **⚠ SLICE 3 (KB-recall bridge) RETIRED 2026-08-02.** The `kb_bridge.py` module +
> its single `web/server.py` call-site were deleted per INV-KB-1 when Worldtree #383
> shipped native Tier-3 `reference_knowledge` (v1.0.0b167, live on :8081 + demo).
> Donut now searches the fiction wing (DCC corpus) natively, in-turn, with evidence
> packets (note_id + path provenance, confidence bucket) and a server-side grounding
> rule — strictly better than the consumer-side memory_context pinning it replaced
> (no separate consult round-trip, not gated by our out-of-band prompt). Retirement
> live-verified: Donut called `reference_knowledge` and grounded in DCC in-voice
> before deletion. `wt.stream_turn`'s `memory_context` param was KEPT (inert SDK
> parity). The DEC-6 / INV-KB-* / FN pin_kb_context sections below are retained as
> historical record of what was built and why it retired. **Slices 1 (persona) + 2
> (auto-TTS) remain LIVE.**
Migration-style contract: three separable slices (persona / TTS / KB-bridge),
each independently shippable. Slice order is chosen for fastest visible result.
-140
View File
@@ -1,140 +0,0 @@
"""Retire-ready KB-recall bridge (slice 3 of the Donut voiced-interview contract).
Tier-3 agents are DELIBERATELY tool-less in Worldtree v1 (build_tier3_agent_context
hard-codes tool_schemas=[]); a character cannot search the corpus herself. This is
the consumer-side workaround (DEC-6, worldtree-dev ruling, wyrd-proven): per interview
turn, ratatoskr consults Mimir (the Well-of-Knowledge agent) out-of-band, extracts the
grounded passages, and pins them as `memory_context` on the character's turn. She
answers in-voice, framing the pinned corpus as her own memory (the persona prompt
instructs exactly this).
RETIRE-READY (INV-KB-1): isolated behind ONE seam — `pin_kb_context` — called from
exactly one call-site (web/server.py's turn path). Retiring the bridge = delete this
module + that call-site; `wt.stream_turn`'s `memory_context` param stays (SDK-parity,
inert). This module imports nothing from the TTS or SDK-adapter core
(`ratatoskr.wt` / `ratatoskr.tts`); it drives the passed-in WorldtreeClient directly.
Delete when Worldtree #361 `reference_knowledge` extends native retrieval to Tier-3.
Foot-gun (verified live 2026-08-02): an UNCONSTRAINED Mimir consult loops
read_note↔search_library and can run >60s without converging. The CONSTRAINED
"one search, passages verbatim, no iterative reading" prompt converges in ~10s. The
consult is HARD-bounded by a timeout and degrades to [] (INV-KB-3), so a slow/looping
consult never hangs the character's turn.
"""
from __future__ import annotations
import asyncio
from contextlib import aclosing
from worldtree_sdk import DoneEvent, TextEvent, WorldtreeClient
# INV-KB-4: only interview characters that need corpus recall pay the retrieval
# round-trip. Maps each opted-in agent_id → the corpus name that scopes the search.
_KB_INTERVIEW_CORPORA = {"ratatoskr:donut": "Dungeon Crawler Carl"}
# The KB agent driven for retrieval (Worldtree's Well-of-Knowledge custodian).
_MIMIR_AGENT_ID = "mimir"
# A fixed partition for the out-of-band consult, isolated from the character's own
# end-user partition. The retrieval channel is never persisted (INV-KB-2): the
# consult is throwaway and its result rides only the character's turn POST body.
_KB_BRIDGE_END_USER = "ratatoskr-kb-bridge"
# Hard ceiling on the consult (degrade to [] past it). The single-search prompt
# below converges in ~3-4s live; the headroom absorbs gateway variance. An
# unconstrained consult loops read_note↔search_library and blows well past this.
_CONSULT_TIMEOUT_S = 20.0
# Bound the pinned extract; a whole-note dump would bloat the character's prompt.
_MAX_EXTRACT_CHARS = 2000
# A no-hit sentinel the consult prompt asks Mimir to emit when the search finds nothing
# relevant, so a non-empty "no results found" answer isn't pinned as corpus evidence and
# voiced as the character's own memory (heid-code-review F7). The token is artificial —
# no real corpus passage contains it — so a substring check is safe, not a fragile heuristic.
_NO_MATCH_SENTINEL = "NO_CORPUS_MATCH"
def _consult_prompt(question: str, corpus: str) -> str:
"""The CONSTRAINED consult (the foot-gun mitigation, tuned live 2026-08-02):
force EXACTLY ONE search_library call and forbid read_note. This is what makes
Mimir converge in ~3-4s — the softer "do one search" phrasing still let it loop
read_note↔search past the 25s ceiling on conversational (non-keyword) questions."""
return (
f"You may call search_library EXACTLY ONCE and call no other tool "
f"(do NOT call read_note). Query: {question} (corpus: {corpus}). "
f"After the single search returns, immediately output the most relevant "
f"result excerpts verbatim and stop. If the search returns nothing relevant, "
f"reply with exactly {_NO_MATCH_SENTINEL} and nothing else."
)
async def _run_consult(client: WorldtreeClient, prompt: str, created: list[str]) -> str:
"""Drive one throwaway Mimir turn and return its grounded answer text. Prefers
the DoneEvent's authoritative full `response`, falling back to the accumulated
text deltas if the terminal event carried none.
`created` is a caller-owned list this appends the new session id to as soon as it
exists, so the caller can delete the session even when a timeout cancels this
coroutine mid-stream (the id would otherwise be lost in this frame)."""
session = await client.sessions.create(
{"agent_id": _MIMIR_AGENT_ID, "end_user_id": _KB_BRIDGE_END_USER}
)
session_id = session["session_id"]
created.append(session_id)
parts: list[str] = []
final = ""
# aclosing: we break on DoneEvent without exhausting the stream; deterministically
# close the SDK generator so it can't finalize mid-flight against a closing
# transport (the "async generator already running" race the live smoke surfaced).
async with aclosing(client.sessions.stream_turn(session_id, prompt)) as stream:
async for ev in stream:
if isinstance(ev, TextEvent) and isinstance(ev.content, str):
parts.append(ev.content)
elif isinstance(ev, DoneEvent):
if isinstance(ev.response, str) and ev.response.strip():
final = ev.response
break
return (final or "".join(parts)).strip()
async def pin_kb_context(
question: str, agent_id: str | None, *, client: WorldtreeClient
) -> list[dict]:
"""Consumer-side retrieval + memory_context pinning (FN pin_kb_context / DEC-6).
Returns memory_context items for the character's turn, or [] on any miss. Total:
an allowlist miss, blank question, empty/failed/timed-out consult, or ANY
exception → [] — never raises to the turn path (INV-KB-3), never blocks the turn.
CancelledError (a browser disconnect) is intentionally NOT caught — it propagates.
"""
corpus = _KB_INTERVIEW_CORPORA.get(agent_id or "")
if corpus is None: # INV-KB-4: not an opted-in interview character
return []
if not isinstance(question, str) or not question.strip():
return []
created: list[str] = []
try:
extract = await asyncio.wait_for(
_run_consult(client, _consult_prompt(question, corpus), created),
timeout=_CONSULT_TIMEOUT_S,
)
except Exception:
# timeout / transport / protocol / any consult failure → degrade silently
# (INV-KB-3). CancelledError is a BaseException, so it is NOT swallowed here.
extract = ""
finally:
# Delete the throwaway consult session so they don't accumulate server-side under
# the fixed partition (heid bug-hunt). Runs on success, error, AND timeout — the
# timeout is caught above, so this executes in a non-cancelled context and the
# delete completes. Best-effort; a delete failure never masks the result. On a
# propagating CancelledError (browser disconnect) the session may outlive us —
# acceptable, Worldtree GCs it; the invariant is that CancelledError still propagates.
for sid in created:
try:
await client.sessions.delete(sid)
except Exception:
pass
if not extract or _NO_MATCH_SENTINEL in extract:
# Empty, OR Mimir signalled no relevant hit — don't pin a non-answer as memory
# (heid-code-review F7). The sentinel is artificial, so `in` can't false-positive
# on a genuine passage.
return []
return [{"kind": "corpus_reference", "text": extract[:_MAX_EXTRACT_CHARS]}]
+4 -31
View File
@@ -37,7 +37,6 @@ from worldtree_sdk import (
from ratatoskr import local_agents as _local_agents
from ratatoskr import wt
from ratatoskr.first_message import seed_preset_first_message
from ratatoskr.kb_bridge import pin_kb_context
from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
@@ -272,10 +271,6 @@ class TurnHandle:
content: str
status: str = "queued" # queued | streaming | done | error | cancelled
upstream_turn_id: int | None = None
# The browser-selected agent for this session — carried so the turn path can gate
# the retire-ready KB-recall bridge on the interview-character allowlist (INV-KB-4).
# Optional: absent (older client / non-character turn) simply means no KB consult.
agent_id: str | None = None
# Process-local monotonic turn_id counter. Per FN submit_turn_endpoint
@@ -294,19 +289,13 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
body = await request.json()
content = body.get("content") if isinstance(body, dict) else None
if not content or not isinstance(content, str):
# Require a non-blank STRING: a truthy non-str (e.g. {"content": {...}}) would
# pass a bare falsy check, then crash pin_kb_context's question.strip() mid-stream
# instead of a deterministic 400 (heid bug-hunt: non-str content).
# Require a non-blank STRING; a truthy non-str (e.g. {"content": {...}}) gets a
# deterministic 400 rather than reaching the SDK turn call as a bad type.
return JSONResponse({"error_code": "missing_content"}, status_code=400)
session_id = request.path_params["session_id"]
# The browser may include agent_id so the stream path can gate the KB-recall
# bridge (INV-KB-4); optional — absent → no consult (a non-str is ignored).
agent_id = body.get("agent_id") if isinstance(body, dict) else None
if not isinstance(agent_id, str):
agent_id = None
turn_id = next(_TURN_COUNTER)
request.app.state.turn_registry[(session_id, turn_id)] = TurnHandle(
session_id=session_id, turn_id=turn_id, content=content, agent_id=agent_id,
session_id=session_id, turn_id=turn_id, content=content,
)
return JSONResponse({"turn_id": turn_id}, status_code=200)
@@ -377,24 +366,8 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
wt_client = _wt_client(client)
try:
handle.status = "streaming"
# Open the SSE stream immediately with a comment frame BEFORE the KB consult
# (which can take up to _CONSULT_TIMEOUT_S). Without a first byte, a reverse
# proxy or the browser EventSource can drop a silent connection → a false
# "WIRE LOST" before the real turn even starts (heid bug-hunt). SSE comment
# frames (":"-prefixed) are ignored by EventSource, so this is inert to the UI.
yield b": keepalive\n\n"
# Retire-ready KB-recall bridge (INV-KB-1: THE single call-site). Consumer-side
# Mimir retrieval for opted-in interview characters (INV-KB-4); [] for everyone
# else. Bounded + degrade-to-[] internally (INV-KB-3), so it never blocks or
# fails the turn — a miss just streams the turn with no pinned corpus context.
memory_context = await pin_kb_context(
handle.content, handle.agent_id, client=wt_client
)
try:
async for event in wt.stream_turn(
wt_client, session_id, handle.content,
memory_context=memory_context or None,
):
async for event in wt.stream_turn(wt_client, session_id, handle.content):
# v0.16.0: capture the upstream (Worldtree-assigned) turn_id from
# the first event so cancel paths target the real upstream turn,
# not our local counter — parsed from the composite sse_id.
+1 -1
View File
@@ -1715,7 +1715,7 @@ async function submitPrompt() {
try {
r = await fetch("/api/turns/" + encodeURIComponent(state.sessionId), {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, agent_id: state.agentId || null }),
body: JSON.stringify({ content }),
});
} catch (e) { tickerAdd("err", "submit failed", "network_error"); return; }
if (r.status !== 200) { tickerAdd("err", "submit failed", "status=" + r.status); return; }
-195
View File
@@ -1,195 +0,0 @@
"""Tests for ratatoskr.kb_bridge per docs/contracts/donut_voiced_interview.contract.md
(slice 3 — the retire-ready KB-recall bridge, FN pin_kb_context).
The bridge drives a passed-in WorldtreeClient; unit tests use a fake client + real
SDK event instances. No live network.
Invariants exercised:
- INV-KB-3: any miss / failure / timeout / exception → [], never raises to the turn.
- INV-KB-4: only the opted-in interview character(s) consult; others short-circuit.
- The consult prompt is the CONSTRAINED shape (the >60s-loop foot-gun mitigation).
"""
import asyncio
import pytest
from worldtree_sdk import DoneEvent, TextEvent
from ratatoskr import kb_bridge
from ratatoskr.kb_bridge import pin_kb_context
_DONUT = "ratatoskr:donut"
def _text(s: str) -> TextEvent:
return TextEvent(type="text", sse_id="2:1", turn_id=2, raw={"content": s}, content=s)
def _done(response: str = "") -> DoneEvent:
return DoneEvent(
type="done", sse_id="2:9", turn_id=2, raw={}, phase="Finishing",
response=response, model="gen", duration_ms=1, usage={},
)
class _FakeSessions:
def __init__(self, *, events=None, create_result=None, stream_error=None,
create_error=None, stream_delay=0.0):
self.events = events or []
self.create_result = create_result or {"session_id": "kb-sess-1"}
self.stream_error = stream_error
self.create_error = create_error
self.stream_delay = stream_delay
self.calls: list = []
self.deleted: list = []
async def create(self, body=None, **kw):
self.calls.append(("create", body, kw))
if self.create_error is not None:
raise self.create_error
return self.create_result
async def delete(self, session_id):
self.deleted.append(session_id)
def stream_turn(self, *args, **kw):
self.calls.append(("stream_turn", args, kw))
return self._gen()
async def _gen(self):
if self.stream_delay:
await asyncio.sleep(self.stream_delay)
for e in self.events:
yield e
if self.stream_error is not None:
raise self.stream_error
class _FakeClient:
def __init__(self, sessions: _FakeSessions):
self.sessions = sessions
class TestAllowlistGate:
async def test_unlisted_agent_returns_empty_without_consulting(self) -> None:
fake = _FakeSessions(events=[_text("x"), _done("x")])
out = await pin_kb_context("who is Carl?", "mimir", client=_FakeClient(fake))
assert out == []
assert fake.calls == [] # INV-KB-4: no consult for a non-interview agent
async def test_none_agent_returns_empty(self) -> None:
fake = _FakeSessions()
out = await pin_kb_context("q", None, client=_FakeClient(fake))
assert out == [] and fake.calls == []
async def test_blank_question_returns_empty(self) -> None:
fake = _FakeSessions()
out = await pin_kb_context(" ", _DONUT, client=_FakeClient(fake))
assert out == [] and fake.calls == []
async def test_non_string_question_returns_empty(self) -> None:
# A non-str question (e.g. a dict slipping past an upstream guard) must not
# crash on .strip() — it degrades to [] like any other miss.
fake = _FakeSessions()
out = await pin_kb_context({"x": 1}, _DONUT, client=_FakeClient(fake))
assert out == [] and fake.calls == []
class TestConsult:
async def test_happy_pins_done_response_as_corpus_reference(self) -> None:
fake = _FakeSessions(events=[_text("partial "), _done("The full grounded passage.")])
out = await pin_kb_context("what happened?", _DONUT, client=_FakeClient(fake))
assert out == [{"kind": "corpus_reference", "text": "The full grounded passage."}]
async def test_falls_back_to_text_deltas_when_no_done_response(self) -> None:
fake = _FakeSessions(events=[_text("alpha "), _text("beta"), _done("")])
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == [{"kind": "corpus_reference", "text": "alpha beta"}]
async def test_constrained_consult_prompt_and_mimir_target(self) -> None:
fake = _FakeSessions(events=[_done("p")])
await pin_kb_context("who is Princess Donut?", _DONUT, client=_FakeClient(fake))
create_body = fake.calls[0][1]
assert create_body["agent_id"] == "mimir"
stream_prompt = fake.calls[1][1][1] # ("stream_turn", (sid, prompt), {})
# The foot-gun mitigation: EXACTLY one search_library call, and read_note FORBIDDEN.
# Assert the distinguishing phrase, not the bare token "read_note" — a mitigation-
# inverted prompt ("DO call read_note") would still contain the token (heid-code-review).
assert "EXACTLY ONCE" in stream_prompt
assert "do NOT call read_note" in stream_prompt # forbids the loop tool (negation)
assert "Dungeon Crawler Carl" in stream_prompt # corpus scoping
assert "who is Princess Donut?" in stream_prompt # the question
assert "NO_CORPUS_MATCH" in stream_prompt # the no-hit sentinel instruction
async def test_empty_consult_returns_empty(self) -> None:
fake = _FakeSessions(events=[_text(" "), _done(" ")])
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == []
async def test_no_match_sentinel_pins_nothing(self) -> None:
# A non-empty "no results" answer carrying the sentinel must NOT be pinned as
# corpus memory (heid-code-review F7) — even wrapped in other prose.
fake = _FakeSessions(events=[_done("I searched but found NO_CORPUS_MATCH here.")])
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == []
async def test_extract_bounded_to_max_chars(self) -> None:
long = "x" * 5000
fake = _FakeSessions(events=[_done(long)])
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
# Assert the concrete bound (2000), not the impl constant it's sliced by — a
# mutated constant must fail this (heid-code-review: non-circular assertion).
assert len(out[0]["text"]) == 2000
class TestDegradation:
async def test_stream_error_degrades_to_empty(self) -> None:
fake = _FakeSessions(events=[_text("x")], stream_error=RuntimeError("boom"))
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == [] # INV-KB-3: never raises to the turn path
async def test_create_error_degrades_to_empty(self) -> None:
fake = _FakeSessions(create_error=RuntimeError("no session"))
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == []
async def test_timeout_degrades_to_empty(self, monkeypatch) -> None:
monkeypatch.setattr(kb_bridge, "_CONSULT_TIMEOUT_S", 0.05)
fake = _FakeSessions(events=[_done("late")], stream_delay=5.0)
out = await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert out == [] # a slow/looping consult never hangs the turn
async def test_cancellation_is_not_swallowed(self) -> None:
# CancelledError (a browser disconnect) MUST propagate, not degrade to [].
fake = _FakeSessions(create_error=asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
class TestSessionCleanup:
"""The throwaway Mimir consult session is deleted so consults don't accumulate
server-side (heid bug-hunt) — on success, error, AND timeout."""
async def test_deletes_session_on_success(self) -> None:
fake = _FakeSessions(events=[_done("grounded")])
await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert fake.deleted == ["kb-sess-1"]
async def test_deletes_session_on_stream_error(self) -> None:
fake = _FakeSessions(events=[_text("x")], stream_error=RuntimeError("boom"))
await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert fake.deleted == ["kb-sess-1"] # cleaned up despite the failure
async def test_deletes_session_on_timeout(self, monkeypatch) -> None:
monkeypatch.setattr(kb_bridge, "_CONSULT_TIMEOUT_S", 0.05)
fake = _FakeSessions(events=[_done("late")], stream_delay=5.0)
await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
# The holder pattern makes the id available even though the timeout cancelled
# the consult mid-stream; the finally deletes it in the caught (non-cancelled) path.
assert fake.deleted == ["kb-sess-1"]
async def test_no_delete_when_create_failed(self) -> None:
# Nothing was created → nothing to delete (created list stays empty).
fake = _FakeSessions(create_error=RuntimeError("no session"))
await pin_kb_context("q", _DONUT, client=_FakeClient(fake))
assert fake.deleted == []
-82
View File
@@ -1575,85 +1575,3 @@ class TestTtsEndpoint:
)
assert resp.status_code == 200
class TestKbBridgeWiring:
"""The KB-recall bridge seam (slice 3, INV-KB-1): the turn path calls
pin_kb_context and forwards its result as memory_context on the turn POST.
Proves the ONE call-site is wired end-to-end (the consult itself is unit-tested
in test_kb_bridge.py; here we stub it to assert the plumbing)."""
@respx.mock
def test_interview_agent_pins_context_into_turn_post(self, monkeypatch) -> None:
from ratatoskr.web import server
from ratatoskr.web.server import create_app
seen = {}
async def _fake_pin(question, agent_id, *, client):
seen["question"] = question
seen["agent_id"] = agent_id
return [{"kind": "corpus_reference", "text": "grounded passage"}]
monkeypatch.setattr(server, "pin_kb_context", _fake_pin)
route = respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=_sse_resp(_sse_chunk("42:1", _DONE_BODY))
)
c = TestClient(create_app(_mock_client_factory()))
tid = c.post(
"/api/turns/s-1",
json={"content": "who is Carl?", "agent_id": "ratatoskr:donut"},
).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp:
b"".join(resp.iter_bytes())
# The browser-sent agent_id reached the bridge, and its result rode the POST.
assert seen == {"question": "who is Carl?", "agent_id": "ratatoskr:donut"}
body = json.loads(route.calls.last.request.content)
assert body["memory_context"] == [
{"kind": "corpus_reference", "text": "grounded passage"}
]
@respx.mock
def test_non_interview_agent_pins_nothing(self) -> None:
# Real pin_kb_context: a non-allowlisted agent short-circuits to [] (no consult),
# so memory_context is absent from the turn POST body (SDK omits None).
from ratatoskr.web.server import create_app
route = respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=_sse_resp(_sse_chunk("42:1", _DONE_BODY))
)
c = TestClient(create_app(_mock_client_factory()))
tid = c.post(
"/api/turns/s-1", json={"content": "hi", "agent_id": "mimir"}
).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp:
b"".join(resp.iter_bytes())
body = json.loads(route.calls.last.request.content)
assert "memory_context" not in body or body["memory_context"] is None
@respx.mock
def test_failed_consult_still_streams_the_turn(self, monkeypatch) -> None:
# A degraded/failed KB consult (returns []) must NOT block or fail the character
# turn — it streams to `done` with no pinned context (INV-KB-3, server-level;
# heid-code-review F9: the turn-path failure seam was untested end to end).
from ratatoskr.web import server
from ratatoskr.web.server import create_app
async def _degraded_pin(question, agent_id, *, client):
return [] # consult failed/timed-out internally → degraded to []
monkeypatch.setattr(server, "pin_kb_context", _degraded_pin)
respx.post("https://w.example/sessions/s-1/messages").mock(
return_value=_sse_resp(
_sse_chunk("42:1", {"type": "text", "content": "hi"})
+ _sse_chunk("42:2", _DONE_BODY)
)
)
c = TestClient(create_app(_mock_client_factory()))
tid = c.post(
"/api/turns/s-1",
json={"content": "who is Carl?", "agent_id": "ratatoskr:donut"},
).json()["turn_id"]
with c.stream("GET", f"/api/turns/s-1/stream?turn_id={tid}") as resp:
raw = b"".join(resp.iter_bytes())
events = [e["event"] for e in _parse_browser_sse(raw)]
assert "text" in events and "done" in events # the turn completed normally