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
@@ -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",
},
},
};