Spaces:
Running
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 +48 -0
- backend/app/api/v1/automations.py +3 -2
- backend/app/core/adk/agents/orchestrator.py +41 -3
- backend/app/core/adk/agents/reply.py +6 -2
- backend/app/core/adk/runner.py +6 -0
- backend/app/core/celery_app.py +5 -0
- backend/app/domain/builder_translator.py +254 -64
- backend/app/models/models.py +9 -1
- backend/app/workers/tasks.py +61 -0
- backend/tests/test_builder_translator.py +50 -11
- backend/tests/test_builder_translator_v2.py +235 -0
- frontend/src/app/(dashboard)/automations/[id]/NodeConfigPanel.tsx +303 -11
- frontend/src/app/(dashboard)/automations/[id]/NodePalette.tsx +80 -14
- frontend/src/app/(dashboard)/automations/[id]/nodes/ActionNode.tsx +112 -16
|
@@ -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")
|
|
@@ -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,
|
|
@@ -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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
@@ -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
|
| 35 |
-
-
|
| 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.
|
|
@@ -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)
|
|
@@ -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"])
|
|
@@ -1,9 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
-
Builder Translator — Mission 27
|
| 3 |
|
| 4 |
-
Converts builder_graph_json (React Flow native format) into
|
| 5 |
-
definition_json
|
| 6 |
-
|
| 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 |
-
#
|
| 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 (
|
| 43 |
-
BUILDER_ONLY_NODE_TYPES =
|
| 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
|
| 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
|
| 152 |
Must only be called after validate_graph() returns an empty list.
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
"""
|
| 155 |
-
|
| 156 |
-
|
|
|
|
| 157 |
|
| 158 |
-
runtime_nodes = []
|
| 159 |
-
start_node_id: str | None = None
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
node_type = _get_node_type(node)
|
| 164 |
-
|
| 165 |
-
|
| 166 |
|
| 167 |
if node_type in TRIGGER_NODE_TYPES:
|
| 168 |
-
|
| 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 |
-
"
|
| 185 |
})
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
})
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 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
|
| 256 |
-
step["description"] = "
|
| 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
|
| 274 |
-
step["description"] =
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]] = {}
|
|
@@ -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 |
)
|
|
@@ -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())
|
|
@@ -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
|
| 125 |
-
"""CONDITION node should
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
-
def
|
| 138 |
-
"""WAIT_DELAY node should
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]}
|
|
@@ -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"
|
|
@@ -1,6 +1,9 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
return (
|
| 209 |
-
<div className="
|
| 210 |
-
<
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 344 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
)}
|
| 346 |
-
{isTrigger &&
|
| 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" && (
|
|
@@ -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 {
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{/*
|
| 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 |
-
|
| 178 |
</p>
|
| 179 |
-
{nodeTypes?.
|
|
|
|
|
|
|
|
|
|
| 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: "
|
| 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 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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 |
)}
|
|
@@ -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-
|
| 77 |
-
headerClass: "bg-
|
| 78 |
-
iconClass: "text-
|
| 79 |
-
summary: () => "
|
| 80 |
-
|
| 81 |
},
|
| 82 |
WAIT_DELAY: {
|
| 83 |
label: "Wait / Delay",
|
| 84 |
icon: <Clock className="w-4 h-4" />,
|
| 85 |
-
colorClass: "border-
|
| 86 |
-
headerClass: "bg-
|
| 87 |
-
iconClass: "text-
|
| 88 |
-
summary: () => "
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
},
|
| 91 |
};
|
| 92 |
|
|
@@ -174,12 +226,56 @@ function ActionNode({ id, data, selected }: NodeProps) {
|
|
| 174 |
)}
|
| 175 |
</div>
|
| 176 |
|
| 177 |
-
{/* Output handle */}
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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 |
}
|