fix(antigravity): avoid visible signatureless tool history

This commit is contained in:
dhaern
2026-05-30 01:26:23 +00:00
parent d39d2719bb
commit 8eff0bda01
2 changed files with 128 additions and 32 deletions

View File

@@ -108,7 +108,7 @@ type GeminiToolNameOptions = {
stripNamespace?: boolean;
functionResponseShape?: "result" | "output";
signatureNamespace?: string | null;
signaturelessToolCallMode?: "native" | "text";
signaturelessToolCallMode?: "native" | "text" | "context";
};
type OpenAIToolCallLike = {
@@ -209,6 +209,24 @@ function buildInertHistoricalToolResponseText(name: string, response: unknown):
].join("\n");
}
function escapeHistoricalContextAttribute(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function buildHistoricalToolResultContext(name: string, response: unknown): string {
const source = escapeHistoricalContextAttribute(name || "unknown");
const result = typeof response === "string" ? response : stringifyHistoricalToolArguments(response);
return [
`<previous_tool_result_context source="${source}">`,
result,
"</previous_tool_result_context>",
].join("\n");
}
// Core: Convert OpenAI request to Gemini format (base for all variants)
function openaiToGeminiBase(
model: string,
@@ -367,8 +385,10 @@ function openaiToGeminiBase(
}
let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature);
const stringifySignaturelessToolCalls =
toolNameOptions.signaturelessToolCallMode === "text";
const signaturelessToolCallMode = toolNameOptions.signaturelessToolCallMode;
const stringifySignaturelessToolCalls = signaturelessToolCallMode === "text";
const contextualizeSignaturelessToolResponses =
signaturelessToolCallMode === "text" || signaturelessToolCallMode === "context";
for (const tc of toolCalls) {
if (tc.type !== "function") continue;
@@ -378,6 +398,9 @@ function openaiToGeminiBase(
if (!fn) continue;
const signatureForToolCall = resolvedSignatures.get(id);
if (!signatureForToolCall && contextualizeSignaturelessToolResponses) {
if (!toolCallIds.includes(id)) toolCallIds.push(id);
}
if (!signatureForToolCall && stringifySignaturelessToolCalls) {
const args = fn.arguments || "{}";
parts.push({
@@ -385,6 +408,9 @@ function openaiToGeminiBase(
});
continue;
}
if (!signatureForToolCall && signaturelessToolCallMode === "context") {
continue;
}
const args = tryParseJSON(fn.arguments || "{}");
const embeddedThoughtSignature = shouldUseEmbeddedSignature
@@ -405,7 +431,9 @@ function openaiToGeminiBase(
},
});
toolCallIds.push(id);
if (!contextualizeSignaturelessToolResponses || signatureForToolCall) {
toolCallIds.push(id);
}
}
if (parts.length > 0) {
@@ -414,7 +442,7 @@ function openaiToGeminiBase(
// Check if there are actual tool responses in the next messages
const hasSignaturelessTextResponses =
stringifySignaturelessToolCalls &&
contextualizeSignaturelessToolResponses &&
toolCalls.some((tc) => {
const id = tc.id as string;
return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id];
@@ -426,6 +454,7 @@ function openaiToGeminiBase(
const toolParts: GeminiPart[] = [];
for (const fid of toolCallIds) {
if (!toolResponses[fid]) continue;
if (contextualizeSignaturelessToolResponses && !resolvedSignatures.has(fid)) continue;
let name = tcID2Name[fid];
if (!name) {
@@ -458,10 +487,13 @@ function openaiToGeminiBase(
});
}
if (stringifySignaturelessToolCalls) {
if (contextualizeSignaturelessToolResponses) {
// Signature-less historical tool responses are represented as text
// so strict Gemini/Antigravity endpoints don't reject them as native
// functionResponse parts missing a matching thoughtSignature.
// In context mode the matching historical functionCall is omitted,
// avoiding pseudo tool-call records that Gemini Flash can repeat as
// the visible final answer.
for (const tc of toolCalls) {
const id = tc.id as string;
if (tc.type !== "function" || !id) continue;
@@ -470,7 +502,10 @@ function openaiToGeminiBase(
const name = tcID2Name[id] || fn?.name || "unknown";
const resp = toolResponses[id];
toolParts.push({
text: buildInertHistoricalToolResponseText(name, resp),
text:
signaturelessToolCallMode === "text"
? buildInertHistoricalToolResponseText(name, resp)
: buildHistoricalToolResultContext(name, resp),
});
}
}
@@ -551,7 +586,7 @@ export function openaiToGeminiRequest(
stream: boolean,
credentials: Record<string, unknown> | null = null,
options: {
signaturelessToolCallMode?: "native" | "text";
signaturelessToolCallMode?: "native" | "text" | "context";
} = {}
) {
// Thread the signature namespace so a thinking model's thoughtSignature (cached on the
@@ -576,7 +611,7 @@ export function openaiToGeminiCLIRequest(
options: {
functionResponseShape?: "result" | "output";
signatureNamespace?: string | null;
signaturelessToolCallMode?: "native" | "text";
signaturelessToolCallMode?: "native" | "text" | "context";
} = {}
) {
return openaiToGeminiBase(model, body, stream, {
@@ -697,7 +732,7 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu
: null;
const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, {
signatureNamespace,
signaturelessToolCallMode: isThinkingGemini ? "text" : "native",
signaturelessToolCallMode: isThinkingGemini ? "context" : "native",
});
if (isClaude) {

View File

@@ -14,9 +14,16 @@ const {
tryParseJSON,
} = await import("../../open-sse/translator/helpers/geminiHelper.ts");
const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts");
const { clearGeminiThoughtSignatures } = await import(
"../../open-sse/services/geminiThoughtSignatureStore.ts"
);
type UnknownRecord = Record<string, unknown>;
test.beforeEach(() => {
clearGeminiThoughtSignatures();
});
function getFunctionCall(part: unknown) {
assert.ok(part && typeof part === "object", "expected Gemini functionCall part");
const functionCall = (part as UnknownRecord).functionCall;
@@ -629,7 +636,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", ()
});
});
test("OpenAI -> Antigravity Gemini preserves signature-less historical tool calls as inert text", () => {
test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls and keeps response context", () => {
const result = openaiToAntigravityRequest(
"gemini-3.5-flash-low",
{
@@ -666,26 +673,23 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call
);
const modelTurn = result.request.contents.find((content) => content.role === "model");
assert.ok(modelTurn, "expected a model turn");
assert.ok(
modelTurn.parts.some(
(part) =>
typeof part.text === "string" &&
part.text.includes("Historical tool-call record only") &&
part.text.includes("Tool name: default_api:todowrite_ide") &&
part.text.includes('Tool arguments JSON: {"todos":[]}')
),
"expected signature-less tool call to be preserved as inert text"
!modelTurn ||
!modelTurn.parts.some(
(part) =>
typeof part.text === "string" && part.text.includes("Historical tool-call record only")
),
"signature-less historical call must not be emitted as visible historical text"
);
assert.equal(
modelTurn.parts.some(
modelTurn?.parts.some(
(part) => typeof part.text === "string" && part.text.includes("[Tool call:")
),
) ?? false,
false,
"signature-less historical call must not use executable textual tool-call markers"
);
assert.equal(
modelTurn.parts.some((part) => part.functionCall),
modelTurn?.parts.some((part) => part.functionCall) ?? false,
false,
"signature-less historical call must not be emitted as native functionCall"
);
@@ -696,12 +700,11 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call
content.parts.some(
(part) =>
typeof part.text === "string" &&
part.text.includes("Historical tool-response record only") &&
part.text.includes("Tool name: default_api:todowrite_ide") &&
part.text.includes("Tool result: []")
part.text.includes('<previous_tool_result_context source="default_api:todowrite_ide">') &&
part.text.includes("[]")
)
);
assert.ok(toolTurn, "expected signature-less tool response to be preserved as inert text");
assert.ok(toolTurn, "expected signature-less tool response to be preserved as safe context");
assert.equal(
toolTurn.parts.some(
(part) => typeof part.text === "string" && part.text.includes("[Tool response:")
@@ -716,7 +719,7 @@ test("OpenAI -> Antigravity Gemini preserves signature-less historical tool call
);
});
test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as text", () => {
test("OpenAI -> Antigravity preserves multiple signature-less historical tool responses as context", () => {
const result = openaiToAntigravityRequest(
"gemini-3.5-flash-low",
{
@@ -755,15 +758,27 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re
);
const text = JSON.stringify(result.request.contents);
assert.ok(text.includes("Historical tool-call record only"), "expected signature-less calls as text");
assert.ok(text.includes("Tool name: terminal"), "expected signature-less calls as text");
assert.equal(
text.includes("Historical tool-call record only"),
false,
"signature-less calls must not be emitted as visible historical text"
);
assert.equal(
text.includes("Tool arguments JSON"),
false,
"signature-less call arguments must not be emitted as visible text"
);
assert.ok(
text.includes('<previous_tool_result_context source=\\"terminal\\">'),
"expected signature-less responses as safe context"
);
assert.ok(
text.includes("data/db.json: No such file"),
"expected first signature-less tool response as text"
"expected first signature-less tool response as context"
);
assert.ok(
text.includes("storage.sqlite"),
"expected second signature-less tool response as text"
"expected second signature-less tool response as context"
);
assert.equal(
result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)),
@@ -772,6 +787,52 @@ test("OpenAI -> Antigravity preserves multiple signature-less historical tool re
);
});
test("OpenAI -> Antigravity preserves signed Gemini tool calls in native form", async () => {
const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } =
await import("../../open-sse/services/geminiThoughtSignatureStore.ts");
const ns = "conn-antigravity-signed";
const toolId = "call_signed_history";
storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_AG_SIGNED_XYZ");
const result = openaiToAntigravityRequest(
"gemini-3.5-flash-low",
{
messages: [
{ role: "user", content: "Read status" },
{
role: "assistant",
tool_calls: [
{
id: toolId,
type: "function",
function: { name: "read_file", arguments: '{"path":"status.txt"}' },
},
],
},
{ role: "tool", tool_call_id: toolId, content: "ready" },
],
},
false,
{ projectId: "proj-antigravity-gemini", _signatureNamespace: ns } as any
);
const text = JSON.stringify(result.request.contents);
assert.ok(text.includes("SIG_AG_SIGNED_XYZ"), "cached signature must be preserved");
assert.equal(
text.includes("previous_tool_result_context"),
false,
"signed tool calls must stay native, not context text"
);
assert.ok(
result.request.contents.some((content) => content.parts.some((part) => part.functionCall)),
"signed historical call must be emitted as native functionCall"
);
assert.ok(
result.request.contents.some((content) => content.parts.some((part) => part.functionResponse)),
"signed historical response must be emitted as native functionResponse"
);
});
test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => {
const result = openaiToAntigravityRequest(
"claude-3-7-sonnet",