mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:10:42 +00:00
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:
committed by
Peter Steinberger
parent
70850d15ee
commit
c76d8f5a7c
165
src/agents/tools/web-search.late-bind.test.ts
Normal file
165
src/agents/tools/web-search.late-bind.test.ts
Normal 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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user