Ashraf Al-Kassem Claude Sonnet 4.6 commited on
Commit
68e389f
·
1 Parent(s): 98b27e0

Mission M-D — Flow Builder Revamp

Browse files

- Unlocks CONDITION and WAIT_DELAY nodes (no longer blocked from publish)
- Adds PARALLEL, AGENT_HANDOFF, QUALIFICATION_GATE, INTENT_ROUTER node types
- builder_translator.translate() now returns dual output: (definition_json, adk_pipeline_config)
- New translate_to_adk_pipeline() function maps builder graph → ADK orchestrator config
- FlowVersion.adk_pipeline_config column (JSON) populated on publish
- ExecutionInstance.resume_at + resume_node_id columns for WAIT_DELAY resumption
- Alembic migration s9t0u1v2w3x4
- build_orchestrator() accepts pipeline_config; applies builder tone/routing overrides
- build_reply_agent() accepts tone + max_length params
- Celery: resume_waiting_instances_task (every 60s) resumes WAITING instances
- Frontend: ActionNode multi-handle (CONDITION: true/false, QUALIFICATION_GATE: qualified/unqualified)
- Frontend: NodePalette groups (Triggers, Agent Nodes, Flow Control, Routing)
- Frontend: full config forms for all new node types
- 352 tests passing (8 new)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/alembic/versions/s9t0u1v2w3x4_mission_md_flow_builder_revamp.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mission M-D: flow builder revamp — add adk_pipeline_config to flowversion,
2
+ add resume_at + resume_node_id to executioninstance
3
+
4
+ Revision ID: s9t0u1v2w3x4
5
+ Revises: r8s9t0u1v2w3
6
+ Create Date: 2026-03-24
7
+
8
+ """
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+
12
+ # revision identifiers
13
+ revision = "s9t0u1v2w3x4"
14
+ down_revision = "r8s9t0u1v2w3"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ bind = op.get_bind()
21
+ inspector = sa.inspect(bind)
22
+
23
+ # --- flowversion: add adk_pipeline_config ---
24
+ fv_cols = {c["name"] for c in inspector.get_columns("flowversion")}
25
+ if "adk_pipeline_config" not in fv_cols:
26
+ op.add_column(
27
+ "flowversion",
28
+ sa.Column("adk_pipeline_config", sa.JSON(), nullable=True),
29
+ )
30
+
31
+ # --- executioninstance: add resume_at + resume_node_id ---
32
+ ei_cols = {c["name"] for c in inspector.get_columns("executioninstance")}
33
+ if "resume_at" not in ei_cols:
34
+ op.add_column(
35
+ "executioninstance",
36
+ sa.Column("resume_at", sa.DateTime(), nullable=True),
37
+ )
38
+ if "resume_node_id" not in ei_cols:
39
+ op.add_column(
40
+ "executioninstance",
41
+ sa.Column("resume_node_id", sa.String(), nullable=True),
42
+ )
43
+
44
+
45
+ def downgrade() -> None:
46
+ op.drop_column("executioninstance", "resume_node_id")
47
+ op.drop_column("executioninstance", "resume_at")
48
+ op.drop_column("flowversion", "adk_pipeline_config")
backend/app/api/v1/automations.py CHANGED
@@ -472,8 +472,8 @@ async def publish_flow(
472
  "errors": errors,
473
  })
474
 
475
- # Translate to runtime contract
476
- definition_json = translate(draft.builder_graph_json)
477
 
478
  # Get next version number
479
  version_result = await db.execute(
@@ -488,6 +488,7 @@ async def publish_flow(
488
  flow_id=flow_id,
489
  version_number=new_version_number,
490
  definition_json=definition_json,
 
491
  is_published=True,
492
  created_at=now,
493
  updated_at=now,
 
472
  "errors": errors,
473
  })
474
 
475
+ # Translate to runtime contract (dual output: legacy + ADK pipeline)
476
+ definition_json, adk_pipeline_config = translate(draft.builder_graph_json)
477
 
478
  # Get next version number
479
  version_result = await db.execute(
 
488
  flow_id=flow_id,
489
  version_number=new_version_number,
490
  definition_json=definition_json,
491
+ adk_pipeline_config=adk_pipeline_config,
492
  is_published=True,
493
  created_at=now,
494
  updated_at=now,
backend/app/core/adk/agents/orchestrator.py CHANGED
@@ -37,6 +37,7 @@ async def build_orchestrator(
37
  conversation_id: UUID,
38
  session: AsyncSession,
39
  instance: ExecutionInstance,
 
40
  ) -> LlmAgent:
41
  """
42
  Build the full multi-agent graph for one conversation turn.
@@ -88,7 +89,12 @@ async def build_orchestrator(
88
  for c in criteria_result.scalars().all()
89
  ]
90
 
91
- # 5. Build sub-agents
 
 
 
 
 
92
  qualification_agent = build_qualification_agent(
93
  qualification_questions=qualification_questions,
94
  qualification_criteria=qualification_criteria,
@@ -96,9 +102,41 @@ async def build_orchestrator(
96
  instance=instance,
97
  )
98
  crm_agent = build_crm_agent(session=session, instance=instance)
99
- reply_agent = build_reply_agent(platform=platform, session=session, instance=instance)
 
 
 
 
 
 
100
  handover_agent = build_handover_agent(session=session, instance=instance)
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  # 6. Orchestrator instruction with injected state
103
  orchestrator_instruction = f"""You are the Conversation Orchestrator for this business's AI system.
104
 
@@ -115,7 +153,7 @@ Routing rules (follow in order):
115
  5. After any sub-agent completes its task, always end the turn by delegating to reply_agent to send a response.
116
 
117
  Business context and tone:
118
- {compiled.system_instruction}
119
  """
120
 
121
  return LlmAgent(
 
37
  conversation_id: UUID,
38
  session: AsyncSession,
39
  instance: ExecutionInstance,
40
+ pipeline_config: dict | None = None,
41
  ) -> LlmAgent:
42
  """
43
  Build the full multi-agent graph for one conversation turn.
 
89
  for c in criteria_result.scalars().all()
90
  ]
91
 
92
+ # 5. Extract builder-configured policies (Mission M-D)
93
+ _pipeline = pipeline_config or {}
94
+ _reply_cfg = _pipeline.get("agents", {}).get("reply", {})
95
+ _routing_rules = _pipeline.get("agents", {}).get("orchestrator", {}).get("routing_rules", [])
96
+
97
+ # Build sub-agents (apply builder tone/max_length overrides to reply agent)
98
  qualification_agent = build_qualification_agent(
99
  qualification_questions=qualification_questions,
100
  qualification_criteria=qualification_criteria,
 
102
  instance=instance,
103
  )
104
  crm_agent = build_crm_agent(session=session, instance=instance)
105
+ reply_agent = build_reply_agent(
106
+ platform=platform,
107
+ session=session,
108
+ instance=instance,
109
+ tone=_reply_cfg.get("tone", "professional"),
110
+ max_length=_reply_cfg.get("max_length", 160),
111
+ )
112
  handover_agent = build_handover_agent(session=session, instance=instance)
113
 
114
+ # Build routing rules addendum from builder config
115
+ _routing_addendum = ""
116
+ if _routing_rules:
117
+ rule_lines = []
118
+ for rule in _routing_rules:
119
+ rt = rule.get("type")
120
+ if rt == "qualification_gate":
121
+ rule_lines.append(
122
+ "- Qualification Gate: route qualified leads to the reply path, "
123
+ "unqualified leads to the qualification path."
124
+ )
125
+ elif rt == "intent_router":
126
+ for r in rule.get("routes", []):
127
+ rule_lines.append(
128
+ f"- If detected intent is '{r.get('intent')}', delegate to {r.get('agent')} agent."
129
+ )
130
+ elif rt == "parallel":
131
+ agents = ", ".join(rule.get("parallel_agents", []))
132
+ rule_lines.append(f"- Run these agents in parallel when appropriate: {agents}.")
133
+ elif rt == "agent_handoff":
134
+ rule_lines.append(
135
+ f"- Explicit handoff configured: delegate to {rule.get('target_agent')} agent."
136
+ )
137
+ if rule_lines:
138
+ _routing_addendum = "\nBuilder-configured routing rules (apply these in addition to defaults):\n" + "\n".join(rule_lines)
139
+
140
  # 6. Orchestrator instruction with injected state
141
  orchestrator_instruction = f"""You are the Conversation Orchestrator for this business's AI system.
142
 
 
153
  5. After any sub-agent completes its task, always end the turn by delegating to reply_agent to send a response.
154
 
155
  Business context and tone:
156
+ {compiled.system_instruction}{_routing_addendum}
157
  """
158
 
159
  return LlmAgent(
backend/app/core/adk/agents/reply.py CHANGED
@@ -19,6 +19,8 @@ def build_reply_agent(
19
  platform: str,
20
  session: AsyncSession,
21
  instance: ExecutionInstance,
 
 
22
  ) -> LlmAgent:
23
  """
24
  Build the reply agent for a specific platform.
@@ -27,12 +29,14 @@ def build_reply_agent(
27
  platform: Messaging platform ("whatsapp" | "messenger" | "instagram")
28
  session: DB session (bound via closure)
29
  instance: Current execution instance (bound via closure)
 
 
30
  """
31
  instruction = f"""You are the Reply Agent. Your ONLY job is to craft and send messages to the lead on {platform}.
32
 
33
  Rules:
34
- - Keep WhatsApp messages under 160 characters unless detail is genuinely required.
35
- - Match the business tone from the conversation context.
36
  - Never send multiple messages when one will do.
37
  - Use send_reply() for all text messages.
38
  - Use send_media_message() only when an image or document is explicitly needed.
 
19
  platform: str,
20
  session: AsyncSession,
21
  instance: ExecutionInstance,
22
+ tone: str = "professional",
23
+ max_length: int = 160,
24
  ) -> LlmAgent:
25
  """
26
  Build the reply agent for a specific platform.
 
29
  platform: Messaging platform ("whatsapp" | "messenger" | "instagram")
30
  session: DB session (bound via closure)
31
  instance: Current execution instance (bound via closure)
32
+ tone: Desired reply tone from builder config (default: professional)
33
+ max_length: Max message character length from builder config (default: 160)
34
  """
35
  instruction = f"""You are the Reply Agent. Your ONLY job is to craft and send messages to the lead on {platform}.
36
 
37
  Rules:
38
+ - Keep messages under {max_length} characters unless detail is genuinely required.
39
+ - Use a {tone} tone in all messages.
40
  - Never send multiple messages when one will do.
41
  - Use send_reply() for all text messages.
42
  - Use send_media_message() only when an image or document is explicitly needed.
backend/app/core/adk/runner.py CHANGED
@@ -19,6 +19,7 @@ from app.models.models import (
19
  ExecutionInstance,
20
  ExecutionStatus,
21
  ExecutionStepLog,
 
22
  Message,
23
  )
24
  from app.services.runtime_event_service import log_event
@@ -44,12 +45,17 @@ async def run_for_contact(
44
  marks the ExecutionInstance COMPLETED or FAILED.
45
  """
46
  try:
 
 
 
 
47
  agent = await build_orchestrator(
48
  workspace_id=workspace_id,
49
  contact_id=contact_id,
50
  conversation_id=conversation_id,
51
  session=session,
52
  instance=execution_instance,
 
53
  )
54
 
55
  session_service = LeadPilotSessionService(db_engine=engine)
 
19
  ExecutionInstance,
20
  ExecutionStatus,
21
  ExecutionStepLog,
22
+ FlowVersion,
23
  Message,
24
  )
