V6 / 920e1e35d097
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);
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);
}
}
}