Skip to content

Conversation

@OlegIvaniv
Copy link
Contributor

@OlegIvaniv OlegIvaniv commented Nov 26, 2025

Summary

This PR refactors the AI workflow builder from a single monolithic agent into a multi-agent system with specialized subgraphs. Each subgraph handles one phase of workflow building, coordinated by a supervisor.

Problem

The single-agent approach has limitations:

  • One agent must handle discovery, building, and configuration in a single context
  • Long prompt with all instructions leads to context dilution
  • Difficult to iterate on individual phases independently
  • No clear separation between structural and parameter changes

Solution

Split into four specialized agents, each with focused responsibilities:

User Request
     │
     ▼
┌─────────────┐
│  Supervisor │ ─── Routes based on intent
└─────────────┘
     │
     ├──► Discovery ─── Find relevant nodes
     │         │
     │         ▼
     ├──► Builder ───── Create structure
     │         │
     │         ▼
     └──► Configurator ─ Set parameters
              │
              ▼
┌─────────────┐
│  Responder  │ ─── Synthesize response
└─────────────┘
     │
     ▼
User Response

Architecture

Supervisor Agent

Routes user requests to the appropriate subgraph based on intent:

User Intent Route To
Question or chat Responder
New workflow or new node types Discovery
Connect/disconnect existing nodes Builder
Change parameter values Configurator

The supervisor only runs once at the start. After that, routing is deterministic: discovery → builder → configurator → responder.

Discovery Subgraph

Purpose: Find n8n nodes relevant to the user's request.

Tools: search_nodes, get_node_details, get_best_practices, submit_discovery_results

Process:

  1. Retrieve best practices for identified workflow patterns
  2. Search for nodes matching user requirements
  3. Get details for promising candidates
  4. Submit structured results with connection-affecting parameters

Output: List of nodes with {nodeName, version, reasoning, connectionChangingParameters}

The connection-changing parameters are critical—they tell the Builder which parameters affect what inputs/outputs a node has (e.g., Vector Store's mode parameter changes whether it accepts documents or queries).

Builder Subgraph

Purpose: Create workflow structure using discovery results.

Tools: add_nodes, connect_nodes, remove_node, remove_connection, validate_structure

Process:

  1. Create all nodes from discovery results
  2. Connect nodes following n8n connection rules
  3. Validate structure (trigger present, connections valid)
  4. Report what was built

Key prompt sections:

  • Workflow Configuration node placement (after trigger)
  • AI sub-node connection patterns (models/tools/memory → agent)
  • RAG workflow patterns (embeddings/loaders → vector store)
  • Connection type mapping (main, ai_languageModel, ai_tool, etc.)

The Builder must call validate_structure before finishing to catch missing triggers or invalid connections.

Configurator Subgraph

Purpose: Set parameters on all nodes after structure exists.

Tools: update_node_parameters, get_node_parameter, validate_configuration

Process:

  1. Configure each node with appropriate parameters
  2. Handle special cases ($fromAI, webhooks, credentials)
  3. Validate configuration (prompts present, valid $fromAI usage)
  4. Report setup instructions for user

Special handling:

  • $fromAI('key', 'description', 'type') expressions for AI tool nodes
  • Instance URL injection for webhook/chat trigger nodes
  • Credential placeholders for authenticated services

The Configurator must call validate_configuration before finishing.

Responder Agent

Purpose: Synthesize user-facing response from coordination log.

No tools—just a prompt that receives context about what was discovered, built, and configured, then generates a coherent response.

Output format:

  • Summary of what was built
  • Brief explanation of the workflow
  • Setup instructions (credentials, webhooks, etc.)
  • Prompt for adjustments

State Management

Parent Graph State

Shared state that coordinates between subgraphs:

{
  messages: BaseMessage[]        // User conversation
  workflowJSON: SimpleWorkflow   // Current workflow
  workflowContext: {...}         // Execution data/schema
  nextPhase: string              // Routing decision
  discoveryContext: {...}        // Nodes found, best practices
  workflowOperations: [...]      // Pending operations
  coordinationLog: [...]         // Phase completion records
}

Coordination Log

Tracks subgraph completion for deterministic routing:

{
  phase: 'discovery' | 'builder' | 'configurator',
  status: 'completed' | 'error',
  timestamp: number,
  summary: string,
  output?: string,  // Setup instructions from configurator
  metadata: {...}   // Phase-specific data
}

After each subgraph completes, getNextPhaseFromLog() determines the next step without LLM involvement.

Subgraph Isolation

Each subgraph has its own internal state:

  • DiscoverySubgraphState: userRequest, nodesFound, bestPractices
  • BuilderSubgraphState: workflowJSON, discoveryContext, workflowOperations
  • ConfiguratorSubgraphState: workflowJSON, instanceUrl, workflowOperations

State transforms at boundaries:

  • transformInput(): Extract what subgraph needs from parent state
  • transformOutput(): Return results to update parent state

Streaming

Event Formats

Parent graph events (legacy):

[streamMode, data]

Subgraph events (new):

[namespace[], streamMode, data]
// namespace like ['builder_subgraph:612f4bc3-...']

Filtering

Internal subgraph messages are filtered—users only see:

  • Tool progress events (real-time feedback)
  • Workflow updates (canvas changes)
  • Responder output (final response)

Messages from discovery/builder/configurator agents are suppressed.

Feature Flag

new WorkflowBuilderAgent({
  enableMultiAgent: true,  // Use multi-agent architecture
  // ... other config
});

When false (default), uses legacy single-agent for backward compatibility.

Validation Tools

Two new validation tools enforce correctness:

validate_structure (Builder):

  • Workflow has exactly one trigger node
  • All connections match valid input/output types
  • No dangling connections

validate_configuration (Configurator):

  • Agent nodes have system prompts
  • Tool nodes have required parameters
  • $fromAI expressions only in tool nodes

Subgraphs must call their validation tool before finishing.

Related Linear tickets, Github issues, and Community forum posts

Review / Merge checklist

  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with release/backport (if the PR is an urgent fix that needs to be backported)

Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
Signed-off-by: Oleg Ivaniv <[email protected]>
@n8n-assistant n8n-assistant bot added the n8n team Authored by the n8n team label Nov 26, 2025
@OlegIvaniv OlegIvaniv force-pushed the ai-1543-implement-discovery-subgraph branch from 96764fd to 59e64d4 Compare November 26, 2025 07:52
Copy link
Contributor

@mike12345567 mike12345567 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great and from my experience playing with it, it seems to be helping with generation, I think the discovery agent is doing a better job!

I feel like this really unlocks some great future avenues to test, like using Opus for the discovery agent - or integrating templates in different ways throughout the subgraphs. I can't wait to get this in/get some experiments running on it, great improvement and great work @OlegIvaniv!

}),