25
  from app.services.runtime_event_service import log_event
 
45
  marks the ExecutionInstance COMPLETED or FAILED.
46
  """
47
  try:
48
+ # Load ADK pipeline config from the published FlowVersion
49
+ flow_version = await session.get(FlowVersion, execution_instance.flow_version_id)
50
+ pipeline_config = (flow_version.adk_pipeline_config or {}) if flow_version else {}
51
+
52
  agent = await build_orchestrator(
53
  workspace_id=workspace_id,
54
  contact_id=contact_id,
55
  conversation_id=conversation_id,
56
  session=session,
57
  instance=execution_instance,
58
+ pipeline_config=pipeline_config,
59
  )
60
 
61
  session_service = LeadPilotSessionService(db_engine=engine)
backend/app/core/celery_app.py CHANGED
@@ -39,6 +39,11 @@ celery_app.conf.beat_schedule = {
39
  "task": "app.workers.tasks.export_to_hf_task",
40
  "schedule": crontab(hour=2, minute=0),
41
  },
 
 
 
 
 
42
  }
43
 
44
  celery_app.autodiscover_tasks(["app.workers"])
 
39
  "task": "app.workers.tasks.export_to_hf_task",
40
  "schedule": crontab(hour=2, minute=0),
41
  },
42
+ # Mission M-D: resume WAITING ExecutionInstances after WAIT_DELAY expires
43
+ "resume_waiting_instances_every_minute": {
44
+ "task": "app.workers.tasks.resume_waiting_instances_task",
45
+ "schedule": 60.0,
46
+ },
47
  }
48
 
49
  celery_app.autodiscover_tasks(["app.workers"])
backend/app/domain/builder_translator.py CHANGED
@@ -1,9 +1,9 @@
1
  """
2
- Builder Translator — Mission 27
3
 
4
- Converts builder_graph_json (React Flow native format) into the runtime
5
- definition_json contract, validates graphs before publish, and simulates
6
- traversal without side effects.
7
 
8
  Builder graph format (stored in FlowDraft.builder_graph_json):
