diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 9717122..ef53f4b 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -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 diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowConditionEvaluator.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowConditionEvaluator.cs new file mode 100644 index 0000000..846d80c --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowConditionEvaluator.cs @@ -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 SupportedConditionTypes = new(StringComparer.OrdinalIgnoreCase) + { + "always", + "message-type", + "expression", + "property", + }; + + private static readonly HashSet SupportedOperators = new(StringComparer.OrdinalIgnoreCase) + { + "equals", + "not-equals", + "contains", + "gt", + "lt", + "regex", + }; + + private static readonly Regex ComparisonExpression = new( + @"^(?[A-Za-z_][A-Za-z0-9_\.]*)\s*(?==|!=|>|<|contains|matches)\s*(?""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'|-?\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? Compile(WorkflowEdgeDto edge) + { + ArgumentNullException.ThrowIfNull(edge); + + Func? 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? 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 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; + } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs index a89cf67..d69b32e 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs @@ -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? condition = WorkflowConditionEvaluator.Compile(edge); + if (condition is null) + { + builder.AddEdge(bindings[edge.Source], bindings[edge.Target]); + } + else + { + builder.AddEdge(bindings[edge.Source], bindings[edge.Target], condition); + } } foreach (IGrouping 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?[] 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( 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 fanInGroup in workflowDefinition.Graph.Edges diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs index 4e69801..9e359cb 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs @@ -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 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 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 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> 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 queue = new([startNodeId]); + HashSet 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 CollectStronglyConnectedNodes(WorkflowGraphDto graph, string nodeId) + { + HashSet 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; + } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowConditionEvaluatorTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowConditionEvaluatorTests.cs new file mode 100644 index 0000000..e91ae55 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowConditionEvaluatorTests.cs @@ -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? 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); +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs new file mode 100644 index 0000000..4936c6c --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs @@ -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 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 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 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(), + }, + }; + } +} diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts index fec609e..34b393a 100644 --- a/src/shared/domain/workflow.ts +++ b/src/shared/domain/workflow.ts @@ -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 0 + ? Math.round(edge.maxIterations) + : undefined, })), }, settings: { @@ -372,9 +415,10 @@ function hasPathToEnd(startNodeId: string, graph: WorkflowGraph, endNodeIds: Set const visited = new Set(); 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 { + const outgoing = new Map(); + 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(); + 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 { + const connected = new Set(); + 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; } diff --git a/tests/shared/workflow.test.ts b/tests/shared/workflow.test.ts index cb59366..3b48aab 100644 --- a/tests/shared/workflow.test.ts +++ b/tests/shared/workflow.test.ts @@ -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([]); + }); });