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
100 changes: 100 additions & 0 deletions packages/components/src/followUpPrompts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { FollowUpPromptConfig, FollowUpPromptProvider, ICommonObject } from './Interface'
import { generateFollowUpPrompts } from './followUpPrompts'
import { getCredentialData } from './utils'
import { ChatOpenAI } from '@langchain/openai'

jest.mock('./utils', () => ({
getCredentialData: jest.fn()
}))

const invokeMock = jest.fn()
const withStructuredOutputMock = jest.fn(() => ({
invoke: invokeMock
}))

jest.mock('@langchain/openai', () => ({
ChatOpenAI: jest.fn().mockImplementation(() => ({
withStructuredOutput: withStructuredOutputMock
})),
AzureChatOpenAI: jest.fn()
}))

const mockedGetCredentialData = getCredentialData as jest.MockedFunction<typeof getCredentialData>
const mockedChatOpenAI = ChatOpenAI as jest.MockedClass<typeof ChatOpenAI>

const createFollowUpPromptConfig = (status = true): FollowUpPromptConfig => {
const providerConfig = {
credentialId: 'credential-id',
modelName: 'gpt-4.1-mini',
baseUrl: '',
prompt: 'Suggest follow-up questions for {history}',
temperature: '0.2'
}

return {
status,
selectedProvider: FollowUpPromptProvider.OPENAI,
[FollowUpPromptProvider.ANTHROPIC]: providerConfig,
[FollowUpPromptProvider.AZURE_OPENAI]: providerConfig,
[FollowUpPromptProvider.GOOGLE_GENAI]: providerConfig,
[FollowUpPromptProvider.MISTRALAI]: providerConfig,
[FollowUpPromptProvider.OPENAI]: providerConfig,
[FollowUpPromptProvider.GROQ]: providerConfig,
[FollowUpPromptProvider.OLLAMA]: providerConfig
}
}

describe('generateFollowUpPrompts', () => {
beforeEach(() => {
jest.useRealTimers()
jest.clearAllMocks()
mockedGetCredentialData.mockResolvedValue({ openAIApiKey: 'openai-key' })
})

it('returns follow-up questions when the provider succeeds', async () => {
const questions = ['What is next?', 'Can you expand?', 'Any constraints?']
invokeMock.mockResolvedValue({ questions })

const result = await generateFollowUpPrompts(createFollowUpPromptConfig(), 'history', {} as ICommonObject)

expect(result).toEqual({ questions })
expect(mockedChatOpenAI).toHaveBeenCalledWith({
apiKey: 'openai-key',
model: 'gpt-4.1-mini',
temperature: 0.2,
useResponsesApi: true
})
expect(invokeMock).toHaveBeenCalledWith('Suggest follow-up questions for history')
})

it('retries once when the first provider call fails', async () => {
const questions = ['Question one?', 'Question two?', 'Question three?']
invokeMock.mockRejectedValueOnce(new Error('temporary provider failure')).mockResolvedValueOnce({ questions })

const result = await generateFollowUpPrompts(createFollowUpPromptConfig(), 'history', {} as ICommonObject)

expect(result).toEqual({ questions })
expect(invokeMock).toHaveBeenCalledTimes(2)
})

it('returns undefined when provider calls time out', async () => {
jest.useFakeTimers()
invokeMock.mockImplementation(() => new Promise(() => undefined))

const resultPromise = generateFollowUpPrompts(createFollowUpPromptConfig(), 'history', {} as ICommonObject)
await Promise.resolve()

await jest.advanceTimersByTimeAsync(20000)

await expect(resultPromise).resolves.toBeUndefined()
expect(invokeMock).toHaveBeenCalledTimes(2)
})

it('does not read credentials or call a provider when follow-up prompts are disabled', async () => {
const result = await generateFollowUpPrompts(createFollowUpPromptConfig(false), 'history', {} as ICommonObject)

expect(result).toBeUndefined()
expect(mockedGetCredentialData).not.toHaveBeenCalled()
expect(mockedChatOpenAI).not.toHaveBeenCalled()
})
})
152 changes: 110 additions & 42 deletions packages/components/src/followUpPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,60 @@ export interface FollowUpPromptResult {
questions: string[]
}

interface FollowUpPromptGenerationOptions {
timeoutMs?: number
maxRetries?: number
}

type FollowUpPromptTask<T> = () => Promise<T>

const DEFAULT_FOLLOW_UP_PROMPT_TIMEOUT_MS = 10000
const DEFAULT_FOLLOW_UP_PROMPT_MAX_RETRIES = 1

class FollowUpPromptTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`Follow-up prompt generation timed out after ${timeoutMs}ms`)
this.name = 'FollowUpPromptTimeoutError'
}
}

const createTimeoutPromise = <T>(timeoutMs: number): { promise: Promise<T>; clear: () => void } => {
let timeout: NodeJS.Timeout
const promise = new Promise<T>((_, reject) => {
timeout = setTimeout(() => reject(new FollowUpPromptTimeoutError(timeoutMs)), timeoutMs)
})

return {
promise,
clear: () => clearTimeout(timeout)
}
}

const invokeWithTimeoutAndRetry = async <T>(
task: FollowUpPromptTask<T>,
options: FollowUpPromptGenerationOptions = {}
): Promise<T | undefined> => {
const timeoutMs = options.timeoutMs ?? DEFAULT_FOLLOW_UP_PROMPT_TIMEOUT_MS
const maxRetries = options.maxRetries ?? DEFAULT_FOLLOW_UP_PROMPT_MAX_RETRIES

for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const timeout = createTimeoutPromise<T>(timeoutMs)

try {
const taskPromise = task()
taskPromise.catch(() => undefined)

return await Promise.race([taskPromise, timeout.promise])
} catch {
if (attempt === maxRetries) return undefined
} finally {
timeout.clear()
}
}

return undefined
}
Comment on lines +28 to +75

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

Issues Identified:

  1. Resource Leak / Dangling Requests: When a timeout occurs, the in-flight LLM request is left running in the background, consuming server resources, connections, and potentially API tokens/billing. We should use an AbortController and pass its AbortSignal to the task so that the in-flight request can be aborted when a timeout or failure occurs.
  2. Swallowed Errors: The catch block completely swallows all errors without any logging, making it extremely difficult to debug failures in follow-up prompt generation. We should log the error on the final attempt.
  3. Internal Function Parameters: For internal functions like invokeWithTimeoutAndRetry, we should avoid using an options object and instead use positional parameters until the parameter list becomes complex or more parameters are needed.

Solution:

Update FollowUpPromptTask to accept an AbortSignal, and use AbortController to abort the in-flight request on timeout or failure. Also, log the error on the final attempt, and use positional parameters for invokeWithTimeoutAndRetry instead of an options object.

type FollowUpPromptTask<T> = (signal: AbortSignal) => Promise<T>

const DEFAULT_FOLLOW_UP_PROMPT_TIMEOUT_MS = 10000
const DEFAULT_FOLLOW_UP_PROMPT_MAX_RETRIES = 1

class FollowUpPromptTimeoutError extends Error {
    constructor(timeoutMs: number) {
        super(`Follow-up prompt generation timed out after ${timeoutMs}ms`)
        this.name = 'FollowUpPromptTimeoutError'
    }
}

const createTimeoutPromise = <T>(timeoutMs: number): { promise: Promise<T>; clear: () => void } => {
    let timeout: NodeJS.Timeout
    const promise = new Promise<T>((_, reject) => {
        timeout = setTimeout(() => reject(new FollowUpPromptTimeoutError(timeoutMs)), timeoutMs)
    })

    return {
        promise,
        clear: () => clearTimeout(timeout)
    }
}

const invokeWithTimeoutAndRetry = async <T>(
    task: FollowUpPromptTask<T>,
    timeoutMs: number = DEFAULT_FOLLOW_UP_PROMPT_TIMEOUT_MS,
    maxRetries: number = DEFAULT_FOLLOW_UP_PROMPT_MAX_RETRIES
): Promise<T | undefined> => {
    for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
        const controller = new AbortController()
        const timeout = createTimeoutPromise<T>(timeoutMs)

        try {
            const taskPromise = task(controller.signal)
            taskPromise.catch(() => undefined)

            return await Promise.race([taskPromise, timeout.promise])
        } catch (error) {
            controller.abort()
            if (attempt === maxRetries) {
                console.error(`Follow-up prompt generation failed after ${maxRetries + 1} attempts:`, error)
                return undefined
            }
        } finally {
            timeout.clear()
        }
    }

    return undefined
}
References
  1. For internal functions, defer refactoring to an options object until the parameter list becomes complex or more parameters are needed.


