feat(search): Tavily + Brave behind a search-provider port; Google CSE retires

Search port + model slots wave, chunk A (owner design, 2026-09-20):
- core/search.ts: SearchHit + SearchProvider port + SearchStatus —
  pure, browser-safe; adapters arrive as injected dependencies.
- server/search.ts: Tavily + Brave adapters (wire shapes VERIFIED
  against docs.tavily.com's OpenAPI + Brave's dashboard docs — Tavily
  auth is the Bearer header, not the legacy body api_key; Brave is
  X-Subscription-Token) + engine resolution per owner decisions: a key
  ALONE activates its engine (no engine choice needed), explicit
  agent.search.engine pins the preference (both keys: Tavily first),
  a pinned engine with a missing key is honestly unavailable — no
  silent fallback, no default engine.
- Curated keys catalog: GOOGLE_SEARCH_API_KEY/GOOGLE_CSE_ID retire
  (CSE closed to new customers, dies Jan 2027 — stored values become
  preserved unknown vars); TAVILY_API_KEY (tvly- shape) + BRAVE_API_KEY
  (length guard) join. UI keys-section TOOL_IDS now derives from the
  catalog — cannot drift.
- GET /api/search: {available, engine, reason} — the honest status for
  the settings gray-out; researcher-disable semantics ride the same
  signal (Teacher stream rewires the tool onto the port).
- agent.search settings field (schema + sanitizer + strict parser);
  web.ts's WebSearchResult is now the core SearchHit (re-exported).
This commit is contained in:
amosblomqvist
2026-09-22 00:02:07 +03:00
parent bd66b31853
commit f1373d5de5
17 changed files with 503 additions and 49 deletions
+10
View File
@@ -61,6 +61,7 @@ describe("sanitizeUserSettings", () => {
},
tools: { research: false, quiz: true },
prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false },
search: { engine: "brave" },
},
appearance: {
mode: "light",
@@ -153,6 +154,15 @@ describe("sanitizeUserSettings", () => {
assert.equal("auditor" in models, false);
});
it("search engine pin survives; unknown engine ids drop (auto-detect resumes) — search wave, 2026-09-20", () => {
const good = sanitizeUserSettings({ version: 1, agent: { search: { engine: "tavily" } } });
assert.deepEqual(good?.agent?.search, { engine: "tavily" });
const bad = sanitizeUserSettings({ version: 1, agent: { search: { engine: "google" } } });
assert.equal(bad?.agent?.search, undefined);
const notObject = sanitizeUserSettings({ version: 1, agent: { search: "tavily" } });
assert.equal(notObject?.agent?.search, undefined);
});
it("partial maps: booleans survive, non-boolean entries drop individually", () => {
const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } });
assert.ok(out);