POST /v1/search and /v2/search fan in SearXNG, Brave LLM Context, and Tavily from config-fixed endpoints. Public sources refuse private A records; SearXNG is a loopback-only client, not the fetch guard. Cache keys include caller class. Restricted callers search like the fleet; logs omit query, hosts, URLs, and upstream errors. Degraded results are not stored. Tavily sends search_depth=basic and drops answer. Missing keys surface in /healthz. Default-on sources stay off until the bake-off. Query harvest is not in this commit.
17 KiB
Sift P3 — search
Drafted 2026-09-23 by titan. Reviewer: Claude (doc, guard boundary, contract tests). Implementer: titan. Orion pin bump: straylight, after Claude approves P2.6 then this P3.
1. Scope
In: POST /v1/search (native, PLAN §4.2), POST /v2/search (Firecrawl-compatible, what
Hermes web_search actually sends), CLI sift search, per-source SearXNG / Brave LLM
Context / Tavily fan-in, canonical-URL dedupe, honcho-embed rerank, search-result cache,
per-source timeouts with partial results, cost counters and a daily cap on paid vendors,
contract tests for Hermes 0.19 and 0.21.
Out: P4 URL/fetch cache; crawl/map; Tavily extract/crawl/answer; fetching result URLs
inside search (agents still sift fetch through the guard); P2.5 is already shipped.
Default-on sources are not chosen in this doc. Each source is a config switch. The bake-off in §10 decides which combination ships enabled on orion.
2. Contract facts (verified 2026-09-23 on titan's hermes-agent tree)
Both fleet Hermes versions speak Firecrawl through firecrawl-py==4.17.0 (P1). Search
goes through the same client.
What Hermes 0.21 actually sends (plugins/web/firecrawl/provider.py):
- Keyless/raw client:
POST {api_url}/v2/searchwith body exactly{"query": "<str>", "limit": <int>}. Timeout 60 s. Bearer whenFIRECRAWL_API_KEYis set. - SDK path:
Firecrawl(api_url=…, api_key=…).search(query=query, limit=limit). - Hermes tool default
limitis 5 (tools/web_tools.py); cap elsewhere in the plugin layer is 20 (SEARCH_LIMIT_CAP).
Decode leniently: ignore unknown fields (scrapeOptions, sources, tbs, …). Search
never scrapes result URLs even if the SDK asks.
What Hermes reads (_extract_web_search_results in the same file), in order:
dataif it is a list of objectsdata.web, thendata.results, then top-levelweb/results- SDK attribute
.web
PLAN §4.1: respond with data.web. Each hit needs url, title, description. Extra
keys are allowed (Hermes keeps them). Empty list is a successful search, not an error.
Response Hermes then wraps (plugins/web/_common.py):
{"success": true, "data": {"web": [{"url": "…", "title": "…", "description": "…", "position": 1}]}}
position is added by some providers; Hermes does not require it. Sift sets it on /v2/search
(1-based after rerank) so the two Hermes versions see the same shape.
Errors: same table as P1. Non-2xx uses {"success": false, "error": "…"}. Never 502
(SDK retries only on 502). A search with zero sources configured, or every source failed
with no cached hit, is 422 no search results (not 500). Auth 401 unchanged.
Native (POST /v1/search), PLAN §4.2:
{"query": "…", "question": "optional", "k": 8, "rerank": true, "digest": false}
{"results": [{"url": "…", "title": "…", "snippet": "…", "score": 0.0, "source": "searxng|brave_llm|tavily"}],
"warnings": [],
"sizes": {"results": 8, "sources_ok": ["searxng"], "sources_failed": []},
"timings": {"search_ms": 0, "rerank_ms": 0},
"cached": false}
question is reserved for a later digest-of-results path; in P3 it is accepted and
ignored (with a warning if digest: true). digest defaults false and stays off.
3. Sources
Config, not the request. Each source has enabled and a fixed endpoint.
enabled = false → source is skipped, no warning.
A source that is enabled but missing its key is not skipped silently: log once at
startup, and /healthz lists it as degraded (search.<name>: missing key). Requests
still run the other sources and add warnings: <name>: missing key.
[search]
k = 8
timeout = "8s" # per source
rerank = true
digest = false
cache_ttl = "1h"
[search.searxng]
enabled = false # bake-off decides
url = "http://127.0.0.1:8888"
[search.brave_llm]
enabled = false
url = "https://api.search.brave.com/res/v1/llm/context"
[search.tavily]
enabled = false
url = "https://api.tavily.com/search"
daily_credit_cap = 500
Upstream URLs are only those strings. A request cannot change them.
3.1 SearXNG (orion loopback)
GET {url}/search?q=<query>&format=json&pageno=1, Accept: application/json, 8 s.
Map results[].url|title|content → snippet. Sort by SearXNG score descending before
the shared fan-in. This is the Hermes SearXNG provider's request (plugins/web/searxng/provider.py).
3.2 Brave LLM Context
PLAN §2, verified earlier: GET /res/v1/llm/context?q=… with BRAVE_API_KEY from
/var/lib/sift/secret.env (0640 root:sift). Do not read SearXNG's secret.env.
Header X-Subscription-Token. Map result URLs and snippets from the live JSON
(sources and any URL-bearing fields). Do not assume a model-written
grounding answer exists — confirm against a live response before dropping or
keeping any field that is not a URL/title/snippet.
This is not Brave Web Search (/res/v1/web/search). SearXNG already runs braveapi
as an engine; doubling that is wasted spend.
3.3 Tavily (Kyle, #inference-infrastructure)
- Key only in
/var/lib/sift/secret.envasTAVILY_API_KEY. Never in chat, logs, metrics labels, or the Nix store. POST {url}JSON:query,max_results(min(k, 20)),include_answer: false,include_raw_content: false,include_images: false,search_depth: "basic"(explicit, so credit cost is fixed).Authorization: Bearer.- Never request or return
answer. If Tavily still sends one, drop it before the fan-in. Do not call/extract,/crawl, or/map. - Map
results[].url|title|content+score. Countusage.creditstoward the daily cap.
Nix activation already creates secret.env with a Brave placeholder; add a Tavily
placeholder line the same way (empty, on-box fill).
4. Pipeline
- Auth (same bearer as fetch). Reject empty query / query > 1500 chars (Tavily's published
cap; apply to all sources) with 400
invalid query. - Cache lookup (§7). Hit → return, no upstream,
cached: true. - Fan-in: one goroutine per enabled source, each with
search.timeout. Collect whatever arrived; a timeout or non-2xx is awarningsentry (searxng: timeout,tavily: 429) and that source contributes nothing. - Dedupe on canonical URL: scheme+host lowercased, default ports stripped, fragment
dropped, trailing slash dropped on the path,
www.left as-is (different sites). First snippet wins;sourcebecomes the first source that produced it; later sources only affect rerank input if they brought a longer snippet (keep the longer one). - Rerank (§5) unless
rerank: falseor Dixie cannot take the call. - Truncate to
k(native) orlimit(/v2/search, default 5, max 20). - Store in the search cache only if every attempted source succeeded and rerank did not fall back. A timeout or fallback must not pin a partial result for the rest of the hour (skip the write, or use a short TTL such as 2 m — skip is the default). Return.
Search never GETs a result URL. Agents sift fetch those through the P1 guard.
5. Rerank and Dixie
Embed the query and each remaining snippet with honcho-embed (same engine, same
dixie.max_inflight FIFO as digest). Cosine similarity → score. Restore nothing to
source order except on fallback.
Dixie saturation must not fail a search: if the embed permit cannot be had within
dixie.queue_wait, or embed errors, or the queue is full → keep source-fan-in order
(SearXNG, then Brave, then Tavily, then original rank inside a source), warning
rerank fell back: <reason>. Never call chat/digest for search in P3.
6. Guard boundary (SSRF)
The P1 Guard dialer blocks loopback, which is where SearXNG lives. Search clients
must not use Guard.Client() / fetch.Fetcher.
Each source owns a dedicated http.Client built at process start from its config URL
only:
- Parse the config URL:
http/httpsonly, no userinfo. SearXNG may be loopback; Brave and Tavily must be public hostnames (api.search.brave.com,api.tavily.com). - Resolve that hostname once (and refresh on a long interval, e.g. 10 m). A source
marked public must resolve only to public addresses — run the guard's public-IP
check (
CheckAddr) on every record. A poisonedapi.tavily.com → 127.0.0.1is refused. The dialerControlhook allows only those resolved IPs, only that port. Anything else, including a rebinding answer, is refused. - TLS against system roots, no override, no
InsecureSkipVerify. No cookies, no proxy env, no redirects to a different host (max 1 hop, same host). Redirect to a different host → source failure, not a follow. - Request path/query are code-built (
/search?q=&format=json,/res/v1/llm/context?q=,POST /search). Query text is query-escaped; it never becomes a URL.
That is not an SSRF hole: the agent cannot name the target. A test proves a request body
cannot point search at http://127.0.0.1:11434 or http://rift:8000. A second test
proves the SearXNG client can dial the configured loopback URL and cannot dial a
different loopback port.
sift fetch of a search result still goes through the guard as today.
7. Search-result cache
SQLite, same /var/lib/sift/cache.db as P2 digest cache, new table search (not
the digest/embed tables).
- Key:
sha256(norm(query) | sources | k | hour_bucket | caller_class).norm= trim, collapse whitespace, lowercase (same as digest questions).sources= sorted list of enabled source names that were actually attempted (not the ones skipped for missing keys).hour_bucket= UTCYYYY-MM-DDTHHof the request.caller_class=restrictedif the caller is inpermit_restricted, elsefleet. This blocks a fleet caller from observingcached: trueon a query only the restricted caller issued this hour. Restricted callers read and write their own partition only. - Do not cache a result if any source failed or rerank fell back (§4 step 7).
- TTL:
search.cache_ttl(default 1 h). Hour-bucket already bounds freshness; TTL is the eviction/serve window if a process spans the hour mark. - Value: URLs, titles, snippets, scores, source names. No page bodies, no Tavily answers, no Honcho context.
- Failure to read/write → warning, serve uncached.
/metrics:search_cache_hit,search_cache_miss.
The URL/fetch cache stays in P4.
Digest/question-embedding keys are partitioned the same way in P2.6.
8. Cost, metrics, logs
/metrics (JSON, additive to P1/P2):
search_queries{caller,source}countssearch_credits{caller,source}(Tavilyusage.credits; Brave 1 per call if 2xx; SearXNG 0)search_errors{source,kind}search_cache_hit/search_cache_misssearch_rerank_fallbacksearch_daily_credits{source}andsearch_capped{source}
Daily cap (paid vendors, currently Tavily): calendar UTC day, in-process counter
plus a search_cap row in SQLite so a restart does not reset it. Hitting the cap
skips that source for the rest of the day (warnings: tavily: daily cap) and does
not fail the search. Brave LLM Context gets a cap too if the key is billed; default
off until the bake-off shows a rate.
Logs: one INFO line search caller=… q_len=… sources=… n=… cache=hit|miss rerank_ms=….
Never log the query text beyond q_len, never keys, never snippets, never hosts or
URLs. For permit_restricted callers, also drop upstream error text; vendor warnings
that name no target (tavily: 429) are fine.
9. Restricted callers (Kyle, 2026-09-23)
"Restricted" means no other agent may see this work, not limited in what it may do.
permit_restricted callers search exactly like the fleet: same sources, including
Brave LLM Context and Tavily. There is no fail-closed 403 and no
search.permit_restricted_paid knob.
Confidentiality:
- Logs: no query text, no hosts or URLs, and no upstream error text. Log
caller,q_len, source names,n, and cache hit or miss only. Vendor warnings that name no target (tavily: 429) are fine. - Caches: every search-cache key includes
caller_class(fleet|restricted). A fleet caller can never hit, or detect viacached: true, a restricted entry. Restricted callers still read and write their own partition. P2.6 does the same for digest and question-embedding keys indcache. - Query harvest (§10): still exclude guardian-star logs and restricted callers. Kyle reviews the list before commit.
10. Bake-off (acceptance)
A fixed set of ~30 real queries harvested from recent fleet web_search use
(gateway/agent.log on straylight, talos, titan). Exclude the guardian-star
profile and anything from permit_restricted callers. Deduped, no secrets. Kyle
sees the list before it is committed as tests/testdata/search_queries.txt. Do not
invent the set.
For each query, run four configurations and record top-k URLs + latency + credits:
- SearXNG alone
- SearXNG + Brave LLM Context
- SearXNG + Brave LLM Context + Tavily
- Current Hermes Tavily
web_search(the live tool, not sift)
Judged blind on whether the result set would get the agent to the right source
(would you sift fetch the correct URL from this list?). Scoring notes live next to
the query file; the judge does not see the configuration name.
Report in this thread:
- which configuration wins (this becomes default-on)
- cost per query per configuration (Tavily credits, Brave calls)
- p90 latency per configuration
Plus the mechanical gates:
make test lint contractgreen (new tests in §12).- Contract: firecrawl-py 4.17.0
.search()and a rawPOST /v2/searchboth parse through Hermes_extract_web_search_results. - Guard: search cannot be aimed at an arbitrary URL; SearXNG client cannot dial a
non-configured loopback port;
sift fetchof a result is still guarded. - Tavily fixture: request body has
include_answer: falseand no extract/crawl; response mapping dropsanswerif present. - Partial results: one source 500 + two 200 → 200 with
warnings. - Dixie busy → rerank fallback, still 200.
- Cache hit/miss metrics on a repeated query inside the hour bucket.
- Restricted vs fleet: same query, different
caller_classin the cache key; fleet cannot observecached: trueon a restricted write. Logs for restricted callers contain no query, host, URL, or upstream error text.
11. Code layout
internal/search Source interface; SearXNG, BraveLLM, Tavily clients; fan-in; dedupe
internal/search/dial fixed-upstream http.Client (config URL → allowed IP:port)
internal/rerank honcho-embed cosine; fallback
internal/dcache add table search (§7)
internal/api POST /v1/search, POST /v2/search
cmd/sift search subcommand
tests/contract sdk_test.py grows a .search() case; raw JSON case
Secrets: secret.env loaded at serve start into the process env (or a small map).
Do not print the file. Tests use httptest upstreams, never live keys.
12. Tests
- Canonical URL table (fragment, case, default port, trailing slash).
- Fan-in: 3 fake sources, one timeout, one 500, one 200 → partial + warning.
- Tavily client: assert outbound JSON (
include_answerfalse,search_depthbasic, no extract); dropanswerfrom inbound. - Public-source dialer: poisoned private A record refused; TLS uses system roots.
- Brave/SearXNG response mapping fixtures (from PLAN's live shapes + Hermes providers).
- Dialer: configured loopback allowed; other loopback port refused; request-supplied URL ignored.
- Rerank fallback when embed gate is full.
- Cache key: same norm/query/sources/k/hour/caller_class → hit; other class → miss; next hour → miss.
- Daily cap: Nth Tavily call skipped.
- Restricted caller: same sources as fleet; logs have no query/host/URL; cache partitioned.
/v2/searchgolden:success+data.web.- Contract harness:
Firecrawl(...).search("contract", limit=3)against sifttest (search fixtures on the local HTTP server,-test-allow-prefixalready exists). AlsoPOST /v2/searchraw JSON → run the result through a copied_extract_web_search_results(or import Hermes if the tree is present; otherwise a 20-line replica of that function in the harness, with a comment pointing at the Hermes path and a test that the replica still matches).
13. CLI and skill
sift search "<query>" [--k N] [--json]
Client config unchanged (~/.config/sift/client.toml). Default output: one line per
hit, title\nurl\nsnippet, blank line between. --json = native envelope. Non-zero
exits match fetch (5 = sift unreachable).
skill/SKILL.md: sift search becomes the first step for web search; web_search
is the fallback when sift is unreachable. Restricted callers use the same path
(same sources). Still sift fetch for URLs.
14. Rollout
- This doc reviewed by Claude (guard boundary + contract).
- titan implements on
git.wntrmute.dev:kyle/sift, evidence in this thread. - Claude reviews the code.
- straylight bumps the orion pin in
~/src/nixoscommitted master (titan does not edit that repo). - Bake-off against live orion; Kyle picks default-on sources; second pin bump if config changes.
- Hermes
web.search_backend: firecrawlon one host (talos first, same as P1) only after the bake-off.
15. Open questions for Kyle
- Tavily
daily_credit_capdefault (500 is a guess). - Brave LLM Context vs leaving Brave entirely inside SearXNG: bake-off should answer; confirm we should not also call Brave Web Search.