diff --git a/CHANGELOG.md b/CHANGELOG.md index ba49677e8b..59f5a036ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - 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 +- Agents: sanitize Cloud Code Assist tool call IDs and detect format/quota errors for failover. (#544) — thanks @jeffersonwarrior - Agents: simplify session tool schemas for Gemini compatibility. (#599) — thanks @mcinteerj - Agents: add `session_status` agent tool for `/status`-equivalent status (incl. usage/cost) + per-session model overrides. — thanks @steipete - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 5b58022555..c280c1b866 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -6,12 +6,14 @@ import { classifyFailoverReason, formatAssistantErrorText, isBillingErrorMessage, + isCloudCodeAssistFormatError, isContextOverflowError, isFailoverErrorMessage, isMessagingToolDuplicate, normalizeTextForComparison, sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, + sanitizeToolCallId, validateGeminiTurns, } from "./pi-embedded-helpers.js"; import { @@ -258,12 +260,34 @@ describe("classifyFailoverReason", () => { it("returns a stable reason", () => { expect(classifyFailoverReason("invalid api key")).toBe("auth"); expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit"); + expect(classifyFailoverReason("resource has been exhausted")).toBe( + "rate_limit", + ); expect(classifyFailoverReason("credit balance too low")).toBe("billing"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); + expect(classifyFailoverReason("string should match pattern")).toBeNull(); expect(classifyFailoverReason("bad request")).toBeNull(); }); }); +describe("isCloudCodeAssistFormatError", () => { + it("matches format errors", () => { + const samples = [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ]; + for (const sample of samples) { + expect(isCloudCodeAssistFormatError(sample)).toBe(true); + } + }); + + it("ignores unrelated errors", () => { + expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); + }); +}); + describe("formatAssistantErrorText", () => { const makeAssistantError = (errorMessage: string): AssistantMessage => ({ @@ -277,6 +301,20 @@ describe("formatAssistantErrorText", () => { }); }); +describe("sanitizeToolCallId", () => { + it("keeps valid tool call IDs", () => { + expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123"); + }); + + it("replaces invalid characters with underscores", () => { + expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456"); + }); + + it("returns default for empty IDs", () => { + expect(sanitizeToolCallId("")).toBe("default_tool_id"); + }); +}); + describe("sanitizeGoogleTurnOrdering", () => { it("prepends a synthetic user turn when history starts with assistant", () => { const input = [ diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index ac945f2cef..2e10029bfe 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -103,7 +103,17 @@ export async function sanitizeSessionMessagesImages( content as ContentBlock[], label, )) as unknown as typeof toolMsg.content; - out.push({ ...toolMsg, content: nextContent }); + const sanitizedToolCallId = toolMsg.toolCallId + ? sanitizeToolCallId(toolMsg.toolCallId) + : undefined; + const sanitizedMsg = { + ...toolMsg, + content: nextContent, + ...(sanitizedToolCallId && { + toolCallId: sanitizedToolCallId, + }), + }; + out.push(sanitizedMsg); continue; } @@ -133,14 +143,32 @@ export async function sanitizeSessionMessagesImages( if (rec.type !== "text" || typeof rec.text !== "string") return true; return rec.text.trim().length > 0; }); - const sanitizedContent = (await sanitizeContentBlocksImages( - filteredContent as unknown as ContentBlock[], + // 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" && + (block as { type?: unknown }).type === "functionCall" && + (block as { id?: unknown }).id + ) { + const functionBlock = block as { type: string; id: string }; + return { + ...functionBlock, + id: sanitizeToolCallId(functionBlock.id), + }; + } + return block; + }), + ); + const finalContent = (await sanitizeContentBlocksImages( + sanitizedContent as unknown as ContentBlock[], label, )) as unknown as typeof assistantMsg.content; - if (sanitizedContent.length === 0) { + if (finalContent.length === 0) { continue; } - out.push({ ...assistantMsg, content: sanitizedContent }); + out.push({ ...assistantMsg, content: finalContent }); continue; } } @@ -257,7 +285,10 @@ export function isRateLimitErrorMessage(raw: string): boolean { const value = raw.toLowerCase(); return ( /rate[_ ]limit|too many requests|429/.test(value) || - value.includes("exceeded your current quota") + value.includes("exceeded your current quota") || + value.includes("resource has been exhausted") || + value.includes("quota exceeded") || + value.includes("resource_exhausted") ); } @@ -307,11 +338,26 @@ export function isAuthErrorMessage(raw: string): boolean { value.includes("unauthorized") || value.includes("forbidden") || value.includes("access denied") || + value.includes("expired") || + value.includes("token has expired") || /\b401\b/.test(value) || /\b403\b/.test(value) ); } +export function isCloudCodeAssistFormatError(raw: string): boolean { + const value = raw.toLowerCase(); + if (!value) return false; + return ( + value.includes("invalid_request_error") || + value.includes("string should match pattern") || + value.includes("tool_use.id") || + value.includes("tool_use_id") || + value.includes("messages.1.content.1.tool_use.id") || + value.includes("invalid request format") + ); +} + export function isAuthAssistantError( msg: AssistantMessage | undefined, ): boolean { @@ -482,6 +528,31 @@ export function normalizeTextForComparison(text: string): string { * Uses substring matching to handle LLM elaboration (e.g., wrapping in quotes, * adding context, or slight rephrasing that includes the original). */ +// ── Tool Call ID Sanitization (Google Cloud Code Assist) ─────────────────────── +// Google Cloud Code Assist rejects tool call IDs that contain invalid characters. +// OpenAI Codex generates IDs like "call_abc123|item_456" with pipe characters, +// but Google requires IDs matching ^[a-zA-Z0-9_-]+$ pattern. +// This function sanitizes tool call IDs by replacing invalid characters with underscores. + +export function sanitizeToolCallId(id: string): string { + if (!id || typeof id !== "string") return "default_tool_id"; + + const cloudCodeAssistPatternReplacement = id.replace(/[^a-zA-Z0-9_-]/g, "_"); + const trimmedInvalidStartChars = cloudCodeAssistPatternReplacement.replace( + /^[^a-zA-Z0-9_-]+/, + "", + ); + + return trimmedInvalidStartChars.length > 0 + ? trimmedInvalidStartChars + : "sanitized_tool_id"; +} + +export function isValidCloudCodeAssistToolId(id: string): boolean { + if (!id || typeof id !== "string") return false; + return /^[a-zA-Z0-9_-]+$/.test(id); +} + export function isMessagingToolDuplicate( text: string, sentTexts: string[], diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 0a23ae9d41..444b084de9 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -67,6 +67,7 @@ import { ensureSessionHeader, formatAssistantErrorText, isAuthAssistantError, + isCloudCodeAssistFormatError, isContextOverflowError, isFailoverAssistantError, isFailoverErrorMessage, @@ -1527,9 +1528,14 @@ export async function runEmbeddedPiAgent(params: { const assistantFailoverReason = classifyFailoverReason( lastAssistant?.errorMessage ?? "", ); + const cloudCodeAssistFormatError = lastAssistant?.errorMessage + ? isCloudCodeAssistFormatError(lastAssistant.errorMessage) + : false; // Treat timeout as potential rate limit (Antigravity hangs on rate limit) - const shouldRotate = (!aborted && failoverFailure) || timedOut; + const shouldRotate = + (!aborted && (failoverFailure || cloudCodeAssistFormatError)) || + timedOut; if (shouldRotate) { // Mark current profile for cooldown before rotating @@ -1550,6 +1556,11 @@ export async function runEmbeddedPiAgent(params: { `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`, ); } + if (cloudCodeAssistFormatError) { + log.warn( + `Profile ${lastProfileId} hit Cloud Code Assist format error. Tool calls will be sanitized on retry.`, + ); + } } const rotated = await advanceAuthProfile(); if (rotated) { diff --git a/src/config/paths.ts b/src/config/paths.ts index 7c095e977c..054fabc3fe 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -44,6 +44,9 @@ function resolveUserPath(input: string): string { export const STATE_DIR_CLAWDBOT = resolveStateDir(); +// Legacy exports for backward compatibility during Clawdis → Clawdbot rebrand +export const STATE_DIR_CLAWDIS = STATE_DIR_CLAWDBOT; + /** * Config file path (JSON5). * Can be overridden via CLAWDBOT_CONFIG_PATH environment variable. @@ -60,6 +63,9 @@ export function resolveConfigPath( export const CONFIG_PATH_CLAWDBOT = resolveConfigPath(); +// Legacy exports for backward compatibility during Clawdis → Clawdbot rebrand +export const CONFIG_PATH_CLAWDIS = CONFIG_PATH_CLAWDBOT; + export const DEFAULT_GATEWAY_PORT = 18789; const OAUTH_FILENAME = "oauth.json";