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
33 changes: 33 additions & 0 deletions packages/components/credentials/LoopQuestApi.credential.ts
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 }
155 changes: 155 additions & 0 deletions packages/components/nodes/tools/LoopQuest/LoopQuest.ts
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(
/\/+$/,
''
)
Comment on lines +95 to +102

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.

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

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)
                }
            }

})
}
}

module.exports = { nodeClass: LoopQuest_Tools }
64 changes: 64 additions & 0 deletions packages/components/nodes/tools/LoopQuest/core.ts
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

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.

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
}
5 changes: 5 additions & 0 deletions packages/components/nodes/tools/LoopQuest/loopquest.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.