V18 / 8d896b8a86e5
ScenarioRunPlan.cs
Lilith/Core/Scenarios/ScenarioRunPlan.cs
Changed in this versionCS
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.");
}
}