singularity-forge/src/resources/extensions/search-the-web/native-search.js

153 lines
6.4 KiB
JavaScript
Raw Normal View History

/**
* Native Anthropic web search hook logic.
*
* Extracted from index.ts so it can be unit-tested without importing
* the heavy tool-registration modules.
*
* The core injection logic (before_provider_request) now lives in:
* packages/coding-agent/src/core/providers/web-search-middleware.ts
*
* This file exports the constants and functions needed by the extension and by tests,
* and delegates before_provider_request to the native middleware singleton so that
* (a) tests exercise the same code path as production and (b) PREFERENCES.md-based
* search_provider overrides are respected via setPreferBraveResolver.
*/
import {
CUSTOM_SEARCH_TOOL_NAMES,
MAX_NATIVE_SEARCHES_PER_SESSION,
setPreferBraveResolver,
stripThinkingFromHistory,
webSearchMiddleware,
} from "@singularity-forge/coding-agent";
import { resolveSearchProviderFromPreferences } from "../sf/preferences.js";
/** Tool names for the Brave-backed custom search tools */
export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"];
/** All custom search tool names that should be disabled when native search is active */
export { CUSTOM_SEARCH_TOOL_NAMES, MAX_NATIVE_SEARCHES_PER_SESSION, stripThinkingFromHistory };
/**
* Returns true when the provider supports native Anthropic web_search injection.
*
* Purpose: github-copilot, minimax, and kimi use Claude-compatible wire format
* but do NOT support the web_search tool injecting it causes a 400 error.
* The `claude-` model-name prefix heuristic is too broad (those providers also
* use claude-* names). Only the explicit "anthropic" provider tag is trusted.
*/
export function supportsNativeWebSearch(provider) {
2026-05-05 14:31:16 +02:00
return provider === "anthropic";
}
/** When true, skip native web search injection and keep Brave/custom tools active on Anthropic. */
export function preferBraveSearch() {
2026-05-05 14:31:16 +02:00
// PREFERENCES.md takes priority over env var
const prefsPref = resolveSearchProviderFromPreferences();
if (
prefsPref === "brave" ||
prefsPref === "tavily" ||
prefsPref === "minimax" ||
prefsPref === "serper" ||
prefsPref === "exa" ||
prefsPref === "ollama" ||
prefsPref === "combosearch"
)
return true;
if (prefsPref === "native") return false;
// Fall back to env var
return (
process.env.PREFER_BRAVE_SEARCH === "1" ||
process.env.PREFER_BRAVE_SEARCH === "true"
);
}
/**
* Register model_select, before_provider_request, and session_start hooks
* for native Anthropic web search injection.
*
* Returns the isAnthropicProvider getter for testing.
*/
export function registerNativeSearchHooks(pi) {
// null = unknown (model_select not yet fired); true/false = provider is/isn't Anthropic.
let isAnthropicProvider = null;
// Register the PREFERENCES.md-aware resolver so the native middleware (shared
// singleton in web-search-middleware.ts) respects search_provider overrides.
// Called here so each test invocation resets the resolver to the current context.
setPreferBraveResolver(preferBraveSearch);
// Reset the shared middleware session counter for this registration.
// In tests, each registerNativeSearchHooks() call starts a fresh counter.
// In production, the session_start handler below resets it on each new session.
webSearchMiddleware.resetSession();
2026-05-05 14:31:16 +02:00
// Track provider changes via model selection — also handles diagnostics
// since model_select fires AFTER session_start and knows the provider.
pi.on("model_select", async (event, ctx) => {
const wasAnthropic = isAnthropicProvider;
isAnthropicProvider = event.model.provider === "anthropic";
const hasSearchKey = !!(
process.env.BRAVE_API_KEY ||
process.env.TAVILY_API_KEY ||
process.env.MINIMAX_CODE_PLAN_KEY ||
process.env.MINIMAX_CODING_API_KEY ||
process.env.MINIMAX_API_KEY ||
process.env.SERPER_API_KEY ||
process.env.EXA_API_KEY ||
process.env.OLLAMA_API_KEY
);
// When Anthropic (and not preferring Brave): disable custom search tools —
// native web_search is server-side and more reliable.
if (isAnthropicProvider && !preferBraveSearch()) {
const active = pi.getActiveTools();
pi.setActiveTools(
active.filter((t) => !CUSTOM_SEARCH_TOOL_NAMES.includes(t)),
);
} else if (!isAnthropicProvider && wasAnthropic) {
// Switching away from Anthropic — re-enable custom search tools (they
// were disabled while native search was active). If keys are missing,
// user sees the error rather than tools silently vanishing.
const active = pi.getActiveTools();
const toAdd = CUSTOM_SEARCH_TOOL_NAMES.filter((t) => !active.includes(t));
if (toAdd.length > 0) {
pi.setActiveTools([...active, ...toAdd]);
}
}
// Show provider-aware diagnostics on first selection or provider change
if (
isAnthropicProvider &&
!preferBraveSearch() &&
!wasAnthropic &&
event.source !== "restore"
) {
ctx.ui.notify("Native Anthropic web search active", "info");
} else if (
isAnthropicProvider &&
preferBraveSearch() &&
!wasAnthropic &&
event.source !== "restore"
) {
ctx.ui.notify("Brave search active (PREFER_BRAVE_SEARCH)", "info");
} else if (!isAnthropicProvider && !hasSearchKey) {
ctx.ui.notify(
"Web search: Set BRAVE_API_KEY, TAVILY_API_KEY, MINIMAX_CODE_PLAN_KEY, SERPER_API_KEY, EXA_API_KEY, or OLLAMA_API_KEY, or use an Anthropic model for built-in search",
"warning",
);
}
});
// before_provider_request is now handled natively by WebSearchMiddleware in sdk.ts.
// This handler delegates to the same singleton so that:
// (a) existing tests continue to exercise the injection logic end-to-end, and
// (b) the double-injection guard (tools.some(web_search_20250305)) is a no-op
// in production where sdk.ts already ran the middleware first.
//
// When event.model is absent but model_select has already run (isAnthropicProvider
// is not null), synthesize a provider hint from the cached state so the middleware
// does not fall back to the model-name heuristic and wrongly inject into Copilot
// claude-* requests (#copilot-false-positive).
2026-05-05 14:31:16 +02:00
pi.on("before_provider_request", (event) => {
let modelHint = event.model;
if (!modelHint && isAnthropicProvider !== null) {
modelHint = { provider: isAnthropicProvider ? "anthropic" : "not-anthropic" };
2026-05-05 14:31:16 +02:00
}
return webSearchMiddleware.applyToPayload(event.payload, modelHint);
2026-05-05 14:31:16 +02:00
});
pi.on("session_start", async (_event, _ctx) => {
// Reset the shared middleware session budget (#1309).
webSearchMiddleware.resetSession();
2026-05-05 14:31:16 +02:00
});
return { getIsAnthropic: () => isAnthropicProvider };
}