// Output: Categorization result
categorization: Annotation<PromptCategorization | undefined>({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is used, looking at the formatOutput it doesn't ever put anything in the categorization field.

// 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.
console.warn('[Discovery Subgraph] Agent stopped without submitting results');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the Logger rather than console logging directly - theres a few places I've noticed console logs which might need updated.

TChildState extends StateRecord = StateRecord,
TParentState extends StateRecord = StateRecord,
> {
name: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice - great to define this early!

- Document Loader defaults to 'json' but MUST be 'binary' when processing files
- HTTP Request defaults to GET but APIs often need POST
- Vector Store mode affects available connections - set explicitly (retrieve-as-tool when using with AI Agent)
</avoid_default_traps>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closing XML doesn't seem to have a section where it opens.


// 1. User request (primary)
if (userRequest) {
contextParts.push('=== USER REQUEST ===');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, do you think for the transform input/output it would be easier to read if we did something like:

const context = `=== USER REQUEST ===
${userRequest}
${parentState.discoveryContext?.bestPractices ?? ''}
=== WORKFLOW TO CONFIGURE ===
${buildWorkflowJsonBlock(parentState.workflowJSON)}
`.split('\n');

And so on - I feel like this might be a little easier to comprehend/adjust at a glance.

return 'No nodes in workflow';
}

const nodeList = workflow.nodes.map((n) => `- ${n.name} (${n.type})`).join('\n');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've written some functions in my templates PoC which converts a workflow to mermaid, that might be useful here, a very lightweight format to describe the whole workflow? Just commenting for the future!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I was also thinking about having some simplified WF representation, especially for builder subgraph that doesnt really need to see all params. If you already have something implemented that'd be very helpful!

function createMessageChunk(text: string): AgentMessageChunk {
return {
export function cleanContextTags(text: string): string {
return text.replace(/\n*<current_workflow_json>[\s\S]*?<\/current_execution_nodes_schemas>/, '');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first part is replacing an open tag, second is replacing a closing tag, is this correct?

/** Handle delete_messages node update */
function processDeleteMessages(update: unknown): StreamOutput | null {
const typed = update as { messages?: MessageContent[] };
if (!typed?.messages?.length) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typed is typed as never being null/undefined, but there is a typed? check here - if it can be undefined we should probably type it as { messages?: MessageContent[] } | undefined

/** Process a parent graph event */
function processParentEvent(event: ParentEvent): StreamOutput | null {
const [streamMode, chunk] = event;
if (typeof streamMode !== 'string' || streamMode.length <= 1) return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why checking if the the stream mode is a single character then return?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

n8n team Authored by the n8n team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants