mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 20:32:58 +00:00
When a tool round-trip is interrupted between the tool result and the
model's text reply (user aborted, network drop, pod restart, ...) and
LibreChat persists the partial assistant message, the next conversation
turn reconstructs an `AIMessage` from `formatAgentMessages` that has
`tool_calls` populated but no `additional_kwargs.signatures`. Vertex
Gemini 3 rejects the resumed request with 400 because the most recent
historical functionCall has no `thought_signature`.
## Storage shape
Capture as `Record<tool_call_id, signature>` rather than a flat array.
This addresses the codex P1 review:
> When an assistant turn contains multiple sequential tool-call batches,
> this restoration path writes all persisted thoughtSignatures onto only
> the last tool-bearing AIMessage. Vertex/Gemini validates signatures
> for each step in the current tool-calling turn, so earlier
> functionCall steps reconstructed without their signature can still
> fail with 400.
A single agent run can fire multiple `chat_model_end` events when the
loop cycles the LLM with intervening tool results — each cycle owns a
distinct `tool_call_id`. Per-id storage maps each signature back onto
the right reconstructed `AIMessage`, not just the last one.
## Mapping
`additional_kwargs.signatures` is a flat array indexed by *response part*
(text + functionCall interleaved). `tool_calls` is just the function
calls in their original order. Non-empty signatures correspond 1:1 with
tool_calls in order — see `partsToSignatures` in
`@langchain/google-common`. Single-pass walk maps `signatures[i]` (when
non-empty) onto the i-th `tool_call.id`.
## Pipeline
| Stage | File | Change |
|---|---|---|
| Capture | callbacks.js | `ModelEndHandler` accepts `Record<string,string>` map; walks signatures + tool_calls in tandem to record per-id. Gated on the map being provided — non-Vertex flows are no-op (and also no-op even when provided, since they don't emit signatures). |
| Plumbing | initialize.js | Allocate `collectedThoughtSignatures = {}`, share with handler + client. Always allocated; the JSDoc explicitly documents that it stays empty for non-Vertex providers. |
| Surface | client.js | `sendCompletion` returns `metadata.thoughtSignatures` when the map has entries; falls through unchanged when empty. |
| Persist | (existing BaseClient.handleRespCompletion) | Writes `metadata` from `sendCompletion` onto `responseMessage.metadata`. Mongoose `Mixed` — no migration. |
| Restore | formatMessages.js | Track every tool-bearing AIMessage produced from a TMessage. For each, build a position-aligned `additional_kwargs.signatures` array (empty placeholders for tool_calls without a stored sig). Agents' `fixThoughtSignatures` dispatches non-empty entries to functionCall parts in order. |
## Live verification
- **Single-step:** real Vertex `gemini-3.1-flash-lite-preview` resume-after-tool case. With fix ✅ / without ❌ 400.
- **Multi-step (codex case):** real two-step agent loop (list /tmp → echo done). Each step's signature attaches to its own reconstructed AIMessage. With fix ✅ / without ❌ 400.
- **Cross-provider:** Anthropic Claude haiku-4.5 + OpenAI gpt-5-mini accept the persisted/restored shape unchanged.
## Tests
`modelEndHandler.spec.js` (new) — 6 tests:
- maps non-empty signatures onto tool_call_ids in order
- accumulates per-id across multiple `model_end` events (multi-step)
- no-op when `collectedThoughtSignatures` is null
- no-op when `signatures` field missing (non-Vertex)
- no-op when `tool_calls` missing
- preserves existing `collectedUsage` array contract
`formatAgentMessages.spec.js` — 6 new tests:
- restores onto the AIMessage that owns the tool_call
- per-step attachment for multi-step turns (codex review case)
- preserves tool_call ordering when signatures are partial
- no-op when metadata.thoughtSignatures absent
- no-op when assistant has no tool_calls
- no-op when stored ids don't match any current tool_call
37 passing across 3 suites; 15 existing formatAgentMessages tests unchanged.
## Compatibility
- Backward-compatible — restore gated on `metadata.thoughtSignatures` being a populated object; capture gated on the map being provided.
- No schema migration — uses `Message.metadata: Mixed` already in place.
- Cross-provider safe — non-Vertex providers tolerate the field (verified live against Anthropic + OpenAI converters).
- Pairs with [agents#159](https://github.com/danny-avila/agents/pull/159) for full coverage on histories that mix plain-text and toolcall AIMessages.
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
const { EModelEndpoint, ContentTypes } = require('librechat-data-provider');
|
|
const {
|
|
AIMessage,
|
|
ToolMessage,
|
|
HumanMessage,
|
|
SystemMessage,
|
|
} = require('@librechat/agents/langchain/messages');
|
|
|
|
/**
|
|
* Formats a message to OpenAI Vision API payload format.
|
|
*
|
|
* @param {Object} params - The parameters for formatting.
|
|
* @param {Object} params.message - The message object to format.
|
|
* @param {string} [params.message.role] - The role of the message sender (must be 'user').
|
|
* @param {string} [params.message.content] - The text content of the message.
|
|
* @param {EModelEndpoint} [params.endpoint] - Identifier for specific endpoint handling
|
|
* @param {Array<string>} [params.image_urls] - The image_urls to attach to the message.
|
|
* @returns {(Object)} - The formatted message.
|
|
*/
|
|
const formatVisionMessage = ({ message, image_urls, endpoint }) => {
|
|
if (endpoint === EModelEndpoint.anthropic) {
|
|
message.content = [...image_urls, { type: ContentTypes.TEXT, text: message.content }];
|
|
return message;
|
|
}
|
|
|
|
message.content = [{ type: ContentTypes.TEXT, text: message.content }, ...image_urls];
|
|
|
|
return message;
|
|
};
|
|
|
|
/**
|
|
* Formats a message to OpenAI payload format based on the provided options.
|
|
*
|
|
* @param {Object} params - The parameters for formatting.
|
|
* @param {Object} params.message - The message object to format.
|
|
* @param {string} [params.message.role] - The role of the message sender (e.g., 'user', 'assistant').
|
|
* @param {string} [params.message._name] - The name associated with the message.
|
|
* @param {string} [params.message.sender] - The sender of the message.
|
|
* @param {string} [params.message.text] - The text content of the message.
|
|
* @param {string} [params.message.content] - The content of the message.
|
|
* @param {Array<string>} [params.message.image_urls] - The image_urls attached to the message for Vision API.
|
|
* @param {string} [params.userName] - The name of the user.
|
|
* @param {string} [params.assistantName] - The name of the assistant.
|
|
* @param {string} [params.endpoint] - Identifier for specific endpoint handling
|
|
* @param {boolean} [params.langChain=false] - Whether to return a LangChain message object.
|
|
* @returns {(Object|HumanMessage|AIMessage|SystemMessage)} - The formatted message.
|
|
*/
|
|
const formatMessage = ({ message, userName, assistantName, endpoint, langChain = false }) => {
|
|
let { role: _role, _name, sender, text, content: _content, lc_id } = message;
|
|
if (lc_id && lc_id[2] && !langChain) {
|
|
const roleMapping = {
|
|
SystemMessage: 'system',
|
|
HumanMessage: 'user',
|
|
AIMessage: 'assistant',
|
|
};
|
|
_role = roleMapping[lc_id[2]];
|
|
}
|
|
const role = _role ?? (sender && sender?.toLowerCase() === 'user' ? 'user' : 'assistant');
|
|
const content = _content ?? text ?? '';
|
|
const formattedMessage = {
|
|
role,
|
|
content,
|
|
};
|
|
|
|
const { image_urls } = message;
|
|
if (Array.isArray(image_urls) && image_urls.length > 0 && role === 'user') {
|
|
return formatVisionMessage({
|
|
message: formattedMessage,
|
|
image_urls: message.image_urls,
|
|
endpoint,
|
|
});
|
|
}
|
|
|
|
if (_name) {
|
|
formattedMessage.name = _name;
|
|
}
|
|
|
|
if (userName && formattedMessage.role === 'user') {
|
|
formattedMessage.name = userName;
|
|
}
|
|
|
|
if (assistantName && formattedMessage.role === 'assistant') {
|
|
formattedMessage.name = assistantName;
|
|
}
|
|
|
|
if (formattedMessage.name) {
|
|
// Conform to API regex: ^[a-zA-Z0-9_-]{1,64}$
|
|
// https://community.openai.com/t/the-format-of-the-name-field-in-the-documentation-is-incorrect/175684/2
|
|
formattedMessage.name = formattedMessage.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
|
|
if (formattedMessage.name.length > 64) {
|
|
formattedMessage.name = formattedMessage.name.substring(0, 64);
|
|
}
|
|
}
|
|
|
|
if (!langChain) {
|
|
return formattedMessage;
|
|
}
|
|
|
|
if (role === 'user') {
|
|
return new HumanMessage(formattedMessage);
|
|
} else if (role === 'assistant') {
|
|
return new AIMessage(formattedMessage);
|
|
} else {
|
|
return new SystemMessage(formattedMessage);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Formats an array of messages for LangChain.
|
|
*
|
|
* @param {Array<Object>} messages - The array of messages to format.
|
|
* @param {Object} formatOptions - The options for formatting each message.
|
|
* @param {string} [formatOptions.userName] - The name of the user.
|
|
* @param {string} [formatOptions.assistantName] - The name of the assistant.
|
|
* @returns {Array<(HumanMessage|AIMessage|SystemMessage)>} - The array of formatted LangChain messages.
|
|
*/
|
|
const formatLangChainMessages = (messages, formatOptions) =>
|
|
messages.map((msg) => formatMessage({ ...formatOptions, message: msg, langChain: true }));
|
|
|
|
/**
|
|
* Formats a LangChain message object by merging properties from `lc_kwargs` or `kwargs` and `additional_kwargs`.
|
|
*
|
|
* @param {Object} message - The message object to format.
|
|
* @param {Object} [message.lc_kwargs] - Contains properties to be merged. Either this or `message.kwargs` should be provided.
|
|
* @param {Object} [message.kwargs] - Contains properties to be merged. Either this or `message.lc_kwargs` should be provided.
|
|
* @param {Object} [message.kwargs.additional_kwargs] - Additional properties to be merged.
|
|
*
|
|
* @returns {Object} The formatted LangChain message.
|
|
*/
|
|
const formatFromLangChain = (message) => {
|
|
const { additional_kwargs, ...message_kwargs } = message.lc_kwargs ?? message.kwargs;
|
|
return {
|
|
...message_kwargs,
|
|
...additional_kwargs,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Formats an array of messages for LangChain, handling tool calls and creating ToolMessage instances.
|
|
*
|
|
* @param {Array<Partial<TMessage>>} payload - The array of messages to format.
|
|
* @returns {Array<(HumanMessage|AIMessage|SystemMessage|ToolMessage)>} - The array of formatted LangChain messages, including ToolMessages for tool calls.
|
|
*/
|
|
const formatAgentMessages = (payload) => {
|
|
const messages = [];
|
|
|
|
for (const message of payload) {
|
|
if (typeof message.content === 'string') {
|
|
message.content = [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }];
|
|
}
|
|
if (message.role !== 'assistant') {
|
|
messages.push(formatMessage({ message, langChain: true }));
|
|
continue;
|
|
}
|
|
|
|
let currentContent = [];
|
|
let lastAIMessage = null;
|
|
/**
|
|
* Every AIMessage produced from this TMessage that received `tool_calls`,
|
|
* in order. Multi-step tool turns (where the agent loop cycles the LLM
|
|
* multiple times with intervening tool results) produce one AIMessage per
|
|
* cycle, each owning a different `tool_call_id`. We attach persisted
|
|
* Vertex Gemini 3 thought signatures (`metadata.thoughtSignatures`,
|
|
* keyed by `tool_call_id`) onto each one so every step has its right
|
|
* signature on resume — Vertex validates per-step, not per-turn
|
|
* (issue #13006 follow-up).
|
|
*/
|
|
const toolBearingAIMessages = [];
|
|
|
|
let hasReasoning = false;
|
|
for (const part of message.content) {
|
|
if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
|
|
/*
|
|
If there's pending content, it needs to be aggregated as a single string to prepare for tool calls.
|
|
For Anthropic models, the "tool_calls" field on a message is only respected if content is a string.
|
|
*/
|
|
if (currentContent.length > 0) {
|
|
let content = currentContent.reduce((acc, curr) => {
|
|
if (curr.type === ContentTypes.TEXT) {
|
|
return `${acc}${curr[ContentTypes.TEXT]}\n`;
|
|
}
|
|
return acc;
|
|
}, '');
|
|
content = `${content}\n${part[ContentTypes.TEXT] ?? ''}`.trim();
|
|
lastAIMessage = new AIMessage({ content });
|
|
messages.push(lastAIMessage);
|
|
currentContent = [];
|
|
continue;
|
|
}
|
|
|
|
// Create a new AIMessage with this text and prepare for tool calls
|
|
lastAIMessage = new AIMessage({
|
|
content: part.text || '',
|
|
});
|
|
|
|
messages.push(lastAIMessage);
|
|
} else if (part.type === ContentTypes.TOOL_CALL) {
|
|
if (!lastAIMessage) {
|
|
throw new Error('Invalid tool call structure: No preceding AIMessage with tool_call_ids');
|
|
}
|
|
|
|
// Note: `tool_calls` list is defined when constructed by `AIMessage` class, and outputs should be excluded from it
|
|
const { output, args: _args, ...tool_call } = part.tool_call;
|
|
// TODO: investigate; args as dictionary may need to be provider-or-tool-specific
|
|
let args = _args;
|
|
try {
|
|
args = JSON.parse(_args);
|
|
} catch (_e) {
|
|
if (typeof _args === 'string') {
|
|
args = { input: _args };
|
|
}
|
|
}
|
|
|
|
tool_call.args = args;
|
|
lastAIMessage.tool_calls.push(tool_call);
|
|
if (toolBearingAIMessages[toolBearingAIMessages.length - 1] !== lastAIMessage) {
|
|
toolBearingAIMessages.push(lastAIMessage);
|
|
}
|
|
|
|
// Add the corresponding ToolMessage
|
|
messages.push(
|
|
new ToolMessage({
|
|
tool_call_id: tool_call.id,
|
|
name: tool_call.name,
|
|
content: output || '',
|
|
}),
|
|
);
|
|
} else if (part.type === ContentTypes.THINK) {
|
|
hasReasoning = true;
|
|
continue;
|
|
} else if (part.type === ContentTypes.ERROR || part.type === ContentTypes.AGENT_UPDATE) {
|
|
continue;
|
|
} else {
|
|
currentContent.push(part);
|
|
}
|
|
}
|
|
|
|
if (hasReasoning) {
|
|
currentContent = currentContent
|
|
.reduce((acc, curr) => {
|
|
if (curr.type === ContentTypes.TEXT) {
|
|
return `${acc}${curr[ContentTypes.TEXT]}\n`;
|
|
}
|
|
return acc;
|
|
}, '')
|
|
.trim();
|
|
}
|
|
|
|
if (currentContent.length > 0) {
|
|
messages.push(new AIMessage({ content: currentContent }));
|
|
}
|
|
|
|
/**
|
|
* Restore signatures per-step. The persisted shape is
|
|
* `{ [tool_call_id]: signature }`; for each tool-bearing AIMessage we
|
|
* build a position-aligned `additional_kwargs.signatures` array (empty
|
|
* placeholders for tool_calls without a stored signature). Agents'
|
|
* `fixThoughtSignatures` then dispatches the non-empty entries to
|
|
* functionCall parts in order — order matches because non-empty
|
|
* signatures and tool_calls share their original parts ordering.
|
|
*/
|
|
const sigsByCallId = message.metadata?.thoughtSignatures;
|
|
if (sigsByCallId && typeof sigsByCallId === 'object' && toolBearingAIMessages.length > 0) {
|
|
for (const aiMsg of toolBearingAIMessages) {
|
|
const sigs = aiMsg.tool_calls.map((tc) => sigsByCallId[tc.id] ?? '');
|
|
if (sigs.some((s) => typeof s === 'string' && s.length > 0)) {
|
|
aiMsg.additional_kwargs ??= {};
|
|
aiMsg.additional_kwargs.signatures = sigs;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
};
|
|
|
|
module.exports = {
|
|
formatMessage,
|
|
formatFromLangChain,
|
|
formatAgentMessages,
|
|
formatLangChainMessages,
|
|
};
|