feat(workflows): add sub-workflow backend support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 19:31:56 +02:00
co-authored by Copilot
parent ea9444ddac
commit 41e74c2fa9
18 changed files with 1064 additions and 29 deletions
@@ -37,7 +37,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
string? validationError = command.Workflow is null
? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message
: _workflowValidator.Validate(command.Workflow).FirstOrDefault()?.Message;
: _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary).FirstOrDefault()?.Message;
if (validationError is not null)
{
throw new InvalidOperationException(validationError);
@@ -86,7 +86,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = command.Workflow is null
? bundle.BuildWorkflow(command.Pattern)
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents);
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents, command.WorkflowLibrary);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
@@ -202,7 +202,7 @@ public sealed class SidecarProtocolHost
{
Type = "workflow-validation",
RequestId = context.Envelope.RequestId,
Issues = _workflowValidator.Validate(command.Workflow),
Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary),
}, context.CancellationToken).ConfigureAwait(false);
}
@@ -1,7 +1,6 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Specialized;
namespace Aryx.AgentHost.Services;
@@ -10,12 +9,19 @@ internal sealed class WorkflowRunner
public Workflow BuildWorkflow(
WorkflowDefinitionDto workflowDefinition,
PatternDefinitionDto patternDefinition,
IReadOnlyList<AIAgent> agents)
IReadOnlyList<AIAgent> agents,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(patternDefinition);
ArgumentNullException.ThrowIfNull(agents);
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
@@ -25,10 +31,23 @@ internal sealed class WorkflowRunner
.Zip(agents, (definition, agent) => (definition.Id, agent))
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
}
private Workflow BuildWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, AIAgent> agentMap,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase));
WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node =>
string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase));
Dictionary<string, ExecutorBinding> bindings = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
bindings[node.Id] = CreateExecutorBinding(node, agentMap);
bindings[node.Id] = CreateExecutorBinding(node, agentMap, workflowLibrary);
}
WorkflowBuilder builder = new(bindings[startNode.Id]);
@@ -92,7 +111,8 @@ internal sealed class WorkflowRunner
private static ExecutorBinding CreateExecutorBinding(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentMap)
IReadOnlyDictionary<string, AIAgent> agentMap,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase))
{
@@ -115,6 +135,32 @@ internal sealed class WorkflowRunner
return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions());
}
if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
{
WorkflowDefinitionDto subWorkflowDefinition = ResolveSubWorkflowDefinition(node, workflowLibrary);
Workflow subWorkflow = new WorkflowRunner().BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary);
return subWorkflow.BindAsExecutor(node.Id);
}
throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet.");
}
private static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
if (node.Config.InlineWorkflow is not null)
{
return node.Config.InlineWorkflow;
}
if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
return workflow;
}
throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
}
}
@@ -1,3 +1,4 @@
using System.Linq;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
@@ -9,11 +10,18 @@ public sealed class WorkflowValidator
"start",
"end",
"agent",
"sub-workflow",
};
public IReadOnlyList<WorkflowValidationIssueDto> Validate(WorkflowDefinitionDto workflow)
public IReadOnlyList<WorkflowValidationIssueDto> Validate(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
List<WorkflowValidationIssueDto> issues = [];
Dictionary<string, WorkflowDefinitionDto>? workflowLibraryById = workflowLibrary?
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id))
.GroupBy(candidate => candidate.Id, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(workflow.Name))
{
@@ -94,6 +102,8 @@ public sealed class WorkflowValidator
});
}
}
ValidateSubWorkflowNode(node, workflowLibraryById, issues);
}
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
@@ -145,8 +155,10 @@ public sealed class WorkflowValidator
List<WorkflowNodeDto> endNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase))
.ToList();
List<WorkflowNodeDto> agentNodes = workflow.Graph.Nodes
.Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
List<WorkflowNodeDto> executableWorkNodes = workflow.Graph.Nodes
.Where(node =>
string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)
|| string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
.ToList();
if (startNodes.Count != 1)
@@ -167,12 +179,12 @@ public sealed class WorkflowValidator
});
}
if (agentNodes.Count == 0)
if (executableWorkNodes.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes",
Message = "Workflow graphs must contain at least one agent node.",
Message = "Workflow graphs must contain at least one agent or sub-workflow node.",
});
}
@@ -338,6 +350,61 @@ public sealed class WorkflowValidator
return issues;
}
private void ValidateSubWorkflowNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibraryById,
List<WorkflowValidationIssueDto> issues)
{
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
{
return;
}
bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId);
bool hasInlineWorkflow = node.Config.InlineWorkflow is not null;
if (hasWorkflowId == hasInlineWorkflow)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config",
NodeId = node.Id,
Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.",
});
return;
}
if (hasWorkflowId
&& workflowLibraryById is not null
&& !workflowLibraryById.ContainsKey(node.Config.WorkflowId!))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.nodes.config.workflowId",
NodeId = node.Id,
Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".",
});
}
if (node.Config.InlineWorkflow is null)
{
return;
}
foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList()))
{
issues.Add(new WorkflowValidationIssueDto
{
Level = inlineIssue.Level,
Field = inlineIssue.Field is null
? "graph.nodes.config.inlineWorkflow"
: $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}",
NodeId = node.Id,
EdgeId = inlineIssue.EdgeId,
Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}",
});
}
}
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
{
if (edge.Condition is null)