feat(workflows): add conditional edges and loop validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-05 18:57:33 +02:00
co-authored by Copilot
parent f011311514
commit 8f6830dca1
8 changed files with 1404 additions and 7 deletions
@@ -138,6 +138,8 @@ public sealed class WorkflowEdgeDto
public EdgeConditionDto? Condition { get; init; }
public string? Label { get; init; }
public FanOutConfigDto? FanOutConfig { get; init; }
public bool? IsLoop { get; init; }
public int? MaxIterations { get; init; }
}
public sealed class WorkflowGraphDto
@@ -0,0 +1,432 @@
using System.Collections;
using System.Globalization;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal static class WorkflowConditionEvaluator
{
private static readonly HashSet<string> SupportedConditionTypes = new(StringComparer.OrdinalIgnoreCase)
{
"always",
"message-type",
"expression",
"property",
};
private static readonly HashSet<string> SupportedOperators = new(StringComparer.OrdinalIgnoreCase)
{
"equals",
"not-equals",
"contains",
"gt",
"lt",
"regex",
};
private static readonly Regex ComparisonExpression = new(
@"^(?<path>[A-Za-z_][A-Za-z0-9_\.]*)\s*(?<operator>==|!=|>|<|contains|matches)\s*(?<value>""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false)$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
internal static bool IsSupportedConditionType(string? type)
=> !string.IsNullOrWhiteSpace(type) && SupportedConditionTypes.Contains(type);
internal static bool IsSupportedOperator(string? @operator)
=> !string.IsNullOrWhiteSpace(@operator) && SupportedOperators.Contains(@operator);
internal static bool IsSupportedExpression(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return false;
}
string trimmed = expression.Trim();
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string? delimiter = trimmed.Contains("&&", StringComparison.Ordinal) ? "&&" : null;
if (delimiter is null && trimmed.Contains("||", StringComparison.Ordinal))
{
delimiter = "||";
}
if (delimiter is null)
{
return ComparisonExpression.IsMatch(trimmed);
}
return trimmed
.Split(delimiter, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.All(segment => ComparisonExpression.IsMatch(segment));
}
internal static Func<object?, bool>? Compile(WorkflowEdgeDto edge)
{
ArgumentNullException.ThrowIfNull(edge);
Func<object?, bool>? baseCondition = edge.Condition is null
? null
: CompileCondition(edge.Condition);
if (edge.IsLoop != true)
{
return baseCondition;
}
int maxIterations = edge.MaxIterations ?? 0;
int successfulIterations = 0;
return payload =>
{
if (successfulIterations >= maxIterations)
{
return false;
}
if (baseCondition is not null && !baseCondition(payload))
{
return false;
}
successfulIterations++;
return true;
};
}
internal static bool Evaluate(EdgeConditionDto condition, object? payload)
{
ArgumentNullException.ThrowIfNull(condition);
return CompileCondition(condition)?.Invoke(payload) ?? true;
}
private static Func<object?, bool>? CompileCondition(EdgeConditionDto condition)
{
if (string.IsNullOrWhiteSpace(condition.Type)
|| string.Equals(condition.Type, "always", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (string.Equals(condition.Type, "message-type", StringComparison.OrdinalIgnoreCase))
{
string expectedTypeName = condition.TypeName?.Trim() ?? string.Empty;
return payload =>
{
if (payload is null || string.IsNullOrWhiteSpace(expectedTypeName))
{
return false;
}
Type payloadType = payload.GetType();
return string.Equals(payloadType.Name, expectedTypeName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(payloadType.FullName, expectedTypeName, StringComparison.OrdinalIgnoreCase);
};
}
if (string.Equals(condition.Type, "property", StringComparison.OrdinalIgnoreCase))
{
string combinator = string.Equals(condition.Combinator, "or", StringComparison.OrdinalIgnoreCase) ? "or" : "and";
return payload =>
{
IReadOnlyList<bool> results = condition.Rules
.Select(rule => EvaluateRule(rule, payload))
.ToArray();
if (results.Count == 0)
{
return false;
}
return string.Equals(combinator, "or", StringComparison.OrdinalIgnoreCase)
? results.Any(result => result)
: results.All(result => result);
};
}
if (string.Equals(condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
{
return payload => EvaluateExpression(condition.Expression, payload);
}
throw new NotSupportedException($"Condition type \"{condition.Type}\" is not supported.");
}
private static bool EvaluateExpression(string? expression, object? payload)
{
string trimmed = expression?.Trim() ?? string.Empty;
if (string.Equals(trimmed, "true", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(trimmed, "false", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (trimmed.Contains("&&", StringComparison.Ordinal))
{
return trimmed
.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.All(segment => EvaluateExpression(segment, payload));
}
if (trimmed.Contains("||", StringComparison.Ordinal))
{
return trimmed
.Split("||", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Any(segment => EvaluateExpression(segment, payload));
}
Match match = ComparisonExpression.Match(trimmed);
if (!match.Success)
{
return false;
}
string path = match.Groups["path"].Value;
string @operator = match.Groups["operator"].Value switch
{
"==" => "equals",
"!=" => "not-equals",
">" => "gt",
"<" => "lt",
"matches" => "regex",
_ => match.Groups["operator"].Value,
};
string rawValue = match.Groups["value"].Value;
string value = UnwrapLiteral(rawValue);
return EvaluateRule(
new WorkflowConditionRuleDto
{
PropertyPath = path,
Operator = @operator,
Value = value,
},
payload);
}
private static bool EvaluateRule(WorkflowConditionRuleDto rule, object? payload)
{
if (payload is null || string.IsNullOrWhiteSpace(rule.PropertyPath))
{
return false;
}
if (!TryResolvePropertyPath(payload, rule.PropertyPath, out object? actualValue))
{
return false;
}
return rule.Operator switch
{
"equals" => AreEqual(actualValue, rule.Value),
"not-equals" => !AreEqual(actualValue, rule.Value),
"contains" => ContainsValue(actualValue, rule.Value),
"gt" => CompareAsNumberOrString(actualValue, rule.Value) > 0,
"lt" => CompareAsNumberOrString(actualValue, rule.Value) < 0,
"regex" => Regex.IsMatch(CoerceToString(actualValue), rule.Value, RegexOptions.CultureInvariant),
_ => false,
};
}
private static bool TryResolvePropertyPath(object payload, string propertyPath, out object? value)
{
object? current = payload;
foreach (string segment in propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (!TryResolvePropertySegment(current, segment, out current))
{
value = null;
return false;
}
}
value = current;
return true;
}
private static bool TryResolvePropertySegment(object? current, string segment, out object? value)
{
value = null;
if (current is null)
{
return false;
}
if (current is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty jsonProperty in jsonElement.EnumerateObject())
{
if (string.Equals(jsonProperty.Name, segment, StringComparison.OrdinalIgnoreCase))
{
value = jsonProperty.Value;
return true;
}
}
}
return false;
}
if (current is IDictionary dictionary)
{
foreach (DictionaryEntry entry in dictionary)
{
if (entry.Key is string key && string.Equals(key, segment, StringComparison.OrdinalIgnoreCase))
{
value = entry.Value;
return true;
}
}
}
Type type = current.GetType();
PropertyInfo? property = type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.FirstOrDefault(candidate => string.Equals(candidate.Name, segment, StringComparison.OrdinalIgnoreCase));
if (property is not null)
{
value = property.GetValue(current);
return true;
}
return false;
}
private static bool AreEqual(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return false;
}
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
{
return actualDecimal == expectedDecimal;
}
return string.Equals(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsValue(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return false;
}
if (actualValue is string actualString)
{
return actualString.Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
}
if (actualValue is IEnumerable enumerable)
{
foreach (object? item in enumerable)
{
if (string.Equals(CoerceToString(item), expectedValue, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return CoerceToString(actualValue).Contains(expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static int CompareAsNumberOrString(object? actualValue, string expectedValue)
{
if (actualValue is null)
{
return -1;
}
if (TryConvertToDecimal(actualValue, out decimal actualDecimal)
&& decimal.TryParse(expectedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal expectedDecimal))
{
return actualDecimal.CompareTo(expectedDecimal);
}
return string.Compare(CoerceToString(actualValue), expectedValue, StringComparison.OrdinalIgnoreCase);
}
private static bool TryConvertToDecimal(object? value, out decimal result)
{
switch (value)
{
case byte byteValue:
result = byteValue;
return true;
case short shortValue:
result = shortValue;
return true;
case int intValue:
result = intValue;
return true;
case long longValue:
result = longValue;
return true;
case float floatValue:
result = (decimal)floatValue;
return true;
case double doubleValue:
result = (decimal)doubleValue;
return true;
case decimal decimalValue:
result = decimalValue;
return true;
case JsonElement jsonElement when jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetDecimal(out decimal jsonDecimal):
result = jsonDecimal;
return true;
default:
return decimal.TryParse(
CoerceToString(value),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out result);
}
}
private static string CoerceToString(object? value)
{
if (value is null)
{
return string.Empty;
}
if (value is JsonElement jsonElement)
{
return jsonElement.ValueKind switch
{
JsonValueKind.String => jsonElement.GetString() ?? string.Empty,
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => jsonElement.ToString(),
};
}
return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
}
private static string UnwrapLiteral(string rawValue)
{
if (rawValue.Length >= 2
&& ((rawValue.StartsWith('"') && rawValue.EndsWith('"'))
|| (rawValue.StartsWith('\'') && rawValue.EndsWith('\''))))
{
return rawValue[1..^1];
}
return rawValue;
}
}
@@ -36,16 +36,41 @@ internal sealed class WorkflowRunner
foreach (WorkflowEdgeDto edge in workflowDefinition.Graph.Edges.Where(edge =>
string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase)))
{
builder.AddEdge(bindings[edge.Source], bindings[edge.Target]);
Func<object?, bool>? condition = WorkflowConditionEvaluator.Compile(edge);
if (condition is null)
{
builder.AddEdge(bindings[edge.Source], bindings[edge.Target]);
}
else
{
builder.AddEdge<object>(bindings[edge.Source], bindings[edge.Target], condition);
}
}
foreach (IGrouping<string, WorkflowEdgeDto> fanOutGroup in workflowDefinition.Graph.Edges
.Where(edge => string.Equals(edge.Kind, "fan-out", StringComparison.OrdinalIgnoreCase))
.GroupBy(edge => edge.Source, StringComparer.Ordinal))
{
builder.AddFanOutEdge(
WorkflowEdgeDto[] fanOutEdges = fanOutGroup.ToArray();
ExecutorBinding[] targets = fanOutEdges.Select(edge => bindings[edge.Target]).ToArray();
Func<object?, bool>?[] compiledConditions = fanOutEdges
.Select(WorkflowConditionEvaluator.Compile)
.ToArray();
bool hasConditionalRouting = fanOutEdges.Any(edge => edge.Condition is not null);
if (!hasConditionalRouting)
{
builder.AddFanOutEdge(bindings[fanOutGroup.Key], targets);
continue;
}
builder.AddFanOutEdge<object>(
bindings[fanOutGroup.Key],
fanOutGroup.Select(edge => bindings[edge.Target]).ToArray());
targets,
(payload, _) => fanOutEdges
.Select((edge, index) => (edge, index))
.Where(pair => compiledConditions[pair.index]?.Invoke(payload) ?? true)
.Select(pair => pair.index)
.ToArray());
}
foreach (IGrouping<string, WorkflowEdgeDto> fanInGroup in workflowDefinition.Graph.Edges
@@ -135,6 +135,8 @@ public sealed class WorkflowValidator
incomingCounts[edge.Target] = incomingCounts.TryGetValue(edge.Target, out int incoming)
? incoming + 1
: 1;
ValidateEdgeCondition(edge, issues);
}
List<WorkflowNodeDto> startNodes = workflow.Graph.Nodes
@@ -250,9 +252,219 @@ public sealed class WorkflowValidator
});
}
if (workflow.Settings.MaxIterations is int workflowMaxIterations
&& (workflowMaxIterations < 1 || workflowMaxIterations > 100))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "settings.maxIterations",
Message = "Workflow maxIterations must be between 1 and 100.",
});
}
foreach (WorkflowEdgeDto edge in workflow.Graph.Edges)
{
bool participatesInCycle = IsLoopEdge(workflow.Graph, edge);
if (!participatesInCycle)
{
if (edge.IsLoop == true)
{
issues.Add(new WorkflowValidationIssueDto
{
Level = "warning",
Field = "graph.edges.isLoop",
EdgeId = edge.Id,
Message = "This edge is marked as a loop but does not currently form a cycle.",
});
}
continue;
}
if (!string.Equals(edge.Kind, "direct", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.kind",
EdgeId = edge.Id,
Message = "Loop edges currently support only direct edges.",
});
}
if (edge.IsLoop != true)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.isLoop",
EdgeId = edge.Id,
Message = "Edges that participate in a cycle must be explicitly marked as loops.",
});
}
if (edge.Condition is null || string.Equals(edge.Condition.Type, "always", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition",
EdgeId = edge.Id,
Message = "Loop edges require a non-default condition so the loop can terminate.",
});
}
if (edge.MaxIterations is null || edge.MaxIterations < 1)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.maxIterations",
EdgeId = edge.Id,
Message = "Loop edges require a maxIterations value of at least 1.",
});
}
HashSet<string> componentNodes = CollectStronglyConnectedNodes(workflow.Graph, edge.Source);
bool hasExitPath = workflow.Graph.Edges.Any(candidate =>
componentNodes.Contains(candidate.Source) && !componentNodes.Contains(candidate.Target));
if (!hasExitPath)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges",
EdgeId = edge.Id,
Message = "Loop cycles must include an exit path to a node outside the loop.",
});
}
}
return issues;
}
private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List<WorkflowValidationIssueDto> issues)
{
if (edge.Condition is null)
{
return;
}
if (string.Equals(edge.Kind, "fan-in", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition",
EdgeId = edge.Id,
Message = "Fan-in edges do not support conditions.",
});
}
if (!WorkflowConditionEvaluator.IsSupportedConditionType(edge.Condition.Type))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.type",
EdgeId = edge.Id,
Message = $"Condition type \"{edge.Condition.Type}\" is not supported.",
});
return;
}
if (string.Equals(edge.Condition.Type, "message-type", StringComparison.OrdinalIgnoreCase)
&& string.IsNullOrWhiteSpace(edge.Condition.TypeName))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.typeName",
EdgeId = edge.Id,
Message = "Message-type conditions require a type name.",
});
}
if (string.Equals(edge.Condition.Type, "expression", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(edge.Condition.Expression))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.expression",
EdgeId = edge.Id,
Message = "Expression conditions require a non-empty expression.",
});
}
else if (!WorkflowConditionEvaluator.IsSupportedExpression(edge.Condition.Expression))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.expression",
EdgeId = edge.Id,
Message = "Expression conditions currently support simple comparisons using ==, !=, >, <, contains, matches, optionally combined with && or ||.",
});
}
}
if (string.Equals(edge.Condition.Type, "property", StringComparison.OrdinalIgnoreCase))
{
if (edge.Condition.Rules.Count == 0)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules",
EdgeId = edge.Id,
Message = "Property conditions require at least one rule.",
});
}
if (!string.IsNullOrWhiteSpace(edge.Condition.Combinator)
&& !string.Equals(edge.Condition.Combinator, "and", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(edge.Condition.Combinator, "or", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.combinator",
EdgeId = edge.Id,
Message = "Property conditions must use the \"and\" or \"or\" combinator.",
});
}
foreach (WorkflowConditionRuleDto rule in edge.Condition.Rules)
{
if (string.IsNullOrWhiteSpace(rule.PropertyPath))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.propertyPath",
EdgeId = edge.Id,
Message = "Property condition rules require a property path.",
});
}
if (!WorkflowConditionEvaluator.IsSupportedOperator(rule.Operator))
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.operator",
EdgeId = edge.Id,
Message = $"Property condition operator \"{rule.Operator}\" is not supported.",
});
}
if (string.Equals(rule.Operator, "regex", StringComparison.OrdinalIgnoreCase))
{
try
{
_ = new System.Text.RegularExpressions.Regex(rule.Value);
}
catch (ArgumentException)
{
issues.Add(new WorkflowValidationIssueDto
{
Field = "graph.edges.condition.rules.value",
EdgeId = edge.Id,
Message = $"Regex pattern \"{rule.Value}\" is invalid.",
});
}
}
}
}
}
private static bool HasPathToAnyEnd(
string startNodeId,
WorkflowGraphDto graph,
@@ -289,4 +501,64 @@ public sealed class WorkflowValidator
return false;
}
private static bool CanReachNode(
WorkflowGraphDto graph,
string startNodeId,
string targetNodeId,
string? excludedEdgeId = null)
{
if (string.Equals(startNodeId, targetNodeId, StringComparison.Ordinal))
{
return true;
}
Dictionary<string, List<string>> outgoing = graph.Edges
.Where(edge => !string.Equals(edge.Id, excludedEdgeId, StringComparison.Ordinal))
.GroupBy(edge => edge.Source, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.Select(edge => edge.Target).ToList(),
StringComparer.Ordinal);
Queue<string> queue = new([startNodeId]);
HashSet<string> visited = new(StringComparer.Ordinal);
while (queue.Count > 0)
{
string current = queue.Dequeue();
if (!visited.Add(current))
{
continue;
}
foreach (string target in outgoing.GetValueOrDefault(current, []))
{
if (string.Equals(target, targetNodeId, StringComparison.Ordinal))
{
return true;
}
queue.Enqueue(target);
}
}
return false;
}
private static bool IsLoopEdge(WorkflowGraphDto graph, WorkflowEdgeDto edge)
=> CanReachNode(graph, edge.Target, edge.Source, edge.Id);
private static HashSet<string> CollectStronglyConnectedNodes(WorkflowGraphDto graph, string nodeId)
{
HashSet<string> connected = new(StringComparer.Ordinal);
foreach (WorkflowNodeDto candidate in graph.Nodes)
{
if (CanReachNode(graph, nodeId, candidate.Id) && CanReachNode(graph, candidate.Id, nodeId))
{
connected.Add(candidate.Id);
}
}
return connected;
}
}
@@ -0,0 +1,80 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowConditionEvaluatorTests
{
[Fact]
public void Evaluate_PropertyCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "property",
Combinator = "and",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Role",
Operator = "equals",
Value = "user",
},
],
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 1, "hello"));
Assert.True(matched);
}
[Fact]
public void Evaluate_ExpressionCondition_MatchesPayload()
{
EdgeConditionDto condition = new()
{
Type = "expression",
Expression = "Iteration < 3 && Role == \"user\"",
};
bool matched = WorkflowConditionEvaluator.Evaluate(condition, new TestPayload("user", 2, "hello"));
Assert.True(matched);
}
[Fact]
public void Compile_LoopCondition_StopsAfterMaxIterations()
{
WorkflowEdgeDto edge = new()
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 2,
Condition = new EdgeConditionDto
{
Type = "property",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Iteration",
Operator = "lt",
Value = "10",
},
],
},
};
Func<object?, bool>? compiled = WorkflowConditionEvaluator.Compile(edge);
Assert.NotNull(compiled);
Assert.True(compiled!(new TestPayload("user", 1, "hello")));
Assert.True(compiled(new TestPayload("user", 2, "hello")));
Assert.False(compiled(new TestPayload("user", 3, "hello")));
}
private sealed record TestPayload(string Role, int Iteration, string Content);
}
@@ -0,0 +1,187 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
namespace Aryx.AgentHost.Tests;
public sealed class WorkflowValidatorTests
{
private readonly WorkflowValidator _validator = new();
[Fact]
public void Validate_RejectsInvalidConditionOperator()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = "agent",
Kind = "direct",
Condition = new EdgeConditionDto
{
Type = "property",
Rules =
[
new WorkflowConditionRuleDto
{
PropertyPath = "Role",
Operator = "bad-op",
Value = "user",
},
],
},
},
workflow.Graph.Edges[1],
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition.rules.operator");
}
[Fact]
public void Validate_RejectsLoopWithoutMetadata()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
.. workflow.Graph.Edges,
new WorkflowEdgeDto
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
},
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.Contains(issues, issue => issue.Field == "graph.edges.isLoop");
Assert.Contains(issues, issue => issue.Field == "graph.edges.condition");
Assert.Contains(issues, issue => issue.Field == "graph.edges.maxIterations");
}
[Fact]
public void Validate_AcceptsLoopWithExitPathConditionAndCap()
{
WorkflowDefinitionDto workflow = CreateWorkflow();
workflow = new WorkflowDefinitionDto
{
Id = workflow.Id,
Name = workflow.Name,
Settings = workflow.Settings,
Graph = new WorkflowGraphDto
{
Nodes = workflow.Graph.Nodes,
Edges =
[
.. workflow.Graph.Edges,
new WorkflowEdgeDto
{
Id = "edge-loop",
Source = "agent",
Target = "agent",
Kind = "direct",
IsLoop = true,
MaxIterations = 3,
Condition = new EdgeConditionDto
{
Type = "expression",
Expression = "Iteration < 3",
},
},
],
},
};
IReadOnlyList<WorkflowValidationIssueDto> issues = _validator.Validate(workflow);
Assert.DoesNotContain(issues, issue => issue.Level == "error");
}
private static WorkflowDefinitionDto CreateWorkflow()
{
return new WorkflowDefinitionDto
{
Id = "workflow-1",
Name = "Loop Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "agent",
Kind = "agent",
Label = "Agent",
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = "agent",
Name = "Agent",
Model = "gpt-5.4",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = "agent",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-agent-end",
Source = "agent",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
};
}
}
+309 -4
View File
@@ -131,6 +131,8 @@ export interface WorkflowEdge {
condition?: EdgeCondition;
label?: string;
fanOutConfig?: FanOutConfig;
isLoop?: boolean;
maxIterations?: number;
}
export interface WorkflowGraph {
@@ -236,6 +238,41 @@ function normalizeNodeConfig(kind: WorkflowNodeKind, config?: Partial<WorkflowNo
}
}
function normalizeConditionRule(rule: WorkflowConditionRule): WorkflowConditionRule {
return {
propertyPath: rule.propertyPath.trim(),
operator: rule.operator,
value: rule.value.trim(),
};
}
function normalizeEdgeCondition(condition?: EdgeCondition): EdgeCondition | undefined {
if (!condition) {
return undefined;
}
switch (condition.type) {
case 'always':
return { type: 'always' };
case 'message-type':
return {
type: 'message-type',
typeName: condition.typeName.trim(),
};
case 'expression':
return {
type: 'expression',
expression: condition.expression.trim(),
};
case 'property':
return {
type: 'property',
combinator: condition.combinator === 'or' ? 'or' : 'and',
rules: condition.rules.map(normalizeConditionRule),
};
}
}
export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): WorkflowDefinition {
return {
...workflow,
@@ -256,7 +293,13 @@ export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): Workf
source: edge.source.trim(),
target: edge.target.trim(),
kind: edge.kind,
condition: normalizeEdgeCondition(edge.condition),
label: normalizeOptionalString(edge.label),
isLoop: edge.isLoop === true ? true : undefined,
maxIterations:
typeof edge.maxIterations === 'number' && edge.maxIterations > 0
? Math.round(edge.maxIterations)
: undefined,
})),
},
settings: {
@@ -372,9 +415,10 @@ function hasPathToEnd(startNodeId: string, graph: WorkflowGraph, endNodeIds: Set
const visited = new Set<string>();
while (queue.length > 0) {
const nodeId = queue.shift()!;
if (!visited.add(nodeId)) {
if (visited.has(nodeId)) {
continue;
}
visited.add(nodeId);
if (endNodeIds.has(nodeId)) {
return true;
}
@@ -386,6 +430,184 @@ function hasPathToEnd(startNodeId: string, graph: WorkflowGraph, endNodeIds: Set
return false;
}
function buildOutgoingEdges(graph: WorkflowGraph, excludedEdgeId?: string): Map<string, WorkflowEdge[]> {
const outgoing = new Map<string, WorkflowEdge[]>();
for (const edge of graph.edges) {
if (edge.id === excludedEdgeId) {
continue;
}
const current = outgoing.get(edge.source);
if (current) {
current.push(edge);
} else {
outgoing.set(edge.source, [edge]);
}
}
return outgoing;
}
function canReachNode(graph: WorkflowGraph, startNodeId: string, targetNodeId: string, excludedEdgeId?: string): boolean {
if (startNodeId === targetNodeId) {
return true;
}
const outgoing = buildOutgoingEdges(graph, excludedEdgeId);
const queue = [startNodeId];
const visited = new Set<string>();
while (queue.length > 0) {
const nodeId = queue.shift()!;
if (visited.has(nodeId)) {
continue;
}
visited.add(nodeId);
for (const edge of outgoing.get(nodeId) ?? []) {
if (edge.target === targetNodeId) {
return true;
}
queue.push(edge.target);
}
}
return false;
}
function collectStronglyConnectedNodes(graph: WorkflowGraph, nodeId: string): Set<string> {
const connected = new Set<string>();
for (const candidate of graph.nodes) {
if (canReachNode(graph, nodeId, candidate.id) && canReachNode(graph, candidate.id, nodeId)) {
connected.add(candidate.id);
}
}
return connected;
}
function isLoopEdge(graph: WorkflowGraph, edge: WorkflowEdge): boolean {
return canReachNode(graph, edge.target, edge.source, edge.id);
}
function isSupportedExpressionCondition(expression: string): boolean {
const trimmed = expression.trim();
if (!trimmed) {
return false;
}
if (trimmed === 'true' || trimmed === 'false') {
return true;
}
const comparisonPattern =
/^[A-Za-z_][A-Za-z0-9_.]*\s*(==|!=|>|<|contains|matches)\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?|true|false)$/;
const logicalOperator = trimmed.includes('&&') ? '&&' : trimmed.includes('||') ? '||' : undefined;
if (!logicalOperator) {
return comparisonPattern.test(trimmed);
}
return trimmed
.split(logicalOperator)
.map((segment) => segment.trim())
.every((segment) => comparisonPattern.test(segment));
}
function validateEdgeCondition(edge: WorkflowEdge, issues: WorkflowValidationIssue[]): void {
const condition = edge.condition;
if (!condition) {
return;
}
switch (condition.type) {
case 'always':
return;
case 'message-type':
if (!condition.typeName.trim()) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.typeName',
edgeId: edge.id,
message: 'Message-type conditions require a type name.',
});
}
return;
case 'expression':
if (!condition.expression.trim()) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.expression',
edgeId: edge.id,
message: 'Expression conditions require a non-empty expression.',
});
return;
}
if (!isSupportedExpressionCondition(condition.expression)) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.expression',
edgeId: edge.id,
message:
'Expression conditions currently support simple comparisons using ==, !=, >, <, contains, matches, optionally combined with && or ||.',
});
}
return;
case 'property': {
if (condition.rules.length === 0) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.rules',
edgeId: edge.id,
message: 'Property conditions require at least one rule.',
});
}
if (condition.combinator && condition.combinator !== 'and' && condition.combinator !== 'or') {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.combinator',
edgeId: edge.id,
message: 'Property conditions must use the "and" or "or" combinator.',
});
}
for (const rule of condition.rules) {
if (!rule.propertyPath.trim()) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.rules.propertyPath',
edgeId: edge.id,
message: 'Property condition rules require a property path.',
});
}
if (!['equals', 'not-equals', 'contains', 'gt', 'lt', 'regex'].includes(rule.operator)) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.rules.operator',
edgeId: edge.id,
message: `Property condition operator "${rule.operator}" is not supported.`,
});
}
if (rule.operator === 'regex') {
try {
new RegExp(rule.value);
} catch {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition.rules.value',
edgeId: edge.id,
message: `Regex pattern "${rule.value}" is invalid.`,
});
}
}
}
}
}
}
export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] {
const normalized = normalizeWorkflowDefinition(workflow);
const issues: WorkflowValidationIssue[] = [];
@@ -464,7 +686,6 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
}
}
}
for (const edge of normalized.graph.edges) {
if (!edge.id) {
addIssue(issues, { level: 'error', field: 'graph.edges.id', message: 'Workflow edges must have an ID.' });
@@ -490,8 +711,18 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
outgoingCounts.set(edge.source, (outgoingCounts.get(edge.source) ?? 0) + 1);
incomingCounts.set(edge.target, (incomingCounts.get(edge.target) ?? 0) + 1);
}
if (edge.kind === 'fan-in' && edge.condition) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition',
edgeId: edge.id,
message: 'Fan-in edges do not support conditions.',
});
}
validateEdgeCondition(edge, issues);
}
const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start');
const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end');
const agentNodes = normalized.graph.nodes.filter((node) => node.kind === 'agent');
@@ -592,7 +823,6 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
});
}
}
const startNode = startNodes[0];
if (startNode && endNodes.length > 0 && !hasPathToEnd(startNode.id, normalized.graph, new Set(endNodes.map((node) => node.id)))) {
addIssue(issues, {
@@ -601,6 +831,81 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
message: 'Workflow graph must include a path from the start node to at least one end node.',
});
}
if (
normalized.settings.maxIterations !== undefined
&& (normalized.settings.maxIterations < 1 || normalized.settings.maxIterations > 100)
) {
addIssue(issues, {
level: 'error',
field: 'settings.maxIterations',
message: 'Workflow maxIterations must be between 1 and 100.',
});
}
for (const edge of normalized.graph.edges) {
const loopEdge = isLoopEdge(normalized.graph, edge);
if (loopEdge && edge.kind !== 'direct') {
addIssue(issues, {
level: 'error',
field: 'graph.edges.kind',
edgeId: edge.id,
message: 'Loop edges currently support only direct edges.',
});
}
if (loopEdge && !edge.isLoop) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.isLoop',
edgeId: edge.id,
message: 'Edges that participate in a cycle must be explicitly marked as loops.',
});
}
if (!loopEdge && edge.isLoop) {
addIssue(issues, {
level: 'warning',
field: 'graph.edges.isLoop',
edgeId: edge.id,
message: 'This edge is marked as a loop but does not currently form a cycle.',
});
}
if (!loopEdge) {
continue;
}
if (!edge.condition || edge.condition.type === 'always') {
addIssue(issues, {
level: 'error',
field: 'graph.edges.condition',
edgeId: edge.id,
message: 'Loop edges require a non-default condition so the loop can terminate.',
});
}
if (edge.maxIterations === undefined || edge.maxIterations < 1) {
addIssue(issues, {
level: 'error',
field: 'graph.edges.maxIterations',
edgeId: edge.id,
message: 'Loop edges require a maxIterations value of at least 1.',
});
}
const loopComponent = collectStronglyConnectedNodes(normalized.graph, edge.source);
const hasExitPath = normalized.graph.edges.some((candidate) =>
loopComponent.has(candidate.source) && !loopComponent.has(candidate.target));
if (!hasExitPath) {
addIssue(issues, {
level: 'error',
field: 'graph.edges',
edgeId: edge.id,
message: 'Loop cycles must include an exit path to a node outside the loop.',
});
}
}
return issues;
}
+94
View File
@@ -92,4 +92,98 @@ describe('workflow validation', () => {
expect(pattern.agents[0]?.name).toBe('Primary Agent');
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
});
test('accepts simple property and expression conditions', () => {
const workflow = createWorkflow();
workflow.graph.edges[0] = {
...workflow.graph.edges[0]!,
condition: {
type: 'property',
combinator: 'and',
rules: [
{
propertyPath: 'role',
operator: 'equals',
value: 'user',
},
],
},
};
workflow.graph.edges[1] = {
...workflow.graph.edges[1]!,
condition: {
type: 'expression',
expression: 'role == "assistant" || role == "user"',
},
};
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
test('rejects invalid condition operators and expressions', () => {
const workflow = createWorkflow();
workflow.graph.edges[0] = {
...workflow.graph.edges[0]!,
condition: {
type: 'property',
rules: [
{
propertyPath: 'role',
operator: 'bad-op' as 'equals',
value: 'user',
},
],
},
};
workflow.graph.edges[1] = {
...workflow.graph.edges[1]!,
condition: {
type: 'expression',
expression: 'role ~= "user"',
},
};
const issues = validateWorkflowDefinition(workflow);
expect(issues.some((issue) => issue.field === 'graph.edges.condition.rules.operator')).toBe(true);
expect(issues.some((issue) => issue.field === 'graph.edges.condition.expression')).toBe(true);
});
test('rejects unmarked loop edges and requires termination metadata', () => {
const workflow = createWorkflow();
workflow.graph.edges.push({
id: 'edge-agent-loop',
source: 'agent-primary',
target: 'agent-primary',
kind: 'direct',
});
const issues = validateWorkflowDefinition(workflow);
expect(issues.some((issue) => issue.field === 'graph.edges.isLoop')).toBe(true);
expect(issues.some((issue) => issue.field === 'graph.edges.condition')).toBe(true);
expect(issues.some((issue) => issue.field === 'graph.edges.maxIterations')).toBe(true);
});
test('accepts loop edges with condition, cap, and exit path', () => {
const workflow = createWorkflow();
workflow.graph.edges.push({
id: 'edge-agent-loop',
source: 'agent-primary',
target: 'agent-primary',
kind: 'direct',
isLoop: true,
maxIterations: 3,
condition: {
type: 'property',
rules: [
{
propertyPath: 'iteration',
operator: 'lt',
value: '3',
},
],
},
});
expect(validateWorkflowDefinition(workflow)).toEqual([]);
});
});