feat: Add structured JSON output support to Agent Node (#5470)
* feat: Add structured JSON output support to Agent Node - Add agentStructuredOutput input parameter matching LLM Node structure - Implement configureStructuredOutput method to convert schema to Zod - Add createZodSchemaFromJSON helper for complex JSON schemas - Configure structured output before binding tools (required order) - Disable streaming when structured output is enabled - Extract structured fields in prepareOutputObject method - Resolves issue #5256 * lint fix * add structured output to Agent node * add structured output to Agent node --------- Co-authored-by: Henry <hzj94@hotmail.com>
This commit is contained in:
parent
4d79653741
commit
1f3f7a7194
|
|
@ -28,7 +28,13 @@ import {
|
||||||
replaceBase64ImagesWithFileReferences,
|
replaceBase64ImagesWithFileReferences,
|
||||||
updateFlowState
|
updateFlowState
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { convertMultiOptionsToStringArray, getCredentialData, getCredentialParam, processTemplateVariables } from '../../../src/utils'
|
import {
|
||||||
|
convertMultiOptionsToStringArray,
|
||||||
|
getCredentialData,
|
||||||
|
getCredentialParam,
|
||||||
|
processTemplateVariables,
|
||||||
|
configureStructuredOutput
|
||||||
|
} from '../../../src/utils'
|
||||||
import { addSingleFileToStorage } from '../../../src/storageUtils'
|
import { addSingleFileToStorage } from '../../../src/storageUtils'
|
||||||
import fetch from 'node-fetch'
|
import fetch from 'node-fetch'
|
||||||
|
|
||||||
|
|
@ -394,6 +400,108 @@ class Agent_Agentflow implements INode {
|
||||||
],
|
],
|
||||||
default: 'userMessage'
|
default: 'userMessage'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'JSON Structured Output',
|
||||||
|
name: 'agentStructuredOutput',
|
||||||
|
description: 'Instruct the Agent to give output in a JSON structured schema',
|
||||||
|
type: 'array',
|
||||||
|
optional: true,
|
||||||
|
acceptVariable: true,
|
||||||
|
array: [
|
||||||
|
{
|
||||||
|
label: 'Key',
|
||||||
|
name: 'key',
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Type',
|
||||||
|
name: 'type',
|
||||||
|
type: 'options',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'String',
|
||||||
|
name: 'string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'String Array',
|
||||||
|
name: 'stringArray'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Number',
|
||||||
|
name: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Boolean',
|
||||||
|
name: 'boolean'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Enum',
|
||||||
|
name: 'enum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'JSON Array',
|
||||||
|
name: 'jsonArray'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Enum Values',
|
||||||
|
name: 'enumValues',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'value1, value2, value3',
|
||||||
|
description: 'Enum values. Separated by comma',
|
||||||
|
optional: true,
|
||||||
|
show: {
|
||||||
|
'agentStructuredOutput[$index].type': 'enum'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'JSON Schema',
|
||||||
|
name: 'jsonSchema',
|
||||||
|
type: 'code',
|
||||||
|
placeholder: `{
|
||||||
|
"answer": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Value of the answer"
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for the answer"
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Value of the children's answer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
description: 'JSON schema for the structured output',
|
||||||
|
optional: true,
|
||||||
|
hideCodeExecute: true,
|
||||||
|
show: {
|
||||||
|
'agentStructuredOutput[$index].type': 'jsonArray'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Description',
|
||||||
|
name: 'description',
|
||||||
|
type: 'string',
|
||||||
|
placeholder: 'Description of the key'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Update Flow State',
|
label: 'Update Flow State',
|
||||||
name: 'agentUpdateState',
|
name: 'agentUpdateState',
|
||||||
|
|
@ -770,6 +878,7 @@ class Agent_Agentflow implements INode {
|
||||||
const memoryType = nodeData.inputs?.agentMemoryType as string
|
const memoryType = nodeData.inputs?.agentMemoryType as string
|
||||||
const userMessage = nodeData.inputs?.agentUserMessage as string
|
const userMessage = nodeData.inputs?.agentUserMessage as string
|
||||||
const _agentUpdateState = nodeData.inputs?.agentUpdateState
|
const _agentUpdateState = nodeData.inputs?.agentUpdateState
|
||||||
|
const _agentStructuredOutput = nodeData.inputs?.agentStructuredOutput
|
||||||
const agentMessages = (nodeData.inputs?.agentMessages as unknown as ILLMMessage[]) ?? []
|
const agentMessages = (nodeData.inputs?.agentMessages as unknown as ILLMMessage[]) ?? []
|
||||||
|
|
||||||
// Extract runtime state and history
|
// Extract runtime state and history
|
||||||
|
|
@ -795,6 +904,8 @@ class Agent_Agentflow implements INode {
|
||||||
const llmWithoutToolsBind = (await newLLMNodeInstance.init(newNodeData, '', options)) as BaseChatModel
|
const llmWithoutToolsBind = (await newLLMNodeInstance.init(newNodeData, '', options)) as BaseChatModel
|
||||||
let llmNodeInstance = llmWithoutToolsBind
|
let llmNodeInstance = llmWithoutToolsBind
|
||||||
|
|
||||||
|
const isStructuredOutput = _agentStructuredOutput && Array.isArray(_agentStructuredOutput) && _agentStructuredOutput.length > 0
|
||||||
|
|
||||||
const agentToolsBuiltInOpenAI = convertMultiOptionsToStringArray(nodeData.inputs?.agentToolsBuiltInOpenAI)
|
const agentToolsBuiltInOpenAI = convertMultiOptionsToStringArray(nodeData.inputs?.agentToolsBuiltInOpenAI)
|
||||||
if (agentToolsBuiltInOpenAI && agentToolsBuiltInOpenAI.length > 0) {
|
if (agentToolsBuiltInOpenAI && agentToolsBuiltInOpenAI.length > 0) {
|
||||||
for (const tool of agentToolsBuiltInOpenAI) {
|
for (const tool of agentToolsBuiltInOpenAI) {
|
||||||
|
|
@ -953,7 +1064,7 @@ class Agent_Agentflow implements INode {
|
||||||
// Initialize response and determine if streaming is possible
|
// Initialize response and determine if streaming is possible
|
||||||
let response: AIMessageChunk = new AIMessageChunk('')
|
let response: AIMessageChunk = new AIMessageChunk('')
|
||||||
const isLastNode = options.isLastNode as boolean
|
const isLastNode = options.isLastNode as boolean
|
||||||
const isStreamable = isLastNode && options.sseStreamer !== undefined && modelConfig?.streaming !== false
|
const isStreamable = isLastNode && options.sseStreamer !== undefined && modelConfig?.streaming !== false && !isStructuredOutput
|
||||||
|
|
||||||
// Start analytics
|
// Start analytics
|
||||||
if (analyticHandlers && options.parentTraceIds) {
|
if (analyticHandlers && options.parentTraceIds) {
|
||||||
|
|
@ -1002,7 +1113,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmWithoutToolsBind,
|
llmWithoutToolsBind,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput
|
||||||
})
|
})
|
||||||
|
|
||||||
response = result.response
|
response = result.response
|
||||||
|
|
@ -1031,7 +1143,14 @@ class Agent_Agentflow implements INode {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isStreamable) {
|
if (isStreamable) {
|
||||||
response = await this.handleStreamingResponse(sseStreamer, llmNodeInstance, messages, chatId, abortController)
|
response = await this.handleStreamingResponse(
|
||||||
|
sseStreamer,
|
||||||
|
llmNodeInstance,
|
||||||
|
messages,
|
||||||
|
chatId,
|
||||||
|
abortController,
|
||||||
|
isStructuredOutput
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
response = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
response = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
||||||
}
|
}
|
||||||
|
|
@ -1053,7 +1172,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmNodeInstance,
|
llmNodeInstance,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput
|
||||||
})
|
})
|
||||||
|
|
||||||
response = result.response
|
response = result.response
|
||||||
|
|
@ -1080,8 +1200,9 @@ class Agent_Agentflow implements INode {
|
||||||
sseStreamer.streamArtifactsEvent(chatId, flatten(artifacts))
|
sseStreamer.streamArtifactsEvent(chatId, flatten(artifacts))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!humanInput && !isStreamable && isLastNode && sseStreamer) {
|
} else if (!humanInput && !isStreamable && isLastNode && sseStreamer && !isStructuredOutput) {
|
||||||
// Stream whole response back to UI if not streaming and no tool calls
|
// Stream whole response back to UI if not streaming and no tool calls
|
||||||
|
// Skip this if structured output is enabled - it will be streamed after conversion
|
||||||
let finalResponse = ''
|
let finalResponse = ''
|
||||||
if (response.content && Array.isArray(response.content)) {
|
if (response.content && Array.isArray(response.content)) {
|
||||||
finalResponse = response.content.map((item: any) => item.text).join('\n')
|
finalResponse = response.content.map((item: any) => item.text).join('\n')
|
||||||
|
|
@ -1159,6 +1280,23 @@ class Agent_Agentflow implements INode {
|
||||||
finalResponse = await this.processSandboxLinks(finalResponse, options.baseURL, options.chatflowid, chatId)
|
finalResponse = await this.processSandboxLinks(finalResponse, options.baseURL, options.chatflowid, chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If is structured output, then invoke LLM again with structured output at the very end after all tool calls
|
||||||
|
if (isStructuredOutput) {
|
||||||
|
llmNodeInstance = configureStructuredOutput(llmNodeInstance, _agentStructuredOutput)
|
||||||
|
const prompt = 'Convert the following response to the structured output format: ' + finalResponse
|
||||||
|
response = await llmNodeInstance.invoke(prompt, { signal: abortController?.signal })
|
||||||
|
|
||||||
|
if (typeof response === 'object') {
|
||||||
|
finalResponse = '```json\n' + JSON.stringify(response, null, 2) + '\n```'
|
||||||
|
} else {
|
||||||
|
finalResponse = response
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLastNode && sseStreamer) {
|
||||||
|
sseStreamer.streamTokenEvent(chatId, finalResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const output = this.prepareOutputObject(
|
const output = this.prepareOutputObject(
|
||||||
response,
|
response,
|
||||||
availableTools,
|
availableTools,
|
||||||
|
|
@ -1171,7 +1309,8 @@ class Agent_Agentflow implements INode {
|
||||||
artifacts,
|
artifacts,
|
||||||
additionalTokens,
|
additionalTokens,
|
||||||
isWaitingForHumanInput,
|
isWaitingForHumanInput,
|
||||||
fileAnnotations
|
fileAnnotations,
|
||||||
|
isStructuredOutput
|
||||||
)
|
)
|
||||||
|
|
||||||
// End analytics tracking
|
// End analytics tracking
|
||||||
|
|
@ -1561,13 +1700,14 @@ class Agent_Agentflow implements INode {
|
||||||
llmNodeInstance: BaseChatModel,
|
llmNodeInstance: BaseChatModel,
|
||||||
messages: BaseMessageLike[],
|
messages: BaseMessageLike[],
|
||||||
chatId: string,
|
chatId: string,
|
||||||
abortController: AbortController
|
abortController: AbortController,
|
||||||
|
isStructuredOutput: boolean = false
|
||||||
): Promise<AIMessageChunk> {
|
): Promise<AIMessageChunk> {
|
||||||
let response = new AIMessageChunk('')
|
let response = new AIMessageChunk('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of await llmNodeInstance.stream(messages, { signal: abortController?.signal })) {
|
for await (const chunk of await llmNodeInstance.stream(messages, { signal: abortController?.signal })) {
|
||||||
if (sseStreamer) {
|
if (sseStreamer && !isStructuredOutput) {
|
||||||
let content = ''
|
let content = ''
|
||||||
|
|
||||||
if (typeof chunk === 'string') {
|
if (typeof chunk === 'string') {
|
||||||
|
|
@ -1610,7 +1750,8 @@ class Agent_Agentflow implements INode {
|
||||||
artifacts: any[],
|
artifacts: any[],
|
||||||
additionalTokens: number = 0,
|
additionalTokens: number = 0,
|
||||||
isWaitingForHumanInput: boolean = false,
|
isWaitingForHumanInput: boolean = false,
|
||||||
fileAnnotations: any[] = []
|
fileAnnotations: any[] = [],
|
||||||
|
isStructuredOutput: boolean = false
|
||||||
): any {
|
): any {
|
||||||
const output: any = {
|
const output: any = {
|
||||||
content: finalResponse,
|
content: finalResponse,
|
||||||
|
|
@ -1645,6 +1786,15 @@ class Agent_Agentflow implements INode {
|
||||||
output.responseMetadata = response.response_metadata
|
output.responseMetadata = response.response_metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStructuredOutput && typeof response === 'object') {
|
||||||
|
const structuredOutput = response as Record<string, any>
|
||||||
|
for (const key in structuredOutput) {
|
||||||
|
if (structuredOutput[key] !== undefined && structuredOutput[key] !== null) {
|
||||||
|
output[key] = structuredOutput[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add used tools, source documents and artifacts to output
|
// Add used tools, source documents and artifacts to output
|
||||||
if (usedTools && usedTools.length > 0) {
|
if (usedTools && usedTools.length > 0) {
|
||||||
output.usedTools = flatten(usedTools)
|
output.usedTools = flatten(usedTools)
|
||||||
|
|
@ -1710,7 +1860,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmNodeInstance,
|
llmNodeInstance,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput = false
|
||||||
}: {
|
}: {
|
||||||
response: AIMessageChunk
|
response: AIMessageChunk
|
||||||
messages: BaseMessageLike[]
|
messages: BaseMessageLike[]
|
||||||
|
|
@ -1724,6 +1875,7 @@ class Agent_Agentflow implements INode {
|
||||||
isStreamable: boolean
|
isStreamable: boolean
|
||||||
isLastNode: boolean
|
isLastNode: boolean
|
||||||
iterationContext: ICommonObject
|
iterationContext: ICommonObject
|
||||||
|
isStructuredOutput?: boolean
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
response: AIMessageChunk
|
response: AIMessageChunk
|
||||||
usedTools: IUsedTool[]
|
usedTools: IUsedTool[]
|
||||||
|
|
@ -1803,7 +1955,9 @@ class Agent_Agentflow implements INode {
|
||||||
const toolCallDetails = '```json\n' + JSON.stringify(toolCall, null, 2) + '\n```'
|
const toolCallDetails = '```json\n' + JSON.stringify(toolCall, null, 2) + '\n```'
|
||||||
const responseContent = response.content + `\nAttempting to use tool:\n${toolCallDetails}`
|
const responseContent = response.content + `\nAttempting to use tool:\n${toolCallDetails}`
|
||||||
response.content = responseContent
|
response.content = responseContent
|
||||||
|
if (!isStructuredOutput) {
|
||||||
sseStreamer?.streamTokenEvent(chatId, responseContent)
|
sseStreamer?.streamTokenEvent(chatId, responseContent)
|
||||||
|
}
|
||||||
return { response, usedTools, sourceDocuments, artifacts, totalTokens, isWaitingForHumanInput: true }
|
return { response, usedTools, sourceDocuments, artifacts, totalTokens, isWaitingForHumanInput: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1909,7 +2063,7 @@ class Agent_Agentflow implements INode {
|
||||||
const lastToolOutput = usedTools[0]?.toolOutput || ''
|
const lastToolOutput = usedTools[0]?.toolOutput || ''
|
||||||
const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2)
|
const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2)
|
||||||
|
|
||||||
if (sseStreamer) {
|
if (sseStreamer && !isStructuredOutput) {
|
||||||
sseStreamer.streamTokenEvent(chatId, lastToolOutputString)
|
sseStreamer.streamTokenEvent(chatId, lastToolOutputString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1938,12 +2092,19 @@ class Agent_Agentflow implements INode {
|
||||||
let newResponse: AIMessageChunk
|
let newResponse: AIMessageChunk
|
||||||
|
|
||||||
if (isStreamable) {
|
if (isStreamable) {
|
||||||
newResponse = await this.handleStreamingResponse(sseStreamer, llmNodeInstance, messages, chatId, abortController)
|
newResponse = await this.handleStreamingResponse(
|
||||||
|
sseStreamer,
|
||||||
|
llmNodeInstance,
|
||||||
|
messages,
|
||||||
|
chatId,
|
||||||
|
abortController,
|
||||||
|
isStructuredOutput
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
||||||
|
|
||||||
// Stream non-streaming response if this is the last node
|
// Stream non-streaming response if this is the last node
|
||||||
if (isLastNode && sseStreamer) {
|
if (isLastNode && sseStreamer && !isStructuredOutput) {
|
||||||
let responseContent = JSON.stringify(newResponse, null, 2)
|
let responseContent = JSON.stringify(newResponse, null, 2)
|
||||||
if (typeof newResponse.content === 'string') {
|
if (typeof newResponse.content === 'string') {
|
||||||
responseContent = newResponse.content
|
responseContent = newResponse.content
|
||||||
|
|
@ -1978,7 +2139,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmNodeInstance,
|
llmNodeInstance,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge results from recursive tool calls
|
// Merge results from recursive tool calls
|
||||||
|
|
@ -2009,7 +2171,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmWithoutToolsBind,
|
llmWithoutToolsBind,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput = false
|
||||||
}: {
|
}: {
|
||||||
humanInput: IHumanInput
|
humanInput: IHumanInput
|
||||||
humanInputAction: Record<string, any> | undefined
|
humanInputAction: Record<string, any> | undefined
|
||||||
|
|
@ -2024,6 +2187,7 @@ class Agent_Agentflow implements INode {
|
||||||
isStreamable: boolean
|
isStreamable: boolean
|
||||||
isLastNode: boolean
|
isLastNode: boolean
|
||||||
iterationContext: ICommonObject
|
iterationContext: ICommonObject
|
||||||
|
isStructuredOutput?: boolean
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
response: AIMessageChunk
|
response: AIMessageChunk
|
||||||
usedTools: IUsedTool[]
|
usedTools: IUsedTool[]
|
||||||
|
|
@ -2226,7 +2390,7 @@ class Agent_Agentflow implements INode {
|
||||||
const lastToolOutput = usedTools[0]?.toolOutput || ''
|
const lastToolOutput = usedTools[0]?.toolOutput || ''
|
||||||
const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2)
|
const lastToolOutputString = typeof lastToolOutput === 'string' ? lastToolOutput : JSON.stringify(lastToolOutput, null, 2)
|
||||||
|
|
||||||
if (sseStreamer) {
|
if (sseStreamer && !isStructuredOutput) {
|
||||||
sseStreamer.streamTokenEvent(chatId, lastToolOutputString)
|
sseStreamer.streamTokenEvent(chatId, lastToolOutputString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2257,12 +2421,19 @@ class Agent_Agentflow implements INode {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isStreamable) {
|
if (isStreamable) {
|
||||||
newResponse = await this.handleStreamingResponse(sseStreamer, llmNodeInstance, messages, chatId, abortController)
|
newResponse = await this.handleStreamingResponse(
|
||||||
|
sseStreamer,
|
||||||
|
llmNodeInstance,
|
||||||
|
messages,
|
||||||
|
chatId,
|
||||||
|
abortController,
|
||||||
|
isStructuredOutput
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
newResponse = await llmNodeInstance.invoke(messages, { signal: abortController?.signal })
|
||||||
|
|
||||||
// Stream non-streaming response if this is the last node
|
// Stream non-streaming response if this is the last node
|
||||||
if (isLastNode && sseStreamer) {
|
if (isLastNode && sseStreamer && !isStructuredOutput) {
|
||||||
let responseContent = JSON.stringify(newResponse, null, 2)
|
let responseContent = JSON.stringify(newResponse, null, 2)
|
||||||
if (typeof newResponse.content === 'string') {
|
if (typeof newResponse.content === 'string') {
|
||||||
responseContent = newResponse.content
|
responseContent = newResponse.content
|
||||||
|
|
@ -2297,7 +2468,8 @@ class Agent_Agentflow implements INode {
|
||||||
llmNodeInstance,
|
llmNodeInstance,
|
||||||
isStreamable,
|
isStreamable,
|
||||||
isLastNode,
|
isLastNode,
|
||||||
iterationContext
|
iterationContext,
|
||||||
|
isStructuredOutput
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge results from recursive tool calls
|
// Merge results from recursive tool calls
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@ import { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
||||||
import { ICommonObject, IMessage, INode, INodeData, INodeOptionsValue, INodeParams, IServerSideEventStreamer } from '../../../src/Interface'
|
import { ICommonObject, IMessage, INode, INodeData, INodeOptionsValue, INodeParams, IServerSideEventStreamer } from '../../../src/Interface'
|
||||||
import { AIMessageChunk, BaseMessageLike, MessageContentText } from '@langchain/core/messages'
|
import { AIMessageChunk, BaseMessageLike, MessageContentText } from '@langchain/core/messages'
|
||||||
import { DEFAULT_SUMMARIZER_TEMPLATE } from '../prompt'
|
import { DEFAULT_SUMMARIZER_TEMPLATE } from '../prompt'
|
||||||
import { z } from 'zod'
|
|
||||||
import { AnalyticHandler } from '../../../src/handler'
|
import { AnalyticHandler } from '../../../src/handler'
|
||||||
import { ILLMMessage, IStructuredOutput } from '../Interface.Agentflow'
|
import { ILLMMessage } from '../Interface.Agentflow'
|
||||||
import {
|
import {
|
||||||
getPastChatHistoryImageMessages,
|
getPastChatHistoryImageMessages,
|
||||||
getUniqueImageMessages,
|
getUniqueImageMessages,
|
||||||
|
|
@ -12,7 +11,7 @@ import {
|
||||||
replaceBase64ImagesWithFileReferences,
|
replaceBase64ImagesWithFileReferences,
|
||||||
updateFlowState
|
updateFlowState
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { processTemplateVariables } from '../../../src/utils'
|
import { processTemplateVariables, configureStructuredOutput } from '../../../src/utils'
|
||||||
import { flatten } from 'lodash'
|
import { flatten } from 'lodash'
|
||||||
|
|
||||||
class LLM_Agentflow implements INode {
|
class LLM_Agentflow implements INode {
|
||||||
|
|
@ -452,7 +451,7 @@ class LLM_Agentflow implements INode {
|
||||||
// Configure structured output if specified
|
// Configure structured output if specified
|
||||||
const isStructuredOutput = _llmStructuredOutput && Array.isArray(_llmStructuredOutput) && _llmStructuredOutput.length > 0
|
const isStructuredOutput = _llmStructuredOutput && Array.isArray(_llmStructuredOutput) && _llmStructuredOutput.length > 0
|
||||||
if (isStructuredOutput) {
|
if (isStructuredOutput) {
|
||||||
llmNodeInstance = this.configureStructuredOutput(llmNodeInstance, _llmStructuredOutput)
|
llmNodeInstance = configureStructuredOutput(llmNodeInstance, _llmStructuredOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize response and determine if streaming is possible
|
// Initialize response and determine if streaming is possible
|
||||||
|
|
@ -755,59 +754,6 @@ class LLM_Agentflow implements INode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Configures structured output for the LLM
|
|
||||||
*/
|
|
||||||
private configureStructuredOutput(llmNodeInstance: BaseChatModel, llmStructuredOutput: IStructuredOutput[]): BaseChatModel {
|
|
||||||
try {
|
|
||||||
const zodObj: ICommonObject = {}
|
|
||||||
for (const sch of llmStructuredOutput) {
|
|
||||||
if (sch.type === 'string') {
|
|
||||||
zodObj[sch.key] = z.string().describe(sch.description || '')
|
|
||||||
} else if (sch.type === 'stringArray') {
|
|
||||||
zodObj[sch.key] = z.array(z.string()).describe(sch.description || '')
|
|
||||||
} else if (sch.type === 'number') {
|
|
||||||
zodObj[sch.key] = z.number().describe(sch.description || '')
|
|
||||||
} else if (sch.type === 'boolean') {
|
|
||||||
zodObj[sch.key] = z.boolean().describe(sch.description || '')
|
|
||||||
} else if (sch.type === 'enum') {
|
|
||||||
const enumValues = sch.enumValues?.split(',').map((item: string) => item.trim()) || []
|
|
||||||
zodObj[sch.key] = z
|
|
||||||
.enum(enumValues.length ? (enumValues as [string, ...string[]]) : ['default'])
|
|
||||||
.describe(sch.description || '')
|
|
||||||
} else if (sch.type === 'jsonArray') {
|
|
||||||
const jsonSchema = sch.jsonSchema
|
|
||||||
if (jsonSchema) {
|
|
||||||
try {
|
|
||||||
// Parse the JSON schema
|
|
||||||
const schemaObj = JSON.parse(jsonSchema)
|
|
||||||
|
|
||||||
// Create a Zod schema from the JSON schema
|
|
||||||
const itemSchema = this.createZodSchemaFromJSON(schemaObj)
|
|
||||||
|
|
||||||
// Create an array schema of the item schema
|
|
||||||
zodObj[sch.key] = z.array(itemSchema).describe(sch.description || '')
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error parsing JSON schema for ${sch.key}:`, err)
|
|
||||||
// Fallback to generic array of records
|
|
||||||
zodObj[sch.key] = z.array(z.record(z.any())).describe(sch.description || '')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If no schema provided, use generic array of records
|
|
||||||
zodObj[sch.key] = z.array(z.record(z.any())).describe(sch.description || '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const structuredOutput = z.object(zodObj)
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return llmNodeInstance.withStructuredOutput(structuredOutput)
|
|
||||||
} catch (exception) {
|
|
||||||
console.error(exception)
|
|
||||||
return llmNodeInstance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles streaming response from the LLM
|
* Handles streaming response from the LLM
|
||||||
*/
|
*/
|
||||||
|
|
@ -911,107 +857,6 @@ class LLM_Agentflow implements INode {
|
||||||
|
|
||||||
sseStreamer.streamEndEvent(chatId)
|
sseStreamer.streamEndEvent(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Zod schema from a JSON schema object
|
|
||||||
* @param jsonSchema The JSON schema object
|
|
||||||
* @returns A Zod schema
|
|
||||||
*/
|
|
||||||
private createZodSchemaFromJSON(jsonSchema: any): z.ZodTypeAny {
|
|
||||||
// If the schema is an object with properties, create an object schema
|
|
||||||
if (typeof jsonSchema === 'object' && jsonSchema !== null) {
|
|
||||||
const schemaObj: Record<string, z.ZodTypeAny> = {}
|
|
||||||
|
|
||||||
// Process each property in the schema
|
|
||||||
for (const [key, value] of Object.entries(jsonSchema)) {
|
|
||||||
if (value === null) {
|
|
||||||
// Handle null values
|
|
||||||
schemaObj[key] = z.null()
|
|
||||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
// Check if the property has a type definition
|
|
||||||
if ('type' in value) {
|
|
||||||
const type = value.type as string
|
|
||||||
const description = ('description' in value ? (value.description as string) : '') || ''
|
|
||||||
|
|
||||||
// Create the appropriate Zod type based on the type property
|
|
||||||
if (type === 'string') {
|
|
||||||
schemaObj[key] = z.string().describe(description)
|
|
||||||
} else if (type === 'number') {
|
|
||||||
schemaObj[key] = z.number().describe(description)
|
|
||||||
} else if (type === 'boolean') {
|
|
||||||
schemaObj[key] = z.boolean().describe(description)
|
|
||||||
} else if (type === 'array') {
|
|
||||||
// If it's an array type, check if items is defined
|
|
||||||
if ('items' in value && value.items) {
|
|
||||||
const itemSchema = this.createZodSchemaFromJSON(value.items)
|
|
||||||
schemaObj[key] = z.array(itemSchema).describe(description)
|
|
||||||
} else {
|
|
||||||
// Default to array of any if items not specified
|
|
||||||
schemaObj[key] = z.array(z.any()).describe(description)
|
|
||||||
}
|
|
||||||
} else if (type === 'object') {
|
|
||||||
// If it's an object type, check if properties is defined
|
|
||||||
if ('properties' in value && value.properties) {
|
|
||||||
const nestedSchema = this.createZodSchemaFromJSON(value.properties)
|
|
||||||
schemaObj[key] = nestedSchema.describe(description)
|
|
||||||
} else {
|
|
||||||
// Default to record of any if properties not specified
|
|
||||||
schemaObj[key] = z.record(z.any()).describe(description)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Default to any for unknown types
|
|
||||||
schemaObj[key] = z.any().describe(description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the property is optional
|
|
||||||
if ('optional' in value && value.optional === true) {
|
|
||||||
schemaObj[key] = schemaObj[key].optional()
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
// Array values without a type property
|
|
||||||
if (value.length > 0) {
|
|
||||||
// If the array has items, recursively create a schema for the first item
|
|
||||||
const itemSchema = this.createZodSchemaFromJSON(value[0])
|
|
||||||
schemaObj[key] = z.array(itemSchema)
|
|
||||||
} else {
|
|
||||||
// Empty array, allow any array
|
|
||||||
schemaObj[key] = z.array(z.any())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// It's a nested object without a type property, recursively create schema
|
|
||||||
schemaObj[key] = this.createZodSchemaFromJSON(value)
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
// Array values
|
|
||||||
if (value.length > 0) {
|
|
||||||
// If the array has items, recursively create a schema for the first item
|
|
||||||
const itemSchema = this.createZodSchemaFromJSON(value[0])
|
|
||||||
schemaObj[key] = z.array(itemSchema)
|
|
||||||
} else {
|
|
||||||
// Empty array, allow any array
|
|
||||||
schemaObj[key] = z.array(z.any())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For primitive values (which shouldn't be in the schema directly)
|
|
||||||
// Use the corresponding Zod type
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
schemaObj[key] = z.string()
|
|
||||||
} else if (typeof value === 'number') {
|
|
||||||
schemaObj[key] = z.number()
|
|
||||||
} else if (typeof value === 'boolean') {
|
|
||||||
schemaObj[key] = z.boolean()
|
|
||||||
} else {
|
|
||||||
schemaObj[key] = z.any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return z.object(schemaObj)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to any for unknown types
|
|
||||||
return z.any()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { nodeClass: LLM_Agentflow }
|
module.exports = { nodeClass: LLM_Agentflow }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { cloneDeep, omit, get } from 'lodash'
|
||||||
import TurndownService from 'turndown'
|
import TurndownService from 'turndown'
|
||||||
import { DataSource, Equal } from 'typeorm'
|
import { DataSource, Equal } from 'typeorm'
|
||||||
import { ICommonObject, IDatabaseEntity, IFileUpload, IMessage, INodeData, IVariable, MessageContentImageUrl } from './Interface'
|
import { ICommonObject, IDatabaseEntity, IFileUpload, IMessage, INodeData, IVariable, MessageContentImageUrl } from './Interface'
|
||||||
|
import { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
||||||
import { AES, enc } from 'crypto-js'
|
import { AES, enc } from 'crypto-js'
|
||||||
import { AIMessage, HumanMessage, BaseMessage } from '@langchain/core/messages'
|
import { AIMessage, HumanMessage, BaseMessage } from '@langchain/core/messages'
|
||||||
import { Document } from '@langchain/core/documents'
|
import { Document } from '@langchain/core/documents'
|
||||||
|
|
@ -1941,3 +1942,160 @@ export async function parseWithTypeConversion<T extends z.ZodTypeAny>(schema: T,
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures structured output for the LLM using Zod schema
|
||||||
|
* @param {BaseChatModel} llmNodeInstance - The LLM instance to configure
|
||||||
|
* @param {any[]} structuredOutput - Array of structured output schema definitions
|
||||||
|
* @returns {BaseChatModel} - The configured LLM instance
|
||||||
|
*/
|
||||||
|
export const configureStructuredOutput = (llmNodeInstance: BaseChatModel, structuredOutput: any[]): BaseChatModel => {
|
||||||
|
try {
|
||||||
|
const zodObj: ICommonObject = {}
|
||||||
|
for (const sch of structuredOutput) {
|
||||||
|
if (sch.type === 'string') {
|
||||||
|
zodObj[sch.key] = z.string().describe(sch.description || '')
|
||||||
|
} else if (sch.type === 'stringArray') {
|
||||||
|
zodObj[sch.key] = z.array(z.string()).describe(sch.description || '')
|
||||||
|
} else if (sch.type === 'number') {
|
||||||
|
zodObj[sch.key] = z.number().describe(sch.description || '')
|
||||||
|
} else if (sch.type === 'boolean') {
|
||||||
|
zodObj[sch.key] = z.boolean().describe(sch.description || '')
|
||||||
|
} else if (sch.type === 'enum') {
|
||||||
|
const enumValues = sch.enumValues?.split(',').map((item: string) => item.trim()) || []
|
||||||
|
zodObj[sch.key] = z
|
||||||
|
.enum(enumValues.length ? (enumValues as [string, ...string[]]) : ['default'])
|
||||||
|
.describe(sch.description || '')
|
||||||
|
} else if (sch.type === 'jsonArray') {
|
||||||
|
const jsonSchema = sch.jsonSchema
|
||||||
|
if (jsonSchema) {
|
||||||
|
try {
|
||||||
|
// Parse the JSON schema
|
||||||
|
const schemaObj = JSON.parse(jsonSchema)
|
||||||
|
|
||||||
|
// Create a Zod schema from the JSON schema
|
||||||
|
const itemSchema = createZodSchemaFromJSON(schemaObj)
|
||||||
|
|
||||||
|
// Create an array schema of the item schema
|
||||||
|
zodObj[sch.key] = z.array(itemSchema).describe(sch.description || '')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error parsing JSON schema for ${sch.key}:`, err)
|
||||||
|
// Fallback to generic array of records
|
||||||
|
zodObj[sch.key] = z.array(z.record(z.any())).describe(sch.description || '')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no schema provided, use generic array of records
|
||||||
|
zodObj[sch.key] = z.array(z.record(z.any())).describe(sch.description || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const structuredOutputSchema = z.object(zodObj)
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return llmNodeInstance.withStructuredOutput(structuredOutputSchema)
|
||||||
|
} catch (exception) {
|
||||||
|
console.error(exception)
|
||||||
|
return llmNodeInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Zod schema from a JSON schema object
|
||||||
|
* @param {any} jsonSchema - The JSON schema object
|
||||||
|
* @returns {z.ZodTypeAny} - A Zod schema
|
||||||
|
*/
|
||||||
|
export const createZodSchemaFromJSON = (jsonSchema: any): z.ZodTypeAny => {
|
||||||
|
// If the schema is an object with properties, create an object schema
|
||||||
|
if (typeof jsonSchema === 'object' && jsonSchema !== null) {
|
||||||
|
const schemaObj: Record<string, z.ZodTypeAny> = {}
|
||||||
|
|
||||||
|
// Process each property in the schema
|
||||||
|
for (const [key, value] of Object.entries(jsonSchema)) {
|
||||||
|
if (value === null) {
|
||||||
|
// Handle null values
|
||||||
|
schemaObj[key] = z.null()
|
||||||
|
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
// Check if the property has a type definition
|
||||||
|
if ('type' in value) {
|
||||||
|
const type = value.type as string
|
||||||
|
const description = ('description' in value ? (value.description as string) : '') || ''
|
||||||
|
|
||||||
|
// Create the appropriate Zod type based on the type property
|
||||||
|
if (type === 'string') {
|
||||||
|
schemaObj[key] = z.string().describe(description)
|
||||||
|
} else if (type === 'number') {
|
||||||
|
schemaObj[key] = z.number().describe(description)
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
schemaObj[key] = z.boolean().describe(description)
|
||||||
|
} else if (type === 'array') {
|
||||||
|
// If it's an array type, check if items is defined
|
||||||
|
if ('items' in value && value.items) {
|
||||||
|
const itemSchema = createZodSchemaFromJSON(value.items)
|
||||||
|
schemaObj[key] = z.array(itemSchema).describe(description)
|
||||||
|
} else {
|
||||||
|
// Default to array of any if items not specified
|
||||||
|
schemaObj[key] = z.array(z.any()).describe(description)
|
||||||
|
}
|
||||||
|
} else if (type === 'object') {
|
||||||
|
// If it's an object type, check if properties is defined
|
||||||
|
if ('properties' in value && value.properties) {
|
||||||
|
const nestedSchema = createZodSchemaFromJSON(value.properties)
|
||||||
|
schemaObj[key] = nestedSchema.describe(description)
|
||||||
|
} else {
|
||||||
|
// Default to record of any if properties not specified
|
||||||
|
schemaObj[key] = z.record(z.any()).describe(description)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default to any for unknown types
|
||||||
|
schemaObj[key] = z.any().describe(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the property is optional
|
||||||
|
if ('optional' in value && value.optional === true) {
|
||||||
|
schemaObj[key] = schemaObj[key].optional()
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// Array values without a type property
|
||||||
|
if (value.length > 0) {
|
||||||
|
// If the array has items, recursively create a schema for the first item
|
||||||
|
const itemSchema = createZodSchemaFromJSON(value[0])
|
||||||
|
schemaObj[key] = z.array(itemSchema)
|
||||||
|
} else {
|
||||||
|
// Empty array, allow any array
|
||||||
|
schemaObj[key] = z.array(z.any())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's a nested object without a type property, recursively create schema
|
||||||
|
schemaObj[key] = createZodSchemaFromJSON(value)
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// Array values
|
||||||
|
if (value.length > 0) {
|
||||||
|
// If the array has items, recursively create a schema for the first item
|
||||||
|
const itemSchema = createZodSchemaFromJSON(value[0])
|
||||||
|
schemaObj[key] = z.array(itemSchema)
|
||||||
|
} else {
|
||||||
|
// Empty array, allow any array
|
||||||
|
schemaObj[key] = z.array(z.any())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For primitive values (which shouldn't be in the schema directly)
|
||||||
|
// Use the corresponding Zod type
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
schemaObj[key] = z.string()
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
schemaObj[key] = z.number()
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
schemaObj[key] = z.boolean()
|
||||||
|
} else {
|
||||||
|
schemaObj[key] = z.any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return z.object(schemaObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to any for unknown types
|
||||||
|
return z.any()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export const suggestionOptions = (
|
||||||
category: 'Node Outputs'
|
category: 'Node Outputs'
|
||||||
})
|
})
|
||||||
|
|
||||||
const structuredOutputs = nodeData?.inputs?.llmStructuredOutput ?? []
|
const structuredOutputs = nodeData?.inputs?.llmStructuredOutput ?? nodeData?.inputs?.agentStructuredOutput ?? []
|
||||||
if (structuredOutputs && structuredOutputs.length > 0) {
|
if (structuredOutputs && structuredOutputs.length > 0) {
|
||||||
structuredOutputs.forEach((item) => {
|
structuredOutputs.forEach((item) => {
|
||||||
defaultItems.unshift({
|
defaultItems.unshift({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue