feat: add backend orchestration mode support

Add first-class backend support for workflow orchestration modes,
including mode settings, graph scaffolding, mode-aware validation,
and real Agent Framework handoff/group-chat execution paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-06 23:18:51 +02:00
co-authored by Copilot
parent 805e369b67
commit e265714225
9 changed files with 1357 additions and 14 deletions
+1 -1
View File
@@ -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.
@@ -111,11 +111,32 @@ public sealed class WorkflowStateScopeDto
public IReadOnlyDictionary<string, JsonElement>? 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<WorkflowStateScopeDto> StateScopes { get; init; } = [];
@@ -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<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> 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<WorkflowNodeDto> 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<AIAgent> 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<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
}
public async ValueTask DisposeAsync()
@@ -279,6 +370,97 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
.ToList();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> 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<string, AIAgent> 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<WorkflowNodeDto> 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)
@@ -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<ChatMessage> 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<AIAgent> 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<StreamingRun> OpenWorkflowRunAsync(
RunTurnCommandDto command,
Workflow workflow,
@@ -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<InvalidOperationException>(() =>
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<AIAgent> 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<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory =
Assert.IsType<Func<IReadOnlyList<AIAgent>, GroupChatManager>>(managerFactoryField.GetValue(builder));
RoundRobinGroupChatManager manager = Assert.IsType<RoundRobinGroupChatManager>(managerFactory(agents));
HashSet<AIAgent> participants = Assert.IsType<HashSet<AIAgent>>(participantsField.GetValue(builder));
Assert.Equal(7, manager.MaximumIterationCount);
Assert.Equal(2, participants.Count);
Assert.Equal("Round Robin Collaboration", Assert.IsType<string>(nameField.GetValue(builder)));
Assert.Equal("Two agents iterate on a shared answer.", Assert.IsType<string>(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,
},
};
}
@@ -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<ChatMessage>));
}
[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<ChatMessage>));
}
[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",
},
},
};
+8
View File
@@ -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;
+604 -5
View File
@@ -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<string, WorkflowEdge[]> {
const outgoing = new Map<string, WorkflowEdge[]>();
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<string, WorkflowEdge[]> {
const incoming = new Map<string, WorkflowEdge[]>();
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;
}
+270
View File
@@ -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'));
});
});