fix(web-search): keep first-class web_search runtime providers visible

When createWebSearchTool is wired with lateBindRuntimeConfig: true, the
first-class assistant tool now lives off whatever runtime is active at
execute time. That works in the gateway process where runtime metadata
and the active secrets snapshot are populated, but in agent contexts that
do not share that in-process state, both fall through to undefined and
the tool returned "web_search is disabled or no provider is available"
even though `openclaw capability web search` and direct provider runtime
execution succeeded.

Two fixes:

- src/agents/tools/web-search.ts: when late-binding, fall back to
  options.runtimeWebSearch when the active runtime web tools metadata is
  null, and fall back to options.config when getActiveSecretsRuntimeSnapshot
  is null. Derive a configured provider id from
  config.tools.web.search.provider and use it together with the runtime
  selection when deciding preferRuntimeProviders, so an explicit Brave/
  Perplexity selection still discovers the configured plugin even when
  no runtime provider id is bound.
- src/plugins/web-provider-runtime-shared.ts: the active gateway plugin
  registry may be otherwise compatible with the active config while
  contributing zero web providers (channels, memory, harnesses, and
  sidecars without Brave/web). Treating that empty active registry as
  authoritative meant first-class tools resolved to "no provider".
  Fall through to the scoped provider plugin load when the active
  registry returns no providers. Explicit `onlyPluginIds: []` still
  short-circuits to [] to preserve the empty-scope contract.

