From dc05f598bb5a4c5a540a62dc7d63f1665c5cd3a5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 31 May 2026 21:38:07 +0200 Subject: [PATCH] fix(doctor): report runtime tool schema errors --- .../doctor-core-checks.runtime-errors.test.ts | 168 +++++++++++++++ src/flows/doctor-core-checks.runtime.ts | 197 +++++++++++++----- 2 files changed, 311 insertions(+), 54 deletions(-) create mode 100644 src/flows/doctor-core-checks.runtime-errors.test.ts diff --git a/src/flows/doctor-core-checks.runtime-errors.test.ts b/src/flows/doctor-core-checks.runtime-errors.test.ts new file mode 100644 index 00000000000..251712eab27 --- /dev/null +++ b/src/flows/doctor-core-checks.runtime-errors.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { setPluginToolMeta } from "../plugins/tools.js"; + +const mocks = vi.hoisted(() => ({ + createBundleMcpToolRuntime: vi.fn(), + createOpenClawCodingTools: vi.fn(), + disposeBundleRuntime: vi.fn(), + loadModelCatalog: vi.fn(async (): Promise>> => []), + normalizeProviderToolSchemasWithPlugin: vi.fn(), + resolveDefaultModelForAgent: vi.fn(() => ({ provider: "openai", model: "gpt-5.5" })), +})); + +vi.mock("../agents/model-catalog.js", () => ({ + findModelInCatalog: ( + catalog: Array<{ provider?: string; id?: string }>, + provider: string, + modelId: string, + ) => catalog.find((entry) => entry.provider === provider && entry.id === modelId), + loadModelCatalog: mocks.loadModelCatalog, +})); + +vi.mock("../agents/model-selection.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveDefaultModelForAgent: mocks.resolveDefaultModelForAgent, +})); + +vi.mock("../agents/agent-bundle-mcp-tools.js", () => ({ + createBundleMcpToolRuntime: mocks.createBundleMcpToolRuntime, +})); + +vi.mock("../agents/agent-tools.js", () => ({ + createOpenClawCodingTools: mocks.createOpenClawCodingTools, +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + inspectProviderToolSchemasWithPlugin: () => [], + normalizeProviderToolSchemasWithPlugin: mocks.normalizeProviderToolSchemasWithPlugin, +})); + +const { collectRuntimeToolSchemaFindings } = await import("./doctor-core-checks.runtime.js"); + +function tool(name: string, parameters: unknown): AnyAgentTool { + return { + name, + label: name, + description: name, + parameters, + execute: async () => ({ text: "ok" }), + } as unknown as AnyAgentTool; +} + +function bundleMcpTool(name: string, parameters: unknown): AnyAgentTool { + const entry = tool(name, parameters); + setPluginToolMeta(entry, { pluginId: "bundle-mcp", optional: false }); + return entry; +} + +describe("doctor runtime tool schema error handling", () => { + beforeEach(() => { + mocks.createOpenClawCodingTools.mockReset().mockReturnValue([]); + mocks.createBundleMcpToolRuntime.mockReset().mockResolvedValue({ + tools: [], + dispose: mocks.disposeBundleRuntime, + }); + mocks.disposeBundleRuntime.mockReset().mockResolvedValue(undefined); + mocks.loadModelCatalog.mockClear(); + mocks.normalizeProviderToolSchemasWithPlugin + .mockReset() + .mockImplementation(({ context }) => context.tools); + mocks.resolveDefaultModelForAgent.mockClear(); + }); + + it("reports agent runtime tool construction failures without aborting schema checks", async () => { + mocks.createOpenClawCodingTools.mockImplementationOnce(() => { + throw new Error("fuzzplugin startup failed"); + }); + + await expect(collectRuntimeToolSchemaFindings({})).resolves.toContainEqual({ + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: "Agent main runtime tool schema validation could not load the runtime tool set.", + path: "agents.main.tools", + requirement: "fuzzplugin startup failed", + fixHint: + "Fix provider/plugin tool loading errors, then rerun doctor before relying on assistant tool startup.", + }); + expect(mocks.createBundleMcpToolRuntime).toHaveBeenCalledTimes(1); + expect(mocks.disposeBundleRuntime).toHaveBeenCalledTimes(1); + }); + + it("reports agent runtime tool normalization failures without aborting doctor", async () => { + mocks.createOpenClawCodingTools.mockReturnValueOnce([ + tool("fuzzplugin_move_angles", { type: "object", properties: {} }), + ]); + mocks.normalizeProviderToolSchemasWithPlugin.mockImplementation(({ context }) => { + const tools = context.tools as AnyAgentTool[]; + if (tools.some((entry) => entry.name === "fuzzplugin_move_angles")) { + throw new Error("fuzzplugin schema normalization failed"); + } + return tools; + }); + + await expect(collectRuntimeToolSchemaFindings({})).resolves.toContainEqual({ + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: + "Agent main runtime tool schema validation could not normalize the runtime tool set.", + path: "agents.main.tools", + requirement: "fuzzplugin schema normalization failed", + fixHint: + "Fix provider/plugin schema normalization errors, then rerun doctor before relying on assistant tool startup.", + }); + expect(mocks.disposeBundleRuntime).toHaveBeenCalledTimes(1); + }); + + it("reports unreadable agent runtime tool schemas without aborting doctor", async () => { + const unreadable = tool("fuzzplugin_unreadable", { type: "object", properties: {} }); + Object.defineProperty(unreadable, "parameters", { + enumerable: true, + get() { + throw new Error("fuzzplugin parameters getter exploded"); + }, + }); + mocks.createOpenClawCodingTools.mockReturnValueOnce([ + unreadable, + tool("healthy", { type: "object", properties: {} }), + ]); + + await expect(collectRuntimeToolSchemaFindings({})).resolves.toContainEqual({ + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: + "Agent main tool fuzzplugin_unreadable has an unsupported input schema for runtime projection.", + path: "tools.fuzzplugin_unreadable", + target: "fuzzplugin_unreadable", + requirement: "fuzzplugin_unreadable.parameters is unreadable", + fixHint: + "Disable or update the offending plugin/tool so its parameters are a JSON object schema, then rerun doctor.", + }); + expect(mocks.disposeBundleRuntime).toHaveBeenCalledTimes(1); + }); + + it("reports bundle MCP runtime tool normalization failures without aborting doctor", async () => { + mocks.createBundleMcpToolRuntime.mockResolvedValueOnce({ + tools: [bundleMcpTool("fuzzplugin__move_angles", { type: "object", properties: {} })], + dispose: mocks.disposeBundleRuntime, + }); + mocks.normalizeProviderToolSchemasWithPlugin.mockImplementation(({ context }) => { + const tools = context.tools as AnyAgentTool[]; + if (tools.some((entry) => entry.name === "fuzzplugin__move_angles")) { + throw new Error("fuzzplugin MCP schema normalization failed"); + } + return tools; + }); + + await expect(collectRuntimeToolSchemaFindings({})).resolves.toContainEqual({ + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: "Configured MCP tool schema validation could not normalize the runtime tool set.", + path: "mcp.servers", + requirement: "fuzzplugin MCP schema normalization failed", + fixHint: + "Fix provider/plugin schema normalization errors, then rerun doctor before relying on assistant tool startup.", + }); + expect(mocks.disposeBundleRuntime).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index 8173d69116b..4311aa8d444 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -606,27 +606,37 @@ function collectBundleMcpRuntimeToolSchemaFindings(params: { modelId: params.modelRef.model, warn: () => {}, }); - const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = []; - const normalizedTools = normalizeAgentRuntimeTools({ - tools: activeBundleTools, - provider: params.modelRef.provider, - config: params.cfg, - workspaceDir: params.workspaceDir, - env: process.env, - modelId: params.modelRef.model, - modelApi: params.model.api, - model: params.model, - onPreNormalizationSchemaDiagnostics: (diagnostics) => - preNormalizationDiagnostics.push(...diagnostics), - }); + const preNormalizationFindings: HealthFinding[] = []; + + let normalizedTools: AnyAgentTool[]; + try { + normalizedTools = normalizeAgentRuntimeTools({ + tools: activeBundleTools, + provider: params.modelRef.provider, + config: params.cfg, + workspaceDir: params.workspaceDir, + env: process.env, + modelId: params.modelRef.model, + modelApi: params.model.api, + model: params.model, + onPreNormalizationSchemaDiagnostics: (diagnostics, sourceTools) => { + preNormalizationFindings.push( + ...diagnostics.map((diagnostic) => + toolSchemaDiagnosticToFinding({ + agentId: params.agentId, + tools: sourceTools, + diagnostic, + }), + ), + ); + }, + }); + } catch (error) { + return [...preNormalizationFindings, bundleMcpRuntimeNormalizationFailureFinding(error)]; + } + return [ - ...preNormalizationDiagnostics.map((diagnostic) => - toolSchemaDiagnosticToFinding({ - agentId: params.agentId, - tools: activeBundleTools, - diagnostic, - }), - ), + ...preNormalizationFindings, ...collectToolSchemaFindings({ agentId: params.agentId, tools: normalizedTools, @@ -634,6 +644,114 @@ function collectBundleMcpRuntimeToolSchemaFindings(params: { ]; } +function agentRuntimeToolLoadFailureFinding(params: { + agentId: string; + error: unknown; +}): HealthFinding { + return { + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: `Agent ${params.agentId} runtime tool schema validation could not load the runtime tool set.`, + path: `agents.${params.agentId}.tools`, + requirement: formatErrorMessage(params.error), + fixHint: + "Fix provider/plugin tool loading errors, then rerun doctor before relying on assistant tool startup.", + }; +} + +function agentRuntimeToolNormalizationFailureFinding(params: { + agentId: string; + error: unknown; +}): HealthFinding { + return { + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: `Agent ${params.agentId} runtime tool schema validation could not normalize the runtime tool set.`, + path: `agents.${params.agentId}.tools`, + requirement: formatErrorMessage(params.error), + fixHint: + "Fix provider/plugin schema normalization errors, then rerun doctor before relying on assistant tool startup.", + }; +} + +function collectAgentRuntimeToolSchemaFindings(params: { + cfg: OpenClawConfig; + agentId: string; + workspaceDir: string; + modelRef: { provider: string; model: string }; + model: ProviderRuntimeModel; +}): readonly HealthFinding[] { + let tools: AnyAgentTool[]; + try { + tools = createOpenClawCodingTools({ + agentId: params.agentId, + workspaceDir: params.workspaceDir, + config: params.cfg, + modelProvider: params.modelRef.provider, + modelId: params.modelRef.model, + modelApi: params.model.api, + modelCompat: params.model.compat, + modelContextWindowTokens: params.model.contextWindow, + allowGatewaySubagentBinding: true, + emitBeforeToolCallDiagnostics: false, + }); + } catch (error) { + return [agentRuntimeToolLoadFailureFinding({ agentId: params.agentId, error })]; + } + + const preNormalizationFindings: HealthFinding[] = []; + + let normalizedTools: AnyAgentTool[]; + try { + normalizedTools = normalizeAgentRuntimeTools({ + tools, + provider: params.modelRef.provider, + config: params.cfg, + workspaceDir: params.workspaceDir, + env: process.env, + modelId: params.modelRef.model, + modelApi: params.model.api, + model: params.model, + onPreNormalizationSchemaDiagnostics: (diagnostics, sourceTools) => { + preNormalizationFindings.push( + ...diagnostics.map((diagnostic) => + toolSchemaDiagnosticToFinding({ + agentId: params.agentId, + tools: sourceTools, + diagnostic, + }), + ), + ); + }, + }); + } catch (error) { + return [ + ...preNormalizationFindings, + agentRuntimeToolNormalizationFailureFinding({ agentId: params.agentId, error }), + ]; + } + + return [ + ...preNormalizationFindings, + ...collectToolSchemaFindings({ + agentId: params.agentId, + tools: normalizedTools, + }), + ]; +} + +function bundleMcpRuntimeNormalizationFailureFinding(error: unknown): HealthFinding { + return { + checkId: "core/doctor/runtime-tool-schemas", + severity: "error", + message: "Configured MCP tool schema validation could not normalize the runtime tool set.", + path: "mcp.servers", + requirement: formatErrorMessage(error), + fixHint: + "Fix provider/plugin schema normalization errors, then rerun doctor before relying on assistant tool startup.", + }; +} + function bundleMcpRuntimeLoadFailureFinding(error: unknown): HealthFinding { return { checkId: "core/doctor/runtime-tool-schemas", @@ -802,42 +920,13 @@ export async function collectRuntimeToolSchemaFindings( if (!supportsModelTools(model)) { continue; } - const tools = createOpenClawCodingTools({ - agentId, - workspaceDir, - config: cfg, - modelProvider: modelRef.provider, - modelId: modelRef.model, - modelApi: model.api, - modelCompat: model.compat, - modelContextWindowTokens: model.contextWindow, - allowGatewaySubagentBinding: true, - emitBeforeToolCallDiagnostics: false, - }); - const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = []; - const normalizedTools = normalizeAgentRuntimeTools({ - tools, - provider: modelRef.provider, - config: cfg, - workspaceDir, - env: process.env, - modelId: modelRef.model, - modelApi: model.api, - model, - onPreNormalizationSchemaDiagnostics: (diagnostics) => - preNormalizationDiagnostics.push(...diagnostics), - }); findings.push( - ...preNormalizationDiagnostics.map((diagnostic) => - toolSchemaDiagnosticToFinding({ - agentId, - tools, - diagnostic, - }), - ), - ...collectToolSchemaFindings({ + ...collectAgentRuntimeToolSchemaFindings({ + cfg, agentId, - tools: normalizedTools, + workspaceDir, + modelRef, + model, }), ); if (!shouldCreateBundleMcpRuntimeForAttempt({ toolsEnabled: true })) {