From 0e287e8974c75096a5c788f241232486cee1b0d7 Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Mon, 27 Apr 2026 15:42:57 -0500 Subject: [PATCH 1/2] refine rule graph, add input/output schema, graph traversal switch, graph validation --- .../Rules/Constants/RuleConstant.cs | 32 ++ .../BotSharp.Abstraction/Rules/IRuleEnd.cs | 5 + .../Rules/IRuleFlowUnit.cs | 12 + .../BotSharp.Abstraction/Rules/IRuleRoot.cs | 5 + .../Rules/Models/FlowUnitSchema.cs | 58 +++ .../Rules/Options/RuleFlowOptions.cs | 12 + .../BotSharp.Abstraction/Rules/RuleGraph.cs | 16 + .../BotSharp.Core.Rules/Actions/ChatAction.cs | 13 +- .../Actions/HttpRequestAction.cs | 27 +- .../Actions/ToolCallAction.cs | 21 +- .../Conditions/AllVisitedRuleCondition.cs | 4 + .../Conditions/LogicGateCondition.cs | 211 ++++++++++ .../Conditions/LoopingCondition.cs | 21 +- .../Constants/RuleConstant.cs | 17 - .../BotSharp.Core.Rules/Engines/Frontier.cs | 66 ++++ .../BotSharp.Core.Rules/Engines/RuleEngine.cs | 373 ++++++++++++------ .../Models/LogicExpression.cs | 44 +++ .../BotSharp.Core.Rules/Root/EndAction.cs | 47 +++ .../BotSharp.Core.Rules/Root/StartAction.cs | 48 +++ .../BotSharp.Core.Rules/RulesPlugin.cs | 8 + .../BotSharp.Core.Rules/Using.cs | 3 +- 21 files changed, 889 insertions(+), 154 deletions(-) create mode 100644 src/Infrastructure/BotSharp.Abstraction/Rules/Constants/RuleConstant.cs create mode 100644 src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEnd.cs create mode 100644 src/Infrastructure/BotSharp.Abstraction/Rules/IRuleRoot.cs create mode 100644 src/Infrastructure/BotSharp.Abstraction/Rules/Models/FlowUnitSchema.cs create mode 100644 src/Infrastructure/BotSharp.Core.Rules/Conditions/LogicGateCondition.cs delete mode 100644 src/Infrastructure/BotSharp.Core.Rules/Constants/RuleConstant.cs create mode 100644 src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs create mode 100644 src/Infrastructure/BotSharp.Core.Rules/Models/LogicExpression.cs create mode 100644 src/Infrastructure/BotSharp.Core.Rules/Root/EndAction.cs create mode 100644 src/Infrastructure/BotSharp.Core.Rules/Root/StartAction.cs diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/Constants/RuleConstant.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/Constants/RuleConstant.cs new file mode 100644 index 000000000..90fdea448 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/Constants/RuleConstant.cs @@ -0,0 +1,32 @@ +namespace BotSharp.Abstraction.Rules.Constants; + +public static class RuleConstant +{ + public const int MAX_GRAPH_RECURSION = 1000; + + public const string INPUT_SCHEMA_KEY = "input_schema"; + public const string OUTPUT_SCHEMA_KEY = "output_schema"; + + public static IEnumerable CONDITION_NODE_TYPES = new List + { + "condition", + "criteria" + }; + + public static IEnumerable ACTION_NODE_TYPES = new List + { + "action" + }; + + public static IEnumerable ROOT_NODE_TYPES = new List + { + "root", + "start" + }; + + public static IEnumerable END_NODE_TYPES = new List + { + "end", + "terminal" + }; +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEnd.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEnd.cs new file mode 100644 index 000000000..d54913313 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleEnd.cs @@ -0,0 +1,5 @@ +namespace BotSharp.Abstraction.Rules; + +public interface IRuleEnd : IRuleFlowUnit +{ +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleFlowUnit.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleFlowUnit.cs index 795b2ce01..fbccc3f45 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleFlowUnit.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleFlowUnit.cs @@ -18,4 +18,16 @@ public interface IRuleFlowUnit /// The trigger names /// IEnumerable? Triggers => null; + + /// + /// Schema describing the expected input parameters. + /// Used for validating that upstream nodes produce the required fields. + /// + FlowUnitSchema? InputSchema => null; + + /// + /// Schema describing the output this unit produces. + /// Used for validating that downstream nodes receive the expected fields. + /// + FlowUnitSchema? OutputSchema => null; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleRoot.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleRoot.cs new file mode 100644 index 000000000..d3ee29eeb --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/IRuleRoot.cs @@ -0,0 +1,5 @@ +namespace BotSharp.Abstraction.Rules; + +public interface IRuleRoot : IRuleFlowUnit +{ +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/Models/FlowUnitSchema.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/Models/FlowUnitSchema.cs new file mode 100644 index 000000000..a443519fd --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/Models/FlowUnitSchema.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace BotSharp.Abstraction.Rules.Models; + +/// +/// Describes the input or output contract of a rule flow unit (action or condition). +/// Follows a JSON Schema-like structure with "properties" and "required" fields. +/// +public class FlowUnitSchema +{ + /// + /// Property definitions keyed by parameter name. + /// + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + /// + /// List of required property names. + /// + [JsonPropertyName("required")] + public List Required { get; set; } = []; + + public FlowUnitSchema() { } + + public FlowUnitSchema( + Dictionary properties, + List? required = null) + { + Properties = properties; + Required = required ?? []; + } +} + +/// +/// Describes a single property in a FlowUnitSchema. +/// +public class FlowUnitSchemaProperty +{ + /// + /// JSON type: "string", "number", "boolean", "object", "array" + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "string"; + + /// + /// A brief explanation of the property's purpose. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + public FlowUnitSchemaProperty() { } + + public FlowUnitSchemaProperty(string type, string? description = null) + { + Type = type; + Description = description; + } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/Options/RuleFlowOptions.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/Options/RuleFlowOptions.cs index 73719ad73..fb89c33c1 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Rules/Options/RuleFlowOptions.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/Options/RuleFlowOptions.cs @@ -20,6 +20,18 @@ public class RuleFlowOptions [JsonPropertyName("traversal_algorithm")] public string TraversalAlgorithm { get; set; } = "dfs"; + /// + /// Whether to skip validation when loading the graph + /// + [JsonPropertyName("skip_validation")] + public bool SkipValidation { get; set; } + + /// + /// Maximum number of nodes to visit + /// + [JsonPropertyName("max_recursion")] + public int? MaxRecursion { get; set; } + /// /// Additional custom parameters, e.g., root_node_name, max_recursion /// diff --git a/src/Infrastructure/BotSharp.Abstraction/Rules/RuleGraph.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/RuleGraph.cs index 32284e362..30ff4a4bf 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Rules/RuleGraph.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/RuleGraph.cs @@ -1,3 +1,5 @@ +using BotSharp.Abstraction.Rules.Models; + namespace BotSharp.Abstraction.Rules; public class RuleGraph @@ -173,6 +175,20 @@ public class RuleNode : GraphItem /// public override string Type { get; set; } = "action"; + /// + /// Input schema loaded from node config. Overrides the code-defined schema on IRuleFlowUnit. + /// + [JsonPropertyName("input_schema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FlowUnitSchema? InputSchema { get; set; } + + /// + /// Output schema loaded from node config. Overrides the code-defined schema on IRuleFlowUnit. + /// + [JsonPropertyName("output_schema")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public FlowUnitSchema? OutputSchema { get; set; } + public override string ToString() { return $"Node ({Id}): {Name} ({Type} => {Description})"; diff --git a/src/Infrastructure/BotSharp.Core.Rules/Actions/ChatAction.cs b/src/Infrastructure/BotSharp.Core.Rules/Actions/ChatAction.cs index ee68dcfef..b6f3cff3e 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Actions/ChatAction.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Actions/ChatAction.cs @@ -1,6 +1,6 @@ namespace BotSharp.Core.Rules.Actions; -public sealed class ChatAction : IRuleAction +public class ChatAction : IRuleAction { private readonly IServiceProvider _services; private readonly ILogger _logger; @@ -15,6 +15,17 @@ public ChatAction( public string Name => "send_message_to_agent"; + public FlowUnitSchema? InputSchema => new(); + + public FlowUnitSchema? OutputSchema => new( + properties: new() + { + ["agent_id"] = new("string", "The agent ID"), + ["conversation_id"] = new("string", "The created conversation ID") + }, + required: ["agent_id", "conversation_id"] + ); + public async Task ExecuteAsync( Agent agent, IRuleTrigger trigger, diff --git a/src/Infrastructure/BotSharp.Core.Rules/Actions/HttpRequestAction.cs b/src/Infrastructure/BotSharp.Core.Rules/Actions/HttpRequestAction.cs index 3bce0de83..c42e2054f 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Actions/HttpRequestAction.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Actions/HttpRequestAction.cs @@ -4,7 +4,7 @@ namespace BotSharp.Core.Rules.Actions; -public sealed class HttpRequestAction : IRuleAction +public class HttpRequestAction : IRuleAction { private readonly IServiceProvider _services; private readonly ILogger _logger; @@ -22,11 +22,26 @@ public HttpRequestAction( public string Name => "http_request"; - // Default configuration example: - // { - // "http_url": "https://dummy.example.com/api/v1/employees", - // "http_method": "GET" - // } + public FlowUnitSchema? InputSchema => new( + properties: new() + { + ["http_url"] = new("string", "The HTTP URL to request"), + ["http_method"] = new("string", "HTTP method: GET, POST, PUT, DELETE, PATCH"), + ["http_query_params"] = new("array", "Query parameters as key-value pairs"), + ["http_request_headers"] = new("array", "Request headers as key-value pairs"), + ["http_request_body"] = new("string", "Request body (JSON string)") + }, + required: ["http_url", "http_method"] + ); + + public FlowUnitSchema? OutputSchema => new( + properties: new() + { + ["http_response"] = new("string", "The HTTP response body"), + ["http_response_headers"] = new("string", "The HTTP response headers as JSON") + }, + required: ["http_response", "http_response_headers"] + ); public async Task ExecuteAsync( Agent agent, diff --git a/src/Infrastructure/BotSharp.Core.Rules/Actions/ToolCallAction.cs b/src/Infrastructure/BotSharp.Core.Rules/Actions/ToolCallAction.cs index f38a15b38..3e6dc647b 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Actions/ToolCallAction.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Actions/ToolCallAction.cs @@ -2,7 +2,7 @@ namespace BotSharp.Core.Rules.Actions; -public sealed class ToolCallAction : IRuleAction +public class ToolCallAction : IRuleAction { private readonly IServiceProvider _services; private readonly ILogger _logger; @@ -17,6 +17,25 @@ public ToolCallAction( public string Name => "tool_call"; + public FlowUnitSchema? InputSchema => new( + properties: new() + { + ["function_name"] = new("string", "The name of the function to call"), + ["function_argument"] = new("object", "The function argument as a RoleDialogModel JSON") + }, + required: ["function_name"] + ); + + public FlowUnitSchema? OutputSchema => new( + properties: new() + { + ["function_name"] = new("string", "The executed function name"), + ["function_argument"] = new("string", "The function argument as JSON string"), + ["function_call_result"] = new("string", "The function call result text") + }, + required: ["function_name", "function_argument", "function_call_result"] + ); + public async Task ExecuteAsync( Agent agent, IRuleTrigger trigger, diff --git a/src/Infrastructure/BotSharp.Core.Rules/Conditions/AllVisitedRuleCondition.cs b/src/Infrastructure/BotSharp.Core.Rules/Conditions/AllVisitedRuleCondition.cs index c9210c690..1ba3650f9 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Conditions/AllVisitedRuleCondition.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Conditions/AllVisitedRuleCondition.cs @@ -12,6 +12,10 @@ public AllVisitedRuleCondition( public string Name => "all_visited"; + public FlowUnitSchema? InputSchema => new(); + + public FlowUnitSchema? OutputSchema => new(); + public async Task EvaluateAsync( Agent agent, IRuleTrigger trigger, diff --git a/src/Infrastructure/BotSharp.Core.Rules/Conditions/LogicGateCondition.cs b/src/Infrastructure/BotSharp.Core.Rules/Conditions/LogicGateCondition.cs new file mode 100644 index 000000000..eb678c63d --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Conditions/LogicGateCondition.cs @@ -0,0 +1,211 @@ +using System.Text.Json; +using BotSharp.Core.Rules.Models; + +namespace BotSharp.Core.Rules.Conditions; + +/// +/// A gate condition node that collects results from multiple parent condition nodes +/// and evaluates a composite logical expression. +/// +/// Supported operators: +/// "and" - All children must evaluate to true (logical conjunction). +/// "or" - At least one child must evaluate to true (logical disjunction). +/// "not" - Negates a single child (unary operator, only the first child is evaluated). +/// +/// Operators can be nested to form arbitrarily complex expressions, e.g.: +/// (A AND B) OR (C AND NOT D) +/// +/// Leaf node format: +/// { "node": "node_name", "key": "data_key" } +/// - "node": The Name of a parent condition node whose result to inspect. +/// - "key": The key in the parent node's RuleNodeResult.Data dictionary that holds +/// a boolean string ("true"/"false"). If omitted, falls back to the parent +/// node's RuleNodeResult.Success flag. +/// +/// Node config: +/// "expression" - A JSON-encoded LogicExpression tree. +/// "default_value" - The default boolean value ("true"/"false") when a referenced +/// parent node or data key is not found. Defaults to "false". +/// +/// Example: work_order_valid AND (client_name_valid OR NOT affiliate_name_valid) +/// +/// Given three parent condition nodes: +/// - Node A ("check_work_order") returns Data["work_order_valid"] = "true" +/// - Node B ("check_client") returns Data["client_name_valid"] = "false" +/// - Node C ("check_affiliate") returns Data["affiliate_name_valid"] = "false" +/// +/// The gate node config would be: +/// { +/// "expression": { +/// "op": "and", +/// "children": [ +/// { "node": "check_work_order", "key": "work_order_valid" }, +/// { "op": "or", "children": [ +/// { "node": "check_client", "key": "client_name_valid" }, +/// { "op": "not", "children": [ +/// { "node": "check_affiliate", "key": "affiliate_name_valid" } +/// ]} +/// ]} +/// ] +/// }, +/// "default_value": "false" +/// } +/// +/// Evaluation: true AND (false OR NOT false) => true AND (false OR true) => true AND true => true +/// +public class LogicGateCondition : IRuleCondition +{ + private readonly ILogger _logger; + + public LogicGateCondition( + ILogger logger) + { + _logger = logger; + } + + public string Name => "logic_gate"; + + public FlowUnitSchema? InputSchema => new( + properties: new() + { + ["expression"] = new("object", "A JSON-encoded LogicExpression tree"), + ["default_value"] = new("string", "Default boolean value when a referenced parent node or data key is not found") + }, + required: ["expression"] + ); + + public FlowUnitSchema? OutputSchema => new(); + + public async Task EvaluateAsync( + Agent agent, + IRuleTrigger trigger, + RuleFlowContext context) + { + var currentNode = context.Node; + + // 1. Ensure all parent nodes have been visited + var parents = context.Graph.GetParentNodes(currentNode); + var parentNodeIds = parents.Select(x => x.Item1.Id).ToHashSet(); + var visitedNodeIds = context.PrevStepResults? + .Select(x => x.Node.Id).ToHashSet() ?? []; + + if (!parentNodeIds.All(id => visitedNodeIds.Contains(id))) + { + _logger.LogInformation( + "Logic gate {NodeName}: not all parent nodes visited yet, deferring (agent {AgentId}).", + currentNode.Name, agent.Id); + return new RuleNodeResult + { + Success = false, + Response = "Not all parent nodes have been visited yet." + }; + } + + // 2. Parse the expression from node config + var expressionJson = currentNode.Config?.GetValueOrDefault("expression"); + if (string.IsNullOrEmpty(expressionJson)) + { + _logger.LogWarning("Logic gate {NodeName} has no expression configured.", currentNode.Name); + return new RuleNodeResult + { + Success = false, + ErrorMessage = "No expression configured for logic gate." + }; + } + + LogicExpression? expression; + try + { + expression = JsonSerializer.Deserialize(expressionJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to parse logic gate expression for node {NodeName}.", currentNode.Name); + return new RuleNodeResult + { + Success = false, + ErrorMessage = $"Invalid expression JSON: {ex.Message}" + }; + } + + if (expression == null) + { + return new RuleNodeResult + { + Success = false, + ErrorMessage = "Expression deserialized to null." + }; + } + + var defaultValue = currentNode.Config?.GetValueOrDefault("default_value") ?? "false"; + + // 3. Build lookup: parent node name → its latest RuleFlowStepResult + var parentResults = (context.PrevStepResults ?? []) + .Where(r => parentNodeIds.Contains(r.Node.Id)) + .GroupBy(r => r.Node.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Last(), StringComparer.OrdinalIgnoreCase); + + // 4. Evaluate the expression tree + var result = Evaluate(expression, parentResults, defaultValue); + + _logger.LogInformation( + "Logic gate {NodeName} evaluated to {Result} (agent {AgentId}).", + currentNode.Name, result, agent.Id); + + return new RuleNodeResult + { + Success = result, + Response = result ? "Logic gate: all conditions met." : "Logic gate: conditions not met." + }; + } + + private bool Evaluate( + LogicExpression expr, + Dictionary parentResults, + string defaultValue) + { + // Leaf node: look up a specific parent's result + if (!string.IsNullOrEmpty(expr.Node)) + { + if (!parentResults.TryGetValue(expr.Node, out var stepResult)) + { + _logger.LogWarning("Logic gate: parent node '{Node}' not found in results, using default '{Default}'.", + expr.Node, defaultValue); + return ParseBool(defaultValue); + } + + // If no custom key specified, fall back to the node's Success flag + if (string.IsNullOrEmpty(expr.Key)) + { + return stepResult.Success; + } + + var value = stepResult.Data?.GetValueOrDefault(expr.Key, defaultValue) ?? defaultValue; + return ParseBool(value); + } + + // Operator node + var op = expr.Op?.ToLowerInvariant(); + var children = expr.Children ?? []; + + return op switch + { + "and" => children.All(c => Evaluate(c, parentResults, defaultValue)), + "or" => children.Any(c => Evaluate(c, parentResults, defaultValue)), + "not" when children.Count > 0 => !Evaluate(children[0], parentResults, defaultValue), + _ => throw new InvalidOperationException($"Unknown or invalid logic gate operator: '{expr.Op}'") + }; + } + + private static bool ParseBool(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + return bool.TryParse(value, out var b) && b; + } +} diff --git a/src/Infrastructure/BotSharp.Core.Rules/Conditions/LoopingCondition.cs b/src/Infrastructure/BotSharp.Core.Rules/Conditions/LoopingCondition.cs index 820157b67..4bd605378 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Conditions/LoopingCondition.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Conditions/LoopingCondition.cs @@ -14,7 +14,7 @@ namespace BotSharp.Core.Rules.Conditions; /// Action Node → LoopCondition → (true) → back to Action Node /// → (false) → resets list_items, iterate_index, iterate_current_item and continues /// -public sealed class LoopingCondition : IRuleCondition +public class LoopingCondition : IRuleCondition { private const string PARAM_LIST_ITEMS = "list_items"; private const string PARAM_LIST_ITEMS_KEY = "list_items_key"; @@ -31,6 +31,25 @@ public LoopingCondition(ILogger logger) public string Name => "looping"; + public FlowUnitSchema? InputSchema => new( + properties: new() + { + ["list_items"] = new("array", "A JSON array of items to iterate over"), + ["list_items_key"] = new("string", "Alternative parameter key holding the list items"), + ["iterate_index"] = new("number", "The current iteration index (auto-managed)"), + ["iterate_item_key"] = new("string", "Property key to extract from each object item") + } + ); + + public FlowUnitSchema? OutputSchema => new( + properties: new() + { + ["iterate_next_item"] = new("string", "The next item being processed"), + ["iterate_index"] = new("number", "The current iteration index") + }, + required: ["iterate_next_item", "iterate_index"] + ); + public async Task EvaluateAsync( Agent agent, IRuleTrigger trigger, diff --git a/src/Infrastructure/BotSharp.Core.Rules/Constants/RuleConstant.cs b/src/Infrastructure/BotSharp.Core.Rules/Constants/RuleConstant.cs deleted file mode 100644 index 9598df345..000000000 --- a/src/Infrastructure/BotSharp.Core.Rules/Constants/RuleConstant.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace BotSharp.Core.Rules.Constants; - -public static class RuleConstant -{ - public const int MAX_GRAPH_RECURSION = 50; - - public static IEnumerable CONDITION_NODE_TYPES = new List - { - "condition", - "criteria" - }; - - public static IEnumerable ACTION_NODE_TYPES = new List - { - "action" - }; -} diff --git a/src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs b/src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs new file mode 100644 index 000000000..355d34e24 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs @@ -0,0 +1,66 @@ +namespace BotSharp.Core.Rules.Engines; + +/// +/// Abstraction over the data structure that drives graph traversal order. +/// Stack → DFS, Queue → BFS. Swap the frontier mid-traversal to switch strategy. +/// +internal interface IFrontier +{ + void Add(T item); + T Remove(); + int Count { get; } + + /// + /// Drain every remaining item into , preserving order. + /// + void DrainTo(IFrontier other); +} + +/// +/// LIFO frontier – produces depth-first traversal. +/// +internal sealed class StackFrontier : IFrontier +{ + private readonly Stack _stack = new(); + + public int Count => _stack.Count; + + public void Add(T item) => _stack.Push(item); + + public T Remove() => _stack.Pop(); + + public void DrainTo(IFrontier other) + { + // Reverse so the item that was on top is added first and + // therefore ends up at the same "priority" position in the target. + var items = _stack.ToList(); + items.Reverse(); + _stack.Clear(); + foreach (var item in items) + { + other.Add(item); + } + } +} + +/// +/// FIFO frontier – produces breadth-first traversal. +/// +internal sealed class QueueFrontier : IFrontier +{ + private readonly Queue _queue = new(); + + public int Count => _queue.Count; + + public void Add(T item) => _queue.Enqueue(item); + + public T Remove() => _queue.Dequeue(); + + public void DrainTo(IFrontier other) + { + while (_queue.Count > 0) + { + other.Add(_queue.Dequeue()); + } + } +} diff --git a/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs b/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs index 9c1808927..d41eff321 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Engines/RuleEngine.cs @@ -139,11 +139,25 @@ await ExecuteGraphNode( param["agent_id"] = param.GetValueOrDefault("agent_id", agent.Id); param["trigger"] = param.GetValueOrDefault("trigger", trigger.Name); - return await flow.GetTopologyAsync(topologyId, options: new() + var graph = await flow.GetTopologyAsync(topologyId, options: new() { Query = options?.Query, Parameters = param }); + + if (graph != null) + { + // Apply input/output schemas from node config to the node + LoadConfigSchemas(graph); + + // Validate input/output schema compatibility between connected nodes + if (options?.SkipValidation != true) + { + ValidateGraphSchema(graph); + } + } + + return graph; } catch (Exception ex) { @@ -165,20 +179,19 @@ private async Task ExecuteGraphNode( { try { - if (options?.Flow?.TraversalAlgorithm?.IsEqualTo("bfs") == true) - { - await ExecuteGraphNodeBfs(node, graph, agent, trigger, text, states, data, options, results); - } - else - { - await ExecuteGraphNodeDfs(node, graph, agent, trigger, text, states, data, options, results); - } + await ExecuteGraphTraversal(node, graph, agent, trigger, text, states, data, options, results); } catch { } } - private async Task ExecuteGraphNodeDfs( - RuleNode node, + /// + /// Unified graph traversal that uses a swappable frontier. + /// Stack frontier → DFS, Queue frontier → BFS. + /// A node or edge can request a mid-traversal switch via its + /// Config["traversal_algorithm"] value ("dfs" or "bfs"). + /// + private async Task ExecuteGraphTraversal( + RuleNode root, RuleGraph graph, Agent agent, IRuleTrigger trigger, @@ -188,36 +201,41 @@ private async Task ExecuteGraphNodeDfs( RuleTriggerOptions? options, List results) { - // Check whether the action nodes have been visited more than limit - var visited = results.Count(); - var param = options?.Flow?.Parameters ?? []; - var maxRecursion = int.TryParse(param.GetValueOrDefault("max_recursion")?.ToString(), out var depth) && depth > 0 - ? depth : RuleConstant.MAX_GRAPH_RECURSION; - + var flow = options?.Flow; + var maxRecursion = flow?.MaxRecursion > 0 ? flow.MaxRecursion : RuleConstant.MAX_GRAPH_RECURSION; var innerData = new Dictionary(data ?? []); - if (visited >= maxRecursion) - { - _logger.LogWarning("Exceed max graph recursion {MaxRecursion} (agent {Agent} and trigger {Trigger}).", - maxRecursion, agent.Name, trigger.Name); - return; - } + // Choose initial frontier based on the global option + var useBfs = options?.Flow?.TraversalAlgorithm?.IsEqualTo("bfs") == true; + IFrontier<(RuleNode Node, RuleEdge Edge)> frontier = useBfs + ? new QueueFrontier<(RuleNode, RuleEdge)>() + : new StackFrontier<(RuleNode, RuleEdge)>(); - // Get current node successors - var nextNodes = graph.GetChildrenNodes(node); - if (nextNodes.IsNullOrEmpty()) + // Seed the frontier with root's children + foreach (var child in graph.GetChildrenNodes(root)) { - return; + frontier.Add(child); } - // Visit neighbor nodes - foreach (var (nextNode, edge) in nextNodes) + while (frontier.Count > 0) { + if (results.Count >= maxRecursion) + { + _logger.LogWarning("Exceed max graph nodes {MaxNodes} (agent {Agent} and trigger {Trigger}).", + maxRecursion, agent.Name, trigger.Name); + break; + } + + var (nextNode, nextEdge) = frontier.Remove(); + + // Check whether node requests a traversal switch + frontier = SwitchFrontier(frontier, nextNode); + // Build context var context = new RuleFlowContext { Node = nextNode, - Edge = edge, + Edge = nextEdge, Graph = graph, Text = text, Parameters = BuildParameters(nextNode.Config, states, innerData), @@ -227,8 +245,9 @@ private async Task ExecuteGraphNodeDfs( if (RuleConstant.CONDITION_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase)) { - // Execute condition node - var conditionResult = await ExecuteCondition(nextNode, edge, graph, agent, trigger, context); + var conditionResult = await ExecuteCondition(nextNode, nextEdge, graph, agent, trigger, context); + innerData = new(context.Parameters ?? []); + if (conditionResult == null) { results.Add(RuleFlowStepResult.FromResult(new() @@ -241,10 +260,9 @@ private async Task ExecuteGraphNodeDfs( results.Add(RuleFlowStepResult.FromResult(conditionResult, nextNode)); - // If condition result is true, then execute the next node, otherwise skip if (conditionResult.Success) { - await ExecuteGraphNodeDfs(nextNode, graph, agent, trigger, text, states, context.Parameters, options, results); + EnqueueChildren(frontier, graph, nextNode); } else { @@ -252,10 +270,13 @@ private async Task ExecuteGraphNodeDfs( nextNode.Name, agent.Name, trigger.Name); } } - else if (RuleConstant.ACTION_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase)) + else if (RuleConstant.ACTION_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase) + || RuleConstant.ROOT_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase) + || RuleConstant.END_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase)) { - // Execute action node - var actionResult = await ExecuteAction(nextNode, edge, graph, agent, trigger, context); + var actionResult = await ExecuteAction(nextNode, nextEdge, graph, agent, trigger, context); + innerData = new(context.Parameters ?? []); + if (actionResult == null) { results.Add(RuleFlowStepResult.FromResult(new() @@ -268,12 +289,10 @@ private async Task ExecuteGraphNodeDfs( results.Add(RuleFlowStepResult.FromResult(actionResult, nextNode)); - if (actionResult.IsDelayed) + if (!actionResult.IsDelayed) { - continue; + EnqueueChildren(frontier, graph, nextNode); } - - await ExecuteGraphNodeDfs(nextNode, graph, agent, trigger, text, states, context.Parameters, options, results); } else { @@ -282,132 +301,232 @@ private async Task ExecuteGraphNodeDfs( Success = true, Response = $"Pass through node {nextNode.Name}." }, nextNode)); - await ExecuteGraphNodeDfs(nextNode, graph, agent, trigger, text, states, context.Parameters, options, results); + + EnqueueChildren(frontier, graph, nextNode); } } } - private async Task ExecuteGraphNodeBfs( - RuleNode root, - RuleGraph graph, - Agent agent, - IRuleTrigger trigger, - string text, - IEnumerable? states, - Dictionary? data, - RuleTriggerOptions? options, - List results) + /// + /// If the node carries a traversal_algorithm config value + /// that differs from the current frontier type, swap to the requested one + /// and drain all pending items into the new frontier. + /// + private static IFrontier<(RuleNode, RuleEdge)> SwitchFrontier( + IFrontier<(RuleNode, RuleEdge)> current, + RuleNode? node) { - var param = options?.Flow?.Parameters ?? []; - var maxRecursion = int.TryParse(param.GetValueOrDefault("max_recursion")?.ToString(), out var depth) && depth > 0 - ? depth : RuleConstant.MAX_GRAPH_RECURSION; + // Edge config takes precedence over node config + var hint = node?.Config?.GetValueOrDefault("traversal_algorithm"); - var innerData = new Dictionary(data ?? []); + if (string.IsNullOrEmpty(hint)) + { + return current; + } - // Each queue entry is (node-to-process, edge-that-leads-to-it) - var queue = new Queue<(RuleNode Node, RuleEdge Edge)>(); + var requireBfs = hint.Equals("bfs", StringComparison.OrdinalIgnoreCase); + var currentBfs = current is QueueFrontier<(RuleNode, RuleEdge)>; - foreach (var (childNode, edge) in graph.GetChildrenNodes(root)) + if (requireBfs == currentBfs) { - queue.Enqueue((childNode, edge)); + return current; } - while (queue.Count > 0) + IFrontier<(RuleNode, RuleEdge)> next = requireBfs + ? new QueueFrontier<(RuleNode, RuleEdge)>() + : new StackFrontier<(RuleNode, RuleEdge)>(); + + current.DrainTo(next); + return next; + } + + private static void EnqueueChildren( + IFrontier<(RuleNode Node, RuleEdge Edge)> frontier, + RuleGraph graph, + RuleNode parent) + { + foreach (var child in graph.GetChildrenNodes(parent)) { - if (results.Count >= maxRecursion) - { - _logger.LogWarning("Exceed max graph nodes {MaxNodes} during BFS (agent {Agent} and trigger {Trigger}).", - maxRecursion, agent.Name, trigger.Name); - break; - } + frontier.Add(child); + } + } + #endregion - var (nextNode, nextEdge) = queue.Dequeue(); - var context = new RuleFlowContext - { - Node = nextNode, - Edge = nextEdge, - Graph = graph, - Text = text, - Parameters = BuildParameters(nextNode.Config, states, innerData), - PrevStepResults = results, - JsonOptions = options?.JsonOptions - }; + #region Schema Validation + /// + /// Reads "input_schema" and "output_schema" from each node's Config, + /// deserializes them into FlowUnitSchema, and sets them on the RuleNode. + /// If a node has no config schema, the code-defined schema from the + /// resolved IRuleFlowUnit is used as fallback during validation. + /// + private void LoadConfigSchemas(RuleGraph graph) + { + var nodes = graph.GetNodes(); + if (nodes == null) + { + return; + } - if (RuleConstant.CONDITION_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase)) + foreach (var node in nodes) + { + if (node.Config.IsNullOrEmpty()) { - // Execute condition node - var conditionResult = await ExecuteCondition(nextNode, nextEdge, graph, agent, trigger, context); - innerData = new(context.Parameters ?? []); + continue; + } - if (conditionResult == null) + if (node.Config!.TryGetValue(RuleConstant.INPUT_SCHEMA_KEY, out var inputJson) + && !string.IsNullOrEmpty(inputJson)) + { + try { - results.Add(RuleFlowStepResult.FromResult(new() - { - Success = false, - ErrorMessage = $"Unable to find condition {nextNode.Name}." - }, nextNode)); - continue; + node.InputSchema = JsonSerializer.Deserialize(inputJson); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialize input_schema from config of node [{NodeName}].", node.Name); + } + } - results.Add(RuleFlowStepResult.FromResult(conditionResult, nextNode)); - - // If condition is true, enqueue children; otherwise skip the branch - if (conditionResult.Success) + if (node.Config!.TryGetValue(RuleConstant.OUTPUT_SCHEMA_KEY, out var outputJson) + && !string.IsNullOrEmpty(outputJson)) + { + try { - foreach (var (childNode, childEdge) in graph.GetChildrenNodes(nextNode)) - { - queue.Enqueue((childNode, childEdge)); - } + node.OutputSchema = JsonSerializer.Deserialize(outputJson); } - else + catch (Exception ex) { - _logger.LogInformation("Condition {ConditionName} evaluated to false, skipping next node (agent {Agent} and trigger {Trigger}).", - nextNode.Name, agent.Name, trigger.Name); + _logger.LogWarning(ex, "Failed to deserialize output_schema from config of node [{NodeName}].", node.Name); } } - else if (RuleConstant.ACTION_NODE_TYPES.Contains(nextNode.Type, StringComparer.OrdinalIgnoreCase)) + } + } + + /// + /// Validates that for every edge in the graph, the downstream node's required input fields + /// can be satisfied by the upstream node's output or the downstream node's own config. + /// Node-level schemas (from config) take precedence over code-defined schemas. + /// + private void ValidateGraphSchema(RuleGraph graph) + { + var edges = graph.GetEdges(); + if (edges == null || !edges.Any()) + { + return; + } + + foreach (var edge in edges) + { + if (edge.From == null || edge.To == null) { - // Execute action node - var actionResult = await ExecuteAction(nextNode, nextEdge, graph, agent, trigger, context); - innerData = new(context.Parameters ?? []); + continue; + } - if (actionResult == null) - { - results.Add(RuleFlowStepResult.FromResult(new() - { - Success = false, - ErrorMessage = $"Unable to find action {nextNode.Name}." - }, nextNode)); - continue; - } + var sourceUnit = ResolveFlowUnit(edge.From); + var targetUnit = ResolveFlowUnit(edge.To); - results.Add(RuleFlowStepResult.FromResult(actionResult, nextNode)); + // Config-defined schema on the node takes precedence over code-defined + var targetInputSchema = edge.To.InputSchema ?? targetUnit?.InputSchema; + if (targetInputSchema?.Required == null || targetInputSchema.Required.Count == 0) + { + continue; + } - if (!actionResult.IsDelayed) + // Collect available keys from upstream output and downstream node's own config + var availableKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + var sourceOutputSchema = edge.From.OutputSchema ?? sourceUnit?.OutputSchema; + if (sourceOutputSchema?.Properties != null && !sourceOutputSchema.Properties.Keys.IsNullOrEmpty()) + { + foreach (var key in sourceOutputSchema.Properties.Keys) { - foreach (var (childNode, childEdge) in graph.GetChildrenNodes(nextNode)) - { - queue.Enqueue((childNode, childEdge)); - } + availableKeys.Add(key); } } - else + + if (edge.To.Config != null && !edge.To.Config.Keys.IsNullOrEmpty()) { - results.Add(RuleFlowStepResult.FromResult(new() + foreach (var key in edge.To.Config.Keys) { - Success = true, - Response = $"Pass through node {nextNode.Name}." - }, nextNode)); + availableKeys.Add(key); + } + } - foreach (var (childNode, childEdge) in graph.GetChildrenNodes(nextNode)) + // Check each required input field + foreach (var key in targetInputSchema.Required) + { + if (!availableKeys.Contains(key)) { - queue.Enqueue((childNode, childEdge)); + _logger.Log( +#if DEBUG + LogLevel.Critical, +#else + LogLevel.Warning, +#endif + "Schema validation: edge [{SourceNode}] -> [{TargetNode}]: " + + "required input '{Key}' is not provided by upstream output or node config.", + edge.From.Name, edge.To.Name, key); + } + // Validate type compatibility when both schemas define the property + else if (sourceOutputSchema?.Properties != null + && sourceOutputSchema.Properties.TryGetValue(key, out var sourceProp) + && targetInputSchema.Properties.TryGetValue(key, out var targetProp) + && !string.IsNullOrEmpty(sourceProp.Type) + && !string.IsNullOrEmpty(targetProp.Type) + && !sourceProp.Type.Equals(targetProp.Type, StringComparison.OrdinalIgnoreCase)) + { + _logger.Log( +#if DEBUG + LogLevel.Critical, +#else + LogLevel.Warning, +#endif + "Schema validation: edge [{SourceNode}] -> [{TargetNode}]: " + + "type mismatch for '{Key}' — upstream produces '{SourceType}' but downstream expects '{TargetType}'.", + edge.From.Name, edge.To.Name, key, sourceProp.Type, targetProp.Type); } } } } - #endregion + + /// + /// Resolves the IRuleFlowUnit (action or condition) implementation for a given node. + /// + private IRuleFlowUnit? ResolveFlowUnit(RuleNode node) + { + if (node == null || string.IsNullOrEmpty(node.Name)) + { + return null; + } + + if (RuleConstant.ROOT_NODE_TYPES.Contains(node.Type, StringComparer.OrdinalIgnoreCase)) + { + return _services.GetServices() + .FirstOrDefault(x => x.Name.IsEqualTo(node.Name)); + } + + if (RuleConstant.END_NODE_TYPES.Contains(node.Type, StringComparer.OrdinalIgnoreCase)) + { + return _services.GetServices() + .FirstOrDefault(x => x.Name.IsEqualTo(node.Name)); + } + + if (RuleConstant.ACTION_NODE_TYPES.Contains(node.Type, StringComparer.OrdinalIgnoreCase)) + { + return _services.GetServices() + .FirstOrDefault(x => x.Name.IsEqualTo(node.Name)); + } + + if (RuleConstant.CONDITION_NODE_TYPES.Contains(node.Type, StringComparer.OrdinalIgnoreCase)) + { + return _services.GetServices() + .FirstOrDefault(x => x.Name.IsEqualTo(node.Name)); + } + + return null; + } +#endregion #region Action diff --git a/src/Infrastructure/BotSharp.Core.Rules/Models/LogicExpression.cs b/src/Infrastructure/BotSharp.Core.Rules/Models/LogicExpression.cs new file mode 100644 index 000000000..7d865e5f2 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Models/LogicExpression.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace BotSharp.Core.Rules.Models; + +/// +/// Represents a node in a logic expression tree used by LogicGateCondition. +/// +/// Leaf node: references a parent node's result by name and an optional custom data key. +/// e.g. { "node": "check_work_order", "key": "work_order_valid" } +/// +/// Operator node: combines children with "and", "or", or "not". +/// e.g. { "op": "and", "children": [ ... ] } +/// +public class LogicExpression +{ + /// + /// For leaf nodes: the parent node name whose result to inspect. + /// + [JsonPropertyName("node")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Node { get; set; } + + /// + /// For leaf nodes: the key in the parent node's Data dictionary that holds the boolean value. + /// If null or empty, falls back to the parent node's Success flag. + /// + [JsonPropertyName("key")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Key { get; set; } + + /// + /// For operator nodes: "and", "or", or "not". + /// + [JsonPropertyName("op")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Op { get; set; } + + /// + /// For operator nodes: the child expressions to combine. + /// + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Children { get; set; } +} diff --git a/src/Infrastructure/BotSharp.Core.Rules/Root/EndAction.cs b/src/Infrastructure/BotSharp.Core.Rules/Root/EndAction.cs new file mode 100644 index 000000000..705f9cb24 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Root/EndAction.cs @@ -0,0 +1,47 @@ +namespace BotSharp.Core.Rules.Root; + +/// +/// Default end/terminal node for a rule flow graph. +/// Collects final results. No output schema — this is the exit point. +/// +public class EndAction : IRuleEnd, IRuleAction +{ + private readonly ILogger _logger; + + public EndAction(ILogger logger) + { + _logger = logger; + } + + public string Name => "end"; + + public FlowUnitSchema? InputSchema => null; + + public Task ExecuteAsync( + Agent agent, + IRuleTrigger trigger, + RuleFlowContext context) + { + _logger.LogInformation("End node executed for agent {AgentId} with trigger {TriggerName}.", + agent.Id, trigger.Name); + + var data = new Dictionary(); + if (!context.Parameters.IsNullOrEmpty()) + { + foreach (var kvp in context.Parameters!) + { + if (kvp.Value != null) + { + data[kvp.Key] = kvp.Value; + } + } + } + + return Task.FromResult(new RuleNodeResult + { + Success = true, + Response = "Graph completed.", + Data = data + }); + } +} diff --git a/src/Infrastructure/BotSharp.Core.Rules/Root/StartAction.cs b/src/Infrastructure/BotSharp.Core.Rules/Root/StartAction.cs new file mode 100644 index 000000000..680140303 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/Root/StartAction.cs @@ -0,0 +1,48 @@ +namespace BotSharp.Core.Rules.Root; + +/// +/// Default root/start node for a rule flow graph. +/// Passes trigger states and config downstream as its output. +/// No input schema — this is the entry point of the graph. +/// +public class StartAction : IRuleRoot, IRuleAction +{ + private readonly ILogger _logger; + + public StartAction(ILogger logger) + { + _logger = logger; + } + + public string Name => "start"; + + public FlowUnitSchema? OutputSchema => null; + + public Task ExecuteAsync( + Agent agent, + IRuleTrigger trigger, + RuleFlowContext context) + { + _logger.LogInformation("Start node executed for agent {AgentId} with trigger {TriggerName}.", + agent.Id, trigger.Name); + + var data = new Dictionary(); + if (!context.Parameters.IsNullOrEmpty()) + { + foreach (var kvp in context.Parameters!) + { + if (kvp.Value != null) + { + data[kvp.Key] = kvp.Value; + } + } + } + + return Task.FromResult(new RuleNodeResult + { + Success = true, + Response = "Graph started.", + Data = data + }); + } +} diff --git a/src/Infrastructure/BotSharp.Core.Rules/RulesPlugin.cs b/src/Infrastructure/BotSharp.Core.Rules/RulesPlugin.cs index 8e0c3b56c..ee50c9b7d 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/RulesPlugin.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/RulesPlugin.cs @@ -1,6 +1,7 @@ using BotSharp.Core.Rules.Actions; using BotSharp.Core.Rules.Conditions; using BotSharp.Core.Rules.Engines; +using BotSharp.Core.Rules.Root; namespace BotSharp.Core.Rules; @@ -21,6 +22,12 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) // Register rule engine services.AddScoped(); + // Register root and end nodes + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Register rule actions services.AddScoped(); services.AddScoped(); @@ -29,6 +36,7 @@ public void RegisterDI(IServiceCollection services, IConfiguration config) // Register rule conditions services.AddScoped(); services.AddScoped(); + services.AddScoped(); #if DEBUG // Register rule trigger diff --git a/src/Infrastructure/BotSharp.Core.Rules/Using.cs b/src/Infrastructure/BotSharp.Core.Rules/Using.cs index 2d1dc6844..46092a8cc 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Using.cs +++ b/src/Infrastructure/BotSharp.Core.Rules/Using.cs @@ -2,6 +2,7 @@ global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using System.Text; +global using System.Text.Json; global using BotSharp.Abstraction.Agents.Enums; global using BotSharp.Abstraction.Plugins; @@ -30,4 +31,4 @@ global using BotSharp.Abstraction.Coding.Settings; global using BotSharp.Abstraction.Hooks; -global using BotSharp.Core.Rules.Constants; \ No newline at end of file +global using BotSharp.Abstraction.Rules.Constants; \ No newline at end of file From ee8af1d8a67f6067bb17746dfe53a7ac4d26176c Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Mon, 27 Apr 2026 17:39:28 -0500 Subject: [PATCH 2/2] add rule flow guide --- .../Rules}/Frontier.cs | 6 +- .../docs/rule-flow-guide.md | 191 ++++++++++++++++++ 2 files changed, 194 insertions(+), 3 deletions(-) rename src/Infrastructure/{BotSharp.Core.Rules/Engines => BotSharp.Abstraction/Rules}/Frontier.cs (91%) create mode 100644 src/Infrastructure/BotSharp.Core.Rules/docs/rule-flow-guide.md diff --git a/src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs b/src/Infrastructure/BotSharp.Abstraction/Rules/Frontier.cs similarity index 91% rename from src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs rename to src/Infrastructure/BotSharp.Abstraction/Rules/Frontier.cs index 355d34e24..3929b36bd 100644 --- a/src/Infrastructure/BotSharp.Core.Rules/Engines/Frontier.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Rules/Frontier.cs @@ -4,7 +4,7 @@ namespace BotSharp.Core.Rules.Engines; /// Abstraction over the data structure that drives graph traversal order. /// Stack → DFS, Queue → BFS. Swap the frontier mid-traversal to switch strategy. /// -internal interface IFrontier +public interface IFrontier { void Add(T item); T Remove(); @@ -19,7 +19,7 @@ internal interface IFrontier /// /// LIFO frontier – produces depth-first traversal. /// -internal sealed class StackFrontier : IFrontier +public sealed class StackFrontier : IFrontier { private readonly Stack _stack = new(); @@ -46,7 +46,7 @@ public void DrainTo(IFrontier other) /// /// FIFO frontier – produces breadth-first traversal. /// -internal sealed class QueueFrontier : IFrontier +public sealed class QueueFrontier : IFrontier { private readonly Queue _queue = new(); diff --git a/src/Infrastructure/BotSharp.Core.Rules/docs/rule-flow-guide.md b/src/Infrastructure/BotSharp.Core.Rules/docs/rule-flow-guide.md new file mode 100644 index 000000000..f1a9df178 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.Rules/docs/rule-flow-guide.md @@ -0,0 +1,191 @@ +# Rule Flow Graph — Build & Modification Guide + +## 1. Graph Structure + +A rule flow graph consists of **Nodes** (units of work) connected by **Edges** (directed links). The engine loads the graph, applies schemas from node config, validates connection compatibility, and then traverses the graph starting from the root node. + +Every graph must have **exactly one root node** and **exactly one end node**. The root node is the entry point where execution begins, and the end node is the exit point where execution concludes. + +## 2. Node Types + +Every node **must** have a `Type` and a `Name`. The `Name` must match a registered flow unit implementation. + +- **Root** (type: `root` or `start`) — The entry point of the graph. It should have **no** input schema but **must** have an output schema that declares the parameters it passes to downstream nodes. When executed, it copies all trigger parameters into its output. +- **End** (type: `end`) — The exit point of the graph. It can have both an input schema and an output schema. When executed, it collects the final context parameters as its result. +- **Action** (type: `action`) — A node that performs work such as sending a chat message, making an HTTP request, or invoking a tool call. Must have both an input schema and an output schema. +- **Condition** (type: `condition`) — A node that evaluates a boolean expression. Its children are only traversed when the evaluation result is `true`; otherwise the branch is skipped. Must have both an input schema and an output schema. + +## 3. Required Node Properties + +| Property | Required | Description | +|----------|----------|-------------| +| `Id` | Yes | Unique identifier (GUID). | +| `Name` | Yes | Must match a registered flow unit's name (e.g., `"start"`, `"send_message_to_agent"`). | +| `Type` | Yes | One of: `root`, `start`, `action`, `condition`, `end`. | +| `Config` | No | Key-value dictionary passed as parameters to the node at execution time. Also supports reserved keys (see below). | + +## 4. Reserved Config Keys + +| Key | Purpose | +|-----|---------| +| `input_schema` | A JSON string describing the node's input schema. When provided, this overrides any default input schema. | +| `output_schema` | A JSON string describing the node's output schema. When provided, this overrides any default output schema. | +| `traversal_algorithm` | The graph traversal defaults to depth-first (DFS). Only set this value if you need to switch the traversal strategy mid-graph. Setting it to `"bfs"` on a node will change the traversal to breadth-first from that node onward. | + +## 5. Edge Properties + +| Property | Required | Description | +|----------|----------|-------------| +| `Id` | Yes | Unique identifier (GUID). | +| `From` | Yes | Reference to the source node. | +| `To` | Yes | Reference to the target node. | +| `Type` | No | Default `"next"`. | +| `Weight` | No | Higher weight = higher priority when multiple children exist. Default `1.0`. | + +## 6. Schema Contract + +Each node can declare an **input** and **output** schema to describe its data contract. The schema follows a JSON Schema-like structure with two fields: + +- **properties** — A dictionary of parameter names. Each entry has a `type` (one of `string`, `number`, `boolean`, `object`, `array`) and an optional `description`. +- **required** — A list of parameter names that must be provided for the node to function correctly. + +Example schema (as JSON): + +```json +{ + "properties": { + "order_id": { "type": "string", "description": "The order identifier" }, + "amount": { "type": "number", "description": "Total amount" } + }, + "required": ["order_id"] +} +``` + +**Schema precedence:** A schema defined in the node's config (via `input_schema` / `output_schema` keys) always **overrides** any default schema. + +## 7. Connection Validation Rules + +The engine validates **every edge** in the graph at load time. For an edge `[A] → [B]` to be valid: + +### Rule 1 — Required keys must be available +**Every key listed in the downstream node's required input must exist in the upstream node's output schema properties.** +**Neither the upstream node's output schema nor the downstream node's input schema can be empty — both must be explicitly defined for every connection**. + +### Rule 2 — Types must be compatible +When a required key appears in both the upstream node's output properties and the downstream node's input properties, the type must match (case-insensitive). For example: +- ✅ `A` outputs `{"order_id": {"type": "string"}}` → `B` expects `{"order_id": {"type": "string"}}` +- ❌ `A` outputs `{"amount": {"type": "number"}}` → `B` expects `{"amount": {"type": "string"}}` + +### Rule 3 — Boundary constraints +- A graph must contain **exactly one** root node and **exactly one** end node. +- **Root nodes** (`root`/`start`): Should have **no** input schema — they are entry points with no upstream. Must have an output schema that declares the parameters available to downstream nodes. +- **Intermediate nodes** (`action`/`condition`): Must have both an input schema and an output schema. The input schema declares what the node expects from upstream, and the output schema declares what it produces for downstream. +- **End nodes** (`end`): Can have both input and output schemas. The input schema validates that upstream nodes provide the required final data. The output schema describes the data the graph produces as its overall result. + +## 8. Built-in Node Reference + +### 8.1 `http_request` (Action) + +An action node that sends an HTTP request and returns the response. The URL supports placeholder substitution — any `{key}` in the URL is replaced with the matching parameter value at runtime. + +**Required config / input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `http_url` | string | Yes | The target URL. May contain `{placeholder}` tokens that are substituted from context parameters. | +| `http_method` | string | Yes | One of: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`. | +| `http_query_params` | array | No | Query parameters as key-value pairs, appended to the URL. | +| `http_request_headers` | array | No | Request headers as key-value pairs. | +| `http_request_body` | string | No | The request body (JSON string). | + +**Output:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `http_response` | string | The HTTP response body. | +| `http_response_headers` | string | The HTTP response headers as a JSON string. | + +If the request fails (non-success status code or exception), the node returns `Success = false` with an error message. + +### 8.2 `logic_gate` (Condition) + +A condition node that collects results from multiple parent condition nodes and evaluates a composite logical expression. It waits until **all** parent nodes have been visited before evaluating. + +**Required config / input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `expression` | object | Yes | A JSON-encoded expression tree (see below). | +| `default_value` | string | No | The fallback boolean value (`"true"` or `"false"`) when a referenced parent node or data key is not found. Defaults to `"false"`. | + +**Expression tree format:** + +There are two kinds of nodes in an expression tree: + +- **Leaf node** — References a specific parent node's result. + - `node` (required): The name of a parent condition node. + - `key` (optional): A key in the parent node's output data that holds a boolean string. If omitted, the parent's overall success flag is used. + +- **Operator node** — Combines child expressions with a logical operator. + - `op` (required): One of `"and"`, `"or"`, `"not"`. + - `children` (required): A list of child expression nodes. + - For `"not"`, only the first child is evaluated. + +**Example expression:** + +Given three parent condition nodes — `check_work_order`, `check_client`, and `check_affiliate` — the following expression evaluates `work_order_valid AND (client_name_valid OR NOT affiliate_name_valid)`: + +```json +{ + "expression": { + "op": "and", + "children": [ + { "node": "check_work_order", "key": "work_order_valid" }, + { + "op": "or", + "children": [ + { "node": "check_client", "key": "client_name_valid" }, + { + "op": "not", + "children": [ + { "node": "check_affiliate", "key": "affiliate_name_valid" } + ] + } + ] + } + ] + }, + "default_value": "false" +} +``` + +The node produces no output schema — it only controls whether downstream nodes are traversed based on the evaluation result. + +## 9. Example: Minimal Valid Graph + +``` +[start] ──→ [check_order] ──→ [send_message_to_agent] ──→ [end] + root condition action end +``` + +**Node definitions:** + +| Node | Type | Name | Output Schema | Input Schema | +|------|------|------|--------------|--------------| +| Start | `root` | `start` | _(from context: `order_id`, `customer_name`)_ | _(none)_ | +| Check Order | `condition` | `check_order` | `{ order_id: string }` | `{ required: [order_id] }` | +| Send Message | `action` | `send_message_to_agent` | `{ agent_id: string, conversation_id: string }` | _(empty — no required)_ | +| End | `end` | `end` | _(none)_ | _(none)_ | + +**Validation at load:** +- `[start] → [check_order]`: `check_order` requires `order_id` — satisfied if `start`'s output or `check_order`'s config provides it. ✅ +- `[check_order] → [send_message_to_agent]`: `send_message_to_agent` has no required inputs. ✅ +- `[send_message_to_agent] → [end]`: `end` has no input schema. ✅ + +## 10. How Data Flows at Runtime + +1. **Start node** — Copies all context parameters (trigger states, flow options) into its output data. +2. **Subsequent nodes** — Receive merged parameters built from three sources in order: the node's own config, then trigger states, then upstream output data. +3. **Condition nodes** — If the evaluation result is `false`, all children of that node are **skipped**. +4. **Action nodes** — If the action result is marked as delayed, children are **deferred** until the next execution cycle. +5. **End node** — Copies the final context parameters into result data. \ No newline at end of file