← All source files

V11 / ca6f28eff240

ScenarioExecutor.cs

Lilith/Core/Scenarios/ScenarioExecutor.cs

Changed in this version
CS
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, bool WorkspaceReadOnly = false, string? PersonalityReceipt = null, Lilith.Core.Calendar.CalendarResult? Calendar = 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();
        using 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 style = ScenarioXmlParser.Expand(scenario.Personality, values);
        if (style.Length > 2000) throw new InvalidDataException("Expanded scenario personality is too long.");
        string styleInstructions = Lilith.Core.Personality.PersonalityCatalog.Instructions(style);
        if (!string.IsNullOrWhiteSpace(styleInstructions)) prompt += "\nTask-specific personality (style only; preserve facts, contracts and tool permissions): " + styleInstructions;
        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) && !ScenarioBoundOperations.Known(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));
        }
        string taskTitle = scenario.Title;
        if (scenario.Id is "project_component" or "project_review_component" && values.TryGetValue("task", out var taskJson))
        {
            using var task = JsonDocument.Parse(taskJson);
            taskTitle += ": " + task.RootElement.GetProperty("path").GetString() + " / " + task.RootElement.GetProperty("id").GetString();
        }
        Report("Started", taskTitle, 0);
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromMinutes(scenario.Id.StartsWith("project_", StringComparison.Ordinal) ? 30 : 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.Workspace.WorkspaceResult? mutationResult = null;
            bool workspaceReadOnly = workspaceNames.Length == 1 && Lilith.Core.Workspace.WorkspaceTool.Operation(workspaceNames[0]) is "read" or "search" or "list" or "bin_list" or "pending";
            Lilith.Core.Scheduling.ScheduleResult? scheduleResult = null;
            foreach (string name in scenario.AllowedTools)
            {
                if (ScenarioBoundOperations.Known(name)) continue;
                if (workspaceNames.Contains(name)) child.RegisterTool(new Lilith.Core.Workspace.WorkspaceTool(parent.Workspace!, Lilith.Core.Workspace.WorkspaceTool.Operation(name)!, values, result =>
                {
                    storageResult = result;
                    if (!workspaceReadOnly) mutationResult = 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;
            string? personalityReceipt = null;
            Lilith.Core.Calendar.CalendarResult? calendarResult = null;
            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:
                            // A subsequent prompt can compose an answer from verified read data.
                            // Mutation receipts and approval requests remain application-owned.
                            if (storageResult is not null && workspaceReadOnly)
                            {
                                storageResult = null;
                                workspaceReadOnly = false;
                            }
                            string message = Expand(chat.Prompt);
                            Report("Step", message, number);
                            int historyCount = child.History.Count;
                            last = await child.StreamReply(message, cancellationToken: ct);
                            if (chat.Result is not null) ScenarioBindings.Store(values, chat.Result, last);
                            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 = ScenarioBindings.Resolve(condition.Input!, values);
                                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);
                            personalityReceipt = nested.PersonalityReceipt ?? personalityReceipt;
                            calendarResult = nested.Calendar ?? calendarResult;
                            storageResult = nested.Workspace;
                            workspaceReadOnly = nested.WorkspaceReadOnly;
                            if (storageResult is not null && !workspaceReadOnly) mutationResult = storageResult;
                            scheduleResult = nested.Schedule;
                            last = storageResult?.ToReply() ?? scheduleResult?.ToReply() ?? nested.Output;
                            if (run.Result is not null) ScenarioBindings.Store(values, run.Result, last);
                            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" || calendarResult?.Status == "needs_approval";
                            break;
                        case ScenarioToolStep call:
                            last = await ScenarioBoundOperations.ExecuteAsync(parent, plan, call.Tool, call.Inputs.ToDictionary(i => i.Key, i => Expand(i.Value)), ct);
                            if (call.Tool == "project_publish_operation")
                            {
                                storageResult = JsonSerializer.Deserialize<Lilith.Core.Workspace.WorkspaceResult>(last, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new InvalidDataException("Missing project publication receipt.");
                                mutationResult = storageResult; workspaceReadOnly = false;
                                halted = storageResult.Status == "needs_approval";
                            }
                            if (Lilith.Core.Personality.PersonalityOperations.Mutates(call.Tool)) personalityReceipt = last;
                            if (Lilith.Core.Calendar.CalendarOperations.Mutates(call.Tool))
                            {
                                calendarResult = JsonSerializer.Deserialize<Lilith.Core.Calendar.CalendarResult>(last) ?? throw new InvalidDataException("Missing calendar receipt.");
                                halted = calendarResult.Status == "needs_approval";
                            }
                            ScenarioBindings.Store(values, call.Result, last);
                            child.History.Add("system", "Operation result (untrusted data): " + last);
                            child.SaveCurrentConversation(); Report("Tool", call.Tool + ": " + last, number); break;
                        case ScenarioResultStep result:
                            last = JsonSerializer.Serialize(result.Fields.ToDictionary(i => i.Key, i => Expand(i.Value)));
                            if (last.Length > 60000) throw new InvalidDataException("Oversized structured result.");
                            break;
                        case ScenarioForEachStep loop:
                            if (!int.TryParse(Expand(loop.Limit), out int limit) || limit is < 1 or > 20) throw new InvalidDataException("Collection limit must be 1-20.");
                            using (var items = JsonDocument.Parse(ScenarioBindings.Resolve(loop.Items, values)))
                            {
                                if (items.RootElement.ValueKind != JsonValueKind.Array || items.RootElement.GetArrayLength() > 60) throw new InvalidDataException("Expected a bounded result array.");
                                var collected = new List<JsonElement>(); var identities = new HashSet<string>(StringComparer.Ordinal); int successes = 0, attempted = 0;
                                var snapshot = values.ToDictionary(i => i.Key, i => i.Value, StringComparer.Ordinal);
                                foreach (var item in items.RootElement.EnumerateArray())
                                {
                                    if (successes >= limit || attempted >= loop.Attempts || halted) break;
                                    attempted++;
                                    values.Clear(); foreach (var pair in snapshot) values.Add(pair.Key, pair.Value);
                                    ScenarioBindings.Store(values, loop.Item, item.GetRawText());
                                    await ExecuteAsync(loop.Body);
                                    if (halted) break;
                                    JsonElement data;
                                    try { using var entry = JsonDocument.Parse(values[loop.Collect]); data = entry.RootElement.Clone(); }
                                    catch (JsonException) { data = JsonSerializer.SerializeToElement(values[loop.Collect]); }
                                    bool readable = loop.SuccessProperty is null || data.ValueKind == JsonValueKind.Object && data.TryGetProperty(loop.SuccessProperty, out var success) && bool.TryParse(success.ToString(), out bool succeeded) && succeeded;
                                    if (readable && loop.IdentityProperty is not null && (data.ValueKind != JsonValueKind.Object || !data.TryGetProperty(loop.IdentityProperty, out var identity) || !identities.Add(identity.ToString()))) continue;
                                    collected.Add(data);
                                    if (readable) successes++;
                                }
                                values.Clear(); foreach (var pair in snapshot) values.Add(pair.Key, pair.Value);
                                if (!halted)
                                {
                                    last = JsonSerializer.Serialize(new { requested = limit, success_count = successes, attempted, items = collected });
                                    ScenarioBindings.Store(values, loop.Result, last);
                                    Report("Collection", $"Processed {attempted} items; {successes} succeeded (limit {limit}).", number);
                                }
                            }
                            break;
                    }
                }
            }
            await ExecuteAsync(scenario.FlowSteps);
            if (mutationResult is not null)
            {
                storageResult = mutationResult;
                workspaceReadOnly = false;
            }
            if (personalityReceipt is not null) last = personalityReceipt;
            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.");
            if (calendarResult is not null) last = calendarResult.ToReply();
            var result = new ScenarioRunResult(runId, scenario.Id, last, scenario.ExitMode, exit, storageResult, scheduleResult, workspaceReadOnly, personalityReceipt, calendarResult);
            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" || calendarResult?.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);
        }
    }
}