fix(gateway): quarantine unsupported effective tool schemas

This commit is contained in:
Vincent Koc
2026-05-27 22:01:48 +02:00
parent a7d2d9c6df
commit 21d9609866
10 changed files with 848 additions and 10 deletions
+470 -1
View File
@@ -9,10 +9,13 @@ function mockTool(params: {
label: string;
description: string;
displaySummary?: string;
parameters?: unknown;
}): AnyAgentTool {
return {
...params,
parameters: { type: "object", properties: {} },
parameters: Object.hasOwn(params, "parameters")
? params.parameters
: { type: "object", properties: {} },
execute: async () => ({ text: params.description }),
} as unknown as AnyAgentTool;
}
@@ -25,6 +28,10 @@ const effectiveInventoryState = vi.hoisted(() => ({
pluginMeta: {} as Record<string, { pluginId: string } | undefined>,
channelMeta: {} as Record<string, { channelId: string } | undefined>,
effectivePolicy: {} as { profile?: string; providerProfile?: string },
normalizeToolsMock: vi.fn((options: { tools: AnyAgentTool[] }) => options.tools),
staticCatalogModelMock: vi.fn((_options: unknown) => undefined as unknown),
dynamicModelMock: vi.fn((_options: unknown) => undefined as unknown),
normalizeTransportMock: vi.fn((_options: unknown) => undefined as unknown),
createToolsMock: vi.fn<typeof createOpenClawCodingTools>(
(_options) =>
[
@@ -64,6 +71,40 @@ vi.mock("./agent-tools.policy.js", () => ({
resolveEffectiveToolPolicy: () => effectiveInventoryState.effectivePolicy,
}));
vi.mock("./runtime-plan/tools.js", () => ({
normalizeAgentRuntimeTools: (options: { tools: AnyAgentTool[] }) =>
effectiveInventoryState.normalizeToolsMock(options),
}));
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
resolveBundledStaticCatalogModel: (options: unknown) =>
effectiveInventoryState.staticCatalogModelMock(options),
}));
vi.mock("./embedded-agent-runner/model.js", () => ({
resolveModel: (
provider: unknown,
modelId: unknown,
agentDir: unknown,
cfg: unknown,
options: unknown,
) =>
({
model: effectiveInventoryState.dynamicModelMock({
provider,
modelId,
agentDir,
cfg,
options,
}),
}) as unknown,
}));
vi.mock("../plugins/provider-runtime.js", () => ({
normalizeProviderTransportWithPlugin: (options: unknown) =>
effectiveInventoryState.normalizeTransportMock(options),
}));
let resolveEffectiveToolInventory: typeof import("./tools-effective-inventory.js").resolveEffectiveToolInventory;
async function loadHarness(options?: {
@@ -72,6 +113,7 @@ async function loadHarness(options?: {
pluginMeta?: Record<string, { pluginId: string } | undefined>;
channelMeta?: Record<string, { channelId: string } | undefined>;
effectivePolicy?: { profile?: string; providerProfile?: string };
normalizeToolsMock?: typeof effectiveInventoryState.normalizeToolsMock;
}) {
effectiveInventoryState.tools = options?.tools ?? [
mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }),
@@ -80,6 +122,11 @@ async function loadHarness(options?: {
effectiveInventoryState.pluginMeta = options?.pluginMeta ?? {};
effectiveInventoryState.channelMeta = options?.channelMeta ?? {};
effectiveInventoryState.effectivePolicy = options?.effectivePolicy ?? {};
effectiveInventoryState.normalizeToolsMock =
options?.normalizeToolsMock ?? vi.fn((normalizeOptions) => normalizeOptions.tools);
effectiveInventoryState.staticCatalogModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.dynamicModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.normalizeTransportMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.createToolsMock =
options?.createToolsMock ??
vi.fn<typeof createOpenClawCodingTools>((_options) => effectiveInventoryState.tools);
@@ -102,6 +149,10 @@ describe("resolveEffectiveToolInventory", () => {
effectiveInventoryState.pluginMeta = {};
effectiveInventoryState.channelMeta = {};
effectiveInventoryState.effectivePolicy = {};
effectiveInventoryState.normalizeToolsMock = vi.fn((options) => options.tools);
effectiveInventoryState.staticCatalogModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.dynamicModelMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.normalizeTransportMock = vi.fn((_options: unknown) => undefined);
effectiveInventoryState.createToolsMock = vi.fn<typeof createOpenClawCodingTools>(
(_options) => effectiveInventoryState.tools,
);
@@ -231,6 +282,423 @@ describe("resolveEffectiveToolInventory", () => {
});
});
it("quarantines tools with schemas that cannot be projected to the model runtime", async () => {
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }),
mockTool({
name: "dofbot_move_angles",
label: "Dofbot Move Angles",
description: "Move robot joints",
parameters: { type: "array", items: { type: "number" } },
}),
],
pluginMeta: { dofbot_move_angles: { pluginId: "dofbot" } },
});
const result = resolveEffectiveToolInventory({ cfg: {} });
expect(result.groups.flatMap((group) => group.tools.map((tool) => tool.id))).toEqual(["exec"]);
expect(result.notices).toEqual([
{
id: "unsupported-tool-schema:dofbot_move_angles",
severity: "warning",
message:
'Tool "dofbot_move_angles" from plugin "dofbot" has an unsupported runtime input schema (dofbot_move_angles.parameters.type must be "object") and was quarantined before model projection. Fix or disable the owner, or remove the tool from active allowlists.',
},
]);
});
it("validates normalized runtime schemas before quarantining effective tools", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[] }) =>
options.tools.map((entry) =>
entry.name === "parameter_free"
? ({
...entry,
parameters: {
type: "object",
properties: {},
required: [],
additionalProperties: false,
},
} as AnyAgentTool)
: entry,
),
);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "parameter_free",
label: "Parameter Free",
description: "Runtime-normalized tool",
parameters: undefined,
}),
],
pluginMeta: { parameter_free: { pluginId: "normalized-plugin" } },
normalizeToolsMock,
});
const result = resolveEffectiveToolInventory({
cfg: {},
modelProvider: "openai",
modelId: "gpt-test",
modelApi: "openai-responses",
runtimeModel: {
id: "gpt-test",
name: "GPT Test",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
} as never,
});
expect(result.groups[0]?.tools[0]).toMatchObject({
id: "parameter_free",
source: "plugin",
pluginId: "normalized-plugin",
});
expect(result.notices).toBeUndefined();
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
modelId: "gpt-test",
modelApi: "openai-responses",
model: expect.objectContaining({
id: "gpt-test",
api: "openai-responses",
provider: "openai",
}),
}),
);
});
it("overlays provider transport config on bundled static model context", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[] }) => options.tools);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "exec",
label: "Exec",
description: "Run shell commands",
}),
],
normalizeToolsMock,
});
effectiveInventoryState.staticCatalogModelMock.mockReturnValue({
id: "gpt-test",
name: "GPT Test",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
resolveEffectiveToolInventory({
cfg: {
models: {
providers: {
openai: {
api: "openai-completions",
baseUrl: "https://proxy.example.com/v1",
},
},
},
} as never,
modelProvider: "openai",
modelId: "gpt-test",
});
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-completions",
model: expect.objectContaining({
api: "openai-completions",
baseUrl: "https://proxy.example.com/v1",
}),
}),
);
expect(effectiveInventoryState.normalizeTransportMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/workspace-main",
context: expect.objectContaining({
config: expect.any(Object),
workspaceDir: "/tmp/workspace-main",
provider: "openai",
api: "openai-completions",
baseUrl: "https://proxy.example.com/v1",
}),
}),
);
expect(effectiveInventoryState.createToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-completions",
}),
);
});
it("applies provider transport normalization to bundled static model context", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[] }) => options.tools);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "exec",
label: "Exec",
description: "Run shell commands",
}),
],
normalizeToolsMock,
});
effectiveInventoryState.staticCatalogModelMock.mockReturnValue({
id: "gpt-test",
name: "GPT Test",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
effectiveInventoryState.normalizeTransportMock.mockReturnValue({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
resolveEffectiveToolInventory({
cfg: {
models: {
providers: {
openai: {
api: "openai-completions",
baseUrl: "https://proxy.example.com/v1",
},
},
},
} as never,
modelProvider: "openai",
modelId: "gpt-test",
});
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-responses",
model: expect.objectContaining({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
}),
}),
);
expect(effectiveInventoryState.normalizeTransportMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/workspace-main",
context: expect.objectContaining({
config: expect.any(Object),
workspaceDir: "/tmp/workspace-main",
provider: "openai",
api: "openai-completions",
baseUrl: "https://proxy.example.com/v1",
}),
}),
);
expect(effectiveInventoryState.createToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-responses",
}),
);
});
it("normalizes configured model context when the model omits api", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[] }) => options.tools);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "exec",
label: "Exec",
description: "Run shell commands",
}),
],
normalizeToolsMock,
});
effectiveInventoryState.normalizeTransportMock.mockReturnValue({
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
});
resolveEffectiveToolInventory({
cfg: {
models: {
providers: {
"openai-codex": {
models: [
{
id: "gpt-5.5-codex",
name: "GPT-5.5 Codex",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 8_192,
},
],
},
},
},
} as never,
modelProvider: "openai-codex",
modelId: "gpt-5.5-codex",
});
expect(effectiveInventoryState.normalizeTransportMock).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/workspace-main",
context: expect.objectContaining({
config: expect.any(Object),
workspaceDir: "/tmp/workspace-main",
provider: "openai-codex",
api: "openai-responses",
baseUrl: undefined,
}),
}),
);
expect(effectiveInventoryState.createToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-codex-responses",
}),
);
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "openai-codex-responses",
model: expect.objectContaining({
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
}),
}),
);
});
it("preserves bundled static transport when configured model row omits api", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[] }) => options.tools);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "exec",
label: "Exec",
description: "Run shell commands",
}),
],
normalizeToolsMock,
});
effectiveInventoryState.staticCatalogModelMock.mockReturnValue({
id: "claude-sonnet-test",
name: "Bundled Claude Sonnet",
provider: "github-copilot",
api: "anthropic-messages",
baseUrl: "https://api.githubcopilot.com",
});
resolveEffectiveToolInventory({
cfg: {
models: {
providers: {
"github-copilot": {
models: [
{
id: "claude-sonnet-test",
name: "Configured Claude Sonnet",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
},
],
},
},
},
} as never,
modelProvider: "github-copilot",
modelId: "claude-sonnet-test",
});
expect(effectiveInventoryState.createToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "anthropic-messages",
}),
);
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
modelApi: "anthropic-messages",
model: expect.objectContaining({
name: "Configured Claude Sonnet",
api: "anthropic-messages",
baseUrl: "https://api.githubcopilot.com",
}),
}),
);
});
it("uses dynamic provider model context before quarantining runtime-normalized tools", async () => {
const normalizeToolsMock = vi.fn((options: { tools: AnyAgentTool[]; modelApi?: string }) =>
options.tools.map((entry) =>
entry.name === "parameter_free" && options.modelApi === "openai-responses"
? ({
...entry,
parameters: {
type: "object",
properties: {},
required: [],
additionalProperties: false,
},
} as AnyAgentTool)
: entry,
),
);
const { resolveEffectiveToolInventory } = await loadHarness({
tools: [
mockTool({
name: "parameter_free",
label: "Parameter Free",
description: "Runtime-normalized tool",
parameters: undefined,
}),
],
pluginMeta: { parameter_free: { pluginId: "normalized-plugin" } },
normalizeToolsMock,
});
effectiveInventoryState.dynamicModelMock.mockReturnValue({
id: "chat-latest",
name: "chat-latest",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
});
const result = resolveEffectiveToolInventory({
cfg: {},
modelProvider: "openai",
modelId: "chat-latest",
});
expect(result.groups[0]?.tools[0]).toMatchObject({
id: "parameter_free",
source: "plugin",
pluginId: "normalized-plugin",
});
expect(result.notices).toBeUndefined();
expect(effectiveInventoryState.dynamicModelMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
modelId: "chat-latest",
agentDir: "/tmp/agents/main/agent",
options: expect.objectContaining({ workspaceDir: "/tmp/workspace-main" }),
}),
);
expect(normalizeToolsMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
modelId: "chat-latest",
modelApi: "openai-responses",
model: expect.objectContaining({
id: "chat-latest",
api: "openai-responses",
provider: "openai",
}),
}),
);
});
it("does not let one plugin project metadata onto another plugin tool", async () => {
const registry = createEmptyPluginRegistry();
registry.toolMetadata = [
@@ -408,5 +876,6 @@ describe("resolveEffectiveToolInventory", () => {
supportsTools: true,
nativeWebSearchTool: true,
});
expect(createToolsOptions?.modelApi).toBe("openai-completions");
});
});
+235 -7
View File
@@ -1,5 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import { extractModelCompat } from "../plugins/provider-model-compat.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
import { normalizeProviderTransportWithPlugin } from "../plugins/provider-runtime.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { buildPluginToolMetadataKey, getPluginToolMeta } from "../plugins/tools.js";
import {
@@ -10,11 +12,18 @@ import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId } from
import { createOpenClawCodingTools } from "./agent-tools.js";
import { resolveEffectiveToolPolicy } from "./agent-tools.policy.js";
import { getChannelAgentToolMeta } from "./channel-tools.js";
import { resolveModel } from "./embedded-agent-runner/model.js";
import { resolveBundledStaticCatalogModel } from "./embedded-agent-runner/model.static-catalog.js";
import { normalizeStaticProviderModelId } from "./model-ref-shared.js";
import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js";
import { normalizeAgentRuntimeTools } from "./runtime-plan/tools.js";
import { summarizeToolDescriptionText } from "./tool-description-summary.js";
import { resolveToolDisplay } from "./tool-display.js";
import { normalizeToolName } from "./tool-policy.js";
import {
filterRuntimeCompatibleTools,
type RuntimeToolSchemaDiagnostic,
} from "./tool-schema-projection.js";
import type {
EffectiveToolInventoryNotice,
EffectiveToolInventoryEntry,
@@ -47,16 +56,22 @@ function summarizeToolDescription(tool: AnyAgentTool): string {
});
}
function resolveEffectiveToolSource(tool: AnyAgentTool): {
function resolveEffectiveToolSource(
tool: AnyAgentTool,
fallbackTool?: AnyAgentTool,
): {
source: EffectiveToolSource;
pluginId?: string;
channelId?: string;
} {
const pluginMeta = getPluginToolMeta(tool);
const pluginMeta =
getPluginToolMeta(tool) ?? (fallbackTool ? getPluginToolMeta(fallbackTool) : undefined);
if (pluginMeta) {
return { source: "plugin", pluginId: pluginMeta.pluginId };
}
const channelMeta = getChannelAgentToolMeta(tool as never);
const channelMeta =
getChannelAgentToolMeta(tool as never) ??
(fallbackTool ? getChannelAgentToolMeta(fallbackTool as never) : undefined);
if (channelMeta) {
return { source: "channel", channelId: channelMeta.channelId };
}
@@ -150,6 +165,41 @@ function buildToolInventoryNotices(params: {
return undefined;
}
function buildUnsupportedToolSchemaNotice(params: {
diagnostic: RuntimeToolSchemaDiagnostic;
tool: AnyAgentTool | undefined;
fallbackTool: AnyAgentTool | undefined;
}): EffectiveToolInventoryNotice {
const source = params.tool
? resolveEffectiveToolSource(params.tool, params.fallbackTool)
: { source: "core" as const };
const owner =
source.source === "plugin" && source.pluginId
? ` from plugin "${source.pluginId}"`
: source.source === "channel" && source.channelId
? ` from channel "${source.channelId}"`
: "";
return {
id: `unsupported-tool-schema:${params.diagnostic.toolName}`,
severity: "warning",
message: `Tool "${params.diagnostic.toolName}"${owner} has an unsupported runtime input schema (${params.diagnostic.violations.join(", ")}) and was quarantined before model projection. Fix or disable the owner, or remove the tool from active allowlists.`,
};
}
function buildUnsupportedToolSchemaNotices(params: {
diagnostics: readonly RuntimeToolSchemaDiagnostic[];
tools: readonly AnyAgentTool[];
rawToolsByName: ReadonlyMap<string, AnyAgentTool>;
}): EffectiveToolInventoryNotice[] {
return params.diagnostics.map((diagnostic) =>
buildUnsupportedToolSchemaNotice({
diagnostic,
tool: params.tools[diagnostic.toolIndex],
fallbackTool: params.rawToolsByName.get(diagnostic.toolName),
}),
);
}
function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveToolInventoryEntry[] {
const counts = new Map<string, number>();
for (const entry of entries) {
@@ -164,6 +214,151 @@ function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveTo
});
}
function applyProviderTransportNormalization(params: {
cfg: OpenClawConfig;
provider: string;
workspaceDir?: string;
runtimeModel: ProviderRuntimeModel;
}): ProviderRuntimeModel {
const normalized = normalizeProviderTransportWithPlugin({
provider: params.provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
context: {
config: params.cfg,
workspaceDir: params.workspaceDir,
provider: params.provider,
api: params.runtimeModel.api,
baseUrl: params.runtimeModel.baseUrl,
},
});
if (!normalized) {
return params.runtimeModel;
}
return {
...params.runtimeModel,
api: normalized.api ?? params.runtimeModel.api,
baseUrl: normalized.baseUrl ?? params.runtimeModel.baseUrl,
} as ProviderRuntimeModel;
}
function resolveConfiguredFallbackApi(
providerConfig: { api?: string; baseUrl?: string } | undefined,
): string {
const explicitApi = normalizeOptionalString(providerConfig?.api);
if (explicitApi) {
return explicitApi;
}
return normalizeOptionalString(providerConfig?.baseUrl)
? "openai-completions"
: "openai-responses";
}
function resolveDynamicRuntimeModelContext(params: {
cfg: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
provider: string;
modelId: string;
}): { modelApi?: string; runtimeModel?: ProviderRuntimeModel } {
const runtimeModel = resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, {
workspaceDir: params.workspaceDir,
}).model as ProviderRuntimeModel | undefined;
if (!runtimeModel) {
return {};
}
return {
modelApi: runtimeModel.api,
runtimeModel,
};
}
export function resolveEffectiveToolInventoryRuntimeModelContext(params: {
cfg: OpenClawConfig;
agentId?: string;
agentDir?: string;
workspaceDir?: string;
modelProvider?: string;
modelId?: string;
}): { modelApi?: string; runtimeModel?: ProviderRuntimeModel } {
const provider = normalizeProviderId(params.modelProvider ?? "");
const modelId = params.modelId?.trim() ?? "";
if (!provider || !modelId) {
return {};
}
const agentId = params.agentId?.trim() || resolveSessionAgentId({ config: params.cfg });
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
const providerConfig = findNormalizedProviderValue(params.cfg.models?.providers, provider);
const configuredModels = Array.isArray(providerConfig?.models) ? providerConfig.models : [];
const normalizedModelId = normalizeStaticProviderModelId(provider, modelId);
const normalizedModelKey = normalizeLowercaseStringOrEmpty(normalizedModelId);
const providerPrefixedModelKey = normalizeLowercaseStringOrEmpty(
`${provider}/${normalizedModelId}`,
);
const configuredModel = configuredModels.find((model) => {
const id = normalizeStaticProviderModelId(provider, model.id);
const key = normalizeLowercaseStringOrEmpty(id);
return key === normalizedModelKey || key === providerPrefixedModelKey;
});
const bundledStaticModel = resolveBundledStaticCatalogModel({
provider,
modelId,
cfg: params.cfg,
workspaceDir,
}) as ProviderRuntimeModel | undefined;
if (configuredModel) {
const configuredApi =
normalizeOptionalString(configuredModel.api) ??
normalizeOptionalString(providerConfig?.api) ??
normalizeOptionalString(bundledStaticModel?.api) ??
resolveConfiguredFallbackApi(providerConfig);
const runtimeModel = applyProviderTransportNormalization({
cfg: params.cfg,
provider,
workspaceDir,
runtimeModel: {
...bundledStaticModel,
...configuredModel,
id: configuredModel.id,
name: configuredModel.name ?? bundledStaticModel?.name ?? configuredModel.id,
provider,
api: configuredApi,
baseUrl:
normalizeOptionalString(configuredModel.baseUrl) ??
normalizeOptionalString(providerConfig?.baseUrl) ??
normalizeOptionalString(bundledStaticModel?.baseUrl),
} as ProviderRuntimeModel,
});
return {
modelApi: runtimeModel.api,
runtimeModel,
};
}
if (!bundledStaticModel) {
return resolveDynamicRuntimeModelContext({
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
provider,
modelId,
});
}
const runtimeModel = applyProviderTransportNormalization({
cfg: params.cfg,
provider,
workspaceDir,
runtimeModel: {
...bundledStaticModel,
api: normalizeOptionalString(providerConfig?.api) ?? bundledStaticModel.api,
baseUrl: normalizeOptionalString(providerConfig?.baseUrl) ?? bundledStaticModel.baseUrl,
} as ProviderRuntimeModel,
});
return {
modelApi: runtimeModel.api,
runtimeModel,
};
}
function resolveEffectiveModelCompat(params: {
cfg: OpenClawConfig;
modelProvider?: string;
@@ -200,6 +395,20 @@ export function resolveEffectiveToolInventory(
resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg });
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
const agentDir = params.agentDir ?? resolveAgentDir(params.cfg, agentId);
const runtimeModelContext =
params.modelApi || params.runtimeModel
? {
modelApi: params.modelApi ?? params.runtimeModel?.api,
runtimeModel: params.runtimeModel,
}
: resolveEffectiveToolInventoryRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir,
workspaceDir,
modelProvider: params.modelProvider,
modelId: params.modelId,
});
const modelCompat = resolveEffectiveModelCompat({
cfg: params.cfg,
modelProvider: params.modelProvider,
@@ -214,6 +423,7 @@ export function resolveEffectiveToolInventory(
config: params.cfg,
modelProvider: params.modelProvider,
modelId: params.modelId,
modelApi: runtimeModelContext.modelApi,
modelCompat,
messageProvider: params.messageProvider,
senderId: params.senderId,
@@ -233,6 +443,17 @@ export function resolveEffectiveToolInventory(
requireExplicitMessageTarget: params.requireExplicitMessageTarget,
disableMessageTool: params.disableMessageTool,
});
const rawToolsByName = new Map(effectiveTools.map((tool) => [tool.name, tool]));
const normalizedEffectiveTools = normalizeAgentRuntimeTools({
tools: effectiveTools,
provider: params.modelProvider ?? "",
config: params.cfg,
workspaceDir,
modelId: params.modelId,
modelApi: runtimeModelContext.modelApi,
model: runtimeModelContext.runtimeModel,
});
const toolSchemaProjection = filterRuntimeCompatibleTools(normalizedEffectiveTools);
const effectivePolicy = resolveEffectiveToolPolicy({
config: params.cfg,
agentId,
@@ -251,9 +472,9 @@ export function resolveEffectiveToolInventory(
);
const entries = disambiguateLabels(
effectiveTools
toolSchemaProjection.tools
.map((tool) => {
const source = resolveEffectiveToolSource(tool);
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(tool.name));
const metadata = source.pluginId
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name))
: undefined;
@@ -276,7 +497,14 @@ export function resolveEffectiveToolInventory(
})
.toSorted((a, b) => a.label.localeCompare(b.label)),
);
const notices = buildToolInventoryNotices({ cfg: params.cfg, profile, entries, effectivePolicy });
const notices = [
...buildUnsupportedToolSchemaNotices({
diagnostics: toolSchemaProjection.diagnostics,
tools: normalizedEffectiveTools,
rawToolsByName,
}),
...(buildToolInventoryNotices({ cfg: params.cfg, profile, entries, effectivePolicy }) ?? []),
];
const groupsBySource = new Map<EffectiveToolSource, EffectiveToolInventoryEntry[]>();
for (const entry of entries) {
const tools = groupsBySource.get(entry.source) ?? [];
@@ -299,5 +527,5 @@ export function resolveEffectiveToolInventory(
})
.filter((group): group is EffectiveToolInventoryGroup => group !== null);
return { agentId, profile, groups, ...(notices ? { notices } : {}) };
return { agentId, profile, groups, ...(notices.length > 0 ? { notices } : {}) };
}
@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
export type EffectiveToolSource = "core" | "plugin" | "channel";
@@ -48,6 +49,8 @@ export type ResolveEffectiveToolInventoryParams = {
accountId?: string | null;
modelProvider?: string;
modelId?: string;
modelApi?: string | null;
runtimeModel?: ProviderRuntimeModel;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
@@ -0,0 +1,60 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { ToolsEffectiveResultSchema } from "./agents-models-skills.js";
function toolsEffectiveResult() {
return {
agentId: "main",
profile: "full",
groups: [
{
id: "core",
label: "Built-in tools",
source: "core",
tools: [
{
id: "exec",
label: "Exec",
description: "Run shell commands",
rawDescription: "Run shell commands",
source: "core",
},
],
},
],
};
}
describe("ToolsEffectiveResultSchema", () => {
it("accepts runtime tool quarantine notices", () => {
const result = {
...toolsEffectiveResult(),
notices: [
{
id: "unsupported-tool-schema:dofbot_move_angles",
severity: "warning",
message:
'Tool "dofbot_move_angles" from plugin "dofbot" has an unsupported runtime input schema and was quarantined before model projection.',
},
],
};
expect(Value.Check(ToolsEffectiveResultSchema, result)).toBe(true);
});
it("keeps tool quarantine notices strict", () => {
const result = {
...toolsEffectiveResult(),
notices: [
{
id: "unsupported-tool-schema:dofbot_move_angles",
severity: "warning",
message: "Unsupported schema.",
extra: true,
},
],
};
expect(Value.Check(ToolsEffectiveResultSchema, result)).toBe(false);
});
});
@@ -593,11 +593,21 @@ export const ToolsEffectiveGroupSchema = Type.Object(
{ additionalProperties: false },
);
export const ToolsEffectiveNoticeSchema = Type.Object(
{
id: NonEmptyString,
severity: Type.Union([Type.Literal("info"), Type.Literal("warning")]),
message: Type.String(),
},
{ additionalProperties: false },
);
export const ToolsEffectiveResultSchema = Type.Object(
{
agentId: NonEmptyString,
profile: NonEmptyString,
groups: Type.Array(ToolsEffectiveGroupSchema),
notices: Type.Optional(Type.Array(ToolsEffectiveNoticeSchema)),
},
{ additionalProperties: false },
);
@@ -53,6 +53,7 @@ import {
ToolsCatalogResultSchema,
ToolsEffectiveEntrySchema,
ToolsEffectiveGroupSchema,
ToolsEffectiveNoticeSchema,
ToolsEffectiveParamsSchema,
ToolsEffectiveResultSchema,
ToolsInvokeErrorSchema,
@@ -453,6 +454,7 @@ export const ProtocolSchemas = {
ToolsEffectiveParams: ToolsEffectiveParamsSchema,
ToolsEffectiveEntry: ToolsEffectiveEntrySchema,
ToolsEffectiveGroup: ToolsEffectiveGroupSchema,
ToolsEffectiveNotice: ToolsEffectiveNoticeSchema,
ToolsEffectiveResult: ToolsEffectiveResultSchema,
ToolsInvokeParams: ToolsInvokeParamsSchema,
ToolsInvokeError: ToolsInvokeErrorSchema,
+1
View File
@@ -177,6 +177,7 @@ export type ToolsCatalogResult = SchemaType<"ToolsCatalogResult">;
export type ToolsEffectiveParams = SchemaType<"ToolsEffectiveParams">;
export type ToolsEffectiveEntry = SchemaType<"ToolsEffectiveEntry">;
export type ToolsEffectiveGroup = SchemaType<"ToolsEffectiveGroup">;
export type ToolsEffectiveNotice = SchemaType<"ToolsEffectiveNotice">;
export type ToolsEffectiveResult = SchemaType<"ToolsEffectiveResult">;
export type ToolsInvokeParams = SchemaType<"ToolsInvokeParams">;
export type ToolsInvokeResult = SchemaType<"ToolsInvokeResult">;
@@ -1,5 +1,8 @@
export { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js";
export { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js";
export { listAgentIds, resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
export {
resolveEffectiveToolInventory,
resolveEffectiveToolInventoryRuntimeModelContext,
} from "../../agents/tools-effective-inventory.js";
export { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js";
export { resolveRuntimeConfigCacheKey } from "../../config/config.js";
export {
@@ -32,6 +32,7 @@ const runtimeMocks = vi.hoisted(() => ({
getActivePluginChannelRegistryVersion: vi.fn(() => 1),
getActivePluginRegistryVersion: vi.fn(() => 1),
resolveRuntimeConfigCacheKey: vi.fn(() => "runtime:1:test"),
resolveAgentDir: vi.fn(() => "/tmp/agents/main/agent"),
resolveEffectiveToolInventory: vi.fn(() => ({
agentId: "main",
profile: "coding",
@@ -55,6 +56,16 @@ const runtimeMocks = vi.hoisted(() => ({
resolveReplyToMode: vi.fn(() => "first"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveSessionModelRef: vi.fn(() => ({ provider: "openai", model: "gpt-4.1" })),
resolveEffectiveToolInventoryRuntimeModelContext: vi.fn(() => ({
modelApi: "openai-responses",
runtimeModel: {
id: "gpt-4.1",
name: "GPT 4.1",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
},
})),
}));
vi.mock("./tools-effective.runtime.js", () => runtimeMocks);
@@ -184,6 +195,22 @@ describe("tools.effective handler", () => {
expect(inventoryParams?.messageProvider).toBe("telegram");
expect(inventoryParams?.modelProvider).toBe("openai");
expect(inventoryParams?.modelId).toBe("gpt-4.1");
expect(inventoryParams?.agentDir).toBe("/tmp/agents/main/agent");
expect(inventoryParams?.modelApi).toBe("openai-responses");
expect(inventoryParams?.runtimeModel).toMatchObject({
id: "gpt-4.1",
api: "openai-responses",
provider: "openai",
});
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(1);
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "main",
agentDir: "/tmp/agents/main/agent",
modelProvider: "openai",
modelId: "gpt-4.1",
}),
);
});
it("serves repeated requests from the fresh inventory cache", async () => {
@@ -193,6 +220,7 @@ describe("tools.effective handler", () => {
await second.invoke();
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1);
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(1);
expect(firstRespondCall(first.respond)?.[0]).toBe(true);
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
@@ -209,6 +237,27 @@ describe("tools.effective handler", () => {
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("does not resolve runtime model context for fresh inventory cache hits", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
await first.invoke();
runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext.mockReturnValueOnce({
modelApi: "openai-completions",
runtimeModel: {
id: "gpt-4.1",
name: "GPT 4.1",
provider: "openai",
api: "openai-completions",
},
} as never);
const second = createInvokeParams({ sessionKey: "main:abc" });
await second.invoke();
expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1);
expect(runtimeMocks.resolveEffectiveToolInventoryRuntimeModelContext).toHaveBeenCalledTimes(1);
expect(firstRespondCall(second.respond)?.[0]).toBe(true);
});
it("coalesces identical cache misses while inventory resolution is pending", async () => {
const first = createInvokeParams({ sessionKey: "main:abc" });
const second = createInvokeParams({ sessionKey: "main:abc" });
@@ -15,7 +15,9 @@ import {
getActivePluginRegistryVersion,
listAgentIds,
loadSessionEntry,
resolveAgentDir,
resolveEffectiveToolInventory,
resolveEffectiveToolInventoryRuntimeModelContext,
resolveReplyToMode,
resolveRuntimeConfigCacheKey,
resolveSessionAgentId,
@@ -132,13 +134,24 @@ function scheduleToolsEffectiveRefresh(
const task = new Promise<EffectiveToolInventoryResult>((resolve, reject) => {
setImmediate(() => {
try {
const agentDir = resolveAgentDir(context.cfg, context.agentId);
const runtimeModelContext = resolveEffectiveToolInventoryRuntimeModelContext({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
modelProvider: context.modelProvider,
modelId: context.modelId,
});
const value = resolveEffectiveToolInventory({
cfg: context.cfg,
agentId: context.agentId,
agentDir,
sessionKey: context.sessionKey,
messageProvider: context.messageProvider,
modelProvider: context.modelProvider,
modelId: context.modelId,
modelApi: runtimeModelContext.modelApi,
runtimeModel: runtimeModelContext.runtimeModel,
currentChannelId: context.currentChannelId,
currentThreadTs: context.currentThreadTs,
accountId: context.accountId,