-
-
Notifications
You must be signed in to change notification settings - Fork 24.7k
feat: add LoopQuest human-in-the-loop tool node #6592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6ed0002
c3175e1
b883d7f
3d504c5
9aeb22f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<any> { | ||
| 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)}` | ||
| } | ||
| } | ||
|
Comment on lines
+121
to
+150
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap the API requests in 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)
}
} |
||
| }) | ||
| } | ||
| } | ||
|
|
||
| module.exports = { nodeClass: LoopQuest_Tools } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string, unknown> { | ||||||||||||||||
| const payload: Record<string, unknown> = { content: input.content, body: input.content }; | ||||||||||||||||
| if (input.claim) payload['claim'] = input.claim; | ||||||||||||||||
| if (input.source) payload['source'] = input.source; | ||||||||||||||||
|
|
||||||||||||||||
| const body: Record<string, unknown> = { | ||||||||||||||||
| 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') { | ||||||||||||||||
|
Comment on lines
+51
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
References
|
||||||||||||||||
| 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 | ||||||||||||||||
| } | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a check to ensure the
apiKeyis provided. Throwing a clear error when the API key is missing prevents confusing runtime failures.References