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:
Chunyue Wang 2026-04-05 18:21:45 +08:00 committed by GitHub
parent 359be4eb48
commit 8c1ca1f245
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 81 additions and 26 deletions

View File

@ -96,4 +96,46 @@ describe("cleanSchemaForGemini", () => {
expect(cleaned.required).toEqual(["nested"]);
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");
});
});

View File

@ -27,6 +27,11 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"uniqueItems",
"minProperties",
"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;

View File

@ -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
| Record<string, { properties?: Record<string, unknown>; type?: unknown }>
| undefined;
const jobProps = root?.job?.properties as
| Record<string, { type?: unknown; not?: { const?: unknown } }>
| Record<string, { type?: unknown; description?: string }>
| undefined;
const schema = jobProps?.failureAlert;
expect(schema?.type).toEqual(["object", "boolean"]);
expect(schema?.not?.const).toBe(true);
// Must be a plain "object" type — not a type array — so providers that
// 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
| Record<string, { properties?: Record<string, unknown> }>
| undefined;
const jobProps = root?.job?.properties as Record<string, { type?: unknown }> | undefined;
expect(jobProps?.agentId?.type).toEqual(["string", "null"]);
expect(jobProps?.sessionKey?.type).toEqual(["string", "null"]);
// Must be plain "string" — not ["string", "null"] — for provider compat.
// 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
| Record<string, { properties?: Record<string, unknown> }>
| undefined;
@ -166,6 +171,17 @@ describe("CronToolSchema", () => {
| Record<string, { properties?: Record<string, { type?: unknown }> }>
| 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*\{/);
});
});

View File

@ -55,22 +55,11 @@ const REMINDER_CONTEXT_TOTAL_MAX = 700;
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
function nullableStringSchema(description: string) {
return Type.Optional(
Type.Unsafe<string | null>({
type: ["string", "null"],
description,
}),
);
return Type.Optional(Type.String({ description }));
}
function nullableStringArraySchema(description: string) {
return Type.Optional(
Type.Unsafe<string[] | null>({
type: ["array", "null"],
items: { type: "string" },
description,
}),
);
return Type.Optional(Type.Array(Type.String(), { description }));
}
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.
// 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(
Type.Unsafe<Record<string, unknown> | false>({
type: ["object", "boolean"],
not: { const: true },
type: "object",
properties: {
after: Type.Optional(Type.Number({ description: "Failures before alerting" })),
channel: Type.Optional(Type.String({ description: "Alert channel" })),
@ -153,7 +144,8 @@ const CronFailureAlertSchema = Type.Optional(
accountId: Type.Optional(Type.String()),
},
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",
}),
);