Skip to content

feat: add LoopQuest human-in-the-loop tool node#6592

Open
TomPhillipsLabs wants to merge 5 commits into
FlowiseAI:mainfrom
TomPhillipsLabs:add-loopquest-node
Open

feat: add LoopQuest human-in-the-loop tool node#6592
TomPhillipsLabs wants to merge 5 commits into
FlowiseAI:mainfrom
TomPhillipsLabs:add-loopquest-node

Conversation

@TomPhillipsLabs

Copy link
Copy Markdown

New tool node: LoopQuest Human Review

Adds a tool node for LoopQuest — a human-in-the-loop step for agents. Before a consequential action, the agent calls request_human_review; a person reviews it, and the verdict returns to the agent.

How it works

Flowise chatflows can't natively pause, so the tool runs a blocking gate: it creates a review task in gate mode (POST /api/v1/tasks) and polls GET /api/v1/tasks/{id} until it resolves (or a fail-closed timeout escalates), then returns a readable verdict string. A Monitor mode creates the review and returns immediately without waiting.

Files

  • packages/components/nodes/tools/LoopQuest/LoopQuest.ts — the INode tool; returns a DynamicStructuredTool (request_human_review).
  • packages/components/nodes/tools/LoopQuest/core.ts — pure helpers (task body + verdict formatting), unit-tested in the source repo.
  • packages/components/nodes/tools/LoopQuest/loopquest.svg — icon.
  • packages/components/credentials/LoopQuestApi.credential.ts — API key (loopQuestApiKey) + optional baseUrl.

Config

Node inputs: Game (swiper/versus/sorter/detective/fixer/redact/grounding), Mode (gate/monitor), gate timeout, max wait, poll interval. The LLM supplies content (+ title, and claim/source for grounding).

Notes

@TomPhillipsLabs

Copy link
Copy Markdown
Author

Test key for review

A dedicated, disposable LoopQuest workspace for validating the node — please feel free to test all paths; I'll delete it after review:

lq_EO33Ufr7JRzvd0hFY_nax8iNFizfNKmc

Auth is a Bearer API key (the loopQuestApiKey credential field). Verify with:

GET https://loopquest.tomphillips.uk/api/v1/me
Authorization: Bearer <key>
→ 200 { "org_id": "...", "workspace": "Flowise Review" }

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces the LoopQuest Human Review tool and its associated API credential configuration, allowing agents to submit outputs for human review and await a verdict. The review feedback focuses on improving robustness and security: validating the presence of the API key, ensuring polling and timeout intervals are positive integers to prevent a potential Denial of Service (DoS) tight loop, wrapping API requests in try/catch blocks to handle transient network errors gracefully, and throwing errors on invalid external API response data to promote fail-fast behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +95 to +99
const apiKey = getCredentialParam('loopQuestApiKey', credentialData, nodeData)
const baseUrl = (getCredentialParam('baseUrl', credentialData, nodeData) || 'https://loopquest.tomphillips.uk').replace(
/\/+$/,
''
)

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

Add a check to ensure the apiKey is provided. Throwing a clear error when the API key is missing prevents confusing runtime failures.

Suggested change
const apiKey = getCredentialParam('loopQuestApiKey', credentialData, nodeData)
const baseUrl = (getCredentialParam('baseUrl', credentialData, nodeData) || 'https://loopquest.tomphillips.uk').replace(
/\/+$/,
''
)
const apiKey = getCredentialParam('loopQuestApiKey', credentialData, nodeData)
if (!apiKey) {
throw new Error('LoopQuest API Key is missing. Please configure the LoopQuest API credential.')
}
const baseUrl = (getCredentialParam('baseUrl', credentialData, nodeData) || 'https://loopquest.tomphillips.uk').replace(
/\/+$/,
''
)
References
  1. When a feature requires a specific configuration (e.g., an API key for a sandboxed environment), it is preferable to throw an error if the configuration is missing rather than silently falling back to a different implementation.
Comment on lines +102 to +104
const timeoutSeconds = Number(nodeData.inputs?.timeoutSeconds) || 3600
const maxWaitSeconds = Number(nodeData.inputs?.maxWaitSeconds) || 300
const pollSeconds = Number(nodeData.inputs?.pollSeconds) || 5

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.

security-high high

Ensure that pollSeconds, maxWaitSeconds, and timeoutSeconds are positive integers. If pollSeconds is configured to 0 or a negative number, it will cause a tight loop that blocks the Node.js event loop, leading to a Denial of Service (DoS).

