From 21d96098664d86d92b2cbee5afbfe5f9612e5112 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 27 May 2026 22:01:48 +0200 Subject: [PATCH] fix(gateway): quarantine unsupported effective tool schemas --- src/agents/tools-effective-inventory.test.ts | 471 +++++++++++++++++- src/agents/tools-effective-inventory.ts | 242 ++++++++- src/agents/tools-effective-inventory.types.ts | 3 + .../schema/agents-models-skills.test.ts | 60 +++ .../protocol/schema/agents-models-skills.ts | 10 + .../protocol/schema/protocol-schemas.ts | 2 + src/gateway/protocol/schema/types.ts | 1 + .../server-methods/tools-effective.runtime.ts | 7 +- .../server-methods/tools-effective.test.ts | 49 ++ src/gateway/server-methods/tools-effective.ts | 13 + 10 files changed, 848 insertions(+), 10 deletions(-) create mode 100644 src/gateway/protocol/schema/agents-models-skills.test.ts diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index 556868f18cfd..92d5a2a6d5b3 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -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, channelMeta: {} as Record, 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( (_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; channelMeta?: Record; 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((_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( (_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"); }); }); diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index 78c8207a2d8a..c0290dd42678 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -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; +}): 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(); 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(); 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 } : {}) }; } diff --git a/src/agents/tools-effective-inventory.types.ts b/src/agents/tools-effective-inventory.types.ts index 29de028837c3..4334aa75cb1e 100644 --- a/src/agents/tools-effective-inventory.types.ts +++ b/src/agents/tools-effective-inventory.types.ts @@ -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; diff --git a/src/gateway/protocol/schema/agents-models-skills.test.ts b/src/gateway/protocol/schema/agents-models-skills.test.ts new file mode 100644 index 000000000000..006afbea6fa9 --- /dev/null +++ b/src/gateway/protocol/schema/agents-models-skills.test.ts @@ -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); + }); +}); diff --git a/src/gateway/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index df9b0783863a..88c47cc52433 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -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 }, ); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index ff19e70533b2..d0940ffbc9e2 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f85067f270eb..c5f4025f5e53 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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">; diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index 6f5b3a54a5ea..7fc6cafa8396 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -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 { diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 66df73455a59..1bcecbda7834 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -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" }); diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 17c609a65a56..76a55e68ec14 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -15,7 +15,9 @@ import { getActivePluginRegistryVersion, listAgentIds, loadSessionEntry, + resolveAgentDir, resolveEffectiveToolInventory, + resolveEffectiveToolInventoryRuntimeModelContext, resolveReplyToMode, resolveRuntimeConfigCacheKey, resolveSessionAgentId, @@ -132,13 +134,24 @@ function scheduleToolsEffectiveRefresh( const task = new Promise((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,