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
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ describe('useAvailableVariables', () => {
const { result } = renderHook(() => useAvailableVariables('agent_0'))

const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(1)
expect(nodeOutputs).toHaveLength(2)
expect(nodeOutputs[0].value).toBe('{{llm_0}}')
expect(nodeOutputs[0].label).toBe('llm_0')
expect(nodeOutputs[1].value).toBe('{{llm_0.output.artifacts}}')
expect(nodeOutputs[1].label).toBe('llm_0.output.artifacts')
})

it('excludes startAgentflow from node outputs', () => {
Expand Down Expand Up @@ -96,6 +98,7 @@ describe('useAvailableVariables', () => {

const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs[0].label).toBe('myFunc')
expect(nodeOutputs[1].label).toBe('myFunc.output.artifacts')
})

it('returns flow state variables from startAgentflow node', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export function useAvailableVariables(nodeId: string): VariableItem[] {
icon: agentflowIcon?.icon,
iconColor: agentflowIcon?.color
})
items.push({
label: `${displayName}.output.artifacts`,
description: `Artifacts from ${node.data.label ?? node.data.name}`,
category: 'Node Outputs',
value: `{{${node.id}.output.artifacts}}`,
icon: agentflowIcon?.icon,
iconColor: agentflowIcon?.color
})
}

// ── Flow state variables from all nodes ─────────────────────────
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const { nodeClass: DirectReply_Agentflow } = require('./DirectReply')

describe('DirectReply_Agentflow', () => {
const createNode = () => new DirectReply_Agentflow()
const createOptions = () => ({
agentflowRuntime: { state: { existing: 'state' } },
chatId: 'chat-1',
isLastNode: true,
sseStreamer: {
streamTokenEvent: jest.fn(),
streamArtifactsEvent: jest.fn()
}
})

it('returns text only when no artifacts are provided', async () => {
const node = createNode()
const options = createOptions()

const result = await node.run(
{
id: 'directReplyAgentflow_0',
inputs: { directReplyMessage: 'done' }
},
'',
options
)

expect(result.output).toEqual({ content: 'done' })
expect(options.sseStreamer.streamTokenEvent).toHaveBeenCalledWith('chat-1', 'done')
expect(options.sseStreamer.streamArtifactsEvent).not.toHaveBeenCalled()
})

it('attaches resolved artifacts from the artifacts input', async () => {
const node = createNode()
const options = createOptions()
const artifacts = [
{ type: 'png', data: 'FILE-STORAGE::image.png' },
{ type: 'pdf', data: 'FILE-STORAGE::report.pdf' }
]

const result = await node.run(
{
id: 'directReplyAgentflow_0',
inputs: {
directReplyMessage: 'generated files',
directReplyArtifacts: JSON.stringify(artifacts)
}
},
'',
options
)

expect(result.output).toEqual({
content: 'generated files',
artifacts
})
expect(options.sseStreamer.streamTokenEvent).toHaveBeenCalledWith('chat-1', 'generated files')
expect(options.sseStreamer.streamArtifactsEvent).toHaveBeenCalledWith('chat-1', artifacts)
})

it('detects artifact-only messages without returning artifact JSON as content', async () => {
const node = createNode()
const options = createOptions()
const artifacts = [{ type: 'png', data: 'FILE-STORAGE::image.png' }]

const result = await node.run(
{
id: 'directReplyAgentflow_0',
inputs: { directReplyMessage: JSON.stringify(artifacts) }
},
'',
options
)

expect(result.output).toEqual({
content: ' ',
artifacts
})
expect(options.sseStreamer.streamTokenEvent).not.toHaveBeenCalled()
expect(options.sseStreamer.streamArtifactsEvent).toHaveBeenCalledWith('chat-1', artifacts)
})
})
51 changes: 48 additions & 3 deletions packages/components/nodes/agentflow/DirectReply/DirectReply.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
import { ICommonObject, INode, INodeData, INodeParams, IServerSideEventStreamer } from '../../../src/Interface'
import { flatten } from 'lodash'

