# Ask layer — agentic, grounded RAG (`kibble ask`)
`kibble ask "<question>"` answers questions **grounded in the indexed corpus**: it seeds a corpus
search, lets a served LLM call a `search_corpus` tool to dig further when the first passages
don't suffice (an agentic loop, bounded by `[ask].max_rounds`), and returns a **cited** answer —
or, when the corpus genuinely doesn't cover it, an interactive not-found flow offering to provide
a source, add detail, or try from general knowledge (`--chance`). This is the "answer" tip of the
stack (discover → acquire → extract → understand → retrieve → **answer**) — a grounded,
self-hosted alternative to a web answer engine, over your own data.
It reuses the `retrieve` layer (`kibble index` / `kibble search`) for retrieval and a shared
OpenAI-compatible chat helper (`src/llm.rs`, also used by `kibble bench`) for the model calls — no
new crate dependency.
## `kibble ask "<question>" [--k N] [--json] [--chance] [--show-context] [--web] [--no-web] [--stream] [--no-stream]`
```bash
kibble ask "how does the retry backoff work"
kibble ask "what's the SSRF guard policy" --k 8 --json
kibble ask "who wrote the original BASIC interpreter" --chance
kibble ask "what changed in the latest Rust release" --web
kibble ask "how does the retry backoff work" --no-stream
```
- `--k N` — passages retrieved per `search_corpus` call (default `[ask].k`, 6).
- `--json` — emit machine-readable JSON instead of prose (see Output below).
- `--chance` — if the corpus doesn't cover the question, skip the interactive prompt and answer
from the model's general knowledge instead (flagged `grounded: false`).
- `--show-context` — additionally print every passage retrieved across all searches, not just the
ones actually cited in the answer.
- `--web` / `--no-web` — force web tools on/off for this query, overriding `[web].default`. See
[Web-augmented answers](#web-augmented-answers) below.
- `--stream` / `--no-stream` — force token streaming on/off for this query, overriding
`[ask].stream`. See [Streaming](#streaming) below.
## Dependencies
- **A built index** (`kibble index`) — `ask` retrieves via `retrieve::search`. No/empty index
errors clearly (`run kibble index first`), same as `kibble search`.
- **A served chat LLM** — any OpenAI-compatible `/chat/completions` endpoint (`[ask].base_url`).
Unlike `search`, this is a **hard dependency**: `ask` can't degrade without a model to talk to.
An empty `base_url` errors immediately, before any work:
`ask needs a served LLM — set [ask].base_url in kibble.toml`.
- **Retrieval stays fail-soft**, same as `kibble search`: if `[understand.embed].base_url` is down
or unconfigured, `search_corpus` calls fall back to lexical-only (BM25) passages instead of
failing the whole `ask`.
## The agentic loop
1. **Seed.** `ask` runs `search_corpus(question)` once up front and hands the model the system
prompt plus the question and the seeded passages.
2. **Iterate.** Up to `[ask].max_rounds` chat turns: the model either
- returns tool calls → `ask` executes each `search_corpus(query)` against `retrieve::search`,
appends the results as a new numbered block, and feeds them back as a tool response; or
- returns content with no tool calls → that's the final answer, and the loop stops.
The last turn is offered no tools at all, which forces a textual answer (grounded or
`NOTFOUND`) rather than letting the model loop indefinitely.
3. **Classify.** The final content is checked for the sentinel `NOTFOUND` (the model's "not in
corpus" signal, per the system prompt). Empty/`NOTFOUND` content → not-found; otherwise the
answer is scanned for `[n]` citations and mapped back to their source passages.
**Passage numbering** is global across the whole run: every distinct chunk retrieved (by
`chunk_id`) gets one number, assigned in the order it first appears, whether that's from the seed
search or a later `search_corpus` call. Repeated hits across rounds keep their original number, so
the model's citations stay stable even as it searches more.
**System prompt (grounded):** answer **only** from the provided passages; cite every claim with
`[n]` matching a passage number; if the passages don't contain the answer, call `search_corpus`
with a better, more specific query and try again; only after genuinely trying, if still unfound,
reply with exactly `NOTFOUND` and nothing else — never invent facts that aren't in the passages.
## Grounding & sources
`ask` always shows its grounding — whether the answer is rooted in the corpus or general knowledge:
**State 1 — cited:** The model wrote `[n]` citation markers in the answer. Only passages actually
cited (`[n]` appearing in the answer) show up in the `Sources:` list — a model can retrieve ten
passages and cite three; the other seven were context, not evidence.
```
Retries use exponential backoff with jitter: the delay doubles each attempt, capped
at a maximum, with up to ±20% random jitter added to avoid thundering-herd retries
across concurrent callers [1][2].
Sources:
[1] [local] src/net.rs "retry backoff implementation" (0.041)
[2] [local] docs/RETRIEVE.md "fail-soft behavior" (0.028)
(2 cited · 2 search(es))
```
**State 2 — grounded, uncited:** Retrieval ran, the model answered from the corpus, but emitted no
`[n]` citation markers. This is common with small local models. Instead of `0 source(s)`, `ask`
shows a `References (retrieved — the model didn't mark citations):` block listing the top 3
retrieved passages by retrieval score, and a footer `(0 cited · R retrieved · M search(es))`.
Importantly, `grounded: true` stays set — the answer is still grounded in the corpus, just
without explicit markers.
```
Retries use exponential backoff with jitter to avoid thundering-herd retries.
References (retrieved — the model didn't mark citations):
[local] src/net.rs "retry backoff implementation" (0.082)
[local] docs/RETRIEVE.md "fail-soft behavior" (0.071)
[local] README.md "backoff strategy" (0.065)
(0 cited · 5 retrieved · 2 search(es))
```
**State 3 — not in corpus:** The model returned `NOTFOUND` (unable to answer from the corpus). When
`--chance` is passed or the user selects "try anyway" at the not-found prompt, a general-knowledge
answer is provided, flagged clearly and ungrounded (`grounded: false`):
```
⚠ Not in your corpus — general knowledge, may be out of date. Verify independently.
BASIC was created in 1964 by John Kemeny and Thomas Kurtz at Dartmouth College.
```
**Footer meanings:** The footer `(N cited · M search(es))` or `(0 cited · R retrieved · M
search(es))` communicates the grounding state:
- `N cited` — N passages were explicitly cited with `[n]` markers in the answer. Always paired with
the number of searches.
- `0 cited · R retrieved` — no explicit citations, but R passages were retrieved and are shown in
the `References` block for you to judge. The answer is still grounded.
- If `grounded: false`, neither block appears (just the warning banner).
**JSON output:** The `retrieved` array in `answer_json` (bounded to the top 3 by retrieval score)
serves as the attribution when the model emitted no citations. Each `retrieved` entry has `kind`
(`local` or `web`), `source`, `title`, and `score`:
```json
{
"answer": "Retries use exponential backoff with jitter …",
"grounded": true,
"sources": [],
"retrieved": [
{ "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.082 },
{ "kind": "local", "source": "docs/RETRIEVE.md", "title": "fail-soft behavior", "score": 0.071 },
{ "kind": "local", "source": "README.md", "title": "backoff strategy", "score": 0.065 }
],
"searches": 2
}
```
**Honest limitation:** A model that skips the `NOTFOUND` signal (always answering from general
knowledge instead) could be labeled grounded-uncited — the retrieved references let you judge
whether the answer is actually rooted in your corpus. A follow-up will auto-downgrade the
relevance score if the model's answer doesn't match the retrieved passages.
## Citations & output
This section covers the `--show-context` and `--json` shapes; see **Grounding & sources** above
for the three grounding states (cited / retrieved-fallback / ungrounded) and their footers.
**Human output** (default) — a cited answer (state 1):
```
$ kibble ask "how does the retry backoff work"
Retries use exponential backoff with jitter: the delay doubles each attempt, capped
at a maximum, with up to ±20% random jitter added to avoid thundering-herd retries
across concurrent callers [1][2].
Sources:
[1] [local] src/net.rs "retry backoff implementation" (0.041)
[2] [local] docs/RETRIEVE.md "fail-soft behavior" (0.028)
(2 cited · 2 search(es))
```
`--show-context` additionally lists every retrieved passage (cited or not):
```
Retrieved:
[1] [local] src/net.rs — fn backoff(attempt: u32) -> Duration { let base = ...
[2] [local] docs/RETRIEVE.md — Fail-soft / lexical-only behavior kibble search never re...
[3] [local] docs/BENCH.md — retry/backoff is also used by the research method's fetc...
```
**`--json`** — a stable machine-readable shape (`retrieved` holds the top few passages that fed
the answer, so consumers get attribution even when the model emitted no `[n]` markers):
```json
{
"answer": "Retries use exponential backoff with jitter ... [1][2].",
"grounded": true,
"sources": [
{ "n": 1, "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 },
{ "n": 2, "kind": "local", "source": "docs/RETRIEVE.md", "title": "fail-soft behavior", "score": 0.028 }
],
"retrieved": [
{ "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 },
{ "kind": "local", "source": "docs/RETRIEVE.md", "title": "fail-soft behavior", "score": 0.028 }
],
"searches": 2
}
```
## Streaming
`kibble ask` streams the answer **token-by-token** to the terminal as the model generates it,
instead of waiting for the full response and printing it all at once.
- **Config:** `[ask].stream` (default `true`). **Per-query override:** `--stream` forces streaming
on, `--no-stream` forces it off, regardless of the config default.
- **Active only when it's safe to interleave with a terminal:** *text* streaming happens only when
stdout is a TTY **and** `--json` isn't set. Piped output (`| cat`, `| tee`, redirected to a file)
buffers the full answer — interleaving raw partial tokens into a non-interactive pipe or a JSON
document would corrupt it. `--json` output also buffers into a single object **unless** you pass
`--stream`, which switches it to the structured NDJSON event stream (see
[Streaming `--json`](#streaming---json-ndjson-events) below) — that is the safe way to stream to a
machine consumer.
- **Only *when* text appears, not *what* appears.** Streaming is purely a presentation change: the
same agentic loop, the same `[n]` citations, the same `Sources:` list and JSON shape come out
either way — only whether the answer's prose appears incrementally as it's generated versus all
at once at the end.
- **Activity notes during tool rounds.** While streaming, `ask` prints short progress notes to
**stderr** (not stdout, so they never mix into piped/redirected answer text) each time it invokes
a tool during the agentic loop:
```
⋯ searching corpus: "retry backoff"
⋯ web search: "rust stdlib backoff guidance"
⋯ fetch: https://blog.rust-lang.org/example-post
```
These notes only print when streaming is active (TTY, no `--json`) — the non-streaming path stays
silent during tool rounds, same as before.
**Under the hood:** a new `llm::chat_turn_stream` sends `stream: true` and reads the response as
Server-Sent Events via `reqwest::Response::chunk()` (no new crate dependency), accumulating content
deltas (forwarded live to the terminal) and tool-call deltas, and returns the same
`(Option<String>, Vec<ToolCall>)` shape as the non-streaming `chat_turn` — so the tool-call loop,
citation parsing, and `NOTFOUND` classification are all unchanged.
### Streaming `--json` (NDJSON events)
`--json --stream` emits the answer as **newline-delimited JSON events**, making it possible for
programmatic consumers to stream an agentic RAG answer without requiring a TTY. This is distinct
from `--json` alone, which always buffers the full answer into a single object (backward
compatible — `[ask].stream` does not auto-stream JSON, so scripts piping `kibble ask --json` get
one parseable object for easy handling).
**Events:**
- `{"type":"search","query":"…","kind":"corpus"|"web"|"fetch"}` — emitted once per tool call
(corpus search, web search, or page fetch). The `query` for fetch is the URL being fetched.
- `{"type":"token","text":"…"}` — emitted as the model generates each token.
- Terminal event: either `{"type":"answer",…}` (same fields as the buffered `--json` object:
`answer`, `grounded`, `sources`, `retrieved`, `searches`) or `{"type":"notfound","searches":N}`.
Consumers should ignore unknown `type` values for forward compatibility. A mid-stream error
propagates with a non-zero exit code — no partial `answer` event is emitted. `--chance` /
general-knowledge answers stream tokens then emit a terminal `answer` event with `grounded:false`.
**Example transcript:**
```
$ kibble ask "how does exponential backoff work" --json --stream
{"type":"search","query":"retry backoff strategy","kind":"corpus"}
{"type":"token","text":"Exponential"}
{"type":"token","text":" backoff"}
{"type":"token","text":" increases"}
{"type":"token","text":" the delay"}
{"type":"token","text":" between"}
{"type":"token","text":" retries"}
{"type":"token","text":","}
{"type":"token","text":" doubling"}
{"type":"token","text":" it"}
{"type":"token","text":" each"}
{"type":"token","text":" time"}
{"type":"token","text":" [1]"}
{"type":"token","text":"."}
{"type":"answer","answer":"Exponential backoff increases the delay between retries, doubling it each time [1].","grounded":true,"sources":[{"n":1,"kind":"local","source":"src/net.rs","title":"retry backoff implementation","score":0.041}],"retrieved":[{"kind":"local","source":"src/net.rs","title":"retry backoff implementation","score":0.041}],"searches":2}
```
Compare to `kibble ask --json` (buffered):
```json
{
"answer": "Exponential backoff increases the delay between retries, doubling it each time [1].",
"grounded": true,
"sources": [
{ "n": 1, "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 }
],
"retrieved": [
{ "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 }
],
"searches": 2
}
```
**Follow-ups (tracked, not yet done):** streaming isn't wired into `kibble bench`'s latency
measurements yet (#21).
## Web-augmented answers
By default `ask` is corpus-only. Passing `--web` (or setting `[web].default = true` in
`kibble.toml`) gives the model two extra tools alongside `search_corpus`:
- **`web_search(query)`** — queries a SearXNG instance (`[web].base_url`) or, when `base_url` is
empty, scrapes DuckDuckGo's HTML results page as a fallback. Returns up to `[web].max_results`
`(title, url, snippet)` hits, registered as passages the same way corpus hits are.
- **`fetch_page(url)`** — fetches a page's main text (byte-capped at `[web].fetch_bytes`,
truncated to `[web].fetch_chars` characters) and upgrades the matching passage's snippet to the
full extracted text.
The system prompt tells the model to **prefer the corpus** and only reach for the web when the
corpus genuinely doesn't cover the question — `search_corpus` stays the first tool it's nudged
toward; `web_search`/`fetch_page` are there for when the answer needs something current or simply
outside the indexed corpus. Web passages are numbered into the same global registry as corpus
passages and cited with `[n]` exactly like local ones.
**Per-query override:** `[web].default` sets the baseline (`false` out of the box); `--web` turns
web tools on for one query regardless of the baseline, `--no-web` forces corpus-only for one query
even if `[web].default = true`. `--web` and `--no-web` are mutually exclusive in effect — `--no-web`
wins if both are somehow set.
### `[web]` config
```toml
[web]
base_url = "https://localhost:8888" # SearXNG instance; empty → DuckDuckGo HTML scrape fallback
default = false # baseline: web tools off unless --web overrides
max_results = 5 # results returned per web_search call
allow_hosts = [] # extra hostnames fetch_page may reach (bypasses the SSRF block, e.g. for a trusted internal host)
fetch_bytes = 2000000 # cap on raw bytes downloaded per fetch_page (2 MB)
fetch_chars = 4000 # cap on extracted text characters fed to the model
```
`kibble bench`'s `research` method reads its own `[bench.search]` table (same shape, separate
config), but both commands call the same underlying `web_search`/`fetch_page`/parsers in
`src/websearch.rs` — a shared module, no new crate dependency.
### `[local]` vs `[web]` citations
Every source in the `Sources:` list is tagged with where it came from. Corpus passages show a
retrieval score; web passages don't have one (it'd be meaningless outside the corpus's ranking), so
that field is simply omitted:
```
$ kibble ask "how does our retry backoff compare to the latest Rust stdlib guidance" --web
Our retry backoff uses exponential delay with jitter [1], which matches current guidance
recommending jittered backoff to avoid thundering-herd retries [2].
Sources:
[1] [local] src/net.rs "retry backoff implementation" (0.041)
[2] [web] https://blog.rust-lang.org/example-post "Backoff strategies for reliable clients"
(2 cited · 2 search(es))
```
`--json` sources gain a `"kind"` field (`"local"` or `"web"`) instead of the bracket tag; web
sources still have a `"score"` key for shape stability, but it's always `0.0`. The `"retrieved"`
array mirrors the same shape (top few passages that fed the answer):
```json
{
"answer": "Our retry backoff uses exponential delay with jitter [1], which matches ... [2].",
"grounded": true,
"sources": [
{ "n": 1, "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 },
{ "n": 2, "kind": "web", "source": "https://blog.rust-lang.org/example-post", "title": "Backoff strategies for reliable clients", "score": 0.0 }
],
"retrieved": [
{ "kind": "local", "source": "src/net.rs", "title": "retry backoff implementation", "score": 0.041 },
{ "kind": "web", "source": "https://blog.rust-lang.org/example-post", "title": "Backoff strategies for reliable clients", "score": 0.0 }
],
"searches": 2
}
```
`--show-context` tags retrieved-but-uncited passages the same way: `[n] [local|web] source — snippet`.
### Fail-soft & safety
- **Web off** (default, or `--no-web`) — behaves exactly as before web support existed: `ask` is
corpus-only, no `web_search`/`fetch_page` tools are offered, no network call beyond the
configured LLM and retrieval endpoints.
- **`fetch_page` is SSRF-guarded** — every URL is checked with `serve::url_is_allowed` before
fetching: literal or resolved private/loopback/link-local/metadata IPs are blocked unless the
host is explicitly listed in `[web].allow_hosts`. Fetches are also capped in bytes downloaded
(`fetch_bytes`) and characters kept (`fetch_chars`).
- **Web search errors never abort the loop** — a failed/unreachable SearXNG, a DuckDuckGo scrape
that returns nothing, or a blocked/failing `fetch_page` all degrade to an empty result or a
`"Blocked: ..."` / `"Fetch failed: ..."` tool response, not a crash; the model just tries again or
falls back to the corpus.
### Transient — not persisted
Pages fetched via `fetch_page` (and results returned by `web_search`) are cited **for that one
answer only**. They are never written to `data/index` — a web-augmented `ask` doesn't grow your
corpus; run `kibble fetch`/`kibble index` yourself if a fetched page is worth keeping.
## Not-found flow
When the model can't answer from the corpus (after genuinely trying — the system prompt requires
at least one refined search before giving up), `ask` doesn't just print an apology:
- **Interactive TTY** (stdin is a terminal, no `--json`, no `--chance`): prints a menu and reads a
choice.
```
$ kibble ask "what's our Q3 pricing strategy"
Couldn't find it in your corpus (searched 2×). What next?
[1] provide a source (path or URL) to index & retry
[2] add detail/keywords and retry
[3] try anyway (general knowledge — may be wrong)
[q] quit
> 1
path or URL: https://example.com/pricing-memo
```
- `[1]` provide a source — reads a path or URL; a URL is fetched (`kibble fetch`) then the whole
index is rebuilt, a local path is indexed directly (`kibble index <path>`); either way `ask`
re-runs the loop against the enlarged index.
- `[2]` add detail — reads extra text, appends it to the question, and retries.
- `[3]` try anyway — falls through to the ungrounded answer (same as `--chance`).
- `[q]` / anything else — quits, printing the same options a non-interactive run would print.
- **Piped / non-TTY stdin, or `--json`**: no prompt (there's nothing to read from) — prints the
three options and exits 0.
```
$ kibble ask "what's our Q3 pricing strategy" | cat
Searched the corpus (2×) — nothing covers this. Options:
• Add sources: kibble index <path-or-url> then re-ask
• Be specific: re-ask with more detail/keywords
• Try anyway: add --chance (general knowledge; may be wrong, not grounded)
```
```json
{
"answer": null,
"grounded": false,
"not_found": true,
"searches": 2,
"suggestions": [
"kibble index <path-or-url> then re-ask",
"re-ask with more detail/keywords",
"add --chance for a general-knowledge answer"
]
}
```
- **`--chance`** pre-authorizes the general-knowledge fallback, skipping the prompt entirely: a
single ungrounded chat turn (no tools, a plain "answer from general knowledge" system prompt),
flagged clearly as **not** grounded in the corpus.
```
$ kibble ask "who wrote the original BASIC interpreter" --chance
⚠ Not in your corpus — general knowledge, may be out of date. Verify independently.
BASIC was created in 1964 by John Kemeny and Thomas Kurtz at Dartmouth College.
```
```json
{
"answer": "BASIC was created in 1964 by John Kemeny and Thomas Kurtz at Dartmouth College.",
"grounded": false,
"not_found": true,
"sources": []
}
```
## Config (`[ask]`)
```toml
[ask]
base_url = "http://localhost:8000/v1" # served LLM (empty → hard error)
model = "kibble-style" # default
temperature = 0.2 # default
max_tokens = 1024 # default
k = 6 # default — passages per search_corpus call
max_rounds = 3 # default — agentic search iterations (bounds total chat turns)
stream = true # default — stream the answer token-by-token (TTY + non-JSON only); see Streaming
```
- `base_url` — empty by default; unlike `[understand.embed]`/`[bench.model]`, an empty `base_url`
is a **hard error** for `ask`, not a fail-soft no-op — there's no meaningful grounded answer
without a model.
- `model` — the model name sent in the request body.
- `temperature` / `max_tokens` — sampling params for both the grounded loop and the ungrounded
fallback.
- `k` — overridable per-invocation with `--k`.
- `max_rounds` — the ceiling on chat turns (seed search doesn't count against it); reaching it
without a final textual answer is treated as `NotFound`.
- `stream` — overridable per-invocation with `--stream`/`--no-stream`; effective streaming also
requires stdout to be a TTY and `--json` to be unset (see [Streaming](#streaming)).
**Auth:** the API key comes from the `ASK_API_KEY` environment variable, falling back to
`OPENAI_API_KEY`. Keys are **env-only** — never read from `kibble.toml`. When set, it's sent as a
`Bearer` token; when unset, requests go out with no `authorization` header at all, which is fine
for a self-hosted endpoint on a trusted LAN.
```bash
# Ask/chat endpoint auth (optional on a trusted LAN).
ASK_API_KEY=
```
## Error handling
- `[ask].base_url` empty → hard error before any work.
- LLM endpoint unreachable/malformed → error surfaced (the run can't proceed without the model);
the message names the endpoint.
- No/empty index → `retrieve::search` already errors (`run kibble index first`); `ask` surfaces it.
- Retrieval embeddings down → `search` runs lexical-only (fail-soft), `ask` proceeds on BM25
passages.
- Malformed tool-call arguments → that `search_corpus` call yields an empty result (never panics);
the loop continues.
- `max_rounds` reached without a final answer → treated as `NotFound`.
- Web mode: an unreachable SearXNG/DuckDuckGo, a blocked URL, or a failed fetch all return a
string tool result (`"(no web results)"` / `"Blocked: ..."` / `"Fetch failed: ..."`), never an
error that aborts the loop — same fail-soft posture as retrieval.
## Testing
No new crate dependency: `ask.rs` reuses `reqwest`/`serde_json`/`std::io::IsTerminal`, and the
chat machinery is the same shared `llm::chat_turn` `kibble bench` already uses (extracted into
`src/llm.rs` so both commands share one implementation). All tests are deterministic and offline:
- Pure helpers (`parse_citations`, `cited_sources`, `parse_choice`) are unit-tested directly.
- The agentic loop (`answer`) is exercised against a real lexical-only index built in a temp dir
(no network) with a `Chat` seam — `ScriptedChat` returns canned turns instead of calling a real
endpoint — covering: answering straight from the seed search, iterating (tool call → search →
final answer) with stable passage numbering across rounds, and the `NOTFOUND` classification.
- `llm::chat_turn` itself is tested against a localhost one-shot TCP mock (mirrors the existing
`embed` endpoint test), and `bench`'s existing tests still pass after the refactor.
- The interactive not-found flow (stdin/stdout prompt, fetch+reindex, retry) is CLI glue verified
by manual smoke testing against a live endpoint, not unit tests — the testable logic underneath
it (`answer`, `answer_ungrounded`, `parse_citations`, `cited_sources`, `parse_choice`) is fully
covered.
- Web mode is covered the same way, offline: `web_search` against a localhost mock standing in for
SearXNG (registers a `Web` passage from the JSON result), and a full `answer` run where the model
scripts `web_search` → `fetch_page` → cited answer against two localhost mocks (search + the
fetched page), asserting the cited source is tagged `SourceKind::Web`.
- `llm::chat_turn_stream` is tested against a localhost mock serving a canned SSE body split across
multiple frames (content deltas plus a tool-call split mid-argument across two frames),
asserting both the pieces delivered live to the `on_content` callback and the final accumulated
`(content, tool_calls)` match — same offline, no-new-dependency approach as the rest of `ask`.
## Out of scope (follow-ups)
- **Streaming for `bench`'s research method** — tracked as #21.
- **Conversational memory** (multi-turn `ask` sessions), cross-encoder re-ranking of passages
before synthesis, and per-source answer filters.