Skip to content

Commit 7a0f446

Browse files
committed
feat: refactor tool response handling and cleanup logic for improved message processing
1 parent 0d58863 commit 7a0f446

1 file changed

Lines changed: 112 additions & 87 deletions

File tree

src/routes/generate-content/translation.ts

Lines changed: 112 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
type ContentPart,
1515
type Message,
1616
type Tool,
17-
type ToolCall,
1817
} from "~/services/copilot/create-chat-completions"
1918

2019
import {
@@ -86,6 +85,34 @@ export function translateGeminiToOpenAI(
8685
return result
8786
}
8887

88+
// Helper function to match function name to tool call ID and emit tool response
89+
function matchAndEmitToolResponse(options: {
90+
functionName: string
91+
functionResponse: unknown
92+
pendingToolCalls: Map<string, string>
93+
messages: Array<Message>
94+
}): void {
95+
const { functionName, functionResponse, pendingToolCalls, messages } = options
96+
97+
// Find tool call ID by searching through the map
98+
let matchedToolCallId: string | undefined
99+
for (const [toolCallId, mappedFunctionName] of pendingToolCalls.entries()) {
100+
if (mappedFunctionName === functionName) {
101+
matchedToolCallId = toolCallId
102+
break
103+
}
104+
}
105+
106+
if (matchedToolCallId) {
107+
messages.push({
108+
role: "tool",
109+
tool_call_id: matchedToolCallId,
110+
content: JSON.stringify(functionResponse),
111+
})
112+
pendingToolCalls.delete(matchedToolCallId)
113+
}
114+
}
115+
89116
// Helper function to process function response arrays
90117
function processFunctionResponseArray(
91118
responseArray: Array<{
@@ -96,46 +123,14 @@ function processFunctionResponseArray(
96123
): void {
97124
for (const responseItem of responseArray) {
98125
if ("functionResponse" in responseItem) {
99-
const functionName = responseItem.functionResponse.name
100-
// Find tool call ID by searching through the map
101-
let matchedToolCallId: string | undefined
102-
for (const [
103-
toolCallId,
104-
mappedFunctionName,
105-
] of pendingToolCalls.entries()) {
106-
if (mappedFunctionName === functionName) {
107-
matchedToolCallId = toolCallId
108-
break
109-
}
110-
}
111-
if (matchedToolCallId) {
112-
messages.push({
113-
role: "tool",
114-
tool_call_id: matchedToolCallId,
115-
content: JSON.stringify(responseItem.functionResponse.response),
116-
})
117-
pendingToolCalls.delete(matchedToolCallId)
118-
}
119-
}
120-
}
121-
}
122-
123-
// Helper function to check if tool calls have corresponding tool responses
124-
function hasCorrespondingToolResponses(
125-
messages: Array<Message>,
126-
toolCalls: Array<ToolCall>,
127-
): boolean {
128-
const toolCallIds = new Set(toolCalls.map((call) => call.id))
129-
130-
// Look for tool messages that respond to these tool calls
131-
for (const message of messages) {
132-
if (message.role === "tool" && message.tool_call_id) {
133-
toolCallIds.delete(message.tool_call_id)
126+
matchAndEmitToolResponse({
127+
functionName: responseItem.functionResponse.name,
128+
functionResponse: responseItem.functionResponse.response,
129+
pendingToolCalls,
130+
messages,
131+
})
134132
}
135133
}
136-
137-
// If any tool call ID remains, it means there's no corresponding response
138-
return toolCallIds.size === 0
139134
}
140135

141136
// Helper function to process function responses in content
@@ -145,23 +140,12 @@ function processFunctionResponses(
145140
messages: Array<Message>,
146141
): void {
147142
for (const funcResponse of functionResponses) {
148-
const functionName = funcResponse.functionResponse.name
149-
// Find tool call ID by searching through the map
150-
let matchedToolCallId: string | undefined
151-
for (const [toolCallId, mappedFunctionName] of pendingToolCalls.entries()) {
152-
if (mappedFunctionName === functionName) {
153-
matchedToolCallId = toolCallId
154-
break
155-
}
156-
}
157-
if (matchedToolCallId) {
158-
messages.push({
159-
role: "tool",
160-
tool_call_id: matchedToolCallId,
161-
content: JSON.stringify(funcResponse.functionResponse.response),
162-
})
163-
pendingToolCalls.delete(matchedToolCallId)
164-
}
143+
matchAndEmitToolResponse({
144+
functionName: funcResponse.functionResponse.name,
145+
functionResponse: funcResponse.functionResponse.response,
146+
pendingToolCalls,
147+
messages,
148+
})
165149
}
166150
}
167151

@@ -237,29 +221,6 @@ function canMergeMessages(
237221
)
238222
}
239223

240-
// Helper function to check if message should be skipped
241-
function shouldSkipMessage(
242-
message: Message,
243-
messages: Array<Message>,
244-
seenToolCallIds: Set<string>,
245-
): boolean {
246-
// Skip incomplete assistant messages with tool calls that have no responses
247-
if (
248-
message.role === "assistant"
249-
&& message.tool_calls
250-
&& !hasCorrespondingToolResponses(messages, message.tool_calls)
251-
) {
252-
return true
253-
}
254-
255-
// Skip duplicate tool responses
256-
if (isDuplicateToolResponse(message, seenToolCallIds)) {
257-
return true
258-
}
259-
260-
return false
261-
}
262-
263224
// Helper function to process and add message to cleaned array
264225
function processAndAddMessage(
265226
message: Message,
@@ -295,8 +256,28 @@ function cleanupMessages(messages: Array<Message>): Array<Message> {
295256
const cleanedMessages: Array<Message> = []
296257
const seenToolCallIds = new Set<string>()
297258

259+
// Pre-build a set of all tool_call_ids that have tool responses (O(n))
260+
const toolCallIdsWithResponses = new Set<string>()
298261
for (const message of messages) {
299-
if (shouldSkipMessage(message, messages, seenToolCallIds)) {
262+
if (message.role === "tool" && message.tool_call_id) {
263+
toolCallIdsWithResponses.add(message.tool_call_id)
264+
}
265+
}
266+
267+
for (const message of messages) {
268+
// Skip incomplete assistant messages with tool calls that have no responses
269+
if (message.role === "assistant" && message.tool_calls) {
270+
// Check if all tool calls have responses
271+
const hasAllResponses = message.tool_calls.every((call) =>
272+
toolCallIdsWithResponses.has(call.id),
273+
)
274+
if (!hasAllResponses) {
275+
continue
276+
}
277+
}
278+
279+
// Skip duplicate tool responses
280+
if (isDuplicateToolResponse(message, seenToolCallIds)) {
300281
continue
301282
}
302283

@@ -306,6 +287,38 @@ function cleanupMessages(messages: Array<Message>): Array<Message> {
306287
return cleanedMessages
307288
}
308289

290+
/**
291+
* Translates Gemini conversation contents to OpenAI message format.
292+
*
293+
* This function handles complex transformations including:
294+
* - Converting Gemini "model" role to OpenAI "assistant" role
295+
* - Processing tool calls (function calls) and their responses
296+
* - Managing tool call ID mapping through pendingToolCalls Map
297+
* - Handling special nested array format for function responses (Gemini CLI compatibility)
298+
* - Cleaning up incomplete tool calls and deduplicating tool responses
299+
*
300+
* @remarks
301+
* The `pendingToolCalls` Map maintains the relationship between generated tool_call_ids
302+
* and function names throughout the conversation. This is necessary because:
303+
* - Gemini function calls don't have IDs, but OpenAI tool calls require them
304+
* - We generate IDs when translating function calls to tool calls
305+
* - Later function responses need to reference these IDs via tool_call_id
306+
*
307+
* Tool Call Matching Strategy:
308+
* - When a function call is encountered, generate a tool_call_id and store it in pendingToolCalls
309+
* - When a function response is encountered, look up the corresponding tool_call_id by function name
310+
* - After matching, remove the tool_call_id from pendingToolCalls to prevent duplicate matches
311+
*
312+
* Special Cases:
313+
* - Nested array format: Gemini CLI sometimes sends function responses as `Array<{functionResponse: ...}>`
314+
* instead of inside GeminiContent.parts. We detect and handle this format separately.
315+
* - Incomplete tool calls: Assistant messages with tool calls that have no corresponding responses
316+
* are filtered out during the cleanup phase to avoid OpenAI API errors.
317+
*
318+
* @param contents - Array of Gemini conversation contents (may include nested arrays for function responses)
319+
* @param systemInstruction - Optional system instruction to prepend to the conversation
320+
* @returns Array of OpenAI-compatible messages
321+
*/
309322
function translateGeminiContentsToOpenAI(
310323
contents: Array<
311324
| GeminiContent
@@ -489,13 +502,25 @@ function translateOpenAIMessageToGeminiContent(
489502
parts.push({
490503
functionCall: {
491504
name: toolCall.function.name,
492-
args:
493-
toolCall.function.arguments ?
494-
(JSON.parse(toolCall.function.arguments) as Record<
495-
string,
496-
unknown
497-
>)
498-
: {},
505+
args: (() => {
506+
if (toolCall.function.arguments) {
507+
try {
508+
return JSON.parse(toolCall.function.arguments) as Record<
509+
string,
510+
unknown
511+
>
512+
} catch (error) {
513+
if (process.env.DEBUG_GEMINI_REQUESTS === "true") {
514+
console.warn(
515+
`[DEBUG] Failed to parse toolCall.function.arguments: "${toolCall.function.arguments}". Error:`,
516+
error,
517+
)
518+
}
519+
return {}
520+
}
521+
}
522+
return {}
523+
})(),
499524
},
500525
})
501526
}

0 commit comments

Comments
 (0)