Merge pull request #665 from sebslight/fix/cloud-code-assist-schema-and-tool-ids

fix(agents): harden Cloud Code Assist compatibility
This commit is contained in:
Peter Steinberger
2026-01-10 17:07:58 +00:00
committed by GitHub
7 changed files with 119 additions and 25 deletions

View File

@@ -363,6 +363,37 @@ describe("sanitizeSessionMessagesImages", () => {
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
});
it("sanitizes tool ids for assistant blocks and tool results", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
{
type: "toolCall",
id: "call_abc|item:456",
name: "bash",
arguments: {},
},
],
},
{
role: "toolResult",
toolUseId: "call_abc|item:123",
content: [{ type: "text", text: "ok" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
const assistant = out[0] as { content?: Array<{ id?: string }> };
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
const toolResult = out[1] as { toolUseId?: string };
expect(toolResult.toolUseId).toBe("call_abc_item_123");
});
it("filters whitespace-only assistant text blocks", async () => {
const input = [
{

View File

@@ -106,12 +106,20 @@ export async function sanitizeSessionMessagesImages(
const sanitizedToolCallId = toolMsg.toolCallId
? sanitizeToolCallId(toolMsg.toolCallId)
: undefined;
const toolUseId = (toolMsg as { toolUseId?: unknown }).toolUseId;
const sanitizedToolUseId =
typeof toolUseId === "string" && toolUseId
? sanitizeToolCallId(toolUseId)
: undefined;
const sanitizedMsg = {
...toolMsg,
content: nextContent,
...(sanitizedToolCallId && {
toolCallId: sanitizedToolCallId,
}),
...(sanitizedToolUseId && {
toolUseId: sanitizedToolUseId,
}),
};
out.push(sanitizedMsg);
continue;
@@ -146,18 +154,24 @@ export async function sanitizeSessionMessagesImages(
// Also sanitize tool call IDs in assistant messages (function call blocks)
const sanitizedContent = await Promise.all(
filteredContent.map(async (block) => {
if (!block || typeof block !== "object") return block;
const type = (block as { type?: unknown }).type;
const id = (block as { id?: unknown }).id;
if (typeof id !== "string" || !id) return block;
// Cloud Code Assist tool blocks require ids matching ^[a-zA-Z0-9_-]+$.
if (
block &&
typeof block === "object" &&
(block as { type?: unknown }).type === "functionCall" &&
(block as { id?: unknown }).id
type === "functionCall" ||
type === "toolUse" ||
type === "toolCall"
) {
const functionBlock = block as { type: string; id: string };
return {
...functionBlock,
id: sanitizeToolCallId(functionBlock.id),
...(block as unknown as Record<string, unknown>),
id: sanitizeToolCallId(id),
};
}
return block;
}),
);

View File

@@ -384,6 +384,19 @@ describe("createClawdbotCodingTools", () => {
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (
@@ -398,9 +411,24 @@ describe("createClawdbotCodingTools", () => {
});
return found;
}
for (const [key, value] of Object.entries(
schema as Record<string, unknown>,
)) {
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(
...findUnsupportedKeywords(value, `${path}.properties.${key}`),
);
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") continue;
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}

View File

@@ -10,6 +10,23 @@ const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"$ref",
"$defs",
"definitions",
// Non-standard (OpenAPI) keyword; Claude validators reject it.
"examples",
// Cloud Code Assist appears to validate tool schemas more strictly/quirkily than
// draft 2020-12 in practice; these constraints frequently trigger 400s.
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
// Check if an anyOf/oneOf array contains only literal values that can be flattened.
@@ -134,14 +151,14 @@ function cleanSchemaForGeminiWithDefs(
const result: Record<string, unknown> = {
...(cleaned as Record<string, unknown>),
};
for (const key of ["description", "title", "default", "examples"]) {
for (const key of ["description", "title", "default"]) {
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"]) {
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
@@ -157,7 +174,7 @@ function cleanSchemaForGeminiWithDefs(
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
@@ -171,7 +188,7 @@ function cleanSchemaForGeminiWithDefs(
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
for (const key of ["description", "title", "default"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;