refactor(web-tools): share runtime provider context

This commit is contained in:
Peter Steinberger
2026-05-04 07:25:46 +01:00
parent 605e89468e
commit 128cc2c84b
5 changed files with 327 additions and 50 deletions

View File

@@ -34,6 +34,7 @@ import {
resolveTimeoutSeconds,
writeCache,
} from "./web-shared.js";
import { resolveWebFetchToolRuntimeContext } from "./web-tool-runtime-context.js";
const EXTRACT_MODES = ["markdown", "text"] as const;
@@ -631,11 +632,17 @@ export function createWebFetchTool(options?: {
const resolveProviderFallback = async () => {
if (!providerFallbackResolved) {
const { resolveWebFetchDefinition } = await loadWebFetchRuntime();
const { config, preferRuntimeProviders, runtimeWebFetch } = resolveWebFetchToolRuntimeContext(
{
config: options?.config,
runtimeWebFetch: options?.runtimeWebFetch,
},
);
providerFallbackCache = resolveWebFetchDefinition({
config: options?.config,
config,
sandboxed: options?.sandboxed,
runtimeWebFetch: options?.runtimeWebFetch,
preferRuntimeProviders: true,
runtimeWebFetch,
preferRuntimeProviders,
});
providerFallbackResolved = true;
}

View File

@@ -1,12 +1,10 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
import { asToolParamsRecord, jsonResult } from "./common.js";
import { MAX_SEARCH_COUNT, SEARCH_CACHE } from "./web-search-provider-common.js";
import { resolveWebSearchToolRuntimeContext } from "./web-tool-runtime-context.js";
const WebSearchSchema = {
type: "object",
@@ -87,32 +85,11 @@ export function createWebSearchTool(options?: {
"Search the web. Returns provider-normalized results for current information lookup.",
parameters: WebSearchSchema,
execute: async (_toolCallId, args, signal) => {
const runtimeWebSearch =
options?.lateBindRuntimeConfig === true
? (getActiveRuntimeWebToolsMetadata()?.search ?? options?.runtimeWebSearch)
: options?.runtimeWebSearch;
const runtimeProviderId =
runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured;
const config =
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 =
!providerSelectionId ||
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: providerSelectionId,
origin: "bundled",
config,
const { config, preferRuntimeProviders, runtimeWebSearch } =
resolveWebSearchToolRuntimeContext({
config: options?.config,
lateBindRuntimeConfig: options?.lateBindRuntimeConfig,
runtimeWebSearch: options?.runtimeWebSearch,
});
const result = await runWebSearch({
config,

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getActiveRuntimeWebToolsMetadata: vi.fn(),
getActiveSecretsRuntimeSnapshot: vi.fn(),
resolveManifestContractOwnerPluginId: vi.fn(),
}));
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 tool runtime context", () => {
beforeEach(() => {
mocks.getActiveRuntimeWebToolsMetadata.mockReset();
mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue(null);
mocks.getActiveSecretsRuntimeSnapshot.mockReset();
mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue(null);
mocks.resolveManifestContractOwnerPluginId.mockReset();
mocks.resolveManifestContractOwnerPluginId.mockReturnValue(undefined);
});
it("late-binds search config and metadata from active runtime before captured options", async () => {
const runtimeConfig = {
tools: { web: { search: { provider: "perplexity" } } },
};
mocks.getActiveSecretsRuntimeSnapshot.mockReturnValue({ config: runtimeConfig });
mocks.getActiveRuntimeWebToolsMetadata.mockReturnValue({
search: {
providerConfigured: "perplexity",
providerSource: "configured",
selectedProvider: "perplexity",
selectedProviderKeySource: "config",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
});
const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js");
const resolved = resolveWebSearchToolRuntimeContext({
config: { tools: { web: { search: { provider: "brave" } } } },
lateBindRuntimeConfig: true,
runtimeWebSearch: {
providerConfigured: "brave",
providerSource: "configured",
selectedProvider: "brave",
selectedProviderKeySource: "config",
diagnostics: [],
},
});
expect(resolved.config).toBe(runtimeConfig);
expect(resolved.runtimeWebSearch).toMatchObject({ selectedProvider: "perplexity" });
expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith(
expect.objectContaining({
contract: "webSearchProviders",
value: "perplexity",
}),
);
});
it("falls back to captured search config and runtime metadata when active globals are missing", async () => {
const capturedConfig = {
tools: { web: { search: { provider: "brave" } } },
};
const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js");
const resolved = resolveWebSearchToolRuntimeContext({
config: capturedConfig,
lateBindRuntimeConfig: true,
runtimeWebSearch: {
providerConfigured: "brave",
providerSource: "configured",
selectedProvider: "brave",
selectedProviderKeySource: "config",
diagnostics: [],
},
});
expect(resolved.config).toBe(capturedConfig);
expect(resolved.runtimeWebSearch).toMatchObject({ selectedProvider: "brave" });
expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith(
expect.objectContaining({
contract: "webSearchProviders",
value: "brave",
}),
);
});
it("uses configured provider ids when runtime metadata is absent", async () => {
const { resolveWebSearchToolRuntimeContext } = await import("./web-tool-runtime-context.js");
resolveWebSearchToolRuntimeContext({
config: { tools: { web: { search: { provider: "Brave" } } } },
});
expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith(
expect.objectContaining({
contract: "webSearchProviders",
value: "brave",
}),
);
});
it("keeps runtime providers disabled for bundled fetch owners", async () => {
mocks.resolveManifestContractOwnerPluginId.mockReturnValue("firecrawl");
const { resolveWebFetchToolRuntimeContext } = await import("./web-tool-runtime-context.js");
const resolved = resolveWebFetchToolRuntimeContext({
config: { tools: { web: { fetch: { provider: "firecrawl" } } } },
});
expect(resolved.preferRuntimeProviders).toBe(false);
expect(mocks.resolveManifestContractOwnerPluginId).toHaveBeenCalledWith(
expect.objectContaining({
contract: "webFetchProviders",
value: "firecrawl",
}),
);
});
it("keeps runtime provider discovery enabled when no provider is selected", async () => {
const { resolveWebFetchToolRuntimeContext } = await import("./web-tool-runtime-context.js");
const resolved = resolveWebFetchToolRuntimeContext({
config: {},
});
expect(resolved.preferRuntimeProviders).toBe(true);
expect(mocks.resolveManifestContractOwnerPluginId).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,123 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import type {
RuntimeWebFetchMetadata,
RuntimeWebSearchMetadata,
} from "../../secrets/runtime-web-tools.types.js";
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
type WebProviderKind = "fetch" | "search";
type WebProviderRuntimeMetadata = RuntimeWebFetchMetadata | RuntimeWebSearchMetadata;
type WebProviderContract = "webFetchProviders" | "webSearchProviders";
type ResolvedWebToolRuntimeContext<TMetadata extends WebProviderRuntimeMetadata> = {
config?: OpenClawConfig;
preferRuntimeProviders: boolean;
runtimeMetadata?: TMetadata;
};
function resolveConfiguredWebProviderId(
config: OpenClawConfig | undefined,
kind: WebProviderKind,
): string {
const provider = config?.tools?.web?.[kind]?.provider;
return typeof provider === "string" ? provider.trim().toLowerCase() : "";
}
function resolveRuntimeWebProviderId(metadata: WebProviderRuntimeMetadata | undefined): string {
return metadata?.selectedProvider ?? metadata?.providerConfigured ?? "";
}
function resolveWebProviderContract(kind: WebProviderKind): WebProviderContract {
return kind === "fetch" ? "webFetchProviders" : "webSearchProviders";
}
function shouldPreferRuntimeProviders(params: {
config?: OpenClawConfig;
kind: WebProviderKind;
providerSelectionId: string;
}): boolean {
if (!params.providerSelectionId) {
return true;
}
return !resolveManifestContractOwnerPluginId({
contract: resolveWebProviderContract(params.kind),
value: params.providerSelectionId,
origin: "bundled",
config: params.config,
});
}
function resolveWebToolRuntimeContext<TMetadata extends WebProviderRuntimeMetadata>(params: {
capturedConfig?: OpenClawConfig;
capturedRuntimeMetadata?: TMetadata;
kind: WebProviderKind;
lateBindRuntimeConfig?: boolean;
}): ResolvedWebToolRuntimeContext<TMetadata> {
const activeWebTools =
params.lateBindRuntimeConfig === true ? getActiveRuntimeWebToolsMetadata() : null;
const runtimeMetadata = (activeWebTools?.[params.kind] ?? params.capturedRuntimeMetadata) as
| TMetadata
| undefined;
const config =
params.lateBindRuntimeConfig === true
? (getActiveSecretsRuntimeSnapshot()?.config ?? params.capturedConfig)
: params.capturedConfig;
const providerSelectionId =
resolveRuntimeWebProviderId(runtimeMetadata) ||
resolveConfiguredWebProviderId(config, params.kind);
return {
config,
preferRuntimeProviders: shouldPreferRuntimeProviders({
config,
kind: params.kind,
providerSelectionId,
}),
runtimeMetadata,
};
}
export function resolveWebSearchToolRuntimeContext(params: {
config?: OpenClawConfig;
lateBindRuntimeConfig?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
}): ResolvedWebToolRuntimeContext<RuntimeWebSearchMetadata> & {
runtimeWebSearch?: RuntimeWebSearchMetadata;
} {
const resolved = resolveWebToolRuntimeContext({
capturedConfig: params.config,
capturedRuntimeMetadata: params.runtimeWebSearch,
kind: "search",
lateBindRuntimeConfig: params.lateBindRuntimeConfig,
});
return {
config: resolved.config,
preferRuntimeProviders: resolved.preferRuntimeProviders,
runtimeMetadata: resolved.runtimeMetadata,
runtimeWebSearch: resolved.runtimeMetadata,
};
}
export function resolveWebFetchToolRuntimeContext(params: {
config?: OpenClawConfig;
lateBindRuntimeConfig?: boolean;
runtimeWebFetch?: RuntimeWebFetchMetadata;
}): ResolvedWebToolRuntimeContext<RuntimeWebFetchMetadata> & {
runtimeWebFetch?: RuntimeWebFetchMetadata;
} {
const resolved = resolveWebToolRuntimeContext({
capturedConfig: params.config,
capturedRuntimeMetadata: params.runtimeWebFetch,
kind: "fetch",
lateBindRuntimeConfig: params.lateBindRuntimeConfig,
});
return {
config: resolved.config,
preferRuntimeProviders: resolved.preferRuntimeProviders,
runtimeMetadata: resolved.runtimeMetadata,
runtimeWebFetch: resolved.runtimeMetadata,
};
}

View File

@@ -64,6 +64,11 @@ type WebProviderRuntimeContext = {
onlyPluginIds?: string[];
};
type RuntimeRegistryWebProviderResolution<TEntry> = {
providers: TEntry[];
shouldReturn: boolean;
};
function resolveWebProviderRuntimeContext<TEntry>(
params: ResolvePluginWebProvidersParams,
deps: ResolveWebProviderRuntimeDeps<TEntry>,
@@ -125,6 +130,25 @@ function resolveWebProviderLoadOptions(
);
}
function resolveRuntimeRegistryWebProviders<TEntry>(params: {
hasExplicitEmptyScope: boolean;
mapRegistryProviders: ResolveWebProviderRuntimeDeps<TEntry>["mapRegistryProviders"];
onlyPluginIds?: readonly string[];
registry: PluginRegistry | undefined;
}): RuntimeRegistryWebProviderResolution<TEntry> | undefined {
if (!params.registry) {
return undefined;
}
const providers = params.mapRegistryProviders({
registry: params.registry,
onlyPluginIds: params.onlyPluginIds,
});
return {
providers,
shouldReturn: providers.length > 0 || params.hasExplicitEmptyScope,
};
}
export function resolvePluginWebProviders<TEntry>(
params: ResolvePluginWebProvidersParams,
deps: ResolveWebProviderRuntimeDeps<TEntry>,
@@ -188,14 +212,16 @@ export function resolvePluginWebProviders<TEntry>(
});
const scopedPluginIds = context.onlyPluginIds;
const hasExplicitEmptyScope = scopedPluginIds !== undefined && scopedPluginIds.length === 0;
if (compatible) {
const resolved = deps.mapRegistryProviders({
registry: compatible,
onlyPluginIds: context.onlyPluginIds,
});
if (resolved.length > 0 || hasExplicitEmptyScope) {
return resolved;
}
const compatibleProviders = resolveRuntimeRegistryWebProviders({
hasExplicitEmptyScope,
mapRegistryProviders: deps.mapRegistryProviders,
onlyPluginIds: context.onlyPluginIds,
registry: compatible,
});
if (compatibleProviders?.shouldReturn) {
return compatibleProviders.providers;
}
if (compatibleProviders) {
// 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
@@ -225,16 +251,16 @@ export function resolveRuntimeWebProviders<TEntry>(
workspaceDir: params.workspaceDir,
requiredPluginIds: params.onlyPluginIds,
});
if (runtimeRegistry) {
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;
}
const hasExplicitEmptyScope =
params.onlyPluginIds !== undefined && params.onlyPluginIds.length === 0;
const runtimeProviders = resolveRuntimeRegistryWebProviders({
hasExplicitEmptyScope,
mapRegistryProviders: deps.mapRegistryProviders,
onlyPluginIds: params.onlyPluginIds,
registry: runtimeRegistry,
});
if (runtimeProviders?.shouldReturn) {
return runtimeProviders.providers;
}
return resolvePluginWebProviders(params, deps);
}