V18 / 8d896b8a86e5
ScenarioExecutor.cs
Lilith/Core/Scenarios/ScenarioExecutor.cs
Changed in this versionCS
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, string? MessagingReceipt = null, Lilith.Core.HomeAutomation.HomeResult? Home = 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();
parent.Kanban?.Boundary();
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;
if(scenario.Id=="addon_write_scenario")child.ResponseSchema=Lilith.Core.Addons.AddonScenarioDraft.Schema;
if(scenario.Id=="addon_write_tool")child.ResponseSchema=Lilith.Core.Addons.AddonToolDraft.Schema;
if(scenario.Id=="image_select")child.ResponseSchema=JsonSerializer.SerializeToElement(new{type="object",properties=new{download_url=new{type="string"},source_url=new{type="string"}},required=new[]{"download_url","source_url"},additionalProperties=false});
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 "memory_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;
string? messagingReceipt = null;
Lilith.Core.HomeAutomation.HomeResult? homeResult = 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:
parent.Kanban?.Boundary();
// 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;
messagingReceipt = nested.MessagingReceipt ?? messagingReceipt;
if (nested.Home is { } nestedHome && (nestedHome.Mutation || homeResult?.Mutation != true)) homeResult = nestedHome;
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" || Lilith.Core.Messaging.MessageOperations.NeedsApproval(messagingReceipt) || homeResult?.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.HomeAutomation.HomeOperations.Names.Contains(call.Tool))
{
var actualHome = JsonSerializer.Deserialize<Lilith.Core.HomeAutomation.HomeResult>(last) ?? throw new InvalidDataException("Missing home receipt.");
if (actualHome.Mutation || homeResult?.Mutation != true) homeResult = actualHome;
halted = homeResult.Status == "needs_approval";
}
if (call.Tool == "message_send_operation") { messagingReceipt = last; halted = Lilith.Core.Messaging.MessageOperations.NeedsApproval(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;
if(parent.ActiveAddonScenarios.Contains(scenario.Id))
{
var actual=child.History.LastOrDefault(m=>m.Role=="tool"&&m.ToolName is {} tool&&parent.IsAddonTool(tool));
last=actual?.Content??JsonSerializer.Serialize(new{status="not_executed",message="The addon did not invoke its compiled tool. No action is claimed.",output=last});
}
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();
if (messagingReceipt is not null && storageResult?.Status != "needs_approval" && scheduleResult?.Status != "needs_approval" && calendarResult?.Status != "needs_approval") last = Lilith.Core.Messaging.MessageReceipt.ToReply(messagingReceipt);
if (homeResult is not null && storageResult?.Status != "needs_approval" && scheduleResult?.Status != "needs_approval" && calendarResult?.Status != "needs_approval" && !Lilith.Core.Messaging.MessageOperations.NeedsApproval(messagingReceipt)) last = homeResult.ToReply();
var result = new ScenarioRunResult(runId, scenario.Id, last, scenario.ExitMode, exit, storageResult, scheduleResult, workspaceReadOnly, personalityReceipt, calendarResult, messagingReceipt, homeResult);
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 or Lilith.Core.Kanban.KanbanYieldException) throw;
throw new ScenarioExecutionException(runId, ex);
}
}
}