From 29c5d9461a18707af832cebca9d18d8f69a0176f Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 7 Feb 2026 17:24:58 +0300 Subject: [PATCH] fix: Claude model compatibility for Antigravity proxy - Add textPartModels config: configurable model patterns that use text parts instead of synthetic tool parts for context injection, preventing Claude VALIDATED mode tool_use/tool_result pairing errors (default: ['antigravity-claude']) - Fix pruneFullTool: replace edit/write tool part content with placeholders instead of removing parts entirely, preserving tool pairing integrity required by Claude's VALIDATED mode - Fix distill/prune Invalid IDs: rebuild toolIdList after syncToolCache in executePruneOperation to prevent stale/empty ID list after session reinitialization --- README.md | 5 +++++ lib/config.ts | 15 +++++++++++++++ lib/messages/inject.ts | 10 ++++++++-- lib/messages/prune.ts | 36 ++++++++++++++++++------------------ lib/tools/prune-shared.ts | 8 +++++--- 5 files changed, 51 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2357e597..ee9494a8 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ DCP uses its own config file: > "contextLimit": 100000, > // Additional tools to protect from pruning > "protectedTools": [], +> // Model name patterns that should use text parts instead of tool parts +> // for DCP context injection. Prevents 400 errors with providers that use +> // strict tool call/result pairing (e.g., Antigravity Claude models). +> // Uses case-insensitive substring matching against the model ID. +> "textPartModels": ["antigravity-claude"], > }, > // Distills key findings into preserved knowledge before removing raw content > "distill": { diff --git a/lib/config.ts b/lib/config.ts index 0ac4a1a0..9fd9275b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -28,6 +28,7 @@ export interface ToolSettings { nudgeFrequency: number protectedTools: string[] contextLimit: number | `${number}%` + textPartModels: string[] } export interface Tools { @@ -107,6 +108,7 @@ export const VALID_CONFIG_KEYS = new Set([ "tools.settings.nudgeFrequency", "tools.settings.protectedTools", "tools.settings.contextLimit", + "tools.settings.textPartModels", "tools.distill", "tools.distill.permission", "tools.distill.showDistillation", @@ -303,6 +305,16 @@ function validateConfigTypes(config: Record): ValidationError[] { }) } } + if ( + tools.settings.textPartModels !== undefined && + !Array.isArray(tools.settings.textPartModels) + ) { + errors.push({ + key: "tools.settings.textPartModels", + expected: "string[]", + actual: typeof tools.settings.textPartModels, + }) + } } if (tools.distill) { if (tools.distill.permission !== undefined) { @@ -505,6 +517,7 @@ const defaultConfig: PluginConfig = { nudgeFrequency: 10, protectedTools: [...DEFAULT_PROTECTED_TOOLS], contextLimit: 100000, + textPartModels: ["antigravity-claude"], }, distill: { permission: "allow", @@ -684,6 +697,7 @@ function mergeTools( ]), ], contextLimit: override.settings?.contextLimit ?? base.settings.contextLimit, + textPartModels: override.settings?.textPartModels ?? base.settings.textPartModels, }, distill: { permission: override.distill?.permission ?? base.distill.permission, @@ -724,6 +738,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { settings: { ...config.tools.settings, protectedTools: [...config.tools.settings.protectedTools], + textPartModels: [...config.tools.settings.textPartModels], }, distill: { ...config.tools.distill }, compress: { ...config.tools.compress }, diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 3f0c60b1..540e5c8f 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -253,13 +253,19 @@ export const insertPruneToolContext = ( // When following a user message, append a synthetic text part since models like Claude // expect assistant turns to start with reasoning parts which cannot be easily faked. + // For models listed in textPartModels, always use text parts to avoid tool pairing issues. // For all other cases, append a synthetic tool part to the last message which works // across all models without disrupting their behavior. - if (lastNonIgnoredMessage.info.role === "user") { + const modelID = userInfo.model?.modelID || "" + const lowerModelID = modelID.toLowerCase() + const useTextPart = config.tools.settings.textPartModels.some( + (pattern) => lowerModelID.includes(pattern.toLowerCase()), + ) + + if (lastNonIgnoredMessage.info.role === "user" || useTextPart) { const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent) lastNonIgnoredMessage.parts.push(textPart) } else { - const modelID = userInfo.model?.modelID || "" const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID) lastNonIgnoredMessage.parts.push(toolPart) } diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts index 09169700..9ec2ea3a 100644 --- a/lib/messages/prune.ts +++ b/lib/messages/prune.ts @@ -24,7 +24,7 @@ export const prune = ( } const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[]): void => { - const messagesToRemove: string[] = [] + let prunedCount = 0 for (const msg of messages) { if (isMessageCompacted(state, msg)) { @@ -32,7 +32,6 @@ const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[ } const parts = Array.isArray(msg.parts) ? msg.parts : [] - const partsToRemove: string[] = [] for (const part of parts) { if (part.type !== "tool") { @@ -45,26 +44,27 @@ const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[ continue } - partsToRemove.push(part.callID) - } - - if (partsToRemove.length === 0) { - continue - } - - msg.parts = parts.filter( - (part) => part.type !== "tool" || !partsToRemove.includes(part.callID), - ) + // Instead of removing the tool part entirely (which breaks Claude's + // tool_use/tool_result pairing in VALIDATED mode), replace the content + // with a placeholder. This preserves the tool part structure so the + // model-level conversion still generates matched functionCall/functionResponse pairs. + if (part.state?.input && typeof part.state.input === "object") { + for (const key of Object.keys(part.state.input)) { + if (typeof part.state.input[key] === "string") { + part.state.input[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT + } + } + } + if (part.state?.status === "completed") { + part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT + } - if (msg.parts.length === 0) { - messagesToRemove.push(msg.info.id) + prunedCount++ } } - if (messagesToRemove.length > 0) { - const result = messages.filter((msg) => !messagesToRemove.includes(msg.info.id)) - messages.length = 0 - messages.push(...result) + if (prunedCount > 0) { + logger.info(`Pruned content for ${prunedCount} edit/write tool parts`) } } diff --git a/lib/tools/prune-shared.ts b/lib/tools/prune-shared.ts index c1253f76..e698777f 100644 --- a/lib/tools/prune-shared.ts +++ b/lib/tools/prune-shared.ts @@ -9,6 +9,7 @@ import { ensureSessionInitialized } from "../state" import { saveSessionState } from "../state/persistence" import { calculateTokensSaved, getCurrentParams } from "../strategies/utils" import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns" +import { buildToolIdList } from "../messages/utils" // Shared logic for executing prune operations. export async function executePruneOperation( @@ -47,10 +48,11 @@ export async function executePruneOperation( }) const messages: WithParts[] = messagesResponse.data || messagesResponse - await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) - await syncToolCache(state, config, logger, messages) + await ensureSessionInitialized(ctx.client, state, sessionId, logger, messages) + await syncToolCache(state, config, logger, messages) + buildToolIdList(state, messages, logger) - const currentParams = getCurrentParams(state, messages, logger) + const currentParams = getCurrentParams(state, messages, logger) const toolIdList = state.toolIdList