diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9f16e4b..1073120 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,7 +147,7 @@ Workflows are shared application data, not renderer-only configuration. The same Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract. -That graph is the execution contract for the sidecar: sequencing comes from saved edges, sub-workflow execution comes from referenced workflow graphs, and orchestration hints such as handoff or group-chat are carried as workflow settings rather than through a separate pattern domain. +That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time. Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol. diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index c4343f7..8bf620b 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -111,11 +111,32 @@ public sealed class WorkflowStateScopeDto public IReadOnlyDictionary? InitialValues { get; init; } } +public sealed class HandoffModeSettingsDto +{ + public string ToolCallFiltering { get; init; } = "handoff-only"; + public bool ReturnToPrevious { get; init; } + public string? HandoffInstructions { get; init; } + public string? TriageAgentNodeId { get; init; } +} + +public sealed class GroupChatModeSettingsDto +{ + public string SelectionStrategy { get; init; } = "round-robin"; + public int MaxRounds { get; init; } = 5; +} + +public sealed class OrchestrationModeSettingsDto +{ + public HandoffModeSettingsDto? Handoff { get; init; } + public GroupChatModeSettingsDto? GroupChat { get; init; } +} + public sealed class WorkflowSettingsDto { public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new(); public string ExecutionMode { get; init; } = "off-thread"; public string? OrchestrationMode { get; init; } + public OrchestrationModeSettingsDto? ModeSettings { get; init; } public int? MaxIterations { get; init; } public ApprovalPolicyDto? ApprovalPolicy { get; init; } public IReadOnlyList StateScopes { get; init; } = []; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 79c102d..0c478f6 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -216,11 +216,102 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable }; } - internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent) + internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder( + AIAgent entryAgent, + HandoffModeSettingsDto? settings = null) { - return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) - .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly) - .WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions()); + HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto(); + HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent) + .WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering)) + .WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions) + ?? HandoffWorkflowGuidance.CreateWorkflowInstructions()); + + if (effectiveSettings.ReturnToPrevious) + { + TryEnableReturnToPrevious(builder); + } + + return builder; + } + + internal static Workflow CreateHandoffWorkflow( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyList agents) + { + ArgumentNullException.ThrowIfNull(workflowDefinition); + ArgumentNullException.ThrowIfNull(agents); + + IReadOnlyList agentNodes = workflowDefinition.GetAgentNodes(); + Dictionary agentsById = CreateAgentMap(agents); + WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes); + AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById); + HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff; + HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings); + + List specialistNodes = agentNodes + .Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal)) + .ToList(); + + if (specialistNodes.Count == 0) + { + throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent."); + } + + foreach (WorkflowNodeDto specialistNode in specialistNodes) + { + AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById); + builder.WithHandoff( + triageAgent, + specialistAgent, + HandoffWorkflowGuidance.CreateForwardReason(specialistNode)); + + if (settings?.ReturnToPrevious != true) + { + builder.WithHandoff( + specialistAgent, + triageAgent, + HandoffWorkflowGuidance.CreateReturnReason(triageNode)); + } + } + + return builder.Build(); + } + + internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyList agents) + { + ArgumentNullException.ThrowIfNull(workflowDefinition); + ArgumentNullException.ThrowIfNull(agents); + + int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition); + GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith( + participants => new RoundRobinGroupChatManager(participants) + { + MaximumIterationCount = maxRounds, + }) + .AddParticipants(agents); + + string? name = NormalizeOptionalString(workflowDefinition.Name); + if (name is not null) + { + builder.WithName(name); + } + + string? description = NormalizeOptionalString(workflowDefinition.Description); + if (description is not null) + { + builder.WithDescription(description); + } + + return builder; + } + + internal static Workflow CreateGroupChatWorkflow( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyList agents) + { + return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build(); } public async ValueTask DisposeAsync() @@ -279,6 +370,97 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable .ToList(); } + private static Dictionary CreateAgentMap(IReadOnlyList agents) + { + Dictionary agentMap = new(StringComparer.OrdinalIgnoreCase); + foreach (AIAgent agent in agents) + { + if (!string.IsNullOrWhiteSpace(agent.Id)) + { + agentMap[agent.Id] = agent; + } + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + agentMap[agent.Name] = agent; + } + } + + return agentMap; + } + + private static AIAgent ResolveAgentForNode( + WorkflowNodeDto node, + IReadOnlyDictionary agentsById) + { + string agentId = node.GetAgentId(); + if (agentsById.TryGetValue(agentId, out AIAgent? agent)) + { + return agent; + } + + string agentName = node.GetAgentName(); + if (agentsById.TryGetValue(agentName, out agent)) + { + return agent; + } + + throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents."); + } + + private static WorkflowNodeDto ResolveTriageAgentNode( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyList agentNodes) + { + if (agentNodes.Count == 0) + { + throw new InvalidOperationException("Handoff workflows require at least one agent node."); + } + + string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId); + if (triageAgentNodeId is null) + { + return agentNodes[0]; + } + + WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal)); + return triageNode ?? throw new InvalidOperationException( + $"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph."); + } + + private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value) + { + return value?.Trim().ToLowerInvariant() switch + { + "none" => HandoffToolCallFilteringBehavior.None, + "all" => HandoffToolCallFilteringBehavior.All, + _ => HandoffToolCallFilteringBehavior.HandoffOnly, + }; + } + + private static void TryEnableReturnToPrevious(HandoffsWorkflowBuilder builder) + { + builder.GetType() + .GetMethod("EnableReturnToPrevious", Type.EmptyTypes)? + .Invoke(builder, null); + } + + private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition) + { + int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds; + if (configuredMaxRounds is > 0) + { + return configuredMaxRounds.Value; + } + + if (workflowDefinition.Settings.MaxIterations is > 0) + { + return workflowDefinition.Settings.MaxIterations.Value; + } + + return 5; + } + private static string? ResolveEffectiveAgent( string? defaultAgent, RunTurnPromptInvocationDto? promptInvocation) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index c8b19f7..488281a 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Text.Json; using Aryx.AgentHost.Contracts; using GitHub.Copilot.SDK; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.InProc; @@ -84,7 +85,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner }, runCancellation.Token); ConfigureHookLifecycleEventSuppression(state, bundle); - Workflow workflow = _workflowRunner.BuildWorkflow(command.Workflow, bundle.Agents, command.WorkflowLibrary); + Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner); List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode); @@ -147,6 +148,22 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } } + internal static Workflow BuildWorkflowForCommand( + RunTurnCommandDto command, + IReadOnlyList agents, + WorkflowRunner? workflowRunner = null) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(agents); + + return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch + { + "handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents), + "group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents), + _ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary), + }; + } + internal static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command) { if (!ShouldEnableWorkflowCheckpointing(command)) @@ -177,6 +194,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return Path.Combine(localAppData, "Aryx", "workflow-checkpoints", command.SessionId, command.RequestId); } + private static string? NormalizeOrchestrationMode(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); + } + private static ValueTask OpenWorkflowRunAsync( RunTurnCommandDto command, Workflow workflow, diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index edd1c4c..74e25dd 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -158,6 +158,97 @@ public sealed class CopilotAgentBundleTests Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions); } + [Fact] + public void CreateHandoffWorkflowBuilder_MapsConfiguredFilteringAndInstructions() + { + ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary"); + + HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder( + entryAgent, + new HandoffModeSettingsDto + { + ToolCallFiltering = "all", + ReturnToPrevious = true, + HandoffInstructions = "Use custom delegation guidance.", + }); + + FieldInfo filteringField = typeof(HandoffsWorkflowBuilder).GetField( + "_toolCallFilteringBehavior", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field."); + + Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder)); + Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions); + } + + [Fact] + public void CreateHandoffWorkflow_RejectsUnknownTriageNode() + { + WorkflowDefinitionDto workflow = CreateWorkflow( + "handoff", + 2, + modeSettings: new OrchestrationModeSettingsDto + { + Handoff = new HandoffModeSettingsDto + { + TriageAgentNodeId = "missing-agent", + }, + }); + + InvalidOperationException error = Assert.Throws(() => + CopilotAgentBundle.CreateHandoffWorkflow(workflow, CreateAgents(2))); + + Assert.Contains("triage agent node", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CreateGroupChatWorkflowBuilder_UsesConfiguredRoundsNameAndDescription() + { + WorkflowDefinitionDto workflow = CreateWorkflow( + "group-chat", + 2, + modeSettings: new OrchestrationModeSettingsDto + { + GroupChat = new GroupChatModeSettingsDto + { + SelectionStrategy = "round-robin", + MaxRounds = 7, + }, + }, + name: "Round Robin Collaboration", + description: "Two agents iterate on a shared answer."); + IReadOnlyList agents = CreateAgents(2); + + GroupChatWorkflowBuilder builder = CopilotAgentBundle.CreateGroupChatWorkflowBuilder(workflow, agents); + + FieldInfo managerFactoryField = typeof(GroupChatWorkflowBuilder).GetField( + "_managerFactory", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a manager factory field."); + FieldInfo participantsField = typeof(GroupChatWorkflowBuilder).GetField( + "_participants", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a participant field."); + FieldInfo nameField = typeof(GroupChatWorkflowBuilder).GetField( + "_name", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a name field."); + FieldInfo descriptionField = typeof(GroupChatWorkflowBuilder).GetField( + "_description", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Expected GroupChatWorkflowBuilder to expose a description field."); + + Func, GroupChatManager> managerFactory = + Assert.IsType, GroupChatManager>>(managerFactoryField.GetValue(builder)); + RoundRobinGroupChatManager manager = Assert.IsType(managerFactory(agents)); + HashSet participants = Assert.IsType>(participantsField.GetValue(builder)); + + Assert.Equal(7, manager.MaximumIterationCount); + Assert.Equal(2, participants.Count); + Assert.Equal("Round Robin Collaboration", Assert.IsType(nameField.GetValue(builder))); + Assert.Equal("Two agents iterate on a shared answer.", Assert.IsType(descriptionField.GetValue(builder))); + } + [Fact] public void CreateAgentHostOptions_UsesExpectedAryxDefaults() { @@ -538,12 +629,16 @@ public sealed class CopilotAgentBundleTests private static WorkflowDefinitionDto CreateWorkflow( string mode, int agentCount, - ApprovalPolicyDto? approvalPolicy = null) + ApprovalPolicyDto? approvalPolicy = null, + OrchestrationModeSettingsDto? modeSettings = null, + string? name = null, + string? description = null) { return new WorkflowDefinitionDto { Id = $"workflow-{mode}", - Name = $"Workflow {mode}", + Name = name ?? $"Workflow {mode}", + Description = description ?? string.Empty, Graph = new WorkflowGraphDto { Nodes = @@ -569,6 +664,7 @@ public sealed class CopilotAgentBundleTests { OrchestrationMode = mode, ApprovalPolicy = approvalPolicy, + ModeSettings = modeSettings, }, }; } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index e42e2e3..aac50a4 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -26,6 +26,82 @@ public sealed class CopilotWorkflowRunnerTests Assert.True(state.SuppressHookLifecycleEvents); } + [Fact] + public void BuildWorkflowForCommand_UsesGroupChatBuilderForGroupChatMode() + { + RunTurnCommandDto command = CreateCommand( + "group-chat", + modeSettings: new OrchestrationModeSettingsDto + { + GroupChat = new GroupChatModeSettingsDto + { + SelectionStrategy = "round-robin", + MaxRounds = 6, + }, + }, + workflowName: "Collaborative Review", + workflowDescription: "Two agents collaborate under a group chat manager.", + agents: + [ + CreateAgent("agent-group-writer", "Writer"), + CreateAgent("agent-group-reviewer", "Reviewer"), + ]); + + Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand( + command, + [ + CreateChatClientAgent("agent-group-writer", "Writer"), + CreateChatClientAgent("agent-group-reviewer", "Reviewer"), + ]); + + Assert.Equal("Collaborative Review", workflow.Name); + Assert.Equal("Two agents collaborate under a group chat manager.", workflow.Description); + } + + [Fact] + public async Task BuildWorkflowForCommand_UsesHandoffBuilderForHandoffMode() + { + RunTurnCommandDto command = CreateCommand( + "handoff", + modeSettings: new OrchestrationModeSettingsDto + { + Handoff = new HandoffModeSettingsDto + { + TriageAgentNodeId = "agent-handoff-triage", + ToolCallFiltering = "handoff-only", + }, + }, + agents: + [ + CreateAgent("agent-handoff-triage", "Triage"), + CreateAgent("agent-handoff-runtime", "Runtime Specialist"), + ]); + + Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand( + command, + [ + CreateChatClientAgent("agent-handoff-triage", "Triage"), + CreateChatClientAgent("agent-handoff-runtime", "Runtime Specialist"), + ]); + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(); + + Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List)); + } + + [Fact] + public async Task BuildWorkflowForCommand_UsesGraphWorkflowRunnerForGraphModes() + { + RunTurnCommandDto command = CreateGraphWorkflowCommand(); + Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand( + command, + [ + CreateChatClientAgent("agent-primary", "Primary Agent"), + ]); + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(); + + Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List)); + } + [Fact] public void SelectNewOutputMessages_SkipsFullTranscriptPrefix() { @@ -2063,6 +2139,16 @@ public sealed class CopilotWorkflowRunnerTests private static RunTurnCommandDto CreateCommand( string orchestrationMode, params WorkflowNodeDto[] agents) + { + return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents); + } + + private static RunTurnCommandDto CreateCommand( + string orchestrationMode, + OrchestrationModeSettingsDto? modeSettings = null, + string? workflowName = null, + string? workflowDescription = null, + params WorkflowNodeDto[] agents) { return new RunTurnCommandDto { @@ -2071,7 +2157,8 @@ public sealed class CopilotWorkflowRunnerTests Workflow = new WorkflowDefinitionDto { Id = $"workflow-{orchestrationMode}", - Name = $"Workflow {orchestrationMode}", + Name = workflowName ?? $"Workflow {orchestrationMode}", + Description = workflowDescription ?? string.Empty, Graph = new WorkflowGraphDto { Nodes = [.. agents], @@ -2079,6 +2166,64 @@ public sealed class CopilotWorkflowRunnerTests Settings = new WorkflowSettingsDto { OrchestrationMode = orchestrationMode, + ModeSettings = modeSettings, + }, + }, + }; + } + + private static RunTurnCommandDto CreateGraphWorkflowCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-graph", + SessionId = "session-graph", + Workflow = new WorkflowDefinitionDto + { + Id = "workflow-single", + Name = "Single Graph Workflow", + Description = "Uses the graph workflow runner.", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + CreateAgent("agent-primary", "Primary Agent"), + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + Edges = + [ + new WorkflowEdgeDto + { + Id = "edge-start-agent", + Source = "start", + Target = "agent-primary", + Kind = "direct", + }, + new WorkflowEdgeDto + { + Id = "edge-agent-end", + Source = "agent-primary", + Target = "end", + Kind = "direct", + }, + ], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "single", }, }, }; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 26f8aef..5ddae5f 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -10,6 +10,14 @@ import type { WorkflowValidationIssue, } from '@shared/domain/workflow'; +export type { + GroupChatModeSettings, + GroupChatSelectionStrategy, + HandoffModeSettings, + HandoffToolCallFiltering, + OrchestrationModeSettings, +} from '@shared/domain/workflow'; + export interface SidecarModeCapability { available: boolean; reason?: string; diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts index fdf61c7..a00844e 100644 --- a/src/shared/domain/workflow.ts +++ b/src/shared/domain/workflow.ts @@ -3,6 +3,25 @@ import type { ApprovalPolicy } from '@shared/domain/approval'; export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; export type WorkflowOrchestrationMode = 'single' | 'sequential' | 'concurrent' | 'handoff' | 'group-chat'; +export type HandoffToolCallFiltering = 'none' | 'handoff-only' | 'all'; +export type GroupChatSelectionStrategy = 'round-robin'; + +export interface HandoffModeSettings { + toolCallFiltering: HandoffToolCallFiltering; + returnToPrevious: boolean; + handoffInstructions?: string; + triageAgentNodeId?: string; +} + +export interface GroupChatModeSettings { + selectionStrategy: GroupChatSelectionStrategy; + maxRounds: number; +} + +export interface OrchestrationModeSettings { + handoff?: HandoffModeSettings; + groupChat?: GroupChatModeSettings; +} export interface WorkflowAgentOverrides { name?: string; @@ -54,6 +73,7 @@ export interface WorkflowSettings { checkpointing: WorkflowCheckpointSettings; executionMode: WorkflowExecutionMode; orchestrationMode?: WorkflowOrchestrationMode; + modeSettings?: OrchestrationModeSettings; maxIterations?: number; approvalPolicy?: ApprovalPolicy; stateScopes?: WorkflowStateScope[]; @@ -225,6 +245,92 @@ function normalizeWorkflowOrchestrationMode( } } +function normalizeHandoffToolCallFiltering( + value?: HandoffToolCallFiltering, +): HandoffToolCallFiltering { + switch (value) { + case 'none': + case 'handoff-only': + case 'all': + return value; + default: + return 'handoff-only'; + } +} + +function normalizeGroupChatSelectionStrategy( + value?: GroupChatSelectionStrategy, +): GroupChatSelectionStrategy { + return value === 'round-robin' ? 'round-robin' : 'round-robin'; +} + +export function createDefaultModeSettings( + mode?: WorkflowOrchestrationMode, +): OrchestrationModeSettings | undefined { + switch (mode) { + case 'handoff': + return { + handoff: { + toolCallFiltering: 'handoff-only', + returnToPrevious: false, + }, + }; + case 'group-chat': + return { + groupChat: { + selectionStrategy: 'round-robin', + maxRounds: 5, + }, + }; + default: + return undefined; + } +} + +export function isGraphBasedMode(mode?: WorkflowOrchestrationMode): boolean { + return mode === 'single' || mode === 'sequential' || mode === 'concurrent'; +} + +export function isBuilderBasedMode(mode?: WorkflowOrchestrationMode): boolean { + return mode === 'handoff' || mode === 'group-chat'; +} + +function normalizeModeSettings( + mode: WorkflowOrchestrationMode | undefined, + modeSettings: OrchestrationModeSettings | undefined, + workflowMaxIterations: number | undefined, +): OrchestrationModeSettings | undefined { + const defaults = createDefaultModeSettings(mode); + const handoffSource = modeSettings?.handoff ?? defaults?.handoff; + const groupChatSource = modeSettings?.groupChat ?? defaults?.groupChat; + const normalized: OrchestrationModeSettings = {}; + + if (handoffSource) { + normalized.handoff = { + toolCallFiltering: normalizeHandoffToolCallFiltering(handoffSource.toolCallFiltering), + returnToPrevious: handoffSource.returnToPrevious ?? false, + handoffInstructions: normalizeOptionalString(handoffSource.handoffInstructions), + triageAgentNodeId: normalizeOptionalString(handoffSource.triageAgentNodeId), + }; + } + + if (groupChatSource) { + const fallbackMaxRounds = workflowMaxIterations && workflowMaxIterations > 0 + ? Math.round(workflowMaxIterations) + : (defaults?.groupChat?.maxRounds ?? 5); + + normalized.groupChat = { + selectionStrategy: normalizeGroupChatSelectionStrategy(groupChatSource.selectionStrategy), + maxRounds: + typeof groupChatSource.maxRounds === 'number' && groupChatSource.maxRounds > 0 + ? Math.round(groupChatSource.maxRounds) + : fallbackMaxRounds, + }; + } + + return normalized.handoff || normalized.groupChat ? normalized : undefined; +} + export function isReasoningEffort(value: string | undefined): value is ReasoningEffort { return reasoningEffortOptions.some((option) => option.value === value); } @@ -330,6 +436,12 @@ function normalizeEdgeCondition(condition?: EdgeCondition): EdgeCondition | unde } export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): WorkflowDefinition { + const normalizedOrchestrationMode = normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode); + const normalizedMaxIterations = + typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0 + ? Math.round(workflow.settings.maxIterations) + : undefined; + return { ...workflow, name: workflow.name.trim(), @@ -363,11 +475,13 @@ export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): Workf enabled: workflow.settings?.checkpointing?.enabled ?? false, }, executionMode: workflow.settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread', - orchestrationMode: normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode), - maxIterations: - typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0 - ? Math.round(workflow.settings.maxIterations) - : undefined, + orchestrationMode: normalizedOrchestrationMode, + modeSettings: normalizeModeSettings( + normalizedOrchestrationMode, + workflow.settings?.modeSettings, + normalizedMaxIterations, + ), + maxIterations: normalizedMaxIterations, approvalPolicy: workflow.settings?.approvalPolicy, stateScopes: workflow.settings?.stateScopes?.map((scope) => ({ name: scope.name.trim(), @@ -580,6 +694,239 @@ function createAgentNode( }; } +function createScaffoldAgentLabel(mode: WorkflowOrchestrationMode, index: number): string { + if (mode === 'handoff') { + return index === 0 ? 'Triage Agent' : `Specialist ${index}`; + } + + if (mode === 'group-chat') { + return index === 0 ? 'Writer' : index === 1 ? 'Reviewer' : `Participant ${index + 1}`; + } + + return `Agent ${index + 1}`; +} + +function createScaffoldAgentDescription(mode: WorkflowOrchestrationMode, index: number): string { + if (mode === 'handoff') { + return index === 0 + ? 'Routes work to the right specialist.' + : `Handles specialist lane ${index}.`; + } + + if (mode === 'group-chat') { + return index === 0 + ? 'Produces the initial draft.' + : index === 1 + ? 'Reviews and refines the draft.' + : `Contributes perspective ${index + 1}.`; + } + + return `Handles step ${index + 1} in the workflow.`; +} + +function createScaffoldAgentInstructions(mode: WorkflowOrchestrationMode, index: number): string { + if (mode === 'handoff') { + return index === 0 + ? 'Triages each request and hands off to the best specialist when needed.' + : `Own the specialist response for lane ${index}.`; + } + + if (mode === 'group-chat') { + return index === 0 + ? 'Draft the first answer, then refine it based on peer feedback.' + : index === 1 + ? 'Review the current draft and suggest concrete improvements.' + : `Build on the current draft with contribution ${index + 1}.`; + } + + return `Complete step ${index + 1} of the workflow.`; +} + +function createPlaceholderAgentNode(mode: WorkflowOrchestrationMode, index: number): WorkflowNode { + const id = mode === 'handoff' + ? index === 0 ? 'agent-handoff-triage' : `agent-handoff-specialist-${index}` + : mode === 'group-chat' + ? index === 0 ? 'agent-group-writer' : index === 1 ? 'agent-group-reviewer' : `agent-group-participant-${index + 1}` + : `agent-${index + 1}`; + + return createAgentNode( + id, + createScaffoldAgentLabel(mode, index), + createScaffoldAgentDescription(mode, index), + createScaffoldAgentInstructions(mode, index), + builtinWorkflowModels.gpt54, + 'medium', + 0, + 0, + index, + ); +} + +function cloneAgentNode(node: WorkflowNode, index: number, position: WorkflowPosition): WorkflowNode { + const label = node.label.trim() || (node.config.kind === 'agent' ? node.config.name : node.id); + return { + ...node, + id: node.id.trim(), + kind: 'agent', + label, + order: index, + position, + config: node.config.kind === 'agent' + ? { + ...node.config, + id: normalizeOptionalString(node.config.id) ?? node.id.trim(), + name: normalizeOptionalString(node.config.name) ?? label, + } + : createPlaceholderAgentNode('single', index).config, + }; +} + +function getMinimumAgentCountForMode(mode: WorkflowOrchestrationMode): number { + switch (mode) { + case 'single': + return 1; + case 'sequential': + return 2; + case 'concurrent': + case 'handoff': + case 'group-chat': + return 2; + } +} + +function prepareScaffoldAgentNodes( + mode: WorkflowOrchestrationMode, + agentNodes?: WorkflowNode[], +): WorkflowNode[] { + const existingAgentNodes = (agentNodes ?? []) + .filter((node) => node.kind === 'agent' && node.config.kind === 'agent') + .map((node) => normalizeWorkflowDefinition({ + id: 'scaffold-normalizer', + name: 'scaffold-normalizer', + description: '', + createdAt: '', + updatedAt: '', + graph: { nodes: [node], edges: [] }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }).graph.nodes[0]!) + .filter((node): node is WorkflowNode => Boolean(node)); + + const desiredCount = Math.max(existingAgentNodes.length, getMinimumAgentCountForMode(mode)); + const agents = existingAgentNodes.slice(0, desiredCount); + + while (agents.length < desiredCount) { + agents.push(createPlaceholderAgentNode(mode, agents.length)); + } + + return agents; +} + +export function scaffoldGraphForMode( + mode: WorkflowOrchestrationMode, + agentNodes?: WorkflowNode[], +): WorkflowGraph { + const preparedAgents = prepareScaffoldAgentNodes(mode, agentNodes); + + if (mode === 'single') { + const agent = cloneAgentNode(preparedAgents[0]!, 0, { x: 220, y: 0 }); + return { + nodes: [createStartNode(0, 0), agent, createEndNode(440, 0)], + edges: [ + createWorkflowEdge('edge-start-to-agent', 'start', agent.id), + createWorkflowEdge(`edge-${agent.id}-to-end`, agent.id, 'end'), + ], + }; + } + + if (mode === 'sequential') { + const agents = preparedAgents.map((node, index) => cloneAgentNode(node, index, { x: 220 + (index * 220), y: 0 })); + const edges = [ + createWorkflowEdge(`edge-start-to-${agents[0]!.id}`, 'start', agents[0]!.id), + ...agents.slice(1).map((agent, index) => + createWorkflowEdge(`edge-${agents[index]!.id}-to-${agent.id}`, agents[index]!.id, agent.id)), + createWorkflowEdge(`edge-${agents[agents.length - 1]!.id}-to-end`, agents[agents.length - 1]!.id, 'end'), + ]; + + return { + nodes: [createStartNode(0, 0), ...agents, createEndNode(220 + (agents.length * 220), 0)], + edges, + }; + } + + if (mode === 'concurrent') { + const centerY = ((preparedAgents.length - 1) * 120) / 2; + const agents = preparedAgents.map((node, index) => + cloneAgentNode(node, index, { x: 260, y: (index * 120) - centerY })); + + return { + nodes: [createStartNode(0, 0), ...agents, createEndNode(520, 0)], + edges: [ + ...agents.map((agent) => + createWorkflowEdge(`edge-start-to-${agent.id}`, 'start', agent.id, 'fan-out', { + fanOutConfig: { strategy: 'broadcast' }, + })), + ...agents.map((agent) => createWorkflowEdge(`edge-${agent.id}-to-end`, agent.id, 'end', 'fan-in')), + ], + }; + } + + if (mode === 'handoff') { + const triage = cloneAgentNode(preparedAgents[0]!, 0, { x: 240, y: 120 }); + const specialists = preparedAgents.slice(1).map((node, index) => + cloneAgentNode(node, index + 1, { x: 520, y: index * 240 })); + + return { + nodes: [createStartNode(0, 120), triage, ...specialists, createEndNode(800, 120)], + edges: [ + createWorkflowEdge(`edge-start-to-${triage.id}`, 'start', triage.id), + createWorkflowEdge(`edge-${triage.id}-to-end`, triage.id, 'end'), + ...specialists.map((specialist) => createWorkflowEdge(`edge-${triage.id}-to-${specialist.id}`, triage.id, specialist.id)), + ...specialists.map((specialist) => createWorkflowEdge(`edge-${specialist.id}-to-${triage.id}`, specialist.id, triage.id, 'direct', { + isLoop: true, + maxIterations: 4, + condition: { type: 'always' }, + })), + ...specialists.map((specialist) => createWorkflowEdge(`edge-${specialist.id}-to-end`, specialist.id, 'end')), + ], + }; + } + + const agents = preparedAgents.map((node, index) => cloneAgentNode(node, index, { x: 240 + (index * 240), y: 0 })); + return { + nodes: [createStartNode(0, 0), ...agents, createEndNode(240 + (agents.length * 240), 0)], + edges: [ + createWorkflowEdge(`edge-start-to-${agents[0]!.id}`, 'start', agents[0]!.id), + ...agents.slice(1).map((agent, index) => createWorkflowEdge( + `edge-${agents[index]!.id}-to-${agent.id}`, + agents[index]!.id, + agent.id, + 'direct', + { + isLoop: true, + maxIterations: 5, + condition: { type: 'always' }, + }, + )), + createWorkflowEdge( + `edge-${agents[agents.length - 1]!.id}-to-${agents[0]!.id}`, + agents[agents.length - 1]!.id, + agents[0]!.id, + 'direct', + { + isLoop: true, + maxIterations: 5, + condition: { type: 'always' }, + label: 'Loop', + }, + ), + createWorkflowEdge(`edge-${agents[agents.length - 1]!.id}-to-end`, agents[agents.length - 1]!.id, 'end'), + ], + }; +} + function createWorkflowEdge( id: string, source: string, @@ -820,6 +1167,7 @@ export function createBuiltinWorkflows(timestamp: string): WorkflowDefinition[] checkpointing: { enabled: true }, executionMode: 'off-thread', orchestrationMode: 'handoff', + modeSettings: createDefaultModeSettings('handoff'), maxIterations: 4, }, }, timestamp), @@ -874,6 +1222,7 @@ export function createBuiltinWorkflows(timestamp: string): WorkflowDefinition[] checkpointing: { enabled: false }, executionMode: 'off-thread', orchestrationMode: 'group-chat', + modeSettings: createDefaultModeSettings('group-chat'), maxIterations: 5, }, }, timestamp), @@ -1178,6 +1527,254 @@ function validateExecutableNodeConfig(node: WorkflowNode, issues: WorkflowValida } } +function addModeShapeWarning( + issues: WorkflowValidationIssue[], + field: string, + message: string, + nodeId?: string, +): void { + addIssue(issues, { + level: 'warning', + field, + nodeId, + message, + }); +} + +function createOutgoingEdgeMap(graph: WorkflowGraph): Map { + const outgoing = new Map(); + for (const edge of graph.edges) { + const current = outgoing.get(edge.source); + if (current) { + current.push(edge); + } else { + outgoing.set(edge.source, [edge]); + } + } + + return outgoing; +} + +function createIncomingEdgeMap(graph: WorkflowGraph): Map { + const incoming = new Map(); + for (const edge of graph.edges) { + const current = incoming.get(edge.target); + if (current) { + current.push(edge); + } else { + incoming.set(edge.target, [edge]); + } + } + + return incoming; +} + +function validateGraphModeCompatibility( + workflow: WorkflowDefinition, + issues: WorkflowValidationIssue[], +): void { + const mode = workflow.settings.orchestrationMode; + if (!mode) { + return; + } + + const agentNodes = resolveWorkflowAgentNodes(workflow); + const agentIds = new Set(agentNodes.map((node) => node.id)); + const outgoing = createOutgoingEdgeMap(workflow.graph); + const incoming = createIncomingEdgeMap(workflow.graph); + const fanOutEdges = workflow.graph.edges.filter((edge) => edge.kind === 'fan-out'); + const fanInEdges = workflow.graph.edges.filter((edge) => edge.kind === 'fan-in'); + const nonAgentExecutableNodes = workflow.graph.nodes.filter((node) => + node.kind !== 'start' + && node.kind !== 'end' + && node.kind !== 'agent'); + + switch (mode) { + case 'single': { + if (agentNodes.length !== 1) { + addIssue(issues, { + level: 'error', + field: 'settings.orchestrationMode', + message: 'Single mode requires exactly one agent node.', + }); + } + + if (fanOutEdges.length > 0 || fanInEdges.length > 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.kind', + message: 'Single mode does not support fan-out or fan-in edges.', + }); + } + + if (nonAgentExecutableNodes.length > 0 || workflow.graph.nodes.length > 3 || workflow.graph.edges.length > 2) { + addModeShapeWarning( + issues, + 'graph', + 'Single mode works best with a simple start → agent → end graph.', + ); + } + + return; + } + case 'sequential': { + if (agentNodes.length === 0) { + addIssue(issues, { + level: 'error', + field: 'settings.orchestrationMode', + message: 'Sequential mode requires at least one agent node.', + }); + } + + if (fanOutEdges.length > 0 || fanInEdges.length > 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.kind', + message: 'Sequential mode does not support fan-out or fan-in edges.', + }); + } + + const firstAgent = agentNodes[0]; + const lastAgent = agentNodes[agentNodes.length - 1]; + const isLinear = agentNodes.every((agent, index) => { + const incomingEdges = incoming.get(agent.id) ?? []; + const outgoingEdges = outgoing.get(agent.id) ?? []; + + if (agentNodes.length === 1) { + return incomingEdges.some((edge) => edge.source === 'start') + && outgoingEdges.some((edge) => edge.target === 'end'); + } + + if (agent === firstAgent) { + return incomingEdges.some((edge) => edge.source === 'start') + && outgoingEdges.some((edge) => agentIds.has(edge.target)); + } + + if (agent === lastAgent) { + return incomingEdges.some((edge) => agentIds.has(edge.source)) + && outgoingEdges.some((edge) => edge.target === 'end'); + } + + return incomingEdges.some((edge) => agentIds.has(edge.source)) + && outgoingEdges.some((edge) => agentIds.has(edge.target)); + }); + + if (!isLinear) { + addModeShapeWarning( + issues, + 'graph.edges', + 'Sequential mode works best with a linear start → agent … → end graph.', + ); + } + + return; + } + case 'concurrent': { + if (agentNodes.length === 0) { + addIssue(issues, { + level: 'error', + field: 'settings.orchestrationMode', + message: 'Concurrent mode requires at least one agent node.', + }); + } + + if (fanOutEdges.length === 0 || fanInEdges.length === 0) { + addIssue(issues, { + level: 'error', + field: 'graph.edges.kind', + message: 'Concurrent mode requires both fan-out and fan-in edges.', + }); + } + + const fanOutTargets = new Set(fanOutEdges.map((edge) => edge.target)); + const fanInSources = new Set(fanInEdges.map((edge) => edge.source)); + const missingJoin = [...fanOutTargets].some((target) => !fanInSources.has(target)); + if (missingJoin) { + addModeShapeWarning( + issues, + 'graph.edges.kind', + 'Concurrent mode works best when every fan-out branch rejoins through a matching fan-in edge.', + ); + } + + return; + } + case 'handoff': { + if (agentNodes.length < 2) { + addIssue(issues, { + level: 'error', + field: 'settings.orchestrationMode', + message: 'Handoff mode requires at least two agent nodes.', + }); + return; + } + + const triageAgentNodeId = workflow.settings.modeSettings?.handoff?.triageAgentNodeId; + const triageAgent = triageAgentNodeId + ? agentNodes.find((node) => node.id === triageAgentNodeId) + : agentNodes[0]; + + if (triageAgentNodeId && !triageAgent) { + addIssue(issues, { + level: 'error', + field: 'settings.modeSettings.handoff.triageAgentNodeId', + message: `Handoff mode triage agent "${triageAgentNodeId}" must reference an agent node in the graph.`, + }); + return; + } + + const specialists = agentNodes.filter((node) => node.id !== triageAgent!.id); + const triageOutgoingSpecialistEdges = (outgoing.get(triageAgent!.id) ?? []) + .filter((edge) => agentIds.has(edge.target) && edge.target !== triageAgent!.id); + if (triageOutgoingSpecialistEdges.length === 0) { + addModeShapeWarning( + issues, + 'graph.edges', + 'Handoff mode works best when the triage agent has outgoing edges to specialist agents.', + triageAgent!.id, + ); + } + + for (const specialist of specialists) { + if (!canReachNode(workflow.graph, specialist.id, triageAgent!.id)) { + addModeShapeWarning( + issues, + 'graph.edges', + `Handoff specialist "${specialist.label || specialist.id}" should have a return path back to the triage agent.`, + specialist.id, + ); + } + } + + return; + } + case 'group-chat': { + if (agentNodes.length < 2) { + addIssue(issues, { + level: 'error', + field: 'settings.orchestrationMode', + message: 'Group-chat mode requires at least two agent nodes.', + }); + } + + const hasLoopAmongAgents = workflow.graph.edges.some((edge) => + agentIds.has(edge.source) + && agentIds.has(edge.target) + && (edge.isLoop === true || canReachNode(workflow.graph, edge.target, edge.source, edge.id))); + + if (!hasLoopAmongAgents) { + addModeShapeWarning( + issues, + 'graph.edges.isLoop', + 'Group-chat mode works best when agent nodes form a loop for repeated turns.', + ); + } + + return; + } + } +} + export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] { const normalized = normalizeWorkflowDefinition(workflow); const issues: WorkflowValidationIssue[] = []; @@ -1484,6 +2081,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl } } + validateGraphModeCompatibility(normalized, issues); + return issues; } diff --git a/tests/shared/workflow.test.ts b/tests/shared/workflow.test.ts index efa1ee2..783448f 100644 --- a/tests/shared/workflow.test.ts +++ b/tests/shared/workflow.test.ts @@ -2,8 +2,15 @@ import { describe, expect, test } from 'bun:test'; import { buildWorkflowExecutionDefinition, + createBuiltinWorkflows, + createDefaultModeSettings, + isBuilderBasedMode, + isGraphBasedMode, + normalizeWorkflowDefinition, + scaffoldGraphForMode, validateWorkflowDefinition, type WorkflowDefinition, + type WorkflowNode, } from '@shared/domain/workflow'; const TIMESTAMP = '2026-04-05T00:00:00.000Z'; @@ -371,4 +378,267 @@ describe('workflow validation', () => { expect(validateWorkflowDefinition(workflow)).toEqual([]); }); + + test('creates default mode settings for builder-based modes', () => { + expect(createDefaultModeSettings()).toBeUndefined(); + expect(createDefaultModeSettings('single')).toBeUndefined(); + expect(createDefaultModeSettings('handoff')).toEqual({ + handoff: { + toolCallFiltering: 'handoff-only', + returnToPrevious: false, + }, + }); + expect(createDefaultModeSettings('group-chat')).toEqual({ + groupChat: { + selectionStrategy: 'round-robin', + maxRounds: 5, + }, + }); + }); + + test('identifies graph-based and builder-based orchestration modes', () => { + expect(isGraphBasedMode('single')).toBe(true); + expect(isGraphBasedMode('sequential')).toBe(true); + expect(isGraphBasedMode('concurrent')).toBe(true); + expect(isGraphBasedMode('handoff')).toBe(false); + expect(isBuilderBasedMode('handoff')).toBe(true); + expect(isBuilderBasedMode('group-chat')).toBe(true); + expect(isBuilderBasedMode('single')).toBe(false); + }); + + test('scaffolds single mode while preserving the provided agent node', () => { + const agentNode = createWorkflow().graph.nodes[1] as WorkflowNode; + const graph = scaffoldGraphForMode('single', [agentNode]); + + expect(graph.nodes.map((node) => node.id)).toEqual(['start', 'agent-primary', 'end']); + expect(graph.edges.map((edge) => edge.kind)).toEqual(['direct', 'direct']); + expect(graph.nodes[1]?.config.kind).toBe('agent'); + expect(graph.nodes[1]?.config.kind === 'agent' ? graph.nodes[1].config.name : '').toBe('Primary Agent'); + }); + + test('scaffolds concurrent mode with fan-out and fan-in edges', () => { + const graph = scaffoldGraphForMode('concurrent'); + + expect(graph.nodes[0]?.id).toBe('start'); + expect(graph.nodes.at(-1)?.id).toBe('end'); + expect(graph.edges.filter((edge) => edge.kind === 'fan-out')).toHaveLength(2); + expect(graph.edges.filter((edge) => edge.kind === 'fan-in')).toHaveLength(2); + }); + + test('scaffolds handoff mode with triage and specialist return loops', () => { + const graph = scaffoldGraphForMode('handoff'); + const loopEdges = graph.edges.filter((edge) => edge.isLoop); + + expect(graph.nodes.map((node) => node.id)).toContain('agent-handoff-triage'); + expect(loopEdges).toHaveLength(1); + expect(loopEdges[0]).toMatchObject({ + source: 'agent-handoff-specialist-1', + target: 'agent-handoff-triage', + maxIterations: 4, + }); + }); + + test('scaffolds group-chat mode with loop edges between agent nodes', () => { + const graph = scaffoldGraphForMode('group-chat'); + const loopEdges = graph.edges.filter((edge) => edge.isLoop); + + expect(loopEdges.length).toBeGreaterThanOrEqual(2); + expect(loopEdges.every((edge) => edge.condition?.type === 'always')).toBe(true); + }); + + test('normalizes mode settings for builder-based workflows', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'handoff'; + workflow.settings.modeSettings = { + handoff: { + toolCallFiltering: 'all', + returnToPrevious: true, + handoffInstructions: ' Delegate carefully. ', + triageAgentNodeId: ' agent-primary ', + }, + }; + + const normalized = normalizeWorkflowDefinition(workflow); + + expect(normalized.settings.modeSettings).toEqual({ + handoff: { + toolCallFiltering: 'all', + returnToPrevious: true, + handoffInstructions: 'Delegate carefully.', + triageAgentNodeId: 'agent-primary', + }, + }); + }); + + test('rejects single mode graphs that use multiple agents or fan edges', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'single'; + workflow.graph.nodes.splice(2, 0, { + id: 'agent-secondary', + kind: 'agent', + label: 'Secondary Agent', + position: { x: 320, y: 120 }, + order: 1, + config: { + kind: 'agent', + id: 'agent-secondary', + name: 'Secondary Agent', + description: 'Handles backup work.', + instructions: 'Assist the primary agent.', + model: 'gpt-5.4', + }, + }); + workflow.graph.edges = [ + { id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } }, + { id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } }, + { id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'fan-in' }, + { id: 'edge-secondary-end', source: 'agent-secondary', target: 'end', kind: 'fan-in' }, + ]; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.message.includes('Single mode requires exactly one agent node'))).toBe(true); + expect(issues.some((issue) => issue.message.includes('Single mode does not support fan-out or fan-in edges'))).toBe(true); + }); + + test('warns when sequential mode is not shaped like a linear graph', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'sequential'; + workflow.graph.nodes.splice(2, 0, { + id: 'agent-secondary', + kind: 'agent', + label: 'Secondary Agent', + position: { x: 220, y: 120 }, + order: 1, + config: { + kind: 'agent', + id: 'agent-secondary', + name: 'Secondary Agent', + description: 'Handles follow-up work.', + instructions: 'Follow up.', + model: 'gpt-5.4', + }, + }); + workflow.graph.edges = [ + { id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'direct' }, + { id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'direct' }, + { id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'direct' }, + { id: 'edge-secondary-end', source: 'agent-secondary', target: 'end', kind: 'direct' }, + ]; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('Sequential mode works best with a linear'))).toBe(true); + }); + + test('requires concurrent mode fan edges and warns when branches do not rejoin', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'concurrent'; + workflow.graph.nodes.splice(2, 0, { + id: 'agent-secondary', + kind: 'agent', + label: 'Secondary Agent', + position: { x: 220, y: 120 }, + order: 1, + config: { + kind: 'agent', + id: 'agent-secondary', + name: 'Secondary Agent', + description: 'Handles alternate work.', + instructions: 'Assist in parallel.', + model: 'gpt-5.4', + }, + }); + workflow.graph.edges = [ + { id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } }, + { id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } }, + { id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'fan-in' }, + { id: 'edge-secondary-primary', source: 'agent-secondary', target: 'agent-primary', kind: 'direct' }, + ]; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('rejoins through a matching fan-in edge'))).toBe(true); + }); + + test('validates handoff triage settings and specialist return paths', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'handoff'; + workflow.settings.modeSettings = { + handoff: { + toolCallFiltering: 'handoff-only', + returnToPrevious: false, + triageAgentNodeId: 'missing-triage', + }, + }; + workflow.graph.nodes.splice(2, 0, { + id: 'agent-specialist', + kind: 'agent', + label: 'Specialist', + position: { x: 420, y: 120 }, + order: 1, + config: { + kind: 'agent', + id: 'agent-specialist', + name: 'Specialist', + description: 'Handles specialist work.', + instructions: 'Own the specialist response.', + model: 'gpt-5.4', + }, + }); + workflow.graph.edges = [ + { id: 'edge-start-triage', source: 'start', target: 'agent-primary', kind: 'direct' }, + { id: 'edge-specialist-end', source: 'agent-specialist', target: 'end', kind: 'direct' }, + { id: 'edge-triage-end', source: 'agent-primary', target: 'end', kind: 'direct' }, + ]; + + const invalidTriageIssues = validateWorkflowDefinition(workflow); + expect(invalidTriageIssues.some((issue) => issue.field === 'settings.modeSettings.handoff.triageAgentNodeId')).toBe(true); + + workflow.settings.modeSettings = { + handoff: { + toolCallFiltering: 'handoff-only', + returnToPrevious: false, + triageAgentNodeId: 'agent-primary', + }, + }; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('triage agent has outgoing edges to specialist agents'))).toBe(true); + expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('should have a return path back to the triage agent'))).toBe(true); + }); + + test('warns when group-chat mode does not create a loop among agents', () => { + const workflow = createWorkflow(); + workflow.settings.orchestrationMode = 'group-chat'; + workflow.graph.nodes.splice(2, 0, { + id: 'agent-reviewer', + kind: 'agent', + label: 'Reviewer', + position: { x: 420, y: 0 }, + order: 1, + config: { + kind: 'agent', + id: 'agent-reviewer', + name: 'Reviewer', + description: 'Reviews drafts.', + instructions: 'Review and refine.', + model: 'gpt-5.4', + }, + }); + workflow.graph.edges = [ + { id: 'edge-start-writer', source: 'start', target: 'agent-primary', kind: 'direct' }, + { id: 'edge-writer-reviewer', source: 'agent-primary', target: 'agent-reviewer', kind: 'direct' }, + { id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' }, + ]; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('form a loop for repeated turns'))).toBe(true); + }); + + test('builtin handoff and group-chat workflows include mode settings defaults', () => { + const builtinWorkflows = createBuiltinWorkflows(TIMESTAMP); + const handoffWorkflow = builtinWorkflows.find((workflow) => workflow.settings.orchestrationMode === 'handoff'); + const groupChatWorkflow = builtinWorkflows.find((workflow) => workflow.settings.orchestrationMode === 'group-chat'); + + expect(handoffWorkflow?.settings.modeSettings).toEqual(createDefaultModeSettings('handoff')); + expect(groupChatWorkflow?.settings.modeSettings).toEqual(createDefaultModeSettings('group-chat')); + }); });