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:
+17
-20
@@ -37,16 +37,16 @@ export const AUTH_KEYS: readonly AuthKeyInfo[] = [
|
||||
docsUrl: "https://console.deepgram.com/",
|
||||
},
|
||||
{
|
||||
id: "GOOGLE_SEARCH_API_KEY",
|
||||
label: "Google Search API key",
|
||||
description: "Web-search half of the Google pair (with the CSE ID) — powers the researcher sub-agent.",
|
||||
docsUrl: "https://developers.google.com/custom-search/v1/introduction",
|
||||
id: "TAVILY_API_KEY",
|
||||
label: "Tavily",
|
||||
description: "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.",
|
||||
docsUrl: "https://app.tavily.com/",
|
||||
},
|
||||
{
|
||||
id: "GOOGLE_CSE_ID",
|
||||
label: "Google CSE ID",
|
||||
description: "Programmable Search Engine ID — which engine the search API queries.",
|
||||
docsUrl: "https://programmablesearchengine.google.com/controlpanel",
|
||||
id: "BRAVE_API_KEY",
|
||||
label: "Brave Search",
|
||||
description: "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.",
|
||||
docsUrl: "https://brave.com/search/api/",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -138,8 +138,8 @@ export type KeyShapeResult = { ok: true } | { ok: false; error: string };
|
||||
* a truncated or wrong-family key can never land in the store). Values are
|
||||
* TRIMMED first (paste hygiene); empty-after-trim always fails.
|
||||
*
|
||||
* CURATED keys carry family rules (Google AIza…, the CSE forms,
|
||||
* Deepgram's length floor — a truncation guard; it publishes no format).
|
||||
* CURATED keys carry family rules (Tavily's tvly-… prefix; Brave and
|
||||
* Deepgram publish no format — length floors + paste hygiene only).
|
||||
* MODEL-PROVIDER keys use genericProviderKeyGuard below instead (families
|
||||
* vary by provider — only paste hygiene applies; owner spec: min length
|
||||
* ~12, no whitespace). OPENROUTER_API_KEY moved to the generic guard with
|
||||
@@ -152,16 +152,13 @@ export function validateKeyShape(id: string, rawValue: string): KeyShapeResult {
|
||||
? { ok: true as const }
|
||||
: { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` };
|
||||
switch (id) {
|
||||
case "GOOGLE_SEARCH_API_KEY":
|
||||
return value.startsWith("AIza")
|
||||
? nonEmpty(10)
|
||||
: { ok: false, error: `${id} must start with "AIza" (Google's API-key format)` };
|
||||
case "GOOGLE_CSE_ID":
|
||||
return (
|
||||
/^[0-9]{6,}:[0-9A-Za-z_-]+$/.test(value) || /^[0-9A-Za-z]{12,}$/.test(value)
|
||||
)
|
||||
? { ok: true }
|
||||
: { ok: false, error: `${id} must be the Programmable Search Engine ID (digits:digits, or the plain alphanumeric form)` };
|
||||
case "TAVILY_API_KEY":
|
||||
return value.startsWith("tvly-")
|
||||
? nonEmpty(20)
|
||||
: { ok: false, error: `${id} must start with "tvly-" (Tavily's key format)` };
|
||||
case "BRAVE_API_KEY":
|
||||
// No public format — a length floor + paste hygiene only.
|
||||
return nonEmpty(20);
|
||||
case "DEEPGRAM_API_KEY":
|
||||
// No public format — the length floor only catches truncated pastes.
|
||||
return nonEmpty(20);
|
||||
|
||||
@@ -41,6 +41,7 @@ export * from "./fuzzy.ts";
|
||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||
export * from "./settings.ts";
|
||||
export * from "./settings-resolution.ts";
|
||||
export * from "./search.ts"; // search port (search wave, 2026-09-20)
|
||||
// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog +
|
||||
// shape rules + masking — pure, browser-safe (the auth page binds to the
|
||||
// /api/keys routes built on it).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The search-provider port (search port + model slots wave, 2026-09-20).
|
||||
*
|
||||
* Search engines are pluggable behind ONE seam: the settings record names a
|
||||
* preferred engine (agent.search.engine — optional), keys live in the auth
|
||||
* store (TAVILY_API_KEY / BRAVE_API_KEY — curated catalog), and the server
|
||||
* composes the live adapter. The Google CSE engine retired with this wave
|
||||
* (closed to new customers; dies Jan 2027) — replaced by Tavily + Brave.
|
||||
*
|
||||
* Owner decisions baked here:
|
||||
* - NO default engine in the factory record: an engine is live iff its
|
||||
* key is present. With a key, the engine auto-activates (the user never
|
||||
* chooses an engine to make search work — setting the key is enough);
|
||||
* agent.search.engine is only an explicit preference.
|
||||
* - Nothing silently falls back: no engine live = search is honestly
|
||||
* unavailable (the tool says so; the researcher sub-agent is disabled
|
||||
* by the agent layer on the same signal — that rewiring is the Teacher
|
||||
* stream's, on this port).
|
||||
*
|
||||
* Pure types + protocol docs only (browser-safe, no node imports): the
|
||||
* adapters are server-side (src/server/search.ts) and arrive as injected
|
||||
* dependencies — the agent-layer tool depends on THIS port only.
|
||||
*/
|
||||
|
||||
/** One search result row — the shape every adapter must produce (the
|
||||
* researcher's tool contract; same fields the old Google adapter emitted). */
|
||||
export interface SearchHit {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/** The engine ids this wave ships (the catalog: curated keys + adapters). */
|
||||
export type SearchEngineId = "tavily" | "brave";
|
||||
|
||||
/** A live search engine: an id (for status/labels) + one operation. The
|
||||
* adapters own their wire protocol (fetch, timeouts, error mapping) —
|
||||
* callers see only this. `search` throws on failure (adapters map HTTP
|
||||
* errors to clear Error messages); the tool layer catches and reports. */
|
||||
export interface SearchProvider {
|
||||
id: SearchEngineId;
|
||||
search(query: string, count: number): Promise<SearchHit[]>;
|
||||
}
|
||||
|
||||
/** Why search is (un)available — the honest status the UI and the tool
|
||||
* layer explain from. `engine` is the ACTIVE engine when live. */
|
||||
export interface SearchStatus {
|
||||
available: boolean;
|
||||
/** The active engine (null when unavailable). */
|
||||
engine: SearchEngineId | null;
|
||||
/** The one-line reason for the state (status text for the UI). */
|
||||
reason: string;
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
import {
|
||||
BACKGROUND_MODEL_ROLES,
|
||||
FACTORY_SETTINGS,
|
||||
@@ -62,6 +63,10 @@ export interface EffectiveSettings {
|
||||
tools: Record<string, boolean>;
|
||||
/** Delta map as stored — absent key → override off. */
|
||||
prompts: Record<string, boolean>;
|
||||
/** The search-engine preference as stored (search wave, 2026-09-20):
|
||||
* passthrough delta — engine resolution (pin → auto → unavailable)
|
||||
* lives in server/search.ts, where keys are visible. */
|
||||
search: { engine?: SearchEngineId };
|
||||
};
|
||||
appearance: EffectiveAppearance;
|
||||
graph: Required<GraphSettings>;
|
||||
@@ -105,6 +110,7 @@ export function resolveSettings(record: UserSettings | null, factory: ModelRoleS
|
||||
models,
|
||||
tools: agent.tools ?? {},
|
||||
prompts: agent.prompts ?? {},
|
||||
search: agent.search ?? {},
|
||||
},
|
||||
appearance: {
|
||||
mode: appearance.mode ?? factoryAppearance.mode,
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import type { ChatSettings } from "./contracts/chat.ts";
|
||||
import type { SearchEngineId } from "./search.ts";
|
||||
|
||||
/** The schema version this code understands. Bump ONLY for breaking shape
|
||||
* changes (a field renamed/meaning changed); pure additions never bump. */
|
||||
@@ -70,6 +71,13 @@ export interface AgentSettings {
|
||||
/** Tool toggles keyed by tool id (absent key = enabled). User-level
|
||||
* only — no per-chat override (owner decision 2026-09-19). */
|
||||
tools?: Record<string, boolean>;
|
||||
/** Search-engine preference (search wave, 2026-09-20). OPTIONAL by
|
||||
* owner decision: setting a key auto-activates its engine — the user
|
||||
* never chooses an engine to make search work. An explicit choice only
|
||||
* pins the preference (e.g. when BOTH keys are present); the chosen
|
||||
* engine must still have its key, else search is honestly unavailable.
|
||||
* Absent + no key = unavailable (no silent fallback, no default). */
|
||||
search?: { engine?: SearchEngineId };
|
||||
/** System-prompt override SWITCHES keyed by segment path (absent key =
|
||||
* off). Switch-on requires the override file to exist (enforced by the
|
||||
* route); the switch is explicit so users keep a file on disk but run
|
||||
@@ -261,6 +269,13 @@ function sanitizeAgent(raw: Record<string, unknown>): AgentSettings {
|
||||
if (tools) out.tools = tools;
|
||||
const prompts = sanitizeBooleanMap(raw.prompts);
|
||||
if (prompts) out.prompts = prompts;
|
||||
// Search preference: lenient read side (an unknown engine id drops;
|
||||
// the write side's parser is strict — a stale client can't brick the
|
||||
// record, it just loses the pin and auto-detection resumes).
|
||||
if (raw.search !== null && typeof raw.search === "object" && !Array.isArray(raw.search)) {
|
||||
const engine = (raw.search as Record<string, unknown>).engine;
|
||||
if (engine === "tavily" || engine === "brave") out.search = { engine };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user