feat: show sub-workflow agents and lifecycle in the Activity panel

Deep agent resolution in the sidecar now walks sub-workflow nodes so
nested agents carry subworkflowNodeId and subworkflowName on activity
events. New subworkflow-started / subworkflow-completed activity types
let the frontend track sub-workflow lifecycle.

The Activity panel groups nested agents under collapsible sub-workflow
cards with status badges, accent-colored left borders, and smooth
expand/collapse transitions. Cards auto-expand when a sub-workflow
starts running. Workflows without sub-workflow nodes render identically
to before.

Extracted AgentRow, SubWorkflowGroup, and shared accent constants to
a new components/activity/ feature directory. Added
resolveWorkflowAgentHierarchy and buildGroupedActivityRows for
hierarchical activity grouping with dynamic fallback for unresolved
sub-workflow agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-08 18:57:23 +02:00
co-authored by Copilot
parent fa8f6ef4b3
commit c70a5c6612
24 changed files with 2063 additions and 240 deletions
@@ -452,6 +452,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string ActivityType { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string? SubworkflowNodeId { get; init; }
public string? SubworkflowName { get; init; }
public string? SourceAgentId { get; init; }
public string? SourceAgentName { get; init; }
public string? ToolName { get; init; }
@@ -3,7 +3,12 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName);
internal readonly record struct AgentIdentity(
string AgentId,
string AgentName,
SubworkflowContext? Subworkflow = null);
internal static class AgentIdentityResolver
{
@@ -13,17 +18,69 @@ internal static class AgentIdentityResolver
WorkflowDefinitionDto workflow,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
public static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
agent = default;
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
if (match is null)
WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
if (shallowMatch is not null)
{
agent = ToAgentIdentity(shallowMatch);
return true;
}
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
if (deepMatch is not null)
{
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
return true;
}
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
if (aliasMatch is null)
{
return false;
}
agent = ToAgentIdentity(match);
if (workflow.GetAgentNodes().Contains(aliasMatch))
{
agent = ToAgentIdentity(aliasMatch);
return true;
}
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
return true;
}
@@ -33,7 +90,24 @@ internal static class AgentIdentityResolver
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
return TryResolveObservedAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
fallbackAgent,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveObservedAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
AgentIdentity? fallbackAgent,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
{
return true;
}
@@ -53,13 +127,46 @@ internal static class AgentIdentityResolver
string? agentId,
string? agentName)
{
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
?? FindKnownAgent(workflow, agentName)
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentId,
agentName,
agentSubworkflowIndex: null);
}
return match is not null
? ToAgentIdentity(match)
: CreateFallbackIdentity(agentId, agentName);
public static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentId,
string? agentName)
{
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentId,
agentName,
agentSubworkflowIndex: null);
}
internal static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
{
return resolvedById;
}
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
{
return resolvedByName;
}
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
}
public static string ResolveDisplayAuthorName(
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
return GenericAssistantIdentifier;
}
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return BuildAgentSubworkflowIndex(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
}
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
CollectAgentSubworkflowContexts(
workflow,
workflowLibrary,
currentSubworkflow: null,
index,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
return index;
}
internal static bool TryResolveSubworkflowContext(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? subworkflowNodeId,
out SubworkflowContext context)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
context = default;
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
if (node is null)
{
return false;
}
context = CreateSubworkflowContext(node, workflowLibrary);
return true;
}
internal static bool IsGenericAssistantIdentifier(string? candidate)
{
return string.Equals(
@@ -100,34 +254,120 @@ internal static class AgentIdentityResolver
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
params string?[] agentIdentifiers)
{
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
? agentNodes[0]
: null;
if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
{
return null;
}
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
if (topLevelAgents.Count == 1)
{
return topLevelAgents[0];
}
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
return allAgents.Count == 1 ? allAgents[0] : null;
}
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
private static WorkflowNodeDto? FindKnownAgent(
IEnumerable<WorkflowNodeDto> agents,
string? candidate)
{
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
}
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
=> new(agent.GetAgentId(), agent.GetAgentName());
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
private static AgentIdentity ToAgentIdentity(
WorkflowNodeDto agent,
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
{
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
? agentId
: agentName ?? "agent";
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
? agentName
: resolvedAgentId;
string agentId = agent.GetAgentId();
return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
: new AgentIdentity(agentId, agent.GetAgentName());
}
private static AgentIdentity CreateFallbackIdentity(
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
string resolvedAgentId = NormalizeOptionalString(agentId)
?? NormalizeOptionalString(agentName)
?? "agent";
string resolvedAgentName = NormalizeOptionalString(agentName)
?? resolvedAgentId;
if (agentSubworkflowIndex is not null
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
{
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
}
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
}
private static void CollectAgentSubworkflowContexts(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
SubworkflowContext? currentSubworkflow,
Dictionary<string, SubworkflowContext> index,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> 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())
{
if (currentSubworkflow.HasValue)
{
index[node.GetAgentId()] = currentSubworkflow.Value;
}
continue;
}
if (!node.IsSubWorkflowNode())
{
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentSubworkflowContexts(
subWorkflow,
workflowLibrary,
CreateSubworkflowContext(node, workflowLibrary),
index,
visitedWorkflowIds,
visitedAnonymousWorkflows);
}
}
private static SubworkflowContext CreateSubworkflowContext(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
}
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
@@ -164,6 +404,9 @@ internal static class AgentIdentityResolver
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeComparisonKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -297,14 +297,21 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{
if (evt is ExecutorInvokedEvent invoked)
{
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
command.Workflow,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-started",
invoked.ExecutorId,
out AgentActivityEventDto subworkflowStarted))
{
TraceHandoff(
command,
$"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
@@ -355,16 +362,22 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
if (evt is ExecutorCompletedEvent completed)
{
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent))
{
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.QueueCompletedActivity(completedAgent);
state.ClearActiveAgentIfMatching(completedAgent);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-completed",
completed.ExecutorId,
out AgentActivityEventDto subworkflowCompleted))
{
TraceHandoff(
command,
$"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
@@ -470,11 +483,10 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
else if (state.TryResolveObservedAgentIdentity(
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
{
updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName;
@@ -545,11 +557,12 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
TraceHandoff(
command,
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
$"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
promotedAgent,
onEvent).ConfigureAwait(false);
}
}
@@ -593,8 +606,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{
case ExecutorFailedEvent executorFailed:
{
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
executorFailed.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedAgent)
@@ -7,6 +7,8 @@ namespace Aryx.AgentHost.Services;
internal class TurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
@@ -19,6 +21,8 @@ internal class TurnExecutionState
public TurnExecutionState(RunTurnCommandDto command)
{
_command = command;
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
@@ -33,6 +37,59 @@ internal class TurnExecutionState
public bool SuppressHookLifecycleEvents { get; set; }
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentId,
agentName,
_agentSubworkflowIndex);
}
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
_agentSubworkflowIndex,
out agent);
}
public bool TryResolveObservedAgentIdentity(
string? agentIdentifier,
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
fallbackAgent,
_agentSubworkflowIndex,
out agent);
}
public bool TryCreateSubworkflowLifecycleActivity(
string activityType,
string? executorId,
out AgentActivityEventDto activity)
{
activity = default!;
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
_command.Workflow,
_workflowLibrary,
executorId,
out SubworkflowContext subworkflow))
{
return false;
}
activity = CreateSubworkflowActivity(activityType, subworkflow);
return true;
}
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<SidecarEventDto, Task> onEvent)
@@ -67,14 +124,13 @@ internal class TurnExecutionState
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
}
}
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
{
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
AgentIdentity agent = ResolveAgentIdentity(
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
@@ -313,6 +369,8 @@ internal class TurnExecutionState
ActivityType = "thinking",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
@@ -335,6 +393,8 @@ internal class TurnExecutionState
ActivityType = "tool-calling",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
ToolName = toolName,
ToolCallId = toolCallId,
ToolArguments = toolArguments,
@@ -351,6 +411,23 @@ internal class TurnExecutionState
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
private AgentActivityEventDto CreateSubworkflowActivity(
string activityType,
SubworkflowContext subworkflow)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = activityType,
SubworkflowNodeId = subworkflow.SubworkflowNodeId,
SubworkflowName = subworkflow.SubworkflowName,
};
}
@@ -45,6 +45,12 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
}
public static bool IsSubWorkflowNode(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase);
}
public static string GetAgentId(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
@@ -87,6 +93,67 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
}
public static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
CreateWorkflowLibraryMap(workflowLibrary),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
workflowLibrary ?? EmptyWorkflowLibrary,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static string GetSubworkflowDisplayName(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
WorkflowDefinitionDto? resolvedWorkflow = null;
if (node.Config.InlineWorkflow is not null)
{
resolvedWorkflow = node.Config.InlineWorkflow;
}
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary is not null
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
resolvedWorkflow = workflow;
}
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
}
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
@@ -128,7 +195,55 @@ internal static class WorkflowDefinitionExtensions
}
}
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
private static WorkflowNodeDto? FindSubWorkflowNode(
WorkflowDefinitionDto workflowDefinition,
string nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return null;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return null;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (!node.IsSubWorkflowNode())
{
continue;
}
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
{
return node;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
WorkflowNodeDto? match = FindSubWorkflowNode(
subWorkflow,
nodeId,
workflowLibrary,
visitedWorkflowIds,
visitedAnonymousWorkflows);
if (match is not null)
{
return match;
}
}
return null;
}
internal static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
{
return workflowLibrary?
@@ -23,7 +23,7 @@ internal static class WorkflowRequestInfoInterpreter
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch
{
HandoffRequestInterpretation handoff =>
@@ -39,7 +39,7 @@ internal static class WorkflowRequestInfoInterpreter
RequestInfoEvent requestInfo)
{
return command.Workflow.IsOrchestrationMode("handoff")
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
&& InterpretRequest(command, requestInfo) is UnknownRequestInterpretation;
}
private static AgentActivityEventDto CreateHandoffActivity(
@@ -55,6 +55,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = HandoffActivityType,
AgentId = handoffAgent.AgentId,
AgentName = handoffAgent.AgentName,
SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName,
SourceAgentId = activeAgent?.AgentId,
SourceAgentName = activeAgent?.AgentName,
};
@@ -88,6 +90,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = ToolCallingActivityType,
AgentId = activeAgent.AgentId,
AgentName = activeAgent.AgentName,
SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = activeAgent.Subworkflow?.SubworkflowName,
ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments,
@@ -109,10 +113,10 @@ internal static class WorkflowRequestInfoInterpreter
}
private static RequestInterpretation InterpretRequest(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
if (TryGetHandoffTarget(command, requestInfo, out AgentIdentity handoffAgent))
{
return new HandoffRequestInterpretation(handoffAgent);
}
@@ -123,7 +127,7 @@ internal static class WorkflowRequestInfoInterpreter
}
private static bool TryGetHandoffTarget(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
out AgentIdentity agent)
{
@@ -142,7 +146,8 @@ internal static class WorkflowRequestInfoInterpreter
}
agent = AgentIdentityResolver.ResolveAgentIdentity(
workflow,
command.Workflow,
command.WorkflowLibrary,
target.Id,
target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -115,19 +115,86 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
workflow,
[nestedWorkflow],
"Reviewer_agent_reviewer",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-reviewer", agent.AgentId);
Assert.Equal("Reviewer", agent.AgentName);
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
{
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
"inner-workflow",
[
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
"outer-workflow",
[
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
],
orchestrationMode: "single");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
}
private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> agents,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
}
private static WorkflowDefinitionDto CreateWorkflow(
string id,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Id = id,
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
.. agents,
.. nodes,
],
},
Settings = new WorkflowSettingsDto
@@ -154,4 +221,24 @@ public sealed class AgentIdentityResolverTests
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
}
@@ -59,6 +59,40 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", observedAgent.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
{
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
CopilotTurnExecutionState state = new(command);
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
state.ObserveSessionEvent(
nestedAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-nested-1",
"deltaContent": "Reviewing"
},
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-reviewer", activity.AgentId);
Assert.Equal("Reviewer", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
@@ -746,5 +780,79 @@ public sealed class CopilotTurnExecutionStateTests
},
};
}
private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
]);
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = [nestedWorkflow],
Workflow = CreateWorkflow(
"workflow-parent",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]),
};
}
private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList<WorkflowNodeDto> nodes)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Execution State Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
}
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
},
};
}
}
@@ -1052,6 +1052,78 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Primary", completed.AgentName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
{
@@ -2243,11 +2315,37 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
params WorkflowNodeDto[] agents)
{
return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents);
return CreateCommand(
orchestrationMode,
modeSettings: null,
workflowName: null,
workflowDescription: null,
workflowLibrary: null,
agents: agents);
}
private static RunTurnCommandDto CreateCommand(
@@ -2255,12 +2353,14 @@ public sealed class CopilotWorkflowRunnerTests
OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null,
string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"workflow-{orchestrationMode}",
@@ -2336,6 +2436,35 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateReferencedSubworkflowCommand()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-reviewer", "Reviewer"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"single",
workflowName: "Parent Workflow",
workflowLibrary: [nestedWorkflow],
agents:
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]);
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return CreateCommand(
@@ -246,6 +246,54 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(tracking.ToolCallHasArgumentsById);
}
[Fact]
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity(
"agent-reviewer",
"Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
Assert.Equal("UX Specialist", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
{
@@ -341,24 +389,56 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateAgent("agent-handoff-ux", "UX Specialist"),
]);
private static RunTurnCommandDto CreateHandoffCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"handoff",
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
workflowLibrary: [nestedWorkflow]);
}
private static (
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
IReadOnlyList<WorkflowNodeDto> nodes,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
@@ -386,6 +466,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");