Kyle-approved fleet harvest. Sibling targets (domain/page or open). Runner labels A-D; mapping stays in a sealed key file until scoring.
18 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 28 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
approved the list; it is tests/testdata/search_queries.txt. Do not invent the set.
Sibling tests/testdata/search_targets.txt: for each query, the page or domain a
good result set must contain, or open when there isn't a clear one.
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. The runner writes result packs labelled A–D with no config names.
The A–D → 1–4 mapping is a separate key file the judge does not read until scoring
is done. Protocol: tests/testdata/BAKEOFF.md. Shuffle helper: tests/bakeoff/blind.py.
Judges: talos or Claude. Score: would you sift fetch the correct URL from this list?
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.