Adds regression tests for both seams.
This commit is contained in:
Joey Krug
2026-05-03 23:18:31 -04:00
committed by Peter Steinberger
parent 70850d15ee
commit c76d8f5a7c
4 changed files with 309 additions and 7 deletions

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
runWebSearch: vi.fn(),
resolveManifestContractOwnerPluginId: vi.fn(),
getActiveRuntimeWebToolsMetadata: vi.fn(),
getActiveSecretsRuntimeSnapshot: vi.fn(),
}));
vi.mock("../../web-search/runtime.js", () => ({
resolveWebSearchProviderId: vi.fn(() => "mock"),
runWebSearch: mocks.runWebSearch,
}));
vi.mock("../../plugins/plugin-registry.js", () => ({
resolveManifestContractOwnerPluginId: mocks.resolveManifestContractOwnerPluginId,
}));
vi.mock("../../secrets/runtime-web-tools-state.js", () => ({
getActiveRuntimeWebToolsMetadata: mocks.getActiveRuntimeWebToolsMetadata,
}));
vi.mock("../../secrets/runtime.js", () => ({
getActiveSecretsRuntimeSnapshot: mocks.getActiveSecretsRuntimeSnapshot,
}));
describe("web_search late-bound runtime fallback", () => {
beforeEach(() => {
mocks.runWebSearch.mockReset();
mocks.runWebSearch.mockResolvedValue({
provider: "brave",
result: { ok: true },
});
mocks.resolveManifestContractOwnerPluginId.mockReset();
mocks.resolveManifestContractOwnerPluginId.mockReturnValue(undefined);
mocks.getActiveRuntimeWebToolsMetadata.mockReset();
mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue(null);
mocks.getActiveSecretsRuntimeSnapshot.mockReset();
mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue(null);
});
it("falls back to options.runtimeWebSearch when active runtime web tools metadata is absent", async () => {
const { createWebSearchTool } = await import("./web-search.js");
const tool = createWebSearchTool({
config: {},
lateBindRuntimeConfig: true,
runtimeWebSearch: {
selectedProvider: "brave",
providerConfigured: "brave",
providerSource: "plugin",
diagnostics: [],
},
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({
runtimeWebSearch: expect.objectContaining({ selectedProvider: "brave" }),
}),
);
});
it("falls back to options.config when getActiveSecretsRuntimeSnapshot is null", async () => {
const { createWebSearchTool } = await import("./web-search.js");
const fallbackConfig = {
tools: { web: { search: { provider: "brave" } } },
};
const tool = createWebSearchTool({
config: fallbackConfig,
lateBindRuntimeConfig: true,
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({
config: fallbackConfig,
}),
);
});
it("uses configured provider id from config when no runtime selection is present", async () => {
const { createWebSearchTool } = await import("./web-search.js");
const config = {
tools: { web: { search: { provider: "Brave" } } },
};
const tool = createWebSearchTool({
config,
lateBindRuntimeConfig: true,
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith(
expect.objectContaining({ value: "brave" }),
);
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({ preferRuntimeProviders: true }),
);
});
it("does not prefer runtime providers when no provider id is selected anywhere", async () => {
const { createWebSearchTool } = await import("./web-search.js");
const tool = createWebSearchTool({
config: {},
lateBindRuntimeConfig: true,
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.resolveManifestContractOwnerPluginId).not.toHaveBeenCalled();
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({ preferRuntimeProviders: false }),
);
});
it("does not prefer runtime providers when the configured provider is a bundled manifest owner", async () => {
mocks.resolveManifestContractOwnerPluginId.mockReturnValue("openclaw-bundled-brave");
const { createWebSearchTool } = await import("./web-search.js");
const config = {
tools: { web: { search: { provider: "brave" } } },
};
const tool = createWebSearchTool({
config,
lateBindRuntimeConfig: true,
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({ preferRuntimeProviders: false }),
);
});
it("prefers active runtime metadata over options.runtimeWebSearch when present", async () => {
mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue({
search: {
selectedProvider: "perplexity",
providerConfigured: "perplexity",
providerSource: "plugin",
diagnostics: [],
},
});
const { createWebSearchTool } = await import("./web-search.js");
const tool = createWebSearchTool({
config: {},
lateBindRuntimeConfig: true,
runtimeWebSearch: {
selectedProvider: "brave",
providerConfigured: "brave",
providerSource: "plugin",
diagnostics: [],
},
});
await tool?.execute("call-search", { query: "openclaw" }, undefined);
expect(mocks.runWebSearch).toHaveBeenCalledWith(
expect.objectContaining({
runtimeWebSearch: expect.objectContaining({ selectedProvider: "perplexity" }),
}),
);
});
});

View File

@@ -89,7 +89,7 @@ export function createWebSearchTool(options?: {
execute: async (_toolCallId, args, signal) => {
const runtimeWebSearch =
options?.lateBindRuntimeConfig === true
? getActiveRuntimeWebToolsMetadata()?.search
? (getActiveRuntimeWebToolsMetadata()?.search ?? options?.runtimeWebSearch)
: options?.runtimeWebSearch;
const runtimeProviderId =
runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured;
@@ -97,11 +97,20 @@ export function createWebSearchTool(options?: {
options?.lateBindRuntimeConfig === true
? (getActiveSecretsRuntimeSnapshot()?.config ?? options?.config)
: options?.config;
// The active gateway plugin registry may omit the configured search
// provider; fall back to the provider id captured in config so the
// first-class assistant tool still resolves the right plugin instead of
// reporting "no provider available".
const configuredProviderId =
typeof config?.tools?.web?.search?.provider === "string"
? config.tools.web.search.provider.trim().toLowerCase()
: "";
const providerSelectionId = runtimeProviderId || configuredProviderId;
const preferRuntimeProviders =
!runtimeProviderId ||
Boolean(providerSelectionId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
value: providerSelectionId,
origin: "bundled",
config,
});

View File

@@ -332,6 +332,120 @@ describe("web-provider-runtime-shared", () => {
);
});
it("falls back to a scoped provider load when the active runtime registry has no web providers", () => {
const activeRegistry = { source: "active" };
const fallbackRegistry = { source: "fallback" };
const mapRegistryProviders = vi.fn(({ registry }) =>
registry === fallbackRegistry ? ["brave"] : [],
);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never);
const result = resolvePluginWebProviders(
{
config: {},
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => undefined,
mapRegistryProviders,
},
);
expect(result).toEqual(["brave"]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
expect(mapRegistryProviders).toHaveBeenCalledTimes(2);
});
it("does not fall back when the active runtime registry returns empty under an explicit empty scope", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
const result = resolvePluginWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(result).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("falls back when the direct runtime registry has no web providers", () => {
const activeRegistry = { source: "active" };
const fallbackRegistry = { source: "fallback" };
const mapRegistryProviders = vi.fn(({ registry }) =>
registry === fallbackRegistry ? ["brave"] : [],
);
mocks.getLoadedRuntimePluginRegistry.mockImplementation((args: unknown) => {
const requiredPluginIds = (args as { requiredPluginIds?: readonly string[] })
?.requiredPluginIds;
if (requiredPluginIds === undefined) {
return activeRegistry as never;
}
return undefined;
});
mocks.loadOpenClawPlugins.mockReturnValue(fallbackRegistry as never);
const result = resolveRuntimeWebProviders(
{
config: {},
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => undefined,
mapRegistryProviders,
},
);
expect(result).toEqual(["brave"]);
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1);
});
it("does not fall back when direct runtime registry returns empty under an explicit empty scope", () => {
const activeRegistry = { source: "active" };
const mapRegistryProviders = vi.fn(() => []);
mocks.getLoadedRuntimePluginRegistry.mockReturnValue(activeRegistry as never);
const result = resolveRuntimeWebProviders(
{
config: {},
onlyPluginIds: [],
},
{
resolveBundledResolutionConfig: () => ({
config: {},
activationSourceConfig: {},
autoEnabledReasons: {},
}),
resolveCandidatePluginIds: () => [],
mapRegistryProviders,
},
);
expect(result).toEqual([]);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
it("keeps explicit setup web provider cache opt-outs", () => {
const loadedRegistry = { source: "setup" };
const mapRegistryProviders = vi.fn(() => ["provider"]);

View File

@@ -186,17 +186,26 @@ export function resolvePluginWebProviders<TEntry>(
workspaceDir: context.workspaceDir,
requiredPluginIds: context.loadPluginIds,
});
const scopedPluginIds = context.onlyPluginIds;
const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0;
if (compatible) {
return deps.mapRegistryProviders({
const resolved = deps.mapRegistryProviders({
registry: compatible,
onlyPluginIds: context.onlyPluginIds,
});
if (resolved.length > 0 || hasExplicitEmptyScope) {
return resolved;
}
// The active gateway plugin registry may be otherwise compatible with this
// config while contributing zero web providers (for example when channels,
// memory, harnesses, and sidecars are loaded but Brave/web providers are
// not). Do not treat that empty active registry as authoritative: fall
// through to a scoped provider load below so first-class assistant tools
// still see the configured provider.
}
if (isPluginRegistryLoadInFlight(loadOptions)) {
return [];
}
const scopedPluginIds = context.onlyPluginIds;
const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0;
if (hasExplicitEmptyScope) {
return [];
}
@@ -217,10 +226,15 @@ export function resolveRuntimeWebProviders<TEntry>(
requiredPluginIds: params.onlyPluginIds,
});
if (runtimeRegistry) {
return deps.mapRegistryProviders({
const resolved = deps.mapRegistryProviders({
registry: runtimeRegistry,
onlyPluginIds: params.onlyPluginIds,
});
const hasExplicitEmptyScope =
params.onlyPluginIds !== undefined && params.onlyPluginIds.length === 0;
if (resolved.length > 0 || hasExplicitEmptyScope) {
return resolved;
}
}
return resolvePluginWebProviders(params, deps);
}