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 } diff --git a/packages/components/nodes/tools/LoopQuest/LoopQuest.ts b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts new file mode 100644 index 00000000000..baa18af419f --- /dev/null +++ b/packages/components/nodes/tools/LoopQuest/LoopQuest.ts @@ -0,0 +1,155 @@ +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, handleErrorMessage } 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) + 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' + // 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({ + 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' + }) + 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 { + // 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)}` + } + } + }) + } +} + +module.exports = { nodeClass: LoopQuest_Tools } 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 +} 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 @@ + + + + +