V6 / CHANGESET
Only the delta.
The exact changes from V5 to V6. Unchanged files are excluded. Nothing from a later version is included.
Green + / red - count added and removed lines; signed bytes show the file size change. UTF-8 file sizes and total lines in this snapshot; deleted files use the previous snapshot. Sorting stays within each folder.
Repository root 1 files
ModifiedVersion.props215 B / 4 lines+2-2+10 B+
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- V6 conditional and nested scenarios. V5 alpha is preserved separately. -->
<PropertyGroup><VersionPrefix>6.0.0</VersionPrefix><VersionSuffix>alpha.2</VersionSuffix></PropertyGroup>
</Project>
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- V5 scheduling development. V4 alpha is preserved separately. -->
<PropertyGroup><VersionPrefix>5.0.0</VersionPrefix><VersionSuffix>alpha.2</VersionSuffix></PropertyGroup>
</Project>
Lilith/Core/Scenarios 6 files
ModifiedRunScenarioTool.cs6,550 B / 87 lines+1-1+7 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Tools;
namespace Lilith.Core.Scenarios;
/// <summary>Loads trusted saved XML, fills values as data, and passes the complete document to the executor.</summary>
public sealed class RunScenarioTool(ProviderClient parent, ScenarioFileService files) : ITurnResultTool
{
public string Name => "run_scenario";
public string Description => "Run a named saved scenario in a fresh sub-agent conversation. " +
"Use this when the user says run X scenario. Supply input values in inputs. " +
"If status is needs_input, ask the user for the missing values before retrying. " +
"Do not retry a failed scenario without a new user request. " +
"For exit mode respond, follow exit_request when composing the final answer. " +
"For workspace data management, always delegate to a workspace scenario. Search with workspace_search and read only selected excerpts. " +
"Never approve a workspace proposal yourself or change access mode. " +
"For timers and cron jobs use schedule_create/update/cancel/delete scenarios. Use schedule_peek(name) for remaining time and schedule_list for all schedules. Never approve schedule proposals yourself. " +
"Schedule create/update take name, kind (timer, interval or cron), seconds (whole seconds for timer/interval), cron (five fields), timezone (zone ID), message (reminder), scenario (optional saved action) and inputs_json (JSON string object for its inputs). Updates replace the complete definition. " +
"Available scenarios and input names: " + string.Join("; ", files.ListNames().Select(name =>
{ var scenario = ScenarioXmlParser.Parse(files.LoadScenarioXml(name)); return name + "(" + string.Join(", ", scenario.Inputs.Select(i => i.Name + (i.Required ? " required" : ""))) + ")"; }));
public JsonElement Parameters => JsonSerializer.SerializeToElement(new
{
type = "object", properties = new
{
scenario_name = new { type = "string", description = "Saved scenario name, without .xml." },
inputs = new { type = "object", additionalProperties = new { type = "string" }, description = "Named scenario input values." }
}, required = new[] { "scenario_name" }, additionalProperties = false
});
public TimeSpan ExecutionTimeout => TimeSpan.FromMinutes(10);
public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
(await ExecuteResultAsync(arguments, cancellationToken)).Content;
public async Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
{
if (arguments.ValueKind != JsonValueKind.Object || !arguments.TryGetProperty("scenario_name", out var name) || name.ValueKind != JsonValueKind.String ||
arguments.EnumerateObject().Any(p => p.Name is not ("scenario_name" or "inputs")))
throw new ArgumentException("Expected scenario_name and optional inputs.");
string xml = files.LoadScenarioXml(name.GetString()!);
var definition = ScenarioXmlParser.Parse(xml);
var supplied = new Dictionary<string, string>(StringComparer.Ordinal);
if (arguments.TryGetProperty("inputs", out var inputValues))
{
if (inputValues.ValueKind != JsonValueKind.Object) throw new ArgumentException("inputs must be an object of string values.");
foreach (var property in inputValues.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.String || !definition.Inputs.Any(i => i.Name == property.Name) ||
!supplied.TryAdd(property.Name, property.Value.GetString()!) || supplied[property.Name].Length > 20000)
throw new ArgumentException("Unknown, duplicate, or invalid scenario input: " + property.Name);
}
}
var document = ScenarioXmlParser.Read(xml);
foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
if (supplied.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
string fullXml = document.ToString(SaveOptions.DisableFormatting);
var filled = ScenarioXmlParser.Parse(fullXml);
string[] missing = filled.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
if (missing.Length > 0)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "needs_input", scenario = definition.Id, missing,
request = "Ask the user for these values. No scenario steps have run." }),
"Please provide the following values for the " + definition.Title + " scenario: " + string.Join(", ", missing) + ".");
ScenarioRunResult run;
try { run = await new ScenarioExecutor(parent, files).RunXmlAsync(fullXml, cancellationToken); }
catch (ScenarioExecutionException ex)
{
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "failed", run_id = ex.RunId, error = ex.Message }));
}
if (run.Schedule is { } schedule)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = schedule.Status, run_id = run.RunId, result = schedule,
exit_mode = run.ExitMode, exit_request = run.ExitRequest }), schedule.Status == "needs_approval" || run.ExitMode == "copy" ? schedule.ToReply() : null);
if (run.Workspace is { } storage)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = storage.Status, run_id = run.RunId, result = storage,
exit_mode = run.ExitMode, exit_request = run.ExitRequest }),
storage.Status == "needs_approval" || run.ExitMode == "copy" ? storage.ToReply() : null);
return new ToolExecutionResult(JsonSerializer.Serialize(new
{
status = "completed", run_id = run.RunId, scenario = run.ScenarioId, output = run.Output,
exit_mode = run.ExitMode, exit_request = run.ExitRequest
}), run.ExitMode == "copy" ? run.Output : null);
}
}
public static class ScenarioTools
{
public static void Register(ProviderClient provider, string? scenarioRoot = null) =>
provider.RegisterTool(new RunScenarioTool(provider, new ScenarioFileService(scenarioRoot)));
}
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Tools;
namespace Lilith.Core.Scenarios;
/// <summary>Loads trusted saved XML, fills values as data, and passes the complete document to the executor.</summary>
public sealed class RunScenarioTool(ProviderClient parent, ScenarioFileService files) : ITurnResultTool
{
public string Name => "run_scenario";
public string Description => "Run a named saved scenario in a fresh sub-agent conversation. " +
"Use this when the user says run X scenario. Supply input values in inputs. " +
"If status is needs_input, ask the user for the missing values before retrying. " +
"Do not retry a failed scenario without a new user request. " +
"For exit mode respond, follow exit_request when composing the final answer. " +
"For workspace data management, always delegate to a workspace scenario. Search with workspace_search and read only selected excerpts. " +
"Never approve a workspace proposal yourself or change access mode. " +
"For timers and cron jobs use schedule_create/update/cancel/delete scenarios. Use schedule_peek(name) for remaining time and schedule_list for all schedules. Never approve schedule proposals yourself. " +
"Schedule create/update take name, kind (timer, interval or cron), seconds (whole seconds for timer/interval), cron (five fields), timezone (zone ID), message (reminder), scenario (optional saved action) and inputs_json (JSON string object for its inputs). Updates replace the complete definition. " +
"Available scenarios and input names: " + string.Join("; ", files.ListNames().Select(name =>
{ var scenario = ScenarioXmlParser.Parse(files.LoadScenarioXml(name)); return name + "(" + string.Join(", ", scenario.Inputs.Select(i => i.Name + (i.Required ? " required" : ""))) + ")"; }));
public JsonElement Parameters => JsonSerializer.SerializeToElement(new
{
type = "object", properties = new
{
scenario_name = new { type = "string", description = "Saved scenario name, without .xml." },
inputs = new { type = "object", additionalProperties = new { type = "string" }, description = "Named scenario input values." }
}, required = new[] { "scenario_name" }, additionalProperties = false
});
public TimeSpan ExecutionTimeout => TimeSpan.FromMinutes(10);
public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
(await ExecuteResultAsync(arguments, cancellationToken)).Content;
public async Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
{
if (arguments.ValueKind != JsonValueKind.Object || !arguments.TryGetProperty("scenario_name", out var name) || name.ValueKind != JsonValueKind.String ||
arguments.EnumerateObject().Any(p => p.Name is not ("scenario_name" or "inputs")))
throw new ArgumentException("Expected scenario_name and optional inputs.");
string xml = files.LoadScenarioXml(name.GetString()!);
var definition = ScenarioXmlParser.Parse(xml);
var supplied = new Dictionary<string, string>(StringComparer.Ordinal);
if (arguments.TryGetProperty("inputs", out var inputValues))
{
if (inputValues.ValueKind != JsonValueKind.Object) throw new ArgumentException("inputs must be an object of string values.");
foreach (var property in inputValues.EnumerateObject())
{
if (property.Value.ValueKind != JsonValueKind.String || !definition.Inputs.Any(i => i.Name == property.Name) ||
!supplied.TryAdd(property.Name, property.Value.GetString()!) || supplied[property.Name].Length > 20000)
throw new ArgumentException("Unknown, duplicate, or invalid scenario input: " + property.Name);
}
}
var document = ScenarioXmlParser.Read(xml);
foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
if (supplied.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
string fullXml = document.ToString(SaveOptions.DisableFormatting);
var filled = ScenarioXmlParser.Parse(fullXml);
string[] missing = filled.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
if (missing.Length > 0)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "needs_input", scenario = definition.Id, missing,
request = "Ask the user for these values. No scenario steps have run." }),
"Please provide the following values for the " + definition.Title + " scenario: " + string.Join(", ", missing) + ".");
ScenarioRunResult run;
try { run = await new ScenarioExecutor(parent).RunXmlAsync(fullXml, cancellationToken); }
catch (ScenarioExecutionException ex)
{
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "failed", run_id = ex.RunId, error = ex.Message }));
}
if (run.Schedule is { } schedule)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = schedule.Status, run_id = run.RunId, result = schedule,
exit_mode = run.ExitMode, exit_request = run.ExitRequest }), schedule.Status == "needs_approval" || run.ExitMode == "copy" ? schedule.ToReply() : null);
if (run.Workspace is { } storage)
return new ToolExecutionResult(JsonSerializer.Serialize(new { status = storage.Status, run_id = run.RunId, result = storage,
exit_mode = run.ExitMode, exit_request = run.ExitRequest }),
storage.Status == "needs_approval" || run.ExitMode == "copy" ? storage.ToReply() : null);
return new ToolExecutionResult(JsonSerializer.Serialize(new
{
status = "completed", run_id = run.RunId, scenario = run.ScenarioId, output = run.Output,
exit_mode = run.ExitMode, exit_request = run.ExitRequest
}), run.ExitMode == "copy" ? run.Output : null);
}
}
public static class ScenarioTools
{
public static void Register(ProviderClient provider, string? scenarioRoot = null) =>
provider.RegisterTool(new RunScenarioTool(provider, new ScenarioFileService(scenarioRoot)));
}
ModifiedScenarioDefinition.cs7,428 B / 117 lines+14-7+234 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
namespace Lilith.Core.Scenarios;
public sealed record ScenarioProgress(string RunId, string ScenarioId, string Kind, string Text, int Step, int TotalSteps);
public sealed record ScenarioInput(string Name, bool Required, string? Value);
public sealed record ScenarioCodeTool(string Name, string Description, JsonElement Parameters, string Code);
public sealed record ScenarioDefinition(string Id, string Title, string SystemPrompt, ScenarioInput[] Inputs,
string[] AllowedTools, ScenarioCodeTool[] CodeTools, string[] Steps, string ExitMode, string ExitRequest, string Xml)
{
public ScenarioFlowStep[] FlowSteps { get; init; } = [];
}
public static class ScenarioXmlParser
{
internal static bool ValidName(string name) => Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,63}$", RegexOptions.CultureInvariant);
internal static XDocument Read(string xml)
{
using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
MaxCharactersInDocument = 1_000_000
});
var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
if (document.Descendants().Any(e => e.Ancestors().Count() > 12)) throw new InvalidDataException("Scenario XML is too deep.");
return document;
}
public static ScenarioDefinition Parse(string xml)
{
var root = Read(xml).Root ?? throw new InvalidDataException("Missing scenario root.");
Shape(root, "scenario", ["schemaVersion", "id", "title"], ["inputs", "systemPrompt", "tools", "steps", "exit"]);
string schemaVersion = (string?)root.Attribute("schemaVersion") ?? "";
if (schemaVersion is not ("1" or "2")) throw new InvalidDataException("Unsupported scenario schemaVersion.");
string id = Attribute(root, "id"), title = Attribute(root, "title");
if (!ValidName(id) || title.Length > 200) throw new InvalidDataException("Invalid scenario identity.");
var inputs = new List<ScenarioInput>();
if (root.Element("inputs") is { } inputRoot)
{
Shape(inputRoot, "inputs", [], ["input"]);
foreach (var input in inputRoot.Elements())
{
Shape(input, "input", ["name", "required", "default", "value"], []);
string name = Attribute(input, "name");
if (!ValidName(name) || inputs.Any(i => i.Name == name)) throw new InvalidDataException("Invalid or duplicate input name.");
string requiredText = (string?)input.Attribute("required") ?? "false";
if (!bool.TryParse(requiredText, out bool required)) throw new InvalidDataException("Invalid required flag.");
inputs.Add(new ScenarioInput(name, required, (string?)input.Attribute("value") ?? (string?)input.Attribute("default")));
}
}
var allowed = new List<string>();
var codeTools = new List<ScenarioCodeTool>();
if (root.Element("tools") is { } tools)
{
Shape(tools, "tools", [], ["allow", "csharp"]);
foreach (var tool in tools.Elements())
{
string name = Attribute(tool, "name");
if (!ValidName(name) || name == "run_scenario" || allowed.Contains(name) || codeTools.Any(t => t.Name == name))
throw new InvalidDataException("Invalid, duplicate, or nested scenario tool.");
if (tool.Name == "allow")
{
Shape(tool, "allow", ["name"], []);
allowed.Add(name);
}
else
{
Shape(tool, "csharp", ["name", "description"], ["parameters", "code"]);
var parameters = RequiredElement(tool, "parameters");
Shape(parameters, "parameters", [], []);
var schema = JsonSerializer.Deserialize<JsonElement>(Text(parameters));
if (schema.ValueKind != JsonValueKind.Object || !schema.TryGetProperty("type", out var type) || type.GetString() != "object")
throw new InvalidDataException("Tool parameters must be an object schema.");
var code = RequiredElement(tool, "code");
Shape(code, "code", [], []);
if (!code.Nodes().Any(n => n is XCData)) throw new InvalidDataException("C# code must use CDATA.");
codeTools.Add(new ScenarioCodeTool(name, Attribute(tool, "description"), schema, Text(code)));
}
}
}
if (inputs.Count > 32 || allowed.Count + codeTools.Count > 16) throw new InvalidDataException("Too many scenario inputs/tools.");
var prompt = RequiredElement(root, "systemPrompt");
Shape(prompt, "systemPrompt", [], []);
var stepsRoot = RequiredElement(root, "steps");
Shape(stepsRoot, "steps", [], schemaVersion == "1" ? ["step"] : ["step", "if", "run"]);
var flow = ScenarioFlowParser.Parse(stepsRoot);
string[] steps = flow.Select(step => step.Description).ToArray();
if (steps.Length is < 1 or > 20) throw new InvalidDataException("Supply 1-20 ordered scenario steps.");
var exit = RequiredElement(root, "exit");
Shape(exit, "exit", ["mode"], []);
string mode = Attribute(exit, "mode");
if (mode is not ("copy" or "respond")) throw new InvalidDataException("Exit mode must be copy or respond.");
return new ScenarioDefinition(id, title, Text(prompt), inputs.ToArray(), allowed.ToArray(), codeTools.ToArray(), steps, mode, Text(exit), xml) { FlowSteps = flow };
}
internal static string Expand(string text, IReadOnlyDictionary<string, string> values) => Regex.Replace(text,
@"\{\{([A-Za-z][A-Za-z0-9_-]{0,63})\}\}", match => values.TryGetValue(match.Groups[1].Value, out var value)
? value : throw new InvalidDataException("Unknown scenario input: " + match.Groups[1].Value));
private static XElement RequiredElement(XElement root, string name) => root.Element(name) ?? throw new InvalidDataException("Missing " + name + ".");
private static string Attribute(XElement element, string name) => !string.IsNullOrWhiteSpace((string?)element.Attribute(name))
? element.Attribute(name)!.Value : throw new InvalidDataException("Missing " + name + ".");
private static string Text(XElement element) => element.Value.Trim() is { Length: > 0 and <= 20000 } value
? value : throw new InvalidDataException("Invalid " + element.Name + " text.");
internal static void Shape(XElement element, string name, string[] attributes, string[] children)
{
if (element.Name != name || element.Attributes().Any(a => !attributes.Contains(a.Name.ToString())) ||
element.Elements().Any(e => !children.Contains(e.Name.ToString()))) throw new InvalidDataException("Unknown XML in " + name + ".");
if (name is "scenario" or "csharp" && element.Elements().GroupBy(e => e.Name).Any(g => g.Count() > 1))
throw new InvalidDataException("Duplicate section in " + name + ".");
}
}
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
namespace Lilith.Core.Scenarios;
public sealed record ScenarioProgress(string RunId, string ScenarioId, string Kind, string Text, int Step, int TotalSteps);
public sealed record ScenarioInput(string Name, bool Required, string? Value);
public sealed record ScenarioCodeTool(string Name, string Description, JsonElement Parameters, string Code);
public sealed record ScenarioDefinition(string Id, string Title, string SystemPrompt, ScenarioInput[] Inputs,
string[] AllowedTools, ScenarioCodeTool[] CodeTools, string[] Steps, string ExitMode, string ExitRequest, string Xml);
public static class ScenarioXmlParser
{
internal static bool ValidName(string name) => Regex.IsMatch(name, "^[A-Za-z][A-Za-z0-9_-]{0,63}$", RegexOptions.CultureInvariant);
internal static XDocument Read(string xml)
{
using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 1_000_000
});
var document = XDocument.Load(reader, LoadOptions.PreserveWhitespace);
if (document.Descendants().Any(e => e.Ancestors().Count() > 12)) throw new InvalidDataException("Scenario XML is too deep.");
return document;
}
public static ScenarioDefinition Parse(string xml)
{
var root = Read(xml).Root ?? throw new InvalidDataException("Missing scenario root.");
Shape(root, "scenario", ["schemaVersion", "id", "title"], ["inputs", "systemPrompt", "tools", "steps", "exit"]);
if ((string?)root.Attribute("schemaVersion") != "1") throw new InvalidDataException("Unsupported scenario schemaVersion.");
string id = Attribute(root, "id"), title = Attribute(root, "title");
if (!ValidName(id) || title.Length > 200) throw new InvalidDataException("Invalid scenario identity.");
var inputs = new List<ScenarioInput>();
if (root.Element("inputs") is { } inputRoot)
{
Shape(inputRoot, "inputs", [], ["input"]);
foreach (var input in inputRoot.Elements())
{
Shape(input, "input", ["name", "required", "default", "value"], []);
string name = Attribute(input, "name");
if (!ValidName(name) || inputs.Any(i => i.Name == name)) throw new InvalidDataException("Invalid or duplicate input name.");
string requiredText = (string?)input.Attribute("required") ?? "false";
if (!bool.TryParse(requiredText, out bool required)) throw new InvalidDataException("Invalid required flag.");
inputs.Add(new ScenarioInput(name, required, (string?)input.Attribute("value") ?? (string?)input.Attribute("default")));
}
}
var allowed = new List<string>();
var codeTools = new List<ScenarioCodeTool>();
if (root.Element("tools") is { } tools)
{
Shape(tools, "tools", [], ["allow", "csharp"]);
foreach (var tool in tools.Elements())
{
string name = Attribute(tool, "name");
if (!ValidName(name) || name == "run_scenario" || allowed.Contains(name) || codeTools.Any(t => t.Name == name))
throw new InvalidDataException("Invalid, duplicate, or nested scenario tool.");
if (tool.Name == "allow")
{
Shape(tool, "allow", ["name"], []);
allowed.Add(name);
}
else
{
Shape(tool, "csharp", ["name", "description"], ["parameters", "code"]);
var parameters = RequiredElement(tool, "parameters");
Shape(parameters, "parameters", [], []);
var schema = JsonSerializer.Deserialize<JsonElement>(Text(parameters));
if (schema.ValueKind != JsonValueKind.Object || !schema.TryGetProperty("type", out var type) || type.GetString() != "object")
throw new InvalidDataException("Tool parameters must be an object schema.");
var code = RequiredElement(tool, "code");
Shape(code, "code", [], []);
if (!code.Nodes().Any(n => n is XCData)) throw new InvalidDataException("C# code must use CDATA.");
codeTools.Add(new ScenarioCodeTool(name, Attribute(tool, "description"), schema, Text(code)));
}
}
}
if (inputs.Count > 32 || allowed.Count + codeTools.Count > 16) throw new InvalidDataException("Too many scenario inputs/tools.");
var prompt = RequiredElement(root, "systemPrompt");
Shape(prompt, "systemPrompt", [], []);
var stepsRoot = RequiredElement(root, "steps");
Shape(stepsRoot, "steps", [], ["step"]);
string[] steps = stepsRoot.Elements().Select(step => { Shape(step, "step", [], []); return Text(step); }).ToArray();
if (steps.Length is < 1 or > 20) throw new InvalidDataException("Supply 1-20 ordered scenario steps.");
var exit = RequiredElement(root, "exit");
Shape(exit, "exit", ["mode"], []);
string mode = Attribute(exit, "mode");
if (mode is not ("copy" or "respond")) throw new InvalidDataException("Exit mode must be copy or respond.");
return new ScenarioDefinition(id, title, Text(prompt), inputs.ToArray(), allowed.ToArray(), codeTools.ToArray(), steps, mode, Text(exit), xml);
}
internal static string Expand(string text, IReadOnlyDictionary<string, string> values) => Regex.Replace(text,
@"\{\{([A-Za-z][A-Za-z0-9_-]{0,63})\}\}", match => values.TryGetValue(match.Groups[1].Value, out var value)
? value : throw new InvalidDataException("Unknown scenario input: " + match.Groups[1].Value));
private static XElement RequiredElement(XElement root, string name) => root.Element(name) ?? throw new InvalidDataException("Missing " + name + ".");
private static string Attribute(XElement element, string name) => !string.IsNullOrWhiteSpace((string?)element.Attribute(name))
? element.Attribute(name)!.Value : throw new InvalidDataException("Missing " + name + ".");
private static string Text(XElement element) => element.Value.Trim() is { Length: > 0 and <= 20000 } value
? value : throw new InvalidDataException("Invalid " + element.Name + " text.");
private static void Shape(XElement element, string name, string[] attributes, string[] children)
{
if (element.Name != name || element.Attributes().Any(a => !attributes.Contains(a.Name.ToString())) ||
element.Elements().Any(e => !children.Contains(e.Name.ToString()))) throw new InvalidDataException("Unknown XML in " + name + ".");
if (name is "scenario" or "csharp" && element.Elements().GroupBy(e => e.Name).Any(g => g.Count() > 1))
throw new InvalidDataException("Duplicate section in " + name + ".");
}
}
ModifiedScenarioExecutor.cs11,534 B / 156 lines+76-15+4,183 B+
C#UnchangedRemoved / replacedAdded
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);
}
}
}
C#UnchangedRemoved / replacedAdded
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)
{
/// <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 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[] steps = scenario.Steps.Select(s => ScenarioXmlParser.Expand(s, values)).ToArray();
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();
if (scheduleNames.Length > 0 && (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 && (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);
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, steps.Length));
}
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;
for (int i = 0; i < steps.Length; i++)
{
ct.ThrowIfCancellationRequested();
Report("Step", $"{i + 1}/{steps.Length}: {steps[i]}", i + 1);
int historyCount = child.History.Count;
last = await child.StreamReply(steps[i], cancellationToken: ct);
foreach (var toolResult in child.History.Skip(historyCount).Where(m => m.Role == "tool"))
Report("Tool", toolResult.ToolName + ": " + toolResult.Content, i + 1);
Report("Reply", last, i + 1);
}
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);
Report(storageResult?.Status == "needs_approval" || scheduleResult?.Status == "needs_approval" ? "NeedsApproval" : "Completed", "Result returned to caller.", steps.Length);
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);
}
}
}
ModifiedScenarioFileService.cs2,967 B / 45 lines+15-0+1,076 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Scenarios;
/// <summary>Loads trusted local application content, never code supplied by a model tool call.</summary>
public sealed class ScenarioFileService
{
public string RootDirectory { get; }
public ScenarioFileService(string? root = null) => RootDirectory = Path.GetFullPath(root ??
Environment.GetEnvironmentVariable("LILITH_SCENARIOS_ROOT") ?? Path.Combine(AppContext.BaseDirectory, "Scenarios"));
public string LoadScenarioXml(string scenarioName)
{
if (!ScenarioXmlParser.ValidName(scenarioName)) throw new ArgumentException("Invalid scenario name.");
var rootInfo = new DirectoryInfo(RootDirectory);
for (DirectoryInfo? directory = rootInfo; directory is not null; directory = directory.Parent)
if (directory.Exists && directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new InvalidDataException("Scenario roots may not traverse reparse points.");
string path = Path.Combine(RootDirectory, scenarioName + ".xml");
var info = new FileInfo(path);
if (!info.Exists) throw new FileNotFoundException("Unknown scenario: " + scenarioName);
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length > 1_000_000)
throw new InvalidDataException("Invalid scenario file.");
string xml = File.ReadAllText(path);
if (ScenarioXmlParser.Parse(xml).Id != scenarioName) throw new InvalidDataException("Scenario ID must match its filename.");
return xml;
}
public IReadOnlyList<string> ListNames() => Directory.Exists(RootDirectory)
? Directory.EnumerateFiles(RootDirectory, "*.xml").Select(Path.GetFileNameWithoutExtension).OfType<string>()
.Where(ScenarioXmlParser.ValidName).OrderBy(n => n, StringComparer.Ordinal).Take(100).ToArray() : [];
internal string Bind(string name, IReadOnlyDictionary<string, string> values)
=> BindXml(LoadScenarioXml(name), values);
internal static string BindXml(string xml, IReadOnlyDictionary<string, string> values)
{
var definition = ScenarioXmlParser.Parse(xml);
if (values.Any(v => !definition.Inputs.Any(i => i.Name == v.Key) || v.Value.Length > 20000)) throw new ArgumentException("Unknown or oversized nested scenario input.");
var document = ScenarioXmlParser.Read(xml);
foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
if (values.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
var filled = ScenarioXmlParser.Parse(document.ToString());
if (filled.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(i.Value))) throw new ArgumentException("Missing required inputs for nested scenario: " + filled.Id);
return document.ToString(System.Xml.Linq.SaveOptions.DisableFormatting);
}
}
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Scenarios;
/// <summary>Loads trusted local application content, never code supplied by a model tool call.</summary>
public sealed class ScenarioFileService
{
public string RootDirectory { get; }
public ScenarioFileService(string? root = null) => RootDirectory = Path.GetFullPath(root ??
Environment.GetEnvironmentVariable("LILITH_SCENARIOS_ROOT") ?? Path.Combine(AppContext.BaseDirectory, "Scenarios"));
public string LoadScenarioXml(string scenarioName)
{
if (!ScenarioXmlParser.ValidName(scenarioName)) throw new ArgumentException("Invalid scenario name.");
var rootInfo = new DirectoryInfo(RootDirectory);
for (DirectoryInfo? directory = rootInfo; directory is not null; directory = directory.Parent)
if (directory.Exists && directory.Attributes.HasFlag(FileAttributes.ReparsePoint))
throw new InvalidDataException("Scenario roots may not traverse reparse points.");
string path = Path.Combine(RootDirectory, scenarioName + ".xml");
var info = new FileInfo(path);
if (!info.Exists) throw new FileNotFoundException("Unknown scenario: " + scenarioName);
if (info.Attributes.HasFlag(FileAttributes.ReparsePoint) || info.Length > 1_000_000)
throw new InvalidDataException("Invalid scenario file.");
string xml = File.ReadAllText(path);
if (ScenarioXmlParser.Parse(xml).Id != scenarioName) throw new InvalidDataException("Scenario ID must match its filename.");
return xml;
}
public IReadOnlyList<string> ListNames() => Directory.Exists(RootDirectory)
? Directory.EnumerateFiles(RootDirectory, "*.xml").Select(Path.GetFileNameWithoutExtension).OfType<string>()
.Where(ScenarioXmlParser.ValidName).OrderBy(n => n, StringComparer.Ordinal).Take(100).ToArray() : [];
}
NEWScenarioFlowStep.cs4,931 B / 73 lines+73-0+4,931 B+
C#UnchangedRemoved / replacedAdded
using System.Xml.Linq;
namespace Lilith.Core.Scenarios;
public abstract record ScenarioFlowStep(string Description);
public sealed record ScenarioChatStep(string Prompt) : ScenarioFlowStep(Prompt);
public sealed record ScenarioRunStep(string Scenario, IReadOnlyDictionary<string, string> Inputs) : ScenarioFlowStep("Run " + Scenario);
public sealed record ScenarioIfStep(string? Input, string? EqualsValue, string? NotEqualsValue, string? WorkspacePath, bool Exists,
ScenarioFlowStep[] Then, ScenarioFlowStep[] Else) : ScenarioFlowStep("Evaluate condition");
internal static class ScenarioFlowParser
{
internal static ScenarioFlowStep[] Parse(XElement section)
{
ScenarioFlowStep[] ParseSteps(XElement container) => container.Elements().Select(ParseStep).ToArray();
ScenarioFlowStep ParseStep(XElement element)
{
switch (element.Name.LocalName)
{
case "step":
ScenarioXmlParser.Shape(element, "step", [], []);
if (string.IsNullOrWhiteSpace(element.Value) || element.Value.Length > 20000) throw new InvalidDataException("Invalid chat step.");
return new ScenarioChatStep(element.Value.Trim());
case "run":
ScenarioXmlParser.Shape(element, "run", ["scenario"], ["input"]);
string name = (string?)element.Attribute("scenario") ?? "";
if (!ScenarioXmlParser.ValidName(name)) throw new InvalidDataException("Nested run requires a literal saved scenario name.");
var inputs = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var input in element.Elements())
{
ScenarioXmlParser.Shape(input, "input", ["name", "value"], []);
string key = (string?)input.Attribute("name") ?? "";
string? value = (string?)input.Attribute("value");
if (!ScenarioXmlParser.ValidName(key) || value is null || value.Length > 20000 || !inputs.TryAdd(key, value)) throw new InvalidDataException("Invalid nested input.");
}
if (inputs.Count > 32) throw new InvalidDataException("Too many nested inputs.");
return new ScenarioRunStep(name, inputs);
case "if":
ScenarioXmlParser.Shape(element, "if", ["input", "equals", "notEquals", "workspacePath", "exists"], ["then", "else"]);
string? inputName = (string?)element.Attribute("input"), path = (string?)element.Attribute("workspacePath");
string? equal = (string?)element.Attribute("equals"), unequal = (string?)element.Attribute("notEquals");
string? exists = (string?)element.Attribute("exists");
bool expected = true;
if (path is not null ? inputName is not null || equal is not null || unequal is not null || string.IsNullOrWhiteSpace(path) || (exists is not null && !bool.TryParse(exists, out expected))
: inputName is null || !ScenarioXmlParser.ValidName(inputName) || (equal is null) == (unequal is null) || exists is not null)
throw new InvalidDataException("Use either input + equals/notEquals, or workspacePath + optional exists=true/false.");
var then = element.Elements("then").ToArray();
var otherwise = element.Elements("else").ToArray();
if (then.Length != 1 || otherwise.Length > 1) throw new InvalidDataException("A condition needs one then and at most one else.");
foreach (var branch in then.Concat(otherwise))
{
ScenarioXmlParser.Shape(branch, branch.Name.LocalName, [], ["step", "run", "if"]);
if (!branch.Elements().Any()) throw new InvalidDataException("Conditional branches cannot be empty.");
}
return new ScenarioIfStep(inputName, equal, unequal, path, expected, ParseSteps(then[0]), otherwise.Length == 0 ? [] : ParseSteps(otherwise[0]));
default: throw new InvalidDataException("Unknown scenario flow step.");
}
}
var result = ParseSteps(section);
if (Flatten(result).Count() > 64) throw new InvalidDataException("Too many scenario flow steps (maximum 64).");
return result;
}
internal static IEnumerable<ScenarioFlowStep> Flatten(IEnumerable<ScenarioFlowStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step is ScenarioIfStep condition)
foreach (var nested in Flatten(condition.Then.Concat(condition.Else))) yield return nested;
}
}
}
NEWScenarioRunPlan.cs4,174 B / 54 lines+54-0+4,174 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Workspace.Extensions;
using Lilith.Core.Tools;
namespace Lilith.Core.Scenarios;
internal sealed class ScenarioRunPlan(ScenarioFileService files, ProviderClient owner)
{
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; }
internal int Steps { get; private set; }
internal void CountRun() { if (++Runs > 32) throw new InvalidDataException("Scenario tree exceeds 32 runs."); }
internal void CountStep() { if (++Steps > 128) throw new InvalidDataException("Scenario tree exceeds 128 executed steps."); }
internal void Validate(ScenarioDefinition root)
{
int nodes = 0;
void Visit(ScenarioDefinition scenario, HashSet<string> active)
{
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 &&
(!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);
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));
}
}
Lilith/Core/Scenarios/Definitions 1 files
NEWfile_branch_demo.xml900 B / 24 lines+24-0+900 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="2" id="file_branch_demo" title="Two runs, two branches">
<inputs>
<input name="first_path" default="first.txt" />
<input name="second_path" default="second.txt" />
</inputs>
<systemPrompt>Execute the declared condition and nested scenario. Report only verified workspace results.</systemPrompt>
<steps>
<if workspacePath="{{first_path}}" exists="false">
<then>
<run scenario="workspace_save">
<input name="path" value="{{first_path}}" />
<input name="content" value="hello" />
</run>
</then>
<else>
<run scenario="workspace_save">
<input name="path" value="{{second_path}}" />
<input name="content" value="again" />
</run>
</else>
</if>
</steps>
<exit mode="copy">Return the verified save receipt unchanged.</exit>
</scenario>
Lilith/Core/Workspace 1 files
ModifiedWorkspaceSession.cs5,574 B / 108 lines+1-0+59 B+
C#UnchangedRemoved / replacedAdded
using System.Collections.Concurrent;
using System.Text.Json;
using Lilith.Core.Workspace.Extensions;
namespace Lilith.Core.Workspace;
/// <summary>Owns workspace serialization, immutable configuration and host approval capabilities.</summary>
public sealed class WorkspaceSession
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new(StringComparer.OrdinalIgnoreCase);
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
private readonly HashSet<string> _authorized = new(StringComparer.Ordinal);
public WorkspaceOptions Options { get; }
public WorkspaceSession(WorkspaceOptions options)
{
if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown workspace mode.");
Options = options with { RootDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.RootDirectory)) };
}
public IDisposable Authorize(string proposalId)
{
Options.GetMetadataPath("pending", proposalId);
lock (_authorized)
if (!_authorized.Add(proposalId)) throw new InvalidOperationException("Approval already in use.");
return new Authorization(this, proposalId);
}
private sealed class Authorization(WorkspaceSession owner, string id) : IDisposable
{
public void Dispose() { lock (owner._authorized) owner._authorized.Remove(id); }
}
public async Task<WorkspaceResult> ExecuteAsync(WorkspaceRequest request, CancellationToken ct = default)
{
var gate = Gates.GetOrAdd(Options.RootDirectory, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try
{
Options.RootDirectory.EnsureNoLinks();
string metadata = Options.GetMetadataDirectory();
metadata.EnsureNoLinks();
Directory.CreateDirectory(metadata);
using var lease = new FileStream(Path.Combine(metadata, "lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
ct.ThrowIfCancellationRequested();
if (request.Offset < 0 || request.Offset > 1000000) throw new ArgumentException("Invalid offset.");
return request.Operation switch
{
"pending" => request.ListPending(Options),
"bin_list" => request.ListBin(Options),
"read" => await request.ReadAsync(Options, ct),
"list" => request.ListEntries(Options),
"search" => await request.SearchAsync(Options, ct),
"exists" => request.CheckExists(Options),
"reject" => request.RejectProposal(Options),
"approve" => await ApproveAsync(request.BinId, ct),
"save" or "mkdir" or "move" or "bin" or "restore" => await ExecuteMutationAsync(request, null, ct),
_ => throw new ArgumentException("Unknown workspace operation.")
};
}
finally { gate.Release(); }
}
private async Task<WorkspaceResult> ApproveAsync(string proposalId, CancellationToken ct)
{
lock (_authorized)
if (!_authorized.Remove(proposalId)) throw new InvalidOperationException("Explicit host approval is required.");
string receipt = Options.GetMetadataPath("receipts", proposalId) + ".json";
if (File.Exists(receipt)) return JsonSerializer.Deserialize<WorkspaceResult>(File.ReadAllText(receipt))!;
string file = Options.GetMetadataPath("pending", proposalId) + ".json";
var proposal = JsonSerializer.Deserialize<WorkspaceProposal>(await File.ReadAllTextAsync(file, ct))!;
if (proposal.Request.Snapshot(Options) != proposal.Before)
throw new IOException("The target changed. Reject this proposal and prepare a new one.");
return await ExecuteMutationAsync(proposal.Request, proposalId, ct);
}
private async Task<WorkspaceResult> ExecuteMutationAsync(WorkspaceRequest request, string? proposalId, CancellationToken ct)
{
if (request.Operation is not ("save" or "mkdir" or "move" or "bin" or "restore"))
throw new ArgumentException("Unknown workspace operation.");
request.ValidateMutation(Options);
return (Options.Mode, proposalId) switch
{
(WorkspaceAccessMode.Ask, null) => await PrepareProposalAsync(request, ct),
_ => await request.CommitMutationAsync(Options, proposalId)
};
}
private async Task<WorkspaceResult> PrepareProposalAsync(WorkspaceRequest request, CancellationToken ct)
{
string id = Guid.NewGuid().ToString("N");
var proposal = new WorkspaceProposal(id, request, request.Snapshot(Options), DateTimeOffset.UtcNow);
await (Options.GetMetadataPath("pending", id) + ".json").WriteAtomicallyAsync(JsonSerializer.Serialize(proposal, Json), ct);
return new("needs_approval",
$"Approve {request.Operation}: {request.Path}{request.Destination}. Review with /workspace review {id}; then /approve {id} or /reject {id}.",
id, new { request.Operation, request.Path, request.Destination, content_preview = request.Content[..Math.Min(400, request.Content.Length)] });
}
public WorkspaceProposal Review(string id)
{
Options.GetMetadataDirectory().EnsureNoLinks();
return JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(Options.GetMetadataPath("pending", id) + ".json"))!;
}
}
C#UnchangedRemoved / replacedAdded
using System.Collections.Concurrent;
using System.Text.Json;
using Lilith.Core.Workspace.Extensions;
namespace Lilith.Core.Workspace;
/// <summary>Owns workspace serialization, immutable configuration and host approval capabilities.</summary>
public sealed class WorkspaceSession
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Gates = new(StringComparer.OrdinalIgnoreCase);
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
private readonly HashSet<string> _authorized = new(StringComparer.Ordinal);
public WorkspaceOptions Options { get; }
public WorkspaceSession(WorkspaceOptions options)
{
if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown workspace mode.");
Options = options with { RootDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.RootDirectory)) };
}
public IDisposable Authorize(string proposalId)
{
Options.GetMetadataPath("pending", proposalId);
lock (_authorized)
if (!_authorized.Add(proposalId)) throw new InvalidOperationException("Approval already in use.");
return new Authorization(this, proposalId);
}
private sealed class Authorization(WorkspaceSession owner, string id) : IDisposable
{
public void Dispose() { lock (owner._authorized) owner._authorized.Remove(id); }
}
public async Task<WorkspaceResult> ExecuteAsync(WorkspaceRequest request, CancellationToken ct = default)
{
var gate = Gates.GetOrAdd(Options.RootDirectory, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(ct);
try
{
Options.RootDirectory.EnsureNoLinks();
string metadata = Options.GetMetadataDirectory();
metadata.EnsureNoLinks();
Directory.CreateDirectory(metadata);
using var lease = new FileStream(Path.Combine(metadata, "lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
ct.ThrowIfCancellationRequested();
if (request.Offset < 0 || request.Offset > 1000000) throw new ArgumentException("Invalid offset.");
return request.Operation switch
{
"pending" => request.ListPending(Options),
"bin_list" => request.ListBin(Options),
"read" => await request.ReadAsync(Options, ct),
"list" => request.ListEntries(Options),
"search" => await request.SearchAsync(Options, ct),
"reject" => request.RejectProposal(Options),
"approve" => await ApproveAsync(request.BinId, ct),
"save" or "mkdir" or "move" or "bin" or "restore" => await ExecuteMutationAsync(request, null, ct),
_ => throw new ArgumentException("Unknown workspace operation.")
};
}
finally { gate.Release(); }
}
private async Task<WorkspaceResult> ApproveAsync(string proposalId, CancellationToken ct)
{
lock (_authorized)
if (!_authorized.Remove(proposalId)) throw new InvalidOperationException("Explicit host approval is required.");
string receipt = Options.GetMetadataPath("receipts", proposalId) + ".json";
if (File.Exists(receipt)) return JsonSerializer.Deserialize<WorkspaceResult>(File.ReadAllText(receipt))!;
string file = Options.GetMetadataPath("pending", proposalId) + ".json";
var proposal = JsonSerializer.Deserialize<WorkspaceProposal>(await File.ReadAllTextAsync(file, ct))!;
if (proposal.Request.Snapshot(Options) != proposal.Before)
throw new IOException("The target changed. Reject this proposal and prepare a new one.");
return await ExecuteMutationAsync(proposal.Request, proposalId, ct);
}
private async Task<WorkspaceResult> ExecuteMutationAsync(WorkspaceRequest request, string? proposalId, CancellationToken ct)
{
if (request.Operation is not ("save" or "mkdir" or "move" or "bin" or "restore"))
throw new ArgumentException("Unknown workspace operation.");
request.ValidateMutation(Options);
return (Options.Mode, proposalId) switch
{
(WorkspaceAccessMode.Ask, null) => await PrepareProposalAsync(request, ct),
_ => await request.CommitMutationAsync(Options, proposalId)
};
}
private async Task<WorkspaceResult> PrepareProposalAsync(WorkspaceRequest request, CancellationToken ct)
{
string id = Guid.NewGuid().ToString("N");
var proposal = new WorkspaceProposal(id, request, request.Snapshot(Options), DateTimeOffset.UtcNow);
await (Options.GetMetadataPath("pending", id) + ".json").WriteAtomicallyAsync(JsonSerializer.Serialize(proposal, Json), ct);
return new("needs_approval",
$"Approve {request.Operation}: {request.Path}{request.Destination}. Review with /workspace review {id}; then /approve {id} or /reject {id}.",
id, new { request.Operation, request.Path, request.Destination, content_preview = request.Content[..Math.Min(400, request.Content.Length)] });
}
public WorkspaceProposal Review(string id)
{
Options.GetMetadataDirectory().EnsureNoLinks();
return JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(Options.GetMetadataPath("pending", id) + ".json"))!;
}
}
Lilith/Core/Workspace/Extensions 1 files
ModifiedWorkspaceReadExtensions.cs5,192 B / 71 lines+5-0+333 B+
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
namespace Lilith.Core.Workspace.Extensions;
// Operation helpers are called under the session's gate and exclusive workspace lease.
internal static class WorkspaceReadExtensions
{
internal static WorkspaceResult CheckExists(this WorkspaceRequest request, WorkspaceOptions options)
{
string path = options.ResolvePath(request.Path);
return new("completed", "Workspace existence checked.", Data: new { path = request.Path, exists = File.Exists(path) || Directory.Exists(path) });
}
internal static WorkspaceResult ListPending(this WorkspaceRequest request, WorkspaceOptions options)
{
string pending = Path.Combine(options.GetMetadataDirectory(), "pending");
var proposals = Directory.Exists(pending) ? Directory.EnumerateFiles(pending, "*.json").Order().Skip(request.Offset).Take(20)
.Select(p => JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(p))!).ToArray() : [];
return new("completed", "Pending proposals (use host approval commands).", Data: proposals.Select(p => new { p.Id, p.Request.Operation, p.Request.Path, p.Request.Destination, p.CreatedAt }));
}
internal static WorkspaceResult ListBin(this WorkspaceRequest request, WorkspaceOptions options)
{
string bin = Path.Combine(options.GetMetadataDirectory(), "bin");
return new("completed", "Workspace bin entries.", Data: Directory.Exists(bin) ? Directory.EnumerateDirectories(bin).Where(p => File.Exists(Path.Combine(p, "item")) || Directory.Exists(Path.Combine(p, "item"))).Order().Skip(request.Offset).Take(20)
.Select(p => new { id = Path.GetFileName(p), original_path = options.GetOriginalBinPath(Path.GetFileName(p)) }).ToArray() : []);
}
internal static async Task<WorkspaceResult> ReadAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
string path = options.ResolvePath(request.Path);
if (!path.IsTextFile()) throw new IOException("Text reading supports Markdown, TXT, JSON, CSV, XML and HTML. PDF/Office extraction is not implemented.");
if (new FileInfo(path).Length > 1000000) throw new IOException("File exceeds the text-read limit.");
string text = await File.ReadAllTextAsync(path, new UTF8Encoding(false, true), ct);
int start = Math.Min(request.Offset, text.Length), length = Math.Min(4000, text.Length - start);
return new("completed", "Read workspace text.", Data: new { path = request.Path, text = text.Substring(start, length), next_offset = start + length < text.Length ? start + length : (int?)null });
}
internal static WorkspaceResult ListEntries(this WorkspaceRequest request, WorkspaceOptions options)
{
string directory = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var items = Directory.EnumerateFileSystemEntries(directory).Where(p => !Path.GetFileName(p).StartsWith('.')).Order().Skip(request.Offset).Take(21).ToArray();
foreach (string item in items) item.EnsureNoLinks();
return new("completed", "Workspace folder entries.", Data: new { matches = items.Take(20).Select(p => new { path = Path.GetRelativePath(options.RootDirectory, p).Replace('\\', '/'), folder = Directory.Exists(p), excerpt = (string?)null }), next_offset = items.Length > 20 ? request.Offset + 20 : (int?)null });
}
internal static async Task<WorkspaceResult> SearchAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Query) || request.Query.Length > 200) throw new ArgumentException("Supply a query up to 200 characters.");
string scope = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var matches = new List<object>(); int found = 0;
foreach (string item in scope.EnumerateEntries(2000))
{
ct.ThrowIfCancellationRequested();
string relative = Path.GetRelativePath(options.RootDirectory, item).Replace('\\', '/');
string? excerpt = null;
if (File.Exists(item) && new FileInfo(item).Length <= 1000000 && item.IsTextFile())
{
string text = await File.ReadAllTextAsync(item, new UTF8Encoding(false, true), ct);
int index = text.IndexOf(request.Query, StringComparison.OrdinalIgnoreCase);
if (index >= 0) excerpt = text.Substring(Math.Max(0, index - 40), Math.Min(200, text.Length - Math.Max(0, index - 40)));
}
if (!relative.Contains(request.Query, StringComparison.OrdinalIgnoreCase) && excerpt is null) continue;
if (found++ < request.Offset) continue;
matches.Add(new { path = relative, folder = Directory.Exists(item), excerpt });
if (matches.Count == 20) break;
}
return new("completed", "Workspace search results; use read for selected excerpts.", Data: new { matches, next_offset = matches.Count == 20 ? request.Offset + 20 : (int?)null });
}
}
C#UnchangedRemoved / replacedAdded
using System.Text;
using System.Text.Json;
namespace Lilith.Core.Workspace.Extensions;
// Operation helpers are called under the session's gate and exclusive workspace lease.
internal static class WorkspaceReadExtensions
{
internal static WorkspaceResult ListPending(this WorkspaceRequest request, WorkspaceOptions options)
{
string pending = Path.Combine(options.GetMetadataDirectory(), "pending");
var proposals = Directory.Exists(pending) ? Directory.EnumerateFiles(pending, "*.json").Order().Skip(request.Offset).Take(20)
.Select(p => JsonSerializer.Deserialize<WorkspaceProposal>(File.ReadAllText(p))!).ToArray() : [];
return new("completed", "Pending proposals (use host approval commands).", Data: proposals.Select(p => new { p.Id, p.Request.Operation, p.Request.Path, p.Request.Destination, p.CreatedAt }));
}
internal static WorkspaceResult ListBin(this WorkspaceRequest request, WorkspaceOptions options)
{
string bin = Path.Combine(options.GetMetadataDirectory(), "bin");
return new("completed", "Workspace bin entries.", Data: Directory.Exists(bin) ? Directory.EnumerateDirectories(bin).Where(p => File.Exists(Path.Combine(p, "item")) || Directory.Exists(Path.Combine(p, "item"))).Order().Skip(request.Offset).Take(20)
.Select(p => new { id = Path.GetFileName(p), original_path = options.GetOriginalBinPath(Path.GetFileName(p)) }).ToArray() : []);
}
internal static async Task<WorkspaceResult> ReadAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
string path = options.ResolvePath(request.Path);
if (!path.IsTextFile()) throw new IOException("Text reading supports Markdown, TXT, JSON, CSV, XML and HTML. PDF/Office extraction is not implemented.");
if (new FileInfo(path).Length > 1000000) throw new IOException("File exceeds the text-read limit.");
string text = await File.ReadAllTextAsync(path, new UTF8Encoding(false, true), ct);
int start = Math.Min(request.Offset, text.Length), length = Math.Min(4000, text.Length - start);
return new("completed", "Read workspace text.", Data: new { path = request.Path, text = text.Substring(start, length), next_offset = start + length < text.Length ? start + length : (int?)null });
}
internal static WorkspaceResult ListEntries(this WorkspaceRequest request, WorkspaceOptions options)
{
string directory = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var items = Directory.EnumerateFileSystemEntries(directory).Where(p => !Path.GetFileName(p).StartsWith('.')).Order().Skip(request.Offset).Take(21).ToArray();
foreach (string item in items) item.EnsureNoLinks();
return new("completed", "Workspace folder entries.", Data: new { matches = items.Take(20).Select(p => new { path = Path.GetRelativePath(options.RootDirectory, p).Replace('\\', '/'), folder = Directory.Exists(p), excerpt = (string?)null }), next_offset = items.Length > 20 ? request.Offset + 20 : (int?)null });
}
internal static async Task<WorkspaceResult> SearchAsync(this WorkspaceRequest request, WorkspaceOptions options, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Query) || request.Query.Length > 200) throw new ArgumentException("Supply a query up to 200 characters.");
string scope = request.Path.Length == 0 ? options.RootDirectory : options.ResolvePath(request.Path);
var matches = new List<object>(); int found = 0;
foreach (string item in scope.EnumerateEntries(2000))
{
ct.ThrowIfCancellationRequested();
string relative = Path.GetRelativePath(options.RootDirectory, item).Replace('\\', '/');
string? excerpt = null;
if (File.Exists(item) && new FileInfo(item).Length <= 1000000 && item.IsTextFile())
{
string text = await File.ReadAllTextAsync(item, new UTF8Encoding(false, true), ct);
int index = text.IndexOf(request.Query, StringComparison.OrdinalIgnoreCase);
if (index >= 0) excerpt = text.Substring(Math.Max(0, index - 40), Math.Min(200, text.Length - Math.Max(0, index - 40)));
}
if (!relative.Contains(request.Query, StringComparison.OrdinalIgnoreCase) && excerpt is null) continue;
if (found++ < request.Offset) continue;
matches.Add(new { path = relative, folder = Directory.Exists(item), excerpt });
if (matches.Count == 20) break;
}
return new("completed", "Workspace search results; use read for selected excerpts.", Data: new { matches, next_offset = matches.Count == 20 ? request.Offset + 20 : (int?)null });
}
}
No changed files match that search.