Compare commits

..
Author SHA1 Message Date
Peter Steinberger 6f3474ae51 fix: scrub tool schemas for Cloud Code Assist (#567) (thanks @erikpr1994) 2026-01-09 14:07:11 +01:00
Erik cb4d0ad5c1 fix(agents): remove unsupported JSON Schema keywords for Cloud Code Assist API
Cloud Code Assist API requires strict JSON Schema draft 2020-12 compliance
and rejects keywords like patternProperties, additionalProperties, $schema,
$id, $ref, $defs, and definitions.

This extends cleanSchemaForGemini to:
- Remove all unsupported keywords from tool schemas
- Add oneOf literal flattening (matching existing anyOf behavior)
- Add test to verify no unsupported keywords remain in tool schemas
2026-01-09 13:53:47 +01:00
13 changed files with 262 additions and 99 deletions

No files matched your search

+15 -23
View File
@@ -91,10 +91,10 @@ jobs:
run: ${{ matrix.command }}
checks-windows:
runs-on: windows-2022
runs-on: windows-latest
defaults:
run:
shell: pwsh
shell: bash
strategy:
fail-fast: false
matrix:
@@ -104,7 +104,7 @@ jobs:
command: pnpm lint
- runtime: node
task: test
command: pnpm test -- --run --silent
command: pnpm test
- runtime: node
task: build
command: pnpm build
@@ -117,22 +117,17 @@ jobs:
with:
submodules: false
- name: Force UTF-8 output (Windows)
run: |
chcp.com 65001
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
"LANG=en_US.UTF-8" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"LC_ALL=en_US.UTF-8" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for ($attempt = 1; $attempt -le 5; $attempt++) {
git -c protocol.version=2 submodule update --init --force --depth=1 --recursive
if ($LASTEXITCODE -eq 0) { exit 0 }
Write-Host "Submodule update failed (attempt $attempt/5). Retrying..."
Start-Sleep -Seconds ($attempt * 10)
}
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
@@ -153,9 +148,7 @@ jobs:
bun -v
- name: Capture node path
run: |
$nodeBin = Split-Path -Parent (node -p "process.execPath")
"NODE_BIN=$nodeBin" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
- name: Enable corepack and pin pnpm
run: |
@@ -167,15 +160,14 @@ jobs:
env:
CI: true
run: |
$env:PATH = "$env:NODE_BIN;$env:PATH"
Get-Command node | Format-List
export PATH="$NODE_BIN:$PATH"
which node
node -v
pnpm -v
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: |
${{ matrix.command }}
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
+1 -1
View File
@@ -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
@@ -46,7 +47,6 @@
- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat.
- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos
- Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow).
- Control UI: default control UI asset base to relative paths for subpath hosting. (#569) — thanks @bjesuiter
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
+71 -1
View File
@@ -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<string, unknown>;
};
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 = [
@@ -331,4 +353,52 @@ describe("createClawdbotCodingTools", () => {
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
const findUnsupportedKeywords = (
schema: unknown,
path: string,
): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
for (const [key, value] of Object.entries(
schema as Record<string, unknown>,
)) {
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of tools) {
const violations = findUnsupportedKeywords(
tool.parameters,
`${tool.name}.parameters`,
);
expect(violations).toEqual([]);
}
});
});
+163 -21
View File
@@ -195,12 +195,122 @@ function tryFlattenLiteralAnyOf(
return null;
}
function cleanSchemaForGemini(schema: unknown): unknown {
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
defs: SchemaDefs | undefined,
schema: Record<string, unknown>,
): SchemaDefs | undefined {
const defsEntry =
schema.$defs &&
typeof schema.$defs === "object" &&
!Array.isArray(schema.$defs)
? (schema.$defs as Record<string, unknown>)
: undefined;
const legacyDefsEntry =
schema.definitions &&
typeof schema.definitions === "object" &&
!Array.isArray(schema.definitions)
? (schema.definitions as Record<string, unknown>)
: undefined;
if (!defsEntry && !legacyDefsEntry) return defs;
const next = defs ? new Map(defs) : new Map<string, unknown>();
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<string> | 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<string, unknown>;
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<string>();
nextRefStack.add(refValue);
const cleaned = cleanSchemaForGeminiWithDefs(
resolved,
nextDefs,
nextRefStack,
);
if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) {
return cleaned;
}
const result: Record<string, unknown> = {
...(cleaned as Record<string, unknown>),
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) {
result[key] = obj[key];
}
}
return result;
}
const result: Record<string, unknown> = {};
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);
// Try to flatten anyOf of literals to a single enum BEFORE processing
// This handles Type.Union([Type.Literal("a"), Type.Literal("b")]) patterns
@@ -221,14 +331,28 @@ function cleanSchemaForGemini(schema: unknown): unknown {
}
}
// Try to flatten oneOf of literals similarly
if (hasOneOf) {
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) {
result[key] = obj[key];
}
}
return result;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// Skip unsupported schema features for Gemini:
// - patternProperties: not in OpenAPI 3.0 subset
// - const: convert to enum with single value instead
if (key === "patternProperties") {
// Gemini doesn't support patternProperties - skip it
// Skip keywords that Cloud Code Assist API doesn't support
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
continue;
}
@@ -238,8 +362,8 @@ function cleanSchemaForGemini(schema: unknown): unknown {
continue;
}
// Skip 'type' if we have 'anyOf' — Gemini doesn't allow both
if (key === "type" && hasAnyOf) {
// Skip 'type' if we have 'anyOf' or 'oneOf' — Gemini doesn't allow both
if (key === "type" && (hasAnyOf || hasOneOf)) {
continue;
}
@@ -247,27 +371,29 @@ function cleanSchemaForGemini(schema: unknown): unknown {
// Recursively clean nested properties
const props = value as Record<string, unknown>;
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));
} else if (
key === "additionalProperties" &&
value &&
typeof value === "object"
) {
// Recursively clean additionalProperties schema
cleaned[key] = cleanSchemaForGemini(value);
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else {
cleaned[key] = value;
}
@@ -276,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<string, unknown>);
return cleanSchemaForGeminiWithDefs(schema, defs, undefined);
}
function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}
function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
@@ -613,6 +751,10 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
export const __testing = {
cleanToolSchemaForGemini,
} as const;
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
messageProvider?: string;
+1 -1
View File
@@ -80,7 +80,7 @@ describe("Agent-specific sandbox config", () => {
expect(context).toBeDefined();
expect(context?.enabled).toBe(true);
}, 20000);
});
it("should allow agent-specific docker setupCommand overrides", async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
+1 -1
View File
@@ -186,7 +186,7 @@ async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
},
list: [
{
id: "main",
id: "dev",
default: true,
identity: {
name: DEV_IDENTITY_NAME,
+1 -1
View File
@@ -289,7 +289,7 @@ describe("doctor", () => {
"+15555550123",
]);
expect(written.routing).toBeUndefined();
}, 20000);
});
it("migrates legacy Clawdis services", async () => {
readConfigFileSnapshot.mockResolvedValue({
+1 -1
View File
@@ -170,7 +170,7 @@ export function applyMinimaxHostedProviderConfig(
cfg: ClawdbotConfig,
params?: { baseUrl?: string },
): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.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",
+2 -2
View File
@@ -1202,16 +1202,16 @@ export type AgentDefaultsConfig = {
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
/** Delivery target (last|whatsapp|telegram|discord|slack|signal|imessage|msteams|none). */
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "msteams"
| "signal"
| "imessage"
| "msteams"
| "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string;
+1 -1
View File
@@ -601,9 +601,9 @@ const HeartbeatSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
z.literal("msteams"),
z.literal("none"),
])
.optional(),
+4 -3
View File
@@ -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();
-42
View File
@@ -2,48 +2,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const shouldSanitizeConsoleOutput =
process.platform === "win32" && process.env.GITHUB_ACTIONS === "true";
if (shouldSanitizeConsoleOutput) {
const sanitize = (value: string) => {
let out = "";
for (const ch of value) {
const code = ch.charCodeAt(0);
if (code === 9 || code === 10 || code === 13) {
out += ch;
continue;
}
if (code >= 32 && code <= 126) {
out += ch;
continue;
}
out += "?";
}
return out;
};
const patchStream = (stream: NodeJS.WriteStream) => {
const originalWrite = stream.write.bind(stream);
stream.write = ((chunk: unknown, encoding?: unknown, cb?: unknown) => {
if (typeof chunk === "string") {
return originalWrite(sanitize(chunk), encoding as never, cb as never);
}
if (Buffer.isBuffer(chunk)) {
return originalWrite(
sanitize(chunk.toString("utf8")),
encoding as never,
cb as never,
);
}
return originalWrite(chunk as never, encoding as never, cb as never);
}) as typeof stream.write;
};
patchStream(process.stdout);
patchStream(process.stderr);
}
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
+1 -1
View File
@@ -14,7 +14,7 @@ function normalizeBase(input: string): string {
export default defineConfig(({ command }) => {
const envBase = process.env.CLAWDBOT_CONTROL_UI_BASE_PATH?.trim();
const base = envBase ? normalizeBase(envBase) : "./";
const base = envBase ? normalizeBase(envBase) : "/";
return {
base,
publicDir: path.resolve(here, "public"),