feat(ai-builder): Properly separate system and user prompts in AI nodes (#21068)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg 2025-10-23 09:59:53 +02:00 committed by GitHub
parent 70523e19c8
commit 8659a73e31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 414 additions and 6 deletions

View File

@ -55,6 +55,9 @@ describe('evaluateAgentPrompt', () => {
position: [0, 0],
parameters: {
text: 'This is a static prompt without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
@ -67,7 +70,7 @@ describe('evaluateAgentPrompt', () => {
expect(result.violations[0]).toEqual({
type: 'minor',
description:
'Agent node "AI Agent" has no expression in its prompt field. This likely means it failed to use chatInput',
'Agent node "AI Agent" has no expression in its prompt field. This likely means it failed to use chatInput or dynamic context',
pointsDeducted: 15,
});
});
@ -83,6 +86,9 @@ describe('evaluateAgentPrompt', () => {
position: [0, 0],
parameters: {
text: '=Process this request: {{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
@ -105,6 +111,9 @@ describe('evaluateAgentPrompt', () => {
position: [0, 0],
parameters: {
text: '=Process: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
@ -115,6 +124,9 @@ describe('evaluateAgentPrompt', () => {
position: [100, 0],
parameters: {
text: "=Process: {{$('Chat Trigger'.params.chatInput)}}",
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
@ -125,6 +137,9 @@ describe('evaluateAgentPrompt', () => {
position: [200, 0],
parameters: {
text: '={{ $json.chatInput }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
@ -171,6 +186,9 @@ describe('evaluateAgentPrompt', () => {
parameters: {
promptType: 'define',
text: 'Static text without expressions',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
@ -200,8 +218,9 @@ describe('evaluateAgentPrompt', () => {
const result = evaluateAgentPrompt(workflow);
expect(result.violations).toHaveLength(1);
expect(result.violations[0].type).toBe('minor');
// Should have violations for: no expression + no systemMessage
expect(result.violations.length).toBeGreaterThanOrEqual(1);
expect(result.violations.some((v) => v.type === 'minor')).toBe(true);
});
it('should detect multiple agents with issues', () => {
@ -215,6 +234,9 @@ describe('evaluateAgentPrompt', () => {
position: [0, 0],
parameters: {
text: 'No expression here',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
@ -225,6 +247,9 @@ describe('evaluateAgentPrompt', () => {
position: [100, 0],
parameters: {
text: 'Also no expression',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
{
@ -235,6 +260,9 @@ describe('evaluateAgentPrompt', () => {
position: [200, 0],
parameters: {
text: '=Has expression: {{ $json.input }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
@ -247,4 +275,157 @@ describe('evaluateAgentPrompt', () => {
expect(result.violations[0].description).toContain('Agent 1');
expect(result.violations[1].description).toContain('Agent 2');
});
describe('System Message Separation', () => {
it('should flag agent with no systemMessage as major violation', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an orchestrator agent that coordinates specialized agents. Your task is to: 1) Call Research Agent 2) Call Fact-Check Agent. The research topic is: {{ $json.researchTopic }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have major violation for missing systemMessage
expect(result.violations.length).toBeGreaterThan(0);
const systemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(systemMessageViolation).toBeDefined();
expect(systemMessageViolation?.type).toBe('major');
expect(systemMessageViolation?.pointsDeducted).toBe(25);
});
it('should not flag agent when it has proper systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=You are an agent. Your task is to process: {{ $json.data }}',
options: {
systemMessage: 'You are a helpful agent.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should not have any system message violations
const systemMessageViolations = result.violations.filter((v) =>
v.description.includes('system message'),
);
expect(systemMessageViolations).toHaveLength(0);
});
it('should pass for properly separated agent configuration', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Orchestrator Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=The research topic is: {{ $json.researchTopic }}',
options: {
systemMessage:
'You are an orchestrator agent that coordinates specialized agents.\n\nYour task is to:\n1. Call the Research Agent Tool\n2. Call the Fact-Check Agent Tool\n3. Generate a report',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations
expect(result.violations).toHaveLength(0);
});
it('should handle agents with expressions in text and proper systemMessage', () => {
const testCases = [
{ text: "=You're analyzing: {{ $json.input }}" }, // Contains "you're" but has systemMessage
{ text: '=Process this data: {{ $json.data }}' },
{ text: '=User question: {{ $json.chatInput }}' },
{ text: '=Analyze for topic: {{ $json.researchTopic }}' },
];
testCases.forEach(({ text }, index) => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: `test-${index}`,
name: `Test Agent ${index}`,
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text,
options: {
systemMessage: 'You are a helpful assistant.',
},
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
// Should have no violations since systemMessage is present
expect(result.violations).toHaveLength(0);
});
});
it('should flag major violation when agent has no systemMessage', () => {
const workflow = mock<SimpleWorkflow>({
nodes: [
{
id: '1',
name: 'Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0],
parameters: {
promptType: 'define',
text: '=Process: {{ $json.input }}',
},
},
],
connections: {},
});
const result = evaluateAgentPrompt(workflow);
const noSystemMessageViolation = result.violations.find((v) =>
v.description.includes('no system message'),
);
expect(noSystemMessageViolation).toBeDefined();
expect(noSystemMessageViolation?.type).toBe('major');
expect(noSystemMessageViolation?.pointsDeducted).toBe(25);
});
});
});

View File

@ -1,3 +1,5 @@
import type { INodeParameters } from 'n8n-workflow';
import type { SimpleWorkflow } from '@/types';
import type { Violation } from '../../types/evaluation';
@ -6,7 +8,23 @@ import { containsExpression } from '../../utils/expressions';
import { calcSingleEvaluatorScore } from '../../utils/score';
/**
* Evaluates Agent nodes to ensure their prompts contain expressions.
* Type guard to check if a value is a valid options object with systemMessage
*/
function isOptionsWithSystemMessage(
value: unknown,
): value is { systemMessage?: string } & INodeParameters {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
('systemMessage' in value || Object.keys(value).length === 0)
);
}
/**
* Evaluates Agent nodes to ensure:
* 1. Their prompts contain expressions (for dynamic context)
* 2. They have a system message defined
* Agent nodes without expressions in prompts (e.g., that failed to use chatInput
* when there was a chat trigger) are most probably errors.
*/
@ -25,17 +43,35 @@ export function evaluateAgentPrompt(workflow: SimpleWorkflow): SingleEvaluatorRe
// Check the text parameter for expressions
const textParam = node.parameters?.text;
const promptType = node.parameters?.promptType;
const options = node.parameters?.options;
// Use type guard to safely access systemMessage
let systemMessage: string | undefined;
if (isOptionsWithSystemMessage(options)) {
systemMessage = options.systemMessage;
}
// Only check when promptType is 'define' or undefined (default)
// 'auto' mode means it uses text from previous node
if (promptType !== 'auto') {
// Check 1: Text parameter should contain expressions for dynamic context
if (!textParam || !containsExpression(textParam)) {
violations.push({
type: 'minor',
description: `Agent node "${node.name}" has no expression in its prompt field. This likely means it failed to use chatInput`,
description: `Agent node "${node.name}" has no expression in its prompt field. This likely means it failed to use chatInput or dynamic context`,
pointsDeducted: 15,
});
}
// Check 2: Agent should have a system message
// If systemMessage is missing, it likely means all instructions are in the text field
if (!systemMessage || systemMessage.trim().length === 0) {
violations.push({
type: 'major',
description: `Agent node "${node.name}" has no system message. System-level instructions (role, tasks, behavior) should be in the system message field, not the text field`,
pointsDeducted: 25,
});
}
}
}
}

View File

@ -0,0 +1,71 @@
export const SYSTEM_MESSAGE_GUIDE = `
## CRITICAL: System Message vs User Prompt Separation
AI nodes (AI Agent, LLM Chain, Anthropic, OpenAI, etc.) have TWO distinct prompt fields that MUST be used correctly:
### Field Separation
1. **System Message** - Model's persistent identity, role, and instructions
2. **User Message/Text** - Dynamic user context and data references per execution
**Node-specific field names:**
- AI Agent: \`options.systemMessage\` and \`text\`
- LLM Chain: \`messages.messageValues[]\` with system role and \`text\`
- Anthropic: \`options.system\` and \`messages.values[]\`
- OpenAI: \`messages.values[]\` with role "system" vs "user"
### System Message
Use for STATIC, ROLE-DEFINING content:
- Agent identity: "You are a [role] agent..."
- Task description: "Your task is to..."
- Step-by-step instructions: "1. Do X, 2. Do Y, 3. Do Z"
- Behavioral guidelines: "Always...", "Never..."
- Tool coordination: "Call the Research Tool to..., then call..."
### Text Parameter
Use for DYNAMIC, EXECUTION-SPECIFIC content:
- User input: "={{ $json.chatInput }}"
- Workflow context: "=The research topic is: {{ $json.topic }}"
- Data from previous nodes: "=Process this data: {{ $json.data }}"
- Variable context: "=Analyze for user: {{ $json.userId }}"
### Common Mistakes to Avoid
**WRONG - Everything in text field:**
{
"text": "=You are an orchestrator agent that coordinates specialized agents. Your task is to: 1) Call Research Agent Tool, 2) Call Fact-Check Agent Tool, 3) Generate report. Research topic: {{ $json.researchTopic }}"
}
**CORRECT - Properly separated:**
{
"text": "=The research topic is: {{ $json.researchTopic }}",
"options": {
"systemMessage": "You are an orchestrator agent that coordinates specialized agents.\\n\\nYour task is to:\\n1. Call the Research Agent Tool to gather information\\n2. Call the Fact-Check Agent Tool to verify findings\\n3. Generate a comprehensive report\\n\\nReturn ONLY the final report."
}
}
### Update Pattern Examples
Example 1 - AI Agent with orchestration:
Instructions: [
"Set text to '=Research topic: {{ $json.researchTopic }}'",
"Set system message to 'You are an orchestrator coordinating research tasks.\\n\\nSteps:\\n1. Call Research Agent Tool\\n2. Call Fact-Check Agent Tool\\n3. Call Report Writer Agent Tool\\n4. Return final HTML report'"
]
Example 2 - Chat-based AI node:
Instructions: [
"Set text to '=User question: {{ $json.chatInput }}'",
"Set system message to 'You are a helpful customer support assistant. Answer questions clearly and professionally. Escalate to human if needed.'"
]
Example 3 - Data processing with AI:
Instructions: [
"Set text to '=Process this data: {{ $json.inputData }}'",
"Set system message to 'You are a data analysis assistant.\\n\\nTasks:\\n1. Validate data structure\\n2. Calculate statistics\\n3. Identify anomalies\\n4. Return JSON with findings'"
]
### Why This Matters
- **Consistency**: System message stays the same across executions
- **Maintainability**: Easy to update AI behavior without touching data flow
- **Best Practice**: Follows standard AI prompt engineering patterns
- **Clarity**: Separates "what the AI model is" from "what data to process"
`;

View File

@ -14,6 +14,7 @@ import { IF_NODE_GUIDE } from './node-types/if-node';
import { SET_NODE_GUIDE } from './node-types/set-node';
import { TOOL_NODES_GUIDE } from './node-types/tool-nodes';
import { RESOURCE_LOCATOR_GUIDE } from './parameter-types/resource-locator';
import { SYSTEM_MESSAGE_GUIDE } from './parameter-types/system-message';
import { TEXT_FIELDS_GUIDE } from './parameter-types/text-fields';
import {
DEFAULT_PROMPT_CONFIG,
@ -35,7 +36,9 @@ export class ParameterUpdatePromptBuilder {
sections.push(EXPRESSION_RULES);
// Add node-type specific guides
if (this.isSetNode(context.nodeType)) {
if (this.hasSystemMessageParameters(context.nodeDefinition)) {
sections.push(SYSTEM_MESSAGE_GUIDE);
} else if (this.isSetNode(context.nodeType)) {
sections.push(SET_NODE_GUIDE);
} else if (this.isIfNode(context.nodeType)) {
sections.push(IF_NODE_GUIDE);
@ -78,6 +81,39 @@ export class ParameterUpdatePromptBuilder {
return finalPrompt;
}
/**
* Checks if node has system message parameters based on node definition
* This applies to nodes like AI Agent, LLM Chain, Anthropic, OpenAI, etc.
*/
private static hasSystemMessageParameters(nodeDefinition: INodeTypeDescription): boolean {
if (!nodeDefinition.properties) return false;
// Check for common system message parameter patterns
const hasSystemMessageParam = nodeDefinition.properties.some((prop) => {
// Pattern 1 & 2: options.systemMessage (AI Agent) or options.system (Anthropic)
if (prop.name === 'options' && prop.type === 'collection') {
const collectionProp = prop;
if (Array.isArray(collectionProp.options)) {
return collectionProp.options.some(
(opt) => opt.name === 'systemMessage' || opt.name === 'system',
);
}
}
// Pattern 3: messages parameter with role support (OpenAI, LLM Chain)
if (
prop.name === 'messages' &&
(prop.type === 'fixedCollection' || prop.type === 'collection')
) {
return true; // Messages typically support system role
}
return false;
});
return hasSystemMessageParam;
}
/**
* Checks if node is a Set node
*/

View File

@ -231,6 +231,90 @@ Configure multiple nodes in parallel:
- update_node_parameters({{ nodeId: "documentLoader1", instructions: ["Set dataType to 'binary' for processing PDF files", "Set loader to 'pdfLoader'", "Enable splitPages option"] }})
Why: Unconfigured nodes WILL fail at runtime
<system_message_configuration>
CRITICAL: For AI nodes (AI Agent, LLM Chain, Anthropic, OpenAI, etc.), you MUST separate system-level instructions from user context.
**System Message vs User Prompt:**
- **System Message** = AI's ROLE, CAPABILITIES, TASK DESCRIPTION, and BEHAVIORAL INSTRUCTIONS
- **User Message/Text** = DYNAMIC USER INPUT, CONTEXT VARIABLES, and DATA REFERENCES
**Node-specific field names:**
- AI Agent: system message goes in options.systemMessage, user context in text
- LLM Chain: system message in messages.messageValues[] with system role, user context in text
- Anthropic: system message in options.system, user context in messages.values[]
- OpenAI: system message in messages.values[] with role "system", user in messages.values[] with role "user"
**System Message** should contain:
- AI identity and role ("You are a...")
- Task description ("Your task is to...")
- Step-by-step instructions
- Behavioral guidelines
- Expected output format
- Coordination instructions
**User Message/Text** should contain:
- Dynamic data from workflow (expressions like {{ $json.field }})
- User input references ({{ $json.chatInput }})
- Context variables from previous nodes
- Minimal instruction (just what varies per execution)
**WRONG - Everything in text/user message field:**
text: "=You are an orchestrator that coordinates specialized AI tasks. Your task is to: 1) Call Research Tool 2) Call Fact-Check Tool 3) Return HTML. The research topic is: {{ $json.researchTopic }}"
**RIGHT - Properly separated:**
text: "=The research topic is: {{ $json.researchTopic }}"
System message: "You are an orchestrator that coordinates specialized AI tasks.\n\nYour task is to:\n1. Call the Research Agent Tool to gather information\n2. Call the Fact-Check Agent Tool to verify findings\n3. Call the Report Writer Agent Tool to create a report\n4. Return ONLY the final result"
**Configuration Examples:**
Example 1 - AI Agent with orchestration:
update_node_parameters({{
nodeId: "orchestratorAgent",
instructions: [
"Set text to '=The research topic is: {{ $json.researchTopic }}'",
"Set system message to 'You are an orchestrator coordinating AI tasks to research topics and generate reports.\\n\\nYour task is to:\\n1. Call the Research Agent Tool to gather information\\n2. Call the Fact-Check Agent Tool to verify findings (require 2+ sources)\\n3. Call the Report Writer Agent Tool to create a report under 1,000 words\\n4. Call the HTML Editor Agent Tool to format as HTML\\n5. Return ONLY the final HTML content'"
]
}})
Example 2 - AI Agent Tool (sub-agent):
update_node_parameters({{
nodeId: "subAgentTool",
instructions: [
"Set text to '=Process this input: {{ $fromAI(\\'input\\') }}'",
"Set system message to 'You are a specialized assistant. Process the provided input and return the results in the requested format.'"
]
}})
CRITICAL: AI Agent Tools MUST have BOTH system message AND text field configured:
- System message: Define the tool's role and capabilities
- Text field: Pass the context/input using $fromAI() to receive parameters from the parent agent
- Never leave text field empty - the tool needs to know what to process
Example 3 - Chat-based AI node:
update_node_parameters({{
nodeId: "chatAssistant",
instructions: [
"Set text to '=User question: {{ $json.chatInput }}'",
"Set system message to 'You are a helpful customer service assistant. Answer questions clearly and concisely. If you don\\'t know the answer, say so and offer to escalate to a human.'"
]
}})
Example 4 - Data processing AI:
update_node_parameters({{
nodeId: "analysisNode",
instructions: [
"Set text to '=Analyze this data: {{ $json.data }}'",
"Set system message to 'You are a data analysis assistant. Examine the provided data and:\\n1. Identify key patterns and trends\\n2. Calculate relevant statistics\\n3. Highlight anomalies or outliers\\n4. Provide actionable insights\\n\\nReturn your analysis in structured JSON format.'"
]
}})
**Why this matters:**
- Keeps AI behavior consistent (system message) while allowing dynamic context (user message)
- Makes workflows more maintainable and reusable
- Follows AI best practices for prompt engineering
- Prevents mixing static instructions with dynamic data
</system_message_configuration>
</configuration_requirements>
<data_parsing_strategy>