← All source files

V4 / 2d54b29f17a8

ScenarioDefinition.cs

Lilith/Core/Scenarios/ScenarioDefinition.cs

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

namespace Lilith.Core.Scenarios;

public sealed record ScenarioProgress(string RunId, string ScenarioId, string Kind, string Text, int Step, int TotalSteps);
public sealed record ScenarioInput(string Name, bool Required, string? Value);
public sealed record ScenarioCodeTool(string Name, string Description, JsonElement Parameters, string Code);
public sealed record ScenarioDefinition(string Id, string Title, string SystemPrompt, ScenarioInput[] Inputs,
    string[] AllowedTools, ScenarioCodeTool[] CodeTools, string[] Steps, string ExitMode, string ExitRequest, string Xml);

public static class ScenarioXmlParser
{
    internal static bool ValidName(string name) => Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,63}$", RegexOptions.CultureInvariant);

    internal static XDocument Read(string xml)
    {
        using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings
        {
            DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 1_000_000
        });
        var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
        if (document.Descendants().Any(e => e.Ancestors().Count() > 12)) throw new InvalidDataException("Scenario XML is too deep.");
        return document;
    }

    public static ScenarioDefinition Parse(string xml)
    {
        var root = Read(xml).Root ?? throw new InvalidDataException("Missing scenario root.");
        Shape(root, "scenario", ["schemaVersion", "id", "title"], ["inputs", "systemPrompt", "tools", "steps", "exit"]);
        if ((string?)root.Attribute("schemaVersion") != "1") throw new InvalidDataException("Unsupported scenario schemaVersion.");
        string id = Attribute(root, "id"), title = Attribute(root, "title");
        if (!ValidName(id) || title.Length > 200) throw new InvalidDataException("Invalid scenario identity.");
        var inputs = new List<ScenarioInput>();
        if (root.Element("inputs") is { } inputRoot)
        {
            Shape(inputRoot, "inputs", [], ["input"]);
            foreach (var input in inputRoot.Elements())
            {
                Shape(input, "input", ["name", "required", "default", "value"], []);
                string name = Attribute(input, "name");
                if (!ValidName(name) || inputs.Any(i => i.Name == name)) throw new InvalidDataException("Invalid or duplicate input name.");
                string requiredText = (string?)input.Attribute("required") ?? "false";
                if (!bool.TryParse(requiredText, out bool required)) throw new InvalidDataException("Invalid required flag.");
                inputs.Add(new ScenarioInput(name, required, (string?)input.Attribute("value") ?? (string?)input.Attribute("default")));
            }
        }
        var allowed = new List<string>();
        var codeTools = new List<ScenarioCodeTool>();
        if (root.Element("tools") is { } tools)
        {
            Shape(tools, "tools", [], ["allow", "csharp"]);
            foreach (var tool in tools.Elements())
            {
                string name = Attribute(tool, "name");
                if (!ValidName(name) || name == "run_scenario" || allowed.Contains(name) || codeTools.Any(t => t.Name == name))
                    throw new InvalidDataException("Invalid, duplicate, or nested scenario tool.");
                if (tool.Name == "allow")
                {
                    Shape(tool, "allow", ["name"], []);
                    allowed.Add(name);
                }
                else
                {
                    Shape(tool, "csharp", ["name", "description"], ["parameters", "code"]);
                    var parameters = RequiredElement(tool, "parameters");
                    Shape(parameters, "parameters", [], []);
                    var schema = JsonSerializer.Deserialize<JsonElement>(Text(parameters));
                    if (schema.ValueKind != JsonValueKind.Object || !schema.TryGetProperty("type", out var type) || type.GetString() != "object")
                        throw new InvalidDataException("Tool parameters must be an object schema.");
                    var code = RequiredElement(tool, "code");
                    Shape(code, "code", [], []);
                    if (!code.Nodes().Any(n => n is XCData)) throw new InvalidDataException("C# code must use CDATA.");
                    codeTools.Add(new ScenarioCodeTool(name, Attribute(tool, "description"), schema, Text(code)));
                }
            }
        }
        if (inputs.Count > 32 || allowed.Count + codeTools.Count > 16) throw new InvalidDataException("Too many scenario inputs/tools.");
        var prompt = RequiredElement(root, "systemPrompt");
        Shape(prompt, "systemPrompt", [], []);
        var stepsRoot = RequiredElement(root, "steps");
        Shape(stepsRoot, "steps", [], ["step"]);
        string[] steps = stepsRoot.Elements().Select(step => { Shape(step, "step", [], []); return Text(step); }).ToArray();
        if (steps.Length is < 1 or > 20) throw new InvalidDataException("Supply 1-20 ordered scenario steps.");
        var exit = RequiredElement(root, "exit");
        Shape(exit, "exit", ["mode"], []);
        string mode = Attribute(exit, "mode");
        if (mode is not ("copy" or "respond")) throw new InvalidDataException("Exit mode must be copy or respond.");
        return new ScenarioDefinition(id, title, Text(prompt), inputs.ToArray(), allowed.ToArray(), codeTools.ToArray(), steps, mode, Text(exit), xml);
    }

    internal static string Expand(string text, IReadOnlyDictionary<string, string> values) => Regex.Replace(text,
        @"\{\{([A-Za-z][A-Za-z0-9_-]{0,63})\}\}", match => values.TryGetValue(match.Groups[1].Value, out var value)
            ? value : throw new InvalidDataException("Unknown scenario input: " + match.Groups[1].Value));

    private static XElement RequiredElement(XElement root, string name) => root.Element(name) ?? throw new InvalidDataException("Missing " + name + ".");
    private static string Attribute(XElement element, string name) => !string.IsNullOrWhiteSpace((string?)element.Attribute(name))
        ? element.Attribute(name)!.Value : throw new InvalidDataException("Missing " + name + ".");
    private static string Text(XElement element) => element.Value.Trim() is { Length: > 0 and <= 20000 } value
        ? value : throw new InvalidDataException("Invalid " + element.Name + " text.");
    private static void Shape(XElement element, string name, string[] attributes, string[] children)
    {
        if (element.Name != name || element.Attributes().Any(a => !attributes.Contains(a.Name.ToString())) ||
            element.Elements().Any(e => !children.Contains(e.Name.ToString()))) throw new InvalidDataException("Unknown XML in " + name + ".");
        if (name is "scenario" or "csharp" && element.Elements().GroupBy(e => e.Name).Any(g => g.Count() > 1))
            throw new InvalidDataException("Duplicate section in " + name + ".");
    }
}