From 6ed000259bc1c1d008e603a9a35ca869eae94ad5 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Thu, 2 Jul 2026 09:38:22 +0100 Subject: [PATCH 1/5] Add LoopQuest: LoopQuest.ts --- .../nodes/tools/LoopQuest/LoopQuest.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/components/nodes/tools/LoopQuest/LoopQuest.ts diff --git a/packages/components/nodes/tools/LoopQuest/LoopQuest.ts b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts new file mode 100644 index 00000000000..a82e80b5e60 --- /dev/null +++ b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts @@ -0,0 +1,143 @@ +import { z } from 'zod/v3' +import axios from 'axios' +import { DynamicStructuredTool } from '@langchain/core/tools' +import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' +import { getCredentialData, getCredentialParam } from '../../../src/utils' +import { buildTaskBody, verdictToString, TaskStatus } from './core' + +class LoopQuest_Tools implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + + constructor() { + this.label = 'LoopQuest Human Review' + this.name = 'loopQuest' + this.version = 1.0 + this.type = 'LoopQuest' + this.icon = 'loopquest.svg' + this.category = 'Tools' + this.description = + "Send the agent's output to a human for review and wait for their verdict (approve/flag) before continuing." + this.baseClasses = [this.type, 'Tool'] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + credentialNames: ['loopQuestApi'] + } + this.inputs = [ + { + label: 'Game', + name: 'module', + type: 'options', + default: 'swiper', + description: 'How the reviewer sees the item.', + options: [ + { label: 'Swiper — approve or reject', name: 'swiper' }, + { label: 'Versus — pick the better of two', name: 'versus' }, + { label: 'Sorter — bucket into categories', name: 'sorter' }, + { label: 'Detective — spot the problem', name: 'detective' }, + { label: 'Fixer — correct the output', name: 'fixer' }, + { label: 'Redact — mask sensitive text', name: 'redact' }, + { label: 'Grounding — verify a claim against a source', name: 'grounding' } + ] + }, + { + label: 'Mode', + name: 'mode', + type: 'options', + default: 'gate', + description: + 'Gate blocks the agent until a human decides. Monitor creates the review and returns immediately without waiting.', + options: [ + { label: 'Gate — wait for a human verdict', name: 'gate' }, + { label: 'Monitor — review in the background', name: 'monitor' } + ] + }, + { + label: 'Gate timeout (seconds)', + name: 'timeoutSeconds', + type: 'number', + default: 3600, + optional: true, + description: 'Server-side fail-closed timeout for the gate (30–2592000). On timeout it escalates.' + }, + { + label: 'Max wait (seconds)', + name: 'maxWaitSeconds', + type: 'number', + default: 300, + optional: true, + additionalParams: true, + description: 'How long this tool blocks polling for a verdict before returning a fail-closed result.' + }, + { + label: 'Poll interval (seconds)', + name: 'pollSeconds', + type: 'number', + default: 5, + optional: true, + additionalParams: true + } + ] + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const apiKey = getCredentialParam('loopQuestApiKey', credentialData, nodeData) + const baseUrl = (getCredentialParam('baseUrl', credentialData, nodeData) || 'https://loopquest.tomphillips.uk').replace( + /\/+$/, + '' + ) + const gameModule = (nodeData.inputs?.module as string) || 'swiper' + const mode = (nodeData.inputs?.mode as string) || 'gate' + const timeoutSeconds = Number(nodeData.inputs?.timeoutSeconds) || 3600 + const maxWaitSeconds = Number(nodeData.inputs?.maxWaitSeconds) || 300 + const pollSeconds = Number(nodeData.inputs?.pollSeconds) || 5 + const headers = { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' } + + return new DynamicStructuredTool({ + name: 'request_human_review', + description: + 'Send content to a human reviewer and wait for their verdict. Use before any consequential or irreversible action.', + schema: z.object({ + content: z.string().describe('The output or decision a human should review.'), + title: z.string().optional().describe('Optional short heading for the reviewer.'), + claim: z.string().optional().describe('Grounding game only: the claim to verify.'), + source: z.string().optional().describe('Grounding game only: the source text to check the claim against.') + }), + 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).' + } + }) + } +} + +module.exports = { nodeClass: LoopQuest_Tools } From c3175e1f095c9f7e3fc0e024df0d33a8f7662308 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Thu, 2 Jul 2026 09:38:23 +0100 Subject: [PATCH 2/5] Add LoopQuest: core.ts --- .../components/nodes/tools/LoopQuest/core.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 packages/components/nodes/tools/LoopQuest/core.ts diff --git a/packages/components/nodes/tools/LoopQuest/core.ts b/packages/components/nodes/tools/LoopQuest/core.ts new file mode 100644 index 00000000000..2fea8ad6db6 --- /dev/null +++ b/packages/components/nodes/tools/LoopQuest/core.ts @@ -0,0 +1,64 @@ +/** + * Pure helpers for the LoopQuest Flowise tool — no Flowise/LangChain imports so + * they can be unit-tested in isolation. + */ + +export interface ReviewConfig { + module: string; // one of the review games + mode: string; // 'gate' | 'monitor' + timeoutSeconds?: number; + onTimeout?: string; // 'escalate' | 'reject' | 'approve' + source?: string; +} + +export interface ReviewInput { + content: string; + title?: string; + claim?: string; // grounding only + source?: string; // grounding only: the reference text +} + +/** Build the POST /api/v1/tasks body from the tool call + node config. */ +export function buildTaskBody(input: ReviewInput, cfg: ReviewConfig): Record { + const payload: Record = { content: input.content, body: input.content }; + if (input.claim) payload['claim'] = input.claim; + if (input.source) payload['source'] = input.source; + + const body: Record = { + module: cfg.module || 'swiper', + mode: cfg.mode || 'gate', + payload, + card: { title: input.title || 'Review', body: input.content }, + source: cfg.source || 'flowise', + }; + if (cfg.timeoutSeconds) body['timeout_seconds'] = cfg.timeoutSeconds; + if (cfg.onTimeout) body['on_timeout'] = cfg.onTimeout; + return body; +} + +export interface TaskStatus { + status?: string; // 'pending' | 'reviewed' | 'escalated' + verdict?: boolean | null; + verdict_choice?: string | null; + verdict_reason?: string | null; + timed_out?: boolean; +} + +/** + * Turn a polled task into a human-readable verdict string for the agent, or + * `null` while it's still pending (keep polling). + */ +export function verdictToString(task: TaskStatus): string | null { + if (task.status === 'reviewed') { + const decision = task.verdict === true ? 'APPROVED' : task.verdict === false ? 'FLAGGED' : 'RESOLVED'; + const parts = [`Human review ${decision}`]; + if (task.verdict_choice) parts.push(`choice: ${task.verdict_choice}`); + if (task.verdict_reason) parts.push(`reason: ${task.verdict_reason}`); + if (task.timed_out) parts.push('(auto-resolved on timeout)'); + return parts.join(' · '); + } + if (task.status === 'escalated') { + return 'Human review ESCALATED — no automatic verdict; a person will follow up.'; + } + return null; // still pending +} From b883d7f5712ce5232d87b875dd6bb1925c6fabe5 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Thu, 2 Jul 2026 09:38:24 +0100 Subject: [PATCH 3/5] Add LoopQuest: loopquest.svg --- packages/components/nodes/tools/LoopQuest/loopquest.svg | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/components/nodes/tools/LoopQuest/loopquest.svg diff --git a/packages/components/nodes/tools/LoopQuest/loopquest.svg b/packages/components/nodes/tools/LoopQuest/loopquest.svg new file mode 100644 index 00000000000..2b89706ddce --- /dev/null +++ b/packages/components/nodes/tools/LoopQuest/loopquest.svg @@ -0,0 +1,5 @@ + + + + + From 3d504c5dbab6489b8119e33d7f7e7a3c6c580629 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Thu, 2 Jul 2026 09:38:25 +0100 Subject: [PATCH 4/5] Add LoopQuest: LoopQuestApi.credential.ts --- .../credentials/LoopQuestApi.credential.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 packages/components/credentials/LoopQuestApi.credential.ts diff --git a/packages/components/credentials/LoopQuestApi.credential.ts b/packages/components/credentials/LoopQuestApi.credential.ts new file mode 100644 index 00000000000..784b389a8c4 --- /dev/null +++ b/packages/components/credentials/LoopQuestApi.credential.ts @@ -0,0 +1,33 @@ +import { INodeParams, INodeCredential } from '../src/Interface' + +class LoopQuestApi implements INodeCredential { + label: string + name: string + version: number + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'LoopQuest API' + this.name = 'loopQuestApi' + this.version = 1.0 + this.description = 'Your LoopQuest workspace API key (Workspaces → API keys).' + this.inputs = [ + { + label: 'API Key', + name: 'loopQuestApiKey', + type: 'password' + }, + { + label: 'Base URL', + name: 'baseUrl', + type: 'string', + optional: true, + default: 'https://loopquest.tomphillips.uk', + description: 'Only change this for a self-hosted LoopQuest deployment.' + } + ] + } +} + +module.exports = { credClass: LoopQuestApi } From 9aeb22fed0577c660e5633a37e390d6b9cdad850 Mon Sep 17 00:00:00 2001 From: Tom Phillips Date: Thu, 2 Jul 2026 09:44:32 +0100 Subject: [PATCH 5/5] Address review: validate API key, clamp intervals, wrap requests in try/catch --- .../nodes/tools/LoopQuest/LoopQuest.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/components/nodes/tools/LoopQuest/LoopQuest.ts b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts index a82e80b5e60..baa18af419f 100644 --- a/packages/components/nodes/tools/LoopQuest/LoopQuest.ts +++ b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts @@ -2,7 +2,7 @@ import { z } from 'zod/v3' import axios from 'axios' import { DynamicStructuredTool } from '@langchain/core/tools' import { ICommonObject, INode, INodeData, INodeParams } from '../../../src/Interface' -import { getCredentialData, getCredentialParam } from '../../../src/utils' +import { getCredentialData, getCredentialParam, handleErrorMessage } from '../../../src/utils' import { buildTaskBody, verdictToString, TaskStatus } from './core' class LoopQuest_Tools implements INode { @@ -93,15 +93,19 @@ class LoopQuest_Tools implements INode { async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const credentialData = await getCredentialData(nodeData.credential ?? '', options) 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( /\/+$/, '' ) const gameModule = (nodeData.inputs?.module as string) || 'swiper' const mode = (nodeData.inputs?.mode as string) || 'gate' - const timeoutSeconds = Number(nodeData.inputs?.timeoutSeconds) || 3600 - const maxWaitSeconds = Number(nodeData.inputs?.maxWaitSeconds) || 300 - const pollSeconds = Number(nodeData.inputs?.pollSeconds) || 5 + // Clamp to positive values — a 0/negative poll interval would tight-loop. + 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) const headers = { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' } return new DynamicStructuredTool({ @@ -122,19 +126,27 @@ class LoopQuest_Tools implements INode { 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.` + 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)) - const res = await axios.get(`${baseUrl}/api/v1/tasks/${taskId}`, { headers }) - const verdict = verdictToString(res.data as TaskStatus) - if (verdict) return 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 { + // Ignore transient network errors while polling — keep waiting. + } + } + return 'No human verdict within the wait window — treat as NOT approved (fail closed).' + } catch (error) { + return `Error in LoopQuest Human Review: ${handleErrorMessage(error)}` } - return 'No human verdict within the wait window — treat as NOT approved (fail closed).' } }) }