mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 11:30:21 +00:00
fix(cron): remove OpenAPI 3.0 incompatible JSON Schema keywords from cron tool (#61221)
The cron tool schema used type arrays (['string','null']), the 'not' keyword, and 'const' — all unsupported by the OpenAPI 3.0 subset that Gemini-backed providers (e.g. GitHub Copilot) enforce. This caused HTTP 400 for every request when cron was enabled. Replace type arrays with scalar types, remove not/const from CronFailureAlertSchema, and add 'not' to the Gemini unsupported keywords list as defense-in-depth. Fixes #61206
This commit is contained in:
@@ -96,4 +96,46 @@ describe("cleanSchemaForGemini", () => {
|
|||||||
expect(cleaned.required).toEqual(["nested"]);
|
expect(cleaned.required).toEqual(["nested"]);
|
||||||
expect(cleaned.properties?.nested).not.toHaveProperty("required");
|
expect(cleaned.properties?.nested).not.toHaveProperty("required");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: #61206 — `not` keyword is not part of the OpenAPI 3.0 subset
|
||||||
|
// and must be stripped to avoid HTTP 400 from Gemini-backed providers.
|
||||||
|
it("strips the not keyword from schemas", () => {
|
||||||
|
const cleaned = cleanSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
not: { const: true },
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(cleaned).not.toHaveProperty("not");
|
||||||
|
expect(cleaned.type).toBe("object");
|
||||||
|
expect(cleaned.properties).toEqual({ name: { type: "string" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: #61206 — type arrays like ["string", "null"] must be
|
||||||
|
// collapsed to a single scalar type for OpenAPI 3.0 compatibility.
|
||||||
|
it("collapses type arrays by stripping null entries", () => {
|
||||||
|
const cleaned = cleanSchemaForGemini({
|
||||||
|
type: ["string", "null"],
|
||||||
|
description: "nullable field",
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(cleaned.type).toBe("string");
|
||||||
|
expect(cleaned.description).toBe("nullable field");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses type arrays in nested property schemas", () => {
|
||||||
|
const cleaned = cleanSchemaForGemini({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
agentId: {
|
||||||
|
type: ["string", "null"],
|
||||||
|
description: "Agent id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as { properties?: { agentId?: Record<string, unknown> } };
|
||||||
|
|
||||||
|
expect(cleaned.properties?.agentId?.type).toBe("string");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|||||||
"uniqueItems",
|
"uniqueItems",
|
||||||
"minProperties",
|
"minProperties",
|
||||||
"maxProperties",
|
"maxProperties",
|
||||||
|
|
||||||
|
// JSON Schema composition keywords not supported by OpenAPI 3.0 subset.
|
||||||
|
// `const` is handled separately (converted to enum) in the cleaning loop,
|
||||||
|
// but `not` has no safe equivalent and must be stripped.
|
||||||
|
"not",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SCHEMA_META_KEYS = ["description", "title", "default"] as const;
|
const SCHEMA_META_KEYS = ["description", "title", "default"] as const;
|
||||||
|
|||||||
@@ -136,29 +136,34 @@ describe("CronToolSchema", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job.failureAlert also allows boolean false", () => {
|
it("job.failureAlert uses plain object type for OpenAPI 3.0 compat", () => {
|
||||||
const root = (CronToolSchema as Record<string, unknown>).properties as
|
const root = (CronToolSchema as Record<string, unknown>).properties as
|
||||||
| Record<string, { properties?: Record<string, unknown>; type?: unknown }>
|
| Record<string, { properties?: Record<string, unknown>; type?: unknown }>
|
||||||
| undefined;
|
| undefined;
|
||||||
const jobProps = root?.job?.properties as
|
const jobProps = root?.job?.properties as
|
||||||
| Record<string, { type?: unknown; not?: { const?: unknown } }>
|
| Record<string, { type?: unknown; description?: string }>
|
||||||
| undefined;
|
| undefined;
|
||||||
const schema = jobProps?.failureAlert;
|
const schema = jobProps?.failureAlert;
|
||||||
expect(schema?.type).toEqual(["object", "boolean"]);
|
// Must be a plain "object" type — not a type array — so providers that
|
||||||
expect(schema?.not?.const).toBe(true);
|
// enforce an OpenAPI 3.0 subset (e.g. Gemini via GitHub Copilot) accept it.
|
||||||
|
expect(schema?.type).toBe("object");
|
||||||
|
// The description must mention "false" so LLMs know they can disable alerts.
|
||||||
|
expect(schema?.description).toMatch(/false/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job.agentId and job.sessionKey accept null for clear/keep-unset flows", () => {
|
it("job.agentId and job.sessionKey use plain string type for OpenAPI 3.0 compat", () => {
|
||||||
const root = (CronToolSchema as Record<string, unknown>).properties as
|
const root = (CronToolSchema as Record<string, unknown>).properties as
|
||||||
| Record<string, { properties?: Record<string, unknown> }>
|
| Record<string, { properties?: Record<string, unknown> }>
|
||||||
| undefined;
|
| undefined;
|
||||||
const jobProps = root?.job?.properties as Record<string, { type?: unknown }> | undefined;
|
const jobProps = root?.job?.properties as Record<string, { type?: unknown }> | undefined;
|
||||||
|
|
||||||
expect(jobProps?.agentId?.type).toEqual(["string", "null"]);
|
// Must be plain "string" — not ["string", "null"] — for provider compat.
|
||||||
expect(jobProps?.sessionKey?.type).toEqual(["string", "null"]);
|
// Null semantics are conveyed via the field description and handled at runtime.
|
||||||
|
expect(jobProps?.agentId?.type).toBe("string");
|
||||||
|
expect(jobProps?.sessionKey?.type).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("patch.payload.toolsAllow accepts null for clear flows", () => {
|
it("patch.payload.toolsAllow uses plain array type for OpenAPI 3.0 compat", () => {
|
||||||
const root = (CronToolSchema as Record<string, unknown>).properties as
|
const root = (CronToolSchema as Record<string, unknown>).properties as
|
||||||
| Record<string, { properties?: Record<string, unknown> }>
|
| Record<string, { properties?: Record<string, unknown> }>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -166,6 +171,17 @@ describe("CronToolSchema", () => {
|
|||||||
| Record<string, { properties?: Record<string, { type?: unknown }> }>
|
| Record<string, { properties?: Record<string, { type?: unknown }> }>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
expect(patchProps?.payload?.properties?.toolsAllow?.type).toEqual(["array", "null"]);
|
// Must be plain "array" — not ["array", "null"] — for provider compat.
|
||||||
|
expect(patchProps?.payload?.properties?.toolsAllow?.type).toBe("array");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression guard: ensure no OpenAPI 3.0 incompatible keywords leak into the
|
||||||
|
// serialized cron tool schema. This catches future regressions at the source.
|
||||||
|
it("serialized schema contains no type-array or not/const keywords", () => {
|
||||||
|
const json = JSON.stringify(CronToolSchema);
|
||||||
|
// type arrays like ["string","null"] are not valid in OpenAPI 3.0
|
||||||
|
expect(json).not.toMatch(/"type"\s*:\s*\[/);
|
||||||
|
// "not" composition keyword is not supported by OpenAPI 3.0
|
||||||
|
expect(json).not.toMatch(/"not"\s*:\s*\{/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,22 +55,11 @@ const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
|||||||
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
||||||
|
|
||||||
function nullableStringSchema(description: string) {
|
function nullableStringSchema(description: string) {
|
||||||
return Type.Optional(
|
return Type.Optional(Type.String({ description }));
|
||||||
Type.Unsafe<string | null>({
|
|
||||||
type: ["string", "null"],
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function nullableStringArraySchema(description: string) {
|
function nullableStringArraySchema(description: string) {
|
||||||
return Type.Optional(
|
return Type.Optional(Type.Array(Type.String(), { description }));
|
||||||
Type.Unsafe<string[] | null>({
|
|
||||||
type: ["array", "null"],
|
|
||||||
items: { type: "string" },
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) {
|
function cronPayloadObjectSchema(params: { toolsAllow: TSchema }) {
|
||||||
@@ -138,12 +127,14 @@ const CronDeliverySchema = Type.Optional(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep `false` expressible without reintroducing anyOf/oneOf into the raw tool schema.
|
|
||||||
// Omitting `failureAlert` means "leave defaults/unchanged"; `false` explicitly disables alerts.
|
// Omitting `failureAlert` means "leave defaults/unchanged"; `false` explicitly disables alerts.
|
||||||
|
// Runtime handles `failureAlert === false` in cron/service/timer.ts.
|
||||||
|
// The schema declares `type: "object"` to stay compatible with providers that
|
||||||
|
// enforce an OpenAPI 3.0 subset (e.g. Gemini via GitHub Copilot). The
|
||||||
|
// description tells the LLM that `false` is also accepted.
|
||||||
const CronFailureAlertSchema = Type.Optional(
|
const CronFailureAlertSchema = Type.Optional(
|
||||||
Type.Unsafe<Record<string, unknown> | false>({
|
Type.Unsafe<Record<string, unknown> | false>({
|
||||||
type: ["object", "boolean"],
|
type: "object",
|
||||||
not: { const: true },
|
|
||||||
properties: {
|
properties: {
|
||||||
after: Type.Optional(Type.Number({ description: "Failures before alerting" })),
|
after: Type.Optional(Type.Number({ description: "Failures before alerting" })),
|
||||||
channel: Type.Optional(Type.String({ description: "Alert channel" })),
|
channel: Type.Optional(Type.String({ description: "Alert channel" })),
|
||||||
@@ -153,7 +144,8 @@ const CronFailureAlertSchema = Type.Optional(
|
|||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String()),
|
||||||
},
|
},
|
||||||
additionalProperties: true,
|
additionalProperties: true,
|
||||||
description: "Failure alert object, or false to disable alerts for this job",
|
description:
|
||||||
|
"Failure alert config object, or the boolean value false to disable alerts for this job",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user