diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ec31f6d..e6428c76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210) +- Agents: scrub unsupported JSON Schema keywords from tool schemas for Cloud Code Assist API compatibility. (#567) — thanks @erikpr1994 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index a32f02637..108896468 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import sharp from "sharp"; import { describe, expect, it } from "vitest"; -import { createClawdbotCodingTools } from "./pi-tools.js"; +import { __testing, createClawdbotCodingTools } from "./pi-tools.js"; import { createBrowserTool } from "./tools/browser-tool.js"; describe("createClawdbotCodingTools", () => { @@ -64,6 +64,28 @@ describe("createClawdbotCodingTools", () => { expect(format?.enum).toEqual(["aria", "ai"]); }); + it("inlines local $ref before removing unsupported keywords", () => { + const cleaned = __testing.cleanToolSchemaForGemini({ + type: "object", + properties: { + foo: { $ref: "#/$defs/Foo" }, + }, + $defs: { + Foo: { type: "string", enum: ["a", "b"] }, + }, + }) as { + $defs?: unknown; + properties?: Record; + }; + + expect(cleaned.$defs).toBeUndefined(); + expect(cleaned.properties).toBeDefined(); + expect(cleaned.properties?.foo).toMatchObject({ + type: "string", + enum: ["a", "b"], + }); + }); + it("preserves action enums in normalized schemas", () => { const tools = createClawdbotCodingTools(); const toolNames = [ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 23e8693ef..2601f5b88 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -206,11 +206,109 @@ const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "definitions", ]); -function cleanSchemaForGemini(schema: unknown): unknown { +type SchemaDefs = Map; + +function extendSchemaDefs( + defs: SchemaDefs | undefined, + schema: Record, +): SchemaDefs | undefined { + const defsEntry = + schema.$defs && + typeof schema.$defs === "object" && + !Array.isArray(schema.$defs) + ? (schema.$defs as Record) + : undefined; + const legacyDefsEntry = + schema.definitions && + typeof schema.definitions === "object" && + !Array.isArray(schema.definitions) + ? (schema.definitions as Record) + : undefined; + + if (!defsEntry && !legacyDefsEntry) return defs; + + const next = defs ? new Map(defs) : new Map(); + if (defsEntry) { + for (const [key, value] of Object.entries(defsEntry)) next.set(key, value); + } + if (legacyDefsEntry) { + for (const [key, value] of Object.entries(legacyDefsEntry)) + next.set(key, value); + } + return next; +} + +function decodeJsonPointerSegment(segment: string): string { + return segment.replaceAll("~1", "/").replaceAll("~0", "~"); +} + +function tryResolveLocalRef( + ref: string, + defs: SchemaDefs | undefined, +): unknown | undefined { + if (!defs) return undefined; + const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/); + if (!match) return undefined; + const name = decodeJsonPointerSegment(match[1] ?? ""); + if (!name) return undefined; + return defs.get(name); +} + +function cleanSchemaForGeminiWithDefs( + schema: unknown, + defs: SchemaDefs | undefined, + refStack: Set | undefined, +): unknown { if (!schema || typeof schema !== "object") return schema; - if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); + if (Array.isArray(schema)) { + return schema.map((item) => + cleanSchemaForGeminiWithDefs(item, defs, refStack), + ); + } const obj = schema as Record; + const nextDefs = extendSchemaDefs(defs, obj); + + const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined; + if (refValue) { + if (refStack?.has(refValue)) { + return {}; + } + + const resolved = tryResolveLocalRef(refValue, nextDefs); + if (resolved) { + const nextRefStack = refStack ? new Set(refStack) : new Set(); + nextRefStack.add(refValue); + + const cleaned = cleanSchemaForGeminiWithDefs( + resolved, + nextDefs, + nextRefStack, + ); + if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) { + return cleaned; + } + + const result: Record = { + ...(cleaned as Record), + }; + for (const key of ["description", "title", "default", "examples"]) { + if (key in obj && obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; + } + + const result: Record = {}; + for (const key of ["description", "title", "default", "examples"]) { + if (key in obj && obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; + } + const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf); const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf); @@ -273,20 +371,29 @@ function cleanSchemaForGemini(schema: unknown): unknown { // Recursively clean nested properties const props = value as Record; cleaned[key] = Object.fromEntries( - Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]), + Object.entries(props).map(([k, v]) => [ + k, + cleanSchemaForGeminiWithDefs(v, nextDefs, refStack), + ]), ); } else if (key === "items" && value && typeof value === "object") { // Recursively clean array items schema - cleaned[key] = cleanSchemaForGemini(value); + cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack); } else if (key === "anyOf" && Array.isArray(value)) { // Clean each anyOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "oneOf" && Array.isArray(value)) { // Clean each oneOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else if (key === "allOf" && Array.isArray(value)) { // Clean each allOf variant - cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); + cleaned[key] = value.map((variant) => + cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack), + ); } else { cleaned[key] = value; } @@ -295,6 +402,18 @@ function cleanSchemaForGemini(schema: unknown): unknown { return cleaned; } +function cleanSchemaForGemini(schema: unknown): unknown { + if (!schema || typeof schema !== "object") return schema; + if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini); + + const defs = extendSchemaDefs(undefined, schema as Record); + return cleanSchemaForGeminiWithDefs(schema, defs, undefined); +} + +function cleanToolSchemaForGemini(schema: Record): unknown { + return cleanSchemaForGemini(schema); +} + function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool { const schema = tool.parameters && typeof tool.parameters === "object" @@ -632,6 +751,10 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool { }; } +export const __testing = { + cleanToolSchemaForGemini, +} as const; + export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; messageProvider?: string; diff --git a/src/config/types.ts b/src/config/types.ts index 0b3ab55da..10099821a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1202,13 +1202,14 @@ export type AgentDefaultsConfig = { every?: string; /** Heartbeat model override (provider/model). */ model?: string; - /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */ + /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */ target?: | "last" | "whatsapp" | "telegram" | "discord" | "slack" + | "msteams" | "signal" | "imessage" | "msteams" diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b29c19e3b..995ba8da8 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -601,6 +601,7 @@ const HeartbeatSchema = z z.literal("telegram"), z.literal("discord"), z.literal("slack"), + z.literal("msteams"), z.literal("signal"), z.literal("imessage"), z.literal("none"), diff --git a/src/msteams/monitor.ts b/src/msteams/monitor.ts index c9bf23bf2..a3241caa7 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -55,10 +55,11 @@ export async function monitorMSTeamsProvider( const port = msteamsCfg.webhook?.port ?? 3978; const textLimit = resolveTextChunkLimit(cfg, "msteams"); const MB = 1024 * 1024; + const agentDefaults = cfg.agents?.defaults; const mediaMaxBytes = - typeof cfg.agents?.defaults?.mediaMaxMb === "number" && - cfg.agents.defaults.mediaMaxMb > 0 - ? Math.floor(cfg.agents.defaults.mediaMaxMb * MB) + typeof agentDefaults?.mediaMaxMb === "number" && + agentDefaults.mediaMaxMb > 0 + ? Math.floor(agentDefaults.mediaMaxMb * MB) : 8 * MB; const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs();