← All source files

V18 / 8d896b8a86e5

ScenarioRunPlan.cs

Lilith/Core/Scenarios/ScenarioRunPlan.cs

Changed in this version
CS
using Lilith.Core.Workspace.Extensions;
using Lilith.Core.Tools;
namespace Lilith.Core.Scenarios;

internal sealed class ScenarioRunPlan(ScenarioFileService files, ProviderClient owner, string? additionalTool = null) : IDisposable
{
    private Lilith.Core.WebResearch.IResearchBrowser? _browser;
    private Lilith.Core.Projects.ProjectGeneration? _projects;
    internal Lilith.Core.Projects.ProjectGeneration Projects => _projects ??= new(owner.HistoryStore.RootDirectory,
        owner.Workspace ?? throw new InvalidOperationException("Project generation requires a configured workspace."));
    internal Lilith.Core.WebResearch.IResearchBrowser Browser => _browser ??= owner.CreateResearchBrowser();
    public void Dispose()
    {
        if (_browser is IDisposable disposable && !ReferenceEquals(_browser, owner.ResearchBrowser)) disposable.Dispose();
    }
    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; }
    private int _runLimit = 32;
    private int _stepLimit = 128;
    internal int Steps { get; private set; }
    internal void CountRun() { if (++Runs > _runLimit) throw new InvalidDataException("Scenario tree exceeds its run limit."); }
    internal void CountStep() { if (++Steps > _stepLimit) throw new InvalidDataException("Scenario tree exceeds its executed step limit."); }
    internal void Validate(ScenarioDefinition root)
    {
        if ((string?)ScenarioXmlParser.Read(root.Xml).Root!.Attribute("schemaVersion") == "3")
        {
            _runLimit = root.Id.StartsWith("project_", StringComparison.Ordinal) ? 128 : 64;
            _stepLimit = root.Id.StartsWith("project_", StringComparison.Ordinal) ? 512 : 256;
            ValidateDynamic(root, new(StringComparer.Ordinal), new HashSet<string>(StringComparer.Ordinal));
            return;
        }
        int nodes = 0;
        void Visit(ScenarioDefinition scenario, HashSet<string> active)
        {
            ValidatePersonalityOperation(scenario);
            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 &&
                    tool != additionalTool && (!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); Expand(scenario.Personality);
            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));
    }

    private void ValidateDynamic(ScenarioDefinition scenario, HashSet<string> active, HashSet<string> visited)
    {
        ValidatePersonalityOperation(scenario);
        if (active.Count >= 8 || !active.Add(scenario.Id)) throw new InvalidDataException("Nested cycle or depth limit.");
        if (visited.Count >= 64 && !visited.Contains(scenario.Id)) throw new InvalidDataException("Too many declared scenarios.");
        visited.Add(scenario.Id);
        foreach (string tool in scenario.AllowedTools)
            if (!ScenarioBoundOperations.Known(tool) && Lilith.Core.Workspace.WorkspaceTool.Operation(tool) is null && Lilith.Core.Scheduling.ScheduleTool.Operation(tool) is null &&
                tool != additionalTool && (!owner.RegisteredTools.TryGetValue(tool, out var registered) || registered is ITurnResultTool)) throw new InvalidDataException("Unavailable scenario tool: " + tool);
        var known = scenario.Inputs.Select(i => i.Name).ToHashSet(StringComparer.Ordinal);
        void Expression(string text)
        {
            foreach (System.Text.RegularExpressions.Match match in System.Text.RegularExpressions.Regex.Matches(text, @"\{\{([A-Za-z][A-Za-z0-9_.-]*)\}\}"))
                if (!known.Contains(match.Groups[1].Value.Split('.')[0])) throw new InvalidDataException("Unknown scenario value: " + match.Groups[1].Value);
        }
        void Declare(string? name)
        {
            if (name is not null && !known.Add(name)) throw new InvalidDataException("Result shadows a declared value: " + name);
        }
        void Sequence(IEnumerable<ScenarioFlowStep> steps)
        {
            foreach (var step in steps)
                switch (step)
                {
                    case ScenarioChatStep chat: Expression(chat.Prompt); Declare(chat.Result); break;
                    case ScenarioToolStep call:
                        if (!scenario.AllowedTools.Contains(call.Tool) || !ScenarioBoundOperations.Known(call.Tool)) throw new InvalidDataException("Direct tool step requires a declared bound operation.");
                        foreach (string value in call.Inputs.Values) Expression(value);
                        Declare(call.Result); break;
                    case ScenarioRunStep run:
                        foreach (string value in run.Inputs.Values) Expression(value);
                        string xml = _sources.GetValueOrDefault(run.Scenario) ?? files.LoadScenarioXml(run.Scenario);
                        _sources[run.Scenario] = xml;
                        var child = ScenarioXmlParser.Parse(xml);
                        if (run.Inputs.Keys.Any(k => !child.Inputs.Any(i => i.Name == k)) || child.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(i.Value) && !run.Inputs.ContainsKey(i.Name))) throw new InvalidDataException("Invalid nested input contract.");
                        ValidateDynamic(child, new(active, StringComparer.Ordinal), visited); Declare(run.Result); break;
                    case ScenarioResultStep result: foreach (string value in result.Fields.Values) Expression(value); break;
                    case ScenarioForEachStep loop:
                        Expression("{{" + loop.Items + "}}"); Expression(loop.Limit);
                        var before = known.ToHashSet(StringComparer.Ordinal);
                        Declare(loop.Item); Sequence(loop.Body);
                        if (!known.Contains(loop.Collect)) throw new InvalidDataException("Unknown collected result.");
                        known.Clear(); known.UnionWith(before); Declare(loop.Result); break;
                    case ScenarioIfStep condition:
                        if (condition.WorkspacePath is not null) Expression(condition.WorkspacePath);
                        if (condition.Input is not null) Expression("{{" + condition.Input + "}}");
                        if (condition.EqualsValue is not null) Expression(condition.EqualsValue);
                        if (condition.NotEqualsValue is not null) Expression(condition.NotEqualsValue);
                        var scope = known.ToHashSet(StringComparer.Ordinal);
                        Sequence(condition.Then); known.Clear(); known.UnionWith(scope);
                        Sequence(condition.Else); known.Clear(); known.UnionWith(scope); break;
                }
        }
        Expression(scenario.SystemPrompt); Expression(scenario.ExitRequest); Expression(scenario.Personality); Sequence(scenario.FlowSteps);
    }
    private void ValidatePersonalityOperation(ScenarioDefinition scenario)
    {
        if (scenario.AllowedTools.Any(Lilith.Core.HomeAutomation.HomeOperations.Mutates) &&
            (owner.Home is null || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0 ||
             scenario.FlowSteps is not [ScenarioToolStep homeCall] || homeCall.Tool != scenario.AllowedTools[0] || scenario.ExitMode != "copy"))
            throw new InvalidDataException("Home writes require a configured registry, one bound operation and copy exit.");
        if (scenario.AllowedTools.Contains("message_send_operation") &&
            (owner.Workspace is null || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0 ||
             scenario.FlowSteps is not [ScenarioToolStep send] || send.Tool != "message_send_operation" || scenario.ExitMode != "copy"))
            throw new InvalidDataException("Message sending requires boot-fixed mode, one bound operation and exact-copy exit.");
        if (scenario.AllowedTools.Any(Lilith.Core.Projects.ProjectOperations.Names.Contains))
        {
            if (owner.Workspace is null) throw new InvalidDataException("Project scenarios require a configured workspace.");
            _runLimit = Math.Max(_runLimit, 128); _stepLimit = Math.Max(_stepLimit, 512);
        }
        if (scenario.AllowedTools.Any(Lilith.Core.Calendar.CalendarOperations.Mutates) &&
            (owner.Calendar is null || owner.ExecutingScheduledJob || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0 ||
             scenario.FlowSteps is not [ScenarioToolStep calendarCall] || calendarCall.Tool != scenario.AllowedTools[0] || scenario.ExitMode != "copy"))
            throw new InvalidDataException("Calendar mutations require a configured calendar, one direct operation and copy exit; scheduled actions cannot manage calendar events.");
        if (!scenario.AllowedTools.Any(Lilith.Core.Personality.PersonalityOperations.Names.Contains)) return;
        if (owner.Personality is null || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0 ||
            scenario.FlowSteps is not [ScenarioToolStep call] || call.Tool != scenario.AllowedTools[0] || scenario.ExitMode != "copy")
            throw new InvalidDataException("Profile scenarios require a configured profile, one declared direct operation and an exact-copy exit.");
    }
}