const parseArtifacts = (value: unknown): ICommonObject[] => {
let artifacts = value

if (typeof artifacts === 'string') {
const trimmedValue = artifacts.trim()
if (!trimmedValue) return []

try {
artifacts = JSON.parse(trimmedValue)
} catch {
return []
}
Comment on lines +11 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid throwing and catching exceptions on every plain text message (which is the most common case for directReplyMessage), we can add a quick heuristic check to see if the string looks like JSON (e.g., starts with [ or {) before calling JSON.parse. This improves performance and prevents debugger disruption when 'Pause on caught exceptions' is enabled. This heuristic-based approach is acceptable here as the risk of false positives is very low.

        if (!trimmedValue.startsWith('[') && !trimmedValue.startsWith('{')) {
            return []
        }

        try {
            artifacts = JSON.parse(trimmedValue)
        } catch {
            return []
        }
References
  1. A heuristic-based solution is acceptable if the risk of false positives is determined to be very low within the specific application's context.

}

const flattenedArtifacts = Array.isArray(artifacts) ? flatten(artifacts) : [artifacts]
return flattenedArtifacts.filter(
(artifact): artifact is ICommonObject =>
typeof artifact === 'object' &&
artifact !== null &&
typeof (artifact as ICommonObject).type === 'string' &&
typeof (artifact as ICommonObject).data === 'string'
)
}

class DirectReply_Agentflow implements INode {
label: string
Expand Down Expand Up @@ -32,30 +57,50 @@ class DirectReply_Agentflow implements INode {
name: 'directReplyMessage',
type: 'string',
rows: 4,
acceptVariable: true
acceptVariable: true,
acceptNodeOutputAsVariable: true
},
{
label: 'Artifacts',
name: 'directReplyArtifacts',
type: 'string',
optional: true,
acceptVariable: true,
acceptNodeOutputAsVariable: true
}
]
}

async run(nodeData: INodeData, _: string, options: ICommonObject): Promise<any> {
const directReplyMessage = nodeData.inputs?.directReplyMessage as string
const directReplyArtifacts = nodeData.inputs?.directReplyArtifacts

const state = options.agentflowRuntime?.state as ICommonObject
const chatId = options.chatId as string
const isLastNode = options.isLastNode as boolean
const isStreamable = isLastNode && options.sseStreamer !== undefined
const artifacts = parseArtifacts(directReplyArtifacts)
const messageArtifacts = artifacts.length > 0 ? [] : parseArtifacts(directReplyMessage)
const resolvedArtifacts = artifacts.length > 0 ? artifacts : messageArtifacts
const outputContent = messageArtifacts.length > 0 && directReplyMessage?.trim() ? ' ' : directReplyMessage

if (isStreamable) {
const sseStreamer: IServerSideEventStreamer = options.sseStreamer
sseStreamer.streamTokenEvent(chatId, directReplyMessage)
if (outputContent?.trim()) {
sseStreamer.streamTokenEvent(chatId, outputContent)
}
Comment on lines +85 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling directReplyMessage?.trim() and outputContent?.trim() can throw a TypeError at runtime if directReplyMessage is not a string (for example, if it is an array or object passed as a variable from an upstream node, which is a common pattern in Agentflow). Optional chaining (?.) only guards against null or undefined, not against other types that lack the .trim() method.

We can make this safer and simpler by:

  1. Setting outputContent to ' ' directly if messageArtifacts.length > 0 (since we already know it contains artifacts and shouldn't be printed as text).
  2. Checking typeof outputContent === 'string' before calling .trim() when streaming.
Suggested change
const outputContent = messageArtifacts.length > 0 && directReplyMessage?.trim() ? ' ' : directReplyMessage
if (isStreamable) {
const sseStreamer: IServerSideEventStreamer = options.sseStreamer
sseStreamer.streamTokenEvent(chatId, directReplyMessage)
if (outputContent?.trim()) {
sseStreamer.streamTokenEvent(chatId, outputContent)
}
const outputContent = messageArtifacts.length > 0 ? ' ' : directReplyMessage
if (isStreamable) {
const sseStreamer: IServerSideEventStreamer = options.sseStreamer
if (typeof outputContent === 'string' && outputContent.trim()) {
sseStreamer.streamTokenEvent(chatId, outputContent)
}

if (resolvedArtifacts.length > 0) {
sseStreamer.streamArtifactsEvent(chatId, resolvedArtifacts)
}
}

const returnOutput = {
id: nodeData.id,
name: this.name,
input: {},
output: {
content: directReplyMessage
content: outputContent,
...(resolvedArtifacts.length > 0 && { artifacts: resolvedArtifacts })
},
state
}
Expand Down