9
  {
@@ -22,6 +22,19 @@ Runtime definition_json format (stored in FlowVersion.definition_json):
22
  "nodes": [{"id": "...", "type": "TRIGGER", "config": {...}}, ...],
23
  "edges": [{"id": "...", "source_node_id": "...", "target_node_id": "...", "source_handle": null}]
24
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  """
26
  from __future__ import annotations
27
 
@@ -33,14 +46,17 @@ logger = logging.getLogger(__name__)
33
  # Trigger node types that map to runtime TRIGGER node type
34
  TRIGGER_NODE_TYPES = {"MESSAGE_INBOUND", "LEAD_AD_SUBMIT"}
35
 
36
- # Node types supported by the runtime engine
37
  RUNTIME_SUPPORTED_NODE_TYPES = {
38
- "AI_REPLY", "SEND_MESSAGE", "HUMAN_HANDOVER",
39
  "TAG_CONTACT", "ZOHO_UPSERT_LEAD",
 
 
 
40
  }
41
 
42
- # Builder-only node types (visual palette only; blocked from publishing)
43
- BUILDER_ONLY_NODE_TYPES = {"CONDITION", "WAIT_DELAY"}
44
 
45
 
46
  def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
@@ -76,16 +92,6 @@ def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
76
  "message": "Flow can only have one trigger node."
77
  })
78
 
79
- # Check builder-only (unsupported runtime) node types
80
- for node in nodes:
81
- nt = _get_node_type(node)
82
- if nt in BUILDER_ONLY_NODE_TYPES:
83
- errors.append({
84
- "node_id": node.get("id"),
85
- "field": "type",
86
- "message": f"Node type '{nt}' is not yet supported by the runtime engine — cannot publish. Remove it or replace it with a supported action."
87
- })
88
-
89
  # Build reachability set from trigger (BFS/DFS)
90
  if trigger_nodes:
91
  trigger_id = trigger_nodes[0].get("id")
@@ -116,7 +122,7 @@ def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
116
  config = node.get("data", {}).get("config", {})
117
  nid = node.get("id")
118
 
119
- if nt == "AI_REPLY":
120
  goal = (config.get("goal") or "").strip()
121
  if not goal:
122
  errors.append({
@@ -143,61 +149,160 @@ def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
143
  "message": "Tag Contact node requires a tag name."
144
  })
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  return errors
147
 
148
 
149
- def translate(builder_graph: dict[str, Any]) -> dict[str, Any]:
150
  """
151
- Translate builder_graph_json to runtime definition_json.
152
  Must only be called after validate_graph() returns an empty list.
153
- Returns the definition_json ready for FlowVersion.definition_json.
 
 
 
 
154
  """
155
- nodes_in = builder_graph.get("nodes", [])
156
- edges_in = builder_graph.get("edges", [])
 
157
 
158
- runtime_nodes = []
159
- start_node_id: str | None = None
160
 
161
- for node in nodes_in:
162
- nid = node.get("id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  node_type = _get_node_type(node)
164
- data = node.get("data", {})
165
- config = data.get("config", {})
166
 
167
  if node_type in TRIGGER_NODE_TYPES:
168
- # Trigger node → runtime TRIGGER type
169
- runtime_nodes.append({
170
- "id": nid,
171
- "type": "TRIGGER",
172
- "config": {
173
- "trigger_type": node_type,
174
- "platform": data.get("platform", config.get("platform", "whatsapp")),
175
- "keywords": data.get("keywords", config.get("keywords", [])),
176
- },
177
- })
178
- start_node_id = nid
179
- else:
180
- # Action node → preserve type and config
181
- runtime_nodes.append({
182
- "id": nid,
183
  "type": node_type,
184
- "config": config,
185
  })
186
 
187
- runtime_edges = []
188
- for edge in edges_in:
189
- runtime_edges.append({
190
- "id": edge.get("id"),
191
- "source_node_id": edge.get("source"),
192
- "target_node_id": edge.get("target"),
193
- "source_handle": edge.get("sourceHandle"),
194
- })
195
 
196
- return {
197
- "start_node_id": start_node_id,
198
- "nodes": runtime_nodes,
199
- "edges": runtime_edges,
200
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
 
203
  def simulate(
@@ -252,8 +357,8 @@ def simulate(
252
  step["description"] = f"Trigger: {node_type}"
253
  if mock_payload:
254
  step["mock_payload"] = mock_payload
255
- elif node_type == "AI_REPLY":
256
- step["description"] = "AI Reply — would generate a response using your workspace prompt configuration."
257
  step["goal"] = config.get("goal", "(no goal set)")
258
  step["would_dispatch"] = False
259
  elif node_type == "SEND_MESSAGE":
@@ -270,9 +375,27 @@ def simulate(
270
  step["description"] = f"Tag Contact — would apply tag '{config.get('tag', '')}' to the contact."
271
  elif node_type == "ZOHO_UPSERT_LEAD":
272
  step["description"] = "Zoho CRM — would upsert the contact as a lead in Zoho CRM."
273
- elif node_type in BUILDER_ONLY_NODE_TYPES:
274
- step["description"] = f"{node_type} — not yet supported by runtime. This node would be skipped."
275
- step["warning"] = "Builder-only node type"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  else:
277
  step["description"] = f"Unknown node type: {node_type}"
278
 
@@ -288,12 +411,79 @@ def simulate(
288
  }
289
 
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  def _get_node_type(node: dict) -> str:
292
  """Extract the business logic node type from a builder node."""
293
  data = node.get("data", {})
294
  return data.get("nodeType", "")
295
 
296
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  def _reachable_nodes(start_id: str, edges: list[dict]) -> set[str]:
298
  """BFS to find all node IDs reachable from start_id via edges."""
299
  edge_map: dict[str, list[str]] = {}
 
1
  """
2
+ Builder Translator — Mission 27 / M-D
3
 
4
+ Converts builder_graph_json (React Flow native format) into:
5
+ 1. runtime definition_json (legacy format, stored in FlowVersion for audit/display)
6
+ 2. adk_pipeline_config (new format, drives the ADK orchestrator at runtime)
7
 
8
  Builder graph format (stored in FlowDraft.builder_graph_json):
9
  {
 
22
  "nodes": [{"id": "...", "type": "TRIGGER", "config": {...}}, ...],
23
  "edges": [{"id": "...", "source_node_id": "...", "target_node_id": "...", "source_handle": null}]
24
  }
25
+
26
+ ADK pipeline config format (stored in FlowVersion.adk_pipeline_config):
27
+ {
28
+ "pipeline_type": "orchestrated",
29
+ "agents": {
30
+ "orchestrator": {"routing_rules": [...], "sub_agents": []},
31
+ "reply": {"tone": "...", "max_length": 160},
32
+ ...
33
+ },
34
+ "triggers": [...],
35
+ "condition_branches": [...],
36
+ "wait_delays": [...]
37
+ }
38
  """
39
  from __future__ import annotations
40
 
 
46
  # Trigger node types that map to runtime TRIGGER node type
47
  TRIGGER_NODE_TYPES = {"MESSAGE_INBOUND", "LEAD_AD_SUBMIT"}
48
 
49
+ # All node types supported by the runtime engine (Mission M-D: unlocked + new)
50
  RUNTIME_SUPPORTED_NODE_TYPES = {
51
+ "AI_REPLY", "AGENT_REPLY", "SEND_MESSAGE", "HUMAN_HANDOVER",
52
  "TAG_CONTACT", "ZOHO_UPSERT_LEAD",
53
+ "CONDITION", "WAIT_DELAY", # unlocked in M-D
54
+ "PARALLEL", "AGENT_HANDOFF", # new in M-D
55
+ "QUALIFICATION_GATE", "INTENT_ROUTER", # new in M-D
56
  }
57
 
58
+ # Builder-only node types blocked from publishing (now empty all types publishable)
59
+ BUILDER_ONLY_NODE_TYPES: set[str] = set()
60
 
61
 
62
  def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
 
92
  "message": "Flow can only have one trigger node."
93
  })
94
 
 
 
 
 
 
 
 
 
 
 
95
  # Build reachability set from trigger (BFS/DFS)
96
  if trigger_nodes:
97
  trigger_id = trigger_nodes[0].get("id")
 
122
  config = node.get("data", {}).get("config", {})
123
  nid = node.get("id")
124
 
125
+ if nt in ("AI_REPLY", "AGENT_REPLY"):
126
  goal = (config.get("goal") or "").strip()
127
  if not goal:
128
  errors.append({
 
149
  "message": "Tag Contact node requires a tag name."
150
  })
151
 
152
+ elif nt == "CONDITION":
153
+ if not (config.get("condition_type") or "").strip():
154
+ errors.append({
155
+ "node_id": nid,
156
+ "field": "config.condition_type",
157
+ "message": "Condition node requires a condition type (e.g. qualification_status, intent, tag)."
158
+ })
159
+ if not (config.get("operator") or "").strip():
160
+ errors.append({
161
+ "node_id": nid,
162
+ "field": "config.operator",
163
+ "message": "Condition node requires an operator (e.g. equals, contains)."
164
+ })
165
+
166
+ elif nt == "WAIT_DELAY":
167
+ delay = config.get("delay_seconds")
168
+ try:
169
+ delay_val = int(delay) if delay is not None else 0
170
+ except (ValueError, TypeError):
171
+ delay_val = 0
172
+ if delay_val < 60:
173
+ errors.append({
174
+ "node_id": nid,
175
+ "field": "config.delay_seconds",
176
+ "message": "Wait / Delay node requires a minimum delay of 60 seconds (1 minute)."
177
+ })
178
+
179
+ elif nt == "AGENT_HANDOFF":
180
+ if not (config.get("target_agent") or "").strip():
181
+ errors.append({
182
+ "node_id": nid,
183
+ "field": "config.target_agent",
184
+ "message": "Agent Handoff node requires a target agent name."
185
+ })
186
+
187
+ elif nt == "INTENT_ROUTER":
188
+ routes = config.get("routes") or []
189
+ if not routes:
190
+ errors.append({
191
+ "node_id": nid,
192
+ "field": "config.routes",
193
+ "message": "Intent Router node requires at least one intent-to-agent route."
194
+ })
195
+
196
+ # QUALIFICATION_GATE: no required config — reads workspace config at runtime
197
+ # PARALLEL: no required config — agents field is optional
198
+
199
  return errors
200
 
201
 
202
+ def translate(builder_graph: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
203
  """
204
+ Translate builder_graph_json to dual output.
205
  Must only be called after validate_graph() returns an empty list.
206
+
207
+ Returns:
208
+ (definition_json, adk_pipeline_config)
209
+ definition_json: legacy runtime format, stored in FlowVersion.definition_json
210
+ adk_pipeline_config: new ADK format, stored in FlowVersion.adk_pipeline_config
211
  """
212
+ definition_json = _translate_legacy(builder_graph)
213
+ adk_pipeline = translate_to_adk_pipeline(builder_graph)
214
+ return definition_json, adk_pipeline
215
 
 
 
216
 
217
+ def translate_to_adk_pipeline(builder_graph: dict[str, Any]) -> dict[str, Any]:
218
+ """
219
+ Translate builder_graph_json to an ADK pipeline config dict.
220
+ This drives how the OrchestratorAgent is configured at runtime.
221
+ """
222
+ nodes = builder_graph.get("nodes", [])
223
+ edges = builder_graph.get("edges", [])
224
+
225
+ pipeline: dict[str, Any] = {
226
+ "pipeline_type": "orchestrated",
227
+ "agents": {
228
+ "orchestrator": {"routing_rules": [], "sub_agents": []},
229
+ "qualification": {},
230
+ "crm": {},
231
+ "reply": {},
232
+ "handover": {},
233
+ },
234
+ "triggers": [],
235
+ "condition_branches": [],
236
+ "wait_delays": [],
237
+ }
238
+
239
+ for node in nodes:
240
  node_type = _get_node_type(node)
241
+ config = node.get("data", {}).get("config", {})
242
+ nid = node.get("id")
243
 
244
  if node_type in TRIGGER_NODE_TYPES:
245
+ pipeline["triggers"].append({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  "type": node_type,
247
+ "platform": node.get("data", {}).get("platform", config.get("platform", "whatsapp")),
248
  })
249
 
250
+ elif node_type in ("AI_REPLY", "AGENT_REPLY"):
251
+ pipeline["agents"]["reply"].update({
252
+ "goal": config.get("goal"),
253
+ "tone": config.get("tone", "professional"),
254
+ "max_length": config.get("max_length", 160),
255
+ "extra_instructions": config.get("extra_instructions"),
256
+ })
 
257
 
258
+ elif node_type == "CONDITION":
259
+ pipeline["condition_branches"].append({
260
+ "node_id": nid,
261
+ "condition_type": config.get("condition_type"),
262
+ "operator": config.get("operator"),
263
+ "value": config.get("value"),
264
+ "true_branch": _get_next_node(nid, edges, handle="true"),
265
+ "false_branch": _get_next_node(nid, edges, handle="false"),
266
+ })
267
+
268
+ elif node_type == "WAIT_DELAY":
269
+ pipeline["wait_delays"].append({
270
+ "node_id": nid,
271
+ "delay_seconds": config.get("delay_seconds", 3600),
272
+ "delay_unit": config.get("delay_unit", "hours"),
273
+ "resume_node_id": _get_next_node(nid, edges),
274
+ })
275
+
276
+ elif node_type == "QUALIFICATION_GATE":
277
+ pipeline["agents"]["orchestrator"]["routing_rules"].append({
278
+ "type": "qualification_gate",
279
+ "node_id": nid,
280
+ "qualified_branch": _get_next_node(nid, edges, handle="qualified"),
281
+ "unqualified_branch": _get_next_node(nid, edges, handle="unqualified"),
282
+ })
283
+
284
+ elif node_type == "INTENT_ROUTER":
285
+ pipeline["agents"]["orchestrator"]["routing_rules"].append({
286
+ "type": "intent_router",
287
+ "node_id": nid,
288
+ "routes": config.get("routes", []),
289
+ })
290
+
291
+ elif node_type == "PARALLEL":
292
+ pipeline["agents"]["orchestrator"]["routing_rules"].append({
293
+ "type": "parallel",
294
+ "node_id": nid,
295
+ "parallel_agents": config.get("agents", []),
296
+ })
297
+
298
+ elif node_type == "AGENT_HANDOFF":
299
+ pipeline["agents"]["orchestrator"]["routing_rules"].append({
300
+ "type": "agent_handoff",
301
+ "node_id": nid,
302
+ "target_agent": config.get("target_agent"),
303
+ })
304
+
305
+ return pipeline
306
 
307
 
308
  def simulate(
 
357
  step["description"] = f"Trigger: {node_type}"
358
  if mock_payload:
359
  step["mock_payload"] = mock_payload
360
+ elif node_type in ("AI_REPLY", "AGENT_REPLY"):
361
+ step["description"] = "Agent Reply — would generate a response using your workspace prompt configuration."
362
  step["goal"] = config.get("goal", "(no goal set)")
363
  step["would_dispatch"] = False
364
  elif node_type == "SEND_MESSAGE":
 
375
  step["description"] = f"Tag Contact — would apply tag '{config.get('tag', '')}' to the contact."
376
  elif node_type == "ZOHO_UPSERT_LEAD":
377
  step["description"] = "Zoho CRM — would upsert the contact as a lead in Zoho CRM."
378
+ elif node_type == "CONDITION":
379
+ step["description"] = (
380
+ f"Condition branches on '{config.get('condition_type', '?')}' "
381
+ f"{config.get('operator', '?')} '{config.get('value', '?')}'. "
382
+ "Simulation follows the 'true' branch."
383
+ )
384
+ elif node_type == "WAIT_DELAY":
385
+ delay = config.get("delay_seconds", 3600)
386
+ unit = config.get("delay_unit", "seconds")
387
+ step["description"] = f"Wait / Delay — would pause flow for {delay} {unit}."
388
+ step["note"] = "Simulation continues immediately; actual delay requires Celery beat."
389
+ elif node_type == "PARALLEL":
390
+ agents = config.get("agents", [])
391
+ step["description"] = f"Parallel — would run {len(agents)} agent(s) simultaneously: {', '.join(agents) or 'none configured'}."
392
+ elif node_type == "AGENT_HANDOFF":
393
+ step["description"] = f"Agent Handoff — would delegate to '{config.get('target_agent', '?')}' agent."
394
+ elif node_type == "QUALIFICATION_GATE":
395
+ step["description"] = "Qualification Gate — routes qualified leads to one branch, unqualified to another."
396
+ elif node_type == "INTENT_ROUTER":
397
+ routes = config.get("routes", [])
398
+ step["description"] = f"Intent Router — routes based on detected intent ({len(routes)} route(s) configured)."
399
  else:
400
  step["description"] = f"Unknown node type: {node_type}"
401
 
 
411
  }
412
 
413
 
414
+ # ---------------------------------------------------------------------------
415
+ # Private helpers
416
+ # ---------------------------------------------------------------------------
417
+
418
+ def _translate_legacy(builder_graph: dict[str, Any]) -> dict[str, Any]:
419
+ """
420
+ Translate builder_graph_json to the legacy runtime definition_json.
421
+ Stored in FlowVersion.definition_json for audit and display.
422
+ """
423
+ nodes_in = builder_graph.get("nodes", [])
424
+ edges_in = builder_graph.get("edges", [])
425
+
426
+ runtime_nodes = []
427
+ start_node_id: str | None = None
428
+
429
+ for node in nodes_in:
430
+ nid = node.get("id")
431
+ node_type = _get_node_type(node)
432
+ data = node.get("data", {})
433
+ config = data.get("config", {})
434
+
435
+ if node_type in TRIGGER_NODE_TYPES:
436
+ runtime_nodes.append({
437
+ "id": nid,
438
+ "type": "TRIGGER",
439
+ "config": {
440
+ "trigger_type": node_type,
441
+ "platform": data.get("platform", config.get("platform", "whatsapp")),
442
+ "keywords": data.get("keywords", config.get("keywords", [])),
443
+ },
444
+ })
445
+ start_node_id = nid
446
+ else:
447
+ runtime_nodes.append({
448
+ "id": nid,
449
+ "type": node_type,
450
+ "config": config,
451
+ })
452
+
453
+ runtime_edges = []
454
+ for edge in edges_in:
455
+ runtime_edges.append({
456
+ "id": edge.get("id"),
457
+ "source_node_id": edge.get("source"),
458
+ "target_node_id": edge.get("target"),
459
+ "source_handle": edge.get("sourceHandle"),
460
+ })
461
+
462
+ return {
463
+ "start_node_id": start_node_id,
464
+ "nodes": runtime_nodes,
465
+ "edges": runtime_edges,
466
+ }
467
+
468
+
469
  def _get_node_type(node: dict) -> str:
470
  """Extract the business logic node type from a builder node."""
471
  data = node.get("data", {})
472
  return data.get("nodeType", "")
473
 
474
 
475
+ def _get_next_node(node_id: str, edges: list[dict], handle: str | None = None) -> str | None:
476
+ """
477
+ Return the first target node ID connected from node_id.
478
+ If handle is specified, only consider edges with that sourceHandle.
479
+ """
480
+ for edge in edges:
481
+ if edge.get("source") == node_id:
482
+ if handle is None or edge.get("sourceHandle") == handle:
483
+ return edge.get("target")
484
+ return None
485
+
486
+
487
  def _reachable_nodes(start_id: str, edges: list[dict]) -> set[str]:
488
  """BFS to find all node IDs reachable from start_id via edges."""
489
  edge_map: dict[str, list[str]] = {}
backend/app/models/models.py CHANGED
@@ -169,7 +169,11 @@ class FlowVersion(BaseIDModel, table=True):
169
  version_number: int
170
  definition_json: Dict[str, Any] = Field(sa_column=Column(JSON))
171
  is_published: bool = Field(default=False)
172
-
 
 
 
 
173
  flow: "Flow" = Relationship(back_populates="versions")
174
 
175
  # --- Execution ---
@@ -188,6 +192,10 @@ class ExecutionInstance(WorkspaceScopedModel, table=True):
188
  abort_reason: Optional[str] = None
189
  aborted_at: Optional[datetime] = None
190
 
 
 
 
 
191
  __table_args__ = (
192
  Index("idx_exec_stats", "workspace_id", "created_at", "status"),
193
  )
 
169
  version_number: int
170
  definition_json: Dict[str, Any] = Field(sa_column=Column(JSON))
171
  is_published: bool = Field(default=False)
172
+ # Mission M-D: ADK pipeline config generated at publish time
173
+ adk_pipeline_config: Optional[Dict[str, Any]] = Field(
174
+ default=None, sa_column=Column(JSON)
175
+ )
176
+
177
  flow: "Flow" = Relationship(back_populates="versions")
178
 
179
  # --- Execution ---
 
192
  abort_reason: Optional[str] = None
193
  aborted_at: Optional[datetime] = None
194
 
195
+ # Mission M-D: WAIT_DELAY resume support
196
+ resume_at: Optional[datetime] = Field(default=None)
197
+ resume_node_id: Optional[str] = Field(default=None)
198
+
199
  __table_args__ = (
200
  Index("idx_exec_stats", "workspace_id", "created_at", "status"),
201
  )
backend/app/workers/tasks.py CHANGED
@@ -672,3 +672,64 @@ def export_to_hf_task():
672
  )
673
 
674
  run_async(_run())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
672
  )
673
 
674
  run_async(_run())
675
+
676
+
677
+ @celery_app.task(name="app.workers.tasks.resume_waiting_instances_task")
678
+ def resume_waiting_instances_task():
679
+ """
680
+ Every-minute task: find WAITING ExecutionInstances whose delay has elapsed
681
+ and re-trigger the ADK runner for them (Mission M-D: WAIT_DELAY support).
682
+ """
683
+ from sqlalchemy.ext.asyncio import AsyncSession
684
+ from app.models.models import Conversation
685
+
686
+ async def _run():
687
+ now = datetime.utcnow()
688
+ async with AsyncSession(engine, expire_on_commit=False) as session:
689
+ result = await session.execute(
690
+ select(ExecutionInstance).where(
691
+ ExecutionInstance.status == ExecutionStatus.WAITING,
692
+ ExecutionInstance.resume_at <= now,
693
+ )
694
+ )
695
+ instances = result.scalars().all()
696
+ if not instances:
697
+ return
698
+
699
+ for instance in instances:
700
+ try:
701
+ instance.status = ExecutionStatus.RUNNING
702
+ session.add(instance)
703
+ await session.flush()
704
+
705
+ # Resolve conversation_id for this contact in this workspace
706
+ conv_result = await session.execute(
707
+ select(Conversation).where(
708
+ Conversation.workspace_id == instance.workspace_id,
709
+ Conversation.contact_id == instance.contact_id,
710
+ ).limit(1)
711
+ )
712
+ conversation = conv_result.scalars().first()
713
+ if not conversation:
714
+ logger.warning(
715
+ f"[resume_waiting_instances_task] No conversation for instance {instance.id}"
716
+ )
717
+ continue
718
+
719
+ await run_for_contact(
720
+ workspace_id=instance.workspace_id,
721
+ contact_id=instance.contact_id,
722
+ conversation_id=conversation.id,
723
+ inbound_message="[Resumed after delay]",
724
+ execution_instance=instance,
725
+ session=session,
726
+ )
727
+ except Exception:
728
+ logger.exception(
729
+ f"[resume_waiting_instances_task] Failed to resume instance {instance.id}"
730
+ )
731
+
732
+ await session.commit()
733
+ logger.info(f"[resume_waiting_instances_task] Resumed {len(instances)} waiting instance(s)")
734
+
735
+ run_async(_run())
backend/tests/test_builder_translator.py CHANGED
@@ -121,8 +121,8 @@ def test_validate_graph_tag_contact_missing_tag():
121
  assert any(e["node_id"] == "node-1" for e in errors)
122
 
123
 
124
- def test_validate_graph_condition_node_blocked():
125
- """CONDITION node should be blocked from publish with a clear message."""
126
  graph = {
127
  "nodes": [
128
  make_trigger_node(),
@@ -131,11 +131,31 @@ def test_validate_graph_condition_node_blocked():
131
  "edges": [make_edge("e1", "trigger-1", "node-1")],
132
  }
133
  errors = validate_graph(graph)
134
- assert any(e["node_id"] == "node-1" and "not yet supported" in e["message"] for e in errors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
 
137
- def test_validate_graph_wait_delay_blocked():
138
- """WAIT_DELAY node should be blocked from publish."""
139
  graph = {
140
  "nodes": [
141
  make_trigger_node(),
@@ -144,7 +164,22 @@ def test_validate_graph_wait_delay_blocked():
144
  "edges": [make_edge("e1", "trigger-1", "node-1")],
145
  }
146
  errors = validate_graph(graph)
147
- assert any(e["node_id"] == "node-1" and "not yet supported" in e["message"] for e in errors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
 
150
  def test_validate_graph_disconnected_node():
@@ -180,9 +215,9 @@ def test_validate_graph_lead_ad_submit_trigger():
180
  # ---------------------------------------------------------------------------
181
 
182
  def test_translate_produces_valid_contract():
183
- """translate() should produce a valid runtime definition_json."""
184
  graph = make_valid_graph()
185
- definition = translate(graph)
186
 
187
  assert "nodes" in definition
188
  assert "edges" in definition
@@ -205,6 +240,10 @@ def test_translate_produces_valid_contract():
205
  assert edge["source_node_id"] == "trigger-1"
206
  assert edge["target_node_id"] == "node-1"
207
 
 
 
 
 
208
 
209
  def test_translate_sets_start_node_id():
210
  """start_node_id in output should match the trigger node id."""
@@ -215,14 +254,14 @@ def test_translate_sets_start_node_id():
215
  ],
216
  "edges": [make_edge("e1", "my-trigger", "step-1")],
217
  }
218
- definition = translate(graph)
219
  assert definition["start_node_id"] == "my-trigger"
220
 
221
 
222
  def test_translate_edge_format():
223
  """Edges in runtime format should use source_node_id and target_node_id."""
224
  graph = make_valid_graph()
225
- definition = translate(graph)
226
  edge = definition["edges"][0]
227
  assert "source_node_id" in edge
228
  assert "target_node_id" in edge
@@ -244,7 +283,7 @@ def test_translate_multiple_nodes():
244
  make_edge("e3", "n2", "n3"),
245
  ],
246
  }
247
- definition = translate(graph)
248
  assert len(definition["nodes"]) == 4
249
  assert len(definition["edges"]) == 3
250
  types = {n["type"] for n in definition["nodes"]}
 
121
  assert any(e["node_id"] == "node-1" for e in errors)
122
 
123
 
124
+ def test_validate_graph_condition_node_requires_config():
125
+ """CONDITION node without condition_type should return a config error (not a 'not supported' error)."""
126
  graph = {
127
  "nodes": [
128
  make_trigger_node(),
 
131
  "edges": [make_edge("e1", "trigger-1", "node-1")],
132
  }
133
  errors = validate_graph(graph)
134
+ # Should have config errors, NOT a "not yet supported" error
135
+ assert any(e["node_id"] == "node-1" for e in errors)
136
+ assert not any("not yet supported" in e["message"] for e in errors)
137
+ assert any("condition_type" in e.get("field", "") for e in errors)
138
+
139
+
140
+ def test_validate_graph_condition_node_valid():
141
+ """CONDITION node with required config should pass validation."""
142
+ graph = {
143
+ "nodes": [
144
+ make_trigger_node(),
145
+ make_action_node("node-1", "CONDITION", {
146
+ "condition_type": "qualification_status",
147
+ "operator": "equals",
148
+ "value": "qualified",
149
+ }),
150
+ ],
151
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
152
+ }
153
+ errors = validate_graph(graph)
154
+ assert errors == []
155
 
156
 
157
+ def test_validate_graph_wait_delay_requires_delay_seconds():
158
+ """WAIT_DELAY node without delay_seconds should return a config error (not 'not supported')."""
159
  graph = {
160
  "nodes": [
161
  make_trigger_node(),
 
164
  "edges": [make_edge("e1", "trigger-1", "node-1")],
165
  }
166
  errors = validate_graph(graph)
167
+ assert any(e["node_id"] == "node-1" for e in errors)
168
+ assert not any("not yet supported" in e["message"] for e in errors)
169
+ assert any("delay_seconds" in e.get("field", "") for e in errors)
170
+
171
+
172
+ def test_validate_graph_wait_delay_valid():
173
+ """WAIT_DELAY node with sufficient delay_seconds should pass."""
174
+ graph = {
175
+ "nodes": [
176
+ make_trigger_node(),
177
+ make_action_node("node-1", "WAIT_DELAY", {"delay_seconds": 3600}),
178
+ ],
179
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
180
+ }
181
+ errors = validate_graph(graph)
182
+ assert errors == []
183
 
184
 
185
  def test_validate_graph_disconnected_node():
 
215
  # ---------------------------------------------------------------------------
216
 
217
  def test_translate_produces_valid_contract():
218
+ """translate() should produce a valid runtime definition_json (first tuple element)."""
219
  graph = make_valid_graph()
220
+ definition, adk_pipeline = translate(graph)
221
 
222
  assert "nodes" in definition
223
  assert "edges" in definition
 
240
  assert edge["source_node_id"] == "trigger-1"
241
  assert edge["target_node_id"] == "node-1"
242
 
243
+ # ADK pipeline also returned
244
+ assert "pipeline_type" in adk_pipeline
245
+ assert adk_pipeline["pipeline_type"] == "orchestrated"
246
+
247
 
248
  def test_translate_sets_start_node_id():
249
  """start_node_id in output should match the trigger node id."""
 
254
  ],
255
  "edges": [make_edge("e1", "my-trigger", "step-1")],
256
  }
257
+ definition, _ = translate(graph)
258
  assert definition["start_node_id"] == "my-trigger"
259
 
260
 
261
  def test_translate_edge_format():
262
  """Edges in runtime format should use source_node_id and target_node_id."""
263
  graph = make_valid_graph()
264
+ definition, _ = translate(graph)
265
  edge = definition["edges"][0]
266
  assert "source_node_id" in edge
267
  assert "target_node_id" in edge
 
283
  make_edge("e3", "n2", "n3"),
284
  ],
285
  }
286
+ definition, _ = translate(graph)
287
  assert len(definition["nodes"]) == 4
288
  assert len(definition["edges"]) == 3
289
  types = {n["type"] for n in definition["nodes"]}
backend/tests/test_builder_translator_v2.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mission M-D: Builder translator v2 tests.
3
+ Tests for new node types: CONDITION, WAIT_DELAY, PARALLEL, AGENT_HANDOFF,
4
+ QUALIFICATION_GATE, INTENT_ROUTER, and the dual translate() output.
5
+ No DB required — pure logic tests.
6
+ """
7
+ import pytest
8
+ from app.domain.builder_translator import validate_graph, translate, translate_to_adk_pipeline
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Helpers (duplicated from test_builder_translator to keep tests isolated)
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def make_trigger_node(node_id="trigger-1", node_type="MESSAGE_INBOUND", platform="whatsapp"):
16
+ return {
17
+ "id": node_id,
18
+ "type": "triggerNode",
19
+ "position": {"x": 250, "y": 50},
20
+ "data": {"nodeType": node_type, "platform": platform, "config": {}},
21
+ }
22
+
23
+
24
+ def make_action_node(node_id, node_type, config=None):
25
+ return {
26
+ "id": node_id,
27
+ "type": "actionNode",
28
+ "position": {"x": 250, "y": 220},
29
+ "data": {"nodeType": node_type, "config": config or {}},
30
+ }
31
+
32
+
33
+ def make_edge(edge_id, source, target, source_handle=None):
34
+ e = {"id": edge_id, "source": source, "target": target}
35
+ if source_handle:
36
+ e["sourceHandle"] = source_handle
37
+ return e
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Test 1 — CONDITION node translates to condition_branches entry
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def test_condition_node_translates_to_condition_branches():
45
+ """CONDITION node should populate pipeline.condition_branches with true/false branch IDs."""
46
+ graph = {
47
+ "nodes": [
48
+ make_trigger_node(),
49
+ make_action_node("cond-1", "CONDITION", {
50
+ "condition_type": "qualification_status",
51
+ "operator": "equals",
52
+ "value": "qualified",
53
+ }),
54
+ make_action_node("true-node", "ZOHO_UPSERT_LEAD", {}),
55
+ make_action_node("false-node", "WAIT_DELAY", {"delay_seconds": 3600}),
56
+ ],
57
+ "edges": [
58
+ make_edge("e1", "trigger-1", "cond-1"),
59
+ make_edge("e2", "cond-1", "true-node", source_handle="true"),
60
+ make_edge("e3", "cond-1", "false-node", source_handle="false"),
61
+ ],
62
+ }
63
+ pipeline = translate_to_adk_pipeline(graph)
64
+
65
+ assert len(pipeline["condition_branches"]) == 1
66
+ branch = pipeline["condition_branches"][0]
67
+ assert branch["node_id"] == "cond-1"
68
+ assert branch["condition_type"] == "qualification_status"
69
+ assert branch["operator"] == "equals"
70
+ assert branch["value"] == "qualified"
71
+ assert branch["true_branch"] == "true-node"
72
+ assert branch["false_branch"] == "false-node"
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Test 2 — WAIT_DELAY node translates to wait_delays entry
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def test_wait_delay_node_translates_to_wait_delays():
80
+ """WAIT_DELAY node should populate pipeline.wait_delays with delay config."""
81
+ graph = {
82
+ "nodes": [
83
+ make_trigger_node(),
84
+ make_action_node("wait-1", "WAIT_DELAY", {
85
+ "delay_seconds": 3600,
86
+ "delay_unit": "hours",
87
+ }),
88
+ make_action_node("after-wait", "AI_REPLY", {"goal": "Follow up"}),
89
+ ],
90
+ "edges": [
91
+ make_edge("e1", "trigger-1", "wait-1"),
92
+ make_edge("e2", "wait-1", "after-wait"),
93
+ ],
94
+ }
95
+ pipeline = translate_to_adk_pipeline(graph)
96
+
97
+ assert len(pipeline["wait_delays"]) == 1
98
+ delay = pipeline["wait_delays"][0]
99
+ assert delay["node_id"] == "wait-1"
100
+ assert delay["delay_seconds"] == 3600
101
+ assert delay["delay_unit"] == "hours"
102
+ assert delay["resume_node_id"] == "after-wait"
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Test 3 — PARALLEL node adds parallel routing rule
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def test_parallel_node_adds_routing_rule():
110
+ """PARALLEL node should add a parallel routing rule to orchestrator.routing_rules."""
111
+ graph = {
112
+ "nodes": [
113
+ make_trigger_node(),
114
+ make_action_node("par-1", "PARALLEL", {"agents": ["crm", "reply"]}),
115
+ ],
116
+ "edges": [make_edge("e1", "trigger-1", "par-1")],
117
+ }
118
+ pipeline = translate_to_adk_pipeline(graph)
119
+
120
+ routing_rules = pipeline["agents"]["orchestrator"]["routing_rules"]
121
+ parallel_rule = next((r for r in routing_rules if r["type"] == "parallel"), None)
122
+ assert parallel_rule is not None
123
+ assert parallel_rule["parallel_agents"] == ["crm", "reply"]
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Test 4 — All new node types pass validation with valid config
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def test_all_new_node_types_pass_validation():
131
+ """A graph with CONDITION + WAIT_DELAY + PARALLEL + AGENT_HANDOFF + QUALIFICATION_GATE + INTENT_ROUTER
132
+ should all pass validation when properly configured."""
133
+ graph = {
134
+ "nodes": [
135
+ make_trigger_node(),
136
+ make_action_node("cond-1", "CONDITION", {
137
+ "condition_type": "qualification_status",
138
+ "operator": "equals",
139
+ "value": "qualified",
140
+ }),
141
+ make_action_node("wait-1", "WAIT_DELAY", {"delay_seconds": 3600}),
142
+ make_action_node("par-1", "PARALLEL", {"agents": ["crm", "reply"]}),
143
+ make_action_node("handoff-1", "AGENT_HANDOFF", {"target_agent": "handover"}),
144
+ make_action_node("gate-1", "QUALIFICATION_GATE", {}),
145
+ make_action_node("router-1", "INTENT_ROUTER", {
146
+ "routes": [{"intent": "complaint", "agent": "handover"}]
147
+ }),
148
+ ],
149
+ "edges": [
150
+ make_edge("e1", "trigger-1", "cond-1"),
151
+ make_edge("e2", "trigger-1", "wait-1"),
152
+ make_edge("e3", "trigger-1", "par-1"),
153
+ make_edge("e4", "trigger-1", "handoff-1"),
154
+ make_edge("e5", "trigger-1", "gate-1"),
155
+ make_edge("e6", "trigger-1", "router-1"),
156
+ ],
157
+ }
158
+ errors = validate_graph(graph)
159
+ # None of the new node types should produce "not yet supported" errors
160
+ assert not any("not yet supported" in e.get("message", "") for e in errors)
161
+ # No config errors for these properly-configured nodes
162
+ new_type_ids = {"cond-1", "wait-1", "par-1", "handoff-1", "gate-1", "router-1"}
163
+ config_errors = [e for e in errors if e.get("node_id") in new_type_ids]
164
+ assert config_errors == []
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Test 5 — translate() returns dual output (definition_json + adk_pipeline_config)
169
+ # ---------------------------------------------------------------------------
170
+
171
+ def test_translate_returns_dual_output():
172
+ """translate() must return a 2-tuple: (definition_json, adk_pipeline_config)."""
173
+ graph = {
174
+ "nodes": [
175
+ make_trigger_node(),
176
+ make_action_node("n1", "AI_REPLY", {"goal": "Greet lead", "tone": "friendly", "max_length": 120}),
177
+ ],
178
+ "edges": [make_edge("e1", "trigger-1", "n1")],
179
+ }
180
+ result = translate(graph)
181
+ assert isinstance(result, tuple)
182
+ assert len(result) == 2
183
+
184
+ definition_json, adk_pipeline = result
185
+
186
+ # definition_json has legacy shape
187
+ assert "start_node_id" in definition_json
188
+ assert "nodes" in definition_json
189
+ assert "edges" in definition_json
190
+
191
+ # adk_pipeline has new shape
192
+ assert adk_pipeline["pipeline_type"] == "orchestrated"
193
+ assert "agents" in adk_pipeline
194
+ assert "triggers" in adk_pipeline
195
+
196
+ # AI_REPLY config should be reflected in reply agent config
197
+ reply_cfg = adk_pipeline["agents"]["reply"]
198
+ assert reply_cfg.get("goal") == "Greet lead"
199
+ assert reply_cfg.get("tone") == "friendly"
200
+ assert reply_cfg.get("max_length") == 120
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Test 6 — QUALIFICATION_GATE + INTENT_ROUTER produce correct routing rules
205
+ # ---------------------------------------------------------------------------
206
+
207
+ def test_qualification_gate_and_intent_router_routing_rules():
208
+ """QUALIFICATION_GATE adds qualification_gate rule; INTENT_ROUTER adds intent_router rule."""
209
+ graph = {
210
+ "nodes": [
211
+ make_trigger_node(),
212
+ make_action_node("gate-1", "QUALIFICATION_GATE", {}),
213
+ make_action_node("router-1", "INTENT_ROUTER", {
214
+ "routes": [
215
+ {"intent": "complaint", "agent": "handover"},
216
+ {"intent": "pricing", "agent": "reply"},
217
+ ]
218
+ }),
219
+ ],
220
+ "edges": [
221
+ make_edge("e1", "trigger-1", "gate-1"),
222
+ make_edge("e2", "trigger-1", "router-1"),
223
+ ],
224
+ }
225
+ pipeline = translate_to_adk_pipeline(graph)
226
+ routing_rules = pipeline["agents"]["orchestrator"]["routing_rules"]
227
+
228
+ gate_rule = next((r for r in routing_rules if r["type"] == "qualification_gate"), None)
229
+ assert gate_rule is not None
230
+ assert gate_rule["node_id"] == "gate-1"
231
+
232
+ intent_rule = next((r for r in routing_rules if r["type"] == "intent_router"), None)
233
+ assert intent_rule is not None
234
+ assert len(intent_rule["routes"]) == 2
235
+ assert intent_rule["routes"][0]["intent"] == "complaint"
frontend/src/app/(dashboard)/automations/[id]/NodeConfigPanel.tsx CHANGED
@@ -1,6 +1,9 @@
1
  "use client";
2
 
3
- import { Bot, Send, User, Tag, Database, GitBranch, Clock, Info, X } from "lucide-react";
 
 
 
4
  import type { BuilderNode } from "@/lib/automations-api";
5
  import { cn } from "@/lib/utils";
6
 
@@ -204,18 +207,284 @@ function ZohoUpsertConfig() {
204
  );
205
  }
