PR-2 of fb-41 multi-hop RAG. Decompose + retrieve + synthesize 3-stage
pipeline가 `opts.multi_hop=true` 일 때 dispatch. Dynamic decide loop
는 PR-3.
- `AskOpts.multi_hop: bool` 필드 추가 + `impl Default for AskOpts`
도입 (HOTFIXES 2026-05-07 의 known limitation 해소). 9 explicit
init site 모두 `multi_hop: false` 추가 — Default 도입으로 향후
`..Default::default()` 점진 migrate 가능.
- `RagPipeline::ask` 의 entry 에 dispatcher 한 줄
(`if opts.multi_hop { return self.ask_multi_hop(...) }`).
- `RagPipeline::ask_multi_hop` 신규 method. 1) decompose LLM call
→ JSON array of strings parse, 2) 각 sub-query 로 retrieve +
chunk_id dedup pool, 3) score gate / no-chunks 가드, 4)
pack_context (single-pass 와 helper 공유), 5) synthesize LLM
call w/ MULTI_HOP_SYNTHESIZE_SYSTEM_PROMPT, 6) citation extract
+ Answer build. `prompt_template_version` = "rag-multi-hop-v1"
로 stamp — eval `compare` 가 single-pass vs multi-hop 분리.
- Prompt const 신규: MULTI_HOP_DECOMPOSE_SYSTEM_PROMPT +
MULTI_HOP_DECOMPOSE_USER_TEMPLATE + MULTI_HOP_SYNTHESIZE_SYSTEM_PROMPT
+ PROMPT_TEMPLATE_VERSION_MULTI_HOP + MULTI_HOP_MAX_SUB_QUERIES_DEFAULT.
- `kebab_core::RefusalReason::MultiHopDecomposeFailed` variant 신규.
Cascade: kebab-store-sqlite `refusal_reason_label` + kebab-tui `ask
refusal render` exhaustive match 갱신.
- `parse_decompose_response` + `strip_markdown_json_fence` helper —
markdown code fence (```json / ```) strip + JSON array of strings
parse + trim + drop empty + cap at MULTI_HOP_MAX_SUB_QUERIES_DEFAULT.
None 반환 시 caller 가 `MultiHopDecomposeFailed` refusal.
Tests (55 passing total, 8 신규):
- 6 unit (parse_decompose_response 의 bare array / fence variants /
garbage / cap / trim 회귀 핀).
- 2 integration: `ask_multi_hop_dispatches_and_decompose_garbage_refuses`
(decompose garbage → MultiHopDecomposeFailed + 정확히 1 LLM call) +
`ask_with_multi_hop_false_keeps_single_pass_path` (회귀 핀, 기존
caller 자동 backwards-compat).
Happy-path multi-hop (decompose 성공 → synthesize) 의 integration
test 는 ScriptedLm helper 가 PR-3 의 decide loop 와 함께 도입될
때 같이 추가. 현 `MockLanguageModel` 는 canned single response 라
2-LLM-call sequence 핀 불가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire two new optional fields onto SearchHit (skip_serializing_if = None)
and two Vec<String> filter fields onto SearchFilters (serde default).
Add RetrievalDetail::Default impl (manual, uses SearchMode::Hybrid as
sentinel). Patch all downstream SearchHit / SearchFilters literal
constructors with repo: None / code_lang: None / vec![] as appropriate.
Also covers Citation::Code arm in kebab-eval metrics match.
Adds the `trace: Option<SearchTrace>` field to `SearchResponse` and
threads `SearchOpts.trace` through `App::search_with_opts`. When the
caller sets `opts.trace = true` the path bypasses the LRU search cache
and runs through `HybridRetriever::search_with_trace`, which dispatches
all 3 SearchModes internally; this means `--trace` requires embeddings
(same constraint as `--mode hybrid`). The non-trace path keeps its
exact prior behavior with `trace: None` stamped on the response.
Picked up Task 1 / Task 3 follow-ups in the same commit so the
workspace compiles: SearchOpts struct-literals in kebab-cli/main.rs +
kebab-mcp/tools/search.rs default the new `trace` field to false, and
the schema-wrapper test in kebab-cli/wire.rs fills the new
media_breakdown / lang_breakdown / index_bytes / stale_doc_count fields
on Stats with `Default::default()`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ingested_after: convert OffsetDateTime to UTC before formatting
so non-Z offsets compare correctly against UTC TEXT storage
(lexical.rs + filters.rs)
- README: --tag is repeatable-only, not csv (only --media is csv)
- test(cli): add multi-value --tag OR-within IN-list coverage
- test(store): add UTC-offset regression test for ingested_after
- mcp: use ERROR_V1_ID const instead of hardcoded "error.v1"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 new optional inputs on SearchInput: tags, lang, path_glob,
trust_min, media, ingested_after, doc_id. Validation surfaces as
error.v1 code = invalid_input via StructuredError. Dispatch builds
SearchFilters from the inputs and forwards through the existing
search_with_opts_with_config facade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors CLI surface: same input shape, same fetch_result.v1
output. invalid_input error for missing kind-specific fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SearchInput gains max_tokens / snippet_chars / cursor (all optional).
Output wrapped in search_response.v1 to match CLI; existing
tools_call_search test updated to read v["hits"] instead of the bare
array.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5th + 6th MCP tools — first mutation surface (fb-30 v1 was read-only).
Both wrap the new kebab-app facade fns + use spawn_blocking via the
existing spawn_tool helper. tools/list now returns 6 tools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues from Task 7 review:
1. CRITICAL — call_tool "ask" arm called blocking ask handle from async
context. OllamaLanguageModel::new builds reqwest::blocking::Client
which creates+drops a tokio runtime → panic inside async. Fix:
tokio::task::spawn_blocking wrap. Also applied preemptively to
"search" arm (SqliteStore + Lance open are blocking IO too).
2. IMPORTANT — ask tool's retrieval mode hardcoded to Lexical (test
workaround for provider="none"); CLI default is Hybrid. Fix: add
`mode: Option<String>` field to AskInput, default Hybrid in handle,
test passes mode=Some("lexical") explicitly to keep test functional
on provider="none".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth (and final v1) tool — `ask` (input: query / optional session_id).
Multi-turn via optional session_id (kebab_app::ask_with_session_with_config),
single-shot via ask_with_config when None. Refusal (grounded:false) NOT
mapped to isError — agent branches on the wire payload's grounded flag.
AskOpts has no Default impl (must construct manually). Answer carries no
schema_version field (tagged inline via entry().or_insert_with, idempotent).
Mode defaulted to Lexical: reqwest::blocking::Client::build creates and
drops a tokio runtime, panicking inside async context — the empty-corpus
refusal test avoids this via spawn_blocking; the tool itself uses Lexical
as the default mode since MCP callers typically run without an embedding
provider configured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third tool — `search` (input: query / mode / k). First tool with
non-empty input — establishes the pattern: SearchInput struct with
JsonSchema derive + Tool::new uses
rmcp::handler::server::common::schema_for_type::<SearchInput>() for
inputSchema + call_tool match arm parses request.arguments via
serde_json::from_value.
search_with_config takes owned Config, so state.config (Arc<Config>)
is cloned via (*state.config).clone(). Output: search_hit.v1 array —
SearchHit (kebab-core) does not carry schema_version field, so each
element is tagged inline before serialising.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second tool — `doctor` (no input args, returns doctor.v1 JSON via
kebab_app::doctor_with_config_path). Mirrors schema tool's manual-dispatch
pattern: Tool::new entry in list_tools, match arm in call_tool, per-tool
module in tools/doctor.rs.
doctor_with_config_path takes Option<&Path> (not &Config), so KebabAppState
is extended with config_path: Option<PathBuf>. All existing callers
(initialize.rs, tools_call_schema.rs, serve_stdio_async) pass None for now;
Plan Task 10 (Cmd::Mcp wiring) will thread the actual --config path through.
doctor_with_config falls back to XDG default when config_path is None —
same behavior as bare `kebab doctor`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First tool wired — `schema` (no input args, returns schema.v1 JSON
mirroring `kebab schema --json`). Establishes the per-tool module
pattern (crates/kebab-mcp/src/tools/<name>.rs) + error helper that maps
anyhow::Error to MCP CallToolResult.error with error.v1 content.
Dispatch pattern: manual dispatch — explicit `list_tools` + `call_tool`
overrides on `impl ServerHandler for KebabHandler` with a
`match request.name.as_ref()` arm per tool. No proc-macro magic.
Tasks 5-7 should add a new arm + new tools/<name>.rs following the same
pattern; also add a `Tool::new(...)` entry in `list_tools`.
API shapes confirmed from rmcp 1.6 source:
- Content = Annotated<RawContent>; text via `Content::text(s)`; pattern
match via `&content.raw` → `RawContent::Text(t)` → `t.text`
- CallToolResult::success(Vec<Content>) / ::error(Vec<Content>)
- ListToolsResult::with_all_items(Vec<Tool>)
- schema_for_empty_input() from rmcp::handler::server::common
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>