fix(stream): suppress unknown textual tool calls

This commit is contained in:
Dmitry Kuznetsov
2026-05-25 21:50:24 +03:00
parent bb18a049e9
commit 68cb9f7992
2 changed files with 80 additions and 5 deletions

View File

@@ -246,12 +246,34 @@ function containsMalformedTextualToolCall(text: unknown): boolean {
return text.replace(/[\u200B-\u200D\uFEFF]/g, "").includes("[Tool call:");
}
function extractAllowedToolNames(body: unknown): Set<string> | null {
const record = asRecord(body);
const tools = record.tools;
if (!Array.isArray(tools)) return null;
const names = new Set<string>();
for (const tool of tools) {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) continue;
const item = tool as JsonRecord;
const directName = typeof item.name === "string" ? item.name.trim() : "";
const fn =
item.function && typeof item.function === "object" && !Array.isArray(item.function)
? (item.function as JsonRecord)
: null;
const functionName = typeof fn?.name === "string" ? fn.name.trim() : "";
const name = functionName || directName;
if (name) names.add(name);
}
return names.size > 0 ? names : null;
}
function collectPassthroughTextualToolCall(
text: string,
toolCalls: Map<string, ToolCall>
toolCalls: Map<string, ToolCall>,
allowedToolNames?: Set<string> | null
): ToolCall | null {
const parsed = parseTextualToolCallFromContent(text);
if (!parsed) return null;
if (allowedToolNames?.size && !allowedToolNames.has(parsed.name)) return null;
const key = `textual:${toolCalls.size}`;
const toolCall: ToolCall = {
id: `call_${Date.now()}_${toolCalls.size}`,
@@ -669,6 +691,7 @@ export function createSSEStream(options: StreamOptions = {}) {
/** Passthrough: accumulate tool_calls deltas for call log responseBody */
const passthroughToolCalls = new Map<string, ToolCall>();
let passthroughToolCallSeq = 0;
const allowedToolNames = extractAllowedToolNames(body);
let skipPassthroughEvent = false;
// State for translate mode (accumulatedContent for call log response body)
@@ -1399,12 +1422,13 @@ export function createSSEStream(options: StreamOptions = {}) {
if (parsedCandidate?.kind === "complete") {
const collectedToolCall = collectPassthroughTextualToolCall(
bufferedCandidate,
passthroughToolCalls
passthroughToolCalls,
allowedToolNames
);
if (collectedToolCall) {
delta.tool_calls = [toStreamingToolCallDelta(collectedToolCall)];
passthroughHasToolCalls = true;
}
passthroughHasToolCalls = true;
textualToolCallConverted = true;
passthroughBufferedTextualToolCallContent = "";
delete delta.content;
@@ -1760,14 +1784,18 @@ export function createSSEStream(options: StreamOptions = {}) {
if (
collectPassthroughTextualToolCall(
finalBufferedTextualToolCall,
passthroughToolCalls
passthroughToolCalls,
allowedToolNames
)
) {
passthroughHasToolCalls = true;
}
passthroughBufferedTextualToolCallContent = "";
}
if (content && collectPassthroughTextualToolCall(content, passthroughToolCalls)) {
if (
content &&
collectPassthroughTextualToolCall(content, passthroughToolCalls, allowedToolNames)
) {
passthroughHasToolCalls = true;
content = "";
} else if (containsMalformedTextualToolCall(content)) {

View File

@@ -232,6 +232,53 @@ test("createSSEStream passthrough converts split textual tool-call content at co
assert.doesNotMatch(text, /\[Tool call: terminal\]/);
});
test("createSSEStream passthrough suppresses textual tool calls for unknown tools", async () => {
let onCompletePayload = null;
const toolText = `[Tool call: search_files_ide]
Arguments: {"path":"/opt/OmniRoute/src","target":"files"}`;
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl_unknown_textual_tool",
object: "chat.completion.chunk",
created: 1,
model: "antigravity/gemini-3.5-flash-low",
choices: [{ index: 0, delta: { role: "assistant", content: toolText } }],
})}\n\n`,
`data: ${JSON.stringify({
id: "chatcmpl_unknown_textual_tool",
object: "chat.completion.chunk",
created: 1,
model: "antigravity/gemini-3.5-flash-low",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI,
provider: "antigravity",
model: "antigravity/gemini-3.5-flash-low",
body: {
messages: [{ role: "user", content: "inspect files" }],
tools: [
{ type: "function", function: { name: "search_files", parameters: { type: "object" } } },
],
},
onComplete(payload) {
onCompletePayload = payload;
},
}
);
const choice = onCompletePayload.responseBody.choices[0];
assert.equal(choice.finish_reason, "stop");
assert.equal(choice.message.content, null);
assert.equal(choice.message.tool_calls, undefined);
assert.doesNotMatch(text, /search_files_ide/);
assert.doesNotMatch(JSON.stringify(onCompletePayload.responseBody), /search_files_ide/);
});
test("createSSEStream passthrough suppresses malformed textual tool-call content", async () => {
let onCompletePayload = null;
const malformedToolText = `(empty)[Tool call: terminal]\nArguments: {"command":"sqlite3 /opt/O\u200dmniRoute/data/o\u200dmniroute.`;