mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(ai-builder): Add Workflow Context Tools for On-Demand Data Fetching (#25070)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -16,12 +16,14 @@ import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import { isAIMessage } from '../types/langchain';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { buildSimplifiedExecutionContext, buildWorkflowOverview } from '../utils/context-builders';
|
||||
import {
|
||||
getErrorEntry,
|
||||
getBuilderOutput,
|
||||
hasRecursionErrorsCleared,
|
||||
} from '../utils/coordination-log';
|
||||
import { extractDataTableInfo } from '../utils/data-table-helpers';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
@@ -55,6 +57,8 @@ export interface ResponderContext {
|
||||
workflowJSON: SimpleWorkflow;
|
||||
/** Summary of previous conversation (from compaction) */
|
||||
previousSummary?: string;
|
||||
/** Workflow context with execution data */
|
||||
workflowContext?: ChatPayload['workflowContext'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +137,8 @@ export class ResponderAgent {
|
||||
if (builderOutput) {
|
||||
contextParts.push(`**Builder:** ${builderOutput}`);
|
||||
} else if (context.workflowJSON.nodes.length) {
|
||||
contextParts.push(`**Workflow:** ${context.workflowJSON.nodes.length} nodes created`);
|
||||
// Provide workflow overview with Mermaid diagram and parameters
|
||||
contextParts.push(`**Workflow:**\n${buildWorkflowOverview(context.workflowJSON)}`);
|
||||
}
|
||||
|
||||
// Data Table creation guidance
|
||||
@@ -144,6 +149,15 @@ export class ResponderAgent {
|
||||
contextParts.push(dataTableGuidance);
|
||||
}
|
||||
|
||||
// Execution status (simplified error info for user explanations)
|
||||
if (context.workflowContext) {
|
||||
const executionStatus = buildSimplifiedExecutionContext(
|
||||
context.workflowContext,
|
||||
context.workflowJSON.nodes,
|
||||
);
|
||||
contextParts.push(`**Execution Status:**\n${executionStatus}`);
|
||||
}
|
||||
|
||||
if (contextParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ import { buildSupervisorPrompt } from '@/prompts';
|
||||
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { buildWorkflowSummary } from '../utils/context-builders';
|
||||
import { buildWorkflowSummary, buildSimplifiedExecutionContext } from '../utils/context-builders';
|
||||
import { summarizeCoordinationLog } from '../utils/coordination-log';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
|
||||
const systemPrompt = ChatPromptTemplate.fromMessages([
|
||||
[
|
||||
@@ -52,6 +53,8 @@ export interface SupervisorContext {
|
||||
coordinationLog: CoordinationLogEntry[];
|
||||
/** Summary of previous conversation (from compaction) */
|
||||
previousSummary?: string;
|
||||
/** Workflow context with execution data */
|
||||
workflowContext?: ChatPayload['workflowContext'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +97,13 @@ export class SupervisorAgent {
|
||||
contextParts.push('</completed_phases>');
|
||||
}
|
||||
|
||||
// 4. Execution status (simplified error info for routing decisions)
|
||||
if (context.workflowContext) {
|
||||
contextParts.push(
|
||||
buildSimplifiedExecutionContext(context.workflowContext, context.workflowJSON.nodes),
|
||||
);
|
||||
}
|
||||
|
||||
if (contextParts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,18 @@ export const AVG_CHARS_PER_TOKEN_ANTHROPIC = 3.5;
|
||||
*/
|
||||
export const MAX_NODE_EXAMPLE_CHARS = 5000 * AVG_CHARS_PER_TOKEN_ANTHROPIC;
|
||||
|
||||
/**
|
||||
* Max characters for execution data truncation in tool responses.
|
||||
* Prevents tool responses from becoming too large and filling up the context.
|
||||
*/
|
||||
export const MAX_EXECUTION_DATA_CHARS = 10000;
|
||||
|
||||
/**
|
||||
* Max characters for AI response in conversation context.
|
||||
* Used when including previous AI responses to provide context.
|
||||
*/
|
||||
export const MAX_AI_RESPONSE_CHARS = 500;
|
||||
|
||||
/**
|
||||
* Maximum iterations for subgraph tool loops.
|
||||
* Prevents infinite loops when agents keep calling tools without finishing.
|
||||
|
||||
@@ -228,6 +228,7 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph
|
||||
workflowJSON: state.workflowJSON,
|
||||
coordinationLog: state.coordinationLog,
|
||||
previousSummary: state.previousSummary,
|
||||
workflowContext: state.workflowContext,
|
||||
},
|
||||
config,
|
||||
);
|
||||
@@ -249,6 +250,7 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph
|
||||
discoveryContext: state.discoveryContext,
|
||||
workflowJSON: state.workflowJSON,
|
||||
previousSummary: state.previousSummary,
|
||||
workflowContext: state.workflowContext,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* Flow: Discovery provides node types → Builder adds, connects, and configures nodes in batches
|
||||
*/
|
||||
|
||||
import { DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS } from '@/utils/data-table-helpers';
|
||||
|
||||
import { prompt } from '../builder';
|
||||
import { webhook } from '../shared/node-guidance';
|
||||
|
||||
@@ -13,32 +15,53 @@ export interface BuilderPromptOptions {
|
||||
includeExamples: boolean;
|
||||
}
|
||||
|
||||
const dataTableColumnOperationsList = DATA_TABLE_ROW_COLUMN_MAPPING_OPERATIONS.join(', ');
|
||||
|
||||
const ROLE =
|
||||
'You are a Builder Agent that constructs n8n workflows: adding nodes, connecting them, and configuring their parameters.';
|
||||
|
||||
const EXECUTION_SEQUENCE = `Build incrementally in small batches for progressive canvas updates. Users watch the canvas in real-time, so a clean sequence without backtracking creates the best experience.
|
||||
const EXECUTION_SEQUENCE = `Users watch the canvas update in real-time. Build progressively so they see nodes appear, get configured, and connect incrementally—not long waits followed by everything appearing at once.
|
||||
|
||||
Batch flow (3-4 nodes per batch):
|
||||
1. add_nodes(batch) → configure(batch) → connect(batch) + add_nodes(next batch)
|
||||
2. Repeat: configure → connect + add_nodes → until done
|
||||
3. Final: configure(last) → connect(last) → validate_structure, validate_configuration
|
||||
<progressive_building>
|
||||
Complete each batch's full lifecycle before starting the next batch. A batch is 3-4 related nodes.
|
||||
|
||||
Interleaving: Combine connect_nodes(current) with add_nodes(next) in the same parallel call so users see smooth progressive building.
|
||||
Batch lifecycle: add_nodes → update_node_parameters → connect_nodes
|
||||
|
||||
Batch size: 3-4 connected nodes per batch.
|
||||
- AI patterns: Agent + sub-nodes (Model, Memory) together, Tools in next batch
|
||||
- Parallel branches: Group by logical unit
|
||||
After connecting a batch, start the next batch in the SAME turn:
|
||||
connect_nodes(batch 1) + add_nodes(batch 2) ← parallel, same turn
|
||||
|
||||
Example "Webhook → Set → IF → Slack / Email":
|
||||
Round 1: add_nodes(Webhook, Set, IF)
|
||||
Round 2: configure(Webhook, Set, IF)
|
||||
Round 3: connect(Webhook→Set→IF) + add_nodes(Slack, Email) ← parallel
|
||||
Round 4: configure(Slack, Email)
|
||||
Round 5: connect(IF→Slack, IF→Email), validate_structure, validate_configuration
|
||||
This interleaving creates continuous visual progress on the canvas.
|
||||
|
||||
Validation: Call validate_structure and validate_configuration once at the end. Once both pass, output your summary and stop—the workflow is complete.
|
||||
Example with 10-node workflow (3 batches):
|
||||
Turn 1: add_nodes(Trigger, AI Agent, Chat Model, Memory) ← batch 1 add
|
||||
Turn 2: update_node_parameters(Trigger, AI Agent, Chat Model, Memory)
|
||||
Turn 3: connect_nodes(batch 1) + add_nodes(Tool1, Tool2, Tool3, Set) ← batch 1 connect + batch 2 add
|
||||
Turn 4: update_node_parameters(Tool1, Tool2, Tool3, Set)
|
||||
Turn 5: connect_nodes(batch 2) + add_nodes(IF, Slack, Gmail) ← batch 2 connect + batch 3 add
|
||||
Turn 6: update_node_parameters(IF, Slack, Gmail)
|
||||
Turn 7: connect_nodes(batch 3) + validate_structure + validate_configuration
|
||||
|
||||
Plan all nodes before starting to avoid backtracking.`;
|
||||
The pattern repeats: after configuring each batch, combine its connections with the next batch's additions.
|
||||
</progressive_building>
|
||||
|
||||
<what_to_avoid>
|
||||
Doing all adds, then all configs, then all connects creates poor UX—users see nothing for a long time, then everything appears at once. Instead, complete each batch before starting the next.
|
||||
</what_to_avoid>
|
||||
|
||||
<batch_grouping>
|
||||
Group related nodes together:
|
||||
- AI patterns: Agent + Model + Memory in one batch, Tools in next batch
|
||||
- Parallel branches: Group by logical unit (e.g., all error handling nodes together)
|
||||
</batch_grouping>
|
||||
|
||||
<modification_flow>
|
||||
When modifying an existing workflow (adding/changing nodes):
|
||||
add_nodes → update_node_parameters → connect_nodes → validate
|
||||
</modification_flow>
|
||||
|
||||
<validation>
|
||||
Call validate_structure and validate_configuration at the end. When validation fails, fix the issues and re-validate. Never call validation in parallel with update operations—validation must see the current state.
|
||||
</validation>`;
|
||||
|
||||
const EXECUTION_SEQUENCE_WITH_EXAMPLES = `Build incrementally in small batches for progressive canvas updates. Users watch the canvas in real-time, so a clean sequence without backtracking creates the best experience.
|
||||
|
||||
@@ -78,6 +101,20 @@ const NODE_CREATION = `Each add_nodes call creates one node:
|
||||
|
||||
Only add nodes that directly contribute to the workflow logic. Do NOT add unnecessary "configuration" or "setup" nodes that just pass data through.`;
|
||||
|
||||
const USE_DISCOVERED_NODES = `<discovered_nodes>
|
||||
Use only node types provided in the DISCOVERY CONTEXT section. This context lists nodes that the Discovery Agent found for the current task, with their exact type names, versions, and available parameters.
|
||||
|
||||
<baseline_nodes>
|
||||
Discovery provides baseline flow control nodes (Aggregate, IF, Switch, Split Out, Merge, Set) for every workflow. These are fundamental data transformation tools available for you to use if needed. You are not required to use all of them—select only the nodes that solve the actual requirements of the workflow.
|
||||
</baseline_nodes>
|
||||
|
||||
When you need a node that wasn't discovered:
|
||||
1. Check if an existing discovered node can solve the problem (e.g., Set node for data transformation, Split Out for expanding arrays)
|
||||
2. If no discovered node fits, explain what functionality you need in your response. The user or discovery agent can identify the right node type.
|
||||
|
||||
Do not guess node type names. Node type names must exactly match the format shown in discovery context (e.g., "n8n-nodes-base.webhook", not "webhook" or "splitOut").
|
||||
</discovered_nodes>`;
|
||||
|
||||
const AI_CONNECTIONS = `AI capability connections flow from sub-node TO parent (reversed from normal data flow) because sub-nodes provide capabilities that the parent consumes.
|
||||
|
||||
Connection patterns:
|
||||
@@ -154,12 +191,50 @@ graph TD
|
||||
CM2[Chat Model 2] -.ai_languageModel.-> SUB
|
||||
\`\`\`
|
||||
|
||||
<multi_agent_architecture>
|
||||
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent—it's a complete sub-agent, not a wrapper for a separate agent node. This design allows the main agent to delegate tasks to specialized sub-agents through the ai_tool connection.
|
||||
|
||||
Supervisor with two sub-agents (Research + Writing):
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
T[Trigger] --> MAIN[Main Supervisor Agent]
|
||||
CM1[Supervisor Model] -.ai_languageModel.-> MAIN
|
||||
|
||||
RESEARCH[Research Agent Tool] -.ai_tool.-> MAIN
|
||||
CM2[Research Model] -.ai_languageModel.-> RESEARCH
|
||||
SEARCH[SerpAPI Tool] -.ai_tool.-> RESEARCH
|
||||
|
||||
WRITING[Writing Agent Tool] -.ai_tool.-> MAIN
|
||||
CM3[Writing Model] -.ai_languageModel.-> WRITING
|
||||
|
||||
MAIN --> OUT[Output]
|
||||
\`\`\`
|
||||
|
||||
Each AgentTool is a complete sub-agent that:
|
||||
- Receives ai_languageModel from its own Chat Model (powers the embedded agent)
|
||||
- Connects to a parent AI Agent via ai_tool (parent can invoke it as a tool)
|
||||
- Can have its own tools connected via ai_tool (gives sub-agent capabilities)
|
||||
|
||||
AgentTool configuration (follows the same $fromAI pattern as other tool nodes):
|
||||
- **name**: Tool identifier (e.g., "research_agent")
|
||||
- **description**: What this sub-agent does (parent agent uses this to decide when to call it)
|
||||
- **systemMessage**: Instructions for the embedded agent's role and behavior
|
||||
- **text**: Use $fromAI so the parent agent can pass the task: \`={{ $fromAI('task', 'The task to perform') }}\`
|
||||
</multi_agent_architecture>
|
||||
|
||||
## Validation Checklist
|
||||
1. Every AI Agent has a Chat Model connected via ai_languageModel
|
||||
2. Every Vector Store has Embeddings connected via ai_embedding
|
||||
3. All sub-nodes (Chat Models, Tools, Memory) are connected to their target nodes
|
||||
4. Sub-nodes connect TO parent nodes, not FROM them
|
||||
|
||||
## AI Agent Prompt Configuration
|
||||
AI Agent nodes have two distinct prompt fields - configure both:
|
||||
- **systemMessage**: Static instructions defining the agent's role, behavior, and task. Example: "You are a content moderator. Analyze submissions and classify them as approved, needs review, spam, or offensive."
|
||||
- **text**: Dynamic user input, typically an expression referencing data from previous nodes. Example: "={{ $json.body.content }}"
|
||||
|
||||
When configuring an AI Agent, set systemMessage to the agent's instructions and text to the dynamic input. Do not combine both in the text field.
|
||||
|
||||
REMEMBER: Every AI Agent MUST have a Chat Model. Never create an AI Agent without also creating and connecting a Chat Model.`;
|
||||
|
||||
const CONNECTION_TYPES = `Connection types:
|
||||
@@ -331,11 +406,17 @@ Common patterns:
|
||||
- String concatenation: =Hello {{{{ $json.name }}}}
|
||||
- Conditional: ={{{{ $json.status === 'active' ? 'Yes' : 'No' }}}}`;
|
||||
|
||||
const TOOL_NODES = `Tool nodes (types ending in "Tool") use $fromAI for dynamic values that the AI Agent determines at runtime:
|
||||
const TOOL_NODES = `Tool nodes (types ending in "Tool") use $fromAI for dynamic values that the parent AI Agent determines at runtime:
|
||||
- $fromAI('key', 'description', 'type', defaultValue)
|
||||
- Example: "Set sendTo to ={{{{ $fromAI('recipient', 'Email address', 'string') }}}}"
|
||||
|
||||
$fromAI is designed specifically for tool nodes where the AI Agent provides values. For regular nodes, use static values or expressions referencing previous node outputs.`;
|
||||
$fromAI is designed specifically for tool nodes where the parent AI Agent provides values. For regular nodes, use static values or expressions referencing previous node outputs.
|
||||
|
||||
AI Agent Tool (agentTool) configuration:
|
||||
- name: Tool identifier (e.g., "research_agent")
|
||||
- description: What the sub-agent does
|
||||
- systemMessage: Instructions for the embedded agent
|
||||
- text: ={{{{ $fromAI('task', 'The task to perform') }}}} — required so the parent agent can pass the task`;
|
||||
|
||||
const CRITICAL_PARAMETERS = `Parameters to set explicitly (these affect core functionality):
|
||||
- HTTP Request: URL, method (determines the API call behavior)
|
||||
@@ -344,6 +425,52 @@ const CRITICAL_PARAMETERS = `Parameters to set explicitly (these affect core fun
|
||||
|
||||
Parameters safe to use defaults: Chat model selection, embedding model, LLM parameters (temperature, etc.) have sensible defaults.`;
|
||||
|
||||
const DATA_TABLE_CONFIGURATION = `<data_table_configuration>
|
||||
Data Table nodes (n8n-nodes-base.dataTable) require specific setup for write operations.
|
||||
|
||||
<write_operations>
|
||||
For row write operations (${dataTableColumnOperationsList}), each Data Table needs its own Set node:
|
||||
- For each Data Table with insert/update/upsert, add a corresponding Set node immediately before it
|
||||
- Configure each Set node with the fields for that specific table
|
||||
- Use a placeholder for dataTableId as a Resource Locator object: {{ "__rl": true, "mode": "id", "value": "<__PLACEHOLDER_VALUE__data_table_name__>" }}
|
||||
- Set columns.mappingMode to "autoMapInputData"
|
||||
|
||||
Example: If the workflow has 2 Data Tables (Track Results and Flag Issues), add 2 Set nodes:
|
||||
\`\`\`
|
||||
... → Prepare Results (Set) → Track Results (Data Table)
|
||||
... → Prepare Flags (Set) → Flag Issues (Data Table)
|
||||
\`\`\`
|
||||
|
||||
Add all Set nodes when you add the Data Tables, not later. The Set node defines the column structure for each table.
|
||||
</write_operations>
|
||||
|
||||
<read_operations>
|
||||
For row read operations (get, getAll, delete):
|
||||
- No Set node required before the Data Table node
|
||||
- Use a placeholder for dataTableId as a Resource Locator object (same format as write operations)
|
||||
- Configure filter or query parameters as needed
|
||||
</read_operations>
|
||||
|
||||
<shared_tracking_pattern>
|
||||
When multiple branches write to the same tracking Data Table (common for logging all outcomes), connect each handler's OUTPUT to a shared Set node:
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
C[Classifier] --> H1[Handler A]
|
||||
C --> H2[Handler B]
|
||||
C --> H3[Handler C]
|
||||
H1 --> S[Prepare Data<br/>Set node]
|
||||
H2 --> S
|
||||
H3 --> S
|
||||
S --> D[Track Results<br/>Data Table]
|
||||
\`\`\`
|
||||
|
||||
The flow is: Classifier → Handler → Set → Data Table (not Classifier → Set directly).
|
||||
|
||||
Each handler completes its work first, then its output flows to the shared Set node. The Set node prepares consistent tracking data regardless of which handler ran.
|
||||
</shared_tracking_pattern>
|
||||
</data_table_configuration>`;
|
||||
|
||||
const COMMON_SETTINGS = `Important node settings:
|
||||
- Forms/Chatbots: Set "Append n8n Attribution" = false
|
||||
- Gmail Trigger: Simplify = false, Download Attachments = true (for attachments)
|
||||
@@ -449,6 +576,58 @@ Error output data structure: When a node errors with continueErrorOutput, the er
|
||||
To log errors, reference: ={{{{ $json.error.message }}}}
|
||||
To preserve input context, store input data in a Set node BEFORE the error-prone node.`;
|
||||
|
||||
// === CONTEXT AND INVESTIGATION ===
|
||||
|
||||
const UNDERSTANDING_CONTEXT = `You receive CONVERSATION CONTEXT showing:
|
||||
- Original request: What the user initially asked for
|
||||
- Previous actions: What Discovery/Builder did before
|
||||
- Current request: What the user is asking now
|
||||
|
||||
<investigating_issues>
|
||||
When the current request is vague (e.g., "fix it", "it's not working", "help"), investigate before acting:
|
||||
1. Review the conversation context to understand what was built and why
|
||||
2. Use execution data tools to understand what went wrong
|
||||
3. Make targeted changes based on your findings
|
||||
</investigating_issues>
|
||||
|
||||
<default_to_action>
|
||||
After investigating and identifying issues, implement the fixes directly. When the user says "fix it" or reports a problem, they want you to resolve it—so proceed with the solution. Asking for confirmation on obvious fixes creates unnecessary back-and-forth and slows down the user's workflow.
|
||||
|
||||
Reserve questions for genuinely ambiguous situations where multiple valid approaches exist and the user's preference matters.
|
||||
</default_to_action>`;
|
||||
|
||||
const WORKFLOW_CONTEXT_TOOLS = `Tools for understanding and investigating workflow state:
|
||||
|
||||
<workflow_context_tools>
|
||||
**get_workflow_overview** (RECOMMENDED for understanding workflow structure)
|
||||
Returns a Mermaid flowchart diagram, node IDs, and summary of the workflow.
|
||||
Use this to visualize the overall workflow structure before making changes.
|
||||
Options: format ('mermaid' or 'summary'), includeParameters (default: true)
|
||||
|
||||
The includeParameters option shows each node's current configuration. This helps you identify nodes that need configuration (empty parameters, missing prompts, unconfigured fields). Keep it enabled when investigating issues or reviewing workflow state.
|
||||
|
||||
**get_node_context**
|
||||
Returns full context for a specific node: ID, parameters, parent/child nodes, classification, and execution data.
|
||||
Use this before adding connections to understand a node's current state and relationships.
|
||||
Parameters: nodeName (required), includeExecutionData (default: true)
|
||||
</workflow_context_tools>
|
||||
|
||||
<execution_data_tools>
|
||||
These tools show execution state from BEFORE your session—they help you understand what the user experienced and identify why a workflow failed.
|
||||
|
||||
**get_execution_logs**
|
||||
Returns full execution data: runData for each node, errors, and which node failed.
|
||||
Use this to see what data flowed through the workflow and identify failures.
|
||||
|
||||
**get_execution_schema**
|
||||
Returns data structure/types from each node's output (field names and types).
|
||||
Use this to understand what data is available for new nodes you're adding.
|
||||
|
||||
**get_expression_data_mapping**
|
||||
Returns resolved expression values - what {{ $json.field }} evaluated to.
|
||||
Use this to debug expression-related issues.
|
||||
</execution_data_tools>`;
|
||||
|
||||
// === SHARED SECTIONS ===
|
||||
|
||||
const ANTI_OVERENGINEERING = `Keep implementations minimal and focused on what's requested.
|
||||
@@ -457,15 +636,9 @@ Plan all nodes before adding any. Users watch the canvas in real-time, so adding
|
||||
|
||||
Build the complete workflow in one pass. Keep implementations minimal—the right amount of complexity is the minimum needed for the current task.`;
|
||||
|
||||
const RESPONSE_FORMAT = `After validation passes, output a summary describing what you built (no emojis, no markdown formatting).
|
||||
const RESPONSE_FORMAT = `After validation passes, stop and output a brief completion message. Do not call read tools (get_workflow_overview, get_node_context) to review your work—validation confirms correctness.
|
||||
|
||||
Include:
|
||||
- Nodes created and their purpose
|
||||
- Key configuration you applied (model names, operations, modes, etc.)
|
||||
- Any placeholders requiring user input
|
||||
|
||||
This summary is passed to another agent who will respond to the user—include enough detail so they can accurately describe what was built.
|
||||
`;
|
||||
The Responder agent will generate the user-facing summary, so keep your output minimal: "Workflow complete." or a single sentence noting any issues encountered.`;
|
||||
|
||||
/** Instance URL template variable for webhooks */
|
||||
export const INSTANCE_URL_PROMPT = `<instance_url>
|
||||
@@ -478,6 +651,7 @@ const COMMON_MISTAKES = `
|
||||
- SUBSTITUTING MODEL NAMES: Use the exact model name the user specifies—never substitute with a different model. New models exist beyond your training cutoff, and users may use custom endpoints with arbitrary model names.
|
||||
- Ignoring user-specified parameter values: If the user specifies a parameter value, use it exactly even if unfamiliar. Trust the user's knowledge of current systems.
|
||||
- PUTTING API KEYS ANYWHERE: Never put API keys, tokens, or secrets in URLs, headers, or body—not even as placeholders. n8n handles authentication through its credential system. For HTTP Request nodes, omit auth parameters from the URL entirely.`;
|
||||
|
||||
// === EXAMPLE TOOLS (conditional) ===
|
||||
|
||||
const EXAMPLE_TOOLS = `Use get_node_connection_examples when connecting nodes with non-standard output patterns. This tool shows how experienced users connect these nodes in real workflows, preventing common mistakes:
|
||||
@@ -511,11 +685,13 @@ export function buildBuilderPrompt(
|
||||
return (
|
||||
prompt()
|
||||
.section('role', ROLE)
|
||||
.section('understanding_context', UNDERSTANDING_CONTEXT)
|
||||
// Execution sequence depends on whether examples are enabled
|
||||
.sectionIf(!options.includeExamples, 'execution_sequence', EXECUTION_SEQUENCE)
|
||||
.sectionIf(options.includeExamples, 'execution_sequence', EXECUTION_SEQUENCE_WITH_EXAMPLES)
|
||||
// Structure
|
||||
.section('node_creation', NODE_CREATION)
|
||||
.section('use_discovered_nodes', USE_DISCOVERED_NODES)
|
||||
.section('ai_connections', AI_CONNECTIONS)
|
||||
.section('connection_types', CONNECTION_TYPES)
|
||||
.section('initial_parameters', INITIAL_PARAMETERS)
|
||||
@@ -527,6 +703,7 @@ export function buildBuilderPrompt(
|
||||
.section('expression_syntax', EXPRESSION_SYNTAX)
|
||||
.section('tool_nodes', TOOL_NODES)
|
||||
.section('critical_parameters', CRITICAL_PARAMETERS)
|
||||
.section('data_table_configuration', DATA_TABLE_CONFIGURATION)
|
||||
.section('common_settings', COMMON_SETTINGS)
|
||||
.section('webhook_configuration', webhook.configuration)
|
||||
.section('credential_security', CREDENTIAL_SECURITY)
|
||||
@@ -534,6 +711,8 @@ export function buildBuilderPrompt(
|
||||
.section('resource_locator_defaults', RESOURCE_LOCATOR_DEFAULTS)
|
||||
.section('model_configuration', MODEL_CONFIGURATION)
|
||||
.section('node_settings', NODE_SETTINGS)
|
||||
// Context and investigation tools
|
||||
.section('workflow_context_tools', WORKFLOW_CONTEXT_TOOLS)
|
||||
// Example tools reference (conditional)
|
||||
.sectionIf(options.includeExamples, 'example_tools', EXAMPLE_TOOLS)
|
||||
// Output
|
||||
|
||||
@@ -46,21 +46,9 @@ export interface DiscoveryPromptOptions {
|
||||
const ROLE = `You are a Discovery Agent for n8n AI Workflow Builder.
|
||||
Identify relevant n8n nodes and their connection-changing parameters for the user's request.`;
|
||||
|
||||
const N8N_EXECUTION_MODEL = `n8n executes each node once per input item. Understanding this is essential for correct workflow design.
|
||||
const N8N_EXECUTION_MODEL = `n8n executes each node once per input item.
|
||||
|
||||
When a trigger or node outputs multiple items (e.g., Gmail returns 10 emails), every downstream node runs 10 times—once for each item. This means:
|
||||
- "Analyze emails" with AI Agent → AI Agent runs separately for each email
|
||||
- "Send summary" after analysis → sends one message per email, not one combined summary
|
||||
|
||||
To process multiple items as a group:
|
||||
- Aggregate node: Combines multiple items into one before processing (e.g., 10 emails → single item containing all emails → AI Agent analyzes together → one summary)
|
||||
- Split Out node: Does the reverse—converts one item with an array field into multiple items for individual processing
|
||||
|
||||
Common patterns requiring Aggregate:
|
||||
- "summarize all [items]" → Aggregate before the summarization node
|
||||
- "send one notification with all results" → Aggregate before notification node
|
||||
- "create a report from multiple sources" → Aggregate to combine data first
|
||||
- "analyze [items] together" → Aggregate before AI Agent`;
|
||||
When a trigger or node outputs multiple items (e.g., Gmail returns 10 emails), every downstream node runs once for each item. Flow control nodes like Aggregate and Split Out change how items flow through the workflow by combining or expanding them.`;
|
||||
|
||||
const PROCESS = `1. Search for nodes matching the user's request using search_nodes tool
|
||||
2. Identify connection-changing parameters from input/output expressions (look for $parameter.X)
|
||||
@@ -80,48 +68,108 @@ Default chat model: OpenAI Chat Model provides the lowest setup friction for new
|
||||
Tool nodes (ending in "Tool"): Connect to AI Agent via ai_tool for agent-controlled actions.
|
||||
Text Classifier vs AI Agent: Text Classifier for simple categorization with fixed categories; AI Agent for complex multi-step classification requiring reasoning.
|
||||
Memory nodes: Include with chatbot AI Agents to maintain conversation context across messages.
|
||||
Structured Output Parser: Prefer this over manually extracting/parsing AI output with Set or Code nodes. Define the desired schema and the LLM handles parsing automatically. Use for classification, data extraction, or any workflow where AI output feeds into database storage, API calls, or Switch routing.`;
|
||||
Structured Output Parser: Prefer this over manually extracting/parsing AI output with Set or Code nodes. Define the desired schema and the LLM handles parsing automatically. Use for classification, data extraction, or any workflow where AI output feeds into database storage, API calls, or Switch routing.
|
||||
|
||||
<multi_agent_systems>
|
||||
For "team of agents", "supervisor agent", "agents that call other agents", or "multi-agent" requests:
|
||||
|
||||
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent—it's a complete sub-agent that the main agent can call through ai_tool. Each AgentTool needs its own Chat Model.
|
||||
|
||||
\`\`\`mermaid
|
||||
graph TD
|
||||
MAIN[Main AI Agent]
|
||||
CM1[Chat Model] -.ai_languageModel.-> MAIN
|
||||
SUB1[Research Agent Tool] -.ai_tool.-> MAIN
|
||||
CM2[Chat Model] -.ai_languageModel.-> SUB1
|
||||
SUB2[Writing Agent Tool] -.ai_tool.-> MAIN
|
||||
CM3[Chat Model] -.ai_languageModel.-> SUB2
|
||||
\`\`\`
|
||||
|
||||
Node selection: 1 AI Agent + N AgentTools + (N+1) Chat Models
|
||||
</multi_agent_systems>`;
|
||||
|
||||
const NODE_SELECTION_PATTERNS = `Node selection by use case:
|
||||
|
||||
DOCUMENTS:
|
||||
- RAG/Vector Store workflows: Document Loader (dataType='binary') handles PDF, CSV, JSON from form uploads automatically
|
||||
- Standalone text extraction: Extract From File requires IF/Switch to route each file type to correct operation
|
||||
- Scanned documents: AWS Textract (OCR), Mindee (invoices/receipts)
|
||||
DATA PROCESSING: Aggregate to combine multiple items before summarization/analysis, Split Out to expand arrays into items, Loop Over Items for 100+ items
|
||||
SUMMARIZATION: When summarizing multiple items (emails, messages, records), include Aggregate before the AI/summarization node—otherwise each item processes separately
|
||||
STORAGE: n8n Data Tables (preferred, requires no credentials), Google Sheets (for collaboration), Airtable (for relationships). Note: Set/Merge transform data in memory only—add a storage node to persist data.
|
||||
TRIGGERS: Schedule Trigger (only runs when activated), Gmail Trigger (set Simplify=false, Download Attachments=true), Form Trigger (always store raw data)
|
||||
SCRAPING: Phantombuster/Apify for social media (LinkedIn/Twitter), HTTP Request + HTML Extract for simple pages
|
||||
NOTIFICATIONS: Email, Slack, Telegram, Twilio. For one notification summarizing multiple items, include Aggregate before the notification node.
|
||||
RESEARCH: SerpAPI Tool, Perplexity Tool connect to AI Agent for research capabilities.
|
||||
CHATBOTS: Use platform-specific nodes (Slack, Telegram, WhatsApp) for platform chatbots, Chat Trigger for n8n-hosted chat.
|
||||
MEDIA: OpenAI for DALL-E/Sora, Google Gemini for Imagen, ElevenLabs for voice (via HTTP Request).`;
|
||||
- Document Loader: Loads documents from various sources (dataType parameter controls format handling)
|
||||
- Extract From File: Extracts text content from binary files (operation varies by file type)
|
||||
- AWS Textract: OCR for scanned documents
|
||||
- Mindee: Extracts structured data from invoices and receipts
|
||||
|
||||
const FLOW_CONTROL_NODES = `Flow control nodes handle item cardinality, branching, and data restructuring. Include these generously—they're commonly needed and the builder can select the most appropriate ones.
|
||||
DATA PROCESSING & TRANSFORMATION:
|
||||
- Aggregate: Combines multiple items into one
|
||||
- Split Out: Expands arrays into separate items
|
||||
- Loop Over Items: Processes large item sets
|
||||
- Set: Adds, modifies, or removes fields from items
|
||||
- Filter: Removes items based on conditions
|
||||
- Sort: Orders items by field values
|
||||
|
||||
ITEM AGGREGATION (include when user wants combined/summarized output from multiple items):
|
||||
- Aggregate (n8n-nodes-base.aggregate): Combines multiple items into one. Essential when the user wants a single output from multiple inputs.
|
||||
Patterns: "summarize all emails", "create one report", "send combined notification", "analyze [items] together"
|
||||
Without Aggregate, each item flows through downstream nodes separately—resulting in multiple outputs instead of one.
|
||||
STORAGE:
|
||||
- n8n Data Tables: Built-in database storage (no credentials required)
|
||||
- Google Sheets: Spreadsheet storage and collaboration
|
||||
- Airtable: Relational database with rich field types
|
||||
|
||||
CONDITIONAL BRANCHING (include when workflow has different paths or decisions):
|
||||
- IF (n8n-nodes-base.if): Binary decisions (true/false paths). Patterns: "if condition", "check whether", "when X do Y otherwise Z"
|
||||
- Switch (n8n-nodes-base.switch): Multiple routing paths (3+). Patterns: "route by category", "different actions for each type", "triage"
|
||||
TRIGGERS:
|
||||
- Schedule Trigger: Time-based automation
|
||||
- Gmail Trigger: Monitors for new emails
|
||||
- Form Trigger: Collects user submissions
|
||||
- Webhook: Receives HTTP requests from external services
|
||||
|
||||
SCRAPING:
|
||||
- Phantombuster/Apify: Social media and LinkedIn data collection
|
||||
- HTTP Request + HTML Extract: Web page content extraction
|
||||
|
||||
NOTIFICATIONS:
|
||||
- Email nodes (Gmail, Outlook, Send Email)
|
||||
- Slack: Team messaging
|
||||
- Telegram: Bot messaging
|
||||
- Twilio: SMS messaging
|
||||
|
||||
RESEARCH:
|
||||
- SerpAPI Tool: Web search capabilities for AI Agents
|
||||
- Perplexity Tool: AI-powered search for AI Agents
|
||||
|
||||
CHATBOTS:
|
||||
- Slack/Telegram/WhatsApp nodes: Platform-specific chatbots
|
||||
- Chat Trigger: n8n-hosted chat interface
|
||||
|
||||
MEDIA:
|
||||
- OpenAI: DALL-E image generation, Sora video, Whisper transcription
|
||||
- Google Gemini: Imagen image generation
|
||||
- ElevenLabs: Text-to-speech (via HTTP Request)`;
|
||||
|
||||
const BASELINE_FLOW_CONTROL = `<always_include_baseline>
|
||||
Always include these fundamental flow control and data transformation nodes in your discovery results. These are used in most workflows and the builder will select which ones are needed:
|
||||
|
||||
- n8n-nodes-base.aggregate: Combines multiple items into one item
|
||||
- n8n-nodes-base.if: Routes items based on true/false condition
|
||||
- n8n-nodes-base.switch: Routes items to different paths based on rules or expressions (connection-changing param: mode)
|
||||
- n8n-nodes-base.splitOut: Expands a single item containing an array into multiple individual items
|
||||
- n8n-nodes-base.merge: Combines data from multiple parallel branches (for 3+ inputs: mode="append" + numberInputs)
|
||||
- n8n-nodes-base.set: Transforms and restructures data fields
|
||||
|
||||
The builder will determine which of these nodes are actually needed for the workflow. Your job is to explain what each node does, not prescribe when to use it.
|
||||
</always_include_baseline>`;
|
||||
|
||||
const FLOW_CONTROL_NODES = `Flow control nodes handle item cardinality, branching, and data restructuring:
|
||||
|
||||
ITEM AGGREGATION:
|
||||
- Aggregate (n8n-nodes-base.aggregate): Combines multiple items into one.
|
||||
|
||||
CONDITIONAL BRANCHING:
|
||||
- IF (n8n-nodes-base.if): Binary true/false routing.
|
||||
- Switch (n8n-nodes-base.switch): Multiple output paths based on conditions.
|
||||
Connection-changing param: mode (expression/rules)
|
||||
|
||||
DATA RESTRUCTURING (include when item structure needs to change):
|
||||
- Split Out (n8n-nodes-base.splitOut): Converts single item with array field into multiple items for individual processing.
|
||||
Patterns: API returns object with array field and each item needs separate processing
|
||||
- Merge (n8n-nodes-base.merge): Combines data from parallel branches that ALL execute together.
|
||||
DATA RESTRUCTURING:
|
||||
- Split Out (n8n-nodes-base.splitOut): Converts single item with array field into multiple items.
|
||||
- Merge (n8n-nodes-base.merge): Combines data from parallel branches that execute together.
|
||||
For 3+ inputs: mode="append" + numberInputs, OR mode="combine" + combineBy="combineByPosition" + numberInputs
|
||||
- Set (n8n-nodes-base.set): Use after IF/Switch to continue flow when only one branch executes (Merge would wait forever).
|
||||
- Set (n8n-nodes-base.set): Transforms and restructures data fields.
|
||||
|
||||
LOOPING & BATCHING (include for large datasets):
|
||||
- Split In Batches (n8n-nodes-base.splitInBatches): Process 100+ items in chunks to prevent memory issues.
|
||||
Output 0 = "done" (final result), Output 1 = "loop" (connect processing here)
|
||||
|
||||
Be inclusive with flow control recommendations. When in doubt, include Aggregate, IF, and Split Out—they're frequently needed and the builder can omit any that aren't required.`;
|
||||
LOOPING & BATCHING:
|
||||
- Split In Batches (n8n-nodes-base.splitInBatches): Process large datasets in chunks.
|
||||
Output 0 = "done" (final result), Output 1 = "loop" (processing)`;
|
||||
|
||||
const CONNECTION_PARAMETERS = `A parameter is connection-changing if it appears in <node_inputs> or <node_outputs> expressions.
|
||||
Look for patterns like: $parameter.mode, $parameter.hasOutputParser in the search results.
|
||||
@@ -162,10 +210,15 @@ When AI Agent needs external capabilities, use TOOL nodes (not regular nodes):
|
||||
- Messaging: Slack Tool, Gmail Tool → AI Agent [ai_tool]
|
||||
- HTTP calls: HTTP Request Tool → AI Agent [ai_tool]
|
||||
- Calculations: Calculator Tool → AI Agent [ai_tool]
|
||||
- Sub-agents: AI Agent Tool → AI Agent [ai_tool] (for multi-agent systems)
|
||||
|
||||
Tool nodes: AI Agent decides when/if to use them based on reasoning.
|
||||
Regular nodes: Execute at that workflow step regardless of context.
|
||||
|
||||
Multi-agent pattern:
|
||||
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent that the main agent can invoke as a tool. Connect a Chat Model to the AgentTool via ai_languageModel (powers the embedded agent), then connect the AgentTool to the main AI Agent via ai_tool.
|
||||
Connection-changing param: hasOutputParser (true/false)
|
||||
|
||||
Vector Store patterns:
|
||||
- Insert documents: Document Loader → Vector Store (mode='insert') [ai_document]
|
||||
- RAG with AI Agent: Vector Store (mode='retrieve-as-tool') → AI Agent [ai_tool]
|
||||
@@ -201,24 +254,49 @@ Fall back to HTTP Request only when the requested service has no native n8n node
|
||||
|
||||
const KEY_RULES = `Output format: nodesFound array with nodeName, version, reasoning, connectionChangingParameters per node.
|
||||
|
||||
REASONING CONTENT (what to include):
|
||||
- What the node does (its purpose and capabilities)
|
||||
- What connection-changing parameters exist and how each value affects inputs/outputs
|
||||
- Describe capabilities neutrally—the builder decides how to configure the node for this specific workflow
|
||||
<reasoning_guidelines>
|
||||
Reasoning should describe WHAT the node does, not WHEN or HOW to use it. Focus on capabilities and behavior, not recommendations or comparisons. The builder will decide which nodes to use.
|
||||
|
||||
CRITICAL - Model names:
|
||||
- If the user specifies a model name (e.g., "gpt-5-mini", "claude-4", any custom model), pass it through EXACTLY in your reasoning
|
||||
- Do NOT substitute, "correct", or replace model names—your training data has a knowledge cutoff and newer models exist
|
||||
- Users may also use custom endpoints with model names you've never seen
|
||||
|
||||
Example reasoning for Vector Store: "Stores and retrieves embeddings. Connection-changing param 'mode': insert (accepts ai_document input), retrieve (standalone retrieval), retrieve-as-tool (connects to AI Agent via ai_tool)."
|
||||
Good reasoning examples (neutral, factual):
|
||||
- "Extracts data from HTML documents using CSS selectors or XPath"
|
||||
- "Transforms data by adding, modifying, or removing fields from items"
|
||||
- "Sends HTTP requests to external APIs with configurable methods and headers"
|
||||
- "Combines multiple items into a single item containing all data"
|
||||
- "Converts a single item with an array field into multiple separate items"
|
||||
|
||||
Bad reasoning examples (prescriptive, comparative):
|
||||
- "Use this to build HTML content" ❌ Tells WHEN to use
|
||||
- "While you can build HTML in a Set node, this provides..." ❌ Compares alternatives
|
||||
- "Better for Y than Z" ❌ Judges superiority
|
||||
- "You should use this when..." ❌ Prescribes usage
|
||||
- "This node will replace X" ❌ Decides architecture
|
||||
- "or to build the HTML content itself" ❌ Suggests specific use case
|
||||
|
||||
Vector Store example (neutral, capability-focused):
|
||||
"Stores and retrieves vector embeddings. Connection-changing param 'mode': insert (accepts ai_document connections), retrieve (outputs retrieved documents), retrieve-as-tool (connects to AI Agent via ai_tool for on-demand retrieval)."
|
||||
|
||||
HTML node example (neutral):
|
||||
Good: "Extracts data from HTML using CSS selectors, converts HTML to markdown, or manipulates HTML structure"
|
||||
Bad: "While you can build HTML in a Set node, this provides HTML-specific operations" ❌
|
||||
</reasoning_guidelines>
|
||||
|
||||
Guidelines:
|
||||
- Extract version from <version> tag in node details (version affects available features)
|
||||
- For flow control nodes (Aggregate, IF, Switch, Split Out, Merge), include all that could be useful—the builder selects which to use
|
||||
- When workflow involves multiple items being processed together (summarize, combine, report), include Aggregate
|
||||
- Prioritize native nodes (especially Edit Fields/Set) because they provide better UX and visual debugging
|
||||
- For RAG with AI Agent, recommend Vector Store in retrieve-as-tool mode (simpler architecture than using a separate Retriever node)`;
|
||||
- Baseline flow control nodes (Aggregate, IF, Switch, Split Out, Merge, Set) are automatically included—no need to search for them
|
||||
- Prioritize native nodes in your searches because they provide better UX and visual debugging than Code node alternatives`;
|
||||
|
||||
const TOOL_CALL_REQUIREMENT = `<tool_call_requirement>
|
||||
Always use the tool calling API to submit your results. The downstream pipeline parses your output by reading the structured tool_calls from the API response, not by parsing text content.
|
||||
|
||||
When you're ready to submit results, invoke the submit_discovery_results tool directly through the tool calling interface. Do not output results as text, XML tags, or any other format—even if the format looks correct, the system cannot process it unless you use an actual tool call.
|
||||
|
||||
If you find yourself writing something like "<invoke name=..." or outputting the nodesFound array as text, stop and use the tool call instead. Only tool_calls in the API response are processed by the system.
|
||||
</tool_call_requirement>`;
|
||||
|
||||
function generateAvailableToolsList(options: DiscoveryPromptOptions): string {
|
||||
const tools = [
|
||||
@@ -244,7 +322,9 @@ export function buildDiscoveryPrompt(options: DiscoveryPromptOptions): string {
|
||||
.section('available_tools', availableTools)
|
||||
.sectionIf(!options.includeExamples, 'process', PROCESS)
|
||||
.sectionIf(options.includeExamples, 'process', PROCESS_WITH_EXAMPLES)
|
||||
.section('tool_call_requirement', TOOL_CALL_REQUIREMENT)
|
||||
.section('n8n_execution_model', N8N_EXECUTION_MODEL)
|
||||
.section('baseline_flow_control', BASELINE_FLOW_CONTROL)
|
||||
.section('trigger_selection', TRIGGER_SELECTION)
|
||||
.section('ai_node_selection', AI_NODE_SELECTION)
|
||||
.section('ai_tool_patterns', AI_TOOL_PATTERNS)
|
||||
|
||||
@@ -14,7 +14,14 @@ const RESPONDER_ROLE = `You are a helpful AI assistant for n8n workflow automati
|
||||
You have access to context about what has been built, including:
|
||||
- Discovery results (nodes found)
|
||||
- Builder output (workflow structure)
|
||||
- Configuration summary (setup instructions)`;
|
||||
- Configuration summary (setup instructions)
|
||||
- Workflow indicator showing current nodes and their connections
|
||||
|
||||
The other agents (Builder) have access to workflow context tools:
|
||||
- get_workflow_overview: Visual Mermaid diagram and summary
|
||||
- get_node_context: Full details for a specific node
|
||||
|
||||
When explaining the workflow to users, use the information provided in your context.`;
|
||||
|
||||
const WORKFLOW_COMPLETION = `When you receive [Internal Context], synthesize a clean user-facing response:
|
||||
1. Summarize what was built in a friendly way
|
||||
@@ -57,6 +64,27 @@ const GUARDRAILS = `Your capabilities are focused on workflow building:
|
||||
|
||||
If a user asks you to search for information or look something up online, let them know you can help build workflows based on your existing knowledge of n8n nodes and integrations, though you don't have access to external websites or real-time information.`;
|
||||
|
||||
const EXECUTION_ISSUE_HANDLING = `IMPORTANT: Check the [Internal Context] to see if work was JUST COMPLETED:
|
||||
|
||||
**If Builder just completed** (shown in Internal Context):
|
||||
- Summarize what was DONE, not what SHOULD be done
|
||||
- Example: "I've fixed the Split Articles configuration to properly handle the array of articles."
|
||||
- Do NOT ask "Would you like me to fix this?" when it was already fixed
|
||||
- The execution status may still show old data from BEFORE the fix - trust the completion status
|
||||
|
||||
**If no recent work was done and execution status shows issues**:
|
||||
1. BRIEFLY explain what happened using the data_flow information
|
||||
- Example: "I can see Fetch AI News returned 1 item, but Split Articles produced nothing"
|
||||
- Keep it concise - one or two sentences
|
||||
|
||||
2. Offer to investigate and fix
|
||||
- Example: "Would you like me to investigate and fix this?"
|
||||
|
||||
3. NEVER ask the user to share data or check outputs themselves
|
||||
- The system has access to execution data - you don't need the user to provide it
|
||||
|
||||
4. Keep explanations brief - the user wants the AI to fix it, not a debugging guide`;
|
||||
|
||||
/**
|
||||
* Error guidance prompts for different error scenarios (AI-1812)
|
||||
*/
|
||||
@@ -141,6 +169,7 @@ export function buildResponderPrompt(): string {
|
||||
return prompt()
|
||||
.section('role', RESPONDER_ROLE)
|
||||
.section('guardrails', GUARDRAILS)
|
||||
.section('execution_issue_handling', EXECUTION_ISSUE_HANDLING)
|
||||
.section('workflow_completion_responses', WORKFLOW_COMPLETION)
|
||||
.section('conversational_responses', CONVERSATIONAL_RESPONSES)
|
||||
.section('response_style', RESPONSE_STYLE)
|
||||
|
||||
@@ -19,12 +19,18 @@ import type { ParentGraphState } from '../parent-graph-state';
|
||||
// Tools (alphabetically ordered)
|
||||
import { createAddNodeTool } from '../tools/add-node.tool';
|
||||
import { createConnectNodesTool } from '../tools/connect-nodes.tool';
|
||||
import { createGetExecutionLogsTool } from '../tools/get-execution-logs.tool';
|
||||
import { createGetExecutionSchemaTool } from '../tools/get-execution-schema.tool';
|
||||
import { createGetExpressionDataMappingTool } from '../tools/get-expression-data-mapping.tool';
|
||||
import { createGetNodeContextTool } from '../tools/get-node-context.tool';
|
||||
import {
|
||||
createGetNodeConnectionExamplesTool,
|
||||
createGetNodeConfigurationExamplesTool,
|
||||
} from '../tools/get-node-examples.tool';
|
||||
import { createGetNodeParameterTool } from '../tools/get-node-parameter.tool';
|
||||
import { createGetResourceLocatorOptionsTool } from '../tools/get-resource-locator-options.tool';
|
||||
// Workflow context tools
|
||||
import { createGetWorkflowOverviewTool } from '../tools/get-workflow-overview.tool';
|
||||
import { createRemoveConnectionTool } from '../tools/remove-connection.tool';
|
||||
import { createRemoveNodeTool } from '../tools/remove-node.tool';
|
||||
import { createRenameNodeTool } from '../tools/rename-node.tool';
|
||||
@@ -40,6 +46,7 @@ import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
|
||||
import { applySubgraphCacheMarkers } from '../utils/cache-control';
|
||||
import {
|
||||
buildConversationContext,
|
||||
buildDiscoveryContextBlock,
|
||||
buildWorkflowJsonBlock,
|
||||
buildExecutionSchemaBlock,
|
||||
@@ -184,6 +191,13 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
),
|
||||
createGetNodeParameterTool(),
|
||||
createValidateConfigurationTool(config.parsedNodeTypes),
|
||||
// Execution data tools
|
||||
createGetExecutionSchemaTool(config.logger),
|
||||
createGetExecutionLogsTool(config.logger),
|
||||
createGetExpressionDataMappingTool(config.logger),
|
||||
// Workflow context tools
|
||||
createGetWorkflowOverviewTool(config.logger),
|
||||
createGetNodeContextTool(config.logger),
|
||||
// Conditionally add resource locator tool if callback is provided
|
||||
...(config.resourceLocatorCallback
|
||||
? [
|
||||
@@ -333,13 +347,25 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
// Build context parts
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// 1. User request (primary)
|
||||
// 1. Conversation context (history, original request, previous actions)
|
||||
// Supports UNDERSTANDING_CONTEXT prompt section for investigating issues
|
||||
const conversationContext = buildConversationContext(
|
||||
parentState.messages,
|
||||
parentState.coordinationLog,
|
||||
parentState.previousSummary,
|
||||
);
|
||||
if (conversationContext) {
|
||||
contextParts.push('=== CONVERSATION CONTEXT ===');
|
||||
contextParts.push(conversationContext);
|
||||
}
|
||||
|
||||
// 2. User request (primary)
|
||||
if (userRequest) {
|
||||
contextParts.push('=== USER REQUEST ===');
|
||||
contextParts.push(userRequest);
|
||||
}
|
||||
|
||||
// 2. Discovery context (what nodes to use)
|
||||
// 3. Discovery context (what nodes to use)
|
||||
// Include best practices only when template examples feature flag is enabled
|
||||
if (parentState.discoveryContext) {
|
||||
const includeBestPractices = this.config?.featureFlags?.templateExamples === true;
|
||||
@@ -349,7 +375,7 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check if this workflow came from a recovered builder recursion error (AI-1812)
|
||||
// 4. Check if this workflow came from a recovered builder recursion error (AI-1812)
|
||||
const builderErrorEntry = parentState.coordinationLog?.find((entry) => {
|
||||
if (entry.status !== 'error') return false;
|
||||
if (entry.phase !== 'builder') return false;
|
||||
@@ -368,7 +394,7 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
contextParts.push(buildRecoveryModeContext(nodeCount, nodeNames));
|
||||
}
|
||||
|
||||
// 4. Current workflow JSON (to add nodes to / configure)
|
||||
// 5. Current workflow JSON (to add nodes to / configure)
|
||||
contextParts.push('=== CURRENT WORKFLOW ===');
|
||||
if (parentState.workflowJSON.nodes.length > 0) {
|
||||
contextParts.push(buildWorkflowJsonBlock(parentState.workflowJSON));
|
||||
@@ -376,14 +402,14 @@ export class BuilderSubgraph extends BaseSubgraph<
|
||||
contextParts.push('Empty workflow - ready to build');
|
||||
}
|
||||
|
||||
// 5. Execution schema (data types available for parameter values)
|
||||
// 6. Execution schema (data types available for parameter values)
|
||||
const schemaBlock = buildExecutionSchemaBlock(parentState.workflowContext);
|
||||
if (schemaBlock) {
|
||||
contextParts.push('=== AVAILABLE DATA SCHEMA ===');
|
||||
contextParts.push(schemaBlock);
|
||||
}
|
||||
|
||||
// 6. Full execution context (data + schema for parameter values)
|
||||
// 7. Full execution context (data + schema for parameter values)
|
||||
contextParts.push('=== EXECUTION CONTEXT ===');
|
||||
contextParts.push(buildExecutionContextBlock(parentState.workflowContext));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage, AIMessage } from '@langchain/core/messages';
|
||||
import { isAIMessage } from '@langchain/core/messages';
|
||||
import { HumanMessage, 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';
|
||||
@@ -126,6 +126,12 @@ export const DiscoverySubgraphState = Annotation.Root({
|
||||
reducer: (x, y) => ({ ...x, ...y }),
|
||||
default: () => ({}),
|
||||
}),
|
||||
|
||||
// Retry count for when LLM fails to use tool calls properly
|
||||
toolCallRetryCount: Annotation<number>({
|
||||
reducer: (x, y) => y ?? x,
|
||||
default: () => 0,
|
||||
}),
|
||||
});
|
||||
|
||||
export interface DiscoverySubgraphConfig {
|
||||
@@ -206,14 +212,17 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
.addNode('agent', this.callAgent.bind(this))
|
||||
.addNode('tools', async (state) => await executeSubgraphTools(state, this.toolMap))
|
||||
.addNode('format_output', this.formatOutput.bind(this))
|
||||
.addNode('reprompt', this.repromptForToolCall.bind(this))
|
||||
.addEdge('__start__', 'agent')
|
||||
// Conditional: tools if has tool calls, format_output if submit called
|
||||
// Conditional: tools if has tool calls, format_output if submit called, reprompt if no tool calls
|
||||
.addConditionalEdges('agent', this.shouldContinue.bind(this), {
|
||||
tools: 'tools',
|
||||
format_output: 'format_output',
|
||||
end: END, // Fallback
|
||||
reprompt: 'reprompt',
|
||||
end: END, // Fallback after max retries
|
||||
})
|
||||
.addEdge('tools', 'agent') // After tools, go back to agent
|
||||
.addEdge('reprompt', 'agent') // After reprompt, try agent again
|
||||
.addEdge('format_output', END); // After formatting, END
|
||||
|
||||
return subgraph.compile();
|
||||
@@ -238,10 +247,40 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
return { messages: [response] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline flow control nodes to always include.
|
||||
* These handle common data transformation needs and are available in every workflow.
|
||||
* Reasoning is kept neutral - describes what the node does, not when/how to use it.
|
||||
*/
|
||||
private readonly BASELINE_NODES = [
|
||||
{ name: 'n8n-nodes-base.aggregate', reasoning: 'Combines multiple items into a single item' },
|
||||
{
|
||||
name: 'n8n-nodes-base.if',
|
||||
reasoning: 'Routes items to different output paths based on true/false condition evaluation',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.switch',
|
||||
reasoning: 'Routes items to different output paths based on rules or expression evaluation',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.splitOut',
|
||||
reasoning: 'Converts a single item containing an array field into multiple separate items',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.merge',
|
||||
reasoning: 'Combines data from multiple parallel input branches into a single output',
|
||||
},
|
||||
{
|
||||
name: 'n8n-nodes-base.set',
|
||||
reasoning: 'Transforms data by adding, modifying, or removing fields from items',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Format the output from the submit tool call
|
||||
* Hydrates availableResources for each node using node type definitions.
|
||||
*/
|
||||
// eslint-disable-next-line complexity
|
||||
private formatOutput(state: typeof DiscoverySubgraphState.State) {
|
||||
const lastMessage = state.messages.at(-1);
|
||||
let output: z.infer<typeof discoveryOutputSchema> | undefined;
|
||||
@@ -287,6 +326,27 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
};
|
||||
}
|
||||
|
||||
// Add baseline flow control nodes if not already discovered
|
||||
const discoveredNames = new Set(output.nodesFound.map((n) => n.nodeName));
|
||||
const baselineNodesToAdd = this.BASELINE_NODES.filter((bn) => !discoveredNames.has(bn.name));
|
||||
|
||||
// Look up versions for baseline nodes
|
||||
for (const baselineNode of baselineNodesToAdd) {
|
||||
const nodeType = this.parsedNodeTypes.find((nt) => nt.name === baselineNode.name);
|
||||
if (nodeType) {
|
||||
const version = Array.isArray(nodeType.version)
|
||||
? Math.max(...nodeType.version)
|
||||
: nodeType.version;
|
||||
|
||||
output.nodesFound.push({
|
||||
nodeName: baselineNode.name,
|
||||
version,
|
||||
reasoning: baselineNode.reasoning,
|
||||
connectionChangingParameters: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build lookup map for resource hydration
|
||||
const nodeTypeMap = new Map<string, INodeTypeDescription>();
|
||||
for (const nt of this.parsedNodeTypes) {
|
||||
@@ -371,15 +431,49 @@ export class DiscoverySubgraph extends BaseSubgraph<
|
||||
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] Agent stopped without calling submit_discovery_results - check if LLM is producing valid tool calls',
|
||||
// No tool calls = agent may have output text instead of using tool calling API
|
||||
// This can happen when the model outputs XML-style invocations as text
|
||||
// Allow one retry to reprompt the agent to use proper tool calls
|
||||
const MAX_TOOL_CALL_RETRIES = 1;
|
||||
if (state.toolCallRetryCount < MAX_TOOL_CALL_RETRIES) {
|
||||
this.logger?.warn(
|
||||
'[Discovery] Agent stopped without tool calls - will reprompt to use submit_discovery_results tool',
|
||||
{
|
||||
retryCount: state.toolCallRetryCount,
|
||||
lastMessageContent:
|
||||
typeof lastMessage?.content === 'string'
|
||||
? lastMessage.content.substring(0, 200)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
return 'reprompt';
|
||||
}
|
||||
|
||||
// Max retries exceeded - give up
|
||||
this.logger?.error(
|
||||
'[Discovery] Agent failed to use tool calls after retry - check if LLM is producing valid tool calls',
|
||||
{
|
||||
retryCount: state.toolCallRetryCount,
|
||||
},
|
||||
);
|
||||
return 'end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprompt the agent to use the tool calling API instead of text output
|
||||
*/
|
||||
private repromptForToolCall(state: typeof DiscoverySubgraphState.State) {
|
||||
const repromptMessage = new HumanMessage({
|
||||
content:
|
||||
'You must use the submit_discovery_results tool to submit your results. Do not output the results as text or XML - use the actual tool call. The downstream system can only process results submitted via the tool calling API, not text output. Please call the submit_discovery_results tool now with your nodesFound array.',
|
||||
});
|
||||
|
||||
return {
|
||||
messages: [repromptMessage],
|
||||
toolCallRetryCount: state.toolCallRetryCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
transformInput(parentState: typeof ParentGraphState.State) {
|
||||
const userRequest = extractUserRequest(parentState.messages, 'Build a workflow');
|
||||
|
||||
|
||||
@@ -6,8 +6,13 @@ import type { BuilderFeatureFlags } from '@/workflow-builder-agent';
|
||||
import { getAddNodeToolBase } from './add-node.tool';
|
||||
import { CONNECT_NODES_TOOL } from './connect-nodes.tool';
|
||||
import { GET_DOCUMENTATION_TOOL } from './get-documentation.tool';
|
||||
import { GET_EXECUTION_LOGS_TOOL } from './get-execution-logs.tool';
|
||||
import { GET_EXECUTION_SCHEMA_TOOL } from './get-execution-schema.tool';
|
||||
import { GET_EXPRESSION_DATA_MAPPING_TOOL } from './get-expression-data-mapping.tool';
|
||||
import { GET_NODE_CONTEXT_TOOL } from './get-node-context.tool';
|
||||
import { GET_NODE_PARAMETER_TOOL } from './get-node-parameter.tool';
|
||||
import { GET_WORKFLOW_EXAMPLES_TOOL } from './get-workflow-examples.tool';
|
||||
import { GET_WORKFLOW_OVERVIEW_TOOL } from './get-workflow-overview.tool';
|
||||
import { NODE_DETAILS_TOOL } from './node-details.tool';
|
||||
import { NODE_SEARCH_TOOL } from './node-search.tool';
|
||||
import { REMOVE_CONNECTION_TOOL } from './remove-connection.tool';
|
||||
@@ -50,6 +55,12 @@ export function getBuilderToolsForDisplay({
|
||||
GET_NODE_PARAMETER_TOOL,
|
||||
VALIDATE_STRUCTURE_TOOL,
|
||||
VALIDATE_CONFIGURATION_TOOL,
|
||||
GET_EXECUTION_SCHEMA_TOOL,
|
||||
GET_EXECUTION_LOGS_TOOL,
|
||||
GET_EXPRESSION_DATA_MAPPING_TOOL,
|
||||
// Workflow context tools
|
||||
GET_WORKFLOW_OVERVIEW_TOOL,
|
||||
GET_NODE_CONTEXT_TOOL,
|
||||
);
|
||||
|
||||
return tools;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { IRunData } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import type { GetExecutionLogsOutput } from '../types/tools';
|
||||
import type { BuilderTool, BuilderToolBase } from '../utils/stream-processor';
|
||||
import { truncateJson } from '../utils/truncate-json';
|
||||
import { createProgressReporter } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting execution logs';
|
||||
|
||||
/**
|
||||
* Schema for getting execution logs
|
||||
*/
|
||||
const getExecutionLogsSchema = z.object({
|
||||
nodeName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: Filter to get execution logs for a specific node only'),
|
||||
});
|
||||
|
||||
function formatExecutionLogs(
|
||||
runData: IRunData | undefined,
|
||||
error: unknown,
|
||||
lastNodeExecuted: string | undefined,
|
||||
nodeName?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add error information if present
|
||||
if (error) {
|
||||
parts.push('<execution_error>');
|
||||
if (lastNodeExecuted) {
|
||||
parts.push(` <last_node_executed>${lastNodeExecuted}</last_node_executed>`);
|
||||
}
|
||||
parts.push(' <error_details>');
|
||||
parts.push(truncateJson(error));
|
||||
parts.push(' </error_details>');
|
||||
parts.push('</execution_error>');
|
||||
}
|
||||
|
||||
// Add run data
|
||||
if (runData && Object.keys(runData).length > 0) {
|
||||
parts.push('<execution_run_data>');
|
||||
|
||||
const filtered = nodeName
|
||||
? Object.fromEntries(Object.entries(runData).filter(([key]) => key === nodeName))
|
||||
: runData;
|
||||
|
||||
if (Object.keys(filtered).length > 0) {
|
||||
parts.push(truncateJson(filtered));
|
||||
} else if (nodeName) {
|
||||
parts.push(`No execution data found for node "${nodeName}"`);
|
||||
}
|
||||
|
||||
parts.push('</execution_run_data>');
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return nodeName
|
||||
? `No execution logs found for node "${nodeName}"`
|
||||
: 'No execution logs available. The workflow may not have been executed yet.';
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_EXECUTION_LOGS_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_execution_logs',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get execution logs tool
|
||||
*/
|
||||
export function createGetExecutionLogsTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_EXECUTION_LOGS_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getExecutionLogsSchema.parse(input);
|
||||
const { nodeName } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get current state
|
||||
const state = getWorkflowState();
|
||||
const executionData = state.workflowContext?.executionData;
|
||||
|
||||
const runData = executionData?.runData;
|
||||
const error = executionData?.error;
|
||||
const lastNodeExecuted = executionData?.lastNodeExecuted;
|
||||
|
||||
logger?.debug(
|
||||
`Getting execution logs${nodeName ? ` for node ${nodeName}` : ''}, hasError: ${!!error}, runData nodes: ${runData ? Object.keys(runData).length : 0}`,
|
||||
);
|
||||
|
||||
// Format the response
|
||||
const formattedLogs = formatExecutionLogs(runData, error, lastNodeExecuted, nodeName);
|
||||
|
||||
const nodeCount = runData ? Object.keys(runData).length : 0;
|
||||
const output: GetExecutionLogsOutput = {
|
||||
hasError: !!error,
|
||||
lastNodeExecuted,
|
||||
nodesWithData: nodeCount,
|
||||
message:
|
||||
nodeCount > 0 || error ? 'Execution logs retrieved' : 'No execution logs available',
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
// Return success response
|
||||
return createSuccessResponse(config, formattedLogs);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
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 : 'Unknown error occurred',
|
||||
{
|
||||
toolName: GET_EXECUTION_LOGS_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_EXECUTION_LOGS_TOOL.toolName,
|
||||
description:
|
||||
'Get the execution logs including run data and error information from the last workflow execution. Use this to debug workflow errors or understand execution results.',
|
||||
schema: getExecutionLogsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_EXECUTION_LOGS_TOOL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import type { GetExecutionSchemaOutput } from '../types/tools';
|
||||
import type { BuilderTool, BuilderToolBase } from '../utils/stream-processor';
|
||||
import { truncateJson } from '../utils/truncate-json';
|
||||
import { createProgressReporter } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting execution schema';
|
||||
|
||||
/**
|
||||
* Schema for getting execution schema
|
||||
*/
|
||||
const getExecutionSchemaSchema = z.object({
|
||||
nodeName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: Filter to get schema for a specific node only'),
|
||||
});
|
||||
|
||||
function formatExecutionSchema(
|
||||
schema: Array<{ nodeName: string; schema: unknown }>,
|
||||
nodeName?: string,
|
||||
): string {
|
||||
const filtered = nodeName ? schema.filter((s) => s.nodeName === nodeName) : schema;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return nodeName
|
||||
? `No execution schema found for node "${nodeName}"`
|
||||
: 'No execution schema available';
|
||||
}
|
||||
|
||||
const parts = ['<execution_schema>'];
|
||||
parts.push(truncateJson(filtered));
|
||||
parts.push('</execution_schema>');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_EXECUTION_SCHEMA_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_execution_schema',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get execution schema tool
|
||||
*/
|
||||
export function createGetExecutionSchemaTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_EXECUTION_SCHEMA_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getExecutionSchemaSchema.parse(input);
|
||||
const { nodeName } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get current state
|
||||
const state = getWorkflowState();
|
||||
const executionSchema = state.workflowContext?.executionSchema ?? [];
|
||||
|
||||
logger?.debug(
|
||||
`Getting execution schema${nodeName ? ` for node ${nodeName}` : ''}, found ${executionSchema.length} entries`,
|
||||
);
|
||||
|
||||
// Format the response
|
||||
const formattedSchema = formatExecutionSchema(executionSchema, nodeName);
|
||||
|
||||
const output: GetExecutionSchemaOutput = {
|
||||
found: executionSchema.length > 0,
|
||||
count: nodeName
|
||||
? executionSchema.filter((s) => s.nodeName === nodeName).length
|
||||
: executionSchema.length,
|
||||
message:
|
||||
executionSchema.length > 0 ? 'Execution schema retrieved' : 'No schema available',
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
// Return success response
|
||||
return createSuccessResponse(config, formattedSchema);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
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 : 'Unknown error occurred',
|
||||
{
|
||||
toolName: GET_EXECUTION_SCHEMA_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_EXECUTION_SCHEMA_TOOL.toolName,
|
||||
description:
|
||||
'Get the execution schema showing the output data structure from the last workflow execution. Returns the raw n8n schema format with node names and their output schemas. Use this to understand what fields are available from each node.',
|
||||
schema: getExecutionSchemaSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_EXECUTION_SCHEMA_TOOL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import type { GetExpressionDataMappingOutput } from '../types/tools';
|
||||
import type { BuilderTool, BuilderToolBase } from '../utils/stream-processor';
|
||||
import { truncateJson } from '../utils/truncate-json';
|
||||
import type { ExpressionValue } from '../workflow-builder-agent';
|
||||
import { createProgressReporter } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting expression data mapping';
|
||||
|
||||
/**
|
||||
* Schema for getting expression data mapping
|
||||
*/
|
||||
const getExpressionDataMappingSchema = z.object({
|
||||
nodeName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: Filter to get expression values for a specific node only'),
|
||||
});
|
||||
|
||||
function formatExpressionValues(
|
||||
expressionValues: Record<string, ExpressionValue[]> | undefined,
|
||||
nodeName?: string,
|
||||
): string {
|
||||
if (!expressionValues || Object.keys(expressionValues).length === 0) {
|
||||
return nodeName
|
||||
? `No expression data mapping found for node "${nodeName}"`
|
||||
: 'No expression data mapping available. The workflow may not have expressions or has not been executed.';
|
||||
}
|
||||
|
||||
const filtered = nodeName
|
||||
? Object.fromEntries(Object.entries(expressionValues).filter(([key]) => key === nodeName))
|
||||
: expressionValues;
|
||||
|
||||
if (Object.keys(filtered).length === 0) {
|
||||
return `No expression data mapping found for node "${nodeName}"`;
|
||||
}
|
||||
|
||||
const parts = ['<expression_data_mapping>'];
|
||||
parts.push(truncateJson(filtered));
|
||||
parts.push('</expression_data_mapping>');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_EXPRESSION_DATA_MAPPING_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_expression_data_mapping',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get expression data mapping tool
|
||||
*/
|
||||
export function createGetExpressionDataMappingTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_EXPRESSION_DATA_MAPPING_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getExpressionDataMappingSchema.parse(input);
|
||||
const { nodeName } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get current state
|
||||
const state = getWorkflowState();
|
||||
const expressionValues = state.workflowContext?.expressionValues;
|
||||
|
||||
const nodeCount = expressionValues ? Object.keys(expressionValues).length : 0;
|
||||
logger?.debug(
|
||||
`Getting expression data mapping${nodeName ? ` for node ${nodeName}` : ''}, found ${nodeCount} nodes with expressions`,
|
||||
);
|
||||
|
||||
// Format the response
|
||||
const formattedMapping = formatExpressionValues(expressionValues, nodeName);
|
||||
|
||||
const output: GetExpressionDataMappingOutput = {
|
||||
found: nodeCount > 0,
|
||||
nodesWithExpressions: nodeCount,
|
||||
message: nodeCount > 0 ? 'Expression data mapping retrieved' : 'No expressions found',
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
// Return success response
|
||||
return createSuccessResponse(config, formattedMapping);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
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 : 'Unknown error occurred',
|
||||
{
|
||||
toolName: GET_EXPRESSION_DATA_MAPPING_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_EXPRESSION_DATA_MAPPING_TOOL.toolName,
|
||||
description:
|
||||
'Get the resolved expression values from the last workflow execution. Shows what data was used in expressions like {{ $json.fieldName }} and their resolved values.',
|
||||
schema: getExpressionDataMappingSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_EXPRESSION_DATA_MAPPING_TOOL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { IConnections, NodeConnectionType } from 'n8n-workflow';
|
||||
import { isNodeConnectionType, mapConnectionsByDestination } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { isTriggerNodeType } from '../utils/node-helpers';
|
||||
import type { BuilderTool, BuilderToolBase } from '../utils/stream-processor';
|
||||
import { truncateJson } from '../utils/truncate-json';
|
||||
import { createProgressReporter } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getEffectiveWorkflow, getWorkflowState } from './helpers/state';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting node context';
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a Record<string, unknown>
|
||||
*/
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely gets node run data from execution data
|
||||
*/
|
||||
function getNodeRunData(
|
||||
executionData: Record<string, unknown> | undefined,
|
||||
nodeName: string,
|
||||
): unknown {
|
||||
if (!executionData || !isRecord(executionData.runData)) {
|
||||
return undefined;
|
||||
}
|
||||
return executionData.runData[nodeName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for getting node context
|
||||
*/
|
||||
const getNodeContextSchema = z.object({
|
||||
nodeName: z.string().describe('The name of the node to get context for'),
|
||||
includeExecutionData: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe('Include execution data for this node if available'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Output type for get node context tool
|
||||
*/
|
||||
export interface GetNodeContextOutput {
|
||||
found: boolean;
|
||||
nodeName: string;
|
||||
nodeType?: string;
|
||||
parentCount: number;
|
||||
childCount: number;
|
||||
hasExecutionData: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node classification types
|
||||
*/
|
||||
type NodeClassification = 'trigger' | 'regular' | 'ai_parent' | 'ai_subnode';
|
||||
|
||||
/**
|
||||
* Classifies a node based on its type and connections
|
||||
*/
|
||||
function classifyNode(
|
||||
nodeType: string,
|
||||
hasAiInputs: boolean,
|
||||
hasAiOutputs: boolean,
|
||||
): NodeClassification {
|
||||
// Check if it's a trigger node
|
||||
if (isTriggerNodeType(nodeType)) {
|
||||
return 'trigger';
|
||||
}
|
||||
|
||||
// Check if it's an AI parent (receives AI connections)
|
||||
if (hasAiInputs) {
|
||||
return 'ai_parent';
|
||||
}
|
||||
|
||||
// Check if it's an AI subnode (sends AI connections)
|
||||
if (hasAiOutputs) {
|
||||
return 'ai_subnode';
|
||||
}
|
||||
|
||||
return 'regular';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets direct parents of a node using inverted connections
|
||||
*/
|
||||
function getParentConnections(
|
||||
nodeName: string,
|
||||
connectionsByDestination: IConnections,
|
||||
): Array<{ node: string; type: NodeConnectionType }> {
|
||||
const nodeConns = connectionsByDestination[nodeName];
|
||||
if (!nodeConns) return [];
|
||||
|
||||
const parents: Array<{ node: string; type: NodeConnectionType }> = [];
|
||||
|
||||
for (const [type, typeConns] of Object.entries(nodeConns)) {
|
||||
if (!isNodeConnectionType(type) || !typeConns) continue;
|
||||
|
||||
for (const connArray of typeConns) {
|
||||
if (!connArray) continue;
|
||||
for (const conn of connArray) {
|
||||
parents.push({ node: conn.node, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets direct children of a node from connections
|
||||
*/
|
||||
function getChildConnections(
|
||||
nodeName: string,
|
||||
connections: IConnections,
|
||||
): Array<{ node: string; type: NodeConnectionType }> {
|
||||
const nodeConns = connections[nodeName];
|
||||
if (!nodeConns) return [];
|
||||
|
||||
const children: Array<{ node: string; type: NodeConnectionType }> = [];
|
||||
|
||||
for (const [type, typeConns] of Object.entries(nodeConns)) {
|
||||
if (!isNodeConnectionType(type) || !typeConns) continue;
|
||||
|
||||
for (const connArray of typeConns) {
|
||||
if (!connArray) continue;
|
||||
for (const conn of connArray) {
|
||||
children.push({ node: conn.node, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the formatted node context output
|
||||
*/
|
||||
function buildNodeContext(
|
||||
node: SimpleWorkflow['nodes'][number],
|
||||
workflow: SimpleWorkflow,
|
||||
includeExecutionData: boolean,
|
||||
executionSchema: Array<{ nodeName: string; schema: unknown }>,
|
||||
executionData: Record<string, unknown> | undefined,
|
||||
): string {
|
||||
const parts: string[] = [`<node_context name="${node.name}" id="${node.id}">`];
|
||||
|
||||
// Basic node info
|
||||
parts.push(`ID: ${node.id}`);
|
||||
parts.push(`Type: ${node.type}`);
|
||||
if (node.typeVersion) {
|
||||
parts.push(`Version: ${node.typeVersion}`);
|
||||
}
|
||||
|
||||
// Get connection info
|
||||
const connectionsByDestination = mapConnectionsByDestination(workflow.connections);
|
||||
const parents = getParentConnections(node.name, connectionsByDestination);
|
||||
const children = getChildConnections(node.name, workflow.connections);
|
||||
|
||||
// Check for AI connections
|
||||
const hasAiInputs = parents.some((p) => p.type !== 'main');
|
||||
const hasAiOutputs = children.some((c) => c.type !== 'main');
|
||||
|
||||
// Classification
|
||||
const classification = classifyNode(node.type, hasAiInputs, hasAiOutputs);
|
||||
parts.push(`Classification: ${classification}`);
|
||||
|
||||
// Parent nodes
|
||||
parts.push('');
|
||||
if (parents.length > 0) {
|
||||
parts.push('Parent nodes (upstream):');
|
||||
for (const parent of parents) {
|
||||
if (parent.type === 'main') {
|
||||
parts.push(` ← ${parent.node}`);
|
||||
} else {
|
||||
parts.push(` ←[${parent.type}] ${parent.node}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts.push('Parent nodes: None (this is a start node)');
|
||||
}
|
||||
|
||||
// Child nodes
|
||||
parts.push('');
|
||||
if (children.length > 0) {
|
||||
parts.push('Child nodes (downstream):');
|
||||
for (const child of children) {
|
||||
if (child.type === 'main') {
|
||||
parts.push(` → ${child.node}`);
|
||||
} else {
|
||||
parts.push(` -[${child.type}]-> ${child.node}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parts.push('Child nodes: None (this is an end node)');
|
||||
}
|
||||
|
||||
// Parameters
|
||||
parts.push('');
|
||||
parts.push('Parameters:');
|
||||
if (node.parameters && Object.keys(node.parameters).length > 0) {
|
||||
parts.push(truncateJson(node.parameters));
|
||||
} else {
|
||||
parts.push(' (no parameters set)');
|
||||
}
|
||||
|
||||
// Execution data
|
||||
if (includeExecutionData) {
|
||||
parts.push('');
|
||||
|
||||
// Schema from executionSchema
|
||||
const nodeSchema = executionSchema.find((s) => s.nodeName === node.name);
|
||||
if (nodeSchema) {
|
||||
parts.push('Output schema (from last execution):');
|
||||
parts.push(truncateJson(nodeSchema.schema));
|
||||
}
|
||||
|
||||
// RunData from executionData
|
||||
const nodeRunData = getNodeRunData(executionData, node.name);
|
||||
if (nodeRunData) {
|
||||
parts.push('');
|
||||
parts.push('Execution data (from last run):');
|
||||
parts.push(truncateJson(nodeRunData));
|
||||
}
|
||||
|
||||
if (!nodeSchema && !nodeRunData) {
|
||||
parts.push('No execution data available for this node');
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('</node_context>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_NODE_CONTEXT_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_node_context',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get node context tool
|
||||
*/
|
||||
export function createGetNodeContextTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_NODE_CONTEXT_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getNodeContextSchema.parse(input);
|
||||
const { nodeName, includeExecutionData } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get effective workflow (includes pending operations from this turn)
|
||||
const workflow = getEffectiveWorkflow();
|
||||
// Get state for execution context (not affected by pending operations)
|
||||
const state = getWorkflowState();
|
||||
|
||||
// Find the node
|
||||
const node = workflow.nodes.find((n) => n.name === nodeName);
|
||||
if (!node) {
|
||||
const output: GetNodeContextOutput = {
|
||||
found: false,
|
||||
nodeName,
|
||||
parentCount: 0,
|
||||
childCount: 0,
|
||||
hasExecutionData: false,
|
||||
message: `Node "${nodeName}" not found in workflow`,
|
||||
};
|
||||
reporter.complete(output);
|
||||
return createSuccessResponse(
|
||||
config,
|
||||
`Node "${nodeName}" not found. Available nodes: ${workflow.nodes.map((n) => n.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get execution context
|
||||
const executionSchema = state.workflowContext?.executionSchema ?? [];
|
||||
const executionData = state.workflowContext?.executionData;
|
||||
|
||||
logger?.debug(`Getting context for node "${nodeName}" (${node.type})`);
|
||||
|
||||
// Build the context
|
||||
const formattedContext = buildNodeContext(
|
||||
node,
|
||||
workflow,
|
||||
includeExecutionData,
|
||||
executionSchema,
|
||||
executionData,
|
||||
);
|
||||
|
||||
// Calculate connection counts
|
||||
const connectionsByDestination = mapConnectionsByDestination(workflow.connections);
|
||||
const parents = getParentConnections(nodeName, connectionsByDestination);
|
||||
const children = getChildConnections(nodeName, workflow.connections);
|
||||
|
||||
const nodeSchema = executionSchema.find((s) => s.nodeName === nodeName);
|
||||
const nodeRunData = getNodeRunData(executionData, nodeName);
|
||||
|
||||
const output: GetNodeContextOutput = {
|
||||
found: true,
|
||||
nodeName,
|
||||
nodeType: node.type,
|
||||
parentCount: parents.length,
|
||||
childCount: children.length,
|
||||
hasExecutionData: nodeSchema !== undefined || nodeRunData !== undefined,
|
||||
message: `Found node "${nodeName}" with ${parents.length} parents and ${children.length} children`,
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
return createSuccessResponse(config, formattedContext);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
if (error instanceof z.ZodError) {
|
||||
const validationError = new ValidationError('Invalid input parameters', {
|
||||
extra: { errors: error.errors },
|
||||
});
|
||||
reporter.error(validationError);
|
||||
return createErrorResponse(config, validationError);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `${error.message}${error.stack ? `\n${error.stack}` : ''}`
|
||||
: 'Unknown error occurred';
|
||||
const toolError = new ToolExecutionError(errorMessage, {
|
||||
toolName: GET_NODE_CONTEXT_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_NODE_CONTEXT_TOOL.toolName,
|
||||
description:
|
||||
'Get full context for a specific node including its parameters, parent nodes (upstream), child nodes (downstream), classification, and execution data. Use this before configuring a node to understand its current state and connections.',
|
||||
schema: getNodeContextSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_NODE_CONTEXT_TOOL,
|
||||
};
|
||||
}
|
||||
@@ -5,11 +5,7 @@ import type { INode, NodeParameterValueType } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MAX_PARAMETER_VALUE_LENGTH } from '@/constants';
|
||||
import {
|
||||
createNodeParameterTooLargeError,
|
||||
getCurrentWorkflow,
|
||||
getWorkflowState,
|
||||
} from '@/tools/helpers';
|
||||
import { createNodeParameterTooLargeError, getEffectiveWorkflow } from '@/tools/helpers';
|
||||
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
@@ -86,9 +82,8 @@ export function createGetNodeParameterTool(logger?: Logger): BuilderTool {
|
||||
logger?.debug(`Looking up parameter ${path} for ${nodeId}...`);
|
||||
reportProgress(reporter, `Looking up parameter ${path} for ${nodeId}...`);
|
||||
|
||||
// Get current state
|
||||
const state = getWorkflowState();
|
||||
const workflow = getCurrentWorkflow(state);
|
||||
// Get effective workflow (includes pending operations from this turn)
|
||||
const workflow = getEffectiveWorkflow();
|
||||
|
||||
// Find the node
|
||||
const node = validateNodeExists(nodeId, workflow.nodes);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ValidationError, ToolExecutionError } from '../errors';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import { isTriggerNodeType } from '../utils/node-helpers';
|
||||
import type { BuilderTool, BuilderToolBase } from '../utils/stream-processor';
|
||||
import { truncateJson } from '../utils/truncate-json';
|
||||
import { createProgressReporter } from './helpers/progress';
|
||||
import { createSuccessResponse, createErrorResponse } from './helpers/response';
|
||||
import { getEffectiveWorkflow } from './helpers/state';
|
||||
import { mermaidStringify } from './utils/mermaid.utils';
|
||||
|
||||
const DISPLAY_TITLE = 'Getting workflow overview';
|
||||
|
||||
/**
|
||||
* Schema for getting workflow overview
|
||||
*/
|
||||
const getWorkflowOverviewSchema = z.object({
|
||||
format: z
|
||||
.enum(['mermaid', 'summary'])
|
||||
.optional()
|
||||
.default('mermaid')
|
||||
.describe('Output format: mermaid (visual diagram) or summary (text list)'),
|
||||
includeParameters: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(
|
||||
'Include node parameters in the output (recommended to identify configuration issues)',
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Output type for get workflow overview tool
|
||||
*/
|
||||
export interface GetWorkflowOverviewOutput {
|
||||
nodeCount: number;
|
||||
connectionCount: number;
|
||||
hasTrigger: boolean;
|
||||
format: 'mermaid' | 'summary';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the trigger node in a workflow
|
||||
*/
|
||||
function findTriggerNode(
|
||||
nodes: Array<{ id: string; name: string; type: string }>,
|
||||
): { id: string; name: string; type: string } | undefined {
|
||||
return nodes.find((n) => isTriggerNodeType(n.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts connections in the workflow
|
||||
*/
|
||||
function countConnections(connections: Record<string, unknown>): number {
|
||||
let count = 0;
|
||||
for (const sourceConns of Object.values(connections)) {
|
||||
if (typeof sourceConns === 'object' && sourceConns !== null) {
|
||||
for (const typeConns of Object.values(sourceConns)) {
|
||||
if (Array.isArray(typeConns)) {
|
||||
for (const connArray of typeConns) {
|
||||
if (Array.isArray(connArray)) {
|
||||
count += connArray.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a summary format output with node IDs
|
||||
*/
|
||||
function buildSummaryFormat(
|
||||
nodes: Array<{ id: string; name: string; type: string; parameters?: Record<string, unknown> }>,
|
||||
triggerNode: { id: string; name: string; type: string } | undefined,
|
||||
includeParameters: boolean,
|
||||
): string {
|
||||
const parts: string[] = ['<workflow_summary>'];
|
||||
|
||||
// Metadata
|
||||
parts.push(`Node count: ${nodes.length}`);
|
||||
if (triggerNode) {
|
||||
parts.push(`Trigger: ${triggerNode.name} [id: ${triggerNode.id}] (${triggerNode.type})`);
|
||||
} else {
|
||||
parts.push('Trigger: None');
|
||||
}
|
||||
|
||||
// Node list with IDs
|
||||
parts.push('');
|
||||
parts.push('Nodes:');
|
||||
for (const node of nodes) {
|
||||
const nodeHeader = `- ${node.name} [id: ${node.id}] (${node.type})`;
|
||||
if (includeParameters && node.parameters && Object.keys(node.parameters).length > 0) {
|
||||
parts.push(nodeHeader);
|
||||
parts.push(` Parameters: ${truncateJson(node.parameters, { indent: 0 })}`);
|
||||
} else {
|
||||
parts.push(nodeHeader);
|
||||
}
|
||||
}
|
||||
|
||||
parts.push('</workflow_summary>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Mermaid diagram output with node IDs embedded in comments
|
||||
*/
|
||||
function buildMermaidFormat(
|
||||
workflow: SimpleWorkflow,
|
||||
triggerNode: { id: string; name: string; type: string } | undefined,
|
||||
includeParameters: boolean,
|
||||
): string {
|
||||
const parts: string[] = ['<workflow_overview>'];
|
||||
|
||||
// Metadata
|
||||
parts.push(`Node count: ${workflow.nodes.length}`);
|
||||
if (triggerNode) {
|
||||
parts.push(`Trigger: ${triggerNode.name} (${triggerNode.type})`);
|
||||
} else {
|
||||
parts.push('Trigger: None');
|
||||
}
|
||||
|
||||
// Mermaid diagram - node IDs are embedded in the comment lines
|
||||
parts.push('');
|
||||
const mermaid = mermaidStringify(
|
||||
{ workflow },
|
||||
{
|
||||
includeNodeType: true,
|
||||
includeNodeParameters: includeParameters,
|
||||
includeNodeName: true,
|
||||
},
|
||||
);
|
||||
parts.push(mermaid);
|
||||
|
||||
parts.push('</workflow_overview>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const GET_WORKFLOW_OVERVIEW_TOOL: BuilderToolBase = {
|
||||
toolName: 'get_workflow_overview',
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create the get workflow overview tool
|
||||
*/
|
||||
export function createGetWorkflowOverviewTool(logger?: Logger): BuilderTool {
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
GET_WORKFLOW_OVERVIEW_TOOL.toolName,
|
||||
DISPLAY_TITLE,
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = getWorkflowOverviewSchema.parse(input);
|
||||
const { format, includeParameters } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get effective workflow (includes pending operations from this turn)
|
||||
const workflow = getEffectiveWorkflow();
|
||||
|
||||
if (!workflow || workflow.nodes.length === 0) {
|
||||
const output: GetWorkflowOverviewOutput = {
|
||||
nodeCount: 0,
|
||||
connectionCount: 0,
|
||||
hasTrigger: false,
|
||||
format,
|
||||
message: 'Empty workflow',
|
||||
};
|
||||
reporter.complete(output);
|
||||
return createSuccessResponse(config, 'Empty workflow - no nodes to display');
|
||||
}
|
||||
|
||||
const triggerNode = findTriggerNode(workflow.nodes);
|
||||
const connectionCount = countConnections(workflow.connections);
|
||||
|
||||
logger?.debug(
|
||||
`Getting workflow overview: ${workflow.nodes.length} nodes, ${connectionCount} connections, format: ${format}`,
|
||||
);
|
||||
|
||||
// Build output based on format
|
||||
let formattedOutput: string;
|
||||
if (format === 'summary') {
|
||||
formattedOutput = buildSummaryFormat(workflow.nodes, triggerNode, includeParameters);
|
||||
} else {
|
||||
formattedOutput = buildMermaidFormat(workflow, triggerNode, includeParameters);
|
||||
}
|
||||
|
||||
const output: GetWorkflowOverviewOutput = {
|
||||
nodeCount: workflow.nodes.length,
|
||||
connectionCount,
|
||||
hasTrigger: !!triggerNode,
|
||||
format,
|
||||
message: `Workflow has ${workflow.nodes.length} nodes with ${connectionCount} connections`,
|
||||
};
|
||||
reporter.complete(output);
|
||||
|
||||
return createSuccessResponse(config, formattedOutput);
|
||||
} catch (error) {
|
||||
// Handle validation or unexpected errors
|
||||
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 : 'Unknown error occurred',
|
||||
{
|
||||
toolName: GET_WORKFLOW_OVERVIEW_TOOL.toolName,
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
},
|
||||
);
|
||||
reporter.error(toolError);
|
||||
return createErrorResponse(config, toolError);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: GET_WORKFLOW_OVERVIEW_TOOL.toolName,
|
||||
description:
|
||||
'Get a high-level overview of the current workflow including a Mermaid flowchart diagram showing node connections and flow. Use this to understand the overall workflow structure before making changes.',
|
||||
schema: getWorkflowOverviewSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
...GET_WORKFLOW_OVERVIEW_TOOL,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
import type { INode, IConnection } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '../../types/workflow';
|
||||
import { applyOperations } from '../../utils/operations-processor';
|
||||
import type { WorkflowState } from '../../workflow-state';
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,27 @@ export function getCurrentWorkflowFromTaskInput(): SimpleWorkflow {
|
||||
return getCurrentWorkflow(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective workflow state including pending operations.
|
||||
*
|
||||
* Within a single agent turn, tools queue operations that are applied after all tools complete.
|
||||
* This means read tools would see stale state if they just read workflowJSON directly.
|
||||
* This function applies any pending operations to return the "effective" current state,
|
||||
* so read tools see the result of writes made earlier in the same turn.
|
||||
*/
|
||||
export function getEffectiveWorkflow(): SimpleWorkflow {
|
||||
const state = getWorkflowState();
|
||||
const pending = state.workflowOperations;
|
||||
|
||||
if (!pending || pending.length === 0) {
|
||||
return state.workflowJSON;
|
||||
}
|
||||
|
||||
// Apply pending operations to get effective state
|
||||
// Use structuredClone to avoid mutating the original state
|
||||
return applyOperations(structuredClone(state.workflowJSON), pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a state update for workflow connections
|
||||
*/
|
||||
|
||||
@@ -95,6 +95,41 @@ jest.mock('../validate-configuration.tool', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../get-execution-schema.tool', () => ({
|
||||
GET_EXECUTION_SCHEMA_TOOL: {
|
||||
toolName: 'get_execution_schema',
|
||||
displayTitle: 'Getting execution schema',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../get-execution-logs.tool', () => ({
|
||||
GET_EXECUTION_LOGS_TOOL: {
|
||||
toolName: 'get_execution_logs',
|
||||
displayTitle: 'Getting execution logs',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../get-expression-data-mapping.tool', () => ({
|
||||
GET_EXPRESSION_DATA_MAPPING_TOOL: {
|
||||
toolName: 'get_expression_data_mapping',
|
||||
displayTitle: 'Getting expression data mapping',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../get-workflow-overview.tool', () => ({
|
||||
GET_WORKFLOW_OVERVIEW_TOOL: {
|
||||
toolName: 'get_workflow_overview',
|
||||
displayTitle: 'Getting workflow overview',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../get-node-context.tool', () => ({
|
||||
GET_NODE_CONTEXT_TOOL: {
|
||||
toolName: 'get_node_context',
|
||||
displayTitle: 'Getting node context',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('builder-tools', () => {
|
||||
let parsedNodeTypes: INodeTypeDescription[];
|
||||
|
||||
@@ -110,10 +145,12 @@ describe('builder-tools', () => {
|
||||
featureFlags: { templateExamples: true },
|
||||
});
|
||||
|
||||
// 13 tools: best_practices, workflow_examples, node_search, node_details, add_node,
|
||||
// 18 tools: best_practices, workflow_examples, node_search, node_details, add_node,
|
||||
// connect_nodes, remove_connection, remove_node, rename_node, update_node_parameters,
|
||||
// get_node_parameter, validate_structure, validate_configuration
|
||||
expect(tools).toHaveLength(13);
|
||||
// get_node_parameter, validate_structure, validate_configuration,
|
||||
// get_execution_schema, get_execution_logs, get_expression_data_mapping,
|
||||
// get_workflow_overview, get_node_context
|
||||
expect(tools).toHaveLength(18);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
|
||||
});
|
||||
|
||||
@@ -123,7 +160,7 @@ describe('builder-tools', () => {
|
||||
featureFlags: { templateExamples: false },
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(12);
|
||||
expect(tools).toHaveLength(17);
|
||||
});
|
||||
|
||||
it('should exclude workflow examples tool when feature flag is not provided', () => {
|
||||
@@ -131,7 +168,7 @@ describe('builder-tools', () => {
|
||||
nodeTypes: parsedNodeTypes,
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(12);
|
||||
expect(tools).toHaveLength(17);
|
||||
});
|
||||
|
||||
it('should work with empty node types array', () => {
|
||||
@@ -139,7 +176,7 @@ describe('builder-tools', () => {
|
||||
nodeTypes: [],
|
||||
});
|
||||
|
||||
expect(tools).toHaveLength(12);
|
||||
expect(tools).toHaveLength(17);
|
||||
expect(getAddNodeToolBase).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
|
||||
import {
|
||||
createNode,
|
||||
createWorkflow,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
createMockRunData,
|
||||
createLargeTestData,
|
||||
setupWorkflowStateWithContext,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createGetExecutionLogsTool } from '../get-execution-logs.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('GetExecutionLogsTool', () => {
|
||||
let tool: ReturnType<typeof createGetExecutionLogsTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tool = createGetExecutionLogsTool().tool;
|
||||
});
|
||||
|
||||
describe('no execution data', () => {
|
||||
it('should return no logs message when execution data is undefined', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, { workflow });
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-1');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution logs available');
|
||||
});
|
||||
|
||||
it('should return no logs message when runData is empty and no error', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: { runData: {} },
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-2');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution logs available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should include error information when execution has error', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: { Code: [] },
|
||||
lastNodeExecuted: 'Code',
|
||||
error: {
|
||||
message: 'Test error message',
|
||||
description: 'Detailed error description',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-3');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_error>');
|
||||
expect(message).toContain('<last_node_executed>Code</last_node_executed>');
|
||||
expect(message).toContain('<error_details>');
|
||||
expect(message).toContain('Test error message');
|
||||
});
|
||||
|
||||
it('should handle error with no lastNodeExecuted', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: {},
|
||||
error: { message: 'Error occurred before any node executed' },
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-4');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_error>');
|
||||
expect(message).not.toContain('<last_node_executed>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runData handling', () => {
|
||||
it('should include runData when present', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
Code: [{ json: { result: 'test' } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-5');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_run_data>');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('result');
|
||||
});
|
||||
|
||||
it('should include both error and runData when both present', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
Code: [{ json: { result: 'ok' } }],
|
||||
}),
|
||||
lastNodeExecuted: 'HTTP Request',
|
||||
error: { message: 'HTTP request failed' },
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-6');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_error>');
|
||||
expect(message).toContain('<execution_run_data>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering by nodeName', () => {
|
||||
it('should filter runData to specific node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
Code: [{ json: { a: 1 } }],
|
||||
'HTTP Request': [{ json: { b: 2 } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-7');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_run_data>');
|
||||
expect(message).toContain('"a": 1');
|
||||
expect(message).not.toContain('"b": 2');
|
||||
});
|
||||
|
||||
it('should show message when filtered node has no data', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
'HTTP Request': [{ json: { b: 2 } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-8');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution data found for node "Code"');
|
||||
});
|
||||
|
||||
it('should show not found message when node has no execution logs', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: { runData: {} },
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-9');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution logs found for node "Code"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncation of large data', () => {
|
||||
it('should truncate large execution data', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
// Create large data that exceeds MAX_EXECUTION_DATA_CHARS (10000)
|
||||
const largeItems = createLargeTestData(100, 30).map((data) => ({
|
||||
json: data,
|
||||
}));
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: {
|
||||
Code: [
|
||||
{
|
||||
data: { main: [largeItems] },
|
||||
startTime: Date.now(),
|
||||
executionTime: 100,
|
||||
executionIndex: 0,
|
||||
source: [null],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_logs', 'test-call-10');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('... (truncated)');
|
||||
});
|
||||
});
|
||||
});
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
|
||||
import {
|
||||
createNode,
|
||||
createWorkflow,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
createMockExecutionSchema,
|
||||
createMockSchema,
|
||||
setupWorkflowStateWithContext,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createGetExecutionSchemaTool } from '../get-execution-schema.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('GetExecutionSchemaTool', () => {
|
||||
let tool: ReturnType<typeof createGetExecutionSchemaTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tool = createGetExecutionSchemaTool().tool;
|
||||
});
|
||||
|
||||
describe('no execution schema', () => {
|
||||
it('should return no schema message when workflowContext is empty', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, { workflow });
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-1');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution schema available');
|
||||
});
|
||||
|
||||
it('should return no schema message when executionSchema is empty array', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: [],
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-2');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution schema available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema retrieval', () => {
|
||||
it('should return schema for all nodes when no filter', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('string', 'result', 'test', 'result'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
nodeName: 'HTTP Request',
|
||||
schema: createMockSchema('object', '', [createMockSchema('array', 'data', [], 'data')]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-3');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_schema>');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('HTTP Request');
|
||||
expect(message).toContain('result');
|
||||
expect(message).toContain('data');
|
||||
});
|
||||
|
||||
it('should return schema with complex nested structure', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('array', '', [
|
||||
createMockSchema('object', '[0]', [
|
||||
createMockSchema(
|
||||
'object',
|
||||
'[0].user',
|
||||
[
|
||||
createMockSchema('string', '[0].user.name', 'John', 'name'),
|
||||
createMockSchema('string', '[0].user.email', 'john@example.com', 'email'),
|
||||
],
|
||||
'user',
|
||||
),
|
||||
createMockSchema('array', '[0].items', [], 'items'),
|
||||
]),
|
||||
]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-4');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_schema>');
|
||||
expect(message).toContain('user');
|
||||
expect(message).toContain('name');
|
||||
expect(message).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering by nodeName', () => {
|
||||
it('should filter schema to specific node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('object', '', [createMockSchema('number', 'a', '1', 'a')]),
|
||||
},
|
||||
{
|
||||
nodeName: 'HTTP Request',
|
||||
schema: createMockSchema('object', '', [createMockSchema('string', 'b', 'test', 'b')]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-5');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<execution_schema>');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('"a"');
|
||||
expect(message).not.toContain('HTTP Request');
|
||||
expect(message).not.toContain('"b"');
|
||||
});
|
||||
|
||||
it('should return not found message when filtered node has no schema', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'HTTP Request',
|
||||
schema: createMockSchema('object', '', [createMockSchema('string', 'b', 'test', 'b')]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-6');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution schema found for node "Code"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple schema entries', () => {
|
||||
it('should handle multiple nodes with schemas', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
createNode({ id: 'set1', name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Webhook',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('object', 'body', [], 'body'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('string', 'output', 'result', 'output'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
nodeName: 'HTTP Request',
|
||||
schema: createMockSchema('array', '', []),
|
||||
},
|
||||
{
|
||||
nodeName: 'Set',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('number', 'newField', '123', 'newField'),
|
||||
]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_execution_schema', 'test-call-7');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Webhook');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('HTTP Request');
|
||||
expect(message).toContain('Set');
|
||||
});
|
||||
});
|
||||
});
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
|
||||
import {
|
||||
createNode,
|
||||
createWorkflow,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
setupWorkflowStateWithContext,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createGetExpressionDataMappingTool } from '../get-expression-data-mapping.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('GetExpressionDataMappingTool', () => {
|
||||
let tool: ReturnType<typeof createGetExpressionDataMappingTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tool = createGetExpressionDataMappingTool().tool;
|
||||
});
|
||||
|
||||
describe('no expression data', () => {
|
||||
it('should return no data message when workflowContext has no expressions', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, { workflow });
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-1');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No expression data mapping available');
|
||||
});
|
||||
|
||||
it('should return no data message when expressionValues is empty object', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-2');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No expression data mapping available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expression data retrieval', () => {
|
||||
it('should return expression data for all nodes when no filter', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {
|
||||
Code: [
|
||||
{ expression: '{{ $json.name }}', resolvedValue: 'John' },
|
||||
{ expression: '{{ $json.email }}', resolvedValue: 'john@example.com' },
|
||||
],
|
||||
'HTTP Request': [
|
||||
{ expression: '{{ $json.url }}', resolvedValue: 'https://api.example.com' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-3');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<expression_data_mapping>');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('HTTP Request');
|
||||
expect(message).toContain('$json.name');
|
||||
expect(message).toContain('John');
|
||||
expect(message).toContain('$json.url');
|
||||
});
|
||||
|
||||
it('should return expression data with complex resolved values', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {
|
||||
Code: [
|
||||
{
|
||||
expression: '{{ $json.user }}',
|
||||
resolvedValue: { name: 'John', email: 'john@example.com' },
|
||||
},
|
||||
{ expression: '{{ $json.items }}', resolvedValue: [1, 2, 3] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-4');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<expression_data_mapping>');
|
||||
expect(message).toContain('name');
|
||||
expect(message).toContain('John');
|
||||
expect(message).toContain('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering by nodeName', () => {
|
||||
it('should filter expression data to specific node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {
|
||||
Code: [{ expression: '{{ $json.a }}', resolvedValue: 'valueA' }],
|
||||
'HTTP Request': [{ expression: '{{ $json.b }}', resolvedValue: 'valueB' }],
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-5');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<expression_data_mapping>');
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('valueA');
|
||||
expect(message).not.toContain('HTTP Request');
|
||||
expect(message).not.toContain('valueB');
|
||||
});
|
||||
|
||||
it('should return not found message when filtered node has no expressions', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {
|
||||
'HTTP Request': [{ expression: '{{ $json.b }}', resolvedValue: 'valueB' }],
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-6');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No expression data mapping found for node "Code"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple nodes with expressions', () => {
|
||||
it('should handle multiple nodes with expressions', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({ id: 'http1', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
|
||||
createNode({ id: 'set1', name: 'Set', type: 'n8n-nodes-base.set' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
expressionValues: {
|
||||
Code: [{ expression: '{{ $json.input }}', resolvedValue: 'test input' }],
|
||||
'HTTP Request': [
|
||||
{ expression: '{{ $json.endpoint }}', resolvedValue: '/api/data' },
|
||||
{ expression: '{{ $json.token }}', resolvedValue: 'abc123' },
|
||||
],
|
||||
Set: [{ expression: '{{ $json.result }}', resolvedValue: 42 }],
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_expression_data_mapping', 'test-call-7');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Code');
|
||||
expect(message).toContain('HTTP Request');
|
||||
expect(message).toContain('Set');
|
||||
expect(message).toContain('input');
|
||||
expect(message).toContain('endpoint');
|
||||
expect(message).toContain('result');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
|
||||
import {
|
||||
createNode,
|
||||
createWorkflow,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
createMockRunData,
|
||||
createMockExecutionSchema,
|
||||
createMockSchema,
|
||||
createLargeTestData,
|
||||
setupWorkflowState,
|
||||
setupWorkflowStateWithContext,
|
||||
setupAIWorkflowConnections,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createGetNodeContextTool } from '../get-node-context.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('GetNodeContextTool', () => {
|
||||
let tool: ReturnType<typeof createGetNodeContextTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tool = createGetNodeContextTool().tool;
|
||||
});
|
||||
|
||||
describe('node not found', () => {
|
||||
it('should return error message when node does not exist', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-1');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'NonExistent' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Node "NonExistent" not found');
|
||||
expect(message).toContain('Available nodes: Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic node context', () => {
|
||||
it('should return context for a node with no connections', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code', typeVersion: 2 }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-2');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<node_context name="Code" id="code1">');
|
||||
expect(message).toContain('ID: code1');
|
||||
expect(message).toContain('Type: n8n-nodes-base.code');
|
||||
expect(message).toContain('Version: 2');
|
||||
expect(message).toContain('Parent nodes: None (this is a start node)');
|
||||
expect(message).toContain('Child nodes: None (this is an end node)');
|
||||
expect(message).toContain('</node_context>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node connections', () => {
|
||||
it('should show parent connections for downstream node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', 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 mockConfig = createToolConfig('get_node_context', 'test-call-3');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Parent nodes (upstream):');
|
||||
expect(message).toContain('← Webhook');
|
||||
});
|
||||
|
||||
it('should show child connections for upstream node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', 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 mockConfig = createToolConfig('get_node_context', 'test-call-4');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Webhook' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Child nodes (downstream):');
|
||||
expect(message).toContain('→ Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node classification', () => {
|
||||
it('should classify trigger nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-5');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Webhook' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Classification: trigger');
|
||||
});
|
||||
|
||||
it('should classify manual trigger nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'trigger1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
}),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-6');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Manual Trigger' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Classification: trigger');
|
||||
});
|
||||
|
||||
it('should classify ai_parent nodes (AI Agent)', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
}),
|
||||
createNode({
|
||||
id: 'model1',
|
||||
name: 'OpenAI Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
]);
|
||||
setupAIWorkflowConnections(workflow, 'OpenAI Model', 'AI Agent');
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-7');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'AI Agent' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Classification: ai_parent');
|
||||
});
|
||||
|
||||
it('should classify ai_subnode nodes (LLM)', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
}),
|
||||
createNode({
|
||||
id: 'model1',
|
||||
name: 'OpenAI Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
]);
|
||||
setupAIWorkflowConnections(workflow, 'OpenAI Model', 'AI Agent');
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-8');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'OpenAI Model' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Classification: ai_subnode');
|
||||
});
|
||||
|
||||
it('should classify regular nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-9');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Classification: regular');
|
||||
});
|
||||
});
|
||||
|
||||
describe('node parameters', () => {
|
||||
it('should display node parameters', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
parameters: {
|
||||
jsCode: 'return items;',
|
||||
mode: 'runOnceForAllItems',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-10');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Parameters:');
|
||||
expect(message).toContain('jsCode');
|
||||
expect(message).toContain('return items;');
|
||||
expect(message).toContain('mode');
|
||||
});
|
||||
|
||||
it('should show no parameters message when empty', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code', parameters: {} }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-11');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('(no parameters set)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execution data', () => {
|
||||
it('should include execution schema when available', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('string', 'name', 'test', 'name'),
|
||||
]),
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-12');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Output schema (from last execution):');
|
||||
expect(message).toContain('name');
|
||||
});
|
||||
|
||||
it('should include execution runData when available', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
Code: [{ json: { result: 'test' } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-13');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Execution data (from last run):');
|
||||
expect(message).toContain('result');
|
||||
});
|
||||
|
||||
it('should exclude execution data when includeExecutionData is false', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionSchema: createMockExecutionSchema([
|
||||
{
|
||||
nodeName: 'Code',
|
||||
schema: createMockSchema('object', '', [
|
||||
createMockSchema('string', 'name', 'test', 'name'),
|
||||
]),
|
||||
},
|
||||
]),
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
Code: [{ json: { result: 'test' } }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-14');
|
||||
|
||||
const result = await tool.invoke(
|
||||
{ nodeName: 'Code', includeExecutionData: false },
|
||||
mockConfig,
|
||||
);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).not.toContain('Output schema');
|
||||
expect(message).not.toContain('Execution data');
|
||||
});
|
||||
|
||||
it('should truncate large execution data', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
// Create large data that exceeds 2000 characters
|
||||
const largeItems = createLargeTestData(100, 30).map((data) => ({
|
||||
json: data,
|
||||
}));
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, {
|
||||
workflow,
|
||||
executionData: {
|
||||
runData: {
|
||||
Code: [
|
||||
{
|
||||
data: { main: [largeItems] },
|
||||
startTime: Date.now(),
|
||||
executionTime: 100,
|
||||
executionIndex: 0,
|
||||
source: [null],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-15');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Execution data (from last run):');
|
||||
expect(message).toContain('... (truncated)');
|
||||
});
|
||||
|
||||
it('should show no execution data message when none available', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
setupWorkflowStateWithContext(mockGetCurrentTaskInput, { workflow });
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-16');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'Code' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('No execution data available for this node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI connection formatting', () => {
|
||||
it('should show AI connection types in parent nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
}),
|
||||
createNode({
|
||||
id: 'model1',
|
||||
name: 'OpenAI Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
]);
|
||||
setupAIWorkflowConnections(workflow, 'OpenAI Model', 'AI Agent');
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-17');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'AI Agent' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('←[ai_languageModel] OpenAI Model');
|
||||
});
|
||||
|
||||
it('should show AI connection types in child nodes', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
}),
|
||||
createNode({
|
||||
id: 'model1',
|
||||
name: 'OpenAI Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
]);
|
||||
setupAIWorkflowConnections(workflow, 'OpenAI Model', 'AI Agent');
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_node_context', 'test-call-18');
|
||||
|
||||
const result = await tool.invoke({ nodeName: 'OpenAI Model' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('-[ai_languageModel]-> AI Agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { getCurrentTaskInput } from '@langchain/langgraph';
|
||||
|
||||
import {
|
||||
createNode,
|
||||
createWorkflow,
|
||||
parseToolResult,
|
||||
createToolConfig,
|
||||
setupWorkflowState,
|
||||
setupAIWorkflowConnections,
|
||||
expectToolSuccess,
|
||||
type ParsedToolContent,
|
||||
} from '../../../test/test-utils';
|
||||
import { createGetWorkflowOverviewTool } from '../get-workflow-overview.tool';
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => ({
|
||||
getCurrentTaskInput: jest.fn(),
|
||||
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
|
||||
content: JSON.stringify(params),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('GetWorkflowOverviewTool', () => {
|
||||
let tool: ReturnType<typeof createGetWorkflowOverviewTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
tool = createGetWorkflowOverviewTool().tool;
|
||||
});
|
||||
|
||||
describe('empty workflow', () => {
|
||||
it('should return empty message for workflow with no nodes', async () => {
|
||||
const emptyWorkflow = createWorkflow([]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, emptyWorkflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-1');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
expectToolSuccess(content, 'Empty workflow - no nodes to display');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mermaid format', () => {
|
||||
it('should return mermaid diagram for single node workflow', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-2');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<workflow_overview>');
|
||||
expect(message).toContain('```mermaid');
|
||||
expect(message).toContain('flowchart TD');
|
||||
expect(message).toContain('[node1]'); // Node ID should be included
|
||||
expect(message).toContain('</workflow_overview>');
|
||||
});
|
||||
|
||||
it('should return mermaid diagram showing flow for multi-node connected workflow', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
createNode({
|
||||
id: 'http1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
}),
|
||||
]);
|
||||
workflow.connections = {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]],
|
||||
},
|
||||
Code: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]],
|
||||
},
|
||||
};
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-3');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('```mermaid');
|
||||
expect(message).toContain('-->'); // Arrow indicating connection
|
||||
expect(message).toContain('[trigger1]');
|
||||
expect(message).toContain('[code1]');
|
||||
expect(message).toContain('[http1]');
|
||||
});
|
||||
|
||||
it('should include parameters when includeParameters is true', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
parameters: { jsCode: 'return items;' },
|
||||
}),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-4');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid', includeParameters: true }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('jsCode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary format', () => {
|
||||
it('should return summary format output with node list', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-5');
|
||||
|
||||
const result = await tool.invoke({ format: 'summary' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('<workflow_summary>');
|
||||
expect(message).toContain('Node count: 2');
|
||||
expect(message).toContain('Nodes:');
|
||||
expect(message).toContain('Webhook [id: trigger1]');
|
||||
expect(message).toContain('Code [id: code1]');
|
||||
expect(message).toContain('</workflow_summary>');
|
||||
});
|
||||
|
||||
it('should include parameters in summary when includeParameters is true', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
parameters: { jsCode: 'return items;' },
|
||||
}),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-6');
|
||||
|
||||
const result = await tool.invoke({ format: 'summary', includeParameters: true }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Parameters:');
|
||||
expect(message).toContain('jsCode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger node detection', () => {
|
||||
it('should detect webhook trigger node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'trigger1', name: 'My Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-7');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Trigger: My Webhook');
|
||||
});
|
||||
|
||||
it('should detect manual trigger node', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'trigger1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
}),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-8');
|
||||
|
||||
const result = await tool.invoke({ format: 'summary' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Trigger: Manual Trigger');
|
||||
});
|
||||
|
||||
it('should show no trigger when workflow has none', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-9');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('Trigger: None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI workflow with sub-nodes', () => {
|
||||
it('should show AI connections with dotted arrows', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
}),
|
||||
createNode({
|
||||
id: 'model1',
|
||||
name: 'OpenAI Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
}),
|
||||
]);
|
||||
setupAIWorkflowConnections(workflow, 'OpenAI Model', 'AI Agent');
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-10');
|
||||
|
||||
const result = await tool.invoke({ format: 'mermaid' }, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
// Dotted arrows for AI connections
|
||||
expect(message).toContain('ai_languageModel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
it('should use mermaid format by default', async () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: 'code1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
setupWorkflowState(mockGetCurrentTaskInput, workflow);
|
||||
|
||||
const mockConfig = createToolConfig('get_workflow_overview', 'test-call-11');
|
||||
|
||||
const result = await tool.invoke({}, mockConfig);
|
||||
const content = parseToolResult<ParsedToolContent>(result);
|
||||
|
||||
const message = content.update.messages[0]?.kwargs.content;
|
||||
expect(message).toContain('```mermaid');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,19 @@ import {
|
||||
addNodeConfigurationToMap,
|
||||
} from './node-configuration.utils';
|
||||
|
||||
/**
|
||||
* Input type for mermaidStringify when you only have workflow data
|
||||
* without full template metadata.
|
||||
* The workflow object must have nodes and connections, name is optional.
|
||||
*/
|
||||
export interface MermaidWorkflowInput {
|
||||
workflow: {
|
||||
name?: string;
|
||||
nodes: WorkflowMetadata['workflow']['nodes'];
|
||||
connections: WorkflowMetadata['workflow']['connections'];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for mermaid diagram generation
|
||||
*/
|
||||
@@ -15,6 +28,8 @@ export interface MermaidOptions {
|
||||
includeNodeParameters?: boolean;
|
||||
/** Include node name in node definition (default: true) */
|
||||
includeNodeName?: boolean;
|
||||
/** Include node UUID in comments for Builder/Configurator reference (default: true) */
|
||||
includeNodeId?: boolean;
|
||||
/** Collect node configurations while processing (default: false) */
|
||||
collectNodeConfigurations?: boolean;
|
||||
}
|
||||
@@ -31,6 +46,7 @@ const DEFAULT_MERMAID_OPTIONS: Required<MermaidOptions> = {
|
||||
includeNodeType: true,
|
||||
includeNodeParameters: true,
|
||||
includeNodeName: true,
|
||||
includeNodeId: true,
|
||||
collectNodeConfigurations: false,
|
||||
};
|
||||
|
||||
@@ -366,15 +382,20 @@ class MermaidBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.options.includeNodeType || this.options.includeNodeParameters) {
|
||||
if (
|
||||
this.options.includeNodeType ||
|
||||
this.options.includeNodeParameters ||
|
||||
this.options.includeNodeId
|
||||
) {
|
||||
const idPart = this.options.includeNodeId && node.id ? `[${node.id}] ` : '';
|
||||
const typePart = this.options.includeNodeType ? this.buildNodeTypePart(node) : '';
|
||||
const paramsPart =
|
||||
this.options.includeNodeParameters && Object.keys(node.parameters).length > 0
|
||||
? ` | ${JSON.stringify(node.parameters)}`
|
||||
: '';
|
||||
|
||||
if (typePart || paramsPart) {
|
||||
lines.push(`%% ${typePart}${paramsPart}`);
|
||||
if (idPart || typePart || paramsPart) {
|
||||
lines.push(`%% ${idPart}${typePart}${paramsPart}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -888,8 +909,11 @@ class MermaidBuilder {
|
||||
/**
|
||||
* Generates a Mermaid flowchart diagram from a workflow
|
||||
*/
|
||||
export function mermaidStringify(workflow: WorkflowMetadata, options?: MermaidOptions): string {
|
||||
const { workflow: wf } = workflow;
|
||||
export function mermaidStringify(
|
||||
input: WorkflowMetadata | MermaidWorkflowInput,
|
||||
options?: MermaidOptions,
|
||||
): string {
|
||||
const { workflow: wf } = input;
|
||||
const mergedOptions: Required<MermaidOptions> = {
|
||||
...DEFAULT_MERMAID_OPTIONS,
|
||||
...options,
|
||||
|
||||
@@ -6,7 +6,8 @@ import { aiAssistantWorkflow } from './workflows/ai-assistant.workflow';
|
||||
describe('markdown-workflow.utils', () => {
|
||||
describe('mermaidStringify', () => {
|
||||
it('should convert a workflow with AI agent and tools to mermaid diagram', () => {
|
||||
const result = mermaidStringify(aiAssistantWorkflow);
|
||||
// includeNodeId: false maintains backwards compatibility with existing expected output
|
||||
const result = mermaidStringify(aiAssistantWorkflow, { includeNodeId: false });
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
%% # Try It Out! Launch Jackie—your personal AI assistant that handles voice & text via Telegram to manage your digital life. **To get started:** 1. **Connect all credentials** (Telegram, OpenAI, Gmail, etc.) 2. **Activate the workflow** and message your Telegram bot: • "What emails do I have today?" • "Show me my calendar for tomorrow" • "Craete new to-do item" • 🎤 Send voice messages for hands-free interaction ## Questions or Need Help? For setup assistance, customization, or workflow support, join my Skool community! ### [AI Automation Engineering Community](https://www.skool.com/ai-automation-engineering-3014) Happy learning! -- Derek Cheung
|
||||
@@ -71,7 +72,10 @@ n6 --> n14
|
||||
});
|
||||
|
||||
it('should convert a workflow with AI agent and tools to mermaid diagram without node parameters', () => {
|
||||
const result = mermaidStringify(aiAssistantWorkflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(aiAssistantWorkflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -156,7 +160,7 @@ n6 --> n14
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
const result = mermaidStringify(workflow, { includeNodeId: false });
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -235,7 +239,7 @@ n1["Trigger"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
const result = mermaidStringify(workflow, { includeNodeId: false });
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -334,7 +338,10 @@ n3 --> n5["Send Failure Email"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -408,7 +415,10 @@ n4 --> n5["End"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -445,7 +455,7 @@ n4["HTTP Request"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow);
|
||||
const result = mermaidStringify(workflow, { includeNodeId: false });
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -484,7 +494,10 @@ n1["Empty Node"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -537,7 +550,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -615,7 +631,10 @@ n1 --> n2["End"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -661,7 +680,10 @@ n2 --> n3{"Filter"}
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
const expected = `\`\`\`mermaid
|
||||
flowchart TD
|
||||
@@ -717,7 +739,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Should contain dotted arrow with connection type for AI connections
|
||||
expect(result).toContain('-.ai_languageModel.->');
|
||||
@@ -758,7 +783,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Chat Model should appear in output
|
||||
expect(result).toContain('Chat Model');
|
||||
@@ -795,6 +823,7 @@ n1["Start"]
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeType: false,
|
||||
includeNodeParameters: true,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Should NOT contain node type comment
|
||||
@@ -805,6 +834,112 @@ n1["Start"]
|
||||
expect(result).toContain('text');
|
||||
});
|
||||
|
||||
it('should include node ID in comments when includeNodeId is true (default)', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
templateId: 9010,
|
||||
name: 'Node ID Test',
|
||||
workflow: {
|
||||
name: 'Node ID Test',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { text: 'hello' },
|
||||
id: 'abc-123-def-456',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
id: 'xyz-789-uvw-012',
|
||||
name: 'NoOp',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
position: [200, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set Data': {
|
||||
main: [[{ node: 'NoOp', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Default behavior (includeNodeId: true)
|
||||
const result = mermaidStringify(workflow);
|
||||
|
||||
// Should contain node IDs in brackets in the comments
|
||||
expect(result).toContain('[abc-123-def-456]');
|
||||
expect(result).toContain('[xyz-789-uvw-012]');
|
||||
// Should still contain node type
|
||||
expect(result).toContain('n8n-nodes-base.set');
|
||||
expect(result).toContain('n8n-nodes-base.noOp');
|
||||
});
|
||||
|
||||
it('should exclude node ID from comments when includeNodeId is false', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
templateId: 9011,
|
||||
name: 'Node ID Excluded Test',
|
||||
workflow: {
|
||||
name: 'Node ID Excluded Test',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { text: 'hello' },
|
||||
id: 'abc-123-def-456',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeId: false });
|
||||
|
||||
// Should NOT contain node ID
|
||||
expect(result).not.toContain('[abc-123-def-456]');
|
||||
// Should still contain node type
|
||||
expect(result).toContain('n8n-nodes-base.set');
|
||||
});
|
||||
|
||||
it('should include node ID even when includeNodeType and includeNodeParameters are false', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
templateId: 9012,
|
||||
name: 'Node ID Only Test',
|
||||
workflow: {
|
||||
name: 'Node ID Only Test',
|
||||
nodes: [
|
||||
{
|
||||
parameters: { text: 'hello' },
|
||||
id: 'abc-123-def-456',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set',
|
||||
position: [0, 0],
|
||||
typeVersion: 1,
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeId: true,
|
||||
includeNodeType: false,
|
||||
includeNodeParameters: false,
|
||||
});
|
||||
|
||||
// Should contain node ID in brackets
|
||||
expect(result).toContain('[abc-123-def-456]');
|
||||
// Should NOT contain node type
|
||||
expect(result).not.toContain('n8n-nodes-base.set');
|
||||
// Should NOT contain parameters
|
||||
expect(result).not.toContain('text');
|
||||
});
|
||||
|
||||
it('should handle cyclic workflows without infinite loops', () => {
|
||||
const workflow: WorkflowMetadata = {
|
||||
templateId: 9004,
|
||||
@@ -857,7 +992,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Should complete without hanging
|
||||
expect(result).toContain('```mermaid');
|
||||
@@ -899,7 +1037,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Agent should still be rendered
|
||||
expect(result).toContain('Lonely Agent');
|
||||
@@ -985,7 +1126,10 @@ n1["Start"]
|
||||
},
|
||||
};
|
||||
|
||||
const result = mermaidStringify(workflow, { includeNodeParameters: false });
|
||||
const result = mermaidStringify(workflow, {
|
||||
includeNodeParameters: false,
|
||||
includeNodeId: false,
|
||||
});
|
||||
|
||||
// Should have two subgraphs
|
||||
expect(result).toContain('subgraph sg1["## Input Section"]');
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { ToolExecutionError, ValidationError } from '../errors';
|
||||
import { createProgressReporter, reportProgress } from './helpers/progress';
|
||||
import { createErrorResponse, createSuccessResponse } from './helpers/response';
|
||||
import { getWorkflowState } from './helpers/state';
|
||||
import { getEffectiveWorkflow } from './helpers/state';
|
||||
|
||||
const validateConfigurationSchema = z.object({}).strict().default({});
|
||||
|
||||
@@ -42,13 +42,14 @@ export function createValidateConfigurationTool(
|
||||
const validatedInput = validateConfigurationSchema.parse(input ?? {});
|
||||
reporter.start(validatedInput);
|
||||
|
||||
const state = getWorkflowState();
|
||||
// Get effective workflow (includes pending operations from this turn)
|
||||
const workflow = getEffectiveWorkflow();
|
||||
reportProgress(reporter, 'Validating configuration');
|
||||
|
||||
const agentViolations = validateAgentPrompt(state.workflowJSON);
|
||||
const toolViolations = validateTools(state.workflowJSON, parsedNodeTypes);
|
||||
const fromAiViolations = validateFromAi(state.workflowJSON, parsedNodeTypes);
|
||||
const parameterViolations = validateParameters(state.workflowJSON, parsedNodeTypes);
|
||||
const agentViolations = validateAgentPrompt(workflow);
|
||||
const toolViolations = validateTools(workflow, parsedNodeTypes);
|
||||
const fromAiViolations = validateFromAi(workflow, parsedNodeTypes);
|
||||
const parameterViolations = validateParameters(workflow, parsedNodeTypes);
|
||||
|
||||
const allViolations = [
|
||||
...agentViolations,
|
||||
|
||||
@@ -8,7 +8,7 @@ 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';
|
||||
import { getEffectiveWorkflow } from './helpers/state';
|
||||
|
||||
const validateStructureSchema = z.object({}).strict().default({});
|
||||
|
||||
@@ -34,11 +34,12 @@ export function createValidateStructureTool(parsedNodeTypes: INodeTypeDescriptio
|
||||
const validatedInput = validateStructureSchema.parse(input ?? {});
|
||||
reporter.start(validatedInput);
|
||||
|
||||
const state = getWorkflowState();
|
||||
// Get effective workflow (includes pending operations from this turn)
|
||||
const workflow = getEffectiveWorkflow();
|
||||
reportProgress(reporter, 'Validating structure');
|
||||
|
||||
const connectionViolations = validateConnections(state.workflowJSON, parsedNodeTypes);
|
||||
const triggerViolations = validateTrigger(state.workflowJSON, parsedNodeTypes);
|
||||
const connectionViolations = validateConnections(workflow, parsedNodeTypes);
|
||||
const triggerViolations = validateTrigger(workflow, parsedNodeTypes);
|
||||
const allViolations = [...connectionViolations, ...triggerViolations];
|
||||
|
||||
let message: string;
|
||||
|
||||
@@ -201,3 +201,31 @@ export interface GetNodeConfigurationExamplesOutput {
|
||||
totalFound: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output type for get execution schema tool
|
||||
*/
|
||||
export interface GetExecutionSchemaOutput {
|
||||
found: boolean;
|
||||
count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output type for get execution logs tool
|
||||
*/
|
||||
export interface GetExecutionLogsOutput {
|
||||
hasError: boolean;
|
||||
lastNodeExecuted?: string;
|
||||
nodesWithData: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output type for get expression data mapping tool
|
||||
*/
|
||||
export interface GetExpressionDataMappingOutput {
|
||||
found: boolean;
|
||||
nodesWithExpressions: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
||||
|
||||
import { MAX_AI_RESPONSE_CHARS } from '../constants';
|
||||
import { mermaidStringify } from '../tools/utils/mermaid.utils';
|
||||
import type { CoordinationLogEntry } from '../types/coordination';
|
||||
import type { DiscoveryContext } from '../types/discovery-types';
|
||||
import type { SimpleWorkflow } from '../types/workflow';
|
||||
import type { ChatPayload } from '../workflow-builder-agent';
|
||||
import { isTriggerNodeType } from './node-helpers';
|
||||
import { trimWorkflowJSON } from './trim-workflow-context';
|
||||
import { truncateJson } from './truncate-json';
|
||||
|
||||
// ============================================================================
|
||||
// WORKFLOW CONTEXT BUILDERS
|
||||
@@ -35,6 +41,58 @@ export function buildWorkflowJsonBlock(workflow: SimpleWorkflow): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all trigger nodes in a workflow
|
||||
*/
|
||||
function findTriggerNodes(
|
||||
nodes: Array<{ name: string; type: string }>,
|
||||
): Array<{ name: string; type: string }> {
|
||||
return nodes.filter((n) => isTriggerNodeType(n.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a comprehensive workflow overview with Mermaid diagram.
|
||||
* Includes node connections, types, and parameters for better context.
|
||||
* Use this when the responder needs to understand the full workflow structure.
|
||||
*/
|
||||
export function buildWorkflowOverview(workflow: SimpleWorkflow): string {
|
||||
if (workflow.nodes.length === 0) {
|
||||
return 'Empty workflow - ready to build';
|
||||
}
|
||||
|
||||
const triggerNodes = findTriggerNodes(workflow.nodes);
|
||||
|
||||
const parts: string[] = ['<workflow_overview>'];
|
||||
|
||||
// Metadata
|
||||
parts.push(`Node count: ${workflow.nodes.length}`);
|
||||
if (triggerNodes.length === 0) {
|
||||
parts.push('Triggers: None');
|
||||
} else if (triggerNodes.length === 1) {
|
||||
parts.push(`Trigger: ${triggerNodes[0].name} (${triggerNodes[0].type})`);
|
||||
} else {
|
||||
parts.push(`Triggers (${triggerNodes.length}):`);
|
||||
for (const trigger of triggerNodes) {
|
||||
parts.push(` - ${trigger.name} (${trigger.type})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mermaid diagram with connections and parameters
|
||||
parts.push('');
|
||||
const mermaid = mermaidStringify(
|
||||
{ workflow },
|
||||
{
|
||||
includeNodeType: true,
|
||||
includeNodeParameters: true,
|
||||
includeNodeName: true,
|
||||
},
|
||||
);
|
||||
parts.push(mermaid);
|
||||
|
||||
parts.push('</workflow_overview>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DISCOVERY CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
@@ -92,6 +150,116 @@ export function buildDiscoveryContextBlock(
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONVERSATION CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the content from a message, handling both string and array formats
|
||||
*/
|
||||
function getMessageContent(message: BaseMessage): string {
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
// Extract text from content blocks
|
||||
return message.content
|
||||
.filter(
|
||||
(block): block is { type: 'text'; text: string } =>
|
||||
typeof block === 'object' && block !== null && 'type' in block && block.type === 'text',
|
||||
)
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build conversation context for subgraphs (Builder)
|
||||
* Provides history so agents understand what happened before the current request
|
||||
*
|
||||
* @param messages - Full conversation history
|
||||
* @param coordinationLog - Log of completed phases
|
||||
* @param previousSummary - Summary from conversation compaction (if any)
|
||||
* @returns Formatted conversation context string
|
||||
*/
|
||||
export function buildConversationContext(
|
||||
messages: BaseMessage[],
|
||||
coordinationLog: CoordinationLogEntry[],
|
||||
previousSummary?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 1. Previous summary (from compaction) - contains earlier conversation context
|
||||
if (previousSummary) {
|
||||
parts.push('Previous conversation summary:');
|
||||
parts.push(previousSummary);
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
// 2. Extract original user request (first HumanMessage)
|
||||
const humanMessages = messages.filter((m) => m instanceof HumanMessage);
|
||||
const firstUserMessage = humanMessages[0];
|
||||
const lastUserMessage = humanMessages[humanMessages.length - 1];
|
||||
|
||||
// Only show original request if it's different from the current request
|
||||
if (firstUserMessage && lastUserMessage && firstUserMessage !== lastUserMessage) {
|
||||
const originalContent = getMessageContent(firstUserMessage);
|
||||
if (originalContent) {
|
||||
parts.push(`Original request: "${originalContent}"`);
|
||||
parts.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Summarize previous actions from coordination log
|
||||
const completedPhases = coordinationLog.filter((e) => e.status === 'completed');
|
||||
if (completedPhases.length > 0) {
|
||||
parts.push('Previous actions:');
|
||||
for (const entry of completedPhases) {
|
||||
parts.push(`- ${capitalizeFirst(entry.phase)}: ${entry.summary}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
|
||||
// 4. Last AI response (what was offered/said before user's current request)
|
||||
// This helps understand what the user is responding to
|
||||
if (lastUserMessage) {
|
||||
const lastUserIndex = messages.lastIndexOf(lastUserMessage);
|
||||
if (lastUserIndex > 0) {
|
||||
// Find the AI message right before the last user message
|
||||
for (let i = lastUserIndex - 1; i >= 0; i--) {
|
||||
if (messages[i] instanceof AIMessage) {
|
||||
const aiContent = getMessageContent(messages[i]);
|
||||
if (aiContent) {
|
||||
// Truncate if too long, keep the most relevant part (usually at the end)
|
||||
const truncatedContent =
|
||||
aiContent.length > MAX_AI_RESPONSE_CHARS
|
||||
? '...' + aiContent.slice(-MAX_AI_RESPONSE_CHARS)
|
||||
: aiContent;
|
||||
parts.push(`Last AI response: "${truncatedContent}"`);
|
||||
parts.push('');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Current request (last HumanMessage)
|
||||
if (lastUserMessage) {
|
||||
const currentContent = getMessageContent(lastUserMessage);
|
||||
if (currentContent) {
|
||||
parts.push(`Current request: "${currentContent}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXECUTION CONTEXT BUILDERS
|
||||
// ============================================================================
|
||||
@@ -108,11 +276,11 @@ export function buildExecutionContextBlock(
|
||||
|
||||
return [
|
||||
'<execution_data>',
|
||||
JSON.stringify(executionData, null, 2),
|
||||
truncateJson(executionData),
|
||||
'</execution_data>',
|
||||
'',
|
||||
'<execution_schema>',
|
||||
JSON.stringify(executionSchema, null, 2),
|
||||
truncateJson(executionSchema),
|
||||
'</execution_schema>',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -127,11 +295,187 @@ export function buildExecutionSchemaBlock(
|
||||
|
||||
if (executionSchema.length === 0) return '';
|
||||
|
||||
return [
|
||||
'<execution_schema>',
|
||||
JSON.stringify(executionSchema, null, 2),
|
||||
'</execution_schema>',
|
||||
].join('\n');
|
||||
return ['<execution_schema>', truncateJson(executionSchema), '</execution_schema>'].join('\n');
|
||||
}
|
||||
|
||||
type ExecutionError = NonNullable<
|
||||
NonNullable<ChatPayload['workflowContext']>['executionData']
|
||||
>['error'];
|
||||
|
||||
type RunData = NonNullable<NonNullable<ChatPayload['workflowContext']>['executionData']>['runData'];
|
||||
|
||||
/**
|
||||
* Count output items for a node from runData
|
||||
* Returns the number of items in the main output, or 0 if not available
|
||||
*/
|
||||
function countNodeOutputItems(runData: RunData, nodeName: string): number {
|
||||
const nodeData = runData?.[nodeName];
|
||||
if (!nodeData || nodeData.length === 0) return 0;
|
||||
|
||||
// Get the first execution's data (index 0)
|
||||
const firstExecution = nodeData[0];
|
||||
const mainOutput = firstExecution?.data?.main;
|
||||
|
||||
if (!mainOutput || mainOutput.length === 0) return 0;
|
||||
|
||||
// Sum items across all output branches (for Switch, If, Router nodes that output to multiple branches)
|
||||
let totalItems = 0;
|
||||
for (const branchOutput of mainOutput) {
|
||||
totalItems += branchOutput?.length ?? 0;
|
||||
}
|
||||
return totalItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build data flow string showing item counts per node
|
||||
* Format: "Node1 (5 items) → Node2 (3 items) → Node3 (0 items)"
|
||||
*/
|
||||
function buildDataFlowString(runData: RunData, executedNodeNames: string[]): string {
|
||||
if (!runData || executedNodeNames.length === 0) return '';
|
||||
|
||||
const nodeFlows = executedNodeNames.map((nodeName) => {
|
||||
const itemCount = countNodeOutputItems(runData, nodeName);
|
||||
const itemLabel = itemCount === 1 ? 'item' : 'items';
|
||||
return `${nodeName} (${itemCount} ${itemLabel})`;
|
||||
});
|
||||
|
||||
return nodeFlows.join(' → ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build error status XML block
|
||||
*/
|
||||
function buildErrorStatus(
|
||||
error: NonNullable<ExecutionError>,
|
||||
lastNodeExecuted: string | undefined,
|
||||
): string {
|
||||
const parts = ['<execution_status>', ' <status>error</status>'];
|
||||
|
||||
if (lastNodeExecuted) {
|
||||
parts.push(` <last_node_executed>${lastNodeExecuted}</last_node_executed>`);
|
||||
}
|
||||
|
||||
parts.push(' <error>');
|
||||
// Check for node property (exists on NodeOperationError)
|
||||
if ('node' in error && error.node) {
|
||||
const nodeName = typeof error.node === 'string' ? error.node : error.node.name;
|
||||
parts.push(` <node>${nodeName}</node>`);
|
||||
}
|
||||
if (error.message) {
|
||||
parts.push(` <message>${error.message}</message>`);
|
||||
}
|
||||
if (error.description) {
|
||||
parts.push(` <description>${error.description}</description>`);
|
||||
}
|
||||
parts.push(' </error>');
|
||||
parts.push('</execution_status>');
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build issues detected status XML block
|
||||
*/
|
||||
function buildIssuesStatus(
|
||||
dataFlow: string,
|
||||
nodesNotExecuted: string[],
|
||||
nodesWithEmptyOutput: string[],
|
||||
): string {
|
||||
const parts = ['<execution_status>', ' <status>issues_detected</status>'];
|
||||
|
||||
if (dataFlow) {
|
||||
parts.push(` <data_flow>${dataFlow}</data_flow>`);
|
||||
}
|
||||
|
||||
if (nodesNotExecuted.length > 0) {
|
||||
parts.push(` <nodes_not_executed>${nodesNotExecuted.join(', ')}</nodes_not_executed>`);
|
||||
}
|
||||
|
||||
if (nodesWithEmptyOutput.length > 0) {
|
||||
parts.push(
|
||||
` <nodes_with_empty_output>${nodesWithEmptyOutput.join(', ')}</nodes_with_empty_output>`,
|
||||
);
|
||||
}
|
||||
|
||||
parts.push('</execution_status>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
const NO_EXECUTION_STATUS = [
|
||||
'<execution_status>',
|
||||
' <status>no_execution</status>',
|
||||
'</execution_status>',
|
||||
].join('\n');
|
||||
|
||||
const SUCCESS_STATUS = [
|
||||
'<execution_status>',
|
||||
' <status>success</status>',
|
||||
'</execution_status>',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Build simplified execution context (for Supervisor/Responder)
|
||||
* Returns a summary of execution status and errors without full data
|
||||
*
|
||||
* Detects three types of issues:
|
||||
* 1. Error - An actual error was thrown during execution
|
||||
* 2. Incomplete execution - Some workflow nodes never ran
|
||||
* 3. Empty outputs - Nodes ran successfully but produced 0 items
|
||||
*
|
||||
* @param workflowContext - The workflow context with execution data
|
||||
* @param workflowNodes - Optional array of workflow nodes to detect incomplete execution
|
||||
*/
|
||||
export function buildSimplifiedExecutionContext(
|
||||
workflowContext: ChatPayload['workflowContext'] | undefined,
|
||||
workflowNodes?: Array<{ name: string; disabled?: boolean }>,
|
||||
): string {
|
||||
if (!workflowContext) {
|
||||
return NO_EXECUTION_STATUS;
|
||||
}
|
||||
|
||||
const executionData = workflowContext.executionData;
|
||||
const lastNodeExecuted = executionData?.lastNodeExecuted;
|
||||
|
||||
// 1. Check for explicit error (highest priority)
|
||||
if (executionData?.error) {
|
||||
return buildErrorStatus(executionData.error, lastNodeExecuted);
|
||||
}
|
||||
|
||||
// No error - check if we have any run data
|
||||
const runData = executionData?.runData ?? {};
|
||||
const runDataNodeNames = Object.keys(runData);
|
||||
|
||||
if (runDataNodeNames.length === 0) {
|
||||
return NO_EXECUTION_STATUS;
|
||||
}
|
||||
|
||||
// 2. Detect incomplete execution - nodes that didn't run
|
||||
// Filter out disabled nodes before comparing
|
||||
const activeNodeNames = (workflowNodes ?? []).filter((n) => !n.disabled).map((n) => n.name);
|
||||
const nodesNotExecuted = activeNodeNames.filter((name) => !runDataNodeNames.includes(name));
|
||||
|
||||
// 3. Detect empty outputs using runData directly (more reliable than executionSchema)
|
||||
// A node that ran but produced 0 items across all output branches
|
||||
// Only check nodes that have actual execution data (not empty arrays)
|
||||
const nodesWithEmptyOutput = runDataNodeNames.filter((nodeName) => {
|
||||
const nodeData = runData?.[nodeName];
|
||||
// Skip if no execution data or empty execution array (node may not have run)
|
||||
if (!nodeData || nodeData.length === 0) return false;
|
||||
// Check if node ran but produced 0 items
|
||||
const itemCount = countNodeOutputItems(runData, nodeName);
|
||||
return itemCount === 0;
|
||||
});
|
||||
|
||||
// 4. Determine status based on findings
|
||||
// Note: nodesNotExecuted may include nodes on branches that weren't taken
|
||||
// The LLM will interpret whether this is expected (branching) or unexpected
|
||||
if (nodesNotExecuted.length > 0 || nodesWithEmptyOutput.length > 0) {
|
||||
// Build data flow showing item counts for executed nodes
|
||||
const dataFlow = buildDataFlowString(runData, runDataNodeNames);
|
||||
return buildIssuesStatus(dataFlow, nodesNotExecuted, nodesWithEmptyOutput);
|
||||
}
|
||||
|
||||
return SUCCESS_STATUS;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { type INode, NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Checks if a node type string represents a trigger node.
|
||||
* This is a heuristic based on the node type name.
|
||||
* @param nodeType - The node type string (e.g., 'n8n-nodes-base.webhook')
|
||||
* @returns true if the node is a trigger node
|
||||
*/
|
||||
export function isTriggerNodeType(nodeType: string): boolean {
|
||||
const lower = nodeType.toLowerCase();
|
||||
return (
|
||||
lower.includes('trigger') ||
|
||||
lower.includes('webhook') ||
|
||||
nodeType === 'n8n-nodes-base.manualTrigger'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a node is a sub-node (has no main input connections)
|
||||
* Sub-nodes are nodes that only have AI inputs or no inputs at all
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
import type { NodeExecutionSchema, Schema } from 'n8n-workflow';
|
||||
|
||||
import { createNode, createWorkflow, createMockRunData } from '../../../test/test-utils';
|
||||
import { MAX_AI_RESPONSE_CHARS } from '../../constants';
|
||||
import type { CoordinationLogEntry } from '../../types/coordination';
|
||||
import type { ChatPayload } from '../../workflow-builder-agent';
|
||||
import {
|
||||
buildSimplifiedExecutionContext,
|
||||
buildConversationContext,
|
||||
buildWorkflowOverview,
|
||||
buildExecutionContextBlock,
|
||||
buildExecutionSchemaBlock,
|
||||
} from '../context-builders';
|
||||
|
||||
// Helper to create mock execution schema with proper typing
|
||||
const createMockSchema = (value: Schema['value']): Schema => ({
|
||||
type: 'array',
|
||||
path: '',
|
||||
value,
|
||||
});
|
||||
|
||||
// Helper to create mock NodeExecutionSchema
|
||||
const createMockNodeSchema = (nodeName: string, value: Schema['value']): NodeExecutionSchema => ({
|
||||
nodeName,
|
||||
schema: createMockSchema(value),
|
||||
});
|
||||
|
||||
describe('buildSimplifiedExecutionContext', () => {
|
||||
describe('no execution data', () => {
|
||||
it('should return no_execution status when workflowContext is undefined', () => {
|
||||
const result = buildSimplifiedExecutionContext(undefined);
|
||||
expect(result).toContain('<status>no_execution</status>');
|
||||
});
|
||||
|
||||
it('should return no_execution status when runData is empty', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {},
|
||||
},
|
||||
};
|
||||
const result = buildSimplifiedExecutionContext(workflowContext);
|
||||
expect(result).toContain('<status>no_execution</status>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error status', () => {
|
||||
it('should return error status when there is an error', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: { Node1: [] },
|
||||
lastNodeExecuted: 'Send to Telegram',
|
||||
error: {
|
||||
message: 'Bad request - please check your parameters',
|
||||
description: 'Bad Request: chat not found',
|
||||
node: { name: 'Send to Telegram' },
|
||||
} as unknown as NonNullable<
|
||||
NonNullable<ChatPayload['workflowContext']>['executionData']
|
||||
>['error'],
|
||||
},
|
||||
};
|
||||
const result = buildSimplifiedExecutionContext(workflowContext);
|
||||
|
||||
expect(result).toContain('<status>error</status>');
|
||||
expect(result).toContain('<last_node_executed>Send to Telegram</last_node_executed>');
|
||||
expect(result).toContain('<node>Send to Telegram</node>');
|
||||
expect(result).toContain('<message>Bad request - please check your parameters</message>');
|
||||
expect(result).toContain('<description>Bad Request: chat not found</description>');
|
||||
});
|
||||
|
||||
it('should handle error with string node name', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: { Node1: [] },
|
||||
error: {
|
||||
message: 'Error occurred',
|
||||
node: 'ErrorNode',
|
||||
} as unknown as NonNullable<
|
||||
NonNullable<ChatPayload['workflowContext']>['executionData']
|
||||
>['error'],
|
||||
},
|
||||
};
|
||||
const result = buildSimplifiedExecutionContext(workflowContext);
|
||||
|
||||
expect(result).toContain('<status>error</status>');
|
||||
expect(result).toContain('<node>ErrorNode</node>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issues_detected status', () => {
|
||||
it('should detect incomplete execution when nodes did not run', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
'Schedule Trigger1': [],
|
||||
'Get Articles': [],
|
||||
'Split Articles1': [],
|
||||
},
|
||||
lastNodeExecuted: 'Split Articles1',
|
||||
},
|
||||
executionSchema: [],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Schedule Trigger1' },
|
||||
{ name: 'Get Articles' },
|
||||
{ name: 'Split Articles1' },
|
||||
{ name: 'Top 5 Articles1' },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain('<data_flow>');
|
||||
expect(result).toContain('Schedule Trigger1');
|
||||
expect(result).toContain('Get Articles');
|
||||
expect(result).toContain('Split Articles1');
|
||||
expect(result).toContain('<nodes_not_executed>Top 5 Articles1</nodes_not_executed>');
|
||||
});
|
||||
|
||||
it('should detect nodes with empty output', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
'Schedule Trigger1': [{ json: { triggered: true } }],
|
||||
'Get Articles': [
|
||||
{ json: { article: 'Article 1' } },
|
||||
{ json: { article: 'Article 2' } },
|
||||
],
|
||||
'Split Articles1': [], // Node ran but produced 0 items
|
||||
}),
|
||||
lastNodeExecuted: 'Split Articles1',
|
||||
},
|
||||
executionSchema: [
|
||||
createMockNodeSchema('Get Articles', [
|
||||
createMockSchema('Article 1'),
|
||||
createMockSchema('Article 2'),
|
||||
]),
|
||||
createMockNodeSchema('Split Articles1', []),
|
||||
],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Schedule Trigger1' },
|
||||
{ name: 'Get Articles' },
|
||||
{ name: 'Split Articles1' },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain(
|
||||
'<nodes_with_empty_output>Split Articles1</nodes_with_empty_output>',
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect both incomplete execution and empty output', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: createMockRunData({
|
||||
'Schedule Trigger1': [{ json: { triggered: true } }],
|
||||
'Get Articles': [{ json: { article: 'test' } }],
|
||||
'Filter Node': [], // Node ran but produced 0 items
|
||||
}),
|
||||
lastNodeExecuted: 'Filter Node',
|
||||
},
|
||||
executionSchema: [createMockNodeSchema('Filter Node', [])],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Schedule Trigger1' },
|
||||
{ name: 'Get Articles' },
|
||||
{ name: 'Filter Node' },
|
||||
{ name: 'Process Data' },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain('<nodes_not_executed>Process Data</nodes_not_executed>');
|
||||
expect(result).toContain('<nodes_with_empty_output>Filter Node</nodes_with_empty_output>');
|
||||
});
|
||||
|
||||
it('should list multiple nodes that did not execute', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Trigger: [],
|
||||
'IF Node': [],
|
||||
},
|
||||
lastNodeExecuted: 'IF Node',
|
||||
},
|
||||
executionSchema: [],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Trigger' },
|
||||
{ name: 'IF Node' },
|
||||
{ name: 'Send Slack' },
|
||||
{ name: 'Process Data' },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain('<nodes_not_executed>Send Slack, Process Data</nodes_not_executed>');
|
||||
});
|
||||
|
||||
it('should show data flow with item counts', () => {
|
||||
// Helper to create mock task data with proper structure
|
||||
const createMockTaskData = (
|
||||
items: Array<{ json: Record<string, unknown> }>,
|
||||
executionIndex: number,
|
||||
) =>
|
||||
({
|
||||
data: { main: [items] },
|
||||
startTime: 0,
|
||||
executionTime: 100,
|
||||
executionIndex,
|
||||
source: [],
|
||||
}) as unknown as NonNullable<
|
||||
NonNullable<ChatPayload['workflowContext']>['executionData']
|
||||
>['runData'] extends infer R
|
||||
? R extends Record<string, infer T>
|
||||
? T extends Array<infer U>
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Trigger: [createMockTaskData([{ json: { id: 1 } }], 0)],
|
||||
'HTTP Request': [
|
||||
createMockTaskData(
|
||||
[{ json: { title: 'Article 1' } }, { json: { title: 'Article 2' } }],
|
||||
1,
|
||||
),
|
||||
],
|
||||
'Split Items': [createMockTaskData([], 2)],
|
||||
},
|
||||
},
|
||||
executionSchema: [createMockNodeSchema('Split Items', [])],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Trigger' },
|
||||
{ name: 'HTTP Request' },
|
||||
{ name: 'Split Items' },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain('<data_flow>');
|
||||
// Trigger produced 1 item
|
||||
expect(result).toContain('Trigger (1 item)');
|
||||
// HTTP Request produced 2 items
|
||||
expect(result).toContain('HTTP Request (2 items)');
|
||||
// Split Items produced 0 items
|
||||
expect(result).toContain('Split Items (0 items)');
|
||||
expect(result).toContain('<nodes_with_empty_output>Split Items</nodes_with_empty_output>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled nodes handling', () => {
|
||||
it('should ignore disabled nodes when detecting incomplete execution', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Trigger: [],
|
||||
'Active Node': [],
|
||||
},
|
||||
lastNodeExecuted: 'Active Node',
|
||||
},
|
||||
executionSchema: [],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Trigger' },
|
||||
{ name: 'Active Node' },
|
||||
{ name: 'Disabled Node', disabled: true },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>success</status>');
|
||||
expect(result).not.toContain('Disabled Node');
|
||||
});
|
||||
|
||||
it('should only list active nodes that did not execute', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Trigger: [],
|
||||
},
|
||||
lastNodeExecuted: 'Trigger',
|
||||
},
|
||||
executionSchema: [],
|
||||
};
|
||||
const workflowNodes = [
|
||||
{ name: 'Trigger' },
|
||||
{ name: 'Active Missing', disabled: false },
|
||||
{ name: 'Disabled Missing', disabled: true },
|
||||
];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>issues_detected</status>');
|
||||
expect(result).toContain('<nodes_not_executed>Active Missing</nodes_not_executed>');
|
||||
expect(result).not.toContain('Disabled Missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('success status', () => {
|
||||
it('should return success when all active nodes executed with output', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Trigger: [],
|
||||
'Process Data': [],
|
||||
'Send Email': [],
|
||||
},
|
||||
lastNodeExecuted: 'Send Email',
|
||||
},
|
||||
executionSchema: [
|
||||
createMockNodeSchema('Trigger', [createMockSchema('trigger data')]),
|
||||
createMockNodeSchema('Process Data', [createMockSchema('result')]),
|
||||
createMockNodeSchema('Send Email', [createMockSchema('sent')]),
|
||||
],
|
||||
};
|
||||
const workflowNodes = [{ name: 'Trigger' }, { name: 'Process Data' }, { name: 'Send Email' }];
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext, workflowNodes);
|
||||
|
||||
expect(result).toContain('<status>success</status>');
|
||||
});
|
||||
|
||||
it('should return success when workflowNodes is not provided and no errors', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Node1: [],
|
||||
Node2: [],
|
||||
},
|
||||
},
|
||||
executionSchema: [],
|
||||
};
|
||||
|
||||
const result = buildSimplifiedExecutionContext(workflowContext);
|
||||
|
||||
// Without workflowNodes, we can't detect incomplete execution
|
||||
expect(result).toContain('<status>success</status>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should work without workflowNodes parameter (legacy behavior)', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: {
|
||||
Node1: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Should not throw when called without workflowNodes
|
||||
const result = buildSimplifiedExecutionContext(workflowContext);
|
||||
expect(result).toContain('<status>success</status>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConversationContext', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should return empty string when no context is available', () => {
|
||||
const result = buildConversationContext([], [], undefined);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should include previous summary when provided', () => {
|
||||
const result = buildConversationContext([], [], 'User asked for a weather workflow');
|
||||
expect(result).toContain('Previous conversation summary:');
|
||||
expect(result).toContain('User asked for a weather workflow');
|
||||
});
|
||||
|
||||
it('should include current request from last HumanMessage', () => {
|
||||
const messages = [new HumanMessage('Fix it please')];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).toContain('Current request: "Fix it please"');
|
||||
});
|
||||
|
||||
it('should include original request when different from current', () => {
|
||||
const messages = [
|
||||
new HumanMessage('Create a workflow that fetches daily AI news'),
|
||||
new AIMessage('I created the workflow'),
|
||||
new HumanMessage('Fix it please'),
|
||||
];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).toContain('Original request: "Create a workflow that fetches daily AI news"');
|
||||
expect(result).toContain('Current request: "Fix it please"');
|
||||
});
|
||||
|
||||
it('should include last AI response before current request', () => {
|
||||
const messages = [
|
||||
new HumanMessage('Why is my workflow not working?'),
|
||||
new AIMessage(
|
||||
'I can see the Split Articles node produced 0 items. Would you like me to investigate and fix this?',
|
||||
),
|
||||
new HumanMessage('Please do'),
|
||||
];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).toContain('Last AI response:');
|
||||
expect(result).toContain('Would you like me to investigate and fix this?');
|
||||
expect(result).toContain('Current request: "Please do"');
|
||||
});
|
||||
|
||||
it('should truncate long AI responses', () => {
|
||||
const longResponse = 'A'.repeat(MAX_AI_RESPONSE_CHARS * 2);
|
||||
const messages = [
|
||||
new HumanMessage('Original request'),
|
||||
new AIMessage(longResponse),
|
||||
new HumanMessage('Yes'),
|
||||
];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).toContain('Last AI response: "...');
|
||||
// Should be truncated to MAX_AI_RESPONSE_CHARS + "..."
|
||||
expect(result).not.toContain('A'.repeat(MAX_AI_RESPONSE_CHARS + 100));
|
||||
});
|
||||
|
||||
it('should not include AI response when there is only one user message', () => {
|
||||
const messages = [new HumanMessage('Create a workflow')];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).not.toContain('Last AI response');
|
||||
expect(result).toContain('Current request: "Create a workflow"');
|
||||
});
|
||||
|
||||
it('should NOT show original request when same as current (single message)', () => {
|
||||
const messages = [new HumanMessage('Create a workflow')];
|
||||
const result = buildConversationContext(messages, [], undefined);
|
||||
expect(result).not.toContain('Original request');
|
||||
expect(result).toContain('Current request: "Create a workflow"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('coordination log handling', () => {
|
||||
it('should include previous actions from coordination log', () => {
|
||||
const coordinationLog: CoordinationLogEntry[] = [
|
||||
{
|
||||
phase: 'discovery',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: 'Found 3 node types (httpRequest, splitOut, limit)',
|
||||
metadata: {
|
||||
phase: 'discovery',
|
||||
nodesFound: 3,
|
||||
nodeTypes: ['httpRequest', 'splitOut', 'limit'],
|
||||
hasBestPractices: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
phase: 'builder',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: 'Created 4 nodes with 3 connections',
|
||||
metadata: {
|
||||
phase: 'builder',
|
||||
nodesCreated: 4,
|
||||
connectionsCreated: 3,
|
||||
nodeNames: ['Trigger', 'HTTP Request', 'Split', 'Limit'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [new HumanMessage('Fix it')];
|
||||
const result = buildConversationContext(messages, coordinationLog, undefined);
|
||||
|
||||
expect(result).toContain('Previous actions:');
|
||||
expect(result).toContain('- Discovery: Found 3 node types');
|
||||
expect(result).toContain('- Builder: Created 4 nodes with 3 connections');
|
||||
});
|
||||
|
||||
it('should skip error entries in coordination log', () => {
|
||||
const coordinationLog: CoordinationLogEntry[] = [
|
||||
{
|
||||
phase: 'builder',
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
summary: 'Recursion limit reached',
|
||||
metadata: {
|
||||
phase: 'error',
|
||||
failedSubgraph: 'builder',
|
||||
errorMessage: 'Recursion limit reached',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [new HumanMessage('Fix it')];
|
||||
const result = buildConversationContext(messages, coordinationLog, undefined);
|
||||
|
||||
// Should not include error entries in "Previous actions"
|
||||
expect(result).not.toContain('Previous actions:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full context assembly', () => {
|
||||
it('should assemble all parts in correct order', () => {
|
||||
const messages = [
|
||||
new HumanMessage('Create a news fetcher'),
|
||||
new AIMessage('I created the workflow. Would you like me to fix anything?'),
|
||||
new HumanMessage('Fix the empty output'),
|
||||
];
|
||||
|
||||
const coordinationLog: CoordinationLogEntry[] = [
|
||||
{
|
||||
phase: 'builder',
|
||||
status: 'completed',
|
||||
timestamp: Date.now(),
|
||||
summary: 'Created 3 nodes',
|
||||
metadata: {
|
||||
phase: 'builder',
|
||||
nodesCreated: 3,
|
||||
connectionsCreated: 2,
|
||||
nodeNames: ['Trigger', 'HTTP', 'Split'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildConversationContext(
|
||||
messages,
|
||||
coordinationLog,
|
||||
'Earlier user discussed weather API',
|
||||
);
|
||||
|
||||
// Check order: summary -> original -> actions -> ai response -> current
|
||||
const summaryIndex = result.indexOf('Previous conversation summary:');
|
||||
const originalIndex = result.indexOf('Original request:');
|
||||
const actionsIndex = result.indexOf('Previous actions:');
|
||||
const aiResponseIndex = result.indexOf('Last AI response:');
|
||||
const currentIndex = result.indexOf('Current request:');
|
||||
|
||||
expect(summaryIndex).toBeLessThan(originalIndex);
|
||||
expect(originalIndex).toBeLessThan(actionsIndex);
|
||||
expect(actionsIndex).toBeLessThan(aiResponseIndex);
|
||||
expect(aiResponseIndex).toBeLessThan(currentIndex);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExecutionContextBlock', () => {
|
||||
it('should return empty data when workflowContext is undefined', () => {
|
||||
const result = buildExecutionContextBlock(undefined);
|
||||
|
||||
expect(result).toContain('<execution_data>');
|
||||
expect(result).toContain('<execution_schema>');
|
||||
expect(result).toContain('{}');
|
||||
expect(result).toContain('[]');
|
||||
});
|
||||
|
||||
it('should include execution data and schema', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionData: {
|
||||
runData: { TestNode: [] },
|
||||
lastNodeExecuted: 'TestNode',
|
||||
},
|
||||
executionSchema: [createMockNodeSchema('TestNode', [createMockSchema('test data')])],
|
||||
};
|
||||
|
||||
const result = buildExecutionContextBlock(workflowContext);
|
||||
|
||||
expect(result).toContain('<execution_data>');
|
||||
expect(result).toContain('TestNode');
|
||||
expect(result).toContain('lastNodeExecuted');
|
||||
expect(result).toContain('<execution_schema>');
|
||||
expect(result).toContain('nodeName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExecutionSchemaBlock', () => {
|
||||
it('should return empty string when no schema', () => {
|
||||
const result = buildExecutionSchemaBlock(undefined);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when executionSchema is empty array', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionSchema: [],
|
||||
};
|
||||
const result = buildExecutionSchemaBlock(workflowContext);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return schema block when schema exists', () => {
|
||||
const workflowContext: ChatPayload['workflowContext'] = {
|
||||
executionSchema: [createMockNodeSchema('Code', [createMockSchema('result')])],
|
||||
};
|
||||
|
||||
const result = buildExecutionSchemaBlock(workflowContext);
|
||||
|
||||
expect(result).toContain('<execution_schema>');
|
||||
expect(result).toContain('Code');
|
||||
expect(result).toContain('result');
|
||||
expect(result).toContain('</execution_schema>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkflowOverview', () => {
|
||||
describe('empty workflow', () => {
|
||||
it('should return ready to build message for empty workflow', () => {
|
||||
const workflow = createWorkflow([]);
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
expect(result).toBe('Empty workflow - ready to build');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow with nodes', () => {
|
||||
it('should include workflow_overview tags', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('<workflow_overview>');
|
||||
expect(result).toContain('</workflow_overview>');
|
||||
});
|
||||
|
||||
it('should include node count', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'Node1' }),
|
||||
createNode({ id: '2', name: 'Node2' }),
|
||||
createNode({ id: '3', name: 'Node3' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('Node count: 3');
|
||||
});
|
||||
|
||||
it('should include trigger info when single trigger exists', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'My Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: '2', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('Trigger: My Webhook (n8n-nodes-base.webhook)');
|
||||
});
|
||||
|
||||
it('should list all triggers when multiple exist', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'Schedule Trigger', type: 'n8n-nodes-base.scheduleTrigger' }),
|
||||
createNode({ id: '2', name: 'Webhook', type: 'n8n-nodes-base.webhook' }),
|
||||
createNode({ id: '3', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('Triggers (2):');
|
||||
expect(result).toContain('- Schedule Trigger (n8n-nodes-base.scheduleTrigger)');
|
||||
expect(result).toContain('- Webhook (n8n-nodes-base.webhook)');
|
||||
});
|
||||
|
||||
it('should indicate no triggers when none exist', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'Code', type: 'n8n-nodes-base.code' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('Triggers: None');
|
||||
});
|
||||
|
||||
it('should include mermaid diagram', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({ id: '1', name: 'Trigger', type: 'n8n-nodes-base.manualTrigger' }),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
expect(result).toContain('```mermaid');
|
||||
expect(result).toContain('flowchart TD');
|
||||
expect(result).toContain('```');
|
||||
});
|
||||
|
||||
it('should include node parameters in mermaid comments', () => {
|
||||
const workflow = createWorkflow([
|
||||
createNode({
|
||||
id: '1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
parameters: { url: 'https://api.example.com', method: 'GET' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = buildWorkflowOverview(workflow);
|
||||
|
||||
// Parameters should be in mermaid comment lines
|
||||
expect(result).toContain('https://api.example.com');
|
||||
expect(result).toContain('GET');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { MAX_EXECUTION_DATA_CHARS } from '../../constants';
|
||||
import { truncateJson } from '../truncate-json';
|
||||
|
||||
describe('truncateJson', () => {
|
||||
describe('basic serialization', () => {
|
||||
it('should stringify simple objects', () => {
|
||||
const obj = { name: 'test', value: 123 };
|
||||
const result = truncateJson(obj);
|
||||
expect(result).toBe(JSON.stringify(obj, null, 2));
|
||||
});
|
||||
|
||||
it('should stringify arrays', () => {
|
||||
const arr = [1, 2, 3, 'test'];
|
||||
const result = truncateJson(arr);
|
||||
expect(result).toBe(JSON.stringify(arr, null, 2));
|
||||
});
|
||||
|
||||
it('should stringify primitive values', () => {
|
||||
expect(truncateJson('hello')).toBe('"hello"');
|
||||
expect(truncateJson(42)).toBe('42');
|
||||
expect(truncateJson(true)).toBe('true');
|
||||
expect(truncateJson(null)).toBe('null');
|
||||
});
|
||||
|
||||
it('should stringify nested objects', () => {
|
||||
const nested = { a: { b: { c: 'deep' } } };
|
||||
const result = truncateJson(nested);
|
||||
expect(result).toBe(JSON.stringify(nested, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncation', () => {
|
||||
it('should truncate strings exceeding maxLength', () => {
|
||||
const largeObj = { data: 'x'.repeat(100) };
|
||||
const result = truncateJson(largeObj, { maxLength: 50 });
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(50 + '\n... (truncated)'.length);
|
||||
expect(result).toContain('... (truncated)');
|
||||
});
|
||||
|
||||
it('should not truncate strings within maxLength', () => {
|
||||
const smallObj = { name: 'test' };
|
||||
const result = truncateJson(smallObj, { maxLength: 1000 });
|
||||
|
||||
expect(result).not.toContain('... (truncated)');
|
||||
expect(result).toBe(JSON.stringify(smallObj, null, 2));
|
||||
});
|
||||
|
||||
it('should use MAX_EXECUTION_DATA_CHARS as default maxLength', () => {
|
||||
const largeObj = { data: 'x'.repeat(MAX_EXECUTION_DATA_CHARS + 100) };
|
||||
const result = truncateJson(largeObj);
|
||||
|
||||
expect(result).toContain('... (truncated)');
|
||||
});
|
||||
|
||||
it('should truncate at exact maxLength boundary', () => {
|
||||
const obj = { value: 'test' };
|
||||
const fullJson = JSON.stringify(obj, null, 2);
|
||||
const result = truncateJson(obj, { maxLength: fullJson.length });
|
||||
|
||||
expect(result).toBe(fullJson);
|
||||
expect(result).not.toContain('... (truncated)');
|
||||
});
|
||||
|
||||
it('should truncate when exceeding maxLength by one character', () => {
|
||||
const obj = { value: 'test' };
|
||||
const fullJson = JSON.stringify(obj, null, 2);
|
||||
const result = truncateJson(obj, { maxLength: fullJson.length - 1 });
|
||||
|
||||
expect(result).toContain('... (truncated)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('indent option', () => {
|
||||
it('should use default indent of 2', () => {
|
||||
const obj = { a: 1 };
|
||||
const result = truncateJson(obj);
|
||||
expect(result).toBe(JSON.stringify(obj, null, 2));
|
||||
});
|
||||
|
||||
it('should respect custom indent', () => {
|
||||
const obj = { a: 1 };
|
||||
const result = truncateJson(obj, { indent: 4 });
|
||||
expect(result).toBe(JSON.stringify(obj, null, 4));
|
||||
});
|
||||
|
||||
it('should support no indent (0)', () => {
|
||||
const obj = { a: 1 };
|
||||
const result = truncateJson(obj, { indent: 0 });
|
||||
expect(result).toBe(JSON.stringify(obj, null, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle circular references gracefully', () => {
|
||||
const circular: Record<string, unknown> = { name: 'test' };
|
||||
circular.self = circular;
|
||||
|
||||
const result = truncateJson(circular);
|
||||
expect(result).toBe('[Unable to serialize data]');
|
||||
});
|
||||
|
||||
it('should handle BigInt values gracefully', () => {
|
||||
const obj = { big: BigInt(9007199254740991) };
|
||||
|
||||
const result = truncateJson(obj);
|
||||
expect(result).toBe('[Unable to serialize data]');
|
||||
});
|
||||
|
||||
it('should handle undefined values in objects', () => {
|
||||
const obj = { a: undefined, b: 'test' };
|
||||
const result = truncateJson(obj);
|
||||
// JSON.stringify omits undefined values
|
||||
expect(result).toBe(JSON.stringify(obj, null, 2));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MAX_EXECUTION_DATA_CHARS } from '../constants';
|
||||
|
||||
export interface TruncateJsonOptions {
|
||||
maxLength?: number;
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely stringify and truncate JSON to a max length.
|
||||
* Handles circular references and other serialization errors gracefully.
|
||||
*
|
||||
* @param value - The value to stringify and truncate
|
||||
* @param options - Configuration options
|
||||
* @returns The stringified (and possibly truncated) JSON
|
||||
*/
|
||||
export function truncateJson(value: unknown, options: TruncateJsonOptions = {}): string {
|
||||
const { maxLength = MAX_EXECUTION_DATA_CHARS, indent = 2 } = options;
|
||||
|
||||
try {
|
||||
const result = JSON.stringify(value, null, indent);
|
||||
if (result.length <= maxLength) {
|
||||
return result;
|
||||
}
|
||||
return result.substring(0, maxLength) + '\n... (truncated)';
|
||||
} catch {
|
||||
return '[Unable to serialize data]';
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import type {
|
||||
IConnection,
|
||||
NodeConnectionType,
|
||||
INodeListSearchResult,
|
||||
IRunData,
|
||||
ITaskDataConnections,
|
||||
NodeExecutionSchema,
|
||||
Schema,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
@@ -416,6 +421,124 @@ export const setupWorkflowState = (
|
||||
});
|
||||
};
|
||||
|
||||
// ========== Execution Data Builders ==========
|
||||
|
||||
// Build run data entry from simple JSON data
|
||||
export interface MockRunDataEntry {
|
||||
json: Record<string, unknown>;
|
||||
startTime?: number;
|
||||
executionTime?: number;
|
||||
}
|
||||
|
||||
// Create mock run data from simplified entries
|
||||
export const createMockRunData = (entries: Record<string, MockRunDataEntry[]>): IRunData => {
|
||||
const runData: IRunData = {};
|
||||
let executionIndex = 0;
|
||||
for (const [nodeName, items] of Object.entries(entries)) {
|
||||
runData[nodeName] = [
|
||||
{
|
||||
data: {
|
||||
main: [items.map((item) => ({ json: item.json }))] as ITaskDataConnections['main'],
|
||||
},
|
||||
startTime: items[0]?.startTime ?? Date.now(),
|
||||
executionTime: items[0]?.executionTime ?? 100,
|
||||
executionIndex: executionIndex++,
|
||||
source: [null],
|
||||
},
|
||||
];
|
||||
}
|
||||
return runData;
|
||||
};
|
||||
|
||||
// Create mock execution schema from simplified entries
|
||||
export interface MockNodeSchema {
|
||||
nodeName: string;
|
||||
schema: Schema;
|
||||
}
|
||||
|
||||
export const createMockExecutionSchema = (nodeSchemas: MockNodeSchema[]): NodeExecutionSchema[] => {
|
||||
return nodeSchemas.map(({ nodeName, schema }) => ({
|
||||
nodeName,
|
||||
schema,
|
||||
}));
|
||||
};
|
||||
|
||||
// Helper to create a Schema object for testing
|
||||
export const createMockSchema = (
|
||||
type: Schema['type'],
|
||||
path: string,
|
||||
value: Schema['value'],
|
||||
key?: string,
|
||||
): Schema => ({
|
||||
type,
|
||||
path,
|
||||
value,
|
||||
...(key && { key }),
|
||||
});
|
||||
|
||||
// Generate large test data for truncation tests
|
||||
export const createLargeTestData = (itemCount = 100, fieldValueSize = 30): IDataObject[] => {
|
||||
return Array.from({ length: itemCount }, (_, i) => ({
|
||||
id: i,
|
||||
field: 'x'.repeat(fieldValueSize) + String(i),
|
||||
extra: 'y'.repeat(fieldValueSize),
|
||||
}));
|
||||
};
|
||||
|
||||
// ========== Extended Workflow State Setup ==========
|
||||
|
||||
export interface ExecutionDataOptions {
|
||||
runData?: IRunData;
|
||||
lastNodeExecuted?: string;
|
||||
error?: { message: string; description?: string };
|
||||
}
|
||||
|
||||
export interface ExpressionValueTestData {
|
||||
expression: string;
|
||||
resolvedValue: unknown;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStateOptions {
|
||||
workflow: SimpleWorkflow;
|
||||
executionData?: ExecutionDataOptions;
|
||||
executionSchema?: NodeExecutionSchema[];
|
||||
expressionValues?: Record<string, ExpressionValueTestData[]>;
|
||||
}
|
||||
|
||||
// Setup workflow state with execution context (extended version)
|
||||
export const setupWorkflowStateWithContext = (
|
||||
mockGetCurrentTaskInput: jest.MockedFunction<typeof getCurrentTaskInput>,
|
||||
options: WorkflowStateOptions,
|
||||
) => {
|
||||
mockGetCurrentTaskInput.mockReturnValue({
|
||||
workflowJSON: options.workflow,
|
||||
workflowOperations: null,
|
||||
workflowContext: {
|
||||
executionData: options.executionData ?? null,
|
||||
executionSchema: options.executionSchema ?? null,
|
||||
expressionValues: options.expressionValues ?? null,
|
||||
},
|
||||
workflowValidation: null,
|
||||
messages: [],
|
||||
previousSummary: 'EMPTY',
|
||||
});
|
||||
};
|
||||
|
||||
// ========== AI Workflow Helpers ==========
|
||||
|
||||
// Setup AI connections on workflow (e.g., model -> agent)
|
||||
export const setupAIWorkflowConnections = (
|
||||
workflow: SimpleWorkflow,
|
||||
modelNodeName: string,
|
||||
agentNodeName: string,
|
||||
connectionType: NodeConnectionType = 'ai_languageModel',
|
||||
) => {
|
||||
workflow.connections[modelNodeName] = {
|
||||
[connectionType]: [[{ node: agentNodeName, type: connectionType, index: 0 }]],
|
||||
};
|
||||
};
|
||||
|
||||
// ========== Common Tool Assertions ==========
|
||||
|
||||
// Expect tool success message
|
||||
|
||||
@@ -16,7 +16,7 @@ import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
|
||||
import { mapLegacyConnectionsToCanvasConnections } from '@/features/workflows/canvas/canvas.utils';
|
||||
import { getAuthTypeForNodeCredential, getMainAuthField } from '@/app/utils/nodeTypesUtils';
|
||||
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
|
||||
import type { IConnections, INode } from 'n8n-workflow';
|
||||
import { NodeHelpers, type IConnections, type INode } from 'n8n-workflow';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
export interface UpdateWorkflowOptions {
|
||||
@@ -116,6 +116,21 @@ export function useWorkflowUpdate() {
|
||||
name: nodeName, // Keep actual name (old if rename failed)
|
||||
});
|
||||
|
||||
// Resolve parameters with defaults to ensure all parameter values are properly initialized
|
||||
// This is necessary because AI builder may send partial parameters without defaults
|
||||
const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
if (nodeTypeDescription) {
|
||||
const resolvedParameters = NodeHelpers.getNodeParameters(
|
||||
nodeTypeDescription.properties ?? [],
|
||||
node.parameters,
|
||||
true, // returnDefaults
|
||||
false,
|
||||
node,
|
||||
nodeTypeDescription,
|
||||
);
|
||||
node.parameters = resolvedParameters ?? {};
|
||||
}
|
||||
|
||||
// Mark node as dirty if parameters changed
|
||||
if (!isEqual(existing.parameters, updated.parameters)) {
|
||||
workflowState.resetParametersLastUpdatedAt(nodeName);
|
||||
|
||||
Reference in New Issue
Block a user