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);