V6 / CHANGESET

Only the delta.

The exact changes from V5 to V6. Unchanged files are excluded. Nothing from a later version is included.

V5→V6920e1e35d097

Green + / red - count added and removed lines; signed bytes show the file size change. UTF-8 file sizes and total lines in this snapshot; deleted files use the previous snapshot. Sorting stays within each folder.

Repository root 1 files

ModifiedVersion.props215 B / 4 lines+2-2+10 B
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
  <!-- V6 conditional and nested scenarios. V5 alpha is preserved separately. -->
  <PropertyGroup><VersionPrefix>6.0.0</VersionPrefix><VersionSuffix>alpha.2</VersionSuffix></PropertyGroup>
</Project>

Lilith/Core/Scenarios 6 files

ModifiedRunScenarioTool.cs6,550 B / 87 lines+1-1+7 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Tools;

namespace Lilith.Core.Scenarios;

/// <summary>Loads trusted saved XML, fills values as data, and passes the complete document to the executor.</summary>
public sealed class RunScenarioTool(ProviderClient parent, ScenarioFileService files) : ITurnResultTool
{
    public string Name => "run_scenario";
    public string Description => "Run a named saved scenario in a fresh sub-agent conversation. " +
        "Use this when the user says run X scenario. Supply input values in inputs. " +
        "If status is needs_input, ask the user for the missing values before retrying. " +
        "Do not retry a failed scenario without a new user request. " +
        "For exit mode respond, follow exit_request when composing the final answer. " +
        "For workspace data management, always delegate to a workspace scenario. Search with workspace_search and read only selected excerpts. " +
        "Never approve a workspace proposal yourself or change access mode. " +
        "For timers and cron jobs use schedule_create/update/cancel/delete scenarios. Use schedule_peek(name) for remaining time and schedule_list for all schedules. Never approve schedule proposals yourself. " +
        "Schedule create/update take name, kind (timer, interval or cron), seconds (whole seconds for timer/interval), cron (five fields), timezone (zone ID), message (reminder), scenario (optional saved action) and inputs_json (JSON string object for its inputs). Updates replace the complete definition. " +
        "Available scenarios and input names: " + string.Join("; ", files.ListNames().Select(name =>
        { var scenario = ScenarioXmlParser.Parse(files.LoadScenarioXml(name)); return name + "(" + string.Join(", ", scenario.Inputs.Select(i => i.Name + (i.Required ? " required" : ""))) + ")"; }));
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new
    {
        type = "object", properties = new
        {
            scenario_name = new { type = "string", description = "Saved scenario name, without .xml." },
            inputs = new { type = "object", additionalProperties = new { type = "string" }, description = "Named scenario input values." }
        }, required = new[] { "scenario_name" }, additionalProperties = false
    });
    public TimeSpan ExecutionTimeout => TimeSpan.FromMinutes(10);
    public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
        (await ExecuteResultAsync(arguments, cancellationToken)).Content;

    public async Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
    {
        if (arguments.ValueKind != JsonValueKind.Object || !arguments.TryGetProperty("scenario_name", out var name) || name.ValueKind != JsonValueKind.String ||
            arguments.EnumerateObject().Any(p => p.Name is not ("scenario_name" or "inputs")))
            throw new ArgumentException("Expected scenario_name and optional inputs.");
        string xml = files.LoadScenarioXml(name.GetString()!);
        var definition = ScenarioXmlParser.Parse(xml);
        var supplied = new Dictionary<string, string>(StringComparer.Ordinal);
        if (arguments.TryGetProperty("inputs", out var inputValues))
        {
            if (inputValues.ValueKind != JsonValueKind.Object) throw new ArgumentException("inputs must be an object of string values.");
            foreach (var property in inputValues.EnumerateObject())
            {
                if (property.Value.ValueKind != JsonValueKind.String || !definition.Inputs.Any(i => i.Name == property.Name) ||
                    !supplied.TryAdd(property.Name, property.Value.GetString()!) || supplied[property.Name].Length > 20000)
                    throw new ArgumentException("Unknown, duplicate, or invalid scenario input: " + property.Name);
            }
        }
        var document = ScenarioXmlParser.Read(xml);
        foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
            if (supplied.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
        string fullXml = document.ToString(SaveOptions.DisableFormatting);
        var filled = ScenarioXmlParser.Parse(fullXml);
        string[] missing = filled.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
        if (missing.Length > 0)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "needs_input", scenario = definition.Id, missing,
                request = "Ask the user for these values. No scenario steps have run." }),
                "Please provide the following values for the " + definition.Title + " scenario: " + string.Join(", ", missing) + ".");
        ScenarioRunResult run;
        try { run = await new ScenarioExecutor(parent, files).RunXmlAsync(fullXml, cancellationToken); }
        catch (ScenarioExecutionException ex)
        {
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "failed", run_id = ex.RunId, error = ex.Message }));
        }
        if (run.Schedule is { } schedule)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = schedule.Status, run_id = run.RunId, result = schedule,
                exit_mode = run.ExitMode, exit_request = run.ExitRequest }), schedule.Status == "needs_approval" || run.ExitMode == "copy" ? schedule.ToReply() : null);
        if (run.Workspace is { } storage)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = storage.Status, run_id = run.RunId, result = storage,
                exit_mode = run.ExitMode, exit_request = run.ExitRequest }),
                storage.Status == "needs_approval" || run.ExitMode == "copy" ? storage.ToReply() : null);
        return new ToolExecutionResult(JsonSerializer.Serialize(new
        {
            status = "completed", run_id = run.RunId, scenario = run.ScenarioId, output = run.Output,
            exit_mode = run.ExitMode, exit_request = run.ExitRequest
        }), run.ExitMode == "copy" ? run.Output : null);
    }
}

