fix(doctor): prevent false provider failures after fresh setup (#114533)

This commit is contained in:
Peter Steinberger
2026-07-27 07:20:51 -04:00
committed by GitHub
parent 3f37731175
commit c068f2b2df
7 changed files with 90 additions and 39 deletions
+1 -1
View File
@@ -746,7 +746,7 @@ vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({
}));
vi.mock("./doctor/shared/active-tool-schema-warnings.js", () => ({
collectActiveToolSchemaProjectionWarnings: vi.fn(() => []),
collectActiveToolSchemaProjectionWarnings: vi.fn(async () => []),
}));
vi.mock("./doctor/shared/plugin-dependency-cleanup.js", () => ({
@@ -288,7 +288,7 @@ describe("doctor repair sequencing", () => {
changes: [],
warnings: [],
});
mocks.collectActiveToolSchemaProjectionWarnings.mockReturnValue([]);
mocks.collectActiveToolSchemaProjectionWarnings.mockResolvedValue([]);
mocks.collectChannelDoctorCompatibilityMutations.mockReturnValue([]);
mocks.resolveAuthProfileOrder.mockReturnValue([]);
mocks.resolveProfileUnusableUntilForDisplay.mockReturnValue(null);
@@ -615,7 +615,7 @@ describe("doctor repair sequencing", () => {
});
it("emits active tool schema projection warnings during doctor repair", async () => {
mocks.collectActiveToolSchemaProjectionWarnings.mockReturnValueOnce([
mocks.collectActiveToolSchemaProjectionWarnings.mockResolvedValueOnce([
'- agents.main: active tool "fuzzplugin_move_angles" from plugin "fuzzplugin" has unsupported runtime input schema.',
]);
+1 -1
View File
@@ -271,7 +271,7 @@ export async function runDoctorRepairSequence(params: {
staleOAuthShadowRepair.changes.length > 0 ||
authProfileSqliteMigration.changes.length > 0;
const activeToolSchemaWarnings = collectActiveToolSchemaProjectionWarnings({
const activeToolSchemaWarnings = await collectActiveToolSchemaProjectionWarnings({
cfg: state.candidate,
env,
});
@@ -16,7 +16,7 @@ const toolState = vi.hoisted(() => ({
compat?: Record<string, unknown>;
} | null,
resolveModelError: null as Error | null,
resolveModel: vi.fn(),
resolveModelAsync: vi.fn(),
createTools: vi.fn<typeof createOpenClawCodingTools>(),
normalizeTools: vi.fn(
(options: { tools: AnyAgentTool[]; modelApi?: string; model?: unknown }) => options.tools,
@@ -24,7 +24,7 @@ const toolState = vi.hoisted(() => ({
}));
vi.mock("../../../agents/embedded-agent-runner/model.js", () => ({
resolveModel: (...args: unknown[]) => toolState.resolveModel(...args),
resolveModelAsync: (...args: unknown[]) => toolState.resolveModelAsync(...args),
}));
vi.mock("../../../agents/agent-tools.js", () => ({
@@ -69,7 +69,7 @@ describe("active tool schema doctor warnings", () => {
toolState.throwError = null;
toolState.runtimeModel = null;
toolState.resolveModelError = null;
toolState.resolveModel.mockReset().mockImplementation(() => {
toolState.resolveModelAsync.mockReset().mockImplementation(async () => {
if (toolState.resolveModelError) {
throw toolState.resolveModelError;
}
@@ -83,7 +83,7 @@ describe("active tool schema doctor warnings", () => {
toolState.normalizeTools.mockReset().mockImplementation((options) => options.tools);
});
it("warns with plugin ownership for active tools blocked by runtime projection", () => {
it("warns with plugin ownership for active tools blocked by runtime projection", async () => {
toolState.tools = [
tool("message", { type: "object", properties: {} }),
tool("fuzzplugin_move_angles", { type: "array", items: { type: "number" } }),
@@ -91,7 +91,7 @@ describe("active tool schema doctor warnings", () => {
toolState.pluginIds = { fuzzplugin_move_angles: "fuzzplugin" };
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: {
plugins: {
entries: {
@@ -109,7 +109,7 @@ describe("active tool schema doctor warnings", () => {
);
});
it("warns about unreadable active tool entries without crashing", () => {
it("warns about unreadable active tool entries without crashing", async () => {
const healthy = tool("message", { type: "object", properties: {} });
toolState.tools = new Proxy([healthy] as AnyAgentTool[], {
get(target, property, receiver) {
@@ -127,7 +127,7 @@ describe("active tool schema doctor warnings", () => {
});
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: {},
env: { HOME: "/tmp/openclaw-test" },
}),
@@ -136,14 +136,14 @@ describe("active tool schema doctor warnings", () => {
]);
});
it("does not validate disabled plugin mode", () => {
it("does not validate disabled plugin mode", async () => {
toolState.tools = [
tool("fuzzplugin_move_angles", { type: "array", items: { type: "number" } }),
];
toolState.pluginIds = { fuzzplugin_move_angles: "fuzzplugin" };
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: { plugins: { enabled: false } },
env: { HOME: "/tmp/openclaw-test" },
}),
@@ -152,7 +152,47 @@ describe("active tool schema doctor warnings", () => {
expect(toolState.normalizeTools).not.toHaveBeenCalled();
});
it("validates provider-normalized runtime schemas before reporting doctor health", () => {
it("resolves provider discovery asynchronously before agent lifecycle publication", async () => {
toolState.runtimeModel = {
id: "llama-3.1-8b-instant",
name: "Llama 3.1 8B Instant",
provider: "groq",
api: "openai-completions",
};
const cfg = {
agents: {
defaults: {
model: { primary: "groq/llama-3.1-8b-instant" },
},
},
};
expect(
await collectActiveToolSchemaProjectionWarnings({
cfg,
env: { HOME: "/tmp/openclaw-test" },
}),
).toEqual([]);
expect(toolState.resolveModelAsync).toHaveBeenCalledWith(
"groq",
"llama-3.1-8b-instant",
expect.any(String),
cfg,
expect.objectContaining({
agentId: "main",
workspaceDir: expect.any(String),
}),
);
expect(toolState.createTools).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: "groq",
modelId: "llama-3.1-8b-instant",
modelApi: "openai-completions",
}),
);
});
it("validates provider-normalized runtime schemas before reporting doctor health", async () => {
const healthyTool = tool("message", { type: "object", properties: {} });
const dynamicTool = tool("fuzzplugin_move_angles", { type: "object", properties: {} });
toolState.runtimeModel = {
@@ -186,7 +226,7 @@ describe("active tool schema doctor warnings", () => {
});
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: {
agents: {
defaults: {
@@ -219,14 +259,14 @@ describe("active tool schema doctor warnings", () => {
);
});
it("reports runtime schema normalization failures instead of crashing doctor", () => {
it("reports runtime schema normalization failures instead of crashing doctor", async () => {
toolState.tools = [tool("message", { type: "object", properties: {} })];
toolState.normalizeTools.mockImplementation(() => {
throw new Error("provider schema hook failed");
});
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: {},
env: { HOME: "/tmp/openclaw-test" },
}),
@@ -235,12 +275,12 @@ describe("active tool schema doctor warnings", () => {
]);
});
it("reports runtime model context failures instead of crashing doctor", () => {
it("reports runtime model context failures instead of crashing doctor", async () => {
toolState.resolveModelError = new Error("provider model hook failed");
toolState.tools = [tool("message", { type: "object", properties: {} })];
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: {},
env: { HOME: "/tmp/openclaw-test" },
}),
@@ -251,11 +291,11 @@ describe("active tool schema doctor warnings", () => {
expect(toolState.normalizeTools).toHaveBeenCalled();
});
it("reports toolset construction failures instead of crashing doctor", () => {
it("reports toolset construction failures instead of crashing doctor", async () => {
toolState.throwError = new Error("plugin startup failed");
expect(
collectActiveToolSchemaProjectionWarnings({
await collectActiveToolSchemaProjectionWarnings({
cfg: { plugins: { entries: { fuzzplugin: { enabled: true } } } },
env: { HOME: "/tmp/openclaw-test" },
}),
@@ -6,7 +6,7 @@ import {
resolveAgentWorkspaceDir,
} from "../../../agents/agent-scope.js";
import { createOpenClawCodingTools } from "../../../agents/agent-tools.js";
import { resolveModel } from "../../../agents/embedded-agent-runner/model.js";
import { resolveModelAsync } from "../../../agents/embedded-agent-runner/model.js";
import { normalizeAgentRuntimeTools } from "../../../agents/runtime-plan/tools.js";
import {
filterRuntimeCompatibleTools,
@@ -22,23 +22,34 @@ import type { ProviderRuntimeModel } from "../../../plugins/provider-runtime-mod
import { getPluginToolMeta } from "../../../plugins/tools.js";
import { resolveDoctorPrimaryModelRef } from "./primary-model-ref.js";
function resolveRuntimeModelContext(params: {
type RuntimeModelContext = {
modelApi?: string;
model?: ProviderRuntimeModel;
modelCompat?: ReturnType<typeof extractModelCompat>;
modelContextWindowTokens?: number;
};
async function resolveRuntimeModelContext(params: {
cfg: OpenClawConfig;
agentId: string;
agentDir: string;
workspaceDir: string;
provider: string;
modelId: string;
}): {
modelApi?: string;
model?: ProviderRuntimeModel;
modelCompat?: ReturnType<typeof extractModelCompat>;
modelContextWindowTokens?: number;
} {
const model = resolveModel(params.provider, params.modelId, params.agentDir, params.cfg, {
agentId: params.agentId,
workspaceDir: params.workspaceDir,
}).model as ProviderRuntimeModel | undefined;
}): Promise<RuntimeModelContext> {
// Doctor runs before agent lifecycle publication; async resolution prepares discovery instead
// of reporting an unpublished synchronous runtime as a broken provider.
const resolution = await resolveModelAsync(
params.provider,
params.modelId,
params.agentDir,
params.cfg,
{
agentId: params.agentId,
workspaceDir: params.workspaceDir,
},
);
const model = resolution.model as ProviderRuntimeModel | undefined;
if (!model) {
return {};
}
@@ -80,10 +91,10 @@ function readPluginId(tool: AnyAgentTool | undefined): string | undefined {
}
/** Collect per-agent warnings for active plugin tools rejected by runtime schema projection. */
export function collectActiveToolSchemaProjectionWarnings(params: {
export async function collectActiveToolSchemaProjectionWarnings(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): string[] {
}): Promise<string[]> {
if (params.cfg.plugins?.enabled === false) {
return [];
}
@@ -95,9 +106,9 @@ export function collectActiveToolSchemaProjectionWarnings(params: {
const modelRef = resolveDoctorPrimaryModelRef(params.cfg, agentConfig?.model);
const agentDir = resolveAgentDir(params.cfg, agentId, env);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId, env);
let runtimeModelContext: ReturnType<typeof resolveRuntimeModelContext> = {};
let runtimeModelContext: RuntimeModelContext = {};
try {
runtimeModelContext = resolveRuntimeModelContext({
runtimeModelContext = await resolveRuntimeModelContext({
cfg: params.cfg,
agentId,
agentDir,
@@ -315,7 +315,7 @@ vi.mock("./stale-auth-order.js", () => ({
}));
vi.mock("./active-tool-schema-warnings.js", () => ({
collectActiveToolSchemaProjectionWarnings: () => activeToolSchemaState.warnings,
collectActiveToolSchemaProjectionWarnings: async () => activeToolSchemaState.warnings,
}));
vi.mock("./codex-route-warnings.js", () => ({
@@ -722,7 +722,7 @@ export async function collectDoctorPreviewNotes(params: {
warnings.push(...collectProfileConfiguredToolSectionWarnings(params.cfg));
const { collectActiveToolSchemaProjectionWarnings } =
await import("./active-tool-schema-warnings.js");
warnings.push(...collectActiveToolSchemaProjectionWarnings({ cfg: params.cfg, env }));
warnings.push(...(await collectActiveToolSchemaProjectionWarnings({ cfg: params.cfg, env })));
const channelPluginRuntime = await import("./channel-plugin-blockers.js");
const channelPluginBlockerHits = channelPluginRuntime.scanConfiguredChannelPluginBlockers(