kibble 0.1.0

chew through any source into clean datasets — a fast ingestion, RAG & fine-tuning toolkit
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
# 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.