feat(search): web_search onto the injected port — Google CSE retired, research honestly disabled
Chunk A of the search port wave (2026-09-20), per master's assignment: - web.ts: the Google Custom Search backend is gone — web_search now runs on the core search port (SearchProvider), injected as a dependency (import law: the tool never imports server code). createWebTools(search) resolves the LIVE provider fresh per call; null = the honest unavailability string (no silent fallback, owner decision). web_fetch unchanged (needs no engine). - runner: JobRunnerDeps.searchProvider — the server composition threaded into the researcher; jobs already in flight when a key disappears still fail honestly through the same seam, never crash. - jobTools: the research dispatch tool DROPS from the teacher's registry when ctx.searchAvailable() is false (owner decision: registry-level drop — the model never sees the tool, no doomed dispatch, no error card). Absent seam = available (tests). chat-handler injects server/searchAvailable + searchProvider. - tests: stubFetch (the Google wire stub) retired; jobs.test.ts threads a recording stub provider through all four research runners and asserts the scripted query hit the port; new web-search.test.ts covers the factory contract (null → honest string, hits, error strings, fresh resolution, web_fetch independence) + the registry drop matrix. Gates: typecheck, 793/793 tests, lint, build — all green.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import type { Usage } from "@earendil-works/pi-ai";
|
||||
import { webTools } from "../tools/web.ts";
|
||||
import { createWebTools } from "../tools/web.ts";
|
||||
import type { SearchProvider } from "../../core/search.ts";
|
||||
import { toPiTools } from "../lh-tool.ts";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import { runSubagent } from "./pi-run.ts";
|
||||
@@ -62,6 +63,12 @@ What couldn't be answered. Suggested next steps.`;
|
||||
|
||||
export interface ResearcherRunOptions {
|
||||
engine?: PiEngine;
|
||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||
* provider per call, null = honestly unavailable (the web_search tool
|
||||
* reports that in its output — no crash, no silent fallback). Absent
|
||||
* seam reads as unavailable: the server wiring always injects one; the
|
||||
* dispatch-level drop (jobTools) keeps doomed jobs from even starting. */
|
||||
search?: () => SearchProvider | null;
|
||||
abortSignal?: AbortSignal;
|
||||
onUsage?: (usage: Usage) => void;
|
||||
onToolStart?: (toolName: string, args: unknown) => void;
|
||||
@@ -69,10 +76,11 @@ export interface ResearcherRunOptions {
|
||||
|
||||
/**
|
||||
* Run the researcher to completion: isolated context, web tools only, final
|
||||
* text = the brief. The engine is injectable so tests run the loop scripted.
|
||||
* text = the brief. The engine and search seam are injectable so tests run
|
||||
* the loop scripted.
|
||||
*/
|
||||
export async function runResearcherAgent(prompt: string, opts: ResearcherRunOptions = {}): Promise<string> {
|
||||
const { text } = await runSubagent(toPiTools(webTools) as AgentTool<any, any>[], {
|
||||
const { text } = await runSubagent(toPiTools(createWebTools(opts.search ?? (() => null))) as AgentTool<any, any>[], {
|
||||
role: "researcher",
|
||||
prompt,
|
||||
engine: opts.engine,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isToolUIPart, type UIMessage } from "ai";
|
||||
import type { PiEngine } from "../models.ts";
|
||||
import type { SearchProvider } from "../../core/search.ts";
|
||||
import {
|
||||
accumulateJobUsage,
|
||||
buildSubagentSteer,
|
||||
@@ -76,6 +77,13 @@ export interface JobRunnerDeps {
|
||||
* Test overrides (below) still win; absent → config pins.
|
||||
*/
|
||||
engineFor?: (role: ModelRole) => PiEngine;
|
||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||
* provider fresh per job — server/search.ts composition injected by
|
||||
* the wiring; null = honestly unavailable (the researcher's web_search
|
||||
* reports it). Absent seam (tests) reads the same. The dispatch-level
|
||||
* drop (jobTools' searchAvailable) uses the same signal — jobs already
|
||||
* in flight when a key disappears still fail honestly, never crash. */
|
||||
searchProvider?: () => SearchProvider | null;
|
||||
}
|
||||
|
||||
export interface JobRunner {
|
||||
@@ -308,6 +316,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
|
||||
jobStore.updateJob(jobId, { activity: "researching" });
|
||||
const brief = await runResearcherAgent(prompt, {
|
||||
engine: model,
|
||||
search: deps.searchProvider,
|
||||
abortSignal: loop.abortSignal,
|
||||
// Money channel: each agent step is one model call — fold its
|
||||
// usage into the record as it completes (progress write).
|
||||
|
||||
@@ -154,6 +154,10 @@ export interface TeacherToolContext extends PlanToolContext {
|
||||
* rides along — the spawn fires mid-stream, before the tool part is
|
||||
* persisted to the transcript (a transcript scan would race it). */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput) => void;
|
||||
/** Search availability (search port wave, 2026-09-20): the research
|
||||
* dispatch tool's honest-disable signal — no live engine = the tool
|
||||
* drops from this registry (JobToolContext.searchAvailable). */
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+19
-5
@@ -22,6 +22,12 @@ export interface JobToolContext {
|
||||
* input rides along because the spawn happens MID-STREAM, before
|
||||
* onFinish persists the tool part — a transcript scan would race it. */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||
/** Search availability (search port wave, 2026-09-20): the honest
|
||||
* disable signal for the research dispatch tool — no live engine
|
||||
* means NO research tool in the registry (owner decision: registry-
|
||||
* level drop, no doomed dispatch, no silent fallback). Absent =
|
||||
* available (tests + any seam without search wiring). */
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
export const researchInputSchema = z.object({
|
||||
@@ -121,14 +127,22 @@ const PDF_DESCRIPTION =
|
||||
* Per-request job tool registry: each tool closes over the composition root's
|
||||
* context (chatId + jobStore + the spawn seam), so concurrent chats never
|
||||
* share state. chat-handler wires it each turn; tests wire their own.
|
||||
*
|
||||
* The research tool drops from the registry when ctx.searchAvailable reports
|
||||
* no live engine (2026-09-20) — the teacher never sees it, so a keyless
|
||||
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
||||
* a key disappears still run to their honest failure (runner backstop).
|
||||
*/
|
||||
export function jobTools(ctx: JobToolContext): Record<string, LhTool<any, any>> {
|
||||
const searchOn = ctx.searchAvailable?.() ?? true;
|
||||
return {
|
||||
research: lhTool({
|
||||
name: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
schema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
...(searchOn && {
|
||||
research: lhTool({
|
||||
name: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
schema: researchInputSchema,
|
||||
execute: async (input, { toolCallId }) => dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
}),
|
||||
}),
|
||||
demo: lhTool({
|
||||
name: "demo",
|
||||
|
||||
+63
-79
@@ -1,9 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { lhTool } from "../lh-tool.ts";
|
||||
// The search-hit shape lives in the core port since the search wave
|
||||
// (2026-09-20) — this module keeps the historical alias so callers don't
|
||||
// churn; the tool's rewiring onto the port is the Teacher stream's.
|
||||
import type { SearchHit as WebSearchResult } from "../../core/search.ts";
|
||||
// (2026-09-20); this alias keeps callers churn-free.
|
||||
import type { SearchHit as WebSearchResult, SearchProvider } from "../../core/search.ts";
|
||||
export type { WebSearchResult };
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import { parseHTML } from "linkedom";
|
||||
@@ -13,15 +12,20 @@ import TurndownService from "turndown";
|
||||
* Server-side web tools for the researcher sub-agent — a minimal port of the
|
||||
* pi extensions (~/.pi/agent/extensions/web-search + web-fetch):
|
||||
*
|
||||
* web_search(query, count?) → {title, url, snippet}[] (Google Custom Search)
|
||||
* web_search(query, count?) → {title, url, snippet}[] (injected search port)
|
||||
* web_fetch(url) → readable markdown text (Readability + Turndown,
|
||||
* Jina Reader fallback for
|
||||
* JS-rendered pages)
|
||||
*
|
||||
* Credentials come from env (the pi extension used auth.json):
|
||||
* GOOGLE_SEARCH_API_KEY, GOOGLE_CSE_ID
|
||||
* SEARCH REWIRING (search port wave, 2026-09-20): the Google CSE backend is
|
||||
* retired — web_search now runs on the injected search port (core/search.ts).
|
||||
* The live adapter (Tavily/Brave — server/search.ts) arrives as a DEPENDENCY
|
||||
* (import law: this tool depends on the core port, never server code). NO
|
||||
* silent fallback (owner decision): provider absent → search is honestly
|
||||
* unavailable, and the composition root drops the research dispatch tool on
|
||||
* the same signal (the jobTools seam, src/agent/tools/jobs.ts).
|
||||
*
|
||||
* Tool contract: NEVER throw into the agent loop — missing credentials and
|
||||
* Tool contract: NEVER throw into the agent loop — missing provider and
|
||||
* every failure path return a clear error STRING as the tool output, so the
|
||||
* researcher can adapt (or report) instead of crashing.
|
||||
*/
|
||||
@@ -29,32 +33,6 @@ import TurndownService from "turndown";
|
||||
// ── web_search ───────────────────────────────────────────────────────────────
|
||||
// (WebSearchResult is the core SearchHit, re-exported above.)
|
||||
|
||||
function searchCredentials(): { apiKey: string; cseId: string } | null {
|
||||
const apiKey = process.env.GOOGLE_SEARCH_API_KEY;
|
||||
const cseId = process.env.GOOGLE_CSE_ID;
|
||||
return apiKey && cseId ? { apiKey, cseId } : null;
|
||||
}
|
||||
|
||||
async function googleSearch(query: string, count: number, creds: { apiKey: string; cseId: string }): Promise<WebSearchResult[]> {
|
||||
const url = new URL("https://www.googleapis.com/customsearch/v1");
|
||||
url.searchParams.set("key", creds.apiKey);
|
||||
url.searchParams.set("cx", creds.cseId);
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("num", String(Math.min(count, 10)));
|
||||
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Google Custom Search API ${resp.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await resp.json()) as { items?: Array<{ title: string; link: string; snippet?: string }> };
|
||||
return (data.items ?? []).map((item) => ({
|
||||
title: item.title,
|
||||
url: item.link,
|
||||
snippet: item.snippet?.replace(/\n/g, " ") ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// ── web_fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
const USER_AGENT =
|
||||
@@ -136,52 +114,58 @@ async function fetchAndExtract(url: string): Promise<string> {
|
||||
|
||||
// ── tool registry ────────────────────────────────────────────────────────────
|
||||
|
||||
export const webTools = {
|
||||
web_search: lhTool({
|
||||
name: "web_search",
|
||||
description:
|
||||
"Search the web via Google Custom Search. One search per call — use one call per search angle. " +
|
||||
"Returns an array of results with title, url, and snippet.",
|
||||
schema: z.object({
|
||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
||||
export function createWebTools(search: () => SearchProvider | null) {
|
||||
return {
|
||||
web_search: lhTool({
|
||||
name: "web_search",
|
||||
description: "Search the web. One search per call — use one call per search angle. " +
|
||||
"Returns an array of results with title, url, and snippet.",
|
||||
schema: z.object({
|
||||
query: z.string().describe("The search query (a normal search string, phrases included)"),
|
||||
count: z.number().int().min(1).max(10).optional().describe("Number of results (default 5, max 10)"),
|
||||
}),
|
||||
execute: async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
const provider = search();
|
||||
if (!provider) {
|
||||
return (
|
||||
"web_search is not available: no search engine is configured. " +
|
||||
"Set a Tavily or Brave search API key in settings. Report in your brief that web search is unavailable."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await provider.search(query, count ?? 5);
|
||||
} catch (err) {
|
||||
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
execute: async ({ query, count }): Promise<WebSearchResult[] | string> => {
|
||||
const creds = searchCredentials();
|
||||
if (!creds) {
|
||||
return (
|
||||
"web_search is not configured on this server: GOOGLE_SEARCH_API_KEY and/or GOOGLE_CSE_ID " +
|
||||
"are missing. Report in your brief that web search is unavailable."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await googleSearch(query, count ?? 5, creds);
|
||||
} catch (err) {
|
||||
return `web_search failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
web_fetch: lhTool({
|
||||
name: "web_fetch",
|
||||
description:
|
||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
||||
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||
schema: z.object({
|
||||
url: z.string().describe("The URL to fetch"),
|
||||
web_fetch: lhTool({
|
||||
name: "web_fetch",
|
||||
description:
|
||||
"Fetch a web page and extract its readable content as markdown (Readability). " +
|
||||
"Handles HTML pages and plain text; JS-rendered pages fall back to a reader service.",
|
||||
schema: z.object({
|
||||
url: z.string().describe("The URL to fetch"),
|
||||
}),
|
||||
execute: async ({ url }): Promise<string> => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Only http(s) URLs are supported");
|
||||
} catch {
|
||||
return `web_fetch failed: invalid URL "${url}"`;
|
||||
}
|
||||
try {
|
||||
return await fetchAndExtract(url);
|
||||
} catch (err) {
|
||||
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
execute: async ({ url }): Promise<string> => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("Only http(s) URLs are supported");
|
||||
} catch {
|
||||
return `web_fetch failed: invalid URL "${url}"`;
|
||||
}
|
||||
try {
|
||||
return await fetchAndExtract(url);
|
||||
} catch (err) {
|
||||
return `web_fetch failed for ${url}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** The provider-less backstop (tests pin the shape with it; the runner
|
||||
* always injects the real seam — an absent seam reads as unavailable,
|
||||
* never a crash and never a silent alternative engine). */
|
||||
export const webTools = createWebTools(() => null);
|
||||
|
||||
Reference in New Issue
Block a user