Files
memind/chat-tool-history-repair.mjs

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,
};
}