From 3a639813a911f518e11563ee00d7d988d09e1cb9 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 7 Apr 2026 12:28:51 +0200 Subject: [PATCH] fix: resolve sub-workflow agent discovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentBundle.cs | 2 +- .../Services/WorkflowDefinitionExtensions.cs | 102 +++++++++++++++ .../Aryx.AgentHost/Services/WorkflowRunner.cs | 59 +-------- .../CopilotAgentBundleTests.cs | 120 ++++++++++++++++++ 4 files changed, 227 insertions(+), 56 deletions(-) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index a1ed4b3..c64a1c1 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -57,7 +57,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable CopilotClient sharedClient = new(clientOptions); await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false); - IReadOnlyList agentNodes = command.Workflow.GetAgentNodes(); + IReadOnlyList agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary); foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index))) { SessionConfig sessionConfig = CreateSessionConfig( diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs index bbe8090..e22c645 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs @@ -13,6 +13,32 @@ internal static class WorkflowDefinitionExtensions .ToList(); } + public static IReadOnlyList GetAllAgentNodes( + this WorkflowDefinitionDto workflow, + IReadOnlyList? workflowLibrary = null) + { + ArgumentNullException.ThrowIfNull(workflow); + + Dictionary workflowLibraryMap = CreateWorkflowLibraryMap(workflowLibrary); + return GetAllAgentNodes(workflow, workflowLibraryMap); + } + + public static IReadOnlyList GetAllAgentNodes( + this WorkflowDefinitionDto workflow, + IReadOnlyDictionary? workflowLibrary) + { + ArgumentNullException.ThrowIfNull(workflow); + + List agentNodes = []; + CollectAgentNodes( + workflow, + workflowLibrary ?? EmptyWorkflowLibrary, + agentNodes, + new HashSet(StringComparer.Ordinal), + new HashSet(ReferenceEqualityComparer.Instance)); + return agentNodes; + } + public static bool IsAgentNode(this WorkflowNodeDto node) { ArgumentNullException.ThrowIfNull(node); @@ -31,6 +57,28 @@ internal static class WorkflowDefinitionExtensions return FirstNonBlank(node.Config.Name, node.Label, node.Id) ?? "agent"; } + public static WorkflowDefinitionDto ResolveSubWorkflowDefinition( + this WorkflowNodeDto node, + IReadOnlyDictionary? workflowLibrary) + { + ArgumentNullException.ThrowIfNull(node); + + if (node.Config.InlineWorkflow is not null) + { + return node.Config.InlineWorkflow; + } + + if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId) + && workflowLibrary is not null + && workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow)) + { + return workflow; + } + + throw new InvalidOperationException( + $"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\"."); + } + public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode) { ArgumentNullException.ThrowIfNull(workflow); @@ -39,6 +87,60 @@ internal static class WorkflowDefinitionExtensions return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase); } + private static readonly IReadOnlyDictionary EmptyWorkflowLibrary = + new Dictionary(StringComparer.Ordinal); + + private static void CollectAgentNodes( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyDictionary workflowLibrary, + List agentNodes, + ISet visitedWorkflowIds, + ISet visitedAnonymousWorkflows) + { + string? workflowId = NormalizeOptionalString(workflowDefinition.Id); + if (workflowId is not null) + { + if (!visitedWorkflowIds.Add(workflowId)) + { + return; + } + } + else if (!visitedAnonymousWorkflows.Add(workflowDefinition)) + { + return; + } + + foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) + { + if (node.IsAgentNode()) + { + agentNodes.Add(node); + continue; + } + + if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary); + CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows); + } + } + + private static Dictionary CreateWorkflowLibraryMap( + IReadOnlyList? workflowLibrary) + { + return workflowLibrary? + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id)) + .GroupBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + } + + private static string? NormalizeOptionalString(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string? FirstNonBlank(params string?[] values) { foreach (string? value in values) diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs index 515db38..45d0adf 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs @@ -171,7 +171,7 @@ internal sealed class WorkflowRunner if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) { - WorkflowDefinitionDto subWorkflowDefinition = ResolveSubWorkflowDefinition(node, workflowLibrary); + WorkflowDefinitionDto subWorkflowDefinition = node.ResolveSubWorkflowDefinition(workflowLibrary); Workflow subWorkflow = BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary); return new WorkflowNodeRoute(subWorkflow.BindAsExecutor(node.Id)); } @@ -196,25 +196,6 @@ internal sealed class WorkflowRunner return new WorkflowNodeRoute(entry, exit, [(entry, portBinding), (portBinding, exit)]); } - private static WorkflowDefinitionDto ResolveSubWorkflowDefinition( - WorkflowNodeDto node, - IReadOnlyDictionary 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}\"."); - } - private static string NormalizeRequired(string? value, string errorMessage) => NormalizeOptionalString(value) ?? throw new InvalidOperationException(errorMessage); @@ -225,41 +206,9 @@ internal sealed class WorkflowRunner WorkflowDefinitionDto workflowDefinition, IReadOnlyDictionary workflowLibrary) { - List agentIds = []; - CollectAgentIds(workflowDefinition, workflowLibrary, agentIds, new HashSet(StringComparer.Ordinal)); - return agentIds; - } - - private static void CollectAgentIds( - WorkflowDefinitionDto workflowDefinition, - IReadOnlyDictionary workflowLibrary, - List agentIds, - ISet visitedWorkflowIds) - { - string workflowKey = string.IsNullOrWhiteSpace(workflowDefinition.Id) - ? Guid.NewGuid().ToString("N") - : workflowDefinition.Id; - if (!visitedWorkflowIds.Add(workflowKey)) - { - return; - } - - foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) - { - if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)) - { - agentIds.Add(!string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id); - continue; - } - - if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - WorkflowDefinitionDto subWorkflow = ResolveSubWorkflowDefinition(node, workflowLibrary); - CollectAgentIds(subWorkflow, workflowLibrary, agentIds, visitedWorkflowIds); - } + return workflowDefinition.GetAllAgentNodes(workflowLibrary) + .Select(node => node.GetAgentId()) + .ToList(); } private sealed record WorkflowNodeRoute( diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index 74e25dd..d32b71f 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -11,6 +11,24 @@ namespace Aryx.AgentHost.Tests; public sealed class CopilotAgentBundleTests { + [Fact] + public void GetAllAgentNodes_IncludesReferencedSubworkflowAgentsInTraversalOrder() + { + WorkflowDefinitionDto childWorkflow = CreateSubworkflowChild( + "child-workflow", + CreateAgentNode("agent-child-1", "Child Agent 1"), + CreateAgentNode("agent-child-2", "Child Agent 2")); + WorkflowDefinitionDto parentWorkflow = CreateSubworkflowParent( + CreateAgentNode("agent-parent", "Parent Agent"), + workflowId: childWorkflow.Id); + + IReadOnlyList agentNodes = parentWorkflow.GetAllAgentNodes([childWorkflow]); + + Assert.Equal( + ["agent-parent", "agent-child-1", "agent-child-2"], + agentNodes.Select(node => node.GetAgentId()).ToArray()); + } + [Fact] public void ApplySessionTooling_MapsMcpServersAndToolsOntoTheSessionConfig() { @@ -669,6 +687,108 @@ public sealed class CopilotAgentBundleTests }; } + private static WorkflowDefinitionDto CreateSubworkflowParent( + WorkflowNodeDto directAgent, + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowDefinitionDto + { + Id = "parent-workflow", + Name = "Parent Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + directAgent, + new WorkflowNodeDto + { + Id = "sub-workflow", + Kind = "sub-workflow", + Label = "Nested Workflow", + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "sequential", + }, + }; + } + + private static WorkflowDefinitionDto CreateSubworkflowChild(string id, params WorkflowNodeDto[] agentNodes) + { + return new WorkflowDefinitionDto + { + Id = id, + Name = "Child Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + .. agentNodes, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "sequential", + }, + }; + } + + private static WorkflowNodeDto CreateAgentNode(string id, string name) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "agent", + Label = name, + Config = new WorkflowNodeConfigDto + { + Kind = "agent", + Id = id, + Name = name, + Description = $"{name} description.", + Instructions = "Help.", + Model = "gpt-5.4", + }, + }; + } + private static ChatClientAgent CreateChatClientAgent(string id, string name) { return new ChatClientAgent(