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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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]}]
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user