public static class ScenarioTools
{
    public static void Register(ProviderClient provider, string? scenarioRoot = null) =>
        provider.RegisterTool(new RunScenarioTool(provider, new ScenarioFileService(scenarioRoot)));
}
ModifiedScenarioDefinition.cs7,428 B / 117 lines+14-7+234 B
C#UnchangedRemoved / replacedAdded
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 ScenarioFlowStep[] FlowSteps { get; init; } = [];
}

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"]);
        string schemaVersion = (string?)root.Attribute("schemaVersion") ?? "";
        if (schemaVersion is not ("1" or "2")) 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", [], schemaVersion == "1" ? ["step"] : ["step", "if", "run"]);
        var flow = ScenarioFlowParser.Parse(stepsRoot);
        string[] steps = flow.Select(step => step.Description).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) { FlowSteps = flow };
    }

    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.");
    internal 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 + ".");
    }
}
ModifiedScenarioExecutor.cs11,534 B / 156 lines+76-15+4,183 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Tools;

namespace Lilith.Core.Scenarios;

public sealed record ScenarioRunResult(string RunId, string ScenarioId, string Output, string ExitMode, string ExitRequest, Lilith.Core.Workspace.WorkspaceResult? Workspace = null, Lilith.Core.Scheduling.ScheduleResult? Schedule = null);
public sealed class ScenarioExecutionException(string runId, Exception inner)
    : Exception(inner.Message, inner)
{
    public string RunId { get; } = runId;
}

public sealed class ScenarioExecutor(ProviderClient parent, ScenarioFileService? files = null)
{
    private readonly ScenarioFileService _files = files ?? new();
    /// <summary>Application API for trusted full XML. Model calls should use the saved-file scenario tool.</summary>
    public async Task<ScenarioRunResult> RunXmlAsync(string completeXml, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var plan = new ScenarioRunPlan(_files, parent);
        plan.Validate(ScenarioXmlParser.Parse(completeXml));
        return await RunCoreAsync(completeXml, plan, null, cancellationToken);
    }

