Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/server-ai/src/shared/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from './parameter'
export * from './constants'
export * from './middleware'
export * from './stream-text'
export * from './sanitize-messages-for-llm'
Original file line number Diff line number Diff line change
@@ -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')
})
})
98 changes: 98 additions & 0 deletions packages/server-ai/src/shared/agent/sanitize-messages-for-llm.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
usage_metadata?: unknown
}

function toAIMessage(message: RecoverableMessage): AIMessage {
const fields: ConstructorParameters<typeof AIMessage>[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<typeof AIMessage>[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
})
}
5 changes: 4 additions & 1 deletion packages/server-ai/src/shared/agent/summarize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -832,8 +832,9 @@ export class XpertAgentSubgraphHandler implements ICommandHandler<XpertAgentSubg
const systemMsg = request.systemMessage ?? systemMessage
systemMessageContent = systemMsg.content
const finalMessages = systemMsg ? [systemMsg, ...reqMessages] : reqMessages
const sanitizedMessages = sanitizeMessagesForLLM(finalMessages, this.#logger)
const response = await model.invoke(
finalMessages,
sanitizedMessages,
{...config, signal: abortController.signal}
)
if (isBaseMessage(response) && isAIMessage(response)) {
Expand Down