export const generateFollowUpPrompts = async (
followUpPromptsConfig: FollowUpPromptConfig,
apiMessageContent: string,
Expand All @@ -44,8 +98,10 @@ export const generateFollowUpPrompts = async (
const structuredLLM = llm.withStructuredOutput(FollowUpPromptType, {
method: 'functionCalling'
})
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
Comment on lines +101 to +104

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

Pass the AbortSignal to the Anthropic .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt, { signal })
return structuredResponse as FollowUpPromptResult
})

}
case FollowUpPromptProvider.AZURE_OPENAI: {
const azureOpenAIApiKey = credentialData['azureOpenAIApiKey']
Expand All @@ -70,11 +126,13 @@ export const generateFollowUpPrompts = async (
{format_instructions}
`)
const chain = prompt.pipe(llm).pipe(parser)
const structuredResponse = await chain.invoke({
history: apiMessageContent,
format_instructions: formatInstructions
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await chain.invoke({
history: apiMessageContent,
format_instructions: formatInstructions
})
return structuredResponse as FollowUpPromptResult
})
Comment on lines +129 to 135

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

Pass the AbortSignal to the Azure OpenAI .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await chain.invoke({
history: apiMessageContent,
format_instructions: formatInstructions
})
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await chain.invoke({
history: apiMessageContent,
format_instructions: formatInstructions
}, { signal })
return structuredResponse as FollowUpPromptResult
})

return structuredResponse as FollowUpPromptResult
}
case FollowUpPromptProvider.GOOGLE_GENAI: {
const model = new ChatGoogleGenerativeAI({
Expand All @@ -85,8 +143,10 @@ export const generateFollowUpPrompts = async (
const structuredLLM = model.withStructuredOutput(FollowUpPromptType, {
method: 'functionCalling'
})
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
Comment on lines +146 to +149

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

Pass the AbortSignal to the Google GenAI .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt, { signal })
return structuredResponse as FollowUpPromptResult
})

}
case FollowUpPromptProvider.MISTRALAI: {
const model = new ChatMistralAI({
Expand All @@ -98,8 +158,10 @@ export const generateFollowUpPrompts = async (
const structuredLLM = model.withStructuredOutput(FollowUpPromptType, {
method: 'functionCalling'
})
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
Comment on lines +161 to +164

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

Pass the AbortSignal to the Mistral .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt, { signal })
return structuredResponse as FollowUpPromptResult
})

}
case FollowUpPromptProvider.OPENAI: {
const model = new ChatOpenAI({
Expand All @@ -112,8 +174,10 @@ export const generateFollowUpPrompts = async (
const structuredLLM = model.withStructuredOutput(FollowUpPromptType, {
method: 'functionCalling'
})
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
Comment on lines +177 to +180

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

Pass the AbortSignal to the OpenAI .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt, { signal })
return structuredResponse as FollowUpPromptResult
})

}
case FollowUpPromptProvider.GROQ: {
const llm = new ChatGroq({
Expand All @@ -124,44 +188,48 @@ export const generateFollowUpPrompts = async (
const structuredLLM = llm.withStructuredOutput(FollowUpPromptType, {
method: 'functionCalling'
})
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
Comment on lines +191 to +194

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

Pass the AbortSignal to the Groq .invoke() call to ensure the request is aborted on timeout or failure.

Suggested change
return await invokeWithTimeoutAndRetry(async () => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt)
return structuredResponse as FollowUpPromptResult
})
return await invokeWithTimeoutAndRetry(async (signal) => {
const structuredResponse = await structuredLLM.invoke(followUpPromptsPrompt, { signal })
return structuredResponse as FollowUpPromptResult
})

}
case FollowUpPromptProvider.OLLAMA: {
const ollamaClient = new Ollama({
host: providerConfig.baseUrl || 'http://127.0.0.1:11434'
})

const response = await ollamaClient.chat({
model: providerConfig.modelName,
messages: [
{
role: 'user',
content: followUpPromptsPrompt
}
],
format: {
type: 'object',
properties: {
questions: {
type: 'array',
items: {
type: 'string'
},
minItems: 3,
maxItems: 3,
description: 'Three follow-up questions based on the conversation history'
return await invokeWithTimeoutAndRetry(async () => {
const response = await ollamaClient.chat({
model: providerConfig.modelName,
messages: [
{
role: 'user',
content: followUpPromptsPrompt
}
],
format: {
type: 'object',
properties: {
questions: {
type: 'array',
items: {
type: 'string'
},
minItems: 3,
maxItems: 3,
description: 'Three follow-up questions based on the conversation history'
}
},
required: ['questions'],
additionalProperties: false
},
required: ['questions'],
additionalProperties: false
},
options: {
temperature: parseFloat(`${providerConfig.temperature}`)
}
})
const result = FollowUpPromptType.parse(JSON.parse(response.message.content))
return result
options: {
temperature: parseFloat(`${providerConfig.temperature}`)
}
})
const result = FollowUpPromptType.parse(JSON.parse(response.message.content))
return result
})
}
}
} else {
Expand Down