    private async Task<ScenarioRunResult> RunCoreAsync(string completeXml, ScenarioRunPlan plan, string? parentRunId, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        plan.CountRun();
        var scenario = ScenarioXmlParser.Parse(completeXml);
        string[] missing = scenario.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
        if (missing.Length > 0) throw new ArgumentException("Missing required scenario inputs: " + string.Join(", ", missing));
        var values = scenario.Inputs.ToDictionary(i => i.Name, i => i.Value ?? string.Empty, StringComparer.Ordinal);
        // Validate every substitution/tool reference before compiling code or running a chat turn.
        string prompt = ScenarioXmlParser.Expand(scenario.SystemPrompt, values);
        string exit = ScenarioXmlParser.Expand(scenario.ExitRequest, values);
        var workspaceNames = scenario.AllowedTools.Where(n => Lilith.Core.Workspace.WorkspaceTool.Operation(n) is not null).ToArray();
        var scheduleNames = scenario.AllowedTools.Where(n => Lilith.Core.Scheduling.ScheduleTool.Operation(n) is not null).ToArray();
        bool controlFlow = scenario.FlowSteps.Any(s => s is not ScenarioChatStep);
        if (scheduleNames.Length > 0 && (controlFlow || parent.Schedules is null || parent.ExecutingScheduledJob || scenario.Steps.Length != 1 || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0))
            throw new InvalidDataException("Schedule scenarios require a configured scheduler, one step, one schedule operation and no embedded code; scheduled jobs cannot manage schedules.");
        if (workspaceNames.Length > 0 && (controlFlow || parent.Workspace is null || scenario.Steps.Length != 1 || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0))
            throw new InvalidDataException("Workspace scenarios require a configured workspace, one step, one workspace operation, and no embedded code.");
        foreach (string tool in scenario.AllowedTools)
            if (!workspaceNames.Contains(tool) && !scheduleNames.Contains(tool) && (!parent.RegisteredTools.ContainsKey(tool) || parent.RegisteredTools[tool] is ITurnResultTool))
                throw new InvalidDataException("Unavailable or nested scenario tool: " + tool);
        string runId = Guid.NewGuid().ToString("N");
        string folder = Path.Combine(parent.HistoryStore.RootDirectory, "Scenarios", runId);
        Directory.CreateDirectory(folder);
        using var transcript = new StreamWriter(Path.Combine(folder, "session.log")) { AutoFlush = true };
        await File.WriteAllTextAsync(Path.Combine(folder, "scenario.xml"), completeXml, cancellationToken);
        await File.WriteAllTextAsync(Path.Combine(folder, "run.json"), JsonSerializer.Serialize(new { RunId = runId, ParentRunId = parentRunId, ScenarioId = scenario.Id }), cancellationToken);
        int totalSteps = ScenarioFlowParser.Flatten(scenario.FlowSteps).Count();
        void Report(string kind, string text, int step)
        {
            transcript.WriteLine($"{DateTimeOffset.UtcNow:O} [{kind}] {text}");
            parent.ReportScenario(new ScenarioProgress(runId, scenario.Id, kind, text, step, totalSteps));
        }
        Report("Started", scenario.Title, 0);
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromMinutes(10));
        var ct = deadline.Token;
        try
        {
            using var compiler = await ScenarioCodeCompiler.CompileAsync(scenario.CodeTools, values, ct);
            using var child = parent.CreateScenarioBranch(prompt, new Logger(), new ChatHistoryStore(Path.Combine(folder, "History")));
            child.AgentName = "Scenario:" + scenario.Id;
            Lilith.Core.Workspace.WorkspaceResult? storageResult = null;
            Lilith.Core.Scheduling.ScheduleResult? scheduleResult = null;
            foreach (string name in scenario.AllowedTools)
            {
                if (workspaceNames.Contains(name)) child.RegisterTool(new Lilith.Core.Workspace.WorkspaceTool(parent.Workspace!, Lilith.Core.Workspace.WorkspaceTool.Operation(name)!, values, result => storageResult = result));
                else if (scheduleNames.Contains(name)) child.RegisterTool(new Lilith.Core.Scheduling.ScheduleTool(parent.Schedules!, Lilith.Core.Scheduling.ScheduleTool.Operation(name)!, values, result => scheduleResult = result));
                else child.RegisterTool(parent.RegisteredTools[name]);
            }
            foreach (var tool in compiler.Tools) child.RegisterTool(tool);
            string? last = null;
            int stepNumber = 0;
            bool halted = false;
            async Task ExecuteAsync(IEnumerable<ScenarioFlowStep> sequence)
            {
                foreach (var step in sequence)
                {
                    if (halted) return;
                    ct.ThrowIfCancellationRequested();
                    plan.CountStep();
                    int number = ++stepNumber;
                    string Expand(string text) => ScenarioXmlParser.Expand(text, values);
                    switch (step)
                    {
                        case ScenarioChatStep chat:
                            string message = Expand(chat.Prompt);
                            Report("Step", message, number);
                            int historyCount = child.History.Count;
                            last = await child.StreamReply(message, cancellationToken: ct);
                            foreach (var toolResult in child.History.Skip(historyCount).Where(m => m.Role == "tool")) Report("Tool", toolResult.ToolName + ": " + toolResult.Content, number);
                            Report("Reply", last, number);
                            break;
                        case ScenarioIfStep condition:
                            bool matches;
                            if (condition.WorkspacePath is not null)
                            {
                                string path = Expand(condition.WorkspacePath);
                                var check = await parent.Workspace!.ExecuteAsync(new("exists", path), ct);
                                bool exists = JsonSerializer.SerializeToElement(check.Data).GetProperty("exists").GetBoolean();
                                matches = exists == condition.Exists;
                                Report("Condition", $"Workspace path {path}: exists={exists}; selected {(matches ? "then" : "else")}", number);
                            }
                            else
                            {
                                string value = values[condition.Input!];
                                matches = condition.EqualsValue is not null ? value == Expand(condition.EqualsValue) : value != Expand(condition.NotEqualsValue!);
                                Report("Condition", $"Input {condition.Input}: selected {(matches ? "then" : "else")}", number);
                            }
                            await ExecuteAsync(matches ? condition.Then : condition.Else);
                            break;
                        case ScenarioRunStep run:
                            Report("NestedStarted", "Run " + run.Scenario, number);
                            string nestedXml = plan.Bind(run.Scenario, run.Inputs.ToDictionary(i => i.Key, i => Expand(i.Value)));
                            var nested = await RunCoreAsync(nestedXml, plan, runId, ct);
                            storageResult = nested.Workspace;
                            scheduleResult = nested.Schedule;
                            last = storageResult?.ToReply() ?? scheduleResult?.ToReply() ?? nested.Output;
                            child.History.Add("system", "Nested result (data): " + JsonSerializer.Serialize(new { nested.RunId, nested.ScenarioId, Output = last }));
                            child.SaveCurrentConversation();
                            Report("NestedCompleted", run.Scenario + " / " + nested.RunId + ": " + last, number);
                            halted = storageResult?.Status == "needs_approval" || scheduleResult?.Status == "needs_approval";
                            break;
                    }
                }
            }
            await ExecuteAsync(scenario.FlowSteps);
            ct.ThrowIfCancellationRequested();
            if (string.IsNullOrWhiteSpace(last)) throw new InvalidDataException("Scenario produced no final response.");
            if (workspaceNames.Length > 0 && storageResult is null) throw new InvalidDataException("The scenario did not perform its workspace operation. No completion is claimed.");
            if (scheduleNames.Length > 0 && scheduleResult is null) throw new InvalidDataException("The scenario did not perform its schedule operation. No completion is claimed.");
            var result = new ScenarioRunResult(runId, scenario.Id, last, scenario.ExitMode, exit, storageResult, scheduleResult);
            await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }), ct);
            bool needsApproval = halted || storageResult?.Status == "needs_approval" || scheduleResult?.Status == "needs_approval";
            Report(needsApproval ? "NeedsApproval" : "Completed", needsApproval ? "Waiting for host approval; later steps were not run." : "Result returned to caller.", stepNumber);
            return result;
        }
        catch (Exception ex)
        {
            await File.WriteAllTextAsync(Path.Combine(folder, "failure.json"), JsonSerializer.Serialize(new
            {
                RunId = runId,
                ScenarioId = scenario.Id,
                Status = ex is OperationCanceledException ? "canceled" : "failed",
                Error = ex.Message
            }));
            Report(ex is OperationCanceledException ? "Canceled" : "Failed", ex.Message, 0);
            if (ex is OperationCanceledException) throw;
            throw new ScenarioExecutionException(runId, ex);
        }
    }
}
ModifiedScenarioFileService.cs2,967 B / 45 lines+15-0+1,076 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Scenarios;

/// <summary>Loads trusted local application content, never code supplied by a model tool call.</summary>
public sealed class ScenarioFileService
{
    public string RootDirectory { get; }
    public ScenarioFileService(string? root = null) => RootDirectory = Path.GetFullPath(root ??
        Environment.GetEnvironmentVariable("LILITH_SCENARIOS_ROOT") ?? Path.Combine(AppContext.BaseDirectory, "Scenarios"));

    public string LoadScenarioXml(string scenarioName)
    {
        if (!ScenarioXmlParser.ValidName(scenarioName)) throw new ArgumentException("Invalid scenario name.");
        var rootInfo = new DirectoryInfo(RootDirectory);
        for (DirectoryInfo? directory = rootInfo; directory is not null; directory = directory.Parent)
            if (directory.Exists && directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
                throw new InvalidDataException("Scenario roots may not traverse reparse points.");
        string path = Path.Combine(RootDirectory, scenarioName + ".xml");
        var info = new FileInfo(path);
        if (!info.Exists) throw new FileNotFoundException("Unknown scenario: " + scenarioName);
        if (info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length > 1_000_000)
            throw new InvalidDataException("Invalid scenario file.");
        string xml = File.ReadAllText(path);
        if (ScenarioXmlParser.Parse(xml).Id != scenarioName) throw new InvalidDataException("Scenario ID must match its filename.");
        return xml;
    }

    public IReadOnlyList<string> ListNames() => Directory.Exists(RootDirectory)
        ? Directory.EnumerateFiles(RootDirectory, "*.xml").Select(Path.GetFileNameWithoutExtension).OfType<string>()
            .Where(ScenarioXmlParser.ValidName).OrderBy(n => n, StringComparer.Ordinal).Take(100).ToArray() : [];

    internal string Bind(string name, IReadOnlyDictionary<string, string> values)
        => BindXml(LoadScenarioXml(name), values);

    internal static string BindXml(string xml, IReadOnlyDictionary<string, string> values)
    {
        var definition = ScenarioXmlParser.Parse(xml);
        if (values.Any(v => !definition.Inputs.Any(i => i.Name == v.Key) || v.Value.Length > 20000)) throw new ArgumentException("Unknown or oversized nested scenario input.");
        var document = ScenarioXmlParser.Read(xml);
        foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
            if (values.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
        var filled = ScenarioXmlParser.Parse(document.ToString());
        if (filled.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(i.Value))) throw new ArgumentException("Missing required inputs for nested scenario: " + filled.Id);
        return document.ToString(System.Xml.Linq.SaveOptions.DisableFormatting);
    }
}
NEWScenarioFlowStep.cs4,931 B / 73 lines+73-0+4,931 B
C#UnchangedRemoved / replacedAdded
using System.Xml.Linq;

namespace Lilith.Core.Scenarios;

public abstract record ScenarioFlowStep(string Description);
public sealed record ScenarioChatStep(string Prompt) : ScenarioFlowStep(Prompt);
public sealed record ScenarioRunStep(string Scenario, IReadOnlyDictionary<string, string> Inputs) : ScenarioFlowStep("Run " + Scenario);
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", [], []);
                    if (string.IsNullOrWhiteSpace(element.Value) || element.Value.Length > 20000) throw new InvalidDataException("Invalid chat step.");
                    return new ScenarioChatStep(element.Value.Trim());
                case "run":
                    ScenarioXmlParser.Shape(element, "run", ["scenario"], ["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);
                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 || !ScenarioXmlParser.ValidName(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"]);
                        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;
        }
    }
}
NEWScenarioRunPlan.cs4,174 B / 54 lines+54-0+4,174 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Workspace.Extensions;
using Lilith.Core.Tools;
namespace Lilith.Core.Scenarios;

internal sealed class ScenarioRunPlan(ScenarioFileService files, ProviderClient owner)
{
    private readonly Dictionary<string, string> _sources = new(StringComparer.Ordinal);
    internal string Bind(string name, IReadOnlyDictionary<string, string> values)
    {
        if (!_sources.TryGetValue(name, out var xml)) _sources.Add(name, xml = files.LoadScenarioXml(name));
        return ScenarioFileService.BindXml(xml, values);
    }
    internal int Runs { get; private set; }
    internal int Steps { get; private set; }
    internal void CountRun() { if (++Runs > 32) throw new InvalidDataException("Scenario tree exceeds 32 runs."); }
    internal void CountStep() { if (++Steps > 128) throw new InvalidDataException("Scenario tree exceeds 128 executed steps."); }
    internal void Validate(ScenarioDefinition root)
    {
        int nodes = 0;
        void Visit(ScenarioDefinition scenario, HashSet<string> active)
        {
            if (active.Count >= 8 || !active.Add(scenario.Id)) throw new InvalidDataException("Nested scenario cycle or depth limit (8).");
            if (++nodes > 64) throw new InvalidDataException("Scenario plan exceeds 64 declared runs.");
            if (scenario.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(i.Value))) throw new ArgumentException("Missing required scenario inputs.");
            var values = scenario.Inputs.ToDictionary(i => i.Name, i => i.Value ?? "", StringComparer.Ordinal);
            bool workspace = scenario.AllowedTools.Any(n => Lilith.Core.Workspace.WorkspaceTool.Operation(n) is not null);
            bool schedule = scenario.AllowedTools.Any(n => Lilith.Core.Scheduling.ScheduleTool.Operation(n) is not null);
            if ((workspace || schedule) && (scenario.FlowSteps.Length != 1 || scenario.FlowSteps[0] is not ScenarioChatStep || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0))
                throw new InvalidDataException("Bound workspace/schedule operations require one chat step and one operation.");
            if (workspace && owner.Workspace is null || schedule && (owner.Schedules is null || owner.ExecutingScheduledJob)) throw new InvalidDataException("Scenario service unavailable.");
            foreach (string tool in scenario.AllowedTools)
                if (Lilith.Core.Workspace.WorkspaceTool.Operation(tool) is null && Lilith.Core.Scheduling.ScheduleTool.Operation(tool) is null &&
                    (!owner.RegisteredTools.TryGetValue(tool, out var registered) || registered is ITurnResultTool)) throw new InvalidDataException("Unavailable scenario tool: " + tool);
            string Expand(string text) => ScenarioXmlParser.Expand(text, values);
            Expand(scenario.SystemPrompt); Expand(scenario.ExitRequest);
            foreach (var step in ScenarioFlowParser.Flatten(scenario.FlowSteps))
                switch (step)
                {
                    case ScenarioChatStep chat: Expand(chat.Prompt); break;
                    case ScenarioIfStep condition:
                        if (condition.Input is not null && !values.ContainsKey(condition.Input)) throw new InvalidDataException("Unknown condition input: " + condition.Input);
                        if (condition.WorkspacePath is not null)
                            (owner.Workspace ?? throw new InvalidDataException("Workspace condition requires a configured workspace.")).Options.ResolvePath(Expand(condition.WorkspacePath));
                        if (condition.EqualsValue is not null) Expand(condition.EqualsValue);
                        if (condition.NotEqualsValue is not null) Expand(condition.NotEqualsValue);
                        break;
                    case ScenarioRunStep run:
                        Visit(ScenarioXmlParser.Parse(Bind(run.Scenario, run.Inputs.ToDictionary(i => i.Key, i => Expand(i.Value)))), new HashSet<string>(active, StringComparer.Ordinal));
                        break;
                }
        }
        Visit(root, new(StringComparer.Ordinal));
    }
}

Lilith/Core/Scenarios/Definitions 1 files

NEWfile_branch_demo.xml900 B / 24 lines+24-0+900 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="2" id="file_branch_demo" title="Two runs, two branches">
  <inputs>
    <input name="first_path" default="first.txt" />
    <input name="second_path" default="second.txt" />
  </inputs>
  <systemPrompt>Execute the declared condition and nested scenario. Report only verified workspace results.</systemPrompt>
  <steps>
    <if workspacePath="{{first_path}}" exists="false">
      <then>
        <run scenario="workspace_save">
          <input name="path" value="{{first_path}}" />
          <input name="content" value="hello" />
        </run>
      </then>
      <else>
        <run scenario="workspace_save">
          <input name="path" value="{{second_path}}" />
          <input name="content" value="again" />
        </run>
      </else>
    </if>
  </steps>
  <exit mode="copy">Return the verified save receipt unchanged.</exit>
</scenario>

Lilith/Core/Workspace 1 files

ModifiedWorkspaceSession.cs5,574 B / 108 lines+1-0+59 B
C#UnchangedRemoved / replacedAdded
using System.Collections.Concurrent;
using System.Text.Json;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Workspace;

/// <summary>Owns workspace serialization, immutable configuration and host approval capabilities.</summary>
public sealed class WorkspaceSession
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new(StringComparer.OrdinalIgnoreCase);
    private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
    private readonly HashSet<string> _authorized = new(StringComparer.Ordinal);
    public WorkspaceOptions Options { get; }

    public WorkspaceSession(WorkspaceOptions options)
    {
        if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown workspace mode.");
        Options = options with { RootDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.RootDirectory)) };
    }

    public IDisposable Authorize(string proposalId)
    {
        Options.GetMetadataPath("pending", proposalId);
        lock (_authorized)
            if (!_authorized.Add(proposalId)) throw new InvalidOperationException("Approval already in use.");
        return new Authorization(this, proposalId);
    }

    private sealed class Authorization(WorkspaceSession owner, string id) : IDisposable
    {
        public void Dispose() { lock (owner._authorized) owner._authorized.Remove(id); }
    }

    public async Task<WorkspaceResult> ExecuteAsync(WorkspaceRequest request, CancellationToken ct = default)
    {
        var gate = Gates.GetOrAdd(Options.RootDirectory, _ => new SemaphoreSlim(1, 1));
        await gate.WaitAsync(ct);
        try
        {
            Options.RootDirectory.EnsureNoLinks();
            string metadata = Options.GetMetadataDirectory();
            metadata.EnsureNoLinks();
            Directory.CreateDirectory(metadata);
            using var lease = new FileStream(Path.Combine(metadata, "lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
            ct.ThrowIfCancellationRequested();
            if (request.Offset < 0 || request.Offset > 1000000) throw new ArgumentException("Invalid offset.");

            return request.Operation switch
            {
                "pending" => request.ListPending(Options),
                "bin_list" => request.ListBin(Options),
                "read" => await request.ReadAsync(Options, ct),
                "list" => request.ListEntries(Options),
                "search" => await request.SearchAsync(Options, ct),
                "exists" => request.CheckExists(Options),
                "reject" => request.RejectProposal(Options),
                "approve" => await ApproveAsync(request.BinId, ct),
                "save" or "mkdir" or "move" or "bin" or "restore" => await ExecuteMutationAsync(request, null, ct),
                _ => throw new ArgumentException("Unknown workspace operation.")
            };
        }
        finally { gate.Release(); }
    }

    private async Task<WorkspaceResult> ApproveAsync(string proposalId, CancellationToken ct)
    {
        lock (_authorized)
            if (!_authorized.Remove(proposalId)) throw new InvalidOperationException("Explicit host approval is required.");

        string receipt = Options.GetMetadataPath("receipts", proposalId) + ".json";
        if (File.Exists(receipt)) return JsonSerializer.Deserialize<WorkspaceResult>(File.ReadAllText(receipt))!;

        string file = Options.GetMetadataPath("pending", proposalId) + ".json";
        var proposal = JsonSerializer.Deserialize<WorkspaceProposal>(await File.ReadAllTextAsync(file, ct))!;
        if (proposal.Request.Snapshot(Options) != proposal.Before)
            throw new IOException("The target changed. Reject this proposal and prepare a new one.");

        return await ExecuteMutationAsync(proposal.Request, proposalId, ct);
    }

    private async Task<WorkspaceResult> ExecuteMutationAsync(WorkspaceRequest request, string? proposalId, CancellationToken ct)
    {
        if (request.Operation is not ("save" or "mkdir" or "move" or "bin" or "restore"))
            throw new ArgumentException("Unknown workspace operation.");
        request.ValidateMutation(Options);
        return (Options.Mode, proposalId) switch
        {
            (WorkspaceAccessMode.Ask, null) => await PrepareProposalAsync(request, ct),
            _ => await request.CommitMutationAsync(Options, proposalId)
        };
    }

    private async Task<WorkspaceResult> PrepareProposalAsync(WorkspaceRequest request, CancellationToken ct)
    {
        string id = Guid.NewGuid().ToString("N");
        var proposal = new WorkspaceProposal(id, request, request.Snapshot(Options), DateTimeOffset.UtcNow);
        await (Options.GetMetadataPath("pending", id) + ".json").WriteAtomicallyAsync(JsonSerializer.Serialize(proposal, Json), ct);
        return new("needs_approval",
            $"Approve {request.Operation}: {request.Path}{request.Destination}. Review with /workspace review {id}; then /approve {id} or /reject {id}.",
            id, new { request.Operation, request.Path, request.Destination, content_preview = request.Content[..Math.Min(400, request.Content.Length)] });
    }

    public WorkspaceProposal Review(string id)
    {
        Options.GetMetadataDirectory().EnsureNoLinks();
        return JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(Options.GetMetadataPath("pending", id) + ".json"))!;
    }
}

Lilith/Core/Workspace/Extensions 1 files

ModifiedWorkspaceReadExtensions.cs5,192 B / 71 lines+5-0+333 B
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;

namespace Lilith.Core.Workspace.Extensions;

// Operation helpers are called under the session's gate and exclusive workspace lease.
internal static class WorkspaceReadExtensions
{
    internal static WorkspaceResult CheckExists(this WorkspaceRequest request, WorkspaceOptions options)
    {
        string path = options.ResolvePath(request.Path);
        return new("completed", "Workspace existence checked.", Data: new { path = request.Path, exists = File.Exists(path) || Directory.Exists(path) });
    }
    internal static WorkspaceResult ListPending(this WorkspaceRequest request, WorkspaceOptions options)
    {
        string pending = Path.Combine(options.GetMetadataDirectory(), "pending");
        var proposals = Directory.Exists(pending) ? Directory.EnumerateFiles(pending, "*.json").Order().Skip(request.Offset).Take(20)
            .Select(p => JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(p))!).ToArray() : [];
        return new("completed", "Pending proposals (use host approval commands).", Data: proposals.Select(p => new { p.Id, p.Request.Operation, p.Request.Path, p.Request.Destination, p.CreatedAt }));
    }

    internal static WorkspaceResult ListBin(this WorkspaceRequest request, WorkspaceOptions options)
    {
        string bin = Path.Combine(options.GetMetadataDirectory(), "bin");
        return new("completed", "Workspace bin entries.", Data: Directory.Exists(bin) ? Directory.EnumerateDirectories(bin).Where(p => File.Exists(Path.Combine(p, "item")) || Directory.Exists(Path.Combine(p, "item"))).Order().Skip(request.Offset).Take(20)
            .Select(p => new { id = Path.GetFileName(p), original_path = options.GetOriginalBinPath(Path.GetFileName(p)) }).ToArray() : []);
    }

    internal static async Task<WorkspaceResult> ReadAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
    {
        string path = options.ResolvePath(request.Path);
        if (!path.IsTextFile()) throw new IOException("Text reading supports Markdown, TXT, JSON, CSV, XML and HTML. PDF/Office extraction is not implemented.");
        if (new FileInfo(path).Length > 1000000) throw new IOException("File exceeds the text-read limit.");
        string text = await File.ReadAllTextAsync(path, new UTF8Encoding(false, true), ct);
        int start = Math.Min(request.Offset, text.Length), length = Math.Min(4000, text.Length - start);
        return new("completed", "Read workspace text.", Data: new { path = request.Path, text = text.Substring(start, length), next_offset = start + length < text.Length ? start + length : (int?)null });
    }

    internal static WorkspaceResult ListEntries(this WorkspaceRequest request, WorkspaceOptions options)
    {
        string directory = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
        var items = Directory.EnumerateFileSystemEntries(directory).Where(p => !Path.GetFileName(p).StartsWith('.')).Order().Skip(request.Offset).Take(21).ToArray();
        foreach (string item in items) item.EnsureNoLinks();
        return new("completed", "Workspace folder entries.", Data: new { matches = items.Take(20).Select(p => new { path = Path.GetRelativePath(options.RootDirectory, p).Replace('\\', '/'), folder = Directory.Exists(p), excerpt = (string?)null }), next_offset = items.Length > 20 ? request.Offset + 20 : (int?)null });
    }

    internal static async Task<WorkspaceResult> SearchAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Query) || request.Query.Length > 200) throw new ArgumentException("Supply a query up to 200 characters.");
        string scope = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
        var matches = new List<object>(); int found = 0;
        foreach (string item in scope.EnumerateEntries(2000))
        {
            ct.ThrowIfCancellationRequested();
            string relative = Path.GetRelativePath(options.RootDirectory, item).Replace('\\', '/');
            string? excerpt = null;
            if (File.Exists(item) && new FileInfo(item).Length <= 1000000 && item.IsTextFile())
            {
                string text = await File.ReadAllTextAsync(item, new UTF8Encoding(false, true), ct);
                int index = text.IndexOf(request.Query, StringComparison.OrdinalIgnoreCase);
                if (index >= 0) excerpt = text.Substring(Math.Max(0, index - 40), Math.Min(200, text.Length - Math.Max(0, index - 40)));
            }
            if (!relative.Contains(request.Query, StringComparison.OrdinalIgnoreCase) && excerpt is null) continue;
            if (found++ < request.Offset) continue;
            matches.Add(new { path = relative, folder = Directory.Exists(item), excerpt });
            if (matches.Count == 20) break;
        }
        return new("completed", "Workspace search results; use read for selected excerpts.", Data: new { matches, next_offset = matches.Count == 20 ? request.Offset + 20 : (int?)null });
    }

}