diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8494feeea..001b155cf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -277,6 +277,15 @@ - External editor (Ctrl-G) now shows full pasted content instead of `[paste #N ...]` placeholders ([#444](https://github.com/badlogic/pi-mono/pull/444) by [@aliou](https://github.com/aliou)) - Subagent example README referenced incorrect filename `subagent.ts` instead of `index.ts` ([#427](https://github.com/badlogic/pi-mono/pull/427) by [@Whamp](https://github.com/Whamp)) + +### Added + +- Added web search provider exclusions so `web_search` can skip configured providers without disabling them for model use ([#2608](https://github.com/can1357/oh-my-pi/issues/2608)). + +### Fixed + +- Fixed `web_search` using stale or missing provider exclusions after `/move` or resuming a session from another project. Provider preferences (`providers.webSearchExclude`, `providers.webSearch`, `providers.image`) are now reapplied when project settings reload on cwd change ([#2611](https://github.com/can1357/oh-my-pi/pull/2611)). + ## [15.13.1] - 2026-06-15 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3807cb440..194a9ec87 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -34,7 +34,7 @@ import { TTS_LOCAL_VOICE_VALUES, } from "../tts/models"; import { EDIT_MODES } from "../utils/edit-mode"; -import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES } from "../web/search/types"; +import { SEARCH_PROVIDER_OPTIONS, SEARCH_PROVIDER_PREFERENCES, type SearchProviderId } from "../web/search/types"; /** Unified settings schema - single source of truth for all settings. * @@ -3846,6 +3846,16 @@ export const SETTINGS_SCHEMA = { options: SEARCH_PROVIDER_OPTIONS, }, }, + "providers.webSearchExclude": { + type: "array", + default: [] as SearchProviderId[], + ui: { + tab: "providers", + group: "Services", + label: "Excluded Web Search Providers", + description: "Providers that web_search should never use, even as fallbacks", + }, + }, "providers.image": { type: "enum", values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 9d56ad2c0..37983afe8 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -40,7 +40,9 @@ import { import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { isImageProviderPreference, + isSearchProviderId, isSearchProviderPreference, + setExcludedSearchProviders, setPreferredImageProvider, setPreferredSearchProvider, } from "../../tools"; @@ -419,6 +421,11 @@ export class SelectorController { setPreferredSearchProvider(value); } break; + case "providers.webSearchExclude": + if (Array.isArray(value)) { + setExcludedSearchProviders(value.filter(isSearchProviderId)); + } + break; case "providers.image": if (isImageProviderPreference(value)) { setPreferredImageProvider(value); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 96c7ba749..140e55dd9 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -92,6 +92,7 @@ import { STTController, type SttState } from "../stt"; import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt"; import { formatTaskId } from "../task/render"; import type { LspStartupServerInfo } from "../tools"; +import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen"; import { normalizeLocalScheme } from "../tools/path-utils"; import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils"; import { setAutoQaConsentHandler } from "../tools/report-tool-issue"; @@ -103,6 +104,12 @@ import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; +import { + isSearchProviderId, + isSearchProviderPreference, + setExcludedSearchProviders, + setPreferredSearchProvider, +} from "../web/search"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { ChatBlock, type ChatBlockHost } from "./components/chat-block"; @@ -904,6 +911,22 @@ export class InteractiveMode implements InteractiveModeContext { // up the destination project's configuration. if (isSettingsInitialized()) { await settings.reloadForCwd(newCwd); + // Reapply provider preferences from the newly-loaded settings so the + // module-level search/image provider state reflects the destination + // project's configuration. Without this, the previous project's + // exclusions leak and newly-excluded providers are still used. + const excludedWebSearchProviders = settings.get("providers.webSearchExclude"); + if (Array.isArray(excludedWebSearchProviders)) { + setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId)); + } + const webSearchProvider = settings.get("providers.webSearch"); + if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { + setPreferredSearchProvider(webSearchProvider); + } + const imageProvider = settings.get("providers.image"); + if (isImageProviderPreference(imageProvider)) { + setPreferredImageProvider(imageProvider); + } } // Re-warm plugin roots, capabilities, slash commands, and the ssh tool so // the next prompt sees everything scoped to the new project directory. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 6ea5b63ef..a57218110 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -177,6 +177,7 @@ import { getSearchTools, HIDDEN_TOOLS, isImageProviderPreference, + isSearchProviderId, isSearchProviderPreference, type LspStartupServerInfo, loadSshTool, @@ -185,6 +186,7 @@ import { renderSearchToolBm25Description, SearchTool, SearchToolBm25Tool, + setExcludedSearchProviders, setPreferredImageProvider, setPreferredSearchProvider, type Tool, @@ -1164,6 +1166,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} discoveredSkillsPromise?.catch(() => {}); // Initialize provider preferences from settings + const excludedWebSearchProviders = settings.get("providers.webSearchExclude"); + if (Array.isArray(excludedWebSearchProviders)) { + setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId)); + } + const webSearchProvider = settings.get("providers.webSearch"); if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) { setPreferredSearchProvider(webSearchProvider); diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index f671734a8..1aa7155a7 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -300,6 +300,6 @@ export function getSearchTools(): CustomTool[] { return [webSearchCustomTool]; } -export { getSearchProvider, setPreferredSearchProvider } from "./provider"; +export { getSearchProvider, setExcludedSearchProviders, setPreferredSearchProvider } from "./provider"; export type { SearchProviderId as SearchProvider, SearchResponse } from "./types"; -export { isSearchProviderPreference } from "./types"; +export { isSearchProviderId, isSearchProviderPreference } from "./types"; diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 0fe94b533..b74b6a00a 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -127,6 +127,18 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): preferredProvId = provider; } +/** Providers excluded from web search resolution via settings. */ +let excludedProvIds = new Set(); + +/** Set providers that web search should never use, including fallbacks. */ +export function setExcludedSearchProviders(providers: readonly SearchProviderId[]): void { + excludedProvIds = new Set(providers); +} + +function isSearchProviderExcluded(id: SearchProviderId): boolean { + return excludedProvIds.has(id); +} + /** * Determine which providers are configured and currently available. * Each candidate is loaded (and its `isAvailable()` called) only as the chain @@ -138,7 +150,7 @@ export async function resolveProviderChain( ): Promise { const providers: SearchProvider[] = []; - if (preferredProvider !== "auto") { + if (preferredProvider !== "auto" && !isSearchProviderExcluded(preferredProvider)) { const provider = await getSearchProvider(preferredProvider); if (await provider.isExplicitlyAvailable(authStorage)) { providers.push(provider); @@ -146,7 +158,7 @@ export async function resolveProviderChain( } for (const id of SEARCH_PROVIDER_ORDER) { - if (id === preferredProvider) continue; + if (id === preferredProvider || isSearchProviderExcluded(id)) continue; const provider = await getSearchProvider(id); if (await provider.isAvailable(authStorage)) { providers.push(provider); diff --git a/packages/coding-agent/test/web/search/provider-chain.test.ts b/packages/coding-agent/test/web/search/provider-chain.test.ts new file mode 100644 index 000000000..2aa3d6248 --- /dev/null +++ b/packages/coding-agent/test/web/search/provider-chain.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { AuthStorage } from "@oh-my-pi/pi-ai"; +import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; +import { + resolveProviderChain, + setExcludedSearchProviders, + setPreferredSearchProvider, +} from "@oh-my-pi/pi-coding-agent/web/search/provider"; +import { SEARCH_PROVIDER_ORDER } from "@oh-my-pi/pi-coding-agent/web/search/types"; + +const authStorage = {} as AuthStorage; +const originalBraveApiKey = process.env.BRAVE_API_KEY; +const originalJinaApiKey = process.env.JINA_API_KEY; + +function enableKeyBackedProviders(): void { + process.env.BRAVE_API_KEY = "test-brave-key"; + process.env.JINA_API_KEY = "test-jina-key"; +} + +function restoreEnv(): void { + if (originalBraveApiKey === undefined) { + delete process.env.BRAVE_API_KEY; + } else { + process.env.BRAVE_API_KEY = originalBraveApiKey; + } + + if (originalJinaApiKey === undefined) { + delete process.env.JINA_API_KEY; + } else { + process.env.JINA_API_KEY = originalJinaApiKey; + } +} + +afterEach(() => { + setPreferredSearchProvider("auto"); + setExcludedSearchProviders([]); + restoreEnv(); +}); + +describe("resolveProviderChain", () => { + it("omits excluded providers from the fallback chain", async () => { + enableKeyBackedProviders(); + setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina")); + + const providers = await resolveProviderChain(authStorage, "auto"); + + expect(providers.map(provider => provider.id)).toEqual(["jina"]); + }); + + it("ignores the preferred provider when it is excluded", async () => { + enableKeyBackedProviders(); + setExcludedSearchProviders(SEARCH_PROVIDER_ORDER.filter(id => id !== "jina")); + + const providers = await resolveProviderChain(authStorage, "brave"); + + expect(providers.map(provider => provider.id)).toEqual(["jina"]); + }); + + it("applies live settings edits to the exclusion chain", async () => { + enableKeyBackedProviders(); + const controller = new SelectorController({} as unknown as ConstructorParameters[0]); + + controller.handleSettingChange( + "providers.webSearchExclude", + SEARCH_PROVIDER_ORDER.filter(id => id !== "jina"), + ); + + const providers = await resolveProviderChain(authStorage, "auto"); + + expect(providers.map(provider => provider.id)).toEqual(["jina"]); + }); +});