← All source files

V14 / fad4e1a110e5

ScenarioFlowStep.cs

Lilith/Core/Scenarios/ScenarioFlowStep.cs

Unchanged from the preceding version
CS
using System.Xml.Linq;

namespace Lilith.Core.Scenarios;

public abstract record ScenarioFlowStep(string Description);
public sealed record ScenarioChatStep(string Prompt, string? Result = null) : ScenarioFlowStep(Prompt);
public sealed record ScenarioRunStep(string Scenario, IReadOnlyDictionary<string, string> Inputs, string? Result = null) : ScenarioFlowStep("Run " + Scenario);
public sealed record ScenarioToolStep(string Tool, IReadOnlyDictionary<string, string> Inputs, string Result) : ScenarioFlowStep("Call " + Tool);
public sealed record ScenarioResultStep(IReadOnlyDictionary<string, string> Fields) : ScenarioFlowStep("Return structured result");
public sealed record ScenarioForEachStep(string Items, string Item, string Collect, string Result, string Limit, int Attempts, string? SuccessProperty, string? IdentityProperty, ScenarioFlowStep[] Body) : ScenarioFlowStep("Process collection");
public sealed record ScenarioIfStep(string? Input, string? EqualsValue, string? NotEqualsValue, string? WorkspacePath, bool Exists,
    ScenarioFlowStep[] Then, ScenarioFlowStep[] Else) : ScenarioFlowStep("Evaluate condition");

internal static class ScenarioFlowParser
{
    internal static ScenarioFlowStep[] Parse(XElement section)
    {
        ScenarioFlowStep[] ParseSteps(XElement container) => container.Elements().Select(ParseStep).ToArray();
        ScenarioFlowStep ParseStep(XElement element)
        {
            switch (element.Name.LocalName)
            {
                case "step":
                    ScenarioXmlParser.Shape(element, "step", ["result"], []);
                    if (string.IsNullOrWhiteSpace(element.Value) || element.Value.Length > 20000) throw new InvalidDataException("Invalid chat step.");
                    return new ScenarioChatStep(element.Value.Trim(), ScenarioBindings.ResultName(element));
                case "tool":
                    ScenarioXmlParser.Shape(element, "tool", ["name", "result"], ["input"]);
                    string tool = (string?)element.Attribute("name") ?? "";
                    if (!ScenarioXmlParser.ValidName(tool)) throw new InvalidDataException("Invalid tool name.");
                    return new ScenarioToolStep(tool, ScenarioBindings.ReadFields(element, "input"), ScenarioBindings.ResultName(element) ?? throw new InvalidDataException("Tool needs result."));
                case "result":
                    ScenarioXmlParser.Shape(element, "result", [], ["field"]);
                    return new ScenarioResultStep(ScenarioBindings.ReadFields(element, "field"));
                case "foreach":
                    ScenarioXmlParser.Shape(element, "foreach", ["items", "item", "collect", "result", "limit", "attempts", "successProperty", "identityProperty"], ["step", "run", "tool", "if", "result"]);
                    string items = (string?)element.Attribute("items") ?? "", item = (string?)element.Attribute("item") ?? "", collect = (string?)element.Attribute("collect") ?? "";
                    string limit = (string?)element.Attribute("limit") ?? "5";
                    if (!ScenarioBindings.ValidReference(items) || !ScenarioXmlParser.ValidName(item) || !ScenarioXmlParser.ValidName(collect) || !int.TryParse((string?)element.Attribute("attempts") ?? "30", out int attempts) || attempts is < 1 or > 30 || !element.Elements().Any()) throw new InvalidDataException("Invalid bounded foreach.");
                    string? success = (string?)element.Attribute("successProperty");
                    if (success is not null && !ScenarioXmlParser.ValidName(success)) throw new InvalidDataException("Invalid success property.");
                    string? identity = (string?)element.Attribute("identityProperty");
                    if (identity is not null && !ScenarioXmlParser.ValidName(identity)) throw new InvalidDataException("Invalid identity property.");
                    return new ScenarioForEachStep(items, item, collect, ScenarioBindings.ResultName(element) ?? throw new InvalidDataException("Foreach needs result."), limit, attempts, success, identity, ParseSteps(element));
                case "run":
                    ScenarioXmlParser.Shape(element, "run", ["scenario", "result"], ["input"]);
                    string name = (string?)element.Attribute("scenario") ?? "";
                    if (!ScenarioXmlParser.ValidName(name)) throw new InvalidDataException("Nested run requires a literal saved scenario name.");
                    var inputs = new Dictionary<string, string>(StringComparer.Ordinal);
                    foreach (var input in element.Elements())
                    {
                        ScenarioXmlParser.Shape(input, "input", ["name", "value"], []);
                        string key = (string?)input.Attribute("name") ?? "";
                        string? value = (string?)input.Attribute("value");
                        if (!ScenarioXmlParser.ValidName(key) || value is null || value.Length > 20000 || !inputs.TryAdd(key, value)) throw new InvalidDataException("Invalid nested input.");
                    }
                    if (inputs.Count > 32) throw new InvalidDataException("Too many nested inputs.");
                    return new ScenarioRunStep(name, inputs, ScenarioBindings.ResultName(element));
                case "if":
                    ScenarioXmlParser.Shape(element, "if", ["input", "equals", "notEquals", "workspacePath", "exists"], ["then", "else"]);
                    string? inputName = (string?)element.Attribute("input"), path = (string?)element.Attribute("workspacePath");
                    string? equal = (string?)element.Attribute("equals"), unequal = (string?)element.Attribute("notEquals");
                    string? exists = (string?)element.Attribute("exists");
                    bool expected = true;
                    if (path is not null ? inputName is not null || equal is not null || unequal is not null || string.IsNullOrWhiteSpace(path) || (exists is not null && !bool.TryParse(exists, out expected))
                        : inputName is null || !ScenarioBindings.ValidReference(inputName) || (equal is null) == (unequal is null) || exists is not null)
                        throw new InvalidDataException("Use either input + equals/notEquals, or workspacePath + optional exists=true/false.");
                    var then = element.Elements("then").ToArray();
                    var otherwise = element.Elements("else").ToArray();
                    if (then.Length != 1 || otherwise.Length > 1) throw new InvalidDataException("A condition needs one then and at most one else.");
                    foreach (var branch in then.Concat(otherwise))
                    {
                        ScenarioXmlParser.Shape(branch, branch.Name.LocalName, [], ["step", "run", "if", "tool", "foreach", "result"]);
                        if (!branch.Elements().Any()) throw new InvalidDataException("Conditional branches cannot be empty.");
                    }
                    return new ScenarioIfStep(inputName, equal, unequal, path, expected, ParseSteps(then[0]), otherwise.Length == 0 ? [] : ParseSteps(otherwise[0]));
                default: throw new InvalidDataException("Unknown scenario flow step.");
            }
        }
        var result = ParseSteps(section);
        if (Flatten(result).Count() > 64) throw new InvalidDataException("Too many scenario flow steps (maximum 64).");
        return result;
    }

    internal static IEnumerable<ScenarioFlowStep> Flatten(IEnumerable<ScenarioFlowStep> steps)
    {
        foreach (var step in steps)
        {
            yield return step;
            if (step is ScenarioIfStep condition)
                foreach (var nested in Flatten(condition.Then.Concat(condition.Else))) yield return nested;
            if (step is ScenarioForEachStep loop)
                foreach (var nested in Flatten(loop.Body)) yield return nested;
        }
    }
}