206
 
207
- function ComingSoonConfig({ nodeType }: { nodeType: string }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  return (
209
- <div className="flex items-start gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-900/40 border border-border text-sm text-muted-foreground">
210
- <Info className="w-4 h-4 shrink-0 mt-0.5" />
211
- <p>
212
- <strong>{nodeType}</strong> is coming soon and cannot be used in published
213
- automations yet. You can add it to the canvas for planning purposes.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  </p>
215
  </div>
216
  );
217
  }
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  function TriggerConfig({
220
  config,
221
  onChange,
@@ -256,24 +525,34 @@ interface NodeConfigPanelProps {
256
 
257
  const NODE_TYPE_ICONS: Record<string, React.ReactNode> = {
258
  AI_REPLY: <Bot className="w-4 h-4" />,
 
259
  SEND_MESSAGE: <Send className="w-4 h-4" />,
260
  HUMAN_HANDOVER: <User className="w-4 h-4" />,
261
  TAG_CONTACT: <Tag className="w-4 h-4" />,
262
  ZOHO_UPSERT_LEAD: <Database className="w-4 h-4" />,
263
  CONDITION: <GitBranch className="w-4 h-4" />,
264
  WAIT_DELAY: <Clock className="w-4 h-4" />,
 
 
 
 
265
  MESSAGE_INBOUND: <Bot className="w-4 h-4" />,
266
  LEAD_AD_SUBMIT: <Bot className="w-4 h-4" />,
267
  };
268
 
269
  const NODE_TYPE_LABELS: Record<string, string> = {
270
  AI_REPLY: "AI Reply",
 
271
  SEND_MESSAGE: "Send Message",
272
  HUMAN_HANDOVER: "Human Handover",
273
  TAG_CONTACT: "Tag Contact",
274
  ZOHO_UPSERT_LEAD: "Zoho: Upsert Lead",
275
  CONDITION: "Condition",
276
  WAIT_DELAY: "Wait / Delay",
 
 
 
 
277
  MESSAGE_INBOUND: "Inbound Message",
278
  LEAD_AD_SUBMIT: "Lead Ad Submission",
279
  };
@@ -327,7 +606,7 @@ export default function NodeConfigPanel({ node, onClose, onUpdateConfig }: NodeC
327
 
328
  {/* Config body */}
329
  <div className="px-4 py-4 flex-1 space-y-4">
330
- {nodeType === "AI_REPLY" && (
331
  <AiReplyConfig config={config} onChange={handleChange} />
332
  )}
333
  {nodeType === "SEND_MESSAGE" && (
@@ -340,10 +619,23 @@ export default function NodeConfigPanel({ node, onClose, onUpdateConfig }: NodeC
340
  <TagContactConfig config={config} onChange={handleChange} />
341
  )}
342
  {nodeType === "ZOHO_UPSERT_LEAD" && <ZohoUpsertConfig />}
343
- {(nodeType === "CONDITION" || nodeType === "WAIT_DELAY") && (
344
- <ComingSoonConfig nodeType={NODE_TYPE_LABELS[nodeType] ?? nodeType} />
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  )}
346
- {isTrigger && (nodeType === "MESSAGE_INBOUND") && (
347
  <TriggerConfig config={config} onChange={handleChange} />
348
  )}
349
  {isTrigger && nodeType === "LEAD_AD_SUBMIT" && (
 
1
  "use client";
2
 
3
+ import {
4
+ Bot, Send, User, Tag, Database, GitBranch, Clock, Info, X,
5
+ Shuffle, ArrowRightCircle, Filter, Map,
6
+ } from "lucide-react";
7
  import type { BuilderNode } from "@/lib/automations-api";
8
  import { cn } from "@/lib/utils";
9
 
 
207
  );
208
  }
209
 
210
+ function ConditionConfig({
211
+ config,
212
+ onChange,
213
+ }: {
214
+ config: Record<string, any>;
215
+ onChange: (updated: Record<string, any>) => void;
216
+ }) {
217
+ const CONDITION_TYPES = [
218
+ { value: "qualification_status", label: "Qualification Status" },
219
+ { value: "intent", label: "Detected Intent" },
220
+ { value: "tag", label: "Contact Tag" },
221
+ { value: "custom_field", label: "Custom Field" },
222
+ ];
223
+ const OPERATORS = [
224
+ { value: "equals", label: "Equals" },
225
+ { value: "not_equals", label: "Not Equals" },
226
+ { value: "contains", label: "Contains" },
227
+ { value: "greater_than", label: "Greater Than" },
228
+ { value: "less_than", label: "Less Than" },
229
+ ];
230
+
231
  return (
232
+ <div className="space-y-4">
233
+ <LabeledField label="Condition Type" required>
234
+ <select
235
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
236
+ value={config.condition_type ?? ""}
237
+ onChange={(e) => onChange({ ...config, condition_type: e.target.value })}
238
+ >
239
+ <option value="">Select a condition type...</option>
240
+ {CONDITION_TYPES.map((ct) => (
241
+ <option key={ct.value} value={ct.value}>{ct.label}</option>
242
+ ))}
243
+ </select>
244
+ </LabeledField>
245
+ <LabeledField label="Operator" required>
246
+ <select
247
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
248
+ value={config.operator ?? ""}
249
+ onChange={(e) => onChange({ ...config, operator: e.target.value })}
250
+ >
251
+ <option value="">Select an operator...</option>
252
+ {OPERATORS.map((op) => (
253
+ <option key={op.value} value={op.value}>{op.label}</option>
254
+ ))}
255
+ </select>
256
+ </LabeledField>
257
+ <LabeledField label="Value">
258
+ <input
259
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
260
+ placeholder="e.g. qualified"
261
+ value={config.value ?? ""}
262
+ onChange={(e) => onChange({ ...config, value: e.target.value })}
263
+ />
264
+ </LabeledField>
265
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-cyan-50 dark:bg-cyan-950/20 border border-cyan-200 text-xs text-cyan-700">
266
+ <Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
267
+ <p>Connect the <strong>TRUE</strong> handle to the path taken when the condition passes, and <strong>FALSE</strong> to the fallback path.</p>
268
+ </div>
269
+ </div>
270
+ );
271
+ }
272
+
273
+ function WaitDelayConfig({
274
+ config,
275
+ onChange,
276
+ }: {
277
+ config: Record<string, any>;
278
+ onChange: (updated: Record<string, any>) => void;
279
+ }) {
280
+ const UNITS = [
281
+ { value: "minutes", seconds: 60, label: "Minutes" },
282
+ { value: "hours", seconds: 3600, label: "Hours" },
283
+ { value: "days", seconds: 86400, label: "Days" },
284
+ ];
285
+
286
+ const unit = config.delay_unit ?? "hours";
287
+ const unitEntry = UNITS.find((u) => u.value === unit) ?? UNITS[1];
288
+ const displayValue = config.delay_seconds
289
+ ? Math.round(Number(config.delay_seconds) / unitEntry.seconds)
290
+ : "";
291
+
292
+ const handleChange = (rawValue: string, rawUnit: string) => {
293
+ const unitEntry2 = UNITS.find((u) => u.value === rawUnit) ?? UNITS[1];
294
+ const seconds = Math.max(60, (Number(rawValue) || 0) * unitEntry2.seconds);
295
+ onChange({ ...config, delay_seconds: seconds, delay_unit: rawUnit });
296
+ };
297
+
298
+ return (
299
+ <div className="space-y-4">
300
+ <LabeledField label="Delay Duration" required>
301
+ <div className="flex gap-2">
302
+ <input
303
+ type="number"
304
+ min="1"
305
+ className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
306
+ placeholder="e.g. 2"
307
+ value={displayValue}
308
+ onChange={(e) => handleChange(e.target.value, unit)}
309
+ />
310
+ <select
311
+ className="w-28 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
312
+ value={unit}
313
+ onChange={(e) => handleChange(String(displayValue), e.target.value)}
314
+ >
315
+ {UNITS.map((u) => (
316
+ <option key={u.value} value={u.value}>{u.label}</option>
317
+ ))}
318
+ </select>
319
+ </div>
320
+ <p className="text-xs text-muted-foreground mt-1">Minimum: 1 minute.</p>
321
+ </LabeledField>
322
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-indigo-50 dark:bg-indigo-950/20 border border-indigo-200 text-xs text-indigo-700">
323
+ <Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
324
+ <p>Execution pauses here. The flow resumes after the delay via a background scheduler.</p>
325
+ </div>
326
+ </div>
327
+ );
328
+ }
329
+
330
+ function ParallelConfig({
331
+ config,
332
+ onChange,
333
+ }: {
334
+ config: Record<string, any>;
335
+ onChange: (updated: Record<string, any>) => void;
336
+ }) {
337
+ const AVAILABLE_AGENTS = [
338
+ { value: "qualification", label: "Qualification Agent" },
339
+ { value: "crm", label: "CRM Agent" },
340
+ { value: "reply", label: "Reply Agent" },
341
+ { value: "handover", label: "Handover Agent" },
342
+ ];
343
+
344
+ const selected: string[] = Array.isArray(config.agents) ? config.agents : [];
345
+
346
+ const toggle = (value: string) => {
347
+ const next = selected.includes(value)
348
+ ? selected.filter((a) => a !== value)
349
+ : [...selected, value];
350
+ onChange({ ...config, agents: next });
351
+ };
352
+
353
+ return (
354
+ <div className="space-y-4">
355
+ <LabeledField label="Agents to run in parallel">
356
+ <div className="space-y-2">
357
+ {AVAILABLE_AGENTS.map((agent) => (
358
+ <label key={agent.value} className="flex items-center gap-2.5 cursor-pointer group">
359
+ <input
360
+ type="checkbox"
361
+ className="w-4 h-4 rounded border-border text-teal-600 focus:ring-teal-500"
362
+ checked={selected.includes(agent.value)}
363
+ onChange={() => toggle(agent.value)}
364
+ />
365
+ <span className="text-sm text-slate-700 group-hover:text-slate-900">{agent.label}</span>
366
+ </label>
367
+ ))}
368
+ </div>
369
+ </LabeledField>
370
+ <div className="flex items-start gap-2 p-2.5 rounded-lg bg-teal-50 dark:bg-teal-950/20 border border-teal-200 text-xs text-teal-700">
371
+ <Info className="w-3.5 h-3.5 shrink-0 mt-0.5" />
372
+ <p>Selected agents will run simultaneously. Results are merged before continuing.</p>
373
+ </div>
374
+ </div>
375
+ );
376
+ }
377
+
378
+ function AgentHandoffConfig({
379
+ config,
380
+ onChange,
381
+ }: {
382
+ config: Record<string, any>;
383
+ onChange: (updated: Record<string, any>) => void;
384
+ }) {
385
+ const AGENTS = [
386
+ { value: "qualification", label: "Qualification Agent" },
387
+ { value: "crm", label: "CRM Agent" },
388
+ { value: "reply", label: "Reply Agent" },
389
+ { value: "handover", label: "Handover Agent" },
390
+ ];
391
+
392
+ return (
393
+ <LabeledField label="Target Agent" required>
394
+ <select
395
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
396
+ value={config.target_agent ?? ""}
397
+ onChange={(e) => onChange({ ...config, target_agent: e.target.value })}
398
+ >
399
+ <option value="">Select an agent...</option>
400
+ {AGENTS.map((a) => (
401
+ <option key={a.value} value={a.value}>{a.label}</option>
402
+ ))}
403
+ </select>
404
+ </LabeledField>
405
+ );
406
+ }
407
+
408
+ function QualificationGateConfig() {
409
+ return (
410
+ <div className="space-y-3">
411
+ <div className="flex items-start gap-2 p-3 rounded-lg bg-lime-50 dark:bg-lime-950/20 border border-lime-200 text-sm text-lime-800">
412
+ <Info className="w-4 h-4 shrink-0 mt-0.5" />
413
+ <p>
414
+ Routes leads based on their qualification status. Connect the{" "}
415
+ <strong>QUALIFIED</strong> handle for qualified leads and the{" "}
416
+ <strong>UNQUALIFIED</strong> handle for those still being evaluated.
417
+ </p>
418
+ </div>
419
+ <p className="text-xs text-muted-foreground">
420
+ Qualification criteria are configured in your workspace settings.
421
  </p>
422
  </div>
423
  );
424
  }
425
 
426
+ function IntentRouterConfig({
427
+ config,
428
+ onChange,
429
+ }: {
430
+ config: Record<string, any>;
431
+ onChange: (updated: Record<string, any>) => void;
432
+ }) {
433
+ const routes: { intent: string; agent: string }[] = Array.isArray(config.routes) ? config.routes : [];
434
+
435
+ const AGENTS = ["qualification", "crm", "reply", "handover"];
436
+
437
+ const addRoute = () =>
438
+ onChange({ ...config, routes: [...routes, { intent: "", agent: "reply" }] });
439
+ const removeRoute = (i: number) =>
440
+ onChange({ ...config, routes: routes.filter((_, idx) => idx !== i) });
441
+ const updateRoute = (i: number, field: "intent" | "agent", val: string) =>
442
+ onChange({
443
+ ...config,
444
+ routes: routes.map((r, idx) => (idx === i ? { ...r, [field]: val } : r)),
445
+ });
446
+
447
+ return (
448
+ <div className="space-y-4">
449
+ <LabeledField label="Intent → Agent Routes" required>
450
+ <div className="space-y-2">
451
+ {routes.map((route, i) => (
452
+ <div key={i} className="flex gap-2 items-center">
453
+ <input
454
+ className="flex-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
455
+ placeholder="Intent (e.g. complaint)"
456
+ value={route.intent}
457
+ onChange={(e) => updateRoute(i, "intent", e.target.value)}
458
+ />
459
+ <select
460
+ className="w-28 rounded-lg border border-border bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
461
+ value={route.agent}
462
+ onChange={(e) => updateRoute(i, "agent", e.target.value)}
463
+ >
464
+ {AGENTS.map((a) => (
465
+ <option key={a} value={a}>{a}</option>
466
+ ))}
467
+ </select>
468
+ <button
469
+ onClick={() => removeRoute(i)}
470
+ className="p-1.5 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 transition-colors"
471
+ >
472
+ <X className="w-3.5 h-3.5" />
473
+ </button>
474
+ </div>
475
+ ))}
476
+ <button
477
+ onClick={addRoute}
478
+ className="text-xs text-teal-600 hover:text-teal-700 font-medium"
479
+ >
480
+ + Add route
481
+ </button>
482
+ </div>
483
+ </LabeledField>
484
+ </div>
485
+ );
486
+ }
487
+
488
  function TriggerConfig({
489
  config,
490
  onChange,
 
525
 
526
  const NODE_TYPE_ICONS: Record<string, React.ReactNode> = {
527
  AI_REPLY: <Bot className="w-4 h-4" />,
528
+ AGENT_REPLY: <Bot className="w-4 h-4" />,
529
  SEND_MESSAGE: <Send className="w-4 h-4" />,
530
  HUMAN_HANDOVER: <User className="w-4 h-4" />,
531
  TAG_CONTACT: <Tag className="w-4 h-4" />,
532
  ZOHO_UPSERT_LEAD: <Database className="w-4 h-4" />,
533
  CONDITION: <GitBranch className="w-4 h-4" />,
534
  WAIT_DELAY: <Clock className="w-4 h-4" />,
535
+ PARALLEL: <Shuffle className="w-4 h-4" />,
536
+ AGENT_HANDOFF: <ArrowRightCircle className="w-4 h-4" />,
537
+ QUALIFICATION_GATE: <Filter className="w-4 h-4" />,
538
+ INTENT_ROUTER: <Map className="w-4 h-4" />,
539
  MESSAGE_INBOUND: <Bot className="w-4 h-4" />,
540
  LEAD_AD_SUBMIT: <Bot className="w-4 h-4" />,
541
  };
542
 
543
  const NODE_TYPE_LABELS: Record<string, string> = {
544
  AI_REPLY: "AI Reply",
545
+ AGENT_REPLY: "Agent Reply",
546
  SEND_MESSAGE: "Send Message",
547
  HUMAN_HANDOVER: "Human Handover",
548
  TAG_CONTACT: "Tag Contact",
549
  ZOHO_UPSERT_LEAD: "Zoho: Upsert Lead",
550
  CONDITION: "Condition",
551
  WAIT_DELAY: "Wait / Delay",
552
+ PARALLEL: "Parallel",
553
+ AGENT_HANDOFF: "Agent Handoff",
554
+ QUALIFICATION_GATE: "Qualification Gate",
555
+ INTENT_ROUTER: "Intent Router",
556
  MESSAGE_INBOUND: "Inbound Message",
557
  LEAD_AD_SUBMIT: "Lead Ad Submission",
558
  };
 
606
 
607
  {/* Config body */}
608
  <div className="px-4 py-4 flex-1 space-y-4">
609
+ {(nodeType === "AI_REPLY" || nodeType === "AGENT_REPLY") && (
610
  <AiReplyConfig config={config} onChange={handleChange} />
611
  )}
612
  {nodeType === "SEND_MESSAGE" && (
 
619
  <TagContactConfig config={config} onChange={handleChange} />
620
  )}
621
  {nodeType === "ZOHO_UPSERT_LEAD" && <ZohoUpsertConfig />}
622
+ {nodeType === "CONDITION" && (
623
+ <ConditionConfig config={config} onChange={handleChange} />
624
+ )}
625
+ {nodeType === "WAIT_DELAY" && (
626
+ <WaitDelayConfig config={config} onChange={handleChange} />
627
+ )}
628
+ {nodeType === "PARALLEL" && (
629
+ <ParallelConfig config={config} onChange={handleChange} />
630
+ )}
631
+ {nodeType === "AGENT_HANDOFF" && (
632
+ <AgentHandoffConfig config={config} onChange={handleChange} />
633
+ )}
634
+ {nodeType === "QUALIFICATION_GATE" && <QualificationGateConfig />}
635
+ {nodeType === "INTENT_ROUTER" && (
636
+ <IntentRouterConfig config={config} onChange={handleChange} />
637
  )}
638
+ {isTrigger && nodeType === "MESSAGE_INBOUND" && (
639
  <TriggerConfig config={config} onChange={handleChange} />
640
  )}
641
  {isTrigger && nodeType === "LEAD_AD_SUBMIT" && (
frontend/src/app/(dashboard)/automations/[id]/NodePalette.tsx CHANGED
@@ -3,7 +3,10 @@
3
  import { useState, useEffect } from "react";
4
  import { useCatalog, type CatalogEntry } from "@/lib/catalog";
5
  import { apiClient } from "@/lib/api";
6
- import { Zap, Bot, Send, User, Tag, Database, GitBranch, Clock, GripVertical, Lock } from "lucide-react";
 
 
 
7
  import { cn } from "@/lib/utils";
8
 
9
  // ---------------------------------------------------------------------------
@@ -20,6 +23,10 @@ const ICON_MAP: Record<string, React.ReactNode> = {
20
  clock: <Clock className="w-4 h-4" />,
21
  message: <Zap className="w-4 h-4" />,
22
  zap: <Zap className="w-4 h-4" />,
 
 
 
 
23
  };
24
 
25
  // ---------------------------------------------------------------------------
@@ -171,12 +178,15 @@ export default function NodePalette({ hasTrigger }: NodePaletteProps) {
171
  {/* Divider */}
172
  <div className="mx-3 border-t border-border" />
173
 
174
- {/* Action Nodes */}
175
  <div className="px-3 py-4 space-y-2">
176
  <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 px-1 mb-3">
177
- Actions
178
  </p>
179
- {nodeTypes?.map((n) => (
 
 
 
180
  <PaletteItem
181
  key={n.key}
182
  nodeType={n.key}
@@ -188,21 +198,77 @@ export default function NodePalette({ hasTrigger }: NodePaletteProps) {
188
  )) ?? (
189
  <div className="space-y-1.5">
190
  {[
191
- { key: "AI_REPLY", label: "AI Reply", iconHint: "bot" },
192
  { key: "SEND_MESSAGE", label: "Send Message", iconHint: "send" },
193
  { key: "HUMAN_HANDOVER", label: "Human Handover", iconHint: "user" },
194
  { key: "TAG_CONTACT", label: "Tag Contact", iconHint: "tag" },
195
  { key: "ZOHO_UPSERT_LEAD", label: "Zoho: Upsert Lead", iconHint: "database" },
196
- { key: "CONDITION", label: "Condition", iconHint: "git-branch", comingSoon: true },
197
- { key: "WAIT_DELAY", label: "Wait / Delay", iconHint: "clock", comingSoon: true },
198
  ].map((n) => (
199
- <PaletteItem
200
- key={n.key}
201
- nodeType={n.key}
202
- label={n.label}
203
- iconHint={n.iconHint}
204
- comingSoon={"comingSoon" in n ? n.comingSoon : false}
205
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  ))}
207
  </div>
208
  )}
 
3
  import { useState, useEffect } from "react";
4
  import { useCatalog, type CatalogEntry } from "@/lib/catalog";
5
  import { apiClient } from "@/lib/api";
6
+ import {
7
+ Zap, Bot, Send, User, Tag, Database, GitBranch, Clock,
8
+ GripVertical, Lock, Shuffle, ArrowRightCircle, Filter, Map,
9
+ } from "lucide-react";
10
  import { cn } from "@/lib/utils";
11
 
12
  // ---------------------------------------------------------------------------
 
23
  clock: <Clock className="w-4 h-4" />,
24
  message: <Zap className="w-4 h-4" />,
25
  zap: <Zap className="w-4 h-4" />,
26
+ shuffle: <Shuffle className="w-4 h-4" />,
27
+ "arrow-right-circle": <ArrowRightCircle className="w-4 h-4" />,
28
+ filter: <Filter className="w-4 h-4" />,
29
+ map: <Map className="w-4 h-4" />,
30
  };
31
 
32
  // ---------------------------------------------------------------------------
 
178
  {/* Divider */}
179
  <div className="mx-3 border-t border-border" />
180
 
181
+ {/* Agent Nodes */}
182
  <div className="px-3 py-4 space-y-2">
183
  <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 px-1 mb-3">
184
+ Agent Nodes
185
  </p>
186
+ {nodeTypes?.filter((n) => [
187
+ "AI_REPLY", "AGENT_REPLY", "SEND_MESSAGE", "HUMAN_HANDOVER",
188
+ "TAG_CONTACT", "ZOHO_UPSERT_LEAD",
189
+ ].includes(n.key)).map((n) => (
190
  <PaletteItem
191
  key={n.key}
192
  nodeType={n.key}
 
198
  )) ?? (
199
  <div className="space-y-1.5">
200
  {[
201
+ { key: "AI_REPLY", label: "Agent Reply", iconHint: "bot" },
202
  { key: "SEND_MESSAGE", label: "Send Message", iconHint: "send" },
203
  { key: "HUMAN_HANDOVER", label: "Human Handover", iconHint: "user" },
204
  { key: "TAG_CONTACT", label: "Tag Contact", iconHint: "tag" },
205
  { key: "ZOHO_UPSERT_LEAD", label: "Zoho: Upsert Lead", iconHint: "database" },
 
 
206
  ].map((n) => (
207
+ <PaletteItem key={n.key} nodeType={n.key} label={n.label} iconHint={n.iconHint} />
208
+ ))}
209
+ </div>
210
+ )}
211
+ </div>
212
+
213
+ {/* Divider */}
214
+ <div className="mx-3 border-t border-border" />
215
+
216
+ {/* Flow Control */}
217
+ <div className="px-3 py-4 space-y-2">
218
+ <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 px-1 mb-3">
219
+ Flow Control
220
+ </p>
221
+ {nodeTypes?.filter((n) => [
222
+ "CONDITION", "WAIT_DELAY", "PARALLEL", "AGENT_HANDOFF",
223
+ ].includes(n.key)).map((n) => (
224
+ <PaletteItem
225
+ key={n.key}
226
+ nodeType={n.key}
227
+ label={n.label}
228
+ iconHint={n.icon_hint ?? "git-branch"}
229
+ comingSoon={n.runtime_supported === false}
230
+ locked={isLocked(n)}
231
+ />
232
+ )) ?? (
233
+ <div className="space-y-1.5">
234
+ {[
235
+ { key: "CONDITION", label: "Condition", iconHint: "git-branch" },
236
+ { key: "WAIT_DELAY", label: "Wait / Delay", iconHint: "clock" },
237
+ { key: "PARALLEL", label: "Parallel", iconHint: "shuffle" },
238
+ { key: "AGENT_HANDOFF", label: "Agent Handoff", iconHint: "arrow-right-circle" },
239
+ ].map((n) => (
240
+ <PaletteItem key={n.key} nodeType={n.key} label={n.label} iconHint={n.iconHint} />
241
+ ))}
242
+ </div>
243
+ )}
244
+ </div>
245
+
246
+ {/* Divider */}
247
+ <div className="mx-3 border-t border-border" />
248
+
249
+ {/* Routing */}
250
+ <div className="px-3 py-4 space-y-2">
251
+ <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 px-1 mb-3">
252
+ Routing
253
+ </p>
254
+ {nodeTypes?.filter((n) => [
255
+ "QUALIFICATION_GATE", "INTENT_ROUTER",
256
+ ].includes(n.key)).map((n) => (
257
+ <PaletteItem
258
+ key={n.key}
259
+ nodeType={n.key}
260
+ label={n.label}
261
+ iconHint={n.icon_hint ?? "filter"}
262
+ comingSoon={n.runtime_supported === false}
263
+ locked={isLocked(n)}
264
+ />
265
+ )) ?? (
266
+ <div className="space-y-1.5">
267
+ {[
268
+ { key: "QUALIFICATION_GATE", label: "Qualification Gate", iconHint: "filter" },
269
+ { key: "INTENT_ROUTER", label: "Intent Router", iconHint: "map" },
270
+ ].map((n) => (
271
+ <PaletteItem key={n.key} nodeType={n.key} label={n.label} iconHint={n.iconHint} />
272
  ))}
273
  </div>
274
  )}
frontend/src/app/(dashboard)/automations/[id]/nodes/ActionNode.tsx CHANGED
@@ -12,6 +12,10 @@ import {
12
  Clock,
13
  X,
14
  AlertCircle,
 
 
 
 
15
  } from "lucide-react";
16
  import { cn } from "@/lib/utils";
17
 
@@ -26,6 +30,8 @@ interface NodeMeta {
26
  headerClass: string; // Tailwind header background
27
  iconClass: string; // Tailwind icon background + text color
28
  summary: (config: Record<string, any>) => string | null;
 
 
29
  comingSoon?: boolean;
30
  }
31
 
@@ -38,6 +44,14 @@ const NODE_META: Record<string, NodeMeta> = {
38
  iconClass: "text-violet-600 bg-violet-100/50 border-violet-200/50",
39
  summary: (c) => c.goal ? `Goal: ${String(c.goal).substring(0, 40)}` : null,
40
  },
 
 
 
 
 
 
 
 
41
  SEND_MESSAGE: {
42
  label: "Send Message",
43
  icon: <Send className="w-4 h-4" />,
@@ -73,20 +87,58 @@ const NODE_META: Record<string, NodeMeta> = {
73
  CONDITION: {
74
  label: "Condition",
75
  icon: <GitBranch className="w-4 h-4" />,
76
- colorClass: "border-slate-300 hover:border-slate-400",
77
- headerClass: "bg-slate-50/50",
78
- iconClass: "text-slate-600 bg-slate-100/50 border-slate-200/50",
79
- summary: () => "Branch logic",
80
- comingSoon: true,
81
  },
82
  WAIT_DELAY: {
83
  label: "Wait / Delay",
84
  icon: <Clock className="w-4 h-4" />,
85
- colorClass: "border-slate-300 hover:border-slate-400",
86
- headerClass: "bg-slate-50/50",
87
- iconClass: "text-slate-600 bg-slate-100/50 border-slate-200/50",
88
- summary: () => "Pause flow",
89
- comingSoon: true,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  },
91
  };
92
 
@@ -174,12 +226,56 @@ function ActionNode({ id, data, selected }: NodeProps) {
174
  )}
175
  </div>
176
 
177
- {/* Output handle */}
178
- <Handle
179
- type="source"
180
- position={Position.Bottom}
181
- className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white !-bottom-1.5"
182
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </div>
184
  );
185
  }
 
12
  Clock,
13
  X,
14
  AlertCircle,
15
+ Shuffle,
16
+ ArrowRightCircle,
17
+ Filter,
18
+ Map,
19
  } from "lucide-react";
20
  import { cn } from "@/lib/utils";
21
 
 
30
  headerClass: string; // Tailwind header background
31
  iconClass: string; // Tailwind icon background + text color
32
  summary: (config: Record<string, any>) => string | null;
33
+ /** Nodes with conditional output handles (renders labeled true/false or qualified/unqualified handles) */
34
+ multiHandle?: "true-false" | "qualified-unqualified";
35
  comingSoon?: boolean;
36
  }
37
 
 
44
  iconClass: "text-violet-600 bg-violet-100/50 border-violet-200/50",
45
  summary: (c) => c.goal ? `Goal: ${String(c.goal).substring(0, 40)}` : null,
46
  },
47
+ AGENT_REPLY: {
48
+ label: "Agent Reply",
49
+ icon: <Bot className="w-4 h-4" />,
50
+ colorClass: "border-violet-300 hover:border-violet-400",
51
+ headerClass: "bg-violet-50/50",
52
+ iconClass: "text-violet-600 bg-violet-100/50 border-violet-200/50",
53
+ summary: (c) => c.goal ? `Goal: ${String(c.goal).substring(0, 40)}` : null,
54
+ },
55
  SEND_MESSAGE: {
56
  label: "Send Message",
57
  icon: <Send className="w-4 h-4" />,
 
87
  CONDITION: {
88
  label: "Condition",
89
  icon: <GitBranch className="w-4 h-4" />,
90
+ colorClass: "border-cyan-300 hover:border-cyan-400",
91
+ headerClass: "bg-cyan-50/50",
92
+ iconClass: "text-cyan-600 bg-cyan-100/50 border-cyan-200/50",
93
+ summary: (c) => c.condition_type ? `If ${c.condition_type} ${c.operator ?? ""} ${c.value ?? ""}`.trim() : "Configure condition",
94
+ multiHandle: "true-false",
95
  },
96
  WAIT_DELAY: {
97
  label: "Wait / Delay",
98
  icon: <Clock className="w-4 h-4" />,
99
+ colorClass: "border-indigo-300 hover:border-indigo-400",
100
+ headerClass: "bg-indigo-50/50",
101
+ iconClass: "text-indigo-600 bg-indigo-100/50 border-indigo-200/50",
102
+ summary: (c) => c.delay_seconds ? `Wait ${c.delay_seconds}s (${c.delay_unit ?? "seconds"})` : "Set delay",
103
+ },
104
+ PARALLEL: {
105
+ label: "Parallel",
106
+ icon: <Shuffle className="w-4 h-4" />,
107
+ colorClass: "border-teal-300 hover:border-teal-400",
108
+ headerClass: "bg-teal-50/50",
109
+ iconClass: "text-teal-600 bg-teal-100/50 border-teal-200/50",
110
+ summary: (c) => {
111
+ const agents: string[] = Array.isArray(c.agents) ? c.agents : [];
112
+ return agents.length ? `Run: ${agents.join(", ")}` : "Configure agents";
113
+ },
114
+ },
115
+ AGENT_HANDOFF: {
116
+ label: "Agent Handoff",
117
+ icon: <ArrowRightCircle className="w-4 h-4" />,
118
+ colorClass: "border-emerald-300 hover:border-emerald-400",
119
+ headerClass: "bg-emerald-50/50",
120
+ iconClass: "text-emerald-600 bg-emerald-100/50 border-emerald-200/50",
121
+ summary: (c) => c.target_agent ? `→ ${c.target_agent}` : "Select agent",
122
+ },
123
+ QUALIFICATION_GATE: {
124
+ label: "Qualification Gate",
125
+ icon: <Filter className="w-4 h-4" />,
126
+ colorClass: "border-lime-300 hover:border-lime-400",
127
+ headerClass: "bg-lime-50/50",
128
+ iconClass: "text-lime-600 bg-lime-100/50 border-lime-200/50",
129
+ summary: () => "Route qualified / unqualified",
130
+ multiHandle: "qualified-unqualified",
131
+ },
132
+ INTENT_ROUTER: {
133
+ label: "Intent Router",
134
+ icon: <Map className="w-4 h-4" />,
135
+ colorClass: "border-rose-300 hover:border-rose-400",
136
+ headerClass: "bg-rose-50/50",
137
+ iconClass: "text-rose-600 bg-rose-100/50 border-rose-200/50",
138
+ summary: (c) => {
139
+ const routes: any[] = Array.isArray(c.routes) ? c.routes : [];
140
+ return routes.length ? `${routes.length} route(s)` : "Configure routes";
141
+ },
142
  },
143
  };
144
 
 
226
  )}
227
  </div>
228
 
229
+ {/* Output handle(s) — single by default, two for branching nodes */}
230
+ {meta.multiHandle === "true-false" ? (
231
+ <div className="relative h-4 flex justify-between px-6">
232
+ <div className="relative flex flex-col items-center">
233
+ <span className="text-[9px] font-bold text-emerald-600 mb-0.5">TRUE</span>
234
+ <Handle
235
+ type="source"
236
+ position={Position.Bottom}
237
+ id="true"
238
+ className="!w-3 !h-3 !bg-emerald-400 !border-2 !border-white !static !transform-none"
239
+ />
240
+ </div>
241
+ <div className="relative flex flex-col items-center">
242
+ <span className="text-[9px] font-bold text-red-500 mb-0.5">FALSE</span>
243
+ <Handle
244
+ type="source"
245
+ position={Position.Bottom}
246
+ id="false"
247
+ className="!w-3 !h-3 !bg-red-400 !border-2 !border-white !static !transform-none"
248
+ />
249
+ </div>
250
+ </div>
251
+ ) : meta.multiHandle === "qualified-unqualified" ? (
252
+ <div className="relative h-4 flex justify-between px-4">
253
+ <div className="relative flex flex-col items-center">
254
+ <span className="text-[9px] font-bold text-lime-600 mb-0.5">QUALIFIED</span>
255
+ <Handle
256
+ type="source"
257
+ position={Position.Bottom}
258
+ id="qualified"
259
+ className="!w-3 !h-3 !bg-lime-500 !border-2 !border-white !static !transform-none"
260
+ />
261
+ </div>
262
+ <div className="relative flex flex-col items-center">
263
+ <span className="text-[9px] font-bold text-slate-500 mb-0.5">UNQUALIFIED</span>
264
+ <Handle
265
+ type="source"
266
+ position={Position.Bottom}
267
+ id="unqualified"
268
+ className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white !static !transform-none"
269
+ />
270
+ </div>
271
+ </div>
272
+ ) : (
273
+ <Handle
274
+ type="source"
275
+ position={Position.Bottom}
276
+ className="!w-3 !h-3 !bg-slate-400 !border-2 !border-white !-bottom-1.5"
277
+ />
278
+ )}
279
  </div>
280
  );
281
  }