From 7ee2c23f66f6503f380b294a206b91a4f56a7633 Mon Sep 17 00:00:00 2001 From: ponyfly6 <2083323301@qq.com> Date: Tue, 10 Mar 2026 16:57:23 +0800 Subject: [PATCH] fix(server-ai): sanitize empty-role generic messages --- packages/server-ai/src/shared/agent/index.ts | 1 + .../agent/sanitize-messages-for-llm.test.ts | 51 ++++++++++ .../shared/agent/sanitize-messages-for-llm.ts | 98 +++++++++++++++++++ .../server-ai/src/shared/agent/summarize.ts | 5 +- .../commands/handlers/subgraph.handler.ts | 5 +- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 packages/server-ai/src/shared/agent/sanitize-messages-for-llm.test.ts create mode 100644 packages/server-ai/src/shared/agent/sanitize-messages-for-llm.ts diff --git a/packages/server-ai/src/shared/agent/index.ts b/packages/server-ai/src/shared/agent/index.ts index 9e10765353..5921a834b3 100644 --- a/packages/server-ai/src/shared/agent/index.ts +++ b/packages/server-ai/src/shared/agent/index.ts @@ -9,3 +9,4 @@ export * from './parameter' export * from './constants' export * from './middleware' export * from './stream-text' +export * from './sanitize-messages-for-llm' diff --git a/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.test.ts b/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.test.ts new file mode 100644 index 0000000000..6d3e611359 --- /dev/null +++ b/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.test.ts @@ -0,0 +1,51 @@ +import { AIMessage, ChatMessage, HumanMessage, ToolMessage } from '@langchain/core/messages' +import { sanitizeMessagesForLLM } from './sanitize-messages-for-llm' + +describe('sanitizeMessagesForLLM', () => { + it('preserves inferred tool calls when recovering an assistant message', () => { + const message = new ChatMessage({ + content: 'Call the weather tool', + role: '', + additional_kwargs: {}, + id: 'msg-ai' + }) as ChatMessage & { tool_calls: Array<{ id: string; name: string; args: { city: string } }> } + message.tool_calls = [ + { + id: 'tool-1', + name: 'weather', + args: { city: 'Shanghai' } + } + ] + + const [sanitized] = sanitizeMessagesForLLM([message]) + + expect(sanitized).toBeInstanceOf(AIMessage) + expect((sanitized as AIMessage).tool_calls).toEqual(message.tool_calls) + }) + + it('recovers tool messages without dropping the tool call id', () => { + const message = new ChatMessage({ + content: 'Tool result', + role: '', + additional_kwargs: {}, + id: 'msg-tool' + }) as ChatMessage & { tool_call_id: string; status: 'success' | 'error'; artifact: { raw: string } } + message.tool_call_id = 'tool-1' + message.status = 'success' + message.artifact = { raw: 'ok' } + + const [sanitized] = sanitizeMessagesForLLM([message]) + + expect(sanitized).toBeInstanceOf(ToolMessage) + expect((sanitized as ToolMessage).tool_call_id).toBe('tool-1') + expect((sanitized as ToolMessage).artifact).toEqual({ raw: 'ok' }) + expect((sanitized as ToolMessage).status).toBe('success') + }) + + it('converts malformed non-message inputs to HumanMessage', () => { + const [sanitized] = sanitizeMessagesForLLM([{ content: 'fallback' } as HumanMessage]) + + expect(sanitized).toBeInstanceOf(HumanMessage) + expect((sanitized as HumanMessage).content).toBe('fallback') + }) +}) diff --git a/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.ts b/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.ts new file mode 100644 index 0000000000..e1c1fae4ed --- /dev/null +++ b/packages/server-ai/src/shared/agent/sanitize-messages-for-llm.ts @@ -0,0 +1,98 @@ +import { AIMessage, BaseMessage, HumanMessage, ToolMessage } from '@langchain/core/messages' +import { Logger } from '@nestjs/common' + +type RecoverableMessage = BaseMessage & { + role?: string + tool_calls?: unknown[] + invalid_tool_calls?: unknown[] + tool_call_id?: string + status?: 'success' | 'error' + artifact?: unknown + metadata?: Record + usage_metadata?: unknown +} + +function toAIMessage(message: RecoverableMessage): AIMessage { + const fields: ConstructorParameters[0] & { + tool_calls?: unknown[] + invalid_tool_calls?: unknown[] + usage_metadata?: unknown + } = { + content: message.content, + name: message.name, + additional_kwargs: message.additional_kwargs, + response_metadata: message.response_metadata, + id: message.id + } + const mutableFields = fields as any + + if (message.tool_calls !== undefined) { + mutableFields.tool_calls = message.tool_calls + } + if (message.invalid_tool_calls !== undefined) { + mutableFields.invalid_tool_calls = message.invalid_tool_calls + } + if (message.usage_metadata !== undefined) { + mutableFields.usage_metadata = message.usage_metadata + } + + return new AIMessage(fields as ConstructorParameters[0]) +} + +function toToolMessage(message: RecoverableMessage): ToolMessage { + const fields: { + content: RecoverableMessage['content'] + name: RecoverableMessage['name'] + additional_kwargs: RecoverableMessage['additional_kwargs'] + response_metadata: RecoverableMessage['response_metadata'] + id: RecoverableMessage['id'] + tool_call_id: string + status?: RecoverableMessage['status'] + artifact?: RecoverableMessage['artifact'] + metadata?: RecoverableMessage['metadata'] + } = { + content: message.content, + name: message.name, + additional_kwargs: message.additional_kwargs, + response_metadata: message.response_metadata, + id: message.id, + tool_call_id: message.tool_call_id as string + } + + if (message.status !== undefined) { + fields.status = message.status + } + if (message.artifact !== undefined) { + fields.artifact = message.artifact + } + if (message.metadata !== undefined) { + fields.metadata = message.metadata + } + + return new ToolMessage(fields) +} + +export function sanitizeMessagesForLLM(messages: BaseMessage[], logger?: Logger): BaseMessage[] { + return messages.map((message, index) => { + if (!message || typeof message._getType !== 'function') { + logger?.warn(`Message at index ${index} is not a BaseMessage, converting to HumanMessage`) + return new HumanMessage({ content: String((message as { content?: unknown } | undefined)?.content ?? '') }) + } + + const recoverableMessage = message as RecoverableMessage + if (recoverableMessage._getType() === 'generic' && !recoverableMessage.role) { + logger?.warn( + `Message at index ${index} is a generic ChatMessage with empty role, inferring type from content/structure` + ) + if (recoverableMessage.additional_kwargs?.tool_calls || recoverableMessage.tool_calls?.length) { + return toAIMessage(recoverableMessage) + } + if (recoverableMessage.tool_call_id) { + return toToolMessage(recoverableMessage) + } + return toAIMessage(recoverableMessage) + } + + return message + }) +} diff --git a/packages/server-ai/src/shared/agent/summarize.ts b/packages/server-ai/src/shared/agent/summarize.ts index 78c3408439..2d4c650191 100644 --- a/packages/server-ai/src/shared/agent/summarize.ts +++ b/packages/server-ai/src/shared/agent/summarize.ts @@ -3,6 +3,7 @@ import { HumanMessage, isHumanMessage, RemoveMessage } from '@langchain/core/mes import { channelName, TMessageChannel, TSummarize } from '@metad/contracts' import { v4 as uuidv4 } from 'uuid' import { AgentStateAnnotation } from './state' +import { sanitizeMessagesForLLM } from './sanitize-messages-for-llm' /** * Create summarize node function for agent's message channel. @@ -38,7 +39,9 @@ export function createSummarizeAgent(model: BaseChatModel, summarize: TSummarize content: summaryMessage }) ] - const response = await model.invoke(allMessages, { tags: ['summarize_conversation'] }) + const response = await model.invoke(sanitizeMessagesForLLM(allMessages), { + tags: ['summarize_conversation'] + }) // We now need to delete messages that we no longer want to show up const summarizedMessages = messages.slice(0, -summarize.retainMessages) const retainMessages = messages.slice(-summarize.retainMessages) diff --git a/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts b/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts index df0714a26e..27110845cf 100644 --- a/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts +++ b/packages/server-ai/src/xpert-agent/commands/handlers/subgraph.handler.ts @@ -40,7 +40,7 @@ import { FakeStreamingChatModel, getChannelState, messageEvent, TAgentSubgraphPa import { initializeMemoryTools, formatMemories } from '../../../copilot-store' import { CreateWorkflowNodeCommand, createWorkflowTaskTools } from '../../workflow' import { toEnvState } from '../../../environment' -import { _BaseToolset, ToolSchemaParser, AgentStateAnnotation, createHumanMessage, stateToParameters, createSummarizeAgent, translate, stateVariable, identifyAgent, createParameters, TGraphTool, TSubAgent, TWorkflowGraphNode, TStateChannel, hasMultipleInputs, getAgentMiddlewares, orderNodesByKeyOrder, createAgentChannel } from '../../../shared' +import { _BaseToolset, ToolSchemaParser, AgentStateAnnotation, createHumanMessage, stateToParameters, createSummarizeAgent, translate, stateVariable, identifyAgent, createParameters, TGraphTool, TSubAgent, TWorkflowGraphNode, TStateChannel, hasMultipleInputs, getAgentMiddlewares, orderNodesByKeyOrder, createAgentChannel, sanitizeMessagesForLLM } from '../../../shared' import { CreateSummarizeTitleAgentCommand } from '../summarize-title.command' import { XpertCollaborator } from '../../../shared/agent/xpert' import { AgenticWorkflowTypes } from '../../types' @@ -832,8 +832,9 @@ export class XpertAgentSubgraphHandler implements ICommandHandler