diff --git a/src/agents/schema/clean-for-gemini.test.ts b/src/agents/schema/clean-for-gemini.test.ts index e8022c457a9..5d04c0e4cac 100644 --- a/src/agents/schema/clean-for-gemini.test.ts +++ b/src/agents/schema/clean-for-gemini.test.ts @@ -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; + + 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; + + 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 } }; + + expect(cleaned.properties?.agentId?.type).toBe("string"); + }); }); diff --git a/src/agents/schema/clean-for-gemini.ts b/src/agents/schema/clean-for-gemini.ts index 8d42a204299..b6bf1b83c43 100644 --- a/src/agents/schema/clean-for-gemini.ts +++ b/src/agents/schema/clean-for-gemini.ts @@ -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; diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 7733c5b91c3..1312318f79f 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -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).properties as | Record; type?: unknown }> | undefined; const jobProps = root?.job?.properties as - | Record + | Record | 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).properties as | Record }> | undefined; const jobProps = root?.job?.properties as Record | 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).properties as | Record }> | undefined; @@ -166,6 +171,17 @@ describe("CronToolSchema", () => { | Record }> | 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*\{/); }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index fe28c635936..34085e55816 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -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({ - type: ["string", "null"], - description, - }), - ); + return Type.Optional(Type.String({ description })); } function nullableStringArraySchema(description: string) { - return Type.Optional( - Type.Unsafe({ - 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 | 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", }), );