Agent pipelines (#193)

* Partial: Backend implementation for agent pipelines

* Add v1 functionality for pipelines

* Add ability to delete pipelines

* Improve agent addition modal

* Add transition messages for pipeline agents

* Update agent config for pipeline agents

* Modify configs for pipeline agents

* Fix agent type and output viz for pipeline agents
This commit is contained in:
Akhilesh Sudhakar 2025-08-07 10:33:02 +05:30 committed by GitHub
parent 97fad8633f
commit 96d87b6bdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1416 additions and 218 deletions

View file

@ -3,7 +3,7 @@ import { WithStringId } from "../../../lib/types/types";
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { z } from "zod";
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings } from "lucide-react";
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2, StarIcon, DatabaseIcon, UserIcon, Settings, Info } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "../workflow/preview-modal";
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Select, SelectItem, Chip, SelectSection } from "@heroui/react";
@ -19,7 +19,7 @@ import clsx from "clsx";
import { InputField } from "@/app/lib/components/input-field";
import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
import { Input } from "@/components/ui/input";
import { Info } from "lucide-react";
import { Info as InfoIcon } from "lucide-react";
import { useCopilot } from "../copilot/use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { ModelsResponse } from "@/app/lib/types/billing_types";
@ -39,6 +39,7 @@ export function AgentConfig({
workflow,
agent,
usedAgentNames,
usedPipelineNames,
agents,
tools,
prompts,
@ -54,6 +55,7 @@ export function AgentConfig({
workflow: z.infer<typeof Workflow>,
agent: z.infer<typeof WorkflowAgent>,
usedAgentNames: Set<string>,
usedPipelineNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[],
tools: z.infer<typeof WorkflowTool>[],
prompts: z.infer<typeof WorkflowPrompt>[],
@ -78,6 +80,9 @@ export function AgentConfig({
const [billingError, setBillingError] = useState<string | null>(null);
const [showSavedBanner, setShowSavedBanner] = useState(false);
// Check if this agent is a pipeline agent
const isPipelineAgent = agent.type === 'pipeline';
const {
start: startCopilotChat,
} = useCopilot({
@ -117,14 +122,31 @@ export function AgentConfig({
setShowRagCta(false);
};
// Add effect to handle control type update when transfer control is disabled or when internal agents have invalid control type
// Add effect to handle control type update to ensure agents have correct control types
useEffect(() => {
if (!USE_TRANSFER_CONTROL_OPTIONS && agent.controlType !== 'retain') {
handleUpdate({ ...agent, controlType: 'retain' });
let correctControlType: "retain" | "relinquish_to_parent" | "relinquish_to_start" | undefined = undefined;
// Determine the correct control type based on agent type and output visibility
if (agent.type === "pipeline") {
correctControlType = "relinquish_to_parent";
} else if (agent.outputVisibility === "internal") {
correctControlType = "relinquish_to_parent";
} else if (agent.outputVisibility === "user_facing") {
correctControlType = "retain";
}
// For internal agents, "retain" is not a valid option, so change it to "relinquish_to_parent"
if (agent.outputVisibility === "internal" && agent.controlType === 'retain') {
handleUpdate({ ...agent, controlType: 'relinquish_to_parent' });
// Handle undefined control type
if (agent.controlType === undefined) {
if (agent.outputVisibility === "user_facing") {
correctControlType = "retain";
} else {
correctControlType = "relinquish_to_parent";
}
}
// Update if the control type is incorrect
if (correctControlType && agent.controlType !== correctControlType) {
handleUpdate({ ...agent, controlType: correctControlType });
}
}, [agent.controlType, agent.outputVisibility, agent, handleUpdate]);
@ -151,7 +173,12 @@ export function AgentConfig({
return false;
}
if (value !== agent.name && usedAgentNames.has(value)) {
setNameError("This name is already taken");
setNameError("This name is already taken by another agent");
return false;
}
// Check for conflicts with pipeline names
if (usedPipelineNames.has(value)) {
setNameError("This name is already taken by a pipeline");
return false;
}
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
@ -175,10 +202,12 @@ export function AgentConfig({
};
const atMentions = createAtMentions({
agents,
agents: agents,
prompts,
tools,
currentAgentName: agent.name
pipelines: agent.type === "pipeline" ? [] : (workflow.pipelines || []), // Pipeline agents can't reference pipelines
currentAgentName: agent.name,
currentAgent: agent
});
// Add local state for max calls input
@ -211,7 +240,7 @@ export function AgentConfig({
<div className="flex flex-col gap-6 p-4 h-[calc(100vh-100px)] min-h-0 flex-1">
{/* Saved Banner */}
{showSavedBanner && (
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
@ -246,7 +275,7 @@ export function AgentConfig({
<div className="h-full flex flex-col">
{/* Saved Banner for maximized instructions */}
{showSavedBanner && (
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
@ -368,7 +397,7 @@ export function AgentConfig({
<div className="h-full flex flex-col">
{/* Saved Banner for maximized examples */}
{showSavedBanner && (
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
@ -500,20 +529,30 @@ export function AgentConfig({
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Agent Type</label>
<div className="flex-1">
<CustomDropdown
value={agent.outputVisibility}
options={[
{ key: "user_facing", label: "Conversation Agent" },
{ key: "internal", label: "Task Agent" }
]}
onChange={(value) => {
handleUpdate({
...agent,
outputVisibility: value as z.infer<typeof WorkflowAgent>["outputVisibility"]
});
showSavedMessage();
}}
/>
{isPipelineAgent ? (
// For pipeline agents, show read-only display
<div className="flex items-center gap-2 px-3 py-2 border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750 rounded-lg">
<span className="text-sm text-gray-900 dark:text-gray-100">
Pipeline Agent
</span>
</div>
) : (
// For non-pipeline agents, show dropdown without pipeline option
<CustomDropdown
value={agent.outputVisibility}
options={[
{ key: "user_facing", label: "Conversation Agent" },
{ key: "internal", label: "Task Agent" }
]}
onChange={(value) => {
handleUpdate({
...agent,
outputVisibility: value as z.infer<typeof WorkflowAgent>["outputVisibility"]
});
showSavedMessage();
}}
/>
)}
</div>
</div>
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
@ -585,7 +624,7 @@ export function AgentConfig({
}
</div>
</div>
{agent.outputVisibility === "internal" && (
{agent.outputVisibility === "internal" && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Max Calls From Parent</label>
<div className="flex-1">
@ -622,14 +661,18 @@ export function AgentConfig({
</div>
</div>
)}
{USE_TRANSFER_CONTROL_OPTIONS && (
{USE_TRANSFER_CONTROL_OPTIONS && !isPipelineAgent && (
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">After Turn</label>
<div className="flex-1">
<CustomDropdown
value={agent.controlType}
value={agent.controlType || 'retain'}
options={
agent.outputVisibility === "internal"
agent.type === "pipeline"
? [
{ key: "relinquish_to_parent", label: "Relinquish to parent" }
]
: agent.outputVisibility === "internal"
? [
{ key: "relinquish_to_parent", label: "Relinquish to parent" },
{ key: "relinquish_to_start", label: "Relinquish to 'start' agent" }

View file

@ -0,0 +1,182 @@
"use client";
import { WorkflowPipeline, WorkflowAgent, Workflow } from "../../../lib/types/workflow_types";
import { z } from "zod";
import { X as XIcon, Settings } from "lucide-react";
import { useState, useEffect } from "react";
import { Panel } from "@/components/common/panel-common";
import { Button as CustomButton } from "@/components/ui/button";
import { InputField } from "@/app/lib/components/input-field";
import { SectionCard } from "@/components/common/section-card";
// Common section header styles
const sectionHeaderStyles = "block text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400";
export function PipelineConfig({
projectId,
workflow,
pipeline,
usedPipelineNames,
usedAgentNames,
agents,
pipelines,
handleUpdate,
handleClose,
}: {
projectId: string,
workflow: z.infer<typeof Workflow>,
pipeline: z.infer<typeof WorkflowPipeline>,
usedPipelineNames: Set<string>,
usedAgentNames: Set<string>,
agents: z.infer<typeof WorkflowAgent>[],
pipelines: z.infer<typeof WorkflowPipeline>[],
handleUpdate: (pipeline: z.infer<typeof WorkflowPipeline>) => void,
handleClose: () => void,
}) {
const [localName, setLocalName] = useState(pipeline.name);
const [nameError, setNameError] = useState<string | null>(null);
const [showSavedBanner, setShowSavedBanner] = useState(false);
// Function to show saved banner
const showSavedMessage = () => {
setShowSavedBanner(true);
setTimeout(() => setShowSavedBanner(false), 2000);
};
useEffect(() => {
setLocalName(pipeline.name);
}, [pipeline.name]);
const validateName = (value: string) => {
if (value.length === 0) {
setNameError("Name cannot be empty");
return false;
}
// Check for conflicts with other pipeline names
if (value !== pipeline.name && usedPipelineNames.has(value)) {
setNameError("This name is already taken by another pipeline");
return false;
}
// Check for conflicts with agent names
if (usedAgentNames.has(value)) {
setNameError("This name is already taken by an agent");
return false;
}
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
setNameError("Name must contain only letters, numbers, underscores, hyphens, and spaces");
return false;
}
setNameError(null);
return true;
};
const handleNameChange = (value: string) => {
setLocalName(value);
if (validateName(value)) {
handleUpdate({
...pipeline,
name: value
});
}
showSavedMessage();
};
return (
<Panel
title={
<div className="flex items-center justify-between w-full">
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">
{pipeline.name}
</div>
<CustomButton
variant="secondary"
size="sm"
onClick={handleClose}
showHoverContent={true}
hoverContent="Close"
>
<XIcon className="w-4 h-4" />
</CustomButton>
</div>
}
>
<div className="flex flex-col gap-6 p-4 h-[calc(100vh-100px)] min-h-0 flex-1">
{/* Saved Banner */}
{showSavedBanner && (
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium">Changes saved</span>
</div>
)}
{/* Pipeline Configuration */}
<div className="flex flex-col gap-4 pb-4 pt-0">
{/* Identity Section Card */}
<SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />}
title="Identity"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
<div className="flex-1">
<InputField
type="text"
value={localName}
onChange={handleNameChange}
error={nameError}
className="w-full"
/>
</div>
</div>
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label>
<div className="flex-1">
<InputField
type="text"
value={pipeline.description || ""}
onChange={(value: string) => {
handleUpdate({ ...pipeline, description: value });
showSavedMessage();
}}
multiline={true}
placeholder="Enter a description for this pipeline"
className="w-full"
/>
</div>
</div>
</div>
</SectionCard>
{/* Pipeline Info */}
<SectionCard
icon={<Settings className="w-5 h-5 text-indigo-500" />}
title="Behavior"
labelWidth="md:w-32"
className="mb-1"
>
<div className="flex flex-col gap-4">
<div className="text-sm text-gray-600 dark:text-gray-400">
<div className="mb-2">
<span className="font-medium">Agents in Pipeline:</span> {pipeline.agents.length}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/30 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="font-medium mb-2">How Pipelines Work:</div>
<ul className="text-xs space-y-1 list-disc list-inside">
<li>Agents execute sequentially in the order shown</li>
<li>Output from one agent flows as input to the next</li>
<li>Add agents to this pipeline from the agents panel</li>
</ul>
</div>
</div>
</div>
</SectionCard>
</div>
</div>
</Panel>
);
}

View file

@ -79,7 +79,7 @@ export function PromptConfig({
<div className="flex flex-col gap-6 p-4">
{/* Saved Banner */}
{showSavedBanner && (
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>

View file

@ -358,7 +358,7 @@ export function ToolConfig({
<div className="flex flex-col gap-4 pb-4 pt-4 p-4">
{/* Saved Banner */}
{showSavedBanner && (
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<div className="absolute top-4 left-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>

View file

@ -89,20 +89,21 @@ function InternalAssistantMessage({ content, sender, latency, delta, showJsonMod
<span>{sender ?? 'Assistant'}</span>
{(Boolean(showDebugMessages && typeof onFix === 'function' && !isFirstAssistant)
|| Boolean(showDebugMessages && typeof onExplain === 'function' && !isFirstAssistant)
|| Boolean(isJsonContent && hasResponseKey)) && (
|| Boolean(isJsonContent)) && (
<MessageActionsMenu
showFix={Boolean(showDebugMessages && typeof onFix === 'function' && !isFirstAssistant)}
showExplain={Boolean(showDebugMessages && typeof onExplain === 'function' && !isFirstAssistant)}
showJson={Boolean(isJsonContent && hasResponseKey)}
showJson={Boolean(isJsonContent)}
onFix={onFix ? () => onFix(content, index) : () => {}}
onExplain={onExplain ? () => onExplain('assistant', content, index) : () => {}}
onJson={() => {}}
onJson={() => setJsonMode(!jsonMode)}
jsonLabel={jsonMode ? 'View formatted content' : 'View complete JSON'}
/>
)}
</div>
<div className="bg-gray-50 dark:bg-zinc-800 px-4 py-2.5 rounded-2xl rounded-bl-lg text-sm leading-relaxed text-gray-700 dark:text-gray-200 border-none shadow-sm animate-slideUpAndFade flex flex-col items-stretch">
<div className="text-left mb-2">
{isJsonContent && hasResponseKey && jsonMode && (
{isJsonContent && jsonMode && (
<div className="mb-2 flex gap-4">
<button
className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-300 hover:underline self-start"
@ -113,7 +114,7 @@ function InternalAssistantMessage({ content, sender, latency, delta, showJsonMod
</button>
</div>
)}
{isJsonContent && hasResponseKey && jsonMode ? (
{isJsonContent && jsonMode ? (
<pre
className={`text-xs leading-snug bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 rounded-lg px-2 py-1 font-mono shadow-sm border border-zinc-100 dark:border-zinc-700 ${
wrapText ? 'whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'
@ -728,9 +729,14 @@ export function Messages({
);
}
// Then check for internal messages
if (message.content && message.responseType === 'internal') {
// Skip internal messages if debug mode is off
// Then check for internal messages (including pipeline agents)
// Check both responseType === 'internal' and pipeline agents by type
const agentConfig = workflow.agents.find(a => a.name === message.agentName);
const isInternalOrPipeline = message.responseType === 'internal' ||
(agentConfig && (agentConfig.outputVisibility === 'internal' || agentConfig.type === 'pipeline'));
if (message.content && isInternalOrPipeline) {
// Skip internal/pipeline messages if debug mode is off
if (!showDebugMessages) {
return null;
}

View file

@ -1,6 +1,6 @@
import React, { forwardRef, useImperativeHandle } from "react";
import { z } from "zod";
import { WorkflowPrompt, WorkflowAgent, WorkflowTool, Workflow } from "../../../lib/types/workflow_types";
import { WorkflowPrompt, WorkflowAgent, WorkflowTool, WorkflowPipeline, Workflow } from "../../../lib/types/workflow_types";
import { Project } from "../../../lib/types/project_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { WithStringId } from "../../../lib/types/types";
@ -46,25 +46,30 @@ interface EntityListProps {
agents: z.infer<typeof WorkflowAgent>[];
tools: z.infer<typeof WorkflowTool>[];
prompts: z.infer<typeof WorkflowPrompt>[];
pipelines: z.infer<typeof WorkflowPipeline>[];
dataSources: WithStringId<z.infer<typeof DataSource>>[];
workflow: z.infer<typeof Workflow>;
selectedEntity: {
type: "agent" | "tool" | "prompt" | "datasource" | "visualise";
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
startAgentName: string | null;
onSelectAgent: (name: string) => void;
onSelectTool: (name: string) => void;
onSelectPrompt: (name: string) => void;
onSelectPipeline: (name: string) => void;
onSelectDataSource?: (id: string) => void;
onAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
onAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
onAddPipeline: (pipeline: Partial<z.infer<typeof WorkflowPipeline>>) => void;
onAddAgentToPipeline: (pipelineName: string) => void;
onToggleAgent: (name: string) => void;
onSetMainAgent: (name: string) => void;
onDeleteAgent: (name: string) => void;
onDeleteTool: (name: string) => void;
onDeletePrompt: (name: string) => void;
onDeletePipeline: (name: string) => void;
onShowVisualise: (name: string) => void;
onProjectToolsUpdated?: () => void;
onDataSourcesUpdated?: () => void;
@ -72,6 +77,7 @@ interface EntityListProps {
useRagUploads: boolean;
useRagS3Uploads: boolean;
useRagScraping: boolean;
onReorderPipelines: (pipelines: z.infer<typeof WorkflowPipeline>[]) => void;
}
interface EmptyStateProps {
@ -174,7 +180,7 @@ interface ServerCardProps {
serverName: string;
tools: z.infer<typeof WorkflowTool>[];
selectedEntity: {
type: "agent" | "tool" | "prompt" | "datasource" | "visualise";
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
onSelectTool: (name: string) => void;
@ -258,6 +264,163 @@ type ComposioToolkit = {
tools: z.infer<typeof WorkflowTool>[];
}
interface PipelineCardProps {
pipeline: z.infer<typeof WorkflowPipeline>;
agents: z.infer<typeof WorkflowAgent>[];
selectedEntity: {
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
onSelectPipeline: (name: string) => void;
onSelectAgent: (name: string) => void;
onDeletePipeline: (name: string) => void;
onDeleteAgent: (name: string) => void;
onAddAgentToPipeline: (pipelineName: string) => void;
selectedRef: React.RefObject<HTMLButtonElement | null>;
startAgentName: string | null;
dragHandle?: React.ReactNode;
}
const PipelineCard = ({
pipeline,
agents,
selectedEntity,
onSelectPipeline,
onSelectAgent,
onDeletePipeline,
onDeleteAgent,
onAddAgentToPipeline,
selectedRef,
startAgentName,
dragHandle,
}: PipelineCardProps) => {
// Get agents that belong to this pipeline
const pipelineAgents = pipeline.agents
.map(agentName => agents.find(agent => agent.name === agentName))
.filter(Boolean) as z.infer<typeof WorkflowAgent>[];
// Check if any agent in this pipeline is currently selected
const hasSelectedAgent = selectedEntity?.type === "agent" &&
pipeline.agents.includes(selectedEntity.name);
// Track expansion state - allow manual override even when agent is selected
const [isExpanded, setIsExpanded] = useState(false);
const [lastSelectedAgent, setLastSelectedAgent] = useState<string | null>(null);
// Auto-expand when a new agent in this pipeline is selected
useEffect(() => {
if (hasSelectedAgent && selectedEntity?.name !== lastSelectedAgent) {
setIsExpanded(true);
setLastSelectedAgent(selectedEntity?.name || null);
} else if (!hasSelectedAgent) {
setLastSelectedAgent(null);
}
}, [hasSelectedAgent, selectedEntity?.name, lastSelectedAgent]);
return (
<div className="mb-1 group">
<div className="flex items-center gap-2 px-2 py-1 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors">
{dragHandle}
{/* Chevron button for expand/collapse - only show when has agents and on hover */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`w-4 h-4 flex items-center justify-center transition-opacity rounded ${
pipelineAgents.length > 0 ? 'group-hover:opacity-100 opacity-60 hover:bg-gray-200 dark:hover:bg-gray-700' : 'opacity-0 pointer-events-none'
}`}
>
{pipelineAgents.length > 0 && (isExpanded ? (
<ChevronDown className="w-3 h-3 text-gray-500" />
) : (
<ChevronRight className="w-3 h-3 text-gray-500" />
))}
</button>
{/* Pipeline name button for configuration */}
<button
onClick={() => onSelectPipeline(pipeline.name)}
className="flex-1 flex items-center gap-2 text-sm text-left min-h-[28px]"
>
<div className="flex items-center gap-2">
<span className="text-xs">{pipeline.name}</span>
<span className="text-xs text-gray-500">({pipelineAgents.length} steps)</span>
</div>
</button>
{/* Pipeline menu */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<Dropdown>
<DropdownTrigger>
<button className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors">
<MoreVertical className="w-4 h-4 text-gray-500" />
</button>
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'delete') {
onDeletePipeline(pipeline.name);
}
}}
>
<DropdownItem key="delete" className="text-danger">Delete Pipeline</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
{isExpanded && (
<div className="ml-6 mt-0.5 space-y-0.5 border-l border-gray-200 dark:border-gray-700 pl-3">
{pipelineAgents.map((agent, index) => (
<div key={`pipeline-agent-${index}`} className="group/agent">
<div className={clsx(
"flex items-center gap-2 px-3 py-2 rounded-md min-h-[24px] cursor-pointer",
{
"bg-indigo-50 dark:bg-indigo-950/30": selectedEntity?.type === "agent" && selectedEntity.name === agent.name,
"hover:bg-zinc-50 dark:hover:bg-zinc-800": !(selectedEntity?.type === "agent" && selectedEntity.name === agent.name)
}
)}
onClick={() => onSelectAgent(agent.name)}>
<div className="shrink-0 flex items-center justify-center w-3 h-3">
<span className="text-xs font-semibold text-indigo-600 dark:text-indigo-400">
{index + 1}
</span>
</div>
<span className="text-xs flex-1">{agent.name}</span>
{startAgentName === agent.name && (
<div className="text-xs text-indigo-500 dark:text-indigo-400 bg-indigo-50/50 dark:bg-indigo-950/30 px-1.5 py-0.5 rounded">
Start
</div>
)}
<div className="opacity-0 group-hover/agent:opacity-100 transition-opacity">
<button
className="p-1 hover:bg-red-100 dark:hover:bg-red-900 rounded-md transition-colors"
onClick={(e) => {
e.stopPropagation();
onDeleteAgent(agent.name);
}}
>
<Trash2 className="w-3 h-3 text-red-500" />
</button>
</div>
</div>
</div>
))}
{/* Add Agent option */}
<button
className="flex items-center gap-2 px-3 py-2 mt-1 text-xs text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 rounded transition-colors"
onClick={() => {
// Create a new pipeline agent and add it to this pipeline
onAddAgentToPipeline(pipeline.name); // This will select the pipeline for editing later
}}
>
<PlusIcon className="w-4 h-4" />
<span>Add Agent to Pipeline</span>
</button>
</div>
)}
</div>
);
};
export const EntityList = forwardRef<
{ openDataSourcesModal: () => void },
EntityListProps & {
@ -268,6 +431,7 @@ export const EntityList = forwardRef<
agents,
tools,
prompts,
pipelines,
dataSources,
workflow,
selectedEntity,
@ -275,27 +439,33 @@ export const EntityList = forwardRef<
onSelectAgent,
onSelectTool,
onSelectPrompt,
onSelectPipeline,
onSelectDataSource,
onAddAgent,
onAddTool,
onAddPrompt,
onAddPipeline,
onAddAgentToPipeline,
onToggleAgent,
onSetMainAgent,
onDeleteAgent,
onDeleteTool,
onDeletePrompt,
onDeletePipeline,
onProjectToolsUpdated,
onDataSourcesUpdated,
projectId,
projectConfig,
onReorderAgents,
onReorderPipelines,
onShowVisualise,
useRagUploads,
useRagS3Uploads,
useRagScraping,
}: EntityListProps & {
projectId: string,
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void,
onReorderPipelines: (pipelines: z.infer<typeof WorkflowPipeline>[]) => void
}, ref) {
const [showAgentTypeModal, setShowAgentTypeModal] = useState(false);
const [showToolsModal, setShowToolsModal] = useState(false);
@ -417,20 +587,44 @@ export const EntityList = forwardRef<
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = agents.findIndex(agent => agent.name === active.id);
const newIndex = agents.findIndex(agent => agent.name === over.id);
// Determine if we're dragging a pipeline or an agent
const isPipelineDrag = pipelines.some(pipeline => pipeline.name === active.id);
const isPipelineTarget = pipelines.some(pipeline => pipeline.name === over.id);
const newAgents = [...agents];
const [movedAgent] = newAgents.splice(oldIndex, 1);
newAgents.splice(newIndex, 0, movedAgent);
// Update order numbers
const updatedAgents = newAgents.map((agent, index) => ({
...agent,
order: index * 100
}));
onReorderAgents(updatedAgents);
if (isPipelineDrag && isPipelineTarget) {
// Reordering pipelines
const oldIndex = pipelines.findIndex(pipeline => pipeline.name === active.id);
const newIndex = pipelines.findIndex(pipeline => pipeline.name === over.id);
const newPipelines = [...pipelines];
const [movedPipeline] = newPipelines.splice(oldIndex, 1);
newPipelines.splice(newIndex, 0, movedPipeline);
// Update order numbers
const updatedPipelines = newPipelines.map((pipeline, index) => ({
...pipeline,
order: index * 100
}));
onReorderPipelines(updatedPipelines);
} else if (!isPipelineDrag && !isPipelineTarget) {
// Reordering individual agents (not in pipelines)
const oldIndex = agents.findIndex(agent => agent.name === active.id);
const newIndex = agents.findIndex(agent => agent.name === over.id);
const newAgents = [...agents];
const [movedAgent] = newAgents.splice(oldIndex, 1);
newAgents.splice(newIndex, 0, movedAgent);
// Update order numbers
const updatedAgents = newAgents.map((agent, index) => ({
...agent,
order: index * 100
}));
onReorderAgents(updatedAgents);
}
// Note: We don't allow dragging between pipelines and agents
}
};
@ -509,36 +703,83 @@ export const EntityList = forwardRef<
{expandedPanels.agents && (
<div className="h-[calc(100%-53px)] overflow-y-auto">
<div className="p-2">
{agents.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={agents.map(a => a.name)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{agents.map((agent) => (
<SortableAgentItem
key={agent.name}
agent={agent}
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
onClick={() => onSelectAgent(agent.name)}
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
onToggle={onToggleAgent}
onSetMainAgent={onSetMainAgent}
onDelete={onDeleteAgent}
isStartAgent={startAgentName === agent.name}
/>
))}
</div>
</SortableContext>
</DndContext>
{pipelines.length > 0 || agents.length > 0 ? (
<div className="space-y-1">
{/* Show pipelines first with drag-and-drop */}
{pipelines.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pipelines.map(p => p.name)}
strategy={verticalListSortingStrategy}
>
{pipelines.map((pipeline) => (
<SortablePipelineItem
key={pipeline.name}
pipeline={pipeline}
agents={agents}
selectedEntity={selectedEntity}
onSelectPipeline={onSelectPipeline}
onSelectAgent={onSelectAgent}
onDeletePipeline={onDeletePipeline}
onDeleteAgent={onDeleteAgent}
onAddAgentToPipeline={onAddAgentToPipeline}
selectedRef={selectedRef}
startAgentName={startAgentName}
/>
))}
</SortableContext>
</DndContext>
)}
{/* Show individual agents that are NOT part of any pipeline */}
{(() => {
// Get all agent names that are part of pipelines
const pipelineAgentNames = new Set(
pipelines.flatMap(pipeline => pipeline.agents)
);
// Filter agents that are not in any pipeline and are not pipeline agents
const individualAgents = agents.filter(
agent => !pipelineAgentNames.has(agent.name) && agent.type !== 'pipeline'
);
if (individualAgents.length === 0) return null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={individualAgents.map(a => a.name)}
strategy={verticalListSortingStrategy}
>
{individualAgents.map((agent) => (
<SortableAgentItem
key={agent.name}
agent={agent}
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
onClick={() => onSelectAgent(agent.name)}
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
onToggle={onToggleAgent}
onSetMainAgent={onSetMainAgent}
onDelete={onDeleteAgent}
isStartAgent={startAgentName === agent.name}
/>
))}
</SortableContext>
</DndContext>
);
})()}
</div>
) : (
<EmptyState entity="agents" hasFilteredItems={false} />
<EmptyState entity="agents and pipelines" hasFilteredItems={false} />
)}
</div>
</div>
@ -925,6 +1166,10 @@ export const EntityList = forwardRef<
isOpen={showAgentTypeModal}
onClose={() => setShowAgentTypeModal(false)}
onConfirm={handleAddAgentWithType}
onCreatePipeline={() => {
onAddPipeline({ name: `Pipeline ${pipelines.length + 1}` });
setShowAgentTypeModal(false);
}}
/>
<ToolsModal
isOpen={showToolsModal}
@ -1027,7 +1272,7 @@ function EntityDropdown({
interface ComposioCardProps {
card: ComposioToolkit;
selectedEntity: {
type: "agent" | "tool" | "prompt" | "datasource" | "visualise";
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
onSelectTool: (name: string) => void;
@ -1210,7 +1455,7 @@ const ComposioCard = ({
<div className="flex items-center gap-2 px-2 py-1 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex-1 flex items-center gap-2 text-sm text-left min-h-[28px] py-1"
className="flex-1 flex items-center gap-2 text-left min-h-[28px]"
>
{/* Chevron - only show on hover or when has tools */}
<div className={`w-4 h-4 flex items-center justify-center transition-opacity ${
@ -1391,62 +1636,132 @@ const SortableAgentItem = ({ agent, isSelected, onClick, selectedRef, statusLabe
);
};
// Add SortableItem component for pipelines
const SortablePipelineItem = ({
pipeline,
agents,
selectedEntity,
onSelectPipeline,
onSelectAgent,
onDeletePipeline,
onDeleteAgent,
onAddAgentToPipeline,
selectedRef,
startAgentName
}: {
pipeline: z.infer<typeof WorkflowPipeline>;
agents: z.infer<typeof WorkflowAgent>[];
selectedEntity: {
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
onSelectPipeline: (name: string) => void;
onSelectAgent: (name: string) => void;
onDeletePipeline: (name: string) => void;
onDeleteAgent: (name: string) => void;
onAddAgentToPipeline: (pipelineName: string) => void;
selectedRef: React.RefObject<HTMLButtonElement | null>;
startAgentName: string | null;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: pipeline.name });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<PipelineCard
pipeline={pipeline}
agents={agents}
selectedEntity={selectedEntity}
onSelectPipeline={onSelectPipeline}
onSelectAgent={onSelectAgent}
onDeletePipeline={onDeletePipeline}
onDeleteAgent={onDeleteAgent}
onAddAgentToPipeline={onAddAgentToPipeline}
selectedRef={selectedRef}
startAgentName={startAgentName}
dragHandle={
<button className="cursor-grab" {...listeners}>
<GripVertical className="w-4 h-4 text-gray-400" />
</button>
}
/>
</div>
);
};
interface AgentTypeModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (agentType: 'internal' | 'user_facing') => void;
onCreatePipeline: () => void;
}
function AgentTypeModal({ isOpen, onClose, onConfirm }: AgentTypeModalProps) {
const [selectedType, setSelectedType] = useState<'internal' | 'user_facing'>('internal');
function AgentTypeModal({ isOpen, onClose, onConfirm, onCreatePipeline }: AgentTypeModalProps) {
const [selectedType, setSelectedType] = useState<'internal' | 'user_facing' | 'pipeline'>('internal');
const handleConfirm = () => {
onConfirm(selectedType);
if (selectedType === 'pipeline') {
onCreatePipeline();
} else {
onConfirm(selectedType);
}
onClose();
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg" className="max-w-3xl w-full">
<ModalContent className="max-w-3xl w-full">
<Modal isOpen={isOpen} onClose={onClose} size="lg" className="max-w-5xl w-full">
<ModalContent className="max-w-5xl w-full">
<ModalHeader>
<div className="flex items-center gap-2">
<Brain className="w-5 h-5 text-indigo-600" />
<span>Create New Agent</span>
<span>Create New Agent or Pipeline</span>
</div>
</ModalHeader>
<ModalBody>
<div className="space-y-6">
<ModalBody className="p-8">
<div className="space-y-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
Choose the type of agent you want to create:
Choose what you want to create:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Task Agent (Internal) */}
<button
type="button"
onClick={() => setSelectedType('internal')}
className={clsx(
"relative group p-6 rounded-2xl border-2 flex flex-col items-start transition-all duration-200 text-left shadow-sm focus:outline-none",
"relative group p-4 rounded-2xl border-2 flex flex-col items-start transition-all duration-200 text-left shadow-sm focus:outline-none",
selectedType === 'internal'
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950/40 shadow-lg scale-[1.03]"
: "border-gray-200 dark:border-gray-700 hover:border-indigo-400 hover:shadow-md bg-white dark:bg-gray-900"
)}
>
<div className="flex items-center gap-4 w-full mb-2">
<div className="flex items-center gap-3 w-full mb-1">
<div className={clsx(
"flex items-center justify-center w-12 h-12 rounded-lg transition-colors",
"flex items-center justify-center w-10 h-10 rounded-lg transition-colors",
selectedType === 'internal'
? "bg-indigo-100 dark:bg-indigo-900/60"
: "bg-gray-100 dark:bg-gray-800"
)}>
<Cog className={clsx(
"w-6 h-6 transition-colors",
"w-5 h-5 transition-colors",
selectedType === 'internal'
? "text-indigo-600 dark:text-indigo-400"
: "text-gray-600 dark:text-gray-400"
)} />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-0.5">
Task Agent
</h3>
<span className="inline-block align-middle">
@ -1456,7 +1771,7 @@ function AgentTypeModal({ isOpen, onClose, onConfirm }: AgentTypeModalProps) {
</span>
</div>
</div>
<ul className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-1 list-disc pl-5 space-y-1">
<ul className="text-sm text-gray-600 dark:text-gray-400 leading-snug mt-0 list-disc pl-4 space-y-0.5">
<li>Perform specific internal tasks, such as parts of workflows, pipelines, and data processing</li>
<li>Cannot put out user-facing responses directly</li>
<li>Can call other agents (both conversation and task agents)</li>
@ -1468,28 +1783,28 @@ function AgentTypeModal({ isOpen, onClose, onConfirm }: AgentTypeModalProps) {
type="button"
onClick={() => setSelectedType('user_facing')}
className={clsx(
"relative group p-6 rounded-2xl border-2 flex flex-col items-start transition-all duration-200 text-left shadow-sm focus:outline-none",
"relative group p-4 rounded-2xl border-2 flex flex-col items-start transition-all duration-200 text-left shadow-sm focus:outline-none",
selectedType === 'user_facing'
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950/40 shadow-lg scale-[1.03]"
: "border-gray-200 dark:border-gray-700 hover:border-indigo-400 hover:shadow-md bg-white dark:bg-gray-900"
)}
>
<div className="flex items-center gap-4 w-full mb-2">
<div className="flex items-center gap-3 w-full mb-1">
<div className={clsx(
"flex items-center justify-center w-12 h-12 rounded-lg transition-colors",
"flex items-center justify-center w-10 h-10 rounded-lg transition-colors",
selectedType === 'user_facing'
? "bg-indigo-100 dark:bg-indigo-900/60"
: "bg-gray-100 dark:bg-gray-800"
)}>
<Users className={clsx(
"w-6 h-6 transition-colors",
"w-5 h-5 transition-colors",
selectedType === 'user_facing'
? "text-indigo-600 dark:text-indigo-400"
: "text-gray-600 dark:text-gray-400"
)} />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-0.5">
Conversation Agent
</h3>
<span className="inline-block align-middle">
@ -1499,16 +1814,59 @@ function AgentTypeModal({ isOpen, onClose, onConfirm }: AgentTypeModalProps) {
</span>
</div>
</div>
<ul className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-1 list-disc pl-5 space-y-1">
<ul className="text-sm text-gray-600 dark:text-gray-400 leading-snug mt-0 list-disc pl-4 space-y-0.5">
<li>Interact directly with users</li>
<li>Ideal for specific roles in customer support, chat interfaces, and other end-user interactions</li>
<li>Can call other agents (both conversation and task agents)</li>
</ul>
</button>
{/* Pipeline */}
<button
type="button"
onClick={() => setSelectedType('pipeline')}
className={clsx(
"relative group p-4 rounded-2xl border-2 flex flex-col items-start transition-all duration-200 text-left shadow-sm focus:outline-none",
selectedType === 'pipeline'
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950/40 shadow-lg scale-[1.03]"
: "border-gray-200 dark:border-gray-700 hover:border-indigo-400 hover:shadow-md bg-white dark:bg-gray-900"
)}
>
<div className="flex items-center gap-3 w-full mb-1">
<div className={clsx(
"flex items-center justify-center w-10 h-10 rounded-lg transition-colors",
selectedType === 'pipeline'
? "bg-indigo-100 dark:bg-indigo-900/60"
: "bg-gray-100 dark:bg-gray-800"
)}>
<Component className={clsx(
"w-5 h-5 transition-colors",
selectedType === 'pipeline'
? "text-indigo-600 dark:text-indigo-400"
: "text-gray-600 dark:text-gray-400"
)} />
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-0.5">
Pipeline
</h3>
<span className="inline-block align-middle">
<span className="text-xs font-medium text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-900/40 px-2 py-0.5 rounded">
Sequential
</span>
</span>
</div>
</div>
<ul className="text-sm text-gray-600 dark:text-gray-400 leading-snug mt-0 list-disc pl-4 space-y-0.5">
<li>Create a sequential workflow of agents</li>
<li>Agents execute one after another in order</li>
<li>Add individual agents to the pipeline after creation</li>
</ul>
</button>
</div>
</div>
</ModalBody>
<ModalFooter>
<ModalFooter className="px-8 pb-8">
<Button
variant="secondary"
onClick={onClose}
@ -1519,7 +1877,7 @@ function AgentTypeModal({ isOpen, onClose, onConfirm }: AgentTypeModalProps) {
variant="primary"
onClick={handleConfirm}
>
Create Agent
{selectedType === 'pipeline' ? 'Create Pipeline' : 'Create Agent'}
</Button>
</ModalFooter>
</ModalContent>

View file

@ -1,11 +1,12 @@
"use client";
import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, createContext, useContext } from "react";
import { MCPServer, Message, WithStringId } from "../../../lib/types/types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent } from "../../../lib/types/workflow_types";
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent, WorkflowPipeline } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { Project } from "../../../lib/types/project_types";
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
import { AgentConfig } from "../entities/agent_config";
import { PipelineConfig } from "../entities/pipeline_config";
import { ToolConfig } from "../entities/tool_config";
import { App as ChatApp } from "../playground/app";
import { z } from "zod";
@ -25,7 +26,7 @@ import { publishWorkflow } from "@/app/actions/project_actions";
import { saveWorkflow } from "@/app/actions/project_actions";
import { updateProjectName } from "@/app/actions/project_actions";
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
import { CopyIcon, ImportIcon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, XIcon, SettingsIcon, ChevronDownIcon, PhoneIcon, MessageCircleIcon } from "lucide-react";
import { EntityList } from "./entity_list";
import { ProductTour } from "@/components/common/product-tour";
import { ModelsResponse } from "@/app/lib/types/billing_types";
@ -49,7 +50,7 @@ interface StateItem {
workflow: z.infer<typeof Workflow>;
publishing: boolean;
selection: {
type: "agent" | "tool" | "prompt" | "datasource" | "visualise";
type: "agent" | "tool" | "prompt" | "datasource" | "pipeline" | "visualise";
name: string;
} | null;
saving: boolean;
@ -83,18 +84,31 @@ export type Action = {
} | {
type: "add_prompt";
prompt: Partial<z.infer<typeof WorkflowPrompt>>;
} | {
type: "add_pipeline";
pipeline: Partial<z.infer<typeof WorkflowPipeline>>;
} | {
type: "select_agent";
name: string;
} | {
type: "select_tool";
name: string;
} | {
type: "select_pipeline";
name: string;
} | {
type: "delete_agent";
name: string;
} | {
type: "delete_tool";
name: string;
} | {
type: "delete_pipeline";
name: string;
} | {
type: "update_pipeline";
name: string;
pipeline: Partial<z.infer<typeof WorkflowPipeline>>;
} | {
type: "update_agent";
name: string;
@ -119,6 +133,8 @@ export type Action = {
name: string;
} | {
type: "unselect_prompt";
} | {
type: "unselect_pipeline";
} | {
type: "delete_prompt";
name: string;
@ -144,6 +160,9 @@ export type Action = {
} | {
type: "reorder_agents";
agents: z.infer<typeof WorkflowAgent>[];
} | {
type: "reorder_pipelines";
pipelines: z.infer<typeof WorkflowPipeline>[];
} | {
type: "select_datasource";
id: string;
@ -235,6 +254,23 @@ function reducer(state: State, action: Action): State {
currentIndex: state.currentIndex + 1,
};
}
case "reorder_pipelines": {
const newState = produce(state.present, draft => {
draft.workflow.pipelines = action.pipelines;
draft.lastUpdatedAt = new Date().toISOString();
});
const [nextState, patches, inversePatches] = produceWithPatches(state.present, draft => {
draft.workflow.pipelines = action.pipelines;
draft.lastUpdatedAt = new Date().toISOString();
});
return {
...state,
present: nextState,
patches: [...state.patches.slice(0, state.currentIndex), patches],
inversePatches: [...state.inversePatches.slice(0, state.currentIndex), inversePatches],
currentIndex: state.currentIndex + 1,
};
}
case "show_visualise": {
newState = produce(state, draft => {
draft.present.selection = { type: "visualise", name: "visualise" };
@ -270,6 +306,12 @@ function reducer(state: State, action: Action): State {
name: action.name
};
break;
case "select_pipeline":
draft.selection = {
type: "pipeline",
name: action.name
};
break;
case "select_datasource":
draft.selection = {
type: "datasource",
@ -280,6 +322,7 @@ function reducer(state: State, action: Action): State {
case "unselect_tool":
case "unselect_prompt":
case "unselect_datasource":
case "unselect_pipeline":
draft.selection = null;
break;
case "add_agent": {
@ -366,13 +409,97 @@ function reducer(state: State, action: Action): State {
draft.chatKey++;
break;
}
case "add_pipeline": {
if (isLive) {
break;
}
let newPipelineName = "New pipeline";
if (draft.workflow?.pipelines?.some((pipeline) => pipeline.name === newPipelineName)) {
newPipelineName = `New pipeline ${(draft.workflow?.pipelines?.filter((pipeline) =>
pipeline.name.startsWith("New pipeline")).length || 0) + 1}`;
}
if (!draft.workflow.pipelines) {
draft.workflow.pipelines = [];
}
// Create the first agent for this pipeline
const firstAgentName = `${action.pipeline.name || newPipelineName} Step 1`;
draft.workflow.agents.push({
name: firstAgentName,
type: "pipeline",
description: "",
disabled: false,
instructions: "",
model: "gpt-4o",
locked: false,
toggleAble: true,
ragReturnType: "chunks",
ragK: 3,
controlType: "relinquish_to_parent",
outputVisibility: "internal",
maxCallsPerParentAgent: 3,
});
// Create the pipeline with the first agent
draft.workflow.pipelines.push({
name: newPipelineName,
description: "",
agents: [firstAgentName],
...action.pipeline
});
// Select the newly created agent to open it in agent_config
draft.selection = {
type: "agent",
name: firstAgentName
};
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "delete_agent":
if (isLive) {
break;
}
// Remove the agent
draft.workflow.agents = draft.workflow.agents.filter(
(agent) => agent.name !== action.name
);
// Update references to deleted agent in other agents' instructions
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
instructions: agent.instructions.replace(
new RegExp(`\\[@agent:${action.name}\\]\\(#mention\\)`, 'g'),
''
)
}));
// Update references in prompts
draft.workflow.prompts = draft.workflow.prompts.map(prompt => ({
...prompt,
prompt: prompt.prompt.replace(
new RegExp(`\\[@agent:${action.name}\\]\\(#mention\\)`, 'g'),
''
)
}));
// Update references in pipelines
if (draft.workflow.pipelines) {
draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline => ({
...pipeline,
agents: pipeline.agents.filter(agentName => agentName !== action.name)
}));
}
// Update start agent if it was the deleted agent
if (draft.workflow.startAgent === action.name) {
// Set to first available agent, or empty string if no agents left
draft.workflow.startAgent = draft.workflow.agents.length > 0
? draft.workflow.agents[0].name
: '';
}
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
@ -399,7 +526,80 @@ function reducer(state: State, action: Action): State {
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_agent":
case "delete_pipeline":
if (isLive) {
break;
}
if (draft.workflow.pipelines) {
// Find the pipeline to get its associated agents
const pipelineToDelete = draft.workflow.pipelines.find(
(pipeline) => pipeline.name === action.name
);
if (pipelineToDelete) {
// Remove all agents that belong to this pipeline
const agentsToDelete = pipelineToDelete.agents || [];
// Check if startAgent is one of the agents being deleted
const startAgentBeingDeleted = agentsToDelete.includes(draft.workflow.startAgent);
draft.workflow.agents = draft.workflow.agents.filter(
(agent) => !agentsToDelete.includes(agent.name)
);
// Update references to deleted agents in other agents' instructions
agentsToDelete.forEach(agentName => {
draft.workflow.agents = draft.workflow.agents.map(agent => ({
...agent,
instructions: agent.instructions.replace(
new RegExp(`\\[@agent:${agentName}\\]\\(#mention\\)`, 'g'),
''
)
}));
// Update references in prompts
draft.workflow.prompts = draft.workflow.prompts.map(prompt => ({
...prompt,
prompt: prompt.prompt.replace(
new RegExp(`\\[@agent:${agentName}\\]\\(#mention\\)`, 'g'),
''
)
}));
});
// Update start agent if it was one of the deleted agents (same logic as regular agent deletion)
if (startAgentBeingDeleted) {
// Set to first available agent, or empty string if no agents left
draft.workflow.startAgent = draft.workflow.agents.length > 0
? draft.workflow.agents[0].name
: '';
}
}
// Remove the pipeline itself
draft.workflow.pipelines = draft.workflow.pipelines.filter(
(pipeline) => pipeline.name !== action.name
);
}
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
break;
case "update_pipeline": {
if (isLive) {
break;
}
if (draft.workflow.pipelines) {
draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline =>
pipeline.name === action.name ? { ...pipeline, ...action.pipeline } : pipeline
);
}
draft.selection = null;
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "update_agent": {
if (isLive) {
break;
}
@ -432,6 +632,16 @@ function reducer(state: State, action: Action): State {
)
}));
// update pipeline references if this agent is part of any pipeline
if (draft.workflow.pipelines) {
draft.workflow.pipelines = draft.workflow.pipelines.map(pipeline => ({
...pipeline,
agents: pipeline.agents.map(agentName =>
agentName === action.name ? action.agent.name! : agentName
)
}));
}
// update the selection pointer if this is the selected agent
if (draft.selection?.type === "agent" && draft.selection.name === action.name) {
draft.selection = {
@ -449,6 +659,7 @@ function reducer(state: State, action: Action): State {
draft.pendingChanges = true;
draft.chatKey++;
break;
}
case "update_tool":
if (isLive) {
break;
@ -785,10 +996,59 @@ export function WorkflowEditor({
dispatch({ type: "add_prompt", prompt });
}
function handleSelectPipeline(name: string) {
dispatch({ type: "select_pipeline", name });
}
function handleAddPipeline(pipeline: Partial<z.infer<typeof WorkflowPipeline>> = {}) {
dispatch({ type: "add_pipeline", pipeline });
}
function handleDeletePipeline(name: string) {
if (window.confirm(`Are you sure you want to delete the pipeline "${name}"?`)) {
dispatch({ type: "delete_pipeline", name });
}
}
function handleAddAgentToPipeline(pipelineName: string) {
// Create a pipeline agent and add it to the specified pipeline
const newAgentName = `${pipelineName} Step ${(state.present.workflow.pipelines?.find(p => p.name === pipelineName)?.agents.length || 0) + 1}`;
const agentWithModel = {
name: newAgentName,
type: 'pipeline' as const,
outputVisibility: 'internal' as const,
model: defaultModel || "gpt-4o"
};
// First add the agent
dispatch({ type: "add_agent", agent: agentWithModel });
// Then add it to the pipeline
const pipeline = state.present.workflow.pipelines?.find(p => p.name === pipelineName);
if (pipeline) {
dispatch({
type: "update_pipeline",
name: pipelineName,
pipeline: {
...pipeline,
agents: [...pipeline.agents, newAgentName]
}
});
}
// Select the newly created agent to open it in agent_config
dispatch({ type: "select_agent", name: newAgentName });
}
function handleUpdateAgent(name: string, agent: Partial<z.infer<typeof WorkflowAgent>>) {
dispatch({ type: "update_agent", name, agent });
}
function handleUpdatePipeline(name: string, pipeline: Partial<z.infer<typeof WorkflowPipeline>>) {
dispatch({ type: "update_pipeline", name, pipeline });
}
function handleDeleteAgent(name: string) {
if (window.confirm(`Are you sure you want to delete the agent "${name}"?`)) {
dispatch({ type: "delete_agent", name });
@ -835,6 +1095,18 @@ export function WorkflowEditor({
dispatch({ type: "reorder_agents", agents });
}
function handleReorderPipelines(pipelines: z.infer<typeof WorkflowPipeline>[]) {
// Save order to localStorage
const orderMap = pipelines.reduce((acc, pipeline, index) => {
acc[pipeline.name] = index;
return acc;
}, {} as Record<string, number>);
const mode = isLive ? 'live' : 'draft';
localStorage.setItem(`${mode}_workflow_${projectId}_pipeline_order`, JSON.stringify(orderMap));
dispatch({ type: "reorder_pipelines", pipelines });
}
async function handlePublishWorkflow() {
await publishWorkflow(projectId, state.present.workflow);
onChangeMode('live');
@ -1110,6 +1382,7 @@ export function WorkflowEditor({
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
pipelines={state.present.workflow.pipelines || []}
dataSources={dataSources}
workflow={state.present.workflow}
selectedEntity={
@ -1117,7 +1390,8 @@ export function WorkflowEditor({
(state.present.selection.type === "agent" ||
state.present.selection.type === "tool" ||
state.present.selection.type === "prompt" ||
state.present.selection.type === "datasource")
state.present.selection.type === "datasource" ||
state.present.selection.type === "pipeline")
? state.present.selection
: null
}
@ -1125,21 +1399,26 @@ export function WorkflowEditor({
onSelectAgent={handleSelectAgent}
onSelectTool={handleSelectTool}
onSelectPrompt={handleSelectPrompt}
onSelectPipeline={handleSelectPipeline}
onSelectDataSource={handleSelectDataSource}
onAddAgent={handleAddAgent}
onAddTool={handleAddTool}
onAddPrompt={handleAddPrompt}
onAddPipeline={handleAddPipeline}
onAddAgentToPipeline={handleAddAgentToPipeline}
onToggleAgent={handleToggleAgent}
onSetMainAgent={handleSetMainAgent}
onDeleteAgent={handleDeleteAgent}
onDeleteTool={handleDeleteTool}
onDeletePrompt={handleDeletePrompt}
onDeletePipeline={handleDeletePipeline}
onShowVisualise={handleShowVisualise}
projectId={projectId}
onProjectToolsUpdated={onProjectToolsUpdated}
onDataSourcesUpdated={onDataSourcesUpdated}
projectConfig={projectConfig}
onReorderAgents={handleReorderAgents}
onReorderPipelines={handleReorderPipelines}
useRagUploads={useRagUploads}
useRagS3Uploads={useRagS3Uploads}
useRagScraping={useRagScraping}
@ -1168,6 +1447,7 @@ export function WorkflowEditor({
workflow={state.present.workflow}
agent={state.present.workflow.agents.find((agent) => agent.name === state.present.selection!.name)!}
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).map((pipeline) => pipeline.name))}
agents={state.present.workflow.agents}
tools={state.present.workflow.tools}
prompts={state.present.workflow.prompts}
@ -1209,6 +1489,18 @@ export function WorkflowEditor({
handleClose={() => dispatch({ type: "unselect_datasource" })}
onDataSourceUpdate={onDataSourcesUpdated}
/>}
{state.present.selection?.type === "pipeline" && <PipelineConfig
key={state.present.selection.name}
projectId={projectId}
workflow={state.present.workflow}
pipeline={state.present.workflow.pipelines?.find((pipeline) => pipeline.name === state.present.selection!.name)!}
usedPipelineNames={new Set((state.present.workflow.pipelines || []).filter((pipeline) => pipeline.name !== state.present.selection!.name).map((pipeline) => pipeline.name))}
usedAgentNames={new Set(state.present.workflow.agents.map((agent) => agent.name))}
agents={state.present.workflow.agents}
pipelines={state.present.workflow.pipelines || []}
handleUpdate={handleUpdatePipeline.bind(null, state.present.selection.name)}
handleClose={() => dispatch({ type: "unselect_pipeline" })}
/>}
{state.present.selection?.type === "visualise" && (
<Panel
title={