Suggested change
const timeoutSeconds = Number(nodeData.inputs?.timeoutSeconds) || 3600
const maxWaitSeconds = Number(nodeData.inputs?.maxWaitSeconds) || 300
const pollSeconds = Number(nodeData.inputs?.pollSeconds) || 5
const timeoutSeconds = Math.max(1, Number(nodeData.inputs?.timeoutSeconds) || 3600)
const maxWaitSeconds = Math.max(1, Number(nodeData.inputs?.maxWaitSeconds) || 300)
const pollSeconds = Math.max(1, Number(nodeData.inputs?.pollSeconds) || 5)
Comment on lines +117 to +138
func: async (input: { content: string; title?: string; claim?: string; source?: string }) => {
const body = buildTaskBody(input, {
module: gameModule,
mode,
timeoutSeconds,
onTimeout: 'escalate',
source: 'flowise'
})
const created = await axios.post(`${baseUrl}/api/v1/tasks`, body, { headers })
const taskId = created.data?.id
if (!taskId) return 'Failed to create the review task.'
if (mode !== 'gate') return `Review task ${taskId} created (monitor mode) — not waiting for a verdict.`

const deadline = Date.now() + maxWaitSeconds * 1000
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, pollSeconds * 1000))
const res = await axios.get(`${baseUrl}/api/v1/tasks/${taskId}`, { headers })
const verdict = verdictToString(res.data as TaskStatus)
if (verdict) return verdict
}
return 'No human verdict within the wait window — treat as NOT approved (fail closed).'
}

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

Wrap the API requests in try/catch blocks. Transient network errors during polling should be caught and ignored so they do not abort the entire wait window. Also, use handleErrorMessage to return a clear error message if the initial task creation fails.

            func: async (input: { content: string; title?: string; claim?: string; source?: string }) => {
                const body = buildTaskBody(input, {
                    module: gameModule,
                    mode,
                    timeoutSeconds,
                    onTimeout: 'escalate',
                    source: 'flowise'
                })
                try {
                    const created = await axios.post(baseUrl + "/api/v1/tasks", body, { headers })
                    const taskId = created.data?.id
                    if (!taskId) return "Failed to create the review task."
                    if (mode !== "gate") return "Review task " + taskId + " created (monitor mode) — not waiting for a verdict."

                    const deadline = Date.now() + maxWaitSeconds * 1000
                    while (Date.now() < deadline) {
                        await new Promise((r) => setTimeout(r, pollSeconds * 1000))
                        try {
                            const res = await axios.get(baseUrl + "/api/v1/tasks/" + taskId, { headers })
                            const verdict = verdictToString(res.data as TaskStatus)
                            if (verdict) return verdict
                        } catch (pollError) {
                            // Ignore transient network errors during polling to avoid failing the entire gate
                        }
                    }
                    return "No human verdict within the wait window — treat as NOT approved (fail closed)."
                } catch (error) {
                    return "Error in LoopQuest Human Review: " + handleErrorMessage(error)
                }
            }
import axios from 'axios'
import { DynamicStructuredTool } from '@langchain/core/tools'
import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface'
import { getCredentialData, getCredentialParam } from '../../../src/utils'

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

Import handleErrorMessage from ../../../src/utils to provide descriptive error messages when API requests fail.

Suggested change
import { getCredentialData, getCredentialParam } from '../../../src/utils'
import { getCredentialData, getCredentialParam, handleErrorMessage } from '../../../src/utils'
Comment on lines +51 to +52
export function verdictToString(task: TaskStatus): string | null {
if (task.status === 'reviewed') {

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

When handling potentially invalid data from external sources (like an API response), prefer throwing an error for invalid input types rather than silently returning a default or empty value. This promotes fail-fast behavior.

Suggested change
export function verdictToString(task: TaskStatus): string | null {
if (task.status === 'reviewed') {
export function verdictToString(task: TaskStatus | null | undefined): string {
if (!task) {
throw new Error('Invalid task data received');
}
if (task.status === 'reviewed') {
References
  1. When handling potentially invalid data from external sources (like an API response), prefer throwing an error for invalid input types rather than silently returning a default or empty value. This promotes fail-fast behavior.
@TomPhillipsLabs

Copy link
Copy Markdown
Author

Thanks @gemini-code-assist — applied 4 of the 5 in the latest commit:

  • API key checkinit now throws a clear error if the credential's key is missing.
  • Clamp intervalstimeoutSeconds/maxWaitSeconds/pollSeconds are Math.max(1, …), so a 0/negative poll interval can't tight-loop.
  • try/catch — the whole flow is wrapped (returns handleErrorMessage(error) on failure), and the poll loop swallows transient network errors so one blip doesn't abort the wait window.
  • handleErrorMessage imported from ../../../src/utils.

Skipped one, with reasoning:

  • verdictToString throwing on invalid data — its null return is an intentional "still pending, keep polling" sentinel, not a silent default. Changing it to throw / never return null would break the poll loop (it treats null as "not resolved yet"). Malformed/missing responses are already handled: the poll try/catch ignores them and the loop falls through to the fail-closed timeout return. Keeping null semantics.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Thanks for the update, @TomPhillipsLabs. The changes to init and the clamping of interval values look solid and address the robustness concerns. Regarding your decision to keep the null return in verdictToString as a polling sentinel, that makes perfect sense given the while loop implementation in LoopQuest.ts. The logic is clear and the handling of transient errors is appropriate for this use case. This looks good to go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant