mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 07:17:04 +02:00
feat(ai-builder): Implement Core Subgraph Infrastructure (no-changelog) (#22325)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
parent
e7a414c2de
commit
ccd974ea22
|
|
@ -1,4 +1,3 @@
|
|||
/* eslint-disable complexity */
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export function createAgent(
|
|||
llmSimpleTask: llm,
|
||||
llmComplexTask: llm,
|
||||
checkpointer: new MemorySaver(),
|
||||
enableMultiAgent: true,
|
||||
tracer,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { getErrorEntry, getBuilderOutput, getConfiguratorOutput } from '../utils/coordination-log';
|
||||
|
||||
/**
|
||||
* Responder Agent Prompt
|
||||
*
|
||||
* Synthesizes final user-facing responses from workflow building context.
|
||||
* Also handles conversational queries.
|
||||
*/
|
||||
const RESPONDER_PROMPT = `You are a helpful AI assistant for n8n workflow automation.
|
||||
|
||||
You have access to context about what has been built, including:
|
||||
- Discovery results (nodes found)
|
||||
- Builder output (workflow structure)
|
||||
- Configuration summary (setup instructions)
|
||||
|
||||
FOR WORKFLOW COMPLETION RESPONSES:
|
||||
When you receive [Internal Context], synthesize a clean user-facing response:
|
||||
1. Summarize what was built in a friendly way
|
||||
2. Explain the workflow structure briefly
|
||||
3. Include setup instructions if provided
|
||||
4. Ask if user wants adjustments
|
||||
|
||||
Example response structure:
|
||||
"I've created your [workflow type] workflow! Here's what it does:
|
||||
[Brief explanation of the flow]
|
||||
|
||||
**Setup Required:**
|
||||
[List any configuration steps from the context]
|
||||
|
||||
Let me know if you'd like to adjust anything."
|
||||
|
||||
FOR QUESTIONS/CONVERSATIONS:
|
||||
- Be friendly and concise
|
||||
- Explain n8n capabilities when asked
|
||||
- Provide practical examples when helpful
|
||||
|
||||
RESPONSE STYLE:
|
||||
- Keep responses focused and not overly long
|
||||
- Use markdown formatting for readability
|
||||
- Be conversational and helpful`;
|
||||
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: RESPONDER_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
|
||||
export interface ResponderAgentConfig {
|
||||
llm: BaseChatModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context required for the responder to generate a response
|
||||
*/
|
||||
export interface ResponderContext {
|
||||
/** Conversation messages */
|
||||
messages: BaseMessage[];
|
||||
/** Coordination log tracking subgraph completion */
|
||||
coordinationLog: CoordinationLogEntry[];
|
||||
/** Discovery results (nodes found) */
|
||||
discoveryContext?: DiscoveryContext | null;
|
||||
/** Current workflow state */
|
||||
workflowJSON: SimpleWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responder Agent
|
||||
*
|
||||
* Synthesizes final user-facing responses from workflow building context.
|
||||
* Handles conversational queries and explanations.
|
||||
*/
|
||||
export class ResponderAgent {
|
||||
private llm: BaseChatModel;
|
||||
|
||||
constructor(config: ResponderAgentConfig) {
|
||||
this.llm = config.llm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build internal context message from coordination log and state
|
||||
*/
|
||||
private buildContextMessage(context: ResponderContext): HumanMessage | null {
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// Check for errors first - if there's an error, surface it prominently
|
||||
const errorEntry = getErrorEntry(context.coordinationLog);
|
||||
if (errorEntry) {
|
||||
contextParts.push(
|
||||
`**Error:** An error occurred in the ${errorEntry.phase} phase: ${errorEntry.summary}`,
|
||||
);
|
||||
contextParts.push(
|
||||
'Please apologize to the user and explain that something went wrong while building their workflow.',
|
||||
);
|
||||
}
|
||||
|
||||
// Discovery context
|
||||
if (context.discoveryContext?.nodesFound.length) {
|
||||
contextParts.push(
|
||||
`**Discovery:** Found ${context.discoveryContext.nodesFound.length} relevant nodes`,
|
||||
);
|
||||
}
|
||||
|
||||
// Builder output
|
||||
const builderOutput = getBuilderOutput(context.coordinationLog);
|
||||
if (builderOutput) {
|
||||
contextParts.push(`**Builder:** ${builderOutput}`);
|
||||
} else if (context.workflowJSON.nodes.length) {
|
||||
contextParts.push(`**Workflow:** ${context.workflowJSON.nodes.length} nodes created`);
|
||||
}
|
||||
|
||||
// Configurator output
|
||||
const configuratorOutput = getConfiguratorOutput(context.coordinationLog);
|
||||
if (configuratorOutput) {
|
||||
contextParts.push(`**Configuration:**\n${configuratorOutput}`);
|
||||
}
|
||||
|
||||
if (contextParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new HumanMessage({
|
||||
content: `[Internal Context - Use this to craft your response]\n${contextParts.join('\n\n')}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the responder agent with the given context
|
||||
*/
|
||||
async invoke(context: ResponderContext): Promise<AIMessage> {
|
||||
const agent = systemPrompt.pipe(this.llm);
|
||||
|
||||
const contextMessage = this.buildContextMessage(context);
|
||||
const messagesToSend = contextMessage
|
||||
? [...context.messages, contextMessage]
|
||||
: context.messages;
|
||||
|
||||
return (await agent.invoke({ messages: messagesToSend })) as AIMessage;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { buildWorkflowSummary } from '../utils/context-builders';
|
||||
import { summarizeCoordinationLog } from '../utils/coordination-log';
|
||||
|
||||
/**
|
||||
* Supervisor Agent Prompt
|
||||
*
|
||||
* Handles INITIAL routing based on user intent.
|
||||
* After initial routing, deterministic routing takes over based on coordination log.
|
||||
*/
|
||||
const SUPERVISOR_PROMPT = `You are a Supervisor that routes user requests to specialist agents.
|
||||
|
||||
AVAILABLE AGENTS:
|
||||
- discovery: Find n8n nodes for building/modifying workflows
|
||||
- builder: Create nodes and connections (requires discovery first for new node types)
|
||||
- configurator: Set parameters on EXISTING nodes (no structural changes)
|
||||
- responder: Answer questions, confirm completion (TERMINAL)
|
||||
|
||||
ROUTING DECISION TREE:
|
||||
|
||||
1. Is user asking a question or chatting? → responder
|
||||
Examples: "what does this do?", "explain the workflow", "thanks"
|
||||
|
||||
2. Does the request involve NEW or DIFFERENT node types? → discovery
|
||||
Examples:
|
||||
- "Build a workflow that..." (new workflow)
|
||||
- "Use [ServiceB] instead of [ServiceA]" (replacing node type)
|
||||
- "Add [some integration]" (new integration)
|
||||
- "Switch from [ServiceA] to [ServiceB]" (swapping services)
|
||||
|
||||
3. Is the request about connecting/disconnecting existing nodes? → builder
|
||||
Examples: "Connect node A to node B", "Remove the connection to X"
|
||||
|
||||
4. Is the request about changing VALUES in existing nodes? → configurator
|
||||
Examples:
|
||||
- "Change the URL to https://..."
|
||||
- "Set the timeout to 30 seconds"
|
||||
- "Update the email subject to..."
|
||||
|
||||
KEY DISTINCTION:
|
||||
- "Use [ServiceB] instead of [ServiceA]" = REPLACEMENT = discovery (new node type needed)
|
||||
- "Change the [ServiceA] API key" = CONFIGURATION = configurator (same node, different value)
|
||||
|
||||
OUTPUT:
|
||||
- reasoning: One sentence explaining your routing decision
|
||||
- next: Agent name`;
|
||||
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
SUPERVISOR_PROMPT +
|
||||
'\n\nGiven the conversation above, which agent should act next? Provide your reasoning and selection.',
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Schema for supervisor routing decision
|
||||
*/
|
||||
export const supervisorRoutingSchema = z.object({
|
||||
reasoning: z.string().describe('One sentence explaining why this agent should act next'),
|
||||
next: z
|
||||
.enum(['responder', 'discovery', 'builder', 'configurator'])
|
||||
.describe('The next agent to call'),
|
||||
});
|
||||
|
||||
export type SupervisorRouting = z.infer<typeof supervisorRoutingSchema>;
|
||||
|
||||
export interface SupervisorAgentConfig {
|
||||
llm: BaseChatModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context required for the supervisor to make routing decisions
|
||||
*/
|
||||
export interface SupervisorContext {
|
||||
/** Conversation messages */
|
||||
messages: BaseMessage[];
|
||||
/** Current workflow state */
|
||||
workflowJSON: SimpleWorkflow;
|
||||
/** Coordination log tracking subgraph completion */
|
||||
coordinationLog: CoordinationLogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervisor Agent
|
||||
*
|
||||
* Coordinates the multi-agent workflow building process.
|
||||
* Routes to Discovery, Builder, or Configurator agents based on current state.
|
||||
*/
|
||||
export class SupervisorAgent {
|
||||
private llm: BaseChatModel;
|
||||
|
||||
constructor(config: SupervisorAgentConfig) {
|
||||
this.llm = config.llm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context message with workflow summary and completed phases
|
||||
*/
|
||||
private buildContextMessage(context: SupervisorContext): HumanMessage | null {
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. Workflow summary (node count and names only)
|
||||
if (context.workflowJSON.nodes.length > 0) {
|
||||
contextParts.push('<workflow_summary>');
|
||||
contextParts.push(buildWorkflowSummary(context.workflowJSON));
|
||||
contextParts.push('</workflow_summary>');
|
||||
}
|
||||
|
||||
// 2. Coordination log summary (what phases completed)
|
||||
if (context.coordinationLog.length > 0) {
|
||||
contextParts.push('<completed_phases>');
|
||||
contextParts.push(summarizeCoordinationLog(context.coordinationLog));
|
||||
contextParts.push('</completed_phases>');
|
||||
}
|
||||
|
||||
if (contextParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new HumanMessage({ content: contextParts.join('\n\n') });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the supervisor to get routing decision
|
||||
*/
|
||||
async invoke(context: SupervisorContext): Promise<SupervisorRouting> {
|
||||
const agent = systemPrompt.pipe<SupervisorRouting>(
|
||||
this.llm.withStructuredOutput(supervisorRoutingSchema, {
|
||||
name: 'routing_decision',
|
||||
}),
|
||||
);
|
||||
|
||||
const contextMessage = this.buildContextMessage(context);
|
||||
const messagesToSend = contextMessage
|
||||
? [...context.messages, contextMessage]
|
||||
: context.messages;
|
||||
|
||||
return await agent.invoke({ messages: messagesToSend });
|
||||
}
|
||||
}
|
||||
|
|
@ -162,6 +162,7 @@ export class AiWorkflowBuilderService {
|
|||
llmSimpleTask: anthropicClaude,
|
||||
llmComplexTask: anthropicClaude,
|
||||
logger: this.logger,
|
||||
enableMultiAgent: process.env.N8N_ENABLE_MULTI_AGENT === 'true',
|
||||
checkpointer: this.sessionManager.getCheckpointer(),
|
||||
tracer: tracingClient
|
||||
? new LangChainTracer({ client: tracingClient, projectName: 'n8n-workflow-builder' })
|
||||
|
|
|
|||
|
|
@ -36,3 +36,11 @@ export const MAX_WORKFLOW_LENGTH_TOKENS = 30_000;
|
|||
* Used for rough token count estimation from character counts.
|
||||
*/
|
||||
export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5;
|
||||
|
||||
/**
|
||||
* Maximum iterations for subgraph tool loops.
|
||||
* Prevents infinite loops when agents keep calling tools without finishing.
|
||||
*/
|
||||
export const MAX_BUILDER_ITERATIONS = 30;
|
||||
export const MAX_CONFIGURATOR_ITERATIONS = 30;
|
||||
export const MAX_DISCOVERY_ITERATIONS = 50;
|
||||
|
|
|
|||
|
|
@ -79,3 +79,25 @@ export const anthropicClaudeSonnet45 = async (config: LLMProviderConfig) => {
|
|||
|
||||
return model;
|
||||
};
|
||||
|
||||
export const anthropicHaiku45 = async (config: LLMProviderConfig) => {
|
||||
const { ChatAnthropic } = await import('@langchain/anthropic');
|
||||
const model = new ChatAnthropic({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
apiKey: config.apiKey,
|
||||
temperature: 0,
|
||||
maxTokens: MAX_OUTPUT_TOKENS,
|
||||
anthropicApiUrl: config.baseUrl,
|
||||
clientOptions: {
|
||||
defaultHeaders: config.headers,
|
||||
fetchOptions: {
|
||||
dispatcher: getProxyAgent(config.baseUrl),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Remove Langchain default topP parameter since Sonnet 4.5 doesn't allow setting both temperature and topP
|
||||
delete model.topP;
|
||||
|
||||
return model;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { StateGraph, END, START, type MemorySaver } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { ResponderAgent } from './agents/responder.agent';
|
||||
import { SupervisorAgent } from './agents/supervisor.agent';
|
||||
import {
|
||||
MAX_BUILDER_ITERATIONS,
|
||||
MAX_CONFIGURATOR_ITERATIONS,
|
||||
MAX_DISCOVERY_ITERATIONS,
|
||||
} from './constants';
|
||||
import { ParentGraphState } from './parent-graph-state';
|
||||
import { BuilderSubgraph } from './subgraphs/builder.subgraph';
|
||||
import { ConfiguratorSubgraph } from './subgraphs/configurator.subgraph';
|
||||
import { DiscoverySubgraph } from './subgraphs/discovery.subgraph';
|
||||
import type { BaseSubgraph } from './subgraphs/subgraph-interface';
|
||||
import type { SubgraphPhase } from './types/coordination';
|
||||
import { createErrorMetadata } from './types/coordination';
|
||||
import { getNextPhaseFromLog } from './utils/coordination-log';
|
||||
import { processOperations } from './utils/operations-processor';
|
||||
|
||||
/**
|
||||
* Maps routing decisions to graph node names.
|
||||
* Used by both supervisor (LLM-based) and process_operations (deterministic) routing.
|
||||
*/
|
||||
function routeToNode(next: string): string {
|
||||
const nodeMapping: Record<string, string> = {
|
||||
responder: 'responder',
|
||||
discovery: 'discovery_subgraph',
|
||||
builder: 'builder_subgraph',
|
||||
configurator: 'configurator_subgraph',
|
||||
};
|
||||
return nodeMapping[next] ?? 'responder';
|
||||
}
|
||||
|
||||
export interface MultiAgentSubgraphConfig {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llmSimpleTask: BaseChatModel;
|
||||
llmComplexTask: BaseChatModel;
|
||||
logger?: Logger;
|
||||
instanceUrl?: string;
|
||||
checkpointer?: MemorySaver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subgraph node handler with standardized error handling
|
||||
*/
|
||||
function createSubgraphNodeHandler<
|
||||
TSubgraph extends BaseSubgraph<unknown, Record<string, unknown>, Record<string, unknown>>,
|
||||
>(
|
||||
subgraph: TSubgraph,
|
||||
compiledGraph: ReturnType<TSubgraph['create']>,
|
||||
name: string,
|
||||
logger?: Logger,
|
||||
recursionLimit?: number,
|
||||
) {
|
||||
return async (state: typeof ParentGraphState.State) => {
|
||||
try {
|
||||
const input = subgraph.transformInput(state);
|
||||
const result = await compiledGraph.invoke(input, { recursionLimit });
|
||||
const output = subgraph.transformOutput(result, state);
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
logger?.error(`[${name}] ERROR:`, { error });
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : `An error occurred in ${name}: ${String(error)}`;
|
||||
|
||||
// Extract phase from subgraph name (e.g., 'discovery_subgraph' → 'discovery')
|
||||
const phase = name.replace('_subgraph', '') as SubgraphPhase;
|
||||
|
||||
// Route to responder to report error (terminal)
|
||||
// Add error entry to coordination log so getNextPhaseFromLog routes to responder
|
||||
return {
|
||||
nextPhase: 'responder',
|
||||
messages: [
|
||||
new HumanMessage({
|
||||
content: `Error in ${name}: ${errorMessage}`,
|
||||
name: 'system_error',
|
||||
}),
|
||||
],
|
||||
coordinationLog: [
|
||||
{
|
||||
phase,
|
||||
status: 'error' as const,
|
||||
timestamp: Date.now(),
|
||||
summary: `Error: ${errorMessage}`,
|
||||
metadata: createErrorMetadata({
|
||||
failedSubgraph: phase,
|
||||
errorMessage,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Multi-Agent Workflow using Subgraph Pattern
|
||||
*
|
||||
* Each specialist agent runs in its own isolated subgraph.
|
||||
* Parent graph orchestrates between subgraphs with minimal shared state.
|
||||
*/
|
||||
export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraphConfig) {
|
||||
const { parsedNodeTypes, llmComplexTask, logger, instanceUrl, checkpointer } = config;
|
||||
|
||||
const supervisorAgent = new SupervisorAgent({ llm: llmComplexTask });
|
||||
const responderAgent = new ResponderAgent({ llm: llmComplexTask });
|
||||
|
||||
// Create subgraph instances
|
||||
const discoverySubgraph = new DiscoverySubgraph();
|
||||
const builderSubgraph = new BuilderSubgraph();
|
||||
const configuratorSubgraph = new ConfiguratorSubgraph();
|
||||
|
||||
// Compile subgraphs
|
||||
const compiledDiscovery = discoverySubgraph.create({
|
||||
parsedNodeTypes,
|
||||
llm: llmComplexTask,
|
||||
logger,
|
||||
});
|
||||
const compiledBuilder = builderSubgraph.create({ parsedNodeTypes, llm: llmComplexTask, logger });
|
||||
const compiledConfigurator = configuratorSubgraph.create({
|
||||
parsedNodeTypes,
|
||||
llm: llmComplexTask,
|
||||
logger,
|
||||
instanceUrl,
|
||||
});
|
||||
|
||||
// Build graph using method chaining for proper TypeScript inference
|
||||
return (
|
||||
new StateGraph(ParentGraphState)
|
||||
// Add Supervisor Node (only used for initial routing)
|
||||
.addNode('supervisor', async (state) => {
|
||||
const routing = await supervisorAgent.invoke({
|
||||
messages: state.messages,
|
||||
workflowJSON: state.workflowJSON,
|
||||
coordinationLog: state.coordinationLog,
|
||||
});
|
||||
|
||||
return {
|
||||
nextPhase: routing.next,
|
||||
};
|
||||
})
|
||||
// Add Responder Node (synthesizes final user-facing response)
|
||||
.addNode('responder', async (state) => {
|
||||
const response = await responderAgent.invoke({
|
||||
messages: state.messages,
|
||||
coordinationLog: state.coordinationLog,
|
||||
discoveryContext: state.discoveryContext,
|
||||
workflowJSON: state.workflowJSON,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [response], // Only responder adds to user messages
|
||||
};
|
||||
})
|
||||
// Add process_operations node for hybrid operations approach
|
||||
.addNode('process_operations', (state) => {
|
||||
// Process accumulated operations and clear the queue
|
||||
const result = processOperations(state);
|
||||
|
||||
return {
|
||||
...result,
|
||||
workflowOperations: [], // Clear operations after processing
|
||||
};
|
||||
})
|
||||
// Add Subgraph Nodes (using helper to reduce duplication)
|
||||
.addNode(
|
||||
'discovery_subgraph',
|
||||
createSubgraphNodeHandler(
|
||||
discoverySubgraph,
|
||||
compiledDiscovery,
|
||||
'discovery_subgraph',
|
||||
logger,
|
||||
MAX_DISCOVERY_ITERATIONS,
|
||||
),
|
||||
)
|
||||
.addNode(
|
||||
'builder_subgraph',
|
||||
createSubgraphNodeHandler(
|
||||
builderSubgraph,
|
||||
compiledBuilder,
|
||||
'builder_subgraph',
|
||||
logger,
|
||||
MAX_BUILDER_ITERATIONS,
|
||||
),
|
||||
)
|
||||
.addNode(
|
||||
'configurator_subgraph',
|
||||
createSubgraphNodeHandler(
|
||||
configuratorSubgraph,
|
||||
compiledConfigurator,
|
||||
'configurator_subgraph',
|
||||
logger,
|
||||
MAX_CONFIGURATOR_ITERATIONS,
|
||||
),
|
||||
)
|
||||
// Connect all subgraphs to process_operations
|
||||
.addEdge('discovery_subgraph', 'process_operations')
|
||||
.addEdge('builder_subgraph', 'process_operations')
|
||||
.addEdge('configurator_subgraph', 'process_operations')
|
||||
// Start flows to supervisor (initial routing only)
|
||||
.addEdge(START, 'supervisor')
|
||||
// Conditional Edge for Supervisor (initial routing via LLM)
|
||||
.addConditionalEdges('supervisor', (state) => routeToNode(state.nextPhase))
|
||||
// Deterministic routing after subgraphs complete (based on coordination log)
|
||||
.addConditionalEdges('process_operations', (state) =>
|
||||
routeToNode(getNextPhaseFromLog(state.coordinationLog)),
|
||||
)
|
||||
// Responder ends the workflow
|
||||
.addEdge('responder', END)
|
||||
// Compile the graph
|
||||
.compile({ checkpointer })
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { Annotation } from '@langchain/langgraph';
|
||||
|
||||
import type { CoordinationLogEntry } from './types/coordination';
|
||||
import type { DiscoveryContext } from './types/discovery-types';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from './types/workflow';
|
||||
import type { ChatPayload } from './workflow-builder-agent';
|
||||
|
||||
/**
|
||||
* Parent Graph State
|
||||
*
|
||||
* Minimal state that coordinates between subgraphs.
|
||||
* Each subgraph has its own isolated state.
|
||||
*/
|
||||
export const ParentGraphState = Annotation.Root({
|
||||
// Shared: User's conversation history (for responder)
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Shared: Current workflow being built
|
||||
workflowJSON: Annotation<SimpleWorkflow>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => ({ nodes: [], connections: {}, name: '' }),
|
||||
}),
|
||||
|
||||
// Input: Workflow context (execution data)
|
||||
workflowContext: Annotation<ChatPayload['workflowContext'] | undefined>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
}),
|
||||
|
||||
// Routing: Next phase to execute
|
||||
nextPhase: Annotation<string>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => '',
|
||||
}),
|
||||
|
||||
// Discovery context to pass to other agents
|
||||
discoveryContext: Annotation<DiscoveryContext | null>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => null,
|
||||
}),
|
||||
|
||||
// Workflow operations collected from subgraphs (hybrid approach)
|
||||
workflowOperations: Annotation<WorkflowOperation[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Coordination log for tracking subgraph completion (deterministic routing)
|
||||
coordinationLog: Annotation<CoordinationLogEntry[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import { Annotation, StateGraph } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import type { ChatPayload } from '@/workflow-builder-agent';
|
||||
|
||||
import { BaseSubgraph } from './subgraph-interface';
|
||||
import type { ParentGraphState } from '../parent-graph-state';
|
||||
import { createAddNodeTool } from '../tools/add-node.tool';
|
||||
import { createConnectNodesTool } from '../tools/connect-nodes.tool';
|
||||
import { createRemoveConnectionTool } from '../tools/remove-connection.tool';
|
||||
import { createRemoveNodeTool } from '../tools/remove-node.tool';
|
||||
import { createValidateStructureTool } from '../tools/validate-structure.tool';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createBuilderMetadata } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import {
|
||||
buildDiscoveryContextBlock,
|
||||
buildWorkflowJsonBlock,
|
||||
buildExecutionSchemaBlock,
|
||||
createContextMessage,
|
||||
} from '../utils/context-builders';
|
||||
import { processOperations } from '../utils/operations-processor';
|
||||
import {
|
||||
executeSubgraphTools,
|
||||
extractUserRequest,
|
||||
createStandardShouldContinue,
|
||||
} from '../utils/subgraph-helpers';
|
||||
|
||||
/**
|
||||
* Builder Agent Prompt
|
||||
*/
|
||||
const BUILDER_PROMPT = `You are a Builder Agent specialized in constructing n8n workflows.
|
||||
|
||||
MANDATORY EXECUTION SEQUENCE:
|
||||
You MUST follow these steps IN ORDER. Do not skip any step.
|
||||
|
||||
STEP 1: CREATE NODES
|
||||
- Call add_nodes for EVERY node needed based on discovery results
|
||||
- Create multiple nodes in PARALLEL for efficiency
|
||||
- Do NOT respond with text - START BUILDING immediately
|
||||
|
||||
STEP 2: CONNECT NODES
|
||||
- Call connect_nodes for ALL required connections
|
||||
- Connect multiple node pairs in PARALLEL
|
||||
|
||||
STEP 3: VALIDATE (REQUIRED)
|
||||
- After ALL nodes and connections are created, call validate_structure
|
||||
- This step is MANDATORY - you cannot finish without it
|
||||
- If validation finds issues (missing trigger, invalid connections), fix them and validate again
|
||||
|
||||
STEP 4: RESPOND TO USER
|
||||
- Only after validation passes, provide your brief summary
|
||||
|
||||
⚠️ NEVER respond to the user without calling validate_structure first ⚠️
|
||||
|
||||
NODE CREATION:
|
||||
Each add_nodes call creates ONE node. You must provide:
|
||||
- nodeType: The exact type from discovery (e.g., "n8n-nodes-base.httpRequest")
|
||||
- name: Descriptive name (e.g., "Fetch Weather Data")
|
||||
- connectionParametersReasoning: Explain your thinking about connection parameters
|
||||
- connectionParameters: Parameters that affect connections (or {{}} if none needed)
|
||||
|
||||
<workflow_configuration_node>
|
||||
Always include a Workflow Configuration node at the start of every workflow.
|
||||
|
||||
The Workflow Configuration node (n8n-nodes-base.set) should be placed immediately after the trigger node and before all other processing nodes.
|
||||
|
||||
Placement rules:
|
||||
- Add between trigger and first processing node
|
||||
- Connect: Trigger → Workflow Configuration → First processing node
|
||||
- Name it "Workflow Configuration"
|
||||
</workflow_configuration_node>
|
||||
|
||||
<data_parsing_strategy>
|
||||
For AI-generated structured data, prefer Structured Output Parser nodes over Code nodes.
|
||||
For binary file data, use Extract From File node to extract content from files before processing.
|
||||
Use Code nodes only for custom business logic beyond parsing.
|
||||
</data_parsing_strategy>
|
||||
|
||||
<proactive_design>
|
||||
Anticipate workflow needs:
|
||||
- IF nodes for conditional logic when multiple outcomes exist
|
||||
- Set nodes for data transformation between incompatible formats
|
||||
- Schedule Triggers for recurring tasks
|
||||
- Error handling for external service calls
|
||||
|
||||
NEVER use Split In Batches nodes.
|
||||
</proactive_design>
|
||||
|
||||
<node_defaults_warning>
|
||||
CRITICAL: NEVER RELY ON DEFAULT PARAMETER VALUES FOR CONNECTIONS
|
||||
|
||||
Default values often hide connection inputs/outputs. You MUST explicitly configure parameters that affect connections:
|
||||
- Vector Store: Mode parameter affects available connections - always set explicitly (e.g., mode: "insert", "retrieve", "retrieve-as-tool")
|
||||
- AI Agent: hasOutputParser default may not match your workflow needs
|
||||
- Document Loader: textSplittingMode affects whether it accepts a text splitter input
|
||||
|
||||
ALWAYS check node details and set connectionParameters explicitly.
|
||||
</node_defaults_warning>
|
||||
|
||||
CONNECTION PARAMETERS EXAMPLES:
|
||||
- Static nodes (HTTP Request, Set, Code): reasoning="Static inputs/outputs", parameters={{}}
|
||||
- AI Agent with parser: reasoning="hasOutputParser creates additional input", parameters={{ hasOutputParser: true }}
|
||||
- Vector Store insert: reasoning="Insert mode requires document input", parameters={{ mode: "insert" }}
|
||||
- Document Loader custom: reasoning="Custom mode enables text splitter input", parameters={{ textSplittingMode: "custom" }}
|
||||
|
||||
<node_connections_understanding>
|
||||
n8n connections flow from SOURCE (output) to TARGET (input).
|
||||
|
||||
Regular data flow: Source node output → Target node input
|
||||
Example: HTTP Request → Set (HTTP Request is source, Set is target)
|
||||
|
||||
AI sub-nodes PROVIDE capabilities, making them the SOURCE:
|
||||
- OpenAI Chat Model → AI Agent [ai_languageModel]
|
||||
- Calculator Tool → AI Agent [ai_tool]
|
||||
- Window Buffer Memory → AI Agent [ai_memory]
|
||||
- Token Splitter → Default Data Loader [ai_textSplitter]
|
||||
- Default Data Loader → Vector Store [ai_document]
|
||||
- Embeddings OpenAI → Vector Store [ai_embedding]
|
||||
</node_connections_understanding>
|
||||
|
||||
<agent_node_distinction>
|
||||
Distinguish between two different agent node types:
|
||||
|
||||
1. **AI Agent** (@n8n/n8n-nodes-langchain.agent)
|
||||
- Main workflow node that orchestrates AI tasks
|
||||
- Use for: Primary AI logic, chatbots, autonomous workflows
|
||||
|
||||
2. **AI Agent Tool** (@n8n/n8n-nodes-langchain.agentTool)
|
||||
- Sub-node that acts as a tool for another AI Agent
|
||||
- Use for: Multi-agent systems where one agent calls another
|
||||
|
||||
Default assumption: When discovery results include "agent", use AI Agent
|
||||
unless explicitly specified as "agent tool" or "sub-agent".
|
||||
</agent_node_distinction>
|
||||
|
||||
<rag_workflow_pattern>
|
||||
For RAG (Retrieval-Augmented Generation) workflows:
|
||||
|
||||
Main data flow:
|
||||
- Data source (e.g., HTTP Request) → Vector Store [main connection]
|
||||
|
||||
AI capability connections:
|
||||
- Document Loader → Vector Store [ai_document]
|
||||
- Embeddings → Vector Store [ai_embedding]
|
||||
- Text Splitter → Document Loader [ai_textSplitter]
|
||||
|
||||
Common mistake to avoid:
|
||||
- NEVER connect Document Loader to main data outputs
|
||||
- Document Loader is an AI sub-node that gives Vector Store document processing capability
|
||||
</rag_workflow_pattern>
|
||||
|
||||
<connection_type_examples>
|
||||
**Main Connections** (regular data flow):
|
||||
- Trigger → HTTP Request → Set → Email
|
||||
|
||||
**AI Language Model Connections** (ai_languageModel):
|
||||
- OpenAI Chat Model → AI Agent
|
||||
|
||||
**AI Tool Connections** (ai_tool):
|
||||
- Calculator Tool → AI Agent
|
||||
- AI Agent Tool → AI Agent (for multi-agent systems)
|
||||
|
||||
**AI Document Connections** (ai_document):
|
||||
- Document Loader → Vector Store
|
||||
|
||||
**AI Embedding Connections** (ai_embedding):
|
||||
- OpenAI Embeddings → Vector Store
|
||||
|
||||
**AI Text Splitter Connections** (ai_textSplitter):
|
||||
- Token Text Splitter → Document Loader
|
||||
|
||||
**AI Memory Connections** (ai_memory):
|
||||
- Window Buffer Memory → AI Agent
|
||||
|
||||
**AI Vector Store in retrieve-as-tool mode** (ai_tool):
|
||||
- Vector Store → AI Agent
|
||||
</connection_type_examples>
|
||||
|
||||
DO NOT:
|
||||
- Respond before calling validate_structure
|
||||
- Skip validation even if you think structure is correct
|
||||
- Add commentary between tool calls - execute tools silently
|
||||
- Configure node parameters (that's the Configurator Agent's job)
|
||||
- Search for nodes (that's the Discovery Agent's job)
|
||||
- Make assumptions about node types - use exactly what Discovery found
|
||||
|
||||
RESPONSE FORMAT (only after validation):
|
||||
Provide ONE brief text message summarizing:
|
||||
- What nodes were added
|
||||
- How they're connected
|
||||
|
||||
Example: "Created 4 nodes: Trigger → Weather → Image Generation → Email"`;
|
||||
|
||||
/**
|
||||
* Builder Subgraph State
|
||||
*/
|
||||
export const BuilderSubgraphState = Annotation.Root({
|
||||
// Input: Current workflow to modify
|
||||
workflowJSON: Annotation<SimpleWorkflow>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => ({ nodes: [], connections: {}, name: '' }),
|
||||
}),
|
||||
|
||||
// Input: What user wants
|
||||
userRequest: Annotation<string>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => '',
|
||||
}),
|
||||
|
||||
// Input: Execution context (optional)
|
||||
workflowContext: Annotation<ChatPayload['workflowContext'] | undefined>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
}),
|
||||
|
||||
// Input: Discovery context from parent
|
||||
discoveryContext: Annotation<DiscoveryContext | null>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => null,
|
||||
}),
|
||||
|
||||
// Internal: Conversation
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Internal: Operations queue
|
||||
workflowOperations: Annotation<WorkflowOperation[] | null>({
|
||||
reducer: (x, y) => {
|
||||
if (y === null) return [];
|
||||
if (!y || y.length === 0) return x ?? [];
|
||||
return [...(x ?? []), ...y];
|
||||
},
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
export interface BuilderSubgraphConfig {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export class BuilderSubgraph extends BaseSubgraph<
|
||||
BuilderSubgraphConfig,
|
||||
typeof BuilderSubgraphState.State,
|
||||
typeof ParentGraphState.State
|
||||
> {
|
||||
name = 'builder_subgraph';
|
||||
description = 'Constructs workflow structure: creating nodes and connections';
|
||||
|
||||
create(config: BuilderSubgraphConfig) {
|
||||
// Create tools
|
||||
const tools = [
|
||||
createAddNodeTool(config.parsedNodeTypes),
|
||||
createConnectNodesTool(config.parsedNodeTypes, config.logger),
|
||||
createRemoveNodeTool(config.logger),
|
||||
createRemoveConnectionTool(config.logger),
|
||||
createValidateStructureTool(config.parsedNodeTypes),
|
||||
];
|
||||
const toolMap = new Map<string, StructuredTool>(tools.map((bt) => [bt.tool.name, bt.tool]));
|
||||
// Create agent with tools bound
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: BUILDER_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
if (typeof config.llm.bindTools !== 'function') {
|
||||
throw new LLMServiceError('LLM does not support tools', {
|
||||
llmModel: config.llm._llmType(),
|
||||
});
|
||||
}
|
||||
const agent = systemPrompt.pipe(config.llm.bindTools(tools.map((bt) => bt.tool)));
|
||||
|
||||
/**
|
||||
* Agent node - calls builder agent
|
||||
* Context is already in messages from transformInput
|
||||
*/
|
||||
const callAgent = async (state: typeof BuilderSubgraphState.State) => {
|
||||
// Apply cache markers to accumulated messages (for tool loop iterations)
|
||||
applySubgraphCacheMarkers(state.messages);
|
||||
|
||||
// Messages already contain context from transformInput
|
||||
const response = await agent.invoke({
|
||||
messages: state.messages,
|
||||
});
|
||||
|
||||
return { messages: [response] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Should continue with tools or finish?
|
||||
*/
|
||||
const shouldContinue = createStandardShouldContinue();
|
||||
|
||||
// Build the subgraph
|
||||
const subgraph = new StateGraph(BuilderSubgraphState)
|
||||
.addNode('agent', callAgent)
|
||||
.addNode('tools', async (state) => await executeSubgraphTools(state, toolMap))
|
||||
.addNode('process_operations', processOperations)
|
||||
.addEdge('__start__', 'agent')
|
||||
// Map 'tools' to tools node, END is handled automatically
|
||||
.addConditionalEdges('agent', shouldContinue)
|
||||
.addEdge('tools', 'process_operations')
|
||||
.addEdge('process_operations', 'agent'); // Loop back to agent
|
||||
|
||||
return subgraph.compile();
|
||||
}
|
||||
|
||||
transformInput(parentState: typeof ParentGraphState.State) {
|
||||
const userRequest = extractUserRequest(parentState.messages);
|
||||
|
||||
// Build context parts for Builder
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. User request (primary)
|
||||
contextParts.push('=== USER REQUEST ===');
|
||||
contextParts.push(userRequest);
|
||||
|
||||
// 2. Discovery context (what nodes to use)
|
||||
if (parentState.discoveryContext) {
|
||||
contextParts.push('=== DISCOVERY CONTEXT ===');
|
||||
contextParts.push(buildDiscoveryContextBlock(parentState.discoveryContext, true));
|
||||
}
|
||||
|
||||
// 3. Current workflow JSON (to add nodes to)
|
||||
contextParts.push('=== CURRENT WORKFLOW ===');
|
||||
if (parentState.workflowJSON.nodes.length > 0) {
|
||||
contextParts.push(buildWorkflowJsonBlock(parentState.workflowJSON));
|
||||
} else {
|
||||
contextParts.push('Empty workflow - ready to build');
|
||||
}
|
||||
|
||||
// 4. Execution schema (data types available, NOT full data)
|
||||
const schemaBlock = buildExecutionSchemaBlock(parentState.workflowContext);
|
||||
if (schemaBlock) {
|
||||
contextParts.push('=== AVAILABLE DATA SCHEMA ===');
|
||||
contextParts.push(schemaBlock);
|
||||
}
|
||||
|
||||
// Create initial message with context
|
||||
const contextMessage = createContextMessage(contextParts);
|
||||
|
||||
return {
|
||||
userRequest,
|
||||
workflowJSON: parentState.workflowJSON,
|
||||
workflowContext: parentState.workflowContext,
|
||||
discoveryContext: parentState.discoveryContext,
|
||||
messages: [contextMessage], // Context already in messages
|
||||
};
|
||||
}
|
||||
|
||||
transformOutput(
|
||||
subgraphOutput: typeof BuilderSubgraphState.State,
|
||||
_parentState: typeof ParentGraphState.State,
|
||||
) {
|
||||
const nodes = subgraphOutput.workflowJSON.nodes;
|
||||
const connections = subgraphOutput.workflowJSON.connections;
|
||||
const connectionCount = Object.values(connections).flat().length;
|
||||
|
||||
// Extract builder's actual summary (last message without tool calls)
|
||||
const builderSummary = subgraphOutput.messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(
|
||||
(m) =>
|
||||
m.content &&
|
||||
(!('tool_calls' in m) ||
|
||||
!m.tool_calls ||
|
||||
(m.tool_calls && Array.isArray(m.tool_calls) && m.tool_calls.length === 0)),
|
||||
);
|
||||
|
||||
const summaryText =
|
||||
typeof builderSummary?.content === 'string' ? builderSummary.content : undefined;
|
||||
|
||||
// Create coordination log entry (not a message)
|
||||
const logEntry: CoordinationLogEntry = {
|
||||
phase: 'builder',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: `Created ${nodes.length} nodes with ${connectionCount} connections`,
|
||||
output: summaryText,
|
||||
metadata: createBuilderMetadata({
|
||||
nodesCreated: nodes.length,
|
||||
connectionsCreated: connectionCount,
|
||||
nodeNames: nodes.map((n) => n.name),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
workflowJSON: subgraphOutput.workflowJSON,
|
||||
workflowOperations: subgraphOutput.workflowOperations ?? [],
|
||||
coordinationLog: [logEntry],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import { Annotation, StateGraph } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
|
||||
import { BaseSubgraph } from './subgraph-interface';
|
||||
import type { ParentGraphState } from '../parent-graph-state';
|
||||
import { createGetNodeParameterTool } from '../tools/get-node-parameter.tool';
|
||||
import { createUpdateNodeParametersTool } from '../tools/update-node-parameters.tool';
|
||||
import { createValidateConfigurationTool } from '../tools/validate-configuration.tool';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createConfiguratorMetadata } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import { isBaseMessage } from '../types/langchain';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import {
|
||||
buildWorkflowJsonBlock,
|
||||
buildExecutionContextBlock,
|
||||
createContextMessage,
|
||||
} from '../utils/context-builders';
|
||||
import { processOperations } from '../utils/operations-processor';
|
||||
import {
|
||||
executeSubgraphTools,
|
||||
extractUserRequest,
|
||||
createStandardShouldContinue,
|
||||
} from '../utils/subgraph-helpers';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
|
||||
/**
|
||||
* Configurator Agent Prompt
|
||||
*/
|
||||
const CONFIGURATOR_PROMPT = `You are a Configurator Agent specialized in setting up n8n node parameters.
|
||||
|
||||
MANDATORY EXECUTION SEQUENCE:
|
||||
You MUST follow these steps IN ORDER. Do not skip any step.
|
||||
|
||||
STEP 1: CONFIGURE ALL NODES
|
||||
- Call update_node_parameters for EVERY node in the workflow
|
||||
- Configure multiple nodes in PARALLEL for efficiency
|
||||
- Do NOT respond with text - START CONFIGURING immediately
|
||||
|
||||
STEP 2: VALIDATE (REQUIRED)
|
||||
- After ALL configurations complete, call validate_configuration
|
||||
- This step is MANDATORY - you cannot finish without it
|
||||
- If validation finds issues, fix them and validate again
|
||||
|
||||
STEP 3: RESPOND TO USER
|
||||
- Only after validation passes, provide your response
|
||||
|
||||
NEVER respond to the user without calling validate_configuration first
|
||||
|
||||
WORKFLOW JSON DETECTION:
|
||||
- You receive <current_workflow_json> in your context
|
||||
- If you see nodes in the workflow JSON, you MUST configure them IMMEDIATELY
|
||||
- Look at the workflow JSON, identify each node, and call update_node_parameters for ALL of them
|
||||
|
||||
PARAMETER CONFIGURATION:
|
||||
Use update_node_parameters with natural language instructions:
|
||||
- "Set URL to https://api.example.com/weather"
|
||||
- "Add header Authorization: Bearer token"
|
||||
- "Set method to POST"
|
||||
- "Add field 'status' with value 'processed'"
|
||||
|
||||
SPECIAL EXPRESSIONS FOR TOOL NODES:
|
||||
Tool nodes (types ending in "Tool") support $fromAI expressions:
|
||||
- "Set sendTo to ={{ $fromAI('to') }}"
|
||||
- "Set subject to ={{ $fromAI('subject') }}"
|
||||
- "Set message to ={{ $fromAI('message_html') }}"
|
||||
- "Set timeMin to ={{ $fromAI('After', '', 'string') }}"
|
||||
|
||||
$fromAI syntax: ={{ $fromAI('key', 'description', 'type', defaultValue) }}
|
||||
- ONLY use in tool nodes (check node type ends with "Tool")
|
||||
- Use for dynamic values that AI determines at runtime
|
||||
- For regular nodes, use static values or standard expressions
|
||||
|
||||
CRITICAL PARAMETERS TO ALWAYS SET:
|
||||
- HTTP Request: URL, method, headers (if auth needed)
|
||||
- Set node: Fields to set with values
|
||||
- Code node: The actual code to execute
|
||||
- IF node: Conditions to check
|
||||
- Document Loader: dataType parameter ('binary' for files like PDF, 'json' for JSON data)
|
||||
- AI nodes: Prompts, models, configurations
|
||||
- Tool nodes: Use $fromAI for dynamic recipient/subject/message fields
|
||||
|
||||
NEVER RELY ON DEFAULT VALUES:
|
||||
Defaults are traps that cause runtime failures. Examples:
|
||||
- Document Loader defaults to 'json' but MUST be 'binary' when processing files
|
||||
- HTTP Request defaults to GET but APIs often need POST
|
||||
- Vector Store mode affects available connections - set explicitly (retrieve-as-tool when using with AI Agent)
|
||||
|
||||
<response_format>
|
||||
After validation passes, provide a concise summary:
|
||||
- List any placeholders requiring user configuration (e.g., "URL placeholder needs actual endpoint")
|
||||
- Note which nodes were configured and key settings applied
|
||||
- Keep it brief - this output is used for coordination with other LLM agents, not displayed directly to users
|
||||
</response_format>
|
||||
|
||||
DO NOT:
|
||||
- Respond before calling validate_configuration
|
||||
- Skip validation even if you think configuration is correct
|
||||
- Add commentary between tool calls - execute tools silently`;
|
||||
|
||||
/**
|
||||
* Instance URL prompt template
|
||||
*/
|
||||
const INSTANCE_URL_PROMPT = `
|
||||
<instance_url>
|
||||
The n8n instance base URL is: {instanceUrl}
|
||||
|
||||
This URL is essential for webhook nodes and chat triggers as it provides the base URL for:
|
||||
- Webhook URLs that external services need to call
|
||||
- Chat trigger URLs for conversational interfaces
|
||||
- Any node that requires the full instance URL to generate proper callback URLs
|
||||
|
||||
When working with webhook or chat trigger nodes, use this URL as the base for constructing proper endpoint URLs.
|
||||
</instance_url>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Configurator Subgraph State
|
||||
*/
|
||||
export const ConfiguratorSubgraphState = Annotation.Root({
|
||||
// Input: Workflow to configure
|
||||
workflowJSON: Annotation<SimpleWorkflow>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => ({ nodes: [], connections: {}, name: '' }),
|
||||
}),
|
||||
|
||||
// Input: Execution context (optional)
|
||||
workflowContext: Annotation<ChatPayload['workflowContext'] | undefined>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
}),
|
||||
|
||||
// Input: Instance URL for webhook nodes
|
||||
instanceUrl: Annotation<string>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => '',
|
||||
}),
|
||||
|
||||
// Input: User request
|
||||
userRequest: Annotation<string>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => '',
|
||||
}),
|
||||
|
||||
// Input: Discovery context from parent
|
||||
discoveryContext: Annotation<DiscoveryContext | null>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => null,
|
||||
}),
|
||||
|
||||
// Internal: Conversation
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Internal: Operations queue
|
||||
workflowOperations: Annotation<WorkflowOperation[] | null>({
|
||||
reducer: (x, y) => {
|
||||
if (y === null) return [];
|
||||
if (!y || y.length === 0) return x ?? [];
|
||||
return [...(x ?? []), ...y];
|
||||
},
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
export interface ConfiguratorSubgraphConfig {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
instanceUrl?: string;
|
||||
}
|
||||
|
||||
export class ConfiguratorSubgraph extends BaseSubgraph<
|
||||
ConfiguratorSubgraphConfig,
|
||||
typeof ConfiguratorSubgraphState.State,
|
||||
typeof ParentGraphState.State
|
||||
> {
|
||||
name = 'configurator_subgraph';
|
||||
description = 'Configures node parameters after structure is built';
|
||||
|
||||
private agent!: Runnable;
|
||||
private toolMap!: Map<string, StructuredTool>;
|
||||
private instanceUrl: string = '';
|
||||
|
||||
create(config: ConfiguratorSubgraphConfig) {
|
||||
this.instanceUrl = config.instanceUrl ?? '';
|
||||
// Create tools
|
||||
const tools = [
|
||||
createUpdateNodeParametersTool(
|
||||
config.parsedNodeTypes,
|
||||
config.llm, // Uses same LLM for parameter updater chain
|
||||
config.logger,
|
||||
config.instanceUrl,
|
||||
),
|
||||
createGetNodeParameterTool(),
|
||||
createValidateConfigurationTool(config.parsedNodeTypes),
|
||||
];
|
||||
this.toolMap = new Map<string, StructuredTool>(tools.map((bt) => [bt.tool.name, bt.tool]));
|
||||
// Create agent with tools bound
|
||||
const systemPromptTemplate = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: CONFIGURATOR_PROMPT,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: INSTANCE_URL_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
|
||||
if (typeof config.llm.bindTools !== 'function') {
|
||||
throw new LLMServiceError('LLM does not support tools', {
|
||||
llmModel: config.llm._llmType(),
|
||||
});
|
||||
}
|
||||
|
||||
this.agent = systemPromptTemplate.pipe(config.llm.bindTools(tools.map((bt) => bt.tool)));
|
||||
|
||||
/**
|
||||
* Agent node - calls configurator agent
|
||||
* Context is already in messages from transformInput
|
||||
*/
|
||||
const callAgent = async (state: typeof ConfiguratorSubgraphState.State) => {
|
||||
// Apply cache markers to accumulated messages (for tool loop iterations)
|
||||
applySubgraphCacheMarkers(state.messages);
|
||||
|
||||
// Messages already contain context from transformInput
|
||||
const response: unknown = await this.agent.invoke({
|
||||
messages: state.messages,
|
||||
instanceUrl: state.instanceUrl ?? '',
|
||||
});
|
||||
|
||||
if (!isBaseMessage(response)) {
|
||||
throw new LLMServiceError('Configurator agent did not return a valid message');
|
||||
}
|
||||
|
||||
return { messages: [response] };
|
||||
};
|
||||
|
||||
// Build the subgraph
|
||||
const subgraph = new StateGraph(ConfiguratorSubgraphState)
|
||||
.addNode('agent', callAgent)
|
||||
.addNode('tools', async (state) => await executeSubgraphTools(state, this.toolMap))
|
||||
.addNode('process_operations', processOperations)
|
||||
.addEdge('__start__', 'agent')
|
||||
// Map 'tools' to tools node, END is handled automatically
|
||||
.addConditionalEdges('agent', createStandardShouldContinue())
|
||||
.addEdge('tools', 'process_operations')
|
||||
.addEdge('process_operations', 'agent'); // Loop back
|
||||
|
||||
return subgraph.compile();
|
||||
}
|
||||
|
||||
transformInput(parentState: typeof ParentGraphState.State) {
|
||||
const userRequest = extractUserRequest(parentState.messages);
|
||||
|
||||
// Build context parts for Configurator
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. User request (primary)
|
||||
if (userRequest) {
|
||||
contextParts.push('=== USER REQUEST ===');
|
||||
contextParts.push(userRequest);
|
||||
}
|
||||
|
||||
// 2. Best practices only from discovery (not full nodes list)
|
||||
if (parentState.discoveryContext?.bestPractices) {
|
||||
contextParts.push(parentState.discoveryContext.bestPractices);
|
||||
}
|
||||
|
||||
// 3. Full workflow JSON (nodes to configure)
|
||||
contextParts.push('=== WORKFLOW TO CONFIGURE ===');
|
||||
contextParts.push(buildWorkflowJsonBlock(parentState.workflowJSON));
|
||||
|
||||
// 4. Full execution context (data + schema for parameter values)
|
||||
contextParts.push('=== EXECUTION CONTEXT ===');
|
||||
contextParts.push(buildExecutionContextBlock(parentState.workflowContext));
|
||||
|
||||
// Create initial message with context
|
||||
const contextMessage = createContextMessage(contextParts);
|
||||
|
||||
return {
|
||||
workflowJSON: parentState.workflowJSON,
|
||||
workflowContext: parentState.workflowContext,
|
||||
instanceUrl: this.instanceUrl,
|
||||
userRequest,
|
||||
discoveryContext: parentState.discoveryContext,
|
||||
messages: [contextMessage],
|
||||
};
|
||||
}
|
||||
|
||||
transformOutput(
|
||||
subgraphOutput: typeof ConfiguratorSubgraphState.State,
|
||||
_parentState: typeof ParentGraphState.State,
|
||||
) {
|
||||
// Extract final response (setup instructions)
|
||||
const lastMessage = subgraphOutput.messages[subgraphOutput.messages.length - 1];
|
||||
const setupInstructions =
|
||||
typeof lastMessage?.content === 'string' ? lastMessage.content : 'Configuration complete';
|
||||
|
||||
const nodesConfigured = subgraphOutput.workflowJSON.nodes.length;
|
||||
const hasSetupInstructions =
|
||||
setupInstructions.includes('Setup') ||
|
||||
setupInstructions.includes('setup') ||
|
||||
setupInstructions.length > 50;
|
||||
|
||||
// Create coordination log entry (not a message)
|
||||
const logEntry: CoordinationLogEntry = {
|
||||
phase: 'configurator',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: `Configured ${nodesConfigured} nodes`,
|
||||
output: setupInstructions, // Full setup instructions for responder
|
||||
metadata: createConfiguratorMetadata({
|
||||
nodesConfigured,
|
||||
hasSetupInstructions,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
workflowJSON: subgraphOutput.workflowJSON,
|
||||
workflowOperations: subgraphOutput.workflowOperations ?? [],
|
||||
coordinationLog: [logEntry],
|
||||
// NO messages - clean separation from user-facing conversation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import { isAIMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import { tool, type StructuredTool } from '@langchain/core/tools';
|
||||
import { Annotation, StateGraph, END } from '@langchain/langgraph';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LLMServiceError } from '@/errors';
|
||||
import {
|
||||
TechniqueDescription,
|
||||
WorkflowTechnique,
|
||||
type WorkflowTechniqueType,
|
||||
} from '@/types/categorization';
|
||||
|
||||
import { BaseSubgraph } from './subgraph-interface';
|
||||
import type { ParentGraphState } from '../parent-graph-state';
|
||||
import { createGetBestPracticesTool } from '../tools/get-best-practices.tool';
|
||||
import { createNodeDetailsTool } from '../tools/node-details.tool';
|
||||
import { createNodeSearchTool } from '../tools/node-search.tool';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import { createDiscoveryMetadata } from '../types/coordination';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import { buildWorkflowSummary, createContextMessage } from '../utils/context-builders';
|
||||
import { executeSubgraphTools, extractUserRequest } from '../utils/subgraph-helpers';
|
||||
|
||||
/**
|
||||
* Example categorizations to guide technique selection
|
||||
* Expanded with diverse examples to improve accuracy
|
||||
*/
|
||||
const exampleCategorizations: Array<{
|
||||
prompt: string;
|
||||
techniques: WorkflowTechniqueType[];
|
||||
}> = [
|
||||
{
|
||||
prompt: 'Monitor social channels for product mentions and auto-respond with campaign messages',
|
||||
techniques: [
|
||||
WorkflowTechnique.MONITORING,
|
||||
WorkflowTechnique.CHATBOT,
|
||||
WorkflowTechnique.CONTENT_GENERATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Collect partner referral submissions and verify client instances via BigQuery',
|
||||
techniques: [
|
||||
WorkflowTechnique.FORM_INPUT,
|
||||
WorkflowTechnique.HUMAN_IN_THE_LOOP,
|
||||
WorkflowTechnique.NOTIFICATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Scrape competitor pricing pages weekly and generate a summary report of changes',
|
||||
techniques: [
|
||||
WorkflowTechnique.SCHEDULING,
|
||||
WorkflowTechnique.SCRAPING_AND_RESEARCH,
|
||||
WorkflowTechnique.DATA_EXTRACTION,
|
||||
WorkflowTechnique.DATA_ANALYSIS,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Process uploaded PDF contracts to extract client details and update CRM records',
|
||||
techniques: [
|
||||
WorkflowTechnique.DOCUMENT_PROCESSING,
|
||||
WorkflowTechnique.DATA_EXTRACTION,
|
||||
WorkflowTechnique.DATA_TRANSFORMATION,
|
||||
WorkflowTechnique.ENRICHMENT,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Build a searchable internal knowledge base from past support tickets',
|
||||
techniques: [
|
||||
WorkflowTechnique.DATA_TRANSFORMATION,
|
||||
WorkflowTechnique.DATA_ANALYSIS,
|
||||
WorkflowTechnique.KNOWLEDGE_BASE,
|
||||
],
|
||||
},
|
||||
// Additional examples to address common misclassifications
|
||||
{
|
||||
prompt: 'Create an AI agent that writes and sends personalized emails to leads',
|
||||
techniques: [WorkflowTechnique.CONTENT_GENERATION, WorkflowTechnique.NOTIFICATION],
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'Fetch trending topics from Google Trends and Reddit, select the best ones, and create social posts',
|
||||
techniques: [
|
||||
WorkflowTechnique.SCRAPING_AND_RESEARCH,
|
||||
WorkflowTechnique.TRIAGE,
|
||||
WorkflowTechnique.CONTENT_GENERATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'Trigger when a new contact is created in HubSpot and enrich their profile with LinkedIn data',
|
||||
techniques: [WorkflowTechnique.MONITORING, WorkflowTechnique.ENRICHMENT],
|
||||
},
|
||||
{
|
||||
prompt: 'Get stock prices from financial APIs and analyze volatility patterns',
|
||||
techniques: [WorkflowTechnique.SCRAPING_AND_RESEARCH, WorkflowTechnique.DATA_ANALYSIS],
|
||||
},
|
||||
{
|
||||
prompt: 'Generate video reels from templates and auto-post to social media on schedule',
|
||||
techniques: [
|
||||
WorkflowTechnique.SCHEDULING,
|
||||
WorkflowTechnique.DOCUMENT_PROCESSING,
|
||||
WorkflowTechnique.CONTENT_GENERATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Receive news from Telegram channels, filter relevant ones, and forward to my channel',
|
||||
techniques: [
|
||||
WorkflowTechnique.MONITORING,
|
||||
WorkflowTechnique.TRIAGE,
|
||||
WorkflowTechnique.NOTIFICATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt: 'Analyze YouTube video performance data and generate a weekly report',
|
||||
techniques: [
|
||||
WorkflowTechnique.SCRAPING_AND_RESEARCH,
|
||||
WorkflowTechnique.DATA_ANALYSIS,
|
||||
WorkflowTechnique.DATA_TRANSFORMATION,
|
||||
],
|
||||
},
|
||||
{
|
||||
prompt:
|
||||
'Create a chatbot that answers questions using data from a Google Sheet as knowledge base',
|
||||
techniques: [WorkflowTechnique.CHATBOT, WorkflowTechnique.KNOWLEDGE_BASE],
|
||||
},
|
||||
{
|
||||
prompt: 'Form submission with file upload triggers document extraction and approval workflow',
|
||||
techniques: [
|
||||
WorkflowTechnique.FORM_INPUT,
|
||||
WorkflowTechnique.DOCUMENT_PROCESSING,
|
||||
WorkflowTechnique.HUMAN_IN_THE_LOOP,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Format technique descriptions for prompt
|
||||
*/
|
||||
function formatTechniqueList(): string {
|
||||
return Object.entries(TechniqueDescription)
|
||||
.map(([key, description]) => `- **${key}**: ${description}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format example categorizations for prompt
|
||||
*/
|
||||
function formatExampleCategorizations(): string {
|
||||
return exampleCategorizations
|
||||
.map((example) => `- ${example.prompt} → ${example.techniques.join(', ')}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Output Schema for Discovery
|
||||
* Simplified to reduce token usage while maintaining utility for downstream subgraphs
|
||||
*/
|
||||
const discoveryOutputSchema = z.object({
|
||||
nodesFound: z
|
||||
.array(
|
||||
z.object({
|
||||
nodeName: z.string().describe('The internal name of the node (e.g., n8n-nodes-base.gmail)'),
|
||||
version: z
|
||||
.number()
|
||||
.describe('The version number of the node (e.g., 1, 1.1, 2, 3, 3.2, etc.)'),
|
||||
reasoning: z.string().describe('Why this node is relevant for the workflow'),
|
||||
connectionChangingParameters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Parameter name (e.g., "mode", "operation", "hasOutputParser")'),
|
||||
possibleValues: z
|
||||
.array(z.union([z.string(), z.boolean(), z.number()]))
|
||||
.describe('Possible values this parameter can take'),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
'Parameters that affect node connections (inputs/outputs). ONLY include if parameter appears in <input> or <output> expressions',
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe('List of n8n nodes identified as necessary for the workflow'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Discovery Agent Prompt
|
||||
*/
|
||||
const DISCOVERY_PROMPT = `You are a Discovery Agent for n8n AI Workflow Builder.
|
||||
|
||||
YOUR ROLE: Identify relevant n8n nodes and their connection-changing parameters.
|
||||
|
||||
AVAILABLE TOOLS:
|
||||
- get_best_practices: Retrieve best practices (internal context)
|
||||
- search_nodes: Find n8n nodes by keyword
|
||||
- get_node_details: Get complete node information including <connections>
|
||||
- submit_discovery_results: Submit final results
|
||||
|
||||
PROCESS:
|
||||
1. **Call get_best_practices** with identified techniques (internal context)
|
||||
2. **Identify workflow components** from user request and best practices
|
||||
3. **Call search_nodes IN PARALLEL** for all components (e.g., "Gmail", "OpenAI", "Schedule")
|
||||
4. **Call get_node_details IN PARALLEL** for ALL promising nodes (batch multiple calls)
|
||||
5. **Extract node information** from each node_details response:
|
||||
- Node name from <name> tag
|
||||
- Version number from <version> tag (required - extract the number)
|
||||
- Connection-changing parameters from <connections> section
|
||||
6. **Call submit_discovery_results** with complete nodesFound array
|
||||
|
||||
TECHNIQUE CATEGORIZATION:
|
||||
When calling get_best_practices, select techniques that match the user's workflow intent.
|
||||
|
||||
<available_techniques>
|
||||
{techniques}
|
||||
</available_techniques>
|
||||
|
||||
<example_categorizations>
|
||||
{exampleCategorizations}
|
||||
</example_categorizations>
|
||||
|
||||
<technique_clarifications>
|
||||
Common distinctions to get right:
|
||||
- **NOTIFICATION vs CHATBOT**: Use NOTIFICATION when SENDING emails/messages/alerts (including to Telegram CHANNELS which are broadcast-only). Use CHATBOT only when RECEIVING and REPLYING to direct messages in a conversation.
|
||||
- **MONITORING**: Use when workflow TRIGGERS on external events (new record created, status changed, incoming webhook, new message in channel). NOT just scheduled runs.
|
||||
- **SCRAPING_AND_RESEARCH vs DATA_EXTRACTION**: Use SCRAPING when fetching from EXTERNAL sources (APIs, websites, social media). Use DATA_EXTRACTION for parsing INTERNAL data you already have.
|
||||
- **TRIAGE**: Use when SELECTING, PRIORITIZING, ROUTING, or QUALIFYING items (e.g., "pick the best", "route to correct team", "qualify leads").
|
||||
- **DOCUMENT_PROCESSING**: Use for ANY file handling - PDFs, images, videos, Excel, Google Sheets, audio files, file uploads in forms.
|
||||
- **HUMAN_IN_THE_LOOP**: Use when workflow PAUSES for human approval, review, signing documents, responding to polls, or any manual input before continuing.
|
||||
- **DATA_ANALYSIS**: Use when ANALYZING, CLASSIFYING, IDENTIFYING PATTERNS, or UNDERSTANDING data (e.g., "analyze outcomes", "learn from previous", "classify by type", "identify trends").
|
||||
- **KNOWLEDGE_BASE**: Use when storing/retrieving from a DATA SOURCE for Q&A - includes vector DBs, spreadsheets used as databases, document collections.
|
||||
- **DATA_TRANSFORMATION**: Use when CONVERTING data format, creating REPORTS/SUMMARIES from analyzed data, or restructuring output.
|
||||
</technique_clarifications>
|
||||
|
||||
Technique selection rules:
|
||||
- Select ALL techniques that apply (most workflows use 2-4)
|
||||
- Maximum 5 techniques
|
||||
- Only select techniques you're confident apply
|
||||
|
||||
CONNECTION-CHANGING PARAMETERS - CRITICAL RULES:
|
||||
|
||||
A parameter is connection-changing ONLY IF it appears in <input> or <output> expressions within <node_details>.
|
||||
|
||||
**How to identify:**
|
||||
1. Look at the <connections> section in node details
|
||||
2. Check if <input> or <output> uses expressions like: ={{...parameterName...}}
|
||||
3. If a parameter is referenced in these expressions, it IS connection-changing
|
||||
4. If a parameter is NOT in <input>/<output> expressions, it is NOT connection-changing
|
||||
|
||||
**Example from AI Agent:**
|
||||
\`\`\`xml
|
||||
<input>={{...hasOutputParser, needsFallback...}}</input>
|
||||
\`\`\`
|
||||
→ hasOutputParser and needsFallback ARE connection-changing (they control which inputs appear)
|
||||
|
||||
**Counter-example:**
|
||||
\`\`\`xml
|
||||
<properties>
|
||||
<property name="promptType">...</property> <!-- NOT in <input>/<output> -->
|
||||
<property name="systemMessage">...</property> <!-- NOT in <input>/<output> -->
|
||||
</properties>
|
||||
\`\`\`
|
||||
→ promptType and systemMessage are NOT connection-changing (they don't affect connections)
|
||||
|
||||
**Common connection-changing parameters:**
|
||||
- Vector Store: mode (appears in <input>/<output> expressions)
|
||||
- AI Agent: hasOutputParser, needsFallback (appears in <input> expression)
|
||||
- Merge: numberInputs (appears in <input> expression)
|
||||
- Webhook: responseMode (appears in <output> expression)
|
||||
|
||||
SUB-NODES SEARCHES:
|
||||
When searching for AI nodes, ALSO search for their required sub-nodes:
|
||||
- "AI Agent" → also search for "Chat Model", "Memory", "Output Parser"
|
||||
- "Basic LLM Chain" → also search for "Chat Model", "Output Parser"
|
||||
- "Vector Store" → also search for "Embeddings", "Document Loader"
|
||||
- Always use search_nodes to find the exact node names and versions - NEVER guess versions
|
||||
|
||||
CRITICAL RULES:
|
||||
- NEVER ask clarifying questions
|
||||
- ALWAYS call get_best_practices first
|
||||
- THEN Call search_nodes to learn about available nodes and their inputs and outputs
|
||||
- FINALLY call get_node_details IN PARALLEL for speed to get more details about RELVANT node
|
||||
- ALWAYS extract version number from <version> tag in node details
|
||||
- NEVER guess node versions - always use search_nodes to find exact versions
|
||||
- ONLY flag connectionChangingParameters if they appear in <input> or <output> expressions
|
||||
- If no parameters appear in connection expressions, return empty array []
|
||||
- Output ONLY: nodesFound with {{ nodeName, version, reasoning, connectionChangingParameters }}
|
||||
|
||||
DO NOT:
|
||||
- Output text commentary between tool calls
|
||||
- Include bestPractices or categorization in submit_discovery_results
|
||||
- Flag parameters that don't affect connections
|
||||
- Stop without calling submit_discovery_results
|
||||
`;
|
||||
|
||||
/**
|
||||
* Discovery Subgraph State
|
||||
*/
|
||||
export const DiscoverySubgraphState = Annotation.Root({
|
||||
// Input: What the user wants to build
|
||||
userRequest: Annotation<string>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => '',
|
||||
}),
|
||||
|
||||
// Internal: Conversation within this subgraph
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Output: Found nodes with version, reasoning and connection-changing parameters
|
||||
nodesFound: Annotation<
|
||||
Array<{
|
||||
nodeName: string;
|
||||
version: number;
|
||||
reasoning: string;
|
||||
connectionChangingParameters: Array<{
|
||||
name: string;
|
||||
possibleValues: Array<string | boolean | number>;
|
||||
}>;
|
||||
}>
|
||||
>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => [],
|
||||
}),
|
||||
|
||||
// Output: Best practices documentation
|
||||
bestPractices: Annotation<string | undefined>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface DiscoverySubgraphConfig {
|
||||
parsedNodeTypes: INodeTypeDescription[];
|
||||
llm: BaseChatModel;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export class DiscoverySubgraph extends BaseSubgraph<
|
||||
DiscoverySubgraphConfig,
|
||||
typeof DiscoverySubgraphState.State,
|
||||
typeof ParentGraphState.State
|
||||
> {
|
||||
name = 'discovery_subgraph';
|
||||
description = 'Discovers nodes and context for the workflow';
|
||||
|
||||
private agent!: Runnable;
|
||||
private toolMap!: Map<string, StructuredTool>;
|
||||
private logger?: Logger;
|
||||
|
||||
create(config: DiscoverySubgraphConfig) {
|
||||
this.logger = config.logger;
|
||||
|
||||
// Create tools
|
||||
const tools = [
|
||||
createGetBestPracticesTool(),
|
||||
createNodeSearchTool(config.parsedNodeTypes),
|
||||
createNodeDetailsTool(config.parsedNodeTypes),
|
||||
];
|
||||
this.toolMap = new Map(tools.map((bt) => [bt.tool.name, bt.tool]));
|
||||
|
||||
// Define output tool
|
||||
const submitTool = tool(() => {}, {
|
||||
name: 'submit_discovery_results',
|
||||
description: 'Submit the final discovery results',
|
||||
schema: discoveryOutputSchema,
|
||||
});
|
||||
|
||||
// Create agent with tools bound (including submit tool)
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
'system',
|
||||
[
|
||||
{
|
||||
type: 'text',
|
||||
text: DISCOVERY_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
],
|
||||
['human', '{prompt}'],
|
||||
['placeholder', '{messages}'],
|
||||
]);
|
||||
|
||||
if (typeof config.llm.bindTools !== 'function') {
|
||||
throw new LLMServiceError('LLM does not support tools', {
|
||||
llmModel: config.llm._llmType(),
|
||||
});
|
||||
}
|
||||
|
||||
// Bind all tools including the output tool
|
||||
const allTools = [...tools.map((bt) => bt.tool), submitTool];
|
||||
this.agent = systemPrompt.pipe(config.llm.bindTools(allTools));
|
||||
|
||||
// Build the subgraph
|
||||
const subgraph = new StateGraph(DiscoverySubgraphState)
|
||||
.addNode('agent', this.callAgent.bind(this))
|
||||
.addNode('tools', async (state) => await executeSubgraphTools(state, this.toolMap))
|
||||
.addNode('format_output', this.formatOutput.bind(this))
|
||||
.addEdge('__start__', 'agent')
|
||||
// Conditional: tools if has tool calls, format_output if submit called
|
||||
.addConditionalEdges('agent', this.shouldContinue.bind(this), {
|
||||
tools: 'tools',
|
||||
format_output: 'format_output',
|
||||
end: END, // Fallback
|
||||
})
|
||||
.addEdge('tools', 'agent') // After tools, go back to agent
|
||||
.addEdge('format_output', END); // After formatting, END
|
||||
|
||||
return subgraph.compile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent node - calls discovery agent
|
||||
* Context is already in messages from transformInput
|
||||
*/
|
||||
private async callAgent(state: typeof DiscoverySubgraphState.State) {
|
||||
// Apply cache markers to accumulated messages (for tool loop iterations)
|
||||
if (state.messages.length > 0) {
|
||||
applySubgraphCacheMarkers(state.messages);
|
||||
}
|
||||
|
||||
// Messages already contain context from transformInput
|
||||
const response = (await this.agent.invoke({
|
||||
messages: state.messages,
|
||||
prompt: state.userRequest,
|
||||
techniques: formatTechniqueList(),
|
||||
exampleCategorizations: formatExampleCategorizations(),
|
||||
})) as AIMessage;
|
||||
|
||||
return { messages: [response] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the output from the submit tool call
|
||||
* No hydration - just return raw node names. Subgraphs will hydrate if needed.
|
||||
*/
|
||||
private formatOutput(state: typeof DiscoverySubgraphState.State) {
|
||||
const lastMessage = state.messages.at(-1);
|
||||
let output: z.infer<typeof discoveryOutputSchema> | undefined;
|
||||
|
||||
if (lastMessage && isAIMessage(lastMessage) && lastMessage.tool_calls) {
|
||||
const submitCall = lastMessage.tool_calls.find(
|
||||
(tc) => tc.name === 'submit_discovery_results',
|
||||
);
|
||||
if (submitCall) {
|
||||
output = submitCall.args as z.infer<typeof discoveryOutputSchema>;
|
||||
}
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
this.logger?.error('[Discovery] No submit tool call found in last message');
|
||||
return {
|
||||
nodesFound: [],
|
||||
};
|
||||
}
|
||||
|
||||
const bestPracticesTool = state.messages.find(
|
||||
(m): m is ToolMessage => m.getType() === 'tool' && m?.text?.startsWith('<best_practices>'),
|
||||
);
|
||||
// Return raw output without hydration
|
||||
return {
|
||||
nodesFound: output.nodesFound,
|
||||
bestPractices: bestPracticesTool?.text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Should continue with tools or finish?
|
||||
*/
|
||||
private shouldContinue(state: typeof DiscoverySubgraphState.State) {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
if (
|
||||
lastMessage &&
|
||||
isAIMessage(lastMessage) &&
|
||||
lastMessage.tool_calls &&
|
||||
lastMessage.tool_calls.length > 0
|
||||
) {
|
||||
// Check if the submit tool was called
|
||||
const submitCall = lastMessage.tool_calls.find(
|
||||
(tc) => tc.name === 'submit_discovery_results',
|
||||
);
|
||||
if (submitCall) {
|
||||
return 'format_output';
|
||||
}
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
// No tool calls = agent is done (or failed to call tool)
|
||||
// In this pattern, we expect a tool call. If none, we might want to force it or just end.
|
||||
// For now, let's treat it as an end, but ideally we'd reprompt.
|
||||
this.logger?.warn('[Discovery Subgraph] Agent stopped without submitting results');
|
||||
return 'end';
|
||||
}
|
||||
|
||||
transformInput(parentState: typeof ParentGraphState.State) {
|
||||
const userRequest = extractUserRequest(parentState.messages, 'Build a workflow');
|
||||
|
||||
// Build context parts for Discovery
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. User request (primary)
|
||||
contextParts.push('<user_request>');
|
||||
contextParts.push(userRequest);
|
||||
contextParts.push('</user_request>');
|
||||
|
||||
// 2. Current workflow summary (just node names, to know what exists)
|
||||
// Discovery doesn't need full JSON, just awareness of existing nodes
|
||||
if (parentState.workflowJSON.nodes.length > 0) {
|
||||
contextParts.push('<existing_workflow_summary>');
|
||||
contextParts.push(buildWorkflowSummary(parentState.workflowJSON));
|
||||
contextParts.push('</existing_workflow_summary>');
|
||||
}
|
||||
|
||||
// Create initial message with context
|
||||
const contextMessage = createContextMessage(contextParts);
|
||||
|
||||
return {
|
||||
userRequest,
|
||||
messages: [contextMessage], // Context already in messages
|
||||
};
|
||||
}
|
||||
|
||||
transformOutput(
|
||||
subgraphOutput: typeof DiscoverySubgraphState.State,
|
||||
_parentState: typeof ParentGraphState.State,
|
||||
) {
|
||||
const nodesFound = subgraphOutput.nodesFound || [];
|
||||
const discoveryContext = {
|
||||
nodesFound,
|
||||
bestPractices: subgraphOutput.bestPractices,
|
||||
};
|
||||
|
||||
// Create coordination log entry (not a message)
|
||||
const logEntry: CoordinationLogEntry = {
|
||||
phase: 'discovery',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: `Discovered ${nodesFound.length} nodes`,
|
||||
metadata: createDiscoveryMetadata({
|
||||
nodesFound: nodesFound.length,
|
||||
nodeTypes: nodesFound.map((n) => n.nodeName),
|
||||
hasBestPractices: !!subgraphOutput.bestPractices,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
discoveryContext,
|
||||
coordinationLog: [logEntry],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
type StateRecord = Record<string, unknown>;
|
||||
|
||||
interface InvokeConfig {
|
||||
recursionLimit?: number;
|
||||
}
|
||||
|
||||
export interface ISubgraph<
|
||||
TConfig = unknown,
|
||||
TChildState extends StateRecord = StateRecord,
|
||||
TParentState extends StateRecord = StateRecord,
|
||||
> {
|
||||
name: string;
|
||||
description: string;
|
||||
create(config: TConfig): {
|
||||
invoke: (input: Partial<TChildState>, config?: InvokeConfig) => Promise<TChildState>;
|
||||
};
|
||||
transformInput: (parentState: TParentState) => Partial<TChildState>;
|
||||
transformOutput: (childOutput: TChildState, parentState: TParentState) => Partial<TParentState>;
|
||||
}
|
||||
|
||||
export abstract class BaseSubgraph<
|
||||
TConfig = unknown,
|
||||
TChildState extends StateRecord = StateRecord,
|
||||
TParentState extends StateRecord = StateRecord,
|
||||
> implements ISubgraph<TConfig, TChildState, TParentState>
|
||||
{
|
||||
abstract name: string;
|
||||
abstract description: string;
|
||||
|
||||
abstract create(config: TConfig): {
|
||||
invoke: (input: Partial<TChildState>, config?: InvokeConfig) => Promise<TChildState>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform parent state to subgraph input state
|
||||
* Returns a partial object with only the fields needed to initialize the child state
|
||||
*/
|
||||
abstract transformInput(parentState: TParentState): Partial<TChildState>;
|
||||
|
||||
/**
|
||||
* Transform subgraph output state to parent state update
|
||||
* Returns a partial object that will be merged into the parent state
|
||||
*/
|
||||
abstract transformOutput(
|
||||
childOutput: TChildState,
|
||||
parentState: TParentState,
|
||||
): Partial<TParentState>;
|
||||
}
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { isAIMessage } from '@langchain/core/messages';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
setupIntegrationLLM,
|
||||
shouldRunIntegrationTests,
|
||||
} from '@/chains/test/integration/test-helpers';
|
||||
import { DiscoverySubgraph } from '@/subgraphs/discovery.subgraph';
|
||||
import type { WorkflowTechniqueType } from '@/types/categorization';
|
||||
|
||||
import techniqueTestData from './techniques.json';
|
||||
import { loadNodesFromFile } from '../../../../evaluations/load-nodes';
|
||||
|
||||
/**
|
||||
* Integration tests for Discovery Subgraph
|
||||
*
|
||||
* These tests use a real LLM and make actual API calls to verify end-to-end discovery behavior.
|
||||
* They are skipped by default and only run when ENABLE_INTEGRATION_TESTS=true
|
||||
*
|
||||
* To run these tests:
|
||||
* ENABLE_INTEGRATION_TESTS=true N8N_AI_ANTHROPIC_KEY=your-key pnpm test discovery-subgraph.integration
|
||||
*/
|
||||
|
||||
// Test prompts covering different workflow types
|
||||
const testPrompts = [
|
||||
{
|
||||
name: 'Monitoring workflow',
|
||||
prompt:
|
||||
'Create a workflow that monitors my website every 5 minutes and sends me a Slack notification if it goes down',
|
||||
expectedNodes: ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.scheduleTrigger'],
|
||||
minNodes: 2,
|
||||
},
|
||||
{
|
||||
name: 'Form input workflow',
|
||||
prompt:
|
||||
'Set up a form to collect customer feedback, analyze sentiment with AI, and store the results in Airtable',
|
||||
expectedNodes: ['n8n-nodes-base.formTrigger'],
|
||||
minNodes: 3,
|
||||
},
|
||||
{
|
||||
name: 'RAG chatbot workflow',
|
||||
prompt:
|
||||
'Build a chatbot that can answer customer questions using information from our knowledge base with RAG',
|
||||
expectedNodes: [
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
'@n8n/n8n-nodes-langchain.agent',
|
||||
'@n8n/n8n-nodes-langchain.vectorStore',
|
||||
],
|
||||
minNodes: 4,
|
||||
},
|
||||
{
|
||||
name: 'API integration workflow',
|
||||
prompt: 'Fetch weather data from OpenWeatherMap API and send daily forecast email via Gmail',
|
||||
expectedNodes: ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.gmail'],
|
||||
minNodes: 2,
|
||||
},
|
||||
{
|
||||
name: 'Data transformation workflow',
|
||||
prompt: 'Read CSV file, transform data with JavaScript, and upload to Google Sheets',
|
||||
expectedNodes: ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.code'],
|
||||
minNodes: 3,
|
||||
},
|
||||
{
|
||||
name: 'Multi-agent AI workflow',
|
||||
prompt:
|
||||
'Create 4 AI agents (research, fact-check, writer, formatter) that work together to create and send weekly newsletter',
|
||||
expectedNodes: ['@n8n/n8n-nodes-langchain.agent', '@n8n/n8n-nodes-langchain.lmChatAnthropic'],
|
||||
minNodes: 5,
|
||||
},
|
||||
{
|
||||
name: 'Webhook workflow',
|
||||
prompt: 'Receive webhook from Stripe, validate payment, and update customer record in database',
|
||||
expectedNodes: ['n8n-nodes-base.webhook'],
|
||||
minNodes: 2,
|
||||
},
|
||||
{
|
||||
name: 'Scheduled scraping',
|
||||
prompt: 'Scrape competitor pricing daily and save to Notion database',
|
||||
expectedNodes: ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.httpRequest'],
|
||||
minNodes: 2,
|
||||
},
|
||||
];
|
||||
|
||||
// Test prompts for technique categorization loaded from JSON file
|
||||
const techniqueTestPrompts = techniqueTestData as Array<{
|
||||
prompt: string;
|
||||
expectedTechniques: WorkflowTechniqueType[];
|
||||
}>;
|
||||
|
||||
// Helper to check if expected nodes are discovered
|
||||
function hasExpectedNodes(
|
||||
discovered: Array<{ nodeName: string; version: number; reasoning: string }>,
|
||||
expectedNames: string[],
|
||||
): { hasAll: boolean; missing: string[] } {
|
||||
const discoveredNames = discovered.map((n) => n.nodeName);
|
||||
const missing = expectedNames.filter((name) => !discoveredNames.includes(name));
|
||||
return {
|
||||
hasAll: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to calculate node discovery frequency
|
||||
function calculateNodeFrequency(
|
||||
results: Array<{ nodesFound: Array<{ nodeName: string }> }>,
|
||||
): Map<string, number> {
|
||||
const frequency = new Map<string, number>();
|
||||
for (const result of results) {
|
||||
for (const { nodeName } of result.nodesFound) {
|
||||
frequency.set(nodeName, (frequency.get(nodeName) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return frequency;
|
||||
}
|
||||
|
||||
// Helper to extract techniques from get_best_practices tool call in messages
|
||||
function extractTechniquesFromMessages(messages: BaseMessage[]): WorkflowTechniqueType[] {
|
||||
for (const msg of messages) {
|
||||
if (isAIMessage(msg) && msg.tool_calls) {
|
||||
const bestPracticesCall = msg.tool_calls.find((tc) => tc.name === 'get_best_practices');
|
||||
if (bestPracticesCall?.args?.techniques) {
|
||||
return bestPracticesCall.args.techniques as WorkflowTechniqueType[];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper to check if expected techniques are present
|
||||
function hasExpectedTechniques(
|
||||
result: WorkflowTechniqueType[],
|
||||
expected: WorkflowTechniqueType[],
|
||||
): { hasAll: boolean; missing: WorkflowTechniqueType[] } {
|
||||
const missing = expected.filter((tech) => !result.includes(tech));
|
||||
return {
|
||||
hasAll: missing.length === 0,
|
||||
missing,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to calculate technique frequency
|
||||
function calculateTechniqueFrequency(
|
||||
results: Array<{ techniques: WorkflowTechniqueType[] }>,
|
||||
): Map<WorkflowTechniqueType, number> {
|
||||
const frequency = new Map<WorkflowTechniqueType, number>();
|
||||
for (const result of results) {
|
||||
for (const technique of result.techniques) {
|
||||
frequency.set(technique, (frequency.get(technique) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return frequency;
|
||||
}
|
||||
|
||||
describe('Discovery Subgraph - Integration Tests', () => {
|
||||
let llm: BaseChatModel;
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
let discoverySubgraph: DiscoverySubgraph;
|
||||
|
||||
// Skip all tests if integration tests are not enabled
|
||||
const skipTests = !shouldRunIntegrationTests();
|
||||
|
||||
// Set default timeout for all tests in this suite
|
||||
jest.setTimeout(1800000); // 30 minutes
|
||||
|
||||
beforeAll(async () => {
|
||||
// Override console.log to use process.stdout directly, bypassing Jest's
|
||||
// verbose wrapper that adds stack traces to every log line
|
||||
jest.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
||||
process.stdout.write(args.map(String).join(' ') + '\n');
|
||||
});
|
||||
|
||||
if (skipTests) {
|
||||
console.log(
|
||||
'\n⏭️ Skipping integration tests. Set ENABLE_INTEGRATION_TESTS=true to run them.\n',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🚀 Setting up discovery subgraph integration test environment...\n');
|
||||
|
||||
// Load real LLM and node types
|
||||
llm = await setupIntegrationLLM();
|
||||
parsedNodeTypes = loadNodesFromFile();
|
||||
|
||||
console.log(`Loaded ${parsedNodeTypes.length} node types for testing\n`);
|
||||
|
||||
// Create discovery subgraph instance
|
||||
discoverySubgraph = new DiscoverySubgraph();
|
||||
});
|
||||
|
||||
describe('Basic Discovery', () => {
|
||||
it('should discover nodes for simple monitoring workflow', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Monitor my website every 5 minutes and send Slack alert if it goes down',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(result.nodesFound.length).toBeGreaterThan(0);
|
||||
|
||||
// Validate connectionChangingParameters are present
|
||||
expect(Array.isArray(result.nodesFound)).toBe(true);
|
||||
result.nodesFound.forEach((node) => {
|
||||
expect(node.connectionChangingParameters).toBeDefined();
|
||||
expect(Array.isArray(node.connectionChangingParameters)).toBe(true);
|
||||
});
|
||||
|
||||
// Should find scheduling and HTTP nodes
|
||||
const nodeNames = result.nodesFound.map((n) => n.nodeName);
|
||||
expect(nodeNames.some((name) => name.includes('schedule'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should discover nodes for form input workflow', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Create a form to collect feedback and store in database',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(result.nodesFound.length).toBeGreaterThan(0);
|
||||
|
||||
// Should find form trigger
|
||||
const nodeNames = result.nodesFound.map((n) => n.nodeName);
|
||||
expect(nodeNames.some((name) => name.includes('form'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should discover nodes for AI/RAG workflow', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Build a chatbot with RAG using knowledge base documents',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(result.nodesFound.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Should find AI-related nodes
|
||||
const nodeNames = result.nodesFound.map((n) => n.nodeName);
|
||||
const hasAINodes = nodeNames.some(
|
||||
(name) => name.includes('langchain') || name.includes('openai'),
|
||||
);
|
||||
expect(hasAINodes).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Structure Validation', () => {
|
||||
it('should return all required discovery fields', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Send daily email with weather forecast',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Validate structure
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(Array.isArray(result.nodesFound)).toBe(true);
|
||||
|
||||
// Validate each node has required fields
|
||||
result.nodesFound.forEach((node) => {
|
||||
expect(node.nodeName).toBeDefined();
|
||||
expect(typeof node.nodeName).toBe('string');
|
||||
expect(node.version).toBeDefined();
|
||||
expect(typeof node.version).toBe('number');
|
||||
expect(node.reasoning).toBeDefined();
|
||||
expect(node.connectionChangingParameters).toBeDefined();
|
||||
expect(Array.isArray(node.connectionChangingParameters)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide reasoning for each discovered node', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Create webhook to receive data and store in PostgreSQL',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound.length).toBeGreaterThan(0);
|
||||
|
||||
// Each node should have nodeName, version, reasoning, and connectionChangingParameters
|
||||
result.nodesFound.forEach(
|
||||
({ nodeName, version, reasoning, connectionChangingParameters }) => {
|
||||
expect(nodeName).toBeDefined();
|
||||
expect(typeof nodeName).toBe('string');
|
||||
expect(version).toBeDefined();
|
||||
expect(typeof version).toBe('number');
|
||||
expect(reasoning).toBeDefined();
|
||||
expect(reasoning.length).toBeGreaterThan(10);
|
||||
expect(connectionChangingParameters).toBeDefined();
|
||||
expect(Array.isArray(connectionChangingParameters)).toBe(true);
|
||||
|
||||
// Validate structure of connection-changing parameters
|
||||
connectionChangingParameters.forEach((param) => {
|
||||
expect(param.name).toBeDefined();
|
||||
expect(typeof param.name).toBe('string');
|
||||
expect(param.possibleValues).toBeDefined();
|
||||
expect(Array.isArray(param.possibleValues)).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include categorization and best practices in internal state', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Automate invoice processing with AI',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.bestPractices).toBeDefined();
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(Array.isArray(result.nodesFound)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Workflows', () => {
|
||||
it('should discover multiple nodes for multi-step workflow', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Scrape competitor data, analyze with AI, generate report, and send via email',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(result.nodesFound.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle vague prompts gracefully', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Automate my workflow',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Should still return structured output even for vague prompts
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
expect(Array.isArray(result.nodesFound)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle prompts with explicit node names', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: 'Use HTTP Request node to call an API and Code node to transform the response',
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(result.nodesFound).toBeDefined();
|
||||
const nodeNames = result.nodesFound.map((n) => n.nodeName);
|
||||
expect(nodeNames.some((name) => name.includes('httpRequest'))).toBe(true);
|
||||
expect(nodeNames.some((name) => name.includes('code'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comprehensive Test Suite', () => {
|
||||
it('should discover nodes for all test prompts and display statistics', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
console.log('\n📊 Running comprehensive discovery test suite...\n');
|
||||
|
||||
const results: Array<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
nodesFound: Array<{
|
||||
nodeName: string;
|
||||
version: number;
|
||||
reasoning: string;
|
||||
connectionChangingParameters: Array<{
|
||||
name: string;
|
||||
possibleValues: Array<string | boolean | number>;
|
||||
}>;
|
||||
}>;
|
||||
nodeCount: number;
|
||||
expectedMatch: boolean;
|
||||
missing: string[];
|
||||
}> = [];
|
||||
|
||||
// Run all test prompts
|
||||
for (const test of testPrompts) {
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: test.prompt,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const check = hasExpectedNodes(result.nodesFound, test.expectedNodes);
|
||||
|
||||
results.push({
|
||||
name: test.name,
|
||||
prompt: test.prompt,
|
||||
nodesFound: result.nodesFound,
|
||||
nodeCount: result.nodesFound.length,
|
||||
expectedMatch: check.hasAll,
|
||||
missing: check.missing,
|
||||
});
|
||||
|
||||
// Log individual result
|
||||
console.log(`✓ ${test.name}`);
|
||||
console.log(` Nodes discovered: ${result.nodesFound.length}`);
|
||||
console.log(` Node types: ${result.nodesFound.map((n) => n.nodeName).join(', ')}`);
|
||||
if (!check.hasAll) {
|
||||
console.log(` ⚠️ Missing expected: ${check.missing.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Calculate and display statistics
|
||||
const frequency = calculateNodeFrequency(results);
|
||||
const sortedFrequency = Array.from(frequency.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15); // Top 15
|
||||
|
||||
console.log('\n📈 Top Discovered Nodes:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const [nodeName, count] of sortedFrequency) {
|
||||
const percentage = ((count / results.length) * 100).toFixed(1);
|
||||
const nodeDisplayName =
|
||||
parsedNodeTypes.find((n) => n.name === nodeName)?.displayName ?? nodeName;
|
||||
console.log(` ${nodeDisplayName.padEnd(35)} ${count} (${percentage}%)`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Calculate match rate
|
||||
const matchCount = results.filter((r) => r.expectedMatch).length;
|
||||
const matchRate = (matchCount / results.length) * 100;
|
||||
console.log(`✓ Expected node match rate: ${matchRate.toFixed(1)}%`);
|
||||
console.log(` ${matchCount}/${results.length} prompts found all expected nodes\n`);
|
||||
|
||||
// Calculate average nodes discovered
|
||||
const avgNodes = results.reduce((sum, r) => sum + r.nodeCount, 0) / results.length;
|
||||
console.log(`📊 Average nodes discovered per prompt: ${avgNodes.toFixed(1)}\n`);
|
||||
|
||||
// All results should have valid structure
|
||||
for (const result of results) {
|
||||
expect(result.nodesFound.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.nodesFound.length).toBeLessThanOrEqual(20); // Sanity check
|
||||
}
|
||||
|
||||
// At least 70% should match expected nodes (allowing for LLM variation)
|
||||
expect(matchRate).toBeGreaterThanOrEqual(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Technique Categorization (parity with promptCategorizationChain)', () => {
|
||||
const PARALLEL_BATCH_SIZE = 30;
|
||||
|
||||
it('should select correct techniques via get_best_practices for all test prompts', async () => {
|
||||
if (skipTests) return;
|
||||
|
||||
console.log(
|
||||
`\n📊 Running technique categorization test suite (${techniqueTestPrompts.length} prompts, ${PARALLEL_BATCH_SIZE} parallel)...\n`,
|
||||
);
|
||||
|
||||
type TestResult = {
|
||||
index: number;
|
||||
prompt: string;
|
||||
techniques: WorkflowTechniqueType[];
|
||||
expectedTechniques: WorkflowTechniqueType[];
|
||||
expectedMatch: boolean;
|
||||
missing: WorkflowTechniqueType[];
|
||||
};
|
||||
|
||||
// Process a single prompt
|
||||
const processPrompt = async (
|
||||
test: (typeof techniqueTestPrompts)[number],
|
||||
index: number,
|
||||
): Promise<TestResult> => {
|
||||
const compiled = discoverySubgraph.create({ parsedNodeTypes, llm });
|
||||
const result = await compiled.invoke({
|
||||
userRequest: test.prompt,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
const techniques = extractTechniquesFromMessages(result.messages);
|
||||
const check = hasExpectedTechniques(techniques, test.expectedTechniques);
|
||||
|
||||
return {
|
||||
index,
|
||||
prompt: test.prompt,
|
||||
techniques,
|
||||
expectedTechniques: test.expectedTechniques,
|
||||
expectedMatch: check.hasAll,
|
||||
missing: check.missing,
|
||||
};
|
||||
};
|
||||
|
||||
// Process prompts in parallel batches
|
||||
const allResults: TestResult[] = [];
|
||||
for (
|
||||
let batchStart = 0;
|
||||
batchStart < techniqueTestPrompts.length;
|
||||
batchStart += PARALLEL_BATCH_SIZE
|
||||
) {
|
||||
const batchEnd = Math.min(batchStart + PARALLEL_BATCH_SIZE, techniqueTestPrompts.length);
|
||||
const batch = techniqueTestPrompts.slice(batchStart, batchEnd);
|
||||
|
||||
console.log(
|
||||
`Processing batch ${Math.floor(batchStart / PARALLEL_BATCH_SIZE) + 1}/${Math.ceil(techniqueTestPrompts.length / PARALLEL_BATCH_SIZE)} (prompts ${batchStart + 1}-${batchEnd})...`,
|
||||
);
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (test, i) => await processPrompt(test, batchStart + i)),
|
||||
);
|
||||
|
||||
// Log results for this batch
|
||||
for (const result of batchResults) {
|
||||
const truncatedPrompt =
|
||||
result.prompt.length > 80 ? `${result.prompt.substring(0, 80)}...` : result.prompt;
|
||||
const status = result.expectedMatch ? '✓' : '⚠️';
|
||||
console.log(
|
||||
`${status} [${result.index + 1}/${techniqueTestPrompts.length}] ${truncatedPrompt}`,
|
||||
);
|
||||
console.log(` Techniques: ${result.techniques.join(', ') || '(none)'}`);
|
||||
console.log(` Expected: ${result.expectedTechniques.join(', ')}`);
|
||||
if (!result.expectedMatch) {
|
||||
console.log(` Missing: ${result.missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
allResults.push(...batchResults);
|
||||
}
|
||||
|
||||
// Calculate and display statistics
|
||||
const frequency = calculateTechniqueFrequency(allResults);
|
||||
const sortedFrequency = Array.from(frequency.entries()).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
console.log('\n📈 Technique Frequency:');
|
||||
console.log('─'.repeat(60));
|
||||
for (const [technique, count] of sortedFrequency) {
|
||||
const percentage = ((count / allResults.length) * 100).toFixed(1);
|
||||
console.log(` ${technique.padEnd(30)} ${count} (${percentage}%)`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Calculate match rate (individual techniques matched / total expected)
|
||||
const totalExpectedTechniques = allResults.reduce(
|
||||
(sum, r) => sum + r.expectedTechniques.length,
|
||||
0,
|
||||
);
|
||||
const totalMatchedTechniques = allResults.reduce(
|
||||
(sum, r) => sum + (r.expectedTechniques.length - r.missing.length),
|
||||
0,
|
||||
);
|
||||
const techniqueMatchRate = (totalMatchedTechniques / totalExpectedTechniques) * 100;
|
||||
|
||||
// Calculate prompt-level match rates
|
||||
const PARTIAL_MATCH_THRESHOLD = 0.5; // 50% of expected techniques = acceptable
|
||||
const fullMatchCount = allResults.filter((r) => r.expectedMatch).length;
|
||||
const acceptableMatchCount = allResults.filter((r) => {
|
||||
const matched = r.expectedTechniques.length - r.missing.length;
|
||||
const matchRatio = matched / r.expectedTechniques.length;
|
||||
return matchRatio >= PARTIAL_MATCH_THRESHOLD;
|
||||
}).length;
|
||||
|
||||
const fullMatchRate = (fullMatchCount / allResults.length) * 100;
|
||||
const acceptableMatchRate = (acceptableMatchCount / allResults.length) * 100;
|
||||
|
||||
console.log(`✓ Technique match rate: ${techniqueMatchRate.toFixed(1)}%`);
|
||||
console.log(
|
||||
` ${totalMatchedTechniques}/${totalExpectedTechniques} individual techniques matched\n`,
|
||||
);
|
||||
console.log(`✓ Acceptable match rate (≥50% of expected): ${acceptableMatchRate.toFixed(1)}%`);
|
||||
console.log(
|
||||
` ${acceptableMatchCount}/${allResults.length} prompts matched at least half of expected techniques\n`,
|
||||
);
|
||||
console.log(`✓ Full match rate (100% of expected): ${fullMatchRate.toFixed(1)}%`);
|
||||
console.log(
|
||||
` ${fullMatchCount}/${allResults.length} prompts matched all expected techniques\n`,
|
||||
);
|
||||
|
||||
// All results should have techniques (discovery should call get_best_practices)
|
||||
for (const result of allResults) {
|
||||
expect(result.techniques.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// At least 80% of prompts should have acceptable matches (≥50% of expected techniques)
|
||||
expect(acceptableMatchRate).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
[
|
||||
{
|
||||
"prompt": "An AI agent that will upload and process a set of files via Drive in a folder, then it will respond based on the documents table of the implemented Supabase vector database, and a webhook that will link my Cursor project and my n8n agent.",
|
||||
"expectedTechniques": ["document_processing", "knowledge_base"]
|
||||
},
|
||||
{
|
||||
"prompt": "Automate LinkedIn posting in n8n: fetch 20 trending topics (Google Trends, Reddit, Twitter, NewsAPI), score with AI for virality (≥7 keep), if <5 fill from evergreen list (Airtable/Sheet), generate founder-style post (hook, body, punchline, CTA) with AI, create creative via Nano Banana API, post 5/day (9am–9pm IST) via LinkedIn node, and archive all (topic, score, text, creative URL, timestamp, engagement)",
|
||||
"expectedTechniques": ["scraping_and_research", "content_generation", "triage"]
|
||||
},
|
||||
{
|
||||
"prompt": "create an ai chat bot",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "Get top 10 volatile stocks at the beginning of the open, we will analyze them and validate which ones are bullish based on technicals. Determine the target sell price or drop out (sell if down) price. Send a text message to me with the ticker and targets",
|
||||
"expectedTechniques": ["scraping_and_research", "data_analysis", "notification"]
|
||||
},
|
||||
{
|
||||
"prompt": "whatsapp message sent automation",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I need a workflow that takes a google doc and identifies the bolded Headings and copies those into a google sheet",
|
||||
"expectedTechniques": ["document_processing", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "i want build a workflow that search all our competitor ads and keywords in ads manager and list it all in a google sheet...our competitors name are Peesafe, Sairona, Eveeve..these details are get once in a month so set schedule trigger like that..search using searchapi",
|
||||
"expectedTechniques": ["scraping_and_research", "scheduling"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create a n8n that automatically posts new content to social media. The trigger should be either a new post published in WordPress or a new row added in Google Sheets. For each new item, take the post title, link, and featured image, then use AI to generate a short and engaging caption under 200 characters. Post the content automatically across multiple platforms with platform-specific formatting: on Twitter (X), keep it short with relevant hashtags; on LinkedIn, use a professional tone with 2–3 industry-related hashtags; and on Facebook, write a friendly caption with the link preview. Make sure the Zap prevents duplicate posts and includes links and images where supported.",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create me a proper pipeline for marketing which researches topics, writes a script using Gemini API, creates a video through heyGen and Eleven Labs, and then uses Instagram Graph API to post, and uses Notion as a database",
|
||||
"expectedTechniques": ["scraping_and_research", "content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Workflow: We want to reduce the cost of information gathering for the team by collecting and analyzing news closely related to the pharmacy support service 'enpas' and posting it on Slack.\nTrigger: Every day at 8 AM\nAction 1: Retrieve information from Google Drive (enpas information, search keywords, competitors, etc.)\nAction 2: Using the retrieved information, AI collects related news. The collection period should be within one week for freshness. Obtain 10 pieces of data in order of high relevance to enpas. Always obtain the title, article content, and URL. Summarize the article content concisely based on the impact on enpas.\nAction 3: Notify the obtained data on Slack.\nNote: Action 2 may be composed of multiple nodes",
|
||||
"expectedTechniques": [
|
||||
"scraping_and_research",
|
||||
"scheduling",
|
||||
"document_processing",
|
||||
"notification"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "ok i need a graph rag system that goes from local files to code node that decides what type of file it is. then goes to a switch. each output is a different document type. it then grabs that document and extracts text from it. for pdf it also has to pull text from images since most my books are in image format inside pdf. then uses gemeni vision to extract text. then place the information into a neo4j graph database",
|
||||
"expectedTechniques": ["knowledge_base", "document_processing", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "To generate images in Midjourney using the CometAPI node, 1 chat input, 2 understanding and interpreting the prompt to make it effective for Midjourney, then saving it to sheets",
|
||||
"expectedTechniques": ["chatbot", "content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "create me an ai receptionist that can answer multiple phone calls and text messages at once using twilio. Books them for appointments, reschedules, or cancels. Checks google calendar for availability. Send confirmation sms reminder after appointment is booked and a day before their appointment. Be able to answer questions such as hours, location, pricing, service info., or policy questions through documents given to you.",
|
||||
"expectedTechniques": ["chatbot", "knowledge_base"]
|
||||
},
|
||||
{
|
||||
"prompt": "This time, it’s all about lead generation automations that small businesses can actually use. Imagine creating a system that automatically finds new customers every week: A plumber in Austin getting fresh leads from Google Maps. A local gym targeting people posting about fitness in their city. An agency scraping LinkedIn for B2B prospects and enriching them with Clearbit",
|
||||
"expectedTechniques": ["scraping_and_research", "enrichment", "triage"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create a comprehensive automation agent for small businesses that fully manages customer interaction through WhatsApp. The system must receive initial messages, respond naturally like a human, request necessary information for quotations, process reservations, and capture all data in a Google Sheets database organized into clients who made reservations and those who did not. It must integrate automatic scheduling in Google Calendar, notifications to the administrator about new reservations, and support online payment processing as well as OXXO payments, all orchestrated through n8n flows that connect these platforms cohesively.",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "want an email agent that create email on my behalf by integrating claude",
|
||||
"expectedTechniques": ["notification"]
|
||||
},
|
||||
{
|
||||
"prompt": "I want to integrate Claude in my workflow so that it can write emails on my behalf.",
|
||||
"expectedTechniques": ["notification"]
|
||||
},
|
||||
{
|
||||
"prompt": "I am an automation expert using n8n, and I want to create a workflow to manage customer purchase orders (POs). The workflow should use Airtable as the central database and do the following:\n\nTrigger: When a purchase order email is received in my Titan email account.\n\nExtract Data: Automatically extract the content from the attached PDF PO document.\n\nDatabase Integration: Add a new record in Airtable with all relevant data from the PO.\n\nOrder Tracking: Include a status field in Airtable for each order. When the status is updated to Completed, trigger the next step.\n\nDocument Signing: Automatically send an email to the customer with the PDF attached for signature, serving as proof of receipt.\n\nConfirmation: Once the customer signs the document, automatically send a confirmation email with the signed document attached.",
|
||||
"expectedTechniques": [
|
||||
"document_processing",
|
||||
"data_extraction",
|
||||
"notification",
|
||||
"human_in_the_loop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "Hello, build the flow corresponding to this code:\n\n/**\n * SAH — WhatsApp Bot (WABA Cloud API)\n * Automated conversation demo aligned to \"This is our culture (paid)\".\n *\n * What it includes:\n * - Webhook (verify + receive)\n * - Main menu (Balance and pay / Make an agreement / Clarification / Report leak)\n * - Flow by option with interactive messages (list/buttons) and CTA\n * - SLAs visible in responses\n * - Example of using templates (HSM) for greeting\n * - Conversation state in memory (for demo)\n *\n * Requirements:\n * - Node 18+, npm i express axios\n * - Environment variables: WABA_TOKEN, WABA_PHONE_ID, VERIFY_TOKEN\n *\n * Note: Adjust real URLs, validations, and persist state in DB for production.\n */\n\nimport express from 'express';\nimport axios from 'axios';\n\nconst app = express();\napp.use(express.json());\n\n// === Config ===\nconst WABA_TOKEN = process.env.WABA_TOKEN; // Long-Lived Token from the Meta app\nconst WABA_PHONE_ID = process.env.WABA_PHONE_ID; // ID",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I have a google sheet with rows having basic prompts for short video creation. I want to create an automation where each of these prompts are fed into google Gemini Veo3 which generate the video using the prompt. Once the video is generated it get saved in Drop box which then can be uploaded on Instagram",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Build an AI receptionist using Twilio and Google Calendar and OpenAI to do guest communication like WiFi passwords and booking and scheduling",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I need a vectorized English to other languages translator",
|
||||
"expectedTechniques": ["data_transformation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Workflow Goal: To create a multi-agent, automated workflow that takes a user-defined topic and moves it through a series of iterative stages—ideation, outlining, writing, editing, and layout—to produce a complete, publication-ready JLI (Jewish Learning Institute) lesson.",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Access the sources: ERP, internal web, and PDF contract repository. Normalize the content of each contract (use OCR if necessary). Classify each contract by type: labor, commercial, lease, service, supply, financial, joint venture, etc. Generate a unique list with: contract ID, type, signing date, and contracting party/contractor. Identify generic clauses: confidentiality, jurisdiction, termination, conflict resolution. Identify specific clauses according to the contract type (e.g., SLA in services, warranties in supply, KPIs in outsourcing). Evaluate the existence of mandatory policies: compliance, civil liability, work stability, etc. For each contract extract: Object, Economic value (COP$), Validity (start–end).",
|
||||
"expectedTechniques": [
|
||||
"document_processing",
|
||||
"data_extraction",
|
||||
"data_transformation",
|
||||
"triage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "A series of agents to develop a lesson from concept through outline, drafting, editorial, and making publish-ready.",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "I want you to add timesheet PDFs to invoices generated in Xero",
|
||||
"expectedTechniques": ["document_processing", "enrichment"]
|
||||
},
|
||||
{
|
||||
"prompt": "Instructions to the AI engine:\n\n1. Information Extraction\n - Access sources: ERP, internal web, and contract repository in PDF.\n - Normalize the content of each contract (use OCR if necessary).\n\n2. Contract Inventory\n - Classify each contract by type: labor, commercial, lease, service, supply, financial, joint venture, etc.\n - Generate a unique list with: contract ID, type, signing date, and contracting party/contractor.\n\n3. Clause Analysis\n - Identify generic clauses: confidentiality, jurisdiction, termination, conflict resolution.\n - Identify specific clauses according to the type of contract (e.g.: SLA in services, warranties in supply, KPIs in outsourcing).\n - Evaluate the existence of mandatory policies: compliance, civil liability, work stability, etc.\n\n4. Executive Summary of Contracts\n - For each contract extract: Object, Economic value (COP$), Duration (start–end),",
|
||||
"expectedTechniques": [
|
||||
"scraping_and_research",
|
||||
"document_processing",
|
||||
"data_extraction",
|
||||
"triage",
|
||||
"data_analysis",
|
||||
"data_transformation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "You are an automatic generator of affiliate product catalogs. Your task is to transform raw data from multiple sources (Amazon, Mercado Livre, and Shopee) into an organized list to import into a showcase website. \n\n### General Instructions:\n1. Receive the input data in JSON, API, or RSS format containing:\n - Product name\n - Price\n - Product link\n - Image\n - Category (when available)\n - Rating or number of sales (when available)\n\n2. For each platform:\n - Amazon → use the affiliate link with parameter `?tag=MY_TAG`.\n - Mercado Livre → use the affiliate program link from Mercado Ads (with the tracking ID).\n - Shopee → use the affiliate program link from Shopee (with your referral code).\n\n3. Filter only products that have:\n - Available price\n - Rating ≥ 4 stars or high relevance (Mercado Livre and Shopee use \"reputation/sales\").\n - Valid affiliate links.\n\n4. Standardize prices to **R$",
|
||||
"expectedTechniques": ["data_transformation", "content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "You are an assistant specialized in project management. You have access to a database containing various projects stored in an Excel spreadsheet. Your role is: 1. Understand the user's question, even if it is asked differently (e.g., \"Which projects are IT?\", \"Show me something about logistics\", \"Suggest a project to reduce costs\"). 2. Search for the most relevant projects in the database (always use the context of the spreadsheet that will be provided). 3. Explain your answer clearly and concisely, providing the project name, responsible area, and main objective. 4. If the user wants to include a new project, organize the data in a structured format (columns: Name, Area, Objective, Status, Deadline, Responsible) and return it in JSON format so I can save it in the spreadsheet. 5. If there is no matching project, suggest a new one based on the existing information. Always respond in Portuguese, clearly and professionally.",
|
||||
"expectedTechniques": ["knowledge_base", "chatbot", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "I want to create a flow where I have a chatbot on WhatsApp that can check projects from a spreadsheet as a database and has artificial intelligence to locate keywords of the projects and find what I need.",
|
||||
"expectedTechniques": ["chatbot", "knowledge_base"]
|
||||
},
|
||||
{
|
||||
"prompt": "You are a smart real estate advisor specialized in searching and recommending properties. Your task is to find and recommend properties according to the parameters provided by the user. You must analyze and compare information from multiple sources available online (example: Airbnb, Booking, real estate portals, local pages) and return the best options according to the needs.\n\nInput parameters (which you will always receive):\n- Type of operation: [sale | rental]\n- Minimum area in m²: [example: 80]\n- Area: [neighborhood, city, country]\n- Attractive features: [example: beachfront, near downtown, tourist areas, good security, transportation]\n\nInstructions for the results:\n1. Filter and select properties that match the parameters.\n2. Prioritize properties that meet more attractive features.\n3. Return the information structured in the following format:\n\nI want you to create a flow consulting the already mentioned portals.",
|
||||
"expectedTechniques": ["scraping_and_research", "data_analysis", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "I need a flow that is triggered when a form is submitted. The form needs to have multiple unique links so if Person A sends the form to Person B, Person A is notified. A copy of the form should be sent via email (Outlook) to Person A, and it should be automatically uploaded into the person's Agency Management system via API (Applied Epic) into the correct account in the system. If no account is found, Person A should receive a notice that no account has been found and that Person A should create an account to begin attaching files.",
|
||||
"expectedTechniques": ["form_input", "notification", "document_processing"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create an n8n workflow that automatically analyzes competitor Facebook ads, extracts marketing insights using AI, and saves results to Google Sheets. The workflow processes multiple competitors in batches and runs on a manual trigger. On trigger, split the comma-separated competitor names into individual items; for each name, call SearchAPI.io’s Meta Ad Library endpoint to fetch up to 50 ads; extract key ad fields; send each ad’s text and CTA to GPT-4o-mini to return JSON with primary, secondary, action, emotional, and brand keywords plus tone, audience, offer, urgency, and strategy insights; merge the AI output with the ad data; and append or update each record in a Google Sheet.",
|
||||
"expectedTechniques": ["data_analysis", "data_extraction", "scraping_and_research"]
|
||||
},
|
||||
{
|
||||
"prompt": "I want to create a chatbot with data that I will import from Excel, so I can validate the projects and that has AI to improve things and give suggestions",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I need a workflow that allows me to paste a website or a LinkedIn link or even a screenshot of a company in a specific Slack channel. I need the bot to then scan through it, look for relevant information, do a web search about the company and summarize the information, then categorize it by defined measures. Then it creates a Notion page and pushes the Notion page back into the Slack channel as a response.",
|
||||
"expectedTechniques": [
|
||||
"chatbot",
|
||||
"scraping_and_research",
|
||||
"data_extraction",
|
||||
"data_analysis",
|
||||
"content_generation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "i want to automate a mechanism that receives different news from telegram channels and checks if the news are about lebanon, if yes translate the news to arabic and send them to a telegram channel of my own",
|
||||
"expectedTechniques": ["data_transformation", "notification", "monitoring", "triage"]
|
||||
},
|
||||
{
|
||||
"prompt": "Motivation Reels Factory — 1080p + Autopost with scheduled cron job at 10:00, environment variables for safe mode, drive folder ID, watermark handle, and query list including crazy experiment, fails, prank gone wrong, falling objects, satisfying, reveal, before after, shock reaction, countdown, with maximum items set to 8 and hook seconds set to 4.",
|
||||
"expectedTechniques": ["scheduling", "document_processing", "content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Migrate JSON data from one schema to another. The schemas are JSON-Schema documents.",
|
||||
"expectedTechniques": ["data_transformation"]
|
||||
},
|
||||
{
|
||||
"prompt": "i want to automate my social media content post process",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "I need to create an artificial intelligence that compiles quotes in Excel for me by learning from previous ones. It must autonomously understand, thanks to the previous quotes I upload, where to find the description, recognize the product, and fill in, where the products are, the unit material cost column and the installation time column in minutes. It must return the same file but filled in.",
|
||||
"expectedTechniques": ["content_generation", "data_analysis", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "when chat massage",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "Takes input from a google chat, searchs google patents based on the input, and returns the results to the google chat",
|
||||
"expectedTechniques": ["scraping_and_research", "chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I would like to automate the execution of a group of queries in snowflake, paste those queries to google sheets and then reload the linked graphs in google slides. The process should run every day, at 5:00 AM Brazil time.",
|
||||
"expectedTechniques": ["scheduling", "data_transformation"]
|
||||
},
|
||||
{
|
||||
"prompt": "2. Designing the main workflow\n\nYour system should have several main nodes:\n\nTrigger (automatic start)\n\nFor example, Cron → every day at 9 AM.\n\nOr Webhook → manual trigger by yourself.\n\nIdea and script (ChatGPT Node)\n\nConnect to ChatGPT API.\n\nGive a prompt: \"Write a 5-minute script for a YouTube channel about [current trending topic].\"\n\nText-to-speech (ElevenLabs Node or HTTP Request)\n\nGive the text to ElevenLabs API → it returns an audio file.\n\nVideo creation (Runway or Pictory Node)\n\nGive the text + audio → it builds a video.\n\nIf you want it simpler → combine audio + stock footage (Pexels API) + ffmpeg.\n\nThumbnail creation (DALL·E or Stable Diffusion Node)\n\nGive a prompt → it returns a thumbnail image.\n\nThen, with ImageMagick Node, you can add text on the image.\n\nUpload to YouTube (YouTube Node or HTTP API)\n\nUploads video + thumbnail + descriptions and tags.\n\nYou can schedule publication time.\n\nAnalysis (YouTube Analytics Node)\n\nAfter a few days, it retrieves data.\n\nChatGPT analyzes and improves the next prompts.\n\n3. File management\n\nTemporary files (audio, video,",
|
||||
"expectedTechniques": ["content_generation", "document_processing", "data_analysis"]
|
||||
},
|
||||
{
|
||||
"prompt": "I NEED AN APPLICATION IN WHICH I CAN RECORD MY GYM EXERCISES BY THE DAYS OF THE WEEK, BEING ABLE TO NOTE THE WEIGHTS AND REPETITIONS I HAVE DONE, WHILE ALSO BEING ABLE TO ADD IMAGES OR SHORT VIDEOS EXPLAINING HOW TO DO THE EXERCISE",
|
||||
"expectedTechniques": ["document_processing"]
|
||||
},
|
||||
{
|
||||
"prompt": "A basic chat bot which does an API call to retell AI to create a chat and return the response",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "I want to create a workflow that will scrape the regions",
|
||||
"expectedTechniques": ["scraping_and_research"]
|
||||
},
|
||||
{
|
||||
"prompt": "cold email outreach using google sheets",
|
||||
"expectedTechniques": ["notification"]
|
||||
},
|
||||
{
|
||||
"prompt": "You are an expert SEO content generator. I will give you a keyword or topic, and you will create an optimized SEO package with the following: 1. A catchy SEO title (max 60 characters). 2. A meta description (max 160 characters). 3. Suggested H1 and H2 headings. 4. A short blog post (300-500 words) optimized for the keyword. 5. 5 SEO-friendly tags/keywords related to the topic. 6. Return the result in clean JSON format with fields: { \"title\": \"\", \"meta_description\": \"\", \"h1\": \"\", \"h2\": \"\", \"content\": \"\", \"tags\": [] } Make sure the text is human-like, engaging, and optimized for search engines.",
|
||||
"expectedTechniques": ["content_generation"]
|
||||
},
|
||||
{
|
||||
"prompt": "reading my google docs and then use free ai Prompt: You are given a transcript of a meeting conversation. Your tasks are: Analyze the text carefully to identify unclear, incomplete, or missing words/phrases. Use contextual clues from the surrounding conversation to suggest the most accurate or logical wording that could fill those gaps. Rewrite the conversation in a clearer, more accurate, and detailed form, making it coherent and professional while preserving the intended meaning. At the end, provide a concise summary (one paragraph) highlighting the main points, decisions, and action items from the meeting. Output structure: Section 1: List of unclear/missing parts + your suggested clarifications. Section 2: Rewritten, clarified conversation. Section 3: Final summary paragraph.",
|
||||
"expectedTechniques": [
|
||||
"document_processing",
|
||||
"data_analysis",
|
||||
"data_extraction",
|
||||
"enrichment",
|
||||
"data_transformation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "I want to build a workflow that:\n\nReads volunteer phone numbers from Google Sheets.\n\nChecks which volunteers are active on Telegram and filters only those users.\n\nSends a poll through a Telegram bot with 10 listed content topics. Along with the poll, the volunteer should also be able to type their own reason (open text) explaining why we should create that content.\n\nCaptures each volunteer’s response (poll option + reason) when they reply.\n\nSaves the collected responses into a separate Google Sheet, including volunteer name/number, selected poll option, and written reason.",
|
||||
"expectedTechniques": ["document_processing", "notification", "human_in_the_loop"]
|
||||
},
|
||||
{
|
||||
"prompt": "I would like to create an automation that when I post a website to a Slack channel, a bot then looks at the website (if it's a LinkedIn page, they look for the website). Then the bot does research and classifies the company and writes a small summary, then pushes this to a Notion page, sending it back to Slack with a link to the Notion page.",
|
||||
"expectedTechniques": [
|
||||
"chatbot",
|
||||
"scraping_and_research",
|
||||
"triage",
|
||||
"data_analysis",
|
||||
"data_transformation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prompt": "make a flow for automated scraping news from economics website and then summarize and send to messenger daily",
|
||||
"expectedTechniques": ["scraping_and_research", "data_transformation", "notification"]
|
||||
},
|
||||
{
|
||||
"prompt": "collecting data from YouTube backstage, and analyze the outcome of each video, and make an simple report",
|
||||
"expectedTechniques": ["scraping_and_research", "data_analysis", "data_transformation"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create a chatbot on Telegram for a clothing business named Netapa CS using Gemini for the NTA agent and using Google Sheets for the database, and send it again to Telegram.",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "create a chatbot in slack where it will answer questions based on a knowledge base, the chatbot should get the email address of the person asking and it will get the team and reporting region (location) from which the person is asking so that it will know how to answer with more context",
|
||||
"expectedTechniques": ["chatbot", "knowledge_base"]
|
||||
},
|
||||
{
|
||||
"prompt": "Search emails from Bob in the past 7 days using Nylas",
|
||||
"expectedTechniques": ["data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "Make my number answer anybody. Like when some homies talk in chat, I put a reaction like 👍🏻 and after 3 seconds answer with 'وعليكم السلام ورحمة الله سيتم الرد قريباً' on Telegram.",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "Create a workflow that triggers when a new contact is created in HubSpot. Then, send the contact's data to an OpenAI node for analysis. Finally, take the result from OpenAI and use the HubSpot node again to update the original contact's properties.",
|
||||
"expectedTechniques": ["monitoring", "data_analysis"]
|
||||
},
|
||||
{
|
||||
"prompt": "1. Webhook Trigger → receives the request (WhatsApp/Facebook/Web)\n2. Router → detects that it is a reservation\n3. Function → extracts date, time, number of people\n4. Google Calendar → checks availability\n5. Google Sheets → records the reservation\n6. Sends confirmation → WhatsApp/Facebook/email to the client\n7. Notification → to the restaurateur + reminder before the reservation",
|
||||
"expectedTechniques": ["form_input", "scheduling", "data_extraction"]
|
||||
},
|
||||
{
|
||||
"prompt": "answering frequently asked question",
|
||||
"expectedTechniques": ["chatbot"]
|
||||
},
|
||||
{
|
||||
"prompt": "Build a 'Reply Triage' workflow that triggers on inbound emails and routes them using AI:\n\n1. Use the **Gmail Webhook** node as the trigger.\n2. Use the **HubSpot** node to find the contact record associated with the sender's email.\n3. Add an **OpenAI** node to read the email body and classify its intent as one of the following: INTERESTED, NOT_INTERESTED, or QUESTION.\n4. Use a **Switch** node to route the workflow based on the intent from the OpenAI node.\n5. Connect the outputs of the Switch node to perform the final actions:\n * If **INTERESTED**, use the HubSpot node to create a task for a sales rep.\n * If **NOT_INTERESTED**, use the HubSpot node to update the contact's status.\n * If **QUESTION**, use the Slack node to send the email content to a specific channel for human review.",
|
||||
"expectedTechniques": ["triage"]
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createWorkflow,
|
||||
createNode,
|
||||
nodeTypes,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
setupWorkflowState,
|
||||
expectToolSuccess,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import {
|
||||
createValidateConfigurationTool,
|
||||
VALIDATE_CONFIGURATION_TOOL,
|
||||
} from '../validate-configuration.tool';
|
||||
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('validateConfiguration tool', () => {
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
let validateConfigurationTool: ReturnType<typeof createValidateConfigurationTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
parsedNodeTypes = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.agent];
|
||||
validateConfigurationTool = createValidateConfigurationTool(parsedNodeTypes).tool;
|
||||
});
|
||||
|
||||
it('should have correct tool metadata', () => {
|
||||
expect(VALIDATE_CONFIGURATION_TOOL.toolName).toBe('validate_configuration');
|
||||
expect(VALIDATE_CONFIGURATION_TOOL.displayTitle).toBe('Validating configuration');
|
||||
});
|
||||
|
||||
it('should return valid for simple workflow without AI nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_configuration', 'call-1');
|
||||
|
||||
const result = await validateConfigurationTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'Configuration is valid');
|
||||
});
|
||||
|
||||
it('should handle empty workflow', async () => {
|
||||
const workflow = createWorkflow([]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_configuration', 'call-2');
|
||||
|
||||
const result = await validateConfigurationTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Empty workflow has no configuration issues
|
||||
expectToolSuccess(content, 'valid');
|
||||
});
|
||||
|
||||
it('should validate workflow with multiple nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'webhook1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
parameters: { path: 'test-webhook' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'http1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
parameters: { url: 'https://api.example.com', method: 'POST' },
|
||||
}),
|
||||
createNode({
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
parameters: { jsCode: 'return items;' },
|
||||
}),
|
||||
]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_configuration', 'call-3');
|
||||
|
||||
const result = await validateConfigurationTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'valid');
|
||||
});
|
||||
|
||||
it('should return configuration validation results in state update', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_configuration', 'call-4');
|
||||
|
||||
const result = await validateConfigurationTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Should have validation results in the update
|
||||
expect(content.update).toBeDefined();
|
||||
expect(content.update.messages).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
createWorkflow,
|
||||
createNode,
|
||||
nodeTypes,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
setupWorkflowState,
|
||||
expectToolSuccess,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createValidateStructureTool, VALIDATE_STRUCTURE_TOOL } from '../validate-structure.tool';
|
||||
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('validateStructure tool', () => {
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
let validateStructureTool: ReturnType<typeof createValidateStructureTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
parsedNodeTypes = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook];
|
||||
validateStructureTool = createValidateStructureTool(parsedNodeTypes).tool;
|
||||
});
|
||||
|
||||
it('should have correct tool metadata', () => {
|
||||
expect(VALIDATE_STRUCTURE_TOOL.toolName).toBe('validate_structure');
|
||||
expect(VALIDATE_STRUCTURE_TOOL.displayTitle).toBe('Validating structure');
|
||||
});
|
||||
|
||||
it('should return valid when workflow has trigger and proper connections', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'webhook1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
workflow.connections = {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]],
|
||||
},
|
||||
};
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_structure', 'call-1');
|
||||
|
||||
const result = await validateStructureTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'Workflow structure is valid');
|
||||
});
|
||||
|
||||
it('should report missing trigger node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_structure', 'call-2');
|
||||
|
||||
const result = await validateStructureTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'structure issues');
|
||||
expect(content.update.messages[0].kwargs.content).toContain('trigger');
|
||||
});
|
||||
|
||||
it('should report connection issues', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'webhook1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
// HTTP Request has no incoming connection (missing required input)
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_structure', 'call-3');
|
||||
|
||||
const result = await validateStructureTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Should report the disconnected node
|
||||
expectToolSuccess(content, 'structure issues');
|
||||
});
|
||||
|
||||
it('should handle empty workflow', async () => {
|
||||
const workflow = createWorkflow([]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_structure', 'call-4');
|
||||
|
||||
const result = await validateStructureTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Empty workflow is valid structurally (no violations)
|
||||
expectToolSuccess(content, 'valid');
|
||||
});
|
||||
|
||||
it('should allow multiple trigger nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'webhook1', name: 'Webhook 1', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'webhook2', name: 'Webhook 2', type: 'n8n-nodes-base.webhook' }),
|
||||
]);
|
||||
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
const config = createToolConfig('validate_structure', 'call-5');
|
||||
|
||||
const result = await validateStructureTool.invoke({}, config);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
// Multiple triggers are valid
|
||||
expectToolSuccess(content, 'valid');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { tool } from '@langchain/core/tools';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
import { validateAgentPrompt, validateTools, validateFromAi } from '@/validation/checks';
|
||||
|
||||
import { ToolExecutionError, ValidationError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
import { createErrorResponse, createSuccessResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
|
||||
const validateConfigurationSchema = z.object({}).strict().default({});
|
||||
|
||||
export const VALIDATE_CONFIGURATION_TOOL: BuilderToolBase = {
|
||||
toolName: 'validate_configuration',
|
||||
displayTitle: 'Validating configuration',
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation tool for Configurator subgraph.
|
||||
* Checks node configuration: agent prompts, tool parameters, $fromAI usage.
|
||||
*/
|
||||
export function createValidateConfigurationTool(
|
||||
parsedNodeTypes: INodeTypeDescription[],
|
||||
): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
VALIDATE_CONFIGURATION_TOOL.toolName,
|
||||
VALIDATE_CONFIGURATION_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
const validatedInput = validateConfigurationSchema.parse(input ?? {});
|
||||
reporter.start(validatedInput);
|
||||
|
||||
const state = getWorkflowState();
|
||||
reportProgress(reporter, 'Validating configuration');
|
||||
|
||||
const agentViolations = validateAgentPrompt(state.workflowJSON);
|
||||
const toolViolations = validateTools(state.workflowJSON, parsedNodeTypes);
|
||||
const fromAiViolations = validateFromAi(state.workflowJSON, parsedNodeTypes);
|
||||
|
||||
const allViolations = [...agentViolations, ...toolViolations, ...fromAiViolations];
|
||||
|
||||
let message: string;
|
||||
if (allViolations.length === 0) {
|
||||
message = 'Configuration is valid. Agent prompts, tools, and $fromAI usage are correct.';
|
||||
} else {
|
||||
message = `Found ${allViolations.length} configuration issues:\n${allViolations.map((v) => `- ${v.description}`).join('\n')}`;
|
||||
}
|
||||
|
||||
reporter.complete({ message });
|
||||
|
||||
return createSuccessResponse(config, message, {
|
||||
configurationValidation: {
|
||||
agentPrompt: agentViolations,
|
||||
tools: toolViolations,
|
||||
fromAi: fromAiViolations,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationError = new ValidationError('Invalid input parameters', {
|
||||
extra: { errors: error.errors },
|
||||
});
|
||||
reporter.error(validationError);
|
||||
return createErrorResponse(config, validationError);
|
||||
}
|
||||
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Failed to validate configuration',
|
||||
{
|
||||
toolName: VALIDATE_CONFIGURATION_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: VALIDATE_CONFIGURATION_TOOL.toolName,
|
||||
description:
|
||||
'Validate node configuration (agent prompts, tool parameters, $fromAI usage). Call after configuring nodes to check for issues.',
|
||||
schema: validateConfigurationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...VALIDATE_CONFIGURATION_TOOL,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { tool } from '@langchain/core/tools';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
import { validateConnections, validateTrigger } from '@/validation/checks';
|
||||
|
||||
import { ToolExecutionError, ValidationError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
import { createErrorResponse, createSuccessResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
|
||||
const validateStructureSchema = z.object({}).strict().default({});
|
||||
|
||||
export const VALIDATE_STRUCTURE_TOOL: BuilderToolBase = {
|
||||
toolName: 'validate_structure',
|
||||
displayTitle: 'Validating structure',
|
||||
};
|
||||
|
||||
/**
|
||||
* Validation tool for Builder subgraph.
|
||||
* Checks workflow structure: connections and trigger presence.
|
||||
*/
|
||||
export function createValidateStructureTool(parsedNodeTypes: INodeTypeDescription[]): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
VALIDATE_STRUCTURE_TOOL.toolName,
|
||||
VALIDATE_STRUCTURE_TOOL.displayTitle,
|
||||
);
|
||||
|
||||
try {
|
||||
const validatedInput = validateStructureSchema.parse(input ?? {});
|
||||
reporter.start(validatedInput);
|
||||
|
||||
const state = getWorkflowState();
|
||||
reportProgress(reporter, 'Validating structure');
|
||||
|
||||
const connectionViolations = validateConnections(state.workflowJSON, parsedNodeTypes);
|
||||
const triggerViolations = validateTrigger(state.workflowJSON, parsedNodeTypes);
|
||||
|
||||
const allViolations = [...connectionViolations, ...triggerViolations];
|
||||
|
||||
let message: string;
|
||||
if (allViolations.length === 0) {
|
||||
message =
|
||||
'Workflow structure is valid. All connections are correct and trigger node is present.';
|
||||
} else {
|
||||
message = `Found ${allViolations.length} structure issues:\n${allViolations.map((v) => `- ${v.description}`).join('\n')}`;
|
||||
}
|
||||
|
||||
reporter.complete({ message });
|
||||
|
||||
return createSuccessResponse(config, message, {
|
||||
structureValidation: { connections: connectionViolations, trigger: triggerViolations },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationError = new ValidationError('Invalid input parameters', {
|
||||
extra: { errors: error.errors },
|
||||
});
|
||||
reporter.error(validationError);
|
||||
return createErrorResponse(config, validationError);
|
||||
}
|
||||
|
||||
const toolError = new ToolExecutionError(
|
||||
error instanceof Error ? error.message : 'Failed to validate structure',
|
||||
{
|
||||
toolName: VALIDATE_STRUCTURE_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: VALIDATE_STRUCTURE_TOOL.toolName,
|
||||
description:
|
||||
'Validate workflow structure (connections, trigger). Call after creating nodes/connections to check for issues.',
|
||||
schema: validateStructureSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...VALIDATE_STRUCTURE_TOOL,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Coordination types for multi-agent subgraph handoff.
|
||||
*
|
||||
* The coordination log is used to track which subgraphs have completed
|
||||
* and enable deterministic routing without polluting the messages array.
|
||||
*/
|
||||
|
||||
export type SubgraphPhase = 'discovery' | 'builder' | 'configurator';
|
||||
|
||||
/**
|
||||
* Entry in the coordination log tracking subgraph completion.
|
||||
*/
|
||||
export interface CoordinationLogEntry {
|
||||
/** Which subgraph completed */
|
||||
phase: SubgraphPhase;
|
||||
|
||||
/** Completion status */
|
||||
status: 'completed' | 'error';
|
||||
|
||||
/** When the subgraph completed (Unix timestamp) */
|
||||
timestamp: number;
|
||||
|
||||
/** Brief summary for logging/debugging */
|
||||
summary: string;
|
||||
|
||||
/** Full output message (e.g., configurator's setup instructions) */
|
||||
output?: string;
|
||||
|
||||
/** Phase-specific metadata */
|
||||
metadata: CoordinationMetadata;
|
||||
}
|
||||
|
||||
export type CoordinationMetadata =
|
||||
| DiscoveryMetadata
|
||||
| BuilderMetadata
|
||||
| ConfiguratorMetadata
|
||||
| ErrorMetadata;
|
||||
|
||||
export interface DiscoveryMetadata {
|
||||
phase: 'discovery';
|
||||
/** Number of nodes discovered */
|
||||
nodesFound: number;
|
||||
/** List of node type names discovered */
|
||||
nodeTypes: string[];
|
||||
/** Whether best practices were retrieved */
|
||||
hasBestPractices: boolean;
|
||||
}
|
||||
|
||||
export interface BuilderMetadata {
|
||||
phase: 'builder';
|
||||
/** Number of nodes created */
|
||||
nodesCreated: number;
|
||||
/** Number of connections created */
|
||||
connectionsCreated: number;
|
||||
/** Names of nodes created */
|
||||
nodeNames: string[];
|
||||
}
|
||||
|
||||
export interface ConfiguratorMetadata {
|
||||
phase: 'configurator';
|
||||
/** Number of nodes configured */
|
||||
nodesConfigured: number;
|
||||
/** Whether setup instructions were generated */
|
||||
hasSetupInstructions: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorMetadata {
|
||||
phase: 'error';
|
||||
/** The subgraph that failed */
|
||||
failedSubgraph: SubgraphPhase;
|
||||
/** Error message */
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper functions to create typed metadata objects.
|
||||
* These eliminate the need for type assertions when creating coordination log entries.
|
||||
*/
|
||||
export function createDiscoveryMetadata(data: Omit<DiscoveryMetadata, 'phase'>): DiscoveryMetadata {
|
||||
return { phase: 'discovery', ...data };
|
||||
}
|
||||
|
||||
export function createBuilderMetadata(data: Omit<BuilderMetadata, 'phase'>): BuilderMetadata {
|
||||
return { phase: 'builder', ...data };
|
||||
}
|
||||
|
||||
export function createConfiguratorMetadata(
|
||||
data: Omit<ConfiguratorMetadata, 'phase'>,
|
||||
): ConfiguratorMetadata {
|
||||
return { phase: 'configurator', ...data };
|
||||
}
|
||||
|
||||
export function createErrorMetadata(data: Omit<ErrorMetadata, 'phase'>): ErrorMetadata {
|
||||
return { phase: 'error', ...data };
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
export interface DiscoveryContext {
|
||||
nodesFound: Array<{
|
||||
nodeName: string;
|
||||
version: number;
|
||||
reasoning: string;
|
||||
connectionChangingParameters: Array<{
|
||||
name: string;
|
||||
possibleValues: Array<string | boolean | number>;
|
||||
}>;
|
||||
}>;
|
||||
bestPractices?: string;
|
||||
}
|
||||
|
|
@ -3,3 +3,17 @@ import type { AIMessage, BaseMessage } from '@langchain/core/messages';
|
|||
export function isAIMessage(msg: BaseMessage): msg is AIMessage {
|
||||
return msg.getType() === 'ai';
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a BaseMessage
|
||||
* BaseMessage instances have a getType method and content property
|
||||
*/
|
||||
export function isBaseMessage(value: unknown): value is BaseMessage {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'getType' in value &&
|
||||
typeof (value as { getType: unknown }).getType === 'function' &&
|
||||
'content' in value
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
findUserToolMessageIndices,
|
||||
cleanStaleWorkflowContext,
|
||||
applyCacheControlMarkers,
|
||||
applySubgraphCacheMarkers,
|
||||
} from '../helpers';
|
||||
|
||||
describe('Cache Control Helpers', () => {
|
||||
|
|
@ -454,4 +455,113 @@ describe('Cache Control Helpers', () => {
|
|||
expect(lastContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySubgraphCacheMarkers', () => {
|
||||
it('should do nothing for empty messages', () => {
|
||||
const messages: BaseMessage[] = [];
|
||||
applySubgraphCacheMarkers(messages);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should do nothing when no user/tool messages exist', () => {
|
||||
const messages = [new AIMessage('response')];
|
||||
applySubgraphCacheMarkers(messages);
|
||||
expect(messages[0].content).toBe('response');
|
||||
});
|
||||
|
||||
it('should apply cache marker to single user message', () => {
|
||||
const messages = [new HumanMessage('user request')];
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
const content = messages[0].content as Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(content[0].text).toBe('user request');
|
||||
expect(content[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should apply cache marker only to last user/tool message', () => {
|
||||
const messages = [
|
||||
new HumanMessage('first message'),
|
||||
new AIMessage('response'),
|
||||
new ToolMessage({ content: 'tool result', tool_call_id: '1' }),
|
||||
];
|
||||
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
// First message should NOT have cache marker (stays string)
|
||||
expect(typeof messages[0].content).toBe('string');
|
||||
|
||||
// Tool message (last user/tool) should have cache marker
|
||||
const toolContent = messages[2].content as Array<{
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(toolContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should remove existing cache markers from old messages', () => {
|
||||
// Set up message with existing cache marker
|
||||
const message0 = new HumanMessage('first');
|
||||
message0.content = [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: 'first',
|
||||
cache_control: { type: 'ephemeral' as const },
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [
|
||||
message0,
|
||||
new AIMessage('response'),
|
||||
new ToolMessage({ content: 'tool result', tool_call_id: '1' }),
|
||||
];
|
||||
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
// First message's cache marker should be removed
|
||||
const content0 = messages[0].content as Array<{ cache_control?: unknown }>;
|
||||
expect(content0[0].cache_control).toBeUndefined();
|
||||
|
||||
// Last message should have the cache marker
|
||||
const toolContent = messages[2].content as Array<{
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(toolContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should handle array content in last message', () => {
|
||||
const toolMessage = new ToolMessage({ content: 'result', tool_call_id: '1' });
|
||||
toolMessage.content = [{ type: 'text' as const, text: 'result' }];
|
||||
|
||||
const messages = [toolMessage];
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
const content = messages[0].content as Array<{
|
||||
text: string;
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(content[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
|
||||
it('should handle multiple tool messages in sequence', () => {
|
||||
const messages = [
|
||||
new ToolMessage({ content: 'tool 1', tool_call_id: '1' }),
|
||||
new ToolMessage({ content: 'tool 2', tool_call_id: '2' }),
|
||||
new ToolMessage({ content: 'tool 3', tool_call_id: '3' }),
|
||||
];
|
||||
|
||||
applySubgraphCacheMarkers(messages);
|
||||
|
||||
// Only the last tool message should have the marker
|
||||
expect(typeof messages[0].content).toBe('string');
|
||||
expect(typeof messages[1].content).toBe('string');
|
||||
|
||||
const lastContent = messages[2].content as Array<{
|
||||
cache_control?: { type: string };
|
||||
}>;
|
||||
expect(lastContent[0].cache_control).toEqual({ type: 'ephemeral' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -160,3 +160,53 @@ export function applyCacheControlMarkers(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cache markers for subgraph internal tool loops.
|
||||
*
|
||||
* This is a simpler version of applyCacheControlMarkers designed for subgraphs:
|
||||
* - First removes all existing cache markers from messages
|
||||
* - Then marks the last user/tool message (no workflow context appending)
|
||||
* - Ensures we stay within the 4 breakpoint limit
|
||||
*
|
||||
* @param messages - Array of LangChain messages to modify
|
||||
*/
|
||||
export function applySubgraphCacheMarkers(messages: BaseMessage[]): void {
|
||||
const userToolIndices = findUserToolMessageIndices(messages);
|
||||
if (userToolIndices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First, remove ALL existing cache_control markers from messages
|
||||
let removedCount = 0;
|
||||
for (const idx of userToolIndices) {
|
||||
const message = messages[idx];
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (hasCacheControl(block) && block.cache_control) {
|
||||
delete block.cache_control;
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now apply marker to the last user/tool message only
|
||||
const lastIdx = userToolIndices[userToolIndices.length - 1];
|
||||
const lastMessage = messages[lastIdx];
|
||||
|
||||
if (typeof lastMessage.content === 'string') {
|
||||
lastMessage.content = [
|
||||
{
|
||||
type: 'text',
|
||||
text: lastMessage.content,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
];
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
const lastBlock = lastMessage.content[lastMessage.content.length - 1];
|
||||
if (isTextBlock(lastBlock)) {
|
||||
lastBlock.cache_control = { type: 'ephemeral' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,5 @@ export {
|
|||
findUserToolMessageIndices,
|
||||
cleanStaleWorkflowContext,
|
||||
applyCacheControlMarkers,
|
||||
applySubgraphCacheMarkers,
|
||||
} from './helpers';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { HumanMessage } from '@langchain/core/messages';
|
||||
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
import { trimWorkflowJSON } from './trim-workflow-context';
|
||||
|
||||
// ============================================================================
|
||||
// WORKFLOW CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build workflow summary (just node names, types, counts)
|
||||
* For Discovery and Supervisor - they don't need full JSON
|
||||
*/
|
||||
export function buildWorkflowSummary(workflow: SimpleWorkflow): string {
|
||||
if (workflow.nodes.length === 0) {
|
||||
return 'No nodes in workflow';
|
||||
}
|
||||
|
||||
const nodeList = workflow.nodes.map((n) => `- ${n.name} (${n.type})`).join('\n');
|
||||
return `${workflow.nodes.length} existing nodes:\n${nodeList}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build workflow JSON block for Builder/Configurator
|
||||
*/
|
||||
export function buildWorkflowJsonBlock(workflow: SimpleWorkflow): string {
|
||||
const trimmed = trimWorkflowJSON(workflow);
|
||||
return [
|
||||
'<current_workflow_json>',
|
||||
JSON.stringify(trimmed, null, 2),
|
||||
'</current_workflow_json>',
|
||||
'<note>Large property values may be trimmed. Use get_node_parameter tool for full details.</note>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DISCOVERY CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build discovery context block for Builder/Configurator
|
||||
* Includes nodes found, connection parameters, and optionally best practices
|
||||
*/
|
||||
export function buildDiscoveryContextBlock(
|
||||
discoveryContext: DiscoveryContext | null,
|
||||
includeBestPractices = true,
|
||||
): string {
|
||||
if (!discoveryContext) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (discoveryContext.nodesFound.length > 0) {
|
||||
parts.push('Discovered Nodes:');
|
||||
discoveryContext.nodesFound.forEach(
|
||||
({ nodeName, version, reasoning, connectionChangingParameters }) => {
|
||||
const params =
|
||||
connectionChangingParameters.length > 0
|
||||
? ` [Connection params: ${connectionChangingParameters.map((p) => p.name).join(', ')}]`
|
||||
: '';
|
||||
parts.push(`- ${nodeName} v${version}: ${reasoning}${params}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (includeBestPractices && discoveryContext.bestPractices) {
|
||||
parts.push('', 'Best Practices:', discoveryContext.bestPractices);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXECUTION CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build execution context block (data + schema) for Configurator
|
||||
* Includes both execution data and schema
|
||||
*/
|
||||
export function buildExecutionContextBlock(
|
||||
workflowContext: ChatPayload['workflowContext'] | undefined,
|
||||
): string {
|
||||
const executionData = workflowContext?.executionData ?? {};
|
||||
const executionSchema = workflowContext?.executionSchema ?? [];
|
||||
|
||||
return [
|
||||
'<execution_data>',
|
||||
JSON.stringify(executionData, null, 2),
|
||||
'</execution_data>',
|
||||
'',
|
||||
'<execution_schema>',
|
||||
JSON.stringify(executionSchema, null, 2),
|
||||
'</execution_schema>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build execution schema only (for Builder - doesn't need full data)
|
||||
*/
|
||||
export function buildExecutionSchemaBlock(
|
||||
workflowContext: ChatPayload['workflowContext'] | undefined,
|
||||
): string {
|
||||
const executionSchema = workflowContext?.executionSchema ?? [];
|
||||
|
||||
if (executionSchema.length === 0) return '';
|
||||
|
||||
return [
|
||||
'<execution_schema>',
|
||||
JSON.stringify(executionSchema, null, 2),
|
||||
'</execution_schema>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGE BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create initial context message for a subgraph
|
||||
* Filters out empty parts and joins with double newlines
|
||||
*/
|
||||
export function createContextMessage(contextParts: string[]): HumanMessage {
|
||||
return new HumanMessage({ content: contextParts.filter(Boolean).join('\n\n') });
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Coordination log utilities for deterministic routing between subgraphs.
|
||||
*
|
||||
* These utilities parse the coordination log to determine:
|
||||
* 1. Which subgraphs have completed
|
||||
* 2. What the next routing decision should be
|
||||
* 3. What output data is available from each phase
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoordinationLogEntry,
|
||||
SubgraphPhase,
|
||||
DiscoveryMetadata,
|
||||
BuilderMetadata,
|
||||
ConfiguratorMetadata,
|
||||
} from '../types/coordination';
|
||||
|
||||
export type RoutingDecision = 'discovery' | 'builder' | 'configurator' | 'responder';
|
||||
|
||||
/**
|
||||
* Get the last completed phase from the coordination log
|
||||
*/
|
||||
export function getLastCompletedPhase(log: CoordinationLogEntry[]): SubgraphPhase | null {
|
||||
if (log.length === 0) return null;
|
||||
|
||||
// Find the most recent completed entry
|
||||
for (let i = log.length - 1; i >= 0; i--) {
|
||||
if (log[i].status === 'completed') {
|
||||
return log[i].phase;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entry for a specific phase
|
||||
*/
|
||||
export function getPhaseEntry(
|
||||
log: CoordinationLogEntry[],
|
||||
phase: SubgraphPhase,
|
||||
): CoordinationLogEntry | null {
|
||||
return log.find((entry) => entry.phase === phase && entry.status === 'completed') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phase has completed
|
||||
*/
|
||||
export function hasPhaseCompleted(log: CoordinationLogEntry[], phase: SubgraphPhase): boolean {
|
||||
return log.some((entry) => entry.phase === phase && entry.status === 'completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configurator output (setup instructions) from the log
|
||||
*/
|
||||
export function getConfiguratorOutput(log: CoordinationLogEntry[]): string | null {
|
||||
const entry = getPhaseEntry(log, 'configurator');
|
||||
return entry?.output ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get builder output (workflow summary) from the log
|
||||
*/
|
||||
export function getBuilderOutput(log: CoordinationLogEntry[]): string | null {
|
||||
const entry = getPhaseEntry(log, 'builder');
|
||||
return entry?.output ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get typed metadata for a specific phase
|
||||
*/
|
||||
export function getPhaseMetadata(
|
||||
log: CoordinationLogEntry[],
|
||||
phase: 'discovery',
|
||||
): DiscoveryMetadata | null;
|
||||
export function getPhaseMetadata(
|
||||
log: CoordinationLogEntry[],
|
||||
phase: 'builder',
|
||||
): BuilderMetadata | null;
|
||||
export function getPhaseMetadata(
|
||||
log: CoordinationLogEntry[],
|
||||
phase: 'configurator',
|
||||
): ConfiguratorMetadata | null;
|
||||
export function getPhaseMetadata(
|
||||
log: CoordinationLogEntry[],
|
||||
phase: SubgraphPhase,
|
||||
): DiscoveryMetadata | BuilderMetadata | ConfiguratorMetadata | null {
|
||||
const entry = getPhaseEntry(log, phase);
|
||||
if (!entry) return null;
|
||||
|
||||
// Error entries have phase: 'error' in metadata, completed entries have the subgraph phase
|
||||
if (entry.metadata.phase === 'error') return null;
|
||||
|
||||
return entry.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any phase has an error status
|
||||
*/
|
||||
export function hasErrorInLog(log: CoordinationLogEntry[]): boolean {
|
||||
return log.some((entry) => entry.status === 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error entry from coordination log (if any)
|
||||
*/
|
||||
export function getErrorEntry(log: CoordinationLogEntry[]): CoordinationLogEntry | null {
|
||||
return log.find((entry) => entry.status === 'error') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic routing based on coordination log.
|
||||
* Called AFTER a subgraph completes to determine next phase.
|
||||
*/
|
||||
export function getNextPhaseFromLog(log: CoordinationLogEntry[]): RoutingDecision {
|
||||
// If any phase errored, route to responder to report the error
|
||||
if (hasErrorInLog(log)) {
|
||||
return 'responder';
|
||||
}
|
||||
|
||||
const lastPhase = getLastCompletedPhase(log);
|
||||
// After discovery → always builder (builder decides what new nodes to add)
|
||||
if (lastPhase === 'discovery') {
|
||||
return 'builder';
|
||||
}
|
||||
|
||||
// After builder → configurator
|
||||
if (lastPhase === 'builder') {
|
||||
return 'configurator';
|
||||
}
|
||||
|
||||
// After configurator → responder (terminal)
|
||||
if (lastPhase === 'configurator') {
|
||||
return 'responder';
|
||||
}
|
||||
|
||||
// No phases completed yet → let supervisor decide
|
||||
return 'responder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a summary of completed phases for debugging/logging
|
||||
*/
|
||||
export function summarizeCoordinationLog(log: CoordinationLogEntry[]): string {
|
||||
if (log.length === 0) return 'No phases completed';
|
||||
|
||||
return log
|
||||
.filter((e) => e.status === 'completed')
|
||||
.map((e) => `${e.phase}: ${e.summary}`)
|
||||
.join(' → ');
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import type { INode, IConnections } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import type { WorkflowState } from '../workflow-state';
|
||||
|
||||
/**
|
||||
* Type for operation handler functions
|
||||
|
|
@ -313,7 +312,10 @@ export function applyOperations(
|
|||
* Process operations node for the LangGraph workflow
|
||||
* This node applies accumulated operations to the workflow state
|
||||
*/
|
||||
export function processOperations(state: typeof WorkflowState.State) {
|
||||
export function processOperations(state: {
|
||||
workflowJSON: SimpleWorkflow;
|
||||
workflowOperations?: WorkflowOperation[] | null;
|
||||
}) {
|
||||
const { workflowJSON, workflowOperations } = state;
|
||||
|
||||
// If no operations to process, return unchanged
|
||||
|
|
@ -328,6 +330,6 @@ export function processOperations(state: typeof WorkflowState.State) {
|
|||
return {
|
||||
workflowJSON: newWorkflow,
|
||||
workflowOperations: null, // Clear processed operations
|
||||
workflowValidation: null,
|
||||
workflowValidation: null, // Invalidate stale validation results
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
// ============================================================================
|
||||
// IMPORTS
|
||||
// ============================================================================
|
||||
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { ToolCall } from '@langchain/core/messages/tool';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
|
|
@ -9,6 +13,10 @@ import type {
|
|||
StreamOutput,
|
||||
} from '../types/streaming';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface BuilderToolBase {
|
||||
toolName: string;
|
||||
displayTitle: string;
|
||||
|
|
@ -19,68 +27,19 @@ export interface BuilderTool extends BuilderToolBase {
|
|||
tool: DynamicStructuredTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a text part in multi-part message content
|
||||
*/
|
||||
interface TextPart {
|
||||
type: string;
|
||||
text: string;
|
||||
}
|
||||
/** Message content structure from LangGraph updates */
|
||||
type MessageContent = { content: string | Array<{ type: string; text: string }> };
|
||||
|
||||
/**
|
||||
* Message content can be either a simple string or an array of text parts
|
||||
*/
|
||||
type MessageContentValue = string | TextPart[];
|
||||
/** Stream event types from LangGraph */
|
||||
type SubgraphEvent = [string[], string, unknown];
|
||||
type ParentEvent = [string, unknown];
|
||||
type StreamEvent = SubgraphEvent | ParentEvent;
|
||||
|
||||
/**
|
||||
* Container for messages in different update types
|
||||
*/
|
||||
interface MessagesContainer {
|
||||
messages?: Array<{ content: MessageContentValue }>;
|
||||
}
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Workflow operations data
|
||||
*/
|
||||
interface ProcessOperations {
|
||||
workflowJSON?: unknown;
|
||||
workflowOperations?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent update chunk containing different types of updates
|
||||
*/
|
||||
interface AgentUpdateChunk {
|
||||
agent?: MessagesContainer;
|
||||
compact_messages?: MessagesContainer;
|
||||
delete_messages?: MessagesContainer;
|
||||
process_operations?: ProcessOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if chunk is an AgentUpdateChunk
|
||||
*/
|
||||
function isAgentUpdateChunk(chunk: unknown): chunk is AgentUpdateChunk {
|
||||
return (
|
||||
typeof chunk === 'object' &&
|
||||
chunk !== null &&
|
||||
('agent' in chunk ||
|
||||
'compact_messages' in chunk ||
|
||||
'delete_messages' in chunk ||
|
||||
'process_operations' in chunk)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if chunk is a ToolProgressChunk
|
||||
*/
|
||||
function isToolProgressChunk(chunk: unknown): chunk is ToolProgressChunk {
|
||||
return typeof chunk === 'object' && chunk !== null && 'type' in chunk && chunk.type === 'tool';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools which should trigger canvas updates
|
||||
*/
|
||||
/** Tools which should trigger canvas updates */
|
||||
export const DEFAULT_WORKFLOW_UPDATE_TOOLS = [
|
||||
'add_nodes',
|
||||
'connect_nodes',
|
||||
|
|
@ -89,130 +48,261 @@ export const DEFAULT_WORKFLOW_UPDATE_TOOLS = [
|
|||
];
|
||||
|
||||
/**
|
||||
* Safely get the last message from an optional array
|
||||
* Parent graph nodes that should emit user-facing messages
|
||||
* - agent: V1 single agent (backward compatibility)
|
||||
* - responder: The ONLY node that should emit in multi-agent mode
|
||||
*/
|
||||
function getLastMessage<T>(messages: T[] | undefined): T | null {
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return messages[messages.length - 1];
|
||||
}
|
||||
const EMITTING_NODES = ['agent', 'responder'];
|
||||
|
||||
/** Parent graph nodes to skip entirely (internal coordination) */
|
||||
const SKIPPED_NODES = [
|
||||
'supervisor',
|
||||
'tools',
|
||||
'cleanup_dangling_tool_calls',
|
||||
'create_workflow_name',
|
||||
'auto_compact_messages',
|
||||
'configurator_subgraph',
|
||||
'discovery_subgraph',
|
||||
'builder_subgraph',
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract text content from message content (handles both string and array formats)
|
||||
* Subgraph namespace prefixes that should not emit message events
|
||||
* Note: Actual namespaces have UUIDs appended like "builder_subgraph:612f4bc3-..."
|
||||
*/
|
||||
function extractTextFromContent(content: MessageContentValue): string {
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
const SKIPPED_SUBGRAPH_PREFIXES = [
|
||||
'discovery_subgraph',
|
||||
'builder_subgraph',
|
||||
'configurator_subgraph',
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FILTERING LOGIC
|
||||
// ============================================================================
|
||||
|
||||
/** Check if namespace indicates a skipped subgraph (handles UUID suffixes) */
|
||||
function isFromSkippedSubgraph(namespace: string[]): boolean {
|
||||
return namespace.some((ns) => SKIPPED_SUBGRAPH_PREFIXES.some((prefix) => ns.startsWith(prefix)));
|
||||
}
|
||||
|
||||
/** Check if a node name should be skipped */
|
||||
function shouldSkipNode(nodeName: string): boolean {
|
||||
return SKIPPED_NODES.includes(nodeName);
|
||||
}
|
||||
|
||||
/** Check if a node should emit messages */
|
||||
function shouldEmitFromNode(nodeName: string): boolean {
|
||||
return EMITTING_NODES.includes(nodeName);
|
||||
}
|
||||
|
||||
/** Check if node update contains message data */
|
||||
function hasMessageInUpdate(update: unknown): boolean {
|
||||
const typed = update as { messages?: unknown[] };
|
||||
return Array.isArray(typed?.messages) && typed.messages.length > 0;
|
||||
}
|
||||
|
||||
/** Determine if a subgraph update event should be filtered out */
|
||||
function shouldFilterSubgraphUpdate(namespace: string[], data: Record<string, unknown>): boolean {
|
||||
if (!isFromSkippedSubgraph(namespace)) return false;
|
||||
|
||||
return Object.entries(data).some(([nodeName, update]) => {
|
||||
if (shouldSkipNode(nodeName)) return false;
|
||||
return hasMessageInUpdate(update);
|
||||
});
|
||||
}
|
||||
|
||||
/** Type guard for subgraph events */
|
||||
function isSubgraphEvent(event: unknown): event is SubgraphEvent {
|
||||
return Array.isArray(event) && event.length === 3 && Array.isArray(event[0]);
|
||||
}
|
||||
|
||||
/** Type guard for parent events */
|
||||
function isParentEvent(event: unknown): event is ParentEvent {
|
||||
return Array.isArray(event) && event.length === 2 && typeof event[0] === 'string';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT EXTRACTION
|
||||
// ============================================================================
|
||||
|
||||
/** Extract message content from a node update */
|
||||
function extractMessageContent(messages: MessageContent[]): string | null {
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage.content) return null;
|
||||
|
||||
// Handle array content (multi-part messages)
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
const textContent = lastMessage.content
|
||||
.filter((c) => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
return textContent || null;
|
||||
}
|
||||
return content;
|
||||
|
||||
return lastMessage.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard agent message chunk
|
||||
* Remove context tags from message content that are used for AI context
|
||||
* but shouldn't be displayed to users.
|
||||
*
|
||||
* This removes the entire context block from <current_workflow_json> through
|
||||
* </current_execution_nodes_schemas>
|
||||
*/
|
||||
function createMessageChunk(text: string): AgentMessageChunk {
|
||||
return {
|
||||
export function cleanContextTags(text: string): string {
|
||||
return text.replace(/\n*<current_workflow_json>[\s\S]*?<\/current_execution_nodes_schemas>/, '');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CHUNK PROCESSORS
|
||||
// ============================================================================
|
||||
|
||||
/** Handle delete_messages node update */
|
||||
function processDeleteMessages(update: unknown): StreamOutput | null {
|
||||
const typed = update as { messages?: MessageContent[] } | undefined;
|
||||
if (!typed?.messages?.length) return null;
|
||||
|
||||
const messageChunk: AgentMessageChunk = {
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text,
|
||||
text: 'Deleted, refresh?',
|
||||
};
|
||||
return { messages: [messageChunk] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process delete_messages updates
|
||||
*/
|
||||
function processDeleteMessages(chunk: AgentUpdateChunk): StreamOutput | null {
|
||||
const messages = chunk.delete_messages?.messages;
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
/** Handle compact_messages node update */
|
||||
function processCompactMessages(update: unknown): StreamOutput | null {
|
||||
const typed = update as { messages?: MessageContent[] } | undefined;
|
||||
if (!typed?.messages?.length) return null;
|
||||
|
||||
return { messages: [createMessageChunk('Deleted, refresh?')] };
|
||||
const content = extractMessageContent(typed.messages);
|
||||
if (!content) return null;
|
||||
|
||||
const messageChunk: AgentMessageChunk = {
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: content,
|
||||
};
|
||||
return { messages: [messageChunk] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process compact_messages updates
|
||||
*/
|
||||
function processCompactMessages(chunk: AgentUpdateChunk): StreamOutput | null {
|
||||
const lastMessage = getLastMessage(chunk.compact_messages?.messages);
|
||||
if (!lastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = extractTextFromContent(lastMessage.content);
|
||||
return { messages: [createMessageChunk(text)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent messages updates
|
||||
*/
|
||||
function processAgentMessages(chunk: AgentUpdateChunk): StreamOutput | null {
|
||||
const lastMessage = getLastMessage(chunk.agent?.messages);
|
||||
if (!lastMessage?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = extractTextFromContent(lastMessage.content);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { messages: [createMessageChunk(text)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process process_operations updates
|
||||
*/
|
||||
function processOperations(chunk: AgentUpdateChunk): StreamOutput | null {
|
||||
const update = chunk.process_operations;
|
||||
if (!update?.workflowJSON || update.workflowOperations === undefined) {
|
||||
return null;
|
||||
}
|
||||
/** Handle process_operations node update */
|
||||
function processOperationsUpdate(update: unknown): StreamOutput | null {
|
||||
const typed = update as { workflowJSON?: unknown; workflowOperations?: unknown } | undefined;
|
||||
if (!typed?.workflowJSON || typed.workflowOperations === undefined) return null;
|
||||
|
||||
const workflowUpdateChunk: WorkflowUpdateChunk = {
|
||||
role: 'assistant',
|
||||
type: 'workflow-updated',
|
||||
codeSnippet: JSON.stringify(update.workflowJSON, null, 2),
|
||||
codeSnippet: JSON.stringify(typed.workflowJSON, null, 2),
|
||||
};
|
||||
|
||||
return { messages: [workflowUpdateChunk] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process custom tool updates
|
||||
*/
|
||||
function processCustomToolChunk(chunk: unknown): StreamOutput | null {
|
||||
if (!isToolProgressChunk(chunk)) {
|
||||
return null;
|
||||
}
|
||||
/** Handle agent node message update */
|
||||
function processAgentNodeUpdate(nodeName: string, update: unknown): StreamOutput | null {
|
||||
if (!shouldEmitFromNode(nodeName)) return null;
|
||||
|
||||
return { messages: [chunk] };
|
||||
const typed = update as { messages?: MessageContent[] } | undefined;
|
||||
if (!typed?.messages?.length) return null;
|
||||
|
||||
const content = extractMessageContent(typed.messages);
|
||||
// Filter out empty content and workflow context artifacts
|
||||
if (!content?.trim() || content.includes('<current_workflow_json>')) return null;
|
||||
|
||||
const messageChunk: AgentMessageChunk = {
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: content,
|
||||
};
|
||||
return { messages: [messageChunk] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single chunk from the LangGraph stream
|
||||
*/
|
||||
/** Handle custom tool progress chunk */
|
||||
function processToolChunk(chunk: unknown): StreamOutput | null {
|
||||
const typed = chunk as ToolProgressChunk;
|
||||
if (typed?.type !== 'tool') return null;
|
||||
|
||||
return { messages: [typed] };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN STREAM PROCESSOR
|
||||
// ============================================================================
|
||||
|
||||
/** Process a single chunk from updates stream mode */
|
||||
function processUpdatesChunk(nodeUpdate: Record<string, unknown>): StreamOutput | null {
|
||||
// Guard against null/undefined chunks
|
||||
if (!nodeUpdate || typeof nodeUpdate !== 'object') return null;
|
||||
|
||||
// Special nodes first (backward compatibility)
|
||||
if (nodeUpdate.delete_messages) {
|
||||
return processDeleteMessages(nodeUpdate.delete_messages);
|
||||
}
|
||||
if (nodeUpdate.compact_messages) {
|
||||
return processCompactMessages(nodeUpdate.compact_messages);
|
||||
}
|
||||
if (nodeUpdate.process_operations) {
|
||||
return processOperationsUpdate(nodeUpdate.process_operations);
|
||||
}
|
||||
|
||||
// Generic agent node handling
|
||||
for (const [nodeName, update] of Object.entries(nodeUpdate)) {
|
||||
if (shouldSkipNode(nodeName)) continue;
|
||||
|
||||
const result = processAgentNodeUpdate(nodeName, update);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Process a single chunk from the LangGraph stream */
|
||||
export function processStreamChunk(streamMode: string, chunk: unknown): StreamOutput | null {
|
||||
if (streamMode === 'updates') {
|
||||
if (!isAgentUpdateChunk(chunk)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Process different update types in priority order
|
||||
return (
|
||||
processDeleteMessages(chunk) ??
|
||||
processCompactMessages(chunk) ??
|
||||
processAgentMessages(chunk) ??
|
||||
processOperations(chunk)
|
||||
);
|
||||
return processUpdatesChunk(chunk as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (streamMode === 'custom') {
|
||||
return processCustomToolChunk(chunk);
|
||||
return processToolChunk(chunk);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Process a subgraph event */
|
||||
function processSubgraphEvent(event: SubgraphEvent): StreamOutput | null {
|
||||
const [namespace, streamMode, data] = event;
|
||||
|
||||
// Filter out message updates from internal subgraphs
|
||||
if (
|
||||
streamMode === 'updates' &&
|
||||
shouldFilterSubgraphUpdate(namespace, data as Record<string, unknown>)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return processStreamChunk(streamMode, data);
|
||||
}
|
||||
|
||||
/** Process a parent graph event */
|
||||
function processParentEvent(event: ParentEvent): StreamOutput | null {
|
||||
const [streamMode, chunk] = event;
|
||||
if (!streamMode || typeof streamMode !== 'string') return null;
|
||||
|
||||
return processStreamChunk(streamMode, chunk);
|
||||
}
|
||||
|
||||
/** Process a single event from the stream */
|
||||
function processEvent(event: StreamEvent): StreamOutput | null {
|
||||
if (isSubgraphEvent(event)) {
|
||||
return processSubgraphEvent(event);
|
||||
}
|
||||
|
||||
if (isParentEvent(event)) {
|
||||
return processParentEvent(event);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -220,56 +310,58 @@ export function processStreamChunk(streamMode: string, chunk: unknown): StreamOu
|
|||
|
||||
/**
|
||||
* Create a stream processor that yields formatted chunks
|
||||
*
|
||||
* Handles both regular graph events and subgraph events.
|
||||
* - Parent events: [streamMode, data]
|
||||
* - Subgraph events: [namespace[], streamMode, data]
|
||||
*/
|
||||
export async function* createStreamProcessor(
|
||||
stream: AsyncGenerator<[string, unknown], void, unknown>,
|
||||
stream: AsyncGenerator<StreamEvent, void, unknown>,
|
||||
): AsyncGenerator<StreamOutput> {
|
||||
for await (const [streamMode, chunk] of stream) {
|
||||
const output = processStreamChunk(streamMode, chunk);
|
||||
|
||||
if (output) {
|
||||
yield output;
|
||||
for await (const event of stream) {
|
||||
const result = processEvent(event);
|
||||
if (result) {
|
||||
yield result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove context tags from message content that are used for AI context
|
||||
* but shouldn't be displayed to users
|
||||
*/
|
||||
export function cleanContextTags(text: string): string {
|
||||
return text.replace(/\n*<current_workflow_json>[\s\S]*?<\/current_execution_nodes_schemas>/, '');
|
||||
}
|
||||
// ============================================================================
|
||||
// MESSAGE FORMATTING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format a HumanMessage into the expected output format
|
||||
*/
|
||||
function formatHumanMessage(msg: HumanMessage): Record<string, unknown> {
|
||||
// Handle array content (multi-part messages with text, images, etc.)
|
||||
if (Array.isArray(msg.content)) {
|
||||
const textParts = msg.content.filter(
|
||||
(c): c is { type: string; text: string } =>
|
||||
typeof c === 'object' && c !== null && 'type' in c && c.type === 'text' && 'text' in c,
|
||||
);
|
||||
const text = textParts.map((part) => cleanContextTags(part.text)).join('\n');
|
||||
return {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text,
|
||||
};
|
||||
/** Extract text from HumanMessage content (handles string and array formats) */
|
||||
function extractHumanMessageText(content: HumanMessage['content']): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Handle simple string content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(
|
||||
(c): c is { type: string; text: string } =>
|
||||
typeof c === 'object' && c !== null && 'type' in c && c.type === 'text' && 'text' in c,
|
||||
)
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Format a HumanMessage into the expected output format */
|
||||
function formatHumanMessage(msg: HumanMessage): Record<string, unknown> {
|
||||
const rawText = extractHumanMessageText(msg.content);
|
||||
const cleanedText = cleanContextTags(rawText);
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: cleanContextTags(msg.content),
|
||||
text: cleanedText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process array content from AIMessage and return formatted text messages
|
||||
*/
|
||||
/** Process array content from AIMessage and return formatted text messages */
|
||||
function processArrayContent(content: unknown[]): Array<Record<string, unknown>> {
|
||||
const textMessages = content.filter(
|
||||
(c): c is { type: string; text: string } =>
|
||||
|
|
@ -283,9 +375,7 @@ function processArrayContent(content: unknown[]): Array<Record<string, unknown>>
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process AIMessage content and return formatted messages
|
||||
*/
|
||||
/** Process AIMessage content and return formatted messages */
|
||||
function processAIMessageContent(msg: AIMessage): Array<Record<string, unknown>> {
|
||||
if (!msg.content) {
|
||||
return [];
|
||||
|
|
@ -304,9 +394,7 @@ function processAIMessageContent(msg: AIMessage): Array<Record<string, unknown>>
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a formatted tool call message
|
||||
*/
|
||||
/** Create a formatted tool call message */
|
||||
function createToolCallMessage(
|
||||
toolCall: ToolCall,
|
||||
builderTool?: BuilderToolBase,
|
||||
|
|
@ -329,9 +417,7 @@ function createToolCallMessage(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool calls from AIMessage and return formatted tool messages
|
||||
*/
|
||||
/** Process tool calls from AIMessage and return formatted tool messages */
|
||||
function processToolCalls(
|
||||
toolCalls: ToolCall[],
|
||||
builderTools?: BuilderToolBase[],
|
||||
|
|
@ -342,9 +428,7 @@ function processToolCalls(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a ToolMessage and add its output to the corresponding tool call
|
||||
*/
|
||||
/** Process a ToolMessage and add its output to the corresponding tool call */
|
||||
function processToolMessage(
|
||||
msg: ToolMessage,
|
||||
formattedMessages: Array<Record<string, unknown>>,
|
||||
|
|
@ -366,6 +450,7 @@ function processToolMessage(
|
|||
}
|
||||
}
|
||||
|
||||
/** Format messages for frontend display */
|
||||
export function formatMessages(
|
||||
messages: Array<AIMessage | HumanMessage | ToolMessage>,
|
||||
builderTools?: BuilderToolBase[],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { isAIMessage, ToolMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import { isCommand, END } from '@langchain/langgraph';
|
||||
|
||||
import { isBaseMessage } from '../types/langchain';
|
||||
import type { WorkflowOperation } from '../types/workflow';
|
||||
|
||||
interface CommandUpdate {
|
||||
messages?: BaseMessage[];
|
||||
workflowOperations?: WorkflowOperation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object has the shape of CommandUpdate
|
||||
*/
|
||||
function isCommandUpdate(value: unknown): value is CommandUpdate {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const obj = value as Record<string, unknown>;
|
||||
// messages is optional, but if present must be an array
|
||||
if ('messages' in obj && obj.messages !== undefined && !Array.isArray(obj.messages)) {
|
||||
return false;
|
||||
}
|
||||
// workflowOperations is optional, but if present must be an array
|
||||
if (
|
||||
'workflowOperations' in obj &&
|
||||
obj.workflowOperations !== undefined &&
|
||||
!Array.isArray(obj.workflowOperations)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tools in a subgraph node
|
||||
*
|
||||
* Adapts the executeToolsInParallel pattern for subgraph use.
|
||||
* Executes all tool calls from the last AI message in parallel.
|
||||
*
|
||||
* @param state - Subgraph state with messages array
|
||||
* @param toolMap - Map of tool name to tool instance
|
||||
* @returns State update with messages and optional operations
|
||||
*/
|
||||
export async function executeSubgraphTools(
|
||||
state: { messages: BaseMessage[] },
|
||||
toolMap: Map<string, StructuredTool>,
|
||||
): Promise<{ messages?: BaseMessage[]; workflowOperations?: WorkflowOperation[] | null }> {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
if (!lastMessage || !isAIMessage(lastMessage) || !lastMessage.tool_calls?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Execute all tools in parallel
|
||||
const toolResults = await Promise.all(
|
||||
lastMessage.tool_calls.map(async (toolCall) => {
|
||||
const tool = toolMap.get(toolCall.name);
|
||||
if (!tool) {
|
||||
return new ToolMessage({
|
||||
content: `Tool ${toolCall.name} not found`,
|
||||
tool_call_id: toolCall.id ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result: unknown = await tool.invoke(toolCall.args ?? {}, {
|
||||
toolCall: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
args: toolCall.args ?? {},
|
||||
},
|
||||
});
|
||||
// Result can be a Command (with update) or a BaseMessage
|
||||
// We return it as-is and handle the type in the loop below
|
||||
return result;
|
||||
} catch (error) {
|
||||
return new ToolMessage({
|
||||
content: `Tool failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
tool_call_id: toolCall.id ?? '',
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Unwrap Command objects and collect messages/operations
|
||||
const messages: BaseMessage[] = [];
|
||||
const operations: WorkflowOperation[] = [];
|
||||
|
||||
for (const result of toolResults) {
|
||||
if (isCommand(result)) {
|
||||
// Tool returned Command - extract update using type guard
|
||||
if (isCommandUpdate(result.update)) {
|
||||
if (result.update.messages) {
|
||||
messages.push(...result.update.messages);
|
||||
}
|
||||
if (result.update.workflowOperations) {
|
||||
operations.push(...result.update.workflowOperations);
|
||||
}
|
||||
}
|
||||
} else if (isBaseMessage(result)) {
|
||||
// Direct message (ToolMessage, AIMessage, etc.)
|
||||
messages.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const stateUpdate: { messages?: BaseMessage[]; workflowOperations?: WorkflowOperation[] | null } =
|
||||
{};
|
||||
|
||||
if (messages.length > 0) {
|
||||
stateUpdate.messages = messages;
|
||||
}
|
||||
|
||||
if (operations.length > 0) {
|
||||
stateUpdate.workflowOperations = operations;
|
||||
}
|
||||
|
||||
return stateUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user request from parent state messages
|
||||
* Gets the LAST HumanMessage (most recent user request), not the first
|
||||
*/
|
||||
export function extractUserRequest(messages: BaseMessage[], defaultValue = ''): string {
|
||||
// Get the LAST HumanMessage (most recent user request for iteration support)
|
||||
const humanMessages = messages.filter((m) => m instanceof HumanMessage);
|
||||
const lastUserMessage = humanMessages[humanMessages.length - 1];
|
||||
return typeof lastUserMessage?.content === 'string' ? lastUserMessage.content : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard shouldContinue logic for tool-based subgraphs
|
||||
* Checks presence of tool calls to determine if we should continue to tools node.
|
||||
*/
|
||||
export function createStandardShouldContinue() {
|
||||
return (state: { messages: BaseMessage[] }) => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const hasToolCalls =
|
||||
lastMessage &&
|
||||
'tool_calls' in lastMessage &&
|
||||
Array.isArray(lastMessage.tool_calls) &&
|
||||
lastMessage.tool_calls.length > 0;
|
||||
|
||||
return hasToolCalls ? 'tools' : END;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import type { CoordinationLogEntry, CoordinationMetadata } from '../../types/coordination';
|
||||
import {
|
||||
getLastCompletedPhase,
|
||||
getPhaseEntry,
|
||||
hasPhaseCompleted,
|
||||
getConfiguratorOutput,
|
||||
getBuilderOutput,
|
||||
getNextPhaseFromLog,
|
||||
hasErrorInLog,
|
||||
getErrorEntry,
|
||||
summarizeCoordinationLog,
|
||||
} from '../coordination-log';
|
||||
|
||||
describe('coordination-log utilities', () => {
|
||||
const createMetadata = (
|
||||
phase: 'discovery' | 'builder' | 'configurator',
|
||||
): CoordinationMetadata => {
|
||||
if (phase === 'discovery') {
|
||||
return { phase: 'discovery', nodesFound: 3, nodeTypes: ['test'], hasBestPractices: true };
|
||||
}
|
||||
if (phase === 'builder') {
|
||||
return {
|
||||
phase: 'builder',
|
||||
nodesCreated: 2,
|
||||
connectionsCreated: 1,
|
||||
nodeNames: ['Node1', 'Node2'],
|
||||
};
|
||||
}
|
||||
return { phase: 'configurator', nodesConfigured: 2, hasSetupInstructions: true };
|
||||
};
|
||||
|
||||
const createLogEntry = (
|
||||
phase: 'discovery' | 'builder' | 'configurator',
|
||||
status: 'completed' | 'error' = 'completed',
|
||||
output?: string,
|
||||
): CoordinationLogEntry => ({
|
||||
phase,
|
||||
status,
|
||||
summary: `${phase} phase ${status}`,
|
||||
output,
|
||||
timestamp: Date.now(),
|
||||
metadata: createMetadata(phase),
|
||||
});
|
||||
|
||||
describe('getLastCompletedPhase', () => {
|
||||
it('should return null for empty log', () => {
|
||||
expect(getLastCompletedPhase([])).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the last completed phase', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder')];
|
||||
expect(getLastCompletedPhase(log)).toBe('builder');
|
||||
});
|
||||
|
||||
it('should skip non-completed entries', () => {
|
||||
const log = [
|
||||
createLogEntry('discovery'),
|
||||
createLogEntry('builder', 'error'),
|
||||
createLogEntry('configurator', 'error'),
|
||||
];
|
||||
expect(getLastCompletedPhase(log)).toBe('discovery');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPhaseEntry', () => {
|
||||
it('should return null when phase not found', () => {
|
||||
const log = [createLogEntry('discovery')];
|
||||
expect(getPhaseEntry(log, 'builder')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the completed entry for a phase', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder', 'completed', 'output')];
|
||||
const entry = getPhaseEntry(log, 'builder');
|
||||
expect(entry?.phase).toBe('builder');
|
||||
expect(entry?.output).toBe('output');
|
||||
});
|
||||
|
||||
it('should not return non-completed entries', () => {
|
||||
const log = [createLogEntry('discovery', 'error')];
|
||||
expect(getPhaseEntry(log, 'discovery')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPhaseCompleted', () => {
|
||||
it('should return false for empty log', () => {
|
||||
expect(hasPhaseCompleted([], 'discovery')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when phase is completed', () => {
|
||||
const log = [createLogEntry('discovery')];
|
||||
expect(hasPhaseCompleted(log, 'discovery')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when phase is not completed', () => {
|
||||
const log = [createLogEntry('discovery', 'error')];
|
||||
expect(hasPhaseCompleted(log, 'discovery')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguratorOutput', () => {
|
||||
it('should return null when configurator not completed', () => {
|
||||
const log = [createLogEntry('discovery')];
|
||||
expect(getConfiguratorOutput(log)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return configurator output', () => {
|
||||
const log = [createLogEntry('configurator', 'completed', 'setup instructions')];
|
||||
expect(getConfiguratorOutput(log)).toBe('setup instructions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBuilderOutput', () => {
|
||||
it('should return null when builder not completed', () => {
|
||||
const log = [createLogEntry('discovery')];
|
||||
expect(getBuilderOutput(log)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return builder output', () => {
|
||||
const log = [createLogEntry('builder', 'completed', 'workflow summary')];
|
||||
expect(getBuilderOutput(log)).toBe('workflow summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasErrorInLog', () => {
|
||||
it('should return false for empty log', () => {
|
||||
expect(hasErrorInLog([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when all phases completed successfully', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder')];
|
||||
expect(hasErrorInLog(log)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when any phase has error', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder', 'error')];
|
||||
expect(hasErrorInLog(log)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when first phase errors', () => {
|
||||
const log = [createLogEntry('discovery', 'error')];
|
||||
expect(hasErrorInLog(log)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorEntry', () => {
|
||||
it('should return null for empty log', () => {
|
||||
expect(getErrorEntry([])).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no errors', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder')];
|
||||
expect(getErrorEntry(log)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error entry when present', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder', 'error')];
|
||||
const errorEntry = getErrorEntry(log);
|
||||
expect(errorEntry?.phase).toBe('builder');
|
||||
expect(errorEntry?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('should return first error entry when multiple errors', () => {
|
||||
const log = [createLogEntry('discovery', 'error'), createLogEntry('builder', 'error')];
|
||||
const errorEntry = getErrorEntry(log);
|
||||
expect(errorEntry?.phase).toBe('discovery');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextPhaseFromLog', () => {
|
||||
it('should return responder for empty log', () => {
|
||||
expect(getNextPhaseFromLog([])).toBe('responder');
|
||||
});
|
||||
|
||||
it('should return builder after discovery', () => {
|
||||
const log = [createLogEntry('discovery')];
|
||||
expect(getNextPhaseFromLog(log)).toBe('builder');
|
||||
});
|
||||
|
||||
it('should return configurator after builder', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder')];
|
||||
expect(getNextPhaseFromLog(log)).toBe('configurator');
|
||||
});
|
||||
|
||||
it('should return responder after configurator', () => {
|
||||
const log = [
|
||||
createLogEntry('discovery'),
|
||||
createLogEntry('builder'),
|
||||
createLogEntry('configurator'),
|
||||
];
|
||||
expect(getNextPhaseFromLog(log)).toBe('responder');
|
||||
});
|
||||
|
||||
it('should return responder when discovery errors', () => {
|
||||
const log = [createLogEntry('discovery', 'error')];
|
||||
expect(getNextPhaseFromLog(log)).toBe('responder');
|
||||
});
|
||||
|
||||
it('should return responder when builder errors after successful discovery', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder', 'error')];
|
||||
expect(getNextPhaseFromLog(log)).toBe('responder');
|
||||
});
|
||||
|
||||
it('should return responder when configurator errors', () => {
|
||||
const log = [
|
||||
createLogEntry('discovery'),
|
||||
createLogEntry('builder'),
|
||||
createLogEntry('configurator', 'error'),
|
||||
];
|
||||
expect(getNextPhaseFromLog(log)).toBe('responder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeCoordinationLog', () => {
|
||||
it('should return message for empty log', () => {
|
||||
expect(summarizeCoordinationLog([])).toBe('No phases completed');
|
||||
});
|
||||
|
||||
it('should summarize completed phases', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder')];
|
||||
const summary = summarizeCoordinationLog(log);
|
||||
expect(summary).toContain('discovery');
|
||||
expect(summary).toContain('builder');
|
||||
expect(summary).toContain('→');
|
||||
});
|
||||
|
||||
it('should skip non-completed phases in summary', () => {
|
||||
const log = [createLogEntry('discovery'), createLogEntry('builder', 'error')];
|
||||
const summary = summarizeCoordinationLog(log);
|
||||
expect(summary).toContain('discovery');
|
||||
expect(summary).not.toContain('builder');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,12 @@ import type {
|
|||
StreamOutput,
|
||||
} from '../../types/streaming';
|
||||
import type { BuilderToolBase } from '../stream-processor';
|
||||
import { processStreamChunk, createStreamProcessor, formatMessages } from '../stream-processor';
|
||||
import {
|
||||
processStreamChunk,
|
||||
createStreamProcessor,
|
||||
formatMessages,
|
||||
cleanContextTags,
|
||||
} from '../stream-processor';
|
||||
|
||||
describe('stream-processor', () => {
|
||||
describe('processStreamChunk', () => {
|
||||
|
|
@ -1037,4 +1042,339 @@ describe('stream-processor', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStreamProcessor with subgraph events', () => {
|
||||
it('should process parent events [streamMode, data]', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string, unknown], void, unknown> {
|
||||
yield ['updates', { agent: { messages: [{ content: 'Hello from parent' }] } }];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].messages[0] as AgentMessageChunk).text).toBe('Hello from parent');
|
||||
});
|
||||
|
||||
it('should process subgraph events [namespace[], streamMode, data]', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
// Non-skipped subgraph event
|
||||
yield [['some_other_graph'], 'updates', { agent: { messages: [{ content: 'Test' }] } }];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter out message events from builder_subgraph namespace', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
// UUID-appended namespace format used by LangGraph
|
||||
yield [
|
||||
['builder_subgraph:612f4bc3-b308-53a8-b2e8-01543d375dff'],
|
||||
'updates',
|
||||
{ agent: { messages: [{ content: 'Internal builder message' }] } },
|
||||
];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter out message events from discovery_subgraph namespace', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
yield [
|
||||
['discovery_subgraph:abc-123'],
|
||||
'updates',
|
||||
{ agent: { messages: [{ content: 'Internal discovery message' }] } },
|
||||
];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter out message events from configurator_subgraph namespace', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
yield [
|
||||
['configurator_subgraph:xyz-789'],
|
||||
'updates',
|
||||
{ configurator: { messages: [{ content: 'Internal config message' }] } },
|
||||
];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should allow tool progress events from subgraphs', async () => {
|
||||
const toolChunk: ToolProgressChunk = {
|
||||
id: 'tool-1',
|
||||
toolCallId: 'call-1',
|
||||
type: 'tool',
|
||||
role: 'assistant',
|
||||
toolName: 'add_nodes',
|
||||
status: 'running',
|
||||
updates: [],
|
||||
};
|
||||
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
yield [['builder_subgraph:uuid'], 'custom', toolChunk];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].messages[0] as ToolProgressChunk).toolName).toBe('add_nodes');
|
||||
});
|
||||
|
||||
it('should allow process_operations events from subgraphs', async () => {
|
||||
const workflowData = { nodes: [], connections: {} };
|
||||
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
yield [
|
||||
['builder_subgraph:uuid'],
|
||||
'updates',
|
||||
{ process_operations: { workflowJSON: workflowData, workflowOperations: null } },
|
||||
];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].messages[0] as WorkflowUpdateChunk).type).toBe('workflow-updated');
|
||||
});
|
||||
|
||||
it('should handle mixed parent and subgraph events', async () => {
|
||||
async function* mockStream(): AsyncGenerator<
|
||||
[string, unknown] | [string[], string, unknown],
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
// Parent event
|
||||
yield ['updates', { responder: { messages: [{ content: 'User-facing response' }] } }];
|
||||
// Filtered subgraph event
|
||||
yield [
|
||||
['builder_subgraph:uuid'],
|
||||
'updates',
|
||||
{ agent: { messages: [{ content: 'Internal' }] } },
|
||||
];
|
||||
// Another parent event
|
||||
yield ['custom', { type: 'tool', toolName: 'test_tool' } as ToolProgressChunk];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect((results[0].messages[0] as AgentMessageChunk).text).toBe('User-facing response');
|
||||
expect((results[1].messages[0] as ToolProgressChunk).toolName).toBe('test_tool');
|
||||
});
|
||||
|
||||
it('should ignore malformed events', async () => {
|
||||
async function* mockStream(): AsyncGenerator<unknown, void, unknown> {
|
||||
yield ['updates', { agent: { messages: [{ content: 'Valid' }] } }];
|
||||
yield null;
|
||||
yield undefined;
|
||||
yield 'just a string';
|
||||
yield 12345;
|
||||
yield { not: 'an array' };
|
||||
yield ['updates', { agent: { messages: [{ content: 'Also valid' }] } }];
|
||||
}
|
||||
|
||||
// Cast to expected type for processor
|
||||
const processor = createStreamProcessor(
|
||||
mockStream() as AsyncGenerator<[string, unknown], void, unknown>,
|
||||
);
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle nested namespace arrays', async () => {
|
||||
async function* mockStream(): AsyncGenerator<[string[], string, unknown], void, unknown> {
|
||||
// Nested namespaces like parent:child:grandchild
|
||||
yield [
|
||||
['parent_graph', 'builder_subgraph:uuid'],
|
||||
'updates',
|
||||
{ agent: { messages: [{ content: 'Nested internal' }] } },
|
||||
];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(mockStream());
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
// Should be filtered because one of the namespaces matches skipped prefix
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not filter subgraph events when node is in SKIPPED_NODES list', async () => {
|
||||
async function* mockStream(): AsyncGenerator<
|
||||
[string[], string, unknown] | [string, unknown],
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
// 'tools' node is in SKIPPED_NODES - subgraph filtering should NOT block this event
|
||||
// (subgraph filtering only blocks events with EMITTING nodes like 'agent')
|
||||
yield [
|
||||
['builder_subgraph:uuid'],
|
||||
'updates',
|
||||
{ tools: { messages: [{ content: 'Tool execution' }] } },
|
||||
];
|
||||
// Follow-up parent event to verify stream processing continues normally
|
||||
yield ['updates', { agent: { messages: [{ content: 'Parent response' }] } }];
|
||||
}
|
||||
|
||||
const processor = createStreamProcessor(
|
||||
mockStream() as AsyncGenerator<[string, unknown], void, unknown>,
|
||||
);
|
||||
const results: StreamOutput[] = [];
|
||||
|
||||
for await (const output of processor) {
|
||||
results.push(output);
|
||||
}
|
||||
|
||||
// First event: no output because 'tools' doesn't emit (but wasn't filtered)
|
||||
// Second event: parent 'agent' produces output, proving stream wasn't blocked
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].messages[0] as AgentMessageChunk).text).toBe('Parent response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanContextTags', () => {
|
||||
// Import cleanContextTags for direct testing
|
||||
it('should remove workflow context tags from text', () => {
|
||||
const input = `Question here
|
||||
<current_workflow_json>
|
||||
{"nodes": []}
|
||||
</current_workflow_json>
|
||||
<current_simplified_execution_data>
|
||||
{}
|
||||
</current_simplified_execution_data>
|
||||
<current_execution_nodes_schemas>
|
||||
[]
|
||||
</current_execution_nodes_schemas>`;
|
||||
|
||||
const result = cleanContextTags(input);
|
||||
expect(result).toBe('Question here');
|
||||
});
|
||||
|
||||
it('should handle text without context tags', () => {
|
||||
const input = 'Plain text without any tags';
|
||||
const result = cleanContextTags(input);
|
||||
expect(result).toBe('Plain text without any tags');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle responder node messages (user-facing)', () => {
|
||||
const chunk = {
|
||||
responder: {
|
||||
messages: [{ content: 'Final response to user' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processStreamChunk('updates', chunk);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.messages).toHaveLength(1);
|
||||
const message = result?.messages[0] as AgentMessageChunk;
|
||||
expect(message.text).toBe('Final response to user');
|
||||
});
|
||||
|
||||
it('should skip supervisor node messages', () => {
|
||||
const chunk = {
|
||||
supervisor: {
|
||||
messages: [{ content: 'Supervisor internal message' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processStreamChunk('updates', chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip tools node messages', () => {
|
||||
const chunk = {
|
||||
tools: {
|
||||
messages: [{ content: 'Tool execution result' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processStreamChunk('updates', chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should filter messages containing workflow context XML', () => {
|
||||
const chunk = {
|
||||
agent: {
|
||||
messages: [{ content: 'Here is <current_workflow_json>{}</current_workflow_json>' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processStreamChunk('updates', chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle stream mode with single character (edge case)', () => {
|
||||
// Single character stream modes should return null (length <= 1 check)
|
||||
const result = processStreamChunk('u', { agent: { messages: [{ content: 'Test' }] } });
|
||||
|
||||
// Actually processStreamChunk handles this at the processParentEvent level
|
||||
// but processStreamChunk itself doesn't have this check - it checks mode names
|
||||
// 'u' is not 'updates' or 'custom', so it returns null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { END } from '@langchain/langgraph';
|
||||
|
||||
import { extractUserRequest, createStandardShouldContinue } from '../subgraph-helpers';
|
||||
|
||||
describe('subgraph-helpers', () => {
|
||||
describe('extractUserRequest', () => {
|
||||
it('should return default value for empty messages', () => {
|
||||
const result = extractUserRequest([]);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return custom default value when provided', () => {
|
||||
const result = extractUserRequest([], 'fallback');
|
||||
expect(result).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should extract content from single HumanMessage', () => {
|
||||
const messages = [new HumanMessage('Build a workflow')];
|
||||
const result = extractUserRequest(messages);
|
||||
expect(result).toBe('Build a workflow');
|
||||
});
|
||||
|
||||
it('should return the LAST HumanMessage content', () => {
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('First request'),
|
||||
new AIMessage('Response 1'),
|
||||
new HumanMessage('Second request'),
|
||||
new AIMessage('Response 2'),
|
||||
new HumanMessage('Latest request'),
|
||||
];
|
||||
const result = extractUserRequest(messages);
|
||||
expect(result).toBe('Latest request');
|
||||
});
|
||||
|
||||
it('should ignore non-HumanMessage messages', () => {
|
||||
const messages: BaseMessage[] = [
|
||||
new AIMessage('System message'),
|
||||
new HumanMessage('User message'),
|
||||
new ToolMessage({ content: 'Tool result', tool_call_id: '1' }),
|
||||
];
|
||||
const result = extractUserRequest(messages);
|
||||
expect(result).toBe('User message');
|
||||
});
|
||||
|
||||
it('should return default when no HumanMessages exist', () => {
|
||||
const messages: BaseMessage[] = [
|
||||
new AIMessage('Response'),
|
||||
new ToolMessage({ content: 'Result', tool_call_id: '1' }),
|
||||
];
|
||||
const result = extractUserRequest(messages, 'no user input');
|
||||
expect(result).toBe('no user input');
|
||||
});
|
||||
|
||||
it('should handle HumanMessage with non-string content', () => {
|
||||
const message = new HumanMessage('test');
|
||||
message.content = [{ type: 'text', text: 'array content' }];
|
||||
const messages = [message];
|
||||
const result = extractUserRequest(messages, 'fallback');
|
||||
// When content is not a string, should return default
|
||||
expect(result).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStandardShouldContinue', () => {
|
||||
// Note: Iteration limits are now enforced via LangGraph's recursionLimit at invoke time
|
||||
const shouldContinue = createStandardShouldContinue();
|
||||
|
||||
it('should return "tools" when tool calls exist', () => {
|
||||
const state = {
|
||||
messages: [
|
||||
new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'search_nodes', args: { query: 'gmail' } }],
|
||||
}),
|
||||
],
|
||||
};
|
||||
expect(shouldContinue(state)).toBe('tools');
|
||||
});
|
||||
|
||||
it('should return END when no tool calls in last message', () => {
|
||||
const state = {
|
||||
messages: [new AIMessage('Regular response without tools')],
|
||||
};
|
||||
expect(shouldContinue(state)).toBe(END);
|
||||
});
|
||||
|
||||
it('should return END when tool_calls is empty array', () => {
|
||||
const state = {
|
||||
messages: [new AIMessage({ content: 'Done', tool_calls: [] })],
|
||||
};
|
||||
expect(shouldContinue(state)).toBe(END);
|
||||
});
|
||||
|
||||
it('should return END for empty messages array', () => {
|
||||
const state = {
|
||||
messages: [] as BaseMessage[],
|
||||
};
|
||||
expect(shouldContinue(state)).toBe(END);
|
||||
});
|
||||
|
||||
it('should check only the last message for tool calls', () => {
|
||||
const state = {
|
||||
messages: [
|
||||
new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'old_tool', args: {} }],
|
||||
}),
|
||||
new AIMessage('Final response without tools'),
|
||||
],
|
||||
};
|
||||
expect(shouldContinue(state)).toBe(END);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages';
|
||||
import type { ToolMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
|
||||
import type { MemorySaver, StateSnapshot } from '@langchain/langgraph';
|
||||
|
|
@ -24,6 +24,7 @@ import { trimWorkflowJSON } from '@/utils/trim-workflow-context';
|
|||
import { conversationCompactChain } from './chains/conversation-compact';
|
||||
import { workflowNameChain } from './chains/workflow-name';
|
||||
import { LLMServiceError, ValidationError, WorkflowStateError } from './errors';
|
||||
import { createMultiAgentWorkflowWithSubgraphs } from './multi-agent-workflow-subgraphs';
|
||||
import { SessionManagerService } from './session-manager.service';
|
||||
import { getBuilderTools } from './tools/builder-tools';
|
||||
import { mainAgentPrompt } from './tools/prompts/main-agent.prompt';
|
||||
|
|
@ -136,6 +137,12 @@ export interface WorkflowBuilderAgentConfig {
|
|||
autoCompactThresholdTokens?: number;
|
||||
instanceUrl?: string;
|
||||
onGenerationSuccess?: () => Promise<void>;
|
||||
/**
|
||||
* Enable multi-agent supervisor architecture (experimental)
|
||||
* When true, uses specialized agents (Discovery, Builder, Configurator) with a Supervisor
|
||||
* When false, uses the legacy single-agent architecture
|
||||
*/
|
||||
enableMultiAgent?: boolean;
|
||||
}
|
||||
|
||||
export interface ExpressionValue {
|
||||
|
|
@ -164,6 +171,7 @@ export class WorkflowBuilderAgent {
|
|||
private autoCompactThresholdTokens: number;
|
||||
private instanceUrl?: string;
|
||||
private onGenerationSuccess?: () => Promise<void>;
|
||||
private enableMultiAgent: boolean;
|
||||
|
||||
constructor(config: WorkflowBuilderAgentConfig) {
|
||||
this.parsedNodeTypes = config.parsedNodeTypes;
|
||||
|
|
@ -176,6 +184,7 @@ export class WorkflowBuilderAgent {
|
|||
config.autoCompactThresholdTokens ?? DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS;
|
||||
this.instanceUrl = config.instanceUrl;
|
||||
this.onGenerationSuccess = config.onGenerationSuccess;
|
||||
this.enableMultiAgent = config.enableMultiAgent ?? false;
|
||||
}
|
||||
|
||||
private getBuilderTools(): BuilderTool[] {
|
||||
|
|
@ -187,7 +196,25 @@ export class WorkflowBuilderAgent {
|
|||
});
|
||||
}
|
||||
|
||||
private createWorkflow() {
|
||||
/**
|
||||
* Create the multi-agent workflow graph
|
||||
* Uses supervisor pattern with specialized agents
|
||||
*/
|
||||
private createMultiAgentGraph() {
|
||||
return createMultiAgentWorkflowWithSubgraphs({
|
||||
parsedNodeTypes: this.parsedNodeTypes,
|
||||
llmSimpleTask: this.llmSimpleTask,
|
||||
llmComplexTask: this.llmComplexTask,
|
||||
logger: this.logger,
|
||||
instanceUrl: this.instanceUrl,
|
||||
checkpointer: this.checkpointer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the legacy single-agent workflow graph
|
||||
*/
|
||||
private createLegacyWorkflow() {
|
||||
const builderTools = this.getBuilderTools();
|
||||
|
||||
// Extract just the tools for LLM binding
|
||||
|
|
@ -389,14 +416,26 @@ export class WorkflowBuilderAgent {
|
|||
.addEdge('compact_messages', END)
|
||||
.addConditionalEdges('agent', shouldContinue);
|
||||
|
||||
return workflow;
|
||||
return workflow.compile({ checkpointer: this.checkpointer });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the workflow graph based on configuration
|
||||
*/
|
||||
private createWorkflow() {
|
||||
if (this.enableMultiAgent) {
|
||||
this.logger?.debug('Using multi-agent supervisor architecture');
|
||||
return this.createMultiAgentGraph();
|
||||
}
|
||||
|
||||
this.logger?.debug('Using legacy single-agent architecture');
|
||||
return this.createLegacyWorkflow();
|
||||
}
|
||||
|
||||
async getState(workflowId?: string, userId?: string): Promise<TypedStateSnapshot> {
|
||||
const workflow = this.createWorkflow();
|
||||
const agent = workflow.compile({ checkpointer: this.checkpointer });
|
||||
const threadId = SessionManagerService.generateThreadId(workflowId, userId);
|
||||
return (await agent.getState({
|
||||
return (await workflow.getState({
|
||||
configurable: { thread_id: threadId },
|
||||
})) as TypedStateSnapshot;
|
||||
}
|
||||
|
|
@ -441,7 +480,7 @@ export class WorkflowBuilderAgent {
|
|||
}
|
||||
|
||||
private setupAgentAndConfigs(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) {
|
||||
const agent = this.createWorkflow().compile({ checkpointer: this.checkpointer });
|
||||
const agent = this.createWorkflow();
|
||||
const workflowId = payload.workflowContext?.currentWorkflow?.id;
|
||||
// Generate thread ID from workflowId and userId
|
||||
// This ensures one session per workflow per user
|
||||
|
|
@ -453,10 +492,12 @@ export class WorkflowBuilderAgent {
|
|||
};
|
||||
const streamConfig = {
|
||||
...threadConfig,
|
||||
streamMode: ['updates', 'custom'],
|
||||
streamMode: ['updates', 'custom'] as const,
|
||||
recursionLimit: 50,
|
||||
signal: abortSignal,
|
||||
callbacks: this.tracer ? [this.tracer] : undefined,
|
||||
// Enable subgraph streaming when using multi-agent architecture
|
||||
subgraphs: this.enableMultiAgent,
|
||||
};
|
||||
|
||||
return { agent, threadConfig, streamConfig };
|
||||
|
|
@ -465,7 +506,7 @@ export class WorkflowBuilderAgent {
|
|||
private async createAgentStream(
|
||||
payload: ChatPayload,
|
||||
streamConfig: RunnableConfig,
|
||||
agent: ReturnType<ReturnType<typeof this.createWorkflow>['compile']>,
|
||||
agent: ReturnType<typeof this.createWorkflow>,
|
||||
) {
|
||||
return await agent.stream(
|
||||
{
|
||||
|
|
@ -489,7 +530,7 @@ export class WorkflowBuilderAgent {
|
|||
|
||||
private async *processAgentStream(
|
||||
stream: AsyncGenerator<[string, unknown], void, unknown>,
|
||||
agent: ReturnType<ReturnType<typeof this.createWorkflow>['compile']>,
|
||||
agent: ReturnType<typeof this.createWorkflow>,
|
||||
threadConfig: RunnableConfig,
|
||||
) {
|
||||
try {
|
||||
|
|
@ -504,7 +545,7 @@ export class WorkflowBuilderAgent {
|
|||
|
||||
private async handleAgentStreamError(
|
||||
error: unknown,
|
||||
agent: ReturnType<ReturnType<typeof this.createWorkflow>['compile']>,
|
||||
agent: ReturnType<typeof this.createWorkflow>,
|
||||
threadConfig: RunnableConfig,
|
||||
): Promise<void> {
|
||||
if (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user