mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
feat(workflows): add conditional edges and loop validation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user