mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 21:48:36 +02:00
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:
+1
-1
@@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user