110 lines
3.6 KiB
JavaScript
110 lines
3.6 KiB
JavaScript
function messageContent(message) {
|
|
return Array.isArray(message?.content) ? message.content : [];
|
|
}
|
|
|
|
function toolItemId(item, type) {
|
|
if (item?.type !== type) return null;
|
|
return String(item?.id ?? '').trim() || null;
|
|
}
|
|
|
|
function cloneMessageWithContent(message, content) {
|
|
return { ...message, content };
|
|
}
|
|
|
|
/**
|
|
* Goose can persist concurrently completed tools out of order. OpenAI-compatible
|
|
* providers reject the next turn when an assistant tool request is not followed
|
|
* immediately by its matching tool response. Re-pair completed calls and remove
|
|
* incomplete/orphaned protocol items while preserving all visible text.
|
|
*/
|
|
export function repairConversationToolHistory(conversation) {
|
|
if (!Array.isArray(conversation) || conversation.length === 0) {
|
|
return {
|
|
conversation: Array.isArray(conversation) ? conversation : [],
|
|
changed: false,
|
|
repairedToolResponses: 0,
|
|
droppedToolRequests: 0,
|
|
droppedOrphanResponses: 0,
|
|
};
|
|
}
|
|
|
|
const responsesById = new Map();
|
|
let totalToolResponses = 0;
|
|
for (const message of conversation) {
|
|
for (const item of messageContent(message)) {
|
|
const id = toolItemId(item, 'toolResponse');
|
|
if (!id) continue;
|
|
totalToolResponses += 1;
|
|
if (!responsesById.has(id)) responsesById.set(id, { message, item });
|
|
}
|
|
}
|
|
|
|
const repaired = [];
|
|
const consumedResponseIds = new Set();
|
|
let repairedToolResponses = 0;
|
|
let droppedToolRequests = 0;
|
|
|
|
for (const message of conversation) {
|
|
const content = messageContent(message);
|
|
const requestItems = content.filter((item) => item?.type === 'toolRequest');
|
|
const responseItems = content.filter((item) => item?.type === 'toolResponse');
|
|
|
|
if (requestItems.length === 0) {
|
|
if (responseItems.length === 0) {
|
|
repaired.push(message);
|
|
continue;
|
|
}
|
|
const remaining = content.filter((item) => item?.type !== 'toolResponse');
|
|
if (remaining.length > 0) repaired.push(cloneMessageWithContent(message, remaining));
|
|
continue;
|
|
}
|
|
|
|
const matchedRequests = [];
|
|
for (const item of requestItems) {
|
|
const id = toolItemId(item, 'toolRequest');
|
|
if (id && responsesById.has(id)) {
|
|
matchedRequests.push(item);
|
|
} else {
|
|
droppedToolRequests += 1;
|
|
}
|
|
}
|
|
const retainedContent = content.filter(
|
|
(item) => item?.type !== 'toolRequest' && item?.type !== 'toolResponse',
|
|
);
|
|
retainedContent.push(...matchedRequests);
|
|
if (retainedContent.length > 0) {
|
|
const unchanged =
|
|
retainedContent.length === content.length &&
|
|
retainedContent.every((item, index) => item === content[index]);
|
|
repaired.push(unchanged ? message : cloneMessageWithContent(message, retainedContent));
|
|
}
|
|
|
|
for (const request of matchedRequests) {
|
|
const id = toolItemId(request, 'toolRequest');
|
|
const response = responsesById.get(id);
|
|
if (!response || consumedResponseIds.has(id)) continue;
|
|
consumedResponseIds.add(id);
|
|
repairedToolResponses += 1;
|
|
const originalContent = messageContent(response.message);
|
|
repaired.push(
|
|
originalContent.length === 1 && originalContent[0] === response.item
|
|
? response.message
|
|
: cloneMessageWithContent(response.message, [response.item]),
|
|
);
|
|
}
|
|
}
|
|
|
|
const droppedOrphanResponses = Math.max(0, totalToolResponses - consumedResponseIds.size);
|
|
const changed =
|
|
repaired.length !== conversation.length ||
|
|
repaired.some((message, index) => message !== conversation[index]);
|
|
|
|
return {
|
|
conversation: changed ? repaired : conversation,
|
|
changed,
|
|
repairedToolResponses,
|
|
droppedToolRequests,
|
|
droppedOrphanResponses,
|
|
};
|
|
}
|