fix: make sub-workflow resolution tolerant of missing references

Extract TryResolveSubWorkflowDefinition that returns null instead of
throwing when a referenced workflow is missing from the library.

Use the tolerant variant in all graph-walking helpers that build
agent indexes, collect agent nodes, and find sub-workflow nodes.
These operations are used for activity UI enrichment and agent
identity resolution — they should degrade gracefully rather than
crash the turn.

The throwing ResolveSubWorkflowDefinition is now only used by
WorkflowRunner.CreateNodeRoute, which is the actual execution
path and correctly fails with a clear error if a reference is
truly missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-08 19:15:58 +02:00
co-authored by Copilot
parent c70a5c6612
commit e36b00ff1d
3 changed files with 44 additions and 6 deletions
@@ -350,7 +350,12 @@ internal static class AgentIdentityResolver
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
CollectAgentSubworkflowContexts(
subWorkflow,
workflowLibrary,
@@ -66,6 +66,15 @@ internal static class WorkflowDefinitionExtensions
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
return node.TryResolveSubWorkflowDefinition(workflowLibrary)
?? throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
}
public static WorkflowDefinitionDto? TryResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
@@ -81,8 +90,7 @@ internal static class WorkflowDefinitionExtensions
return workflow;
}
throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
return null;
}
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
@@ -190,8 +198,11 @@ internal static class WorkflowDefinitionExtensions
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is not null)
{
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
}
}
}
@@ -227,7 +238,12 @@ internal static class WorkflowDefinitionExtensions
return node;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
WorkflowNodeDto? match = FindSubWorkflowNode(
subWorkflow,
nodeId,
@@ -174,6 +174,23 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_SkipsUnresolvableSubWorkflowReferences()
{
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateAgent("agent-top-level", "Top Level"),
CreateSubworkflow("subworkflow-missing", "Missing Pipeline", workflowId: "nonexistent-workflow"),
],
orchestrationMode: "concurrent");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.Empty(index);
}
private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")