mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix(antigravity): avoid visible signatureless tool history
This commit is contained in:
@@ -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("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user