From 6f3474ae5123cb8d57f4f6b139ca90bf292fe3ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 14:07:11 +0100 Subject: [PATCH] fix: scrub tool schemas for Cloud Code Assist (#567) (thanks @erikpr1994) --- CHANGELOG.md | 1 + src/agents/pi-tools.test.ts | 24 +++++- src/agents/pi-tools.ts | 137 ++++++++++++++++++++++++++++-- src/cli/gateway-cli.ts | 24 ++++-- src/commands/onboard-auth.ts | 43 +++++----- src/commands/onboard-providers.ts | 6 +- src/config/types.ts | 3 +- src/config/zod-schema.ts | 1 + src/msteams/monitor.ts | 6 +- src/telegram/send.ts | 8 +- src/telegram/webhook-set.ts | 10 +-- 11 files changed, 207 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c775398c5..4c9fc5cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,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/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 86776d28d..b96828248 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -179,14 +179,22 @@ async function ensureDevGatewayConfig(opts: { reset?: boolean }) { mode: "local", bind: "loopback", }, - agent: { - workspace, - skipBootstrap: true, - }, - identity: { - name: DEV_IDENTITY_NAME, - theme: DEV_IDENTITY_THEME, - emoji: DEV_IDENTITY_EMOJI, + agents: { + defaults: { + workspace, + skipBootstrap: true, + }, + list: [ + { + id: "dev", + default: true, + identity: { + name: DEV_IDENTITY_NAME, + theme: DEV_IDENTITY_THEME, + emoji: DEV_IDENTITY_EMOJI, + }, + }, + ], }, }); await ensureDevWorkspace(workspace); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 14325ff20..b25dde66b 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -10,12 +10,6 @@ const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; const DEFAULT_MINIMAX_MAX_TOKENS = 8192; export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; -const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1"; -const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; -const DEFAULT_MINIMAX_MAX_TOKENS = 8192; -export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; - export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, @@ -176,7 +170,7 @@ export function applyMinimaxHostedProviderConfig( cfg: ClawdbotConfig, params?: { baseUrl?: string }, ): ClawdbotConfig { - const models = { ...cfg.agent?.models }; + const models = { ...(cfg.agents?.defaults?.models ?? {}) }; models[MINIMAX_HOSTED_MODEL_REF] = { ...models[MINIMAX_HOSTED_MODEL_REF], alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax", @@ -212,9 +206,12 @@ export function applyMinimaxHostedProviderConfig( return { ...cfg, - agent: { - ...cfg.agent, - models, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, }, models: { mode: cfg.models?.mode ?? "merge", @@ -254,17 +251,21 @@ export function applyMinimaxHostedConfig( const next = applyMinimaxHostedProviderConfig(cfg, params); return { ...next, - agent: { - ...next.agent, - model: { - ...(next.agent?.model && - "fallbacks" in (next.agent.model as Record) - ? { - fallbacks: (next.agent.model as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: MINIMAX_HOSTED_MODEL_REF, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: { + ...(next.agents?.defaults?.model && + "fallbacks" in (next.agents.defaults.model as Record) + ? { + fallbacks: ( + next.agents.defaults.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: MINIMAX_HOSTED_MODEL_REF, + }, }, }, }; diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 23dc60e22..dd08e30cf 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -546,7 +546,8 @@ async function promptWhatsAppAllowFrom( "WhatsApp number", ); const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", + message: + "Your personal WhatsApp number (the phone you will message from)", placeholder: "+15555550123", initialValue: existingAllowFrom[0], validate: (value) => { @@ -613,7 +614,8 @@ async function promptWhatsAppAllowFrom( "WhatsApp number", ); const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", + message: + "Your personal WhatsApp number (the phone you will message from)", placeholder: "+15555550123", initialValue: existingAllowFrom[0], validate: (value) => { diff --git a/src/config/types.ts b/src/config/types.ts index e09d1af69..45413dfb9 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" | "none"; 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 a137cd190..a3241caa7 100644 --- a/src/msteams/monitor.ts +++ b/src/msteams/monitor.ts @@ -55,9 +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.agent?.mediaMaxMb === "number" && cfg.agent.mediaMaxMb > 0 - ? Math.floor(cfg.agent.mediaMaxMb * MB) + typeof agentDefaults?.mediaMaxMb === "number" && + agentDefaults.mediaMaxMb > 0 + ? Math.floor(agentDefaults.mediaMaxMb * MB) : 8 * MB; const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs(); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 2464d5b5e..5309a5f89 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -122,9 +122,7 @@ export async function sendMessageTelegram( const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const api = - opts.api ?? - new Bot(token, client ? { client } : undefined).api; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const mediaUrl = opts.mediaUrl?.trim(); // Build optional params for forum topics and reply threading. @@ -296,9 +294,7 @@ export async function reactMessageTelegram( const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const api = - opts.api ?? - new Bot(token, client ? { client } : undefined).api; + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const request = createTelegramRetryRunner({ retry: opts.retry, configRetry: account.config.retry, diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts index 69609bcd6..eced660e6 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -11,10 +11,7 @@ export async function setTelegramWebhook(opts: { const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const bot = new Bot( - opts.token, - client ? { client } : undefined, - ); + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.setWebhook(opts.url, { secret_token: opts.secret, drop_pending_updates: opts.dropPendingUpdates ?? false, @@ -26,9 +23,6 @@ export async function deleteTelegramWebhook(opts: { token: string }) { const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; - const bot = new Bot( - opts.token, - client ? { client } : undefined, - ); + const bot = new Bot(opts.token, client ? { client } : undefined); await bot.api.deleteWebhook(); }