Coverage for src/lilbee/server/handlers/rag.py: 100%
389 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Search, ask, and chat handlers (one-shot and streaming)."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import logging
8import threading
9from collections.abc import AsyncGenerator, AsyncIterator, Callable
10from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
12from lilbee.app.memory import auto_extract, auto_extract_enabled
13from lilbee.app.search import clean_results, source_labels
14from lilbee.app.services import get_services
15from lilbee.core.config import cfg
16from lilbee.core.results import DocumentResult, group
17from lilbee.data.store import ChunkType, EmbeddingModelMismatchError
18from lilbee.providers.base import ProviderError, ProviderErrorKind
19from lilbee.providers.roles import WorkerRole
20from lilbee.retrieval.query.compaction import (
21 compaction_due,
22 foldable,
23 history_budget,
24 prompt_history,
25)
26from lilbee.retrieval.query.formatting import (
27 StreamingCitationFilter,
28 cited_subset,
29 strip_llm_citations,
30)
31from lilbee.retrieval.query.searcher import (
32 GROUNDED_REFUSAL,
33 SEARCH_NEEDS_EMBEDDER,
34 RagContext,
35)
36from lilbee.retrieval.reasoning import (
37 CAP_NOTICE_TEMPLATE,
38 REASONING_EXHAUSTED_NOTICE,
39 CapNotice,
40 StreamToken,
41 TagParser,
42 effective_reasoning_cap,
43 stream_chat_with_cap,
44 strip_reasoning,
45)
46from lilbee.runtime.progress import SseErrorCode, SseEvent
47from lilbee.server.chat_completions_api.errors import (
48 _BACKEND_FAILURE_MESSAGE,
49 _INFRASTRUCTURE_KINDS,
50 CompletionsErrorCode,
51)
52from lilbee.server.chat_dispatch.canonical import (
53 CanonicalChatRequest,
54 CanonicalMessage,
55 ContentBlockDelta,
56 TextBlock,
57 TextDelta,
58)
59from lilbee.server.chat_dispatch.dispatch import (
60 ModelDoesNotSupportToolsError,
61 ModelNotFoundError,
62 dispatch_chat,
63 dispatch_chat_stream,
64)
65from lilbee.server.chat_dispatch.reasoning_cap import nudged_request
66from lilbee.server.handlers.sse import (
67 SseErrorCodeValue,
68 SseStream,
69 _resolve_generation_options,
70 classify_load_error,
71 sse_done,
72 sse_error,
73 sse_event,
74)
75from lilbee.server.models import (
76 AskResponse,
77 CleanedChunk,
78 CompactionInfo,
79 MemoryExtractedEvent,
80 MemoryExtractedItem,
81)
82from lilbee.sessions import SessionNotFoundError, sessions_enabled
84if TYPE_CHECKING:
85 from lilbee.core.results import SearchChunk
86 from lilbee.retrieval.query import ChatMessage
87 from lilbee.retrieval.query.searcher import Searcher
89log = logging.getLogger(__name__)
92# Unmapped kinds surface as their ProviderErrorKind string; shipped clients branch
93# on it. The kinds that describe the backend keep their code and lose their text
94# (see _classify_stream_error), so a client can still branch without being handed
95# engine internals.
96_STREAM_KIND_CODES: dict[ProviderErrorKind, CompletionsErrorCode] = {
97 ProviderErrorKind.CONTEXT_OVERFLOW: CompletionsErrorCode.CONTEXT_LENGTH_EXCEEDED,
98 ProviderErrorKind.NOT_FOUND: CompletionsErrorCode.MODEL_NOT_FOUND,
99}
102def _classify_stream_error(exc: BaseException) -> tuple[SseErrorCodeValue | None, str]:
103 """Return ``(code, user_message)`` for an SSE error event, typed-exception aware."""
104 if isinstance(exc, ModelNotFoundError):
105 return CompletionsErrorCode.MODEL_NOT_FOUND, str(exc)
106 if isinstance(exc, ModelDoesNotSupportToolsError):
107 return CompletionsErrorCode.MODEL_DOES_NOT_SUPPORT_TOOLS, str(exc)
108 if isinstance(exc, ProviderError):
109 mapped = _STREAM_KIND_CODES.get(exc.kind)
110 if mapped is not None:
111 return mapped, str(exc)
112 code = None if exc.kind is ProviderErrorKind.UNKNOWN else exc.kind
113 if exc.kind in _INFRASTRUCTURE_KINDS:
114 # Kinds that describe the backend rather than the request. Their text
115 # is built at the fleet boundary and carries the dead engine's stderr,
116 # so it is logged rather than sent, exactly as the completions surface
117 # already does. Both surfaces answer to the same set.
118 log.warning("Backend failure on the stream surface: %s", exc)
119 return code, _BACKEND_FAILURE_MESSAGE
120 return code, str(exc)
121 return classify_load_error(str(exc))
124async def search(
125 q: str, top_k: int | None = None, chunk_type: ChunkType | None = None
126) -> list[DocumentResult]:
127 """Search and return grouped DocumentResults.
129 An omitted ``top_k`` resolves to ``cfg.top_k``, read live at call time.
130 """
131 if not q or not q.strip():
132 raise ValueError("query must not be empty")
133 resolved_top_k = top_k if top_k is not None else cfg.top_k
134 # search() blocks on retrieval; run it off the event loop so other admitted
135 # requests stay responsive, matching the sibling ask() handler.
136 results = await asyncio.to_thread(
137 get_services().searcher.search, q, top_k=resolved_top_k, chunk_type=chunk_type
138 )
139 labels = source_labels(results)
140 return [r.model_copy(update={"label": labels[r.source]}) for r in group(results)]
143async def ask(
144 question: str,
145 top_k: int = 0,
146 options: dict[str, Any] | None = None,
147 chunk_type: ChunkType | None = None,
148) -> AskResponse:
149 """One-shot RAG answer. Returns answer and sources."""
150 if not question or not question.strip():
151 raise ValueError("question must not be empty")
152 opts = _resolve_generation_options(options)
153 searcher = get_services().searcher
154 # ask_raw blocks for retrieval plus the whole generation; run it off the
155 # event loop so other admitted requests stay responsive.
156 result = await asyncio.to_thread(
157 searcher.ask_raw,
158 question,
159 top_k=top_k,
160 options=opts,
161 chunk_type=chunk_type,
162 )
163 # Mirror the streaming ask path: auto-extract memories from a real answer,
164 # but never from the search-needs-embedder refusal ask_raw returns.
165 if not searcher.search_unavailable():
166 await _store_extracted_memories(question, result.answer)
167 return AskResponse(
168 answer=result.answer,
169 sources=_cleaned_chunks(result.sources, result.sources),
170 cited_sources=_cleaned_chunks(result.cited_sources, result.sources),
171 retrieval_query=result.retrieval_query,
172 dropped_sources=_cleaned_chunks(result.dropped_sources, result.sources),
173 )
176def _cleaned_chunks(
177 chunks: list[SearchChunk], answered_from: list[SearchChunk] | None = None
178) -> list[CleanedChunk]:
179 """*chunks* as response models, numbered by the retrieved list *answered_from*."""
180 return [CleanedChunk(**c) for c in clean_results(chunks, answered_from or [])]
183def _chat_warming_events() -> list[str]:
184 """One ``warming`` SSE event when the chat server is cold, else nothing.
186 A cold chat server blocks the first token while it loads; the early event
187 lets the client show a warming state instead of an apparently-dead stream.
188 """
189 if get_services().provider.role_ready(WorkerRole.CHAT):
190 return []
191 log.info("Chat engine cold; streaming a warming notice before the first token.")
192 return [sse_event(SseEvent.WARMING, {"role": WorkerRole.CHAT.value})]
195def _put_answer_token(
196 content: str,
197 put: Callable[[str | None], None],
198 cite_filter: StreamingCitationFilter | None,
199 answer_parts: list[str],
200) -> None:
201 """Filter one streamed answer chunk (dropping a model Sources block on
202 grounded turns), record it, and push it to the SSE queue."""
203 token = cite_filter.feed(content) if cite_filter else content
204 if token:
205 answer_parts.append(token)
206 put(sse_event(SseEvent.TOKEN, {"token": token}))
209def _put_answer_tail(
210 put: Callable[[str | None], None],
211 cite_filter: StreamingCitationFilter | None,
212 answer_parts: list[str],
213) -> None:
214 """Release any answer text the filter held back once the stream ends."""
215 if cite_filter is None:
216 return
217 tail = cite_filter.flush()
218 if tail:
219 answer_parts.append(tail)
220 put(sse_event(SseEvent.TOKEN, {"token": tail}))
223def _run_llm_stream(
224 messages: list[ChatMessage],
225 opts: dict[str, Any] | None,
226 put: Callable[[str | None], None],
227 cancel: threading.Event,
228 error_holder: list[BaseException],
229 answer_parts: list[str],
230 cite_filter: StreamingCitationFilter | None,
231) -> None:
232 """Forward tokens from the cap-aware chat orchestrator into the SSE queue.
234 Answer tokens (not reasoning) are also accumulated into *answer_parts* so the
235 caller can feed the finished answer to auto-extraction. When *cite_filter* is
236 set (grounded turns), answer tokens pass through it so a model-generated
237 ``Sources:`` block never reaches the client alongside the authoritative
238 SOURCES event; ungrounded turns pass ``None`` and stream verbatim.
239 """
240 try:
241 events = stream_chat_with_cap(
242 get_services().provider,
243 cast("list[dict[str, Any]]", messages),
244 options=opts,
245 model=cfg.chat_model,
246 show_reasoning=cfg.show_reasoning,
247 cap_chars=effective_reasoning_cap(),
248 )
249 for event in events:
250 if cancel.is_set():
251 events.close()
252 break
253 if isinstance(event, CapNotice):
254 put(
255 sse_event(
256 SseEvent.REASONING,
257 {"token": CAP_NOTICE_TEMPLATE.format(chars=event.cap_chars)},
258 )
259 )
260 elif event.is_reasoning:
261 if event.content:
262 put(sse_event(SseEvent.REASONING, {"token": event.content}))
263 elif event.content:
264 _put_answer_token(event.content, put, cite_filter, answer_parts)
265 except Exception as exc:
266 error_holder.append(exc)
267 finally:
268 _put_answer_tail(put, cite_filter, answer_parts)
269 put(None)
272async def _store_extracted_memories(question: str, answer: str) -> list[Any]:
273 """Run the auto-extraction LLM pass off the event loop and return stored memories.
275 The pass is best effort and never raises: an empty list comes back when the
276 answer is empty, when auto-extraction is off, and when the pass itself fails.
277 A failure is logged with its traceback.
278 """
279 if not answer or not auto_extract_enabled():
280 return []
281 try:
282 return await asyncio.to_thread(auto_extract, question, answer)
283 except Exception:
284 log.exception("auto-extraction failed; the answer is unaffected")
285 return []
288async def _emit_extracted_memories(question: str, answer: str) -> AsyncGenerator[str, None]:
289 """Yield a ``memory_extracted`` SSE event if the turn auto-saved any memories.
291 Silent (yields nothing) when the answer is empty, auto-extraction is off, or
292 nothing was extracted, so existing consumers are unaffected.
293 """
294 stored = await _store_extracted_memories(question, answer)
295 if not stored:
296 return
297 event = MemoryExtractedEvent(
298 count=len(stored),
299 items=[MemoryExtractedItem(id=m.id, kind=m.kind, text=m.text) for m in stored],
300 )
301 yield sse_event(SseEvent.MEMORY_EXTRACTED, event.model_dump(mode="json"))
304def _mismatch_detail(exc: EmbeddingModelMismatchError) -> str | None:
305 """The index's persisted embedder when dims match, so a client can offer to
306 adopt it; None when they don't match and adoption wouldn't help."""
307 return exc.persisted_model if exc.dims_match else None
310async def _emit_sources_and_memories(
311 question: str,
312 answer_parts: list[str],
313 sources: list[SearchChunk],
314) -> AsyncGenerator[str, None]:
315 """Emit the trailing SOURCES event, ``done``, and any memory-extracted event.
317 SOURCES carries the cited subset (what the answer referenced), falling back to
318 the full retrieved set when the answer cited nothing, mirroring
319 ``Searcher.ask_stream``. Recalled memories always ride along: they were in the
320 prompt whether the answer cited a document or not. Auto-extraction trails
321 ``done``; it cannot fail the stream, so a client that drains past ``done``
322 still keeps the answer.
323 """
324 answer = "".join(answer_parts)
325 cited = cited_subset(answer, sources)
326 if cited:
327 cited_sources = {c.source for c in cited}
328 memories = [s for s in sources if s.memory_id is not None and s.source not in cited_sources]
329 source_list = [*cited, *memories]
330 else:
331 source_list = sources
332 yield sse_event(SseEvent.SOURCES, clean_results(source_list, sources))
333 yield sse_done({})
334 async for event in _emit_extracted_memories(question, answer):
335 yield event
338async def _stream_rag_response(
339 question: str,
340 history: list[ChatMessage] | None = None,
341 top_k: int = 0,
342 options: dict[str, Any] | None = None,
343 chunk_type: ChunkType | None = None,
344) -> AsyncGenerator[str, None]:
345 """SSE streaming for the ask (search) endpoint.
347 Mirrors ``Searcher.ask_stream`` so streaming, one-shot, and CLI ask agree:
348 search mode with no embedder refuses cleanly, chat mode answers ungrounded,
349 otherwise the answer is grounded in retrieved sources.
350 """
351 yield "" # force generator
353 for warming in _chat_warming_events():
354 yield warming
356 searcher = get_services().searcher
357 if searcher.search_unavailable():
358 # Search needs an embedder to ground. Mirror Searcher.ask_stream by
359 # returning the refusal as a normal answer token (not an SSE error) so the
360 # streaming, one-shot, and CLI ask paths all surface it the same way.
361 yield sse_event(SseEvent.TOKEN, {"token": SEARCH_NEEDS_EMBEDDER})
362 yield sse_event(SseEvent.SOURCES, [])
363 yield sse_done({})
364 return
365 # Retrieval embeds, searches, reranks, and can spend an LLM call expanding
366 # the query. On the loop it stalls every other admitted request for the whole
367 # turn; the non-streaming siblings already thread the same work.
368 results, messages, preempt = await asyncio.to_thread(
369 _resolve_stream_context,
370 searcher,
371 question,
372 history,
373 top_k,
374 chunk_type,
375 retrieval_off=searcher.skip_retrieval(),
376 )
377 for frame in preempt:
378 yield frame
379 if messages is None:
380 return
382 opts = _resolve_generation_options(options) or cfg.generation_options()
384 sse = SseStream()
385 error_holder: list[BaseException] = []
386 answer_parts: list[str] = []
387 # Only grounded turns append an authoritative SOURCES event, so only they
388 # need a model-generated Sources block suppressed.
389 cite_filter = StreamingCitationFilter() if results else None
391 executor_fut = sse.loop.run_in_executor(
392 None,
393 _run_llm_stream,
394 messages,
395 opts,
396 sse.put_threadsafe,
397 sse.cancel,
398 error_holder,
399 answer_parts,
400 cite_filter,
401 )
402 task = asyncio.ensure_future(executor_fut)
403 async for event in sse.drain(task, "RAG stream"):
404 yield event
406 if error_holder:
407 exc = error_holder[0]
408 raw = str(exc)
409 code, user_message = _classify_stream_error(exc)
410 log.warning("Stream error: %s", raw)
411 yield sse_error(user_message, code=code, detail=raw if code else None)
412 sse.cancel.set()
413 return
415 # Ensure executor thread has finished before yielding final events
416 await executor_fut
418 async for event in _emit_sources_and_memories(question, answer_parts, results):
419 yield event
422def ask_stream(
423 question: str,
424 top_k: int = 0,
425 options: dict[str, Any] | None = None,
426 chunk_type: ChunkType | None = None,
427) -> AsyncGenerator[str, None]:
428 """Yield SSE events: token, sources, done."""
429 return _stream_rag_response(question, top_k=top_k, options=options, chunk_type=chunk_type)
432def _compaction_pending(history: list[ChatMessage], summary: str) -> bool:
433 """Whether this turn will fold turns into notes before answering."""
434 budget = history_budget(cfg.chat_n_ctx_target)
435 return bool(
436 cfg.chat_compaction
437 and compaction_due(history, summary, max_tokens=budget)
438 and foldable(history)
439 )
442def _manage_history(
443 history: list[ChatMessage],
444 summary: str,
445 on_batch: Callable[[int, int], None] | None = None,
446) -> tuple[list[ChatMessage], CompactionInfo | None]:
447 """Apply the TUI's pre-turn context discipline to an HTTP conversation."""
448 budget = history_budget(cfg.chat_n_ctx_target)
449 info: CompactionInfo | None = None
450 if _compaction_pending(history, summary):
451 dropped = foldable(history)
452 result = get_services().searcher.summarize_history(dropped, summary, on_batch=on_batch)
453 history = history[len(dropped) :]
454 summary = result.summary
455 info = CompactionInfo(
456 summary=result.summary, condensed=result.condensed, stranded=result.stranded
457 )
458 return prompt_history(history, summary, max_tokens=budget), info
461async def _context_management_frames(
462 history: list[ChatMessage], summary: str, session_id: str | None
463) -> AsyncGenerator[str | tuple[list[ChatMessage], CompactionInfo | None], None]:
464 """Manage this turn's history off-loop, yielding its SSE frames, then the result.
466 Yields the ``compacting`` announcement, per-batch progress frames, and the
467 closing ``compaction`` frame (str items), persisting a fresh summary along
468 the way; the ``(history, info)`` tuple arrives exactly once, last.
469 """
470 if not _compaction_pending(history, summary):
471 # Windowing only: pure arithmetic over the messages, no model call to wait on.
472 yield await asyncio.to_thread(_manage_history, history, summary)
473 return
475 # Condensing blocks this turn on model calls; announce it like warming, then
476 # relay per-batch progress. SseStream carries the heartbeats and the
477 # client-disconnect cancellation every other streaming endpoint here gets.
478 yield sse_event(SseEvent.COMPACTING, {})
479 stream = SseStream()
481 def _on_batch(batch: int, total: int) -> None:
482 stream.put_threadsafe(sse_event(SseEvent.COMPACTING, {"batch": batch, "batches": total}))
484 async def _condense() -> tuple[list[ChatMessage], CompactionInfo | None]:
485 try:
486 return await asyncio.to_thread(_manage_history, history, summary, _on_batch)
487 finally:
488 stream.put_threadsafe(None)
490 task = asyncio.ensure_future(_condense())
491 async for frame in stream.drain(task, "Compaction stream"):
492 yield frame
493 try:
494 managed_history, compaction = await task
495 except Exception:
496 # Condensing is best-effort: a failed fold degrades to plain windowing,
497 # which is what this turn would have done with compaction off. Losing the
498 # summary costs context; failing the turn costs the user their answer.
499 log.warning("Compaction failed; falling back to windowing", exc_info=True)
500 budget = history_budget(cfg.chat_n_ctx_target)
501 yield (prompt_history(history, summary, max_tokens=budget), None)
502 return
503 if compaction is not None:
504 _persist_summary(session_id, compaction)
505 yield sse_event(SseEvent.COMPACTION, compaction.model_dump())
506 yield (managed_history, compaction)
509def _persist_summary(session_id: str | None, info: CompactionInfo | None) -> None:
510 """Store fresh notes on the session; a session deleted mid-chat is tolerated."""
511 if info is None or not session_id or not info.summary or not sessions_enabled():
512 return
513 with contextlib.suppress(SessionNotFoundError):
514 get_services().session_store.set_summary(session_id, info.summary)
517async def chat(
518 question: str,
519 history: list[ChatMessage],
520 top_k: int | None = None,
521 options: dict[str, Any] | None = None,
522 chunk_type: ChunkType | None = None,
523 summary: str = "",
524 session_id: str | None = None,
525) -> AskResponse:
526 """Chat with history. Returns answer and sources via canonical dispatch."""
527 searcher = get_services().searcher
528 if searcher.search_unavailable():
529 # Search mode with no embedder can't ground; refuse cleanly with the same
530 # message ask returns instead of silently answering off-corpus.
531 return AskResponse(answer=SEARCH_NEEDS_EMBEDDER, sources=[], cited_sources=[])
532 history, compaction = await asyncio.to_thread(_manage_history, history, summary)
533 _persist_summary(session_id, compaction)
534 retrieval_query: str | None = None
535 dropped: list[SearchChunk] = []
536 if _retrieval_off(searcher, top_k):
537 # Chat-only mode or an explicit top_k:0 pure-LLM call.
538 sources: list[SearchChunk] = []
539 messages = searcher.direct_messages(question, history)
540 else:
541 # Grounded turn: the searcher's own pre-retrieval ladder (empty
542 # library, count routing, memory-awareness) so surfaces cannot drift.
543 pre_answer = searcher.pre_retrieval_answer(question)
544 if pre_answer is not None:
545 return AskResponse(
546 answer=pre_answer, sources=[], cited_sources=[], compaction=compaction
547 )
548 rag = searcher.build_rag_context(
549 question, top_k=top_k or 0, history=history, chunk_type=chunk_type
550 )
551 if rag is None:
552 # Refuse like every sibling surface; the old fallback silently
553 # answered off-corpus with nothing telling the caller so.
554 return AskResponse(
555 answer=GROUNDED_REFUSAL, sources=[], cited_sources=[], compaction=compaction
556 )
557 sources, messages = rag.results, rag.messages
558 retrieval_query = rag.retrieval_query
559 dropped = rag.dropped or []
560 req = _build_canonical_request(messages, options)
561 response = await asyncio.to_thread(dispatch_chat, req)
562 text = _join_text_blocks(response.content)
563 answer = text if cfg.show_reasoning else strip_reasoning(text)
564 if not answer.strip() and text.strip():
565 # The model emitted only reasoning (stripped to nothing) and no final
566 # answer. Surface that distinctly instead of a silent empty string the
567 # caller can't tell apart from a legitimate empty response (bb-cpu). The
568 # synthetic notice is not an answer, so -- like the search-needs-embedder
569 # refusal -- it doesn't seed memory.
570 answer = REASONING_EXHAUSTED_NOTICE
571 else:
572 await _store_extracted_memories(question, answer)
573 return AskResponse(
574 answer=answer,
575 sources=_cleaned_chunks(sources, sources),
576 cited_sources=_cleaned_chunks(cited_subset(strip_llm_citations(answer), sources), sources),
577 compaction=compaction,
578 retrieval_query=retrieval_query,
579 dropped_sources=_cleaned_chunks(dropped, sources),
580 )
583def chat_stream(
584 question: str,
585 history: list[ChatMessage],
586 top_k: int | None = None,
587 options: dict[str, Any] | None = None,
588 chunk_type: ChunkType | None = None,
589 summary: str = "",
590 session_id: str | None = None,
591) -> AsyncGenerator[str, None]:
592 """Stream RAG chat tokens through canonical dispatch as token/sources/done events."""
593 return _stream_chat_response(
594 question,
595 history=history,
596 top_k=top_k,
597 options=options,
598 chunk_type=chunk_type,
599 summary=summary,
600 session_id=session_id,
601 )
604class _StreamResolution(NamedTuple):
605 """Retrieval outcome for a streaming turn.
607 ``preempt_frames`` are emitted verbatim before anything else; ``messages``
608 of ``None`` means the stream ends after them (a direct exact-scan answer
609 or a clean refusal/error), otherwise they lead the answer.
610 """
612 sources: list[SearchChunk]
613 messages: list[ChatMessage] | None
614 preempt_frames: list[str]
617class _ChatStreamPlan(NamedTuple):
618 """Leading SSE frames plus the grounded context for a chat stream.
620 A ``None`` context means the turn can't proceed: emit the frames (a clean
621 refusal or error) and stop.
622 """
624 frames: list[str]
625 context: RagContext | None
628def _resolve_chat_stream_context(
629 searcher: Searcher,
630 question: str,
631 history: list[ChatMessage],
632 top_k: int | None,
633 chunk_type: ChunkType | None,
634) -> _ChatStreamPlan:
635 frames = list(_chat_warming_events())
636 if searcher.search_unavailable():
637 # Search mode with no embedder can't ground; refuse cleanly with the same
638 # token the ask stream emits instead of silently answering off-corpus.
639 frames += [
640 sse_event(SseEvent.TOKEN, {"token": SEARCH_NEEDS_EMBEDDER}),
641 sse_event(SseEvent.SOURCES, []),
642 sse_done({}),
643 ]
644 return _ChatStreamPlan(frames, None)
645 # Retrieval itself is resolved by the shared helper, so the chat stream
646 # routes empty libraries and count questions exactly like the ask stream.
647 sources, messages, preempt = _resolve_stream_context(
648 searcher,
649 question,
650 history,
651 top_k,
652 chunk_type,
653 retrieval_off=_retrieval_off(searcher, top_k),
654 )
655 frames += preempt
656 if messages is None:
657 return _ChatStreamPlan(frames, None)
658 return _ChatStreamPlan(frames, RagContext(sources, messages))
661async def _stream_chat_response(
662 question: str,
663 history: list[ChatMessage],
664 top_k: int | None,
665 options: dict[str, Any] | None,
666 chunk_type: ChunkType | None,
667 summary: str = "",
668 session_id: str | None = None,
669) -> AsyncGenerator[str, None]:
670 """Drive ``dispatch_chat_stream`` and emit reasoning/token/sources/done SSE events."""
671 async for item in _context_management_frames(history, summary, session_id):
672 if isinstance(item, str):
673 yield item
674 continue
675 history, _compaction = item
676 frames, ctx = await asyncio.to_thread(
677 _resolve_chat_stream_context, get_services().searcher, question, history, top_k, chunk_type
678 )
679 for frame in frames:
680 yield frame
681 if ctx is None:
682 return
683 sources, messages = ctx.results, ctx.messages
685 req = _build_canonical_request(messages, options)
686 answer_parts: list[str] = []
687 # Only grounded turns append an authoritative SOURCES event, so only they
688 # need a model-generated Sources block suppressed.
689 cite_filter = StreamingCitationFilter() if sources else None
690 try:
691 async for event in _cap_aware_chat_events(req):
692 frame = _chat_answer_frame(event, cite_filter, answer_parts)
693 if frame:
694 yield frame
695 except Exception as exc:
696 raw = str(exc)
697 code, user_message = _classify_stream_error(exc)
698 log.warning("Stream error: %s", raw)
699 yield sse_error(user_message, code=code, detail=raw if code else None)
700 return
702 tail_frame = _chat_answer_tail_frame(cite_filter, answer_parts)
703 if tail_frame:
704 yield tail_frame
706 async for frame in _emit_sources_and_memories(question, answer_parts, sources):
707 yield frame
710async def _cap_aware_chat_events(
711 req: CanonicalChatRequest,
712) -> AsyncIterator[StreamToken | CapNotice]:
713 """Run ``dispatch_chat_stream``, split reasoning, and re-issue on cap-fire.
715 Mirrors :func:`stream_chat_with_cap` but consumes the canonical async
716 stream. ``CapNotice`` is yielded once between the truncated reasoning
717 and the continuation answer; ``StreamToken`` carries the
718 reasoning-vs-response split for downstream SSE shaping. When reasoning
719 runs but no final answer follows, a closing ``StreamToken`` carrying
720 ``REASONING_EXHAUSTED_NOTICE`` is yielded so the run isn't silent (bb-cpu).
721 """
722 cap_chars = effective_reasoning_cap()
723 show = cfg.show_reasoning
724 answered = False
726 first_parser = TagParser(show=show)
727 async for tok in _drive_stream(dispatch_chat_stream(req), first_parser, cap_chars):
728 answered = answered or (not tok.is_reasoning and bool(tok.content))
729 yield tok
731 if cap_chars > 0 and first_parser.reasoning_chars > cap_chars:
732 yield CapNotice(cap_chars=cap_chars)
733 nudged = nudged_request(req)
734 cont_parser = TagParser(show=show)
735 async for tok in _drive_stream(dispatch_chat_stream(nudged), cont_parser, cap_chars=0):
736 answered = answered or bool(tok.content)
737 # Continuation tokens are always treated as final-answer text.
738 yield StreamToken(content=tok.content, is_reasoning=False)
740 if first_parser.reasoning_chars > 0 and not answered:
741 # The model spent its budget reasoning and produced no final answer;
742 # a distinct notice tells a reasoning-only run apart from a completed one.
743 yield StreamToken(content=REASONING_EXHAUSTED_NOTICE, is_reasoning=False)
746async def _drive_stream(
747 stream: AsyncIterator[Any],
748 parser: TagParser,
749 cap_chars: int,
750) -> AsyncIterator[StreamToken]:
751 """Feed *stream* through *parser*; yield ``StreamToken``s; stop on cap-fire."""
752 cap_fired = False
753 try:
754 async for event in stream:
755 text = _text_from_event(event)
756 if not text:
757 continue
758 for tok in parser.feed(text):
759 if tok.content:
760 yield tok
761 if cap_chars > 0 and parser.reasoning_chars > cap_chars:
762 cap_fired = True
763 break
764 finally:
765 if cap_fired:
766 await _aclose(stream)
767 tail = parser.flush()
768 if tail is not None and tail.content:
769 yield tail
772async def _aclose(stream: AsyncIterator[Any]) -> None:
773 """Best-effort close for async-generator-shaped streams."""
774 if not isinstance(stream, AsyncGenerator):
775 return
776 with contextlib.suppress(Exception):
777 await stream.aclose()
780def _sse_for_chat_event(event: StreamToken | CapNotice) -> str:
781 """Render one orchestrator event as an SSE frame with the right channel."""
782 if isinstance(event, CapNotice):
783 return sse_event(
784 SseEvent.REASONING,
785 {"token": CAP_NOTICE_TEMPLATE.format(chars=event.cap_chars)},
786 )
787 kind = SseEvent.REASONING if event.is_reasoning else SseEvent.TOKEN
788 return sse_event(kind, {"token": event.content})
791def _chat_answer_frame(
792 event: StreamToken | CapNotice,
793 cite_filter: StreamingCitationFilter | None,
794 answer_parts: list[str],
795) -> str:
796 """Render the SSE frame for one chat event and record answer text, dropping a
797 model Sources block on grounded turns. Returns '' when nothing should emit.
799 The reasoning-exhausted notice streams to the client but is not a real
800 answer, so it is left out of *answer_parts*: it seeds no memory and is not
801 treated as a citation source.
802 """
803 is_answer = (
804 isinstance(event, StreamToken)
805 and not event.is_reasoning
806 and event.content != REASONING_EXHAUSTED_NOTICE
807 )
808 if not is_answer:
809 return _sse_for_chat_event(event)
810 content = cast("StreamToken", event).content
811 if cite_filter is None:
812 answer_parts.append(content)
813 return _sse_for_chat_event(event)
814 shown = cite_filter.feed(content)
815 if not shown:
816 return ""
817 answer_parts.append(shown)
818 return sse_event(SseEvent.TOKEN, {"token": shown})
821def _chat_answer_tail_frame(
822 cite_filter: StreamingCitationFilter | None,
823 answer_parts: list[str],
824) -> str:
825 """SSE frame releasing any answer text the filter held back, or '' if none."""
826 if cite_filter is None:
827 return ""
828 tail = cite_filter.flush()
829 if not tail:
830 return ""
831 answer_parts.append(tail)
832 return sse_event(SseEvent.TOKEN, {"token": tail})
835def _text_from_event(event: Any) -> str:
836 """Return the text payload of a canonical event, or '' if not a text delta."""
837 if isinstance(event, ContentBlockDelta) and isinstance(event.delta, TextDelta):
838 return event.delta.text
839 return ""
842def _retrieval_off(searcher: Searcher, top_k: int | None) -> bool:
843 """Whether this /api/chat turn bypasses RAG.
845 An explicit ``top_k == 0`` is a pure-LLM call: answer without retrieval. An
846 unspecified ``top_k`` (``None``) uses the configured default and grounds
847 normally. Chat-only mode or a missing embedder also bypass.
848 """
849 return top_k == 0 or searcher.skip_retrieval()
852def _resolve_stream_context(
853 searcher: Searcher,
854 question: str,
855 history: list[ChatMessage] | None,
856 top_k: int | None,
857 chunk_type: ChunkType | None,
858 *,
859 retrieval_off: bool,
860) -> _StreamResolution:
861 """Resolve retrieval for a streaming handler.
863 Shared by the ask and chat streams so the two paths cannot drift: both
864 route count questions to the exact scan, surface an embedder mismatch as
865 a coded SSE error, and report empty retrieval the same way.
866 """
867 if retrieval_off:
868 return _StreamResolution([], searcher.direct_messages(question, history), [])
869 # The searcher's own pre-retrieval ladder (empty library, count routing,
870 # memory-awareness), so the stream surfaces cannot drift from ask_raw.
871 pre_answer = searcher.pre_retrieval_answer(question)
872 if pre_answer is not None:
873 frames = [
874 sse_event(SseEvent.TOKEN, {"token": pre_answer}),
875 sse_event(SseEvent.SOURCES, []),
876 sse_done({}),
877 ]
878 return _StreamResolution([], None, frames)
879 try:
880 rag = searcher.build_rag_context(
881 question, top_k=top_k or 0, history=history, chunk_type=chunk_type
882 )
883 except EmbeddingModelMismatchError as mismatch:
884 # detail carries the index's embedder so the client can offer to adopt it.
885 frame = sse_error(
886 str(mismatch),
887 code=SseErrorCode.INDEX_EMBEDDER_MISMATCH,
888 detail=_mismatch_detail(mismatch),
889 )
890 return _StreamResolution([], None, [frame])
891 if rag is None:
892 return _StreamResolution([], None, [sse_error("No relevant documents found.")])
893 results, messages = rag.results, rag.messages
894 announce = (
895 [sse_event(SseEvent.RETRIEVAL_QUERY, {"query": rag.retrieval_query})]
896 if rag.retrieval_query
897 else []
898 )
899 return _StreamResolution(results, messages, announce)
902_CANONICAL_ROLE_BY_WIRE: dict[str, Literal["user", "assistant", "tool"]] = {
903 "user": "user",
904 "assistant": "assistant",
905 "tool": "tool",
906}
909def _build_canonical_request(
910 messages: list[ChatMessage], options: dict[str, Any] | None
911) -> CanonicalChatRequest:
912 """Convert a wire-shaped message list to a no-tools ``CanonicalChatRequest``."""
913 opts = _resolve_generation_options(options) or cfg.generation_options() or {}
914 system, chat_msgs = _split_system(messages)
915 return CanonicalChatRequest(
916 model=cfg.chat_model,
917 messages=[
918 CanonicalMessage.from_string(role=_canonical_role(m["role"]), text=m["content"])
919 for m in chat_msgs
920 ],
921 system=system,
922 temperature=opts.get("temperature"),
923 top_p=opts.get("top_p"),
924 top_k=opts.get("top_k"),
925 max_tokens=opts.get("num_predict"),
926 stop=opts.get("stop"),
927 )
930def _canonical_role(wire_role: str) -> Literal["user", "assistant", "tool"]:
931 """Narrow a raw wire role string to the canonical literal set or raise."""
932 try:
933 return _CANONICAL_ROLE_BY_WIRE[wire_role]
934 except KeyError:
935 raise ValueError(f"Unsupported message role {wire_role!r}") from None
938def _split_system(
939 messages: list[ChatMessage],
940) -> tuple[str | None, list[ChatMessage]]:
941 """Pull the leading system message out, returning (system, rest)."""
942 if messages and messages[0]["role"] == "system":
943 return messages[0]["content"], messages[1:]
944 return None, list(messages)
947def _join_text_blocks(content: list[Any]) -> str:
948 """Concatenate the text from every ``TextBlock`` in a canonical content list."""
949 return "".join(block.text for block in content if isinstance(block, TextBlock))