← All source files

V3 / 536edbbdbb6e

ScenarioCodeCompiler.cs

Lilith/Core/Scenarios/ScenarioCodeCompiler.cs

Changed in this version
CS
using System.Diagnostics;
using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Tools;

namespace Lilith.Core.Scenarios;

/// <summary>Compiles trusted scenario C# using the installed SDK. This is not a sandbox.</summary>
internal sealed class ScenarioCodeCompiler : IDisposable
{
    private AssemblyLoadContext? _context;
    public List<ILilithTool> Tools { get; } = [];

    public static async Task<ScenarioCodeCompiler> CompileAsync(ScenarioCodeTool[] definitions,
        IReadOnlyDictionary<string, string> inputs, CancellationToken cancellationToken)
    {
        var compiler = new ScenarioCodeCompiler();
        if (definitions.Length == 0) return compiler;
        try
        {
            string folder = Path.Combine(Path.GetTempPath(), "Lilith-ScenarioTools", Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(folder);
            string project = Path.Combine(folder, "ScenarioCode.csproj");
            var xml = new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"),
                new XElement("PropertyGroup", new XElement("TargetFramework", $"net{Environment.Version.Major}.0"),
                    new XElement("ImplicitUsings", "enable"), new XElement("Nullable", "enable"),
                    new XElement("EnableDefaultCompileItems", "false")),
                new XElement("ItemGroup", new XElement("Compile", new XAttribute("Include", "Code.cs")))));
            xml.Save(project);
            // Framework-only build: no package feeds or arbitrary project/MSBuild XML from scenarios.
            await File.WriteAllTextAsync(Path.Combine(folder, "NuGet.Config"),
                "<configuration><packageSources><clear /></packageSources></configuration>", cancellationToken);
            var code = new StringBuilder("using System.Text.Json;\npublic static class ScenarioFunctions\n{\n");
            for (int i = 0; i < definitions.Length; i++)
            {
                code.AppendLine("#pragma warning disable CS1998");
                code.AppendLine($"public static async Task<string> Tool{i}(JsonElement arguments, IReadOnlyDictionary<string,string> inputs, CancellationToken cancellationToken) {{");
                code.AppendLine(definitions[i].Code);
                code.AppendLine("}");
            }
            code.AppendLine("}");
            await File.WriteAllTextAsync(Path.Combine(folder, "Code.cs"), code.ToString(), cancellationToken);
            var start = new ProcessStartInfo("dotnet")
            {
                WorkingDirectory = folder, UseShellExecute = false, CreateNoWindow = true,
                RedirectStandardOutput = true, RedirectStandardError = true
            };
            foreach (string argument in new[] { "build", project, "-c", "Release", "--nologo", "--disable-build-servers", "-v", "quiet" })
                start.ArgumentList.Add(argument);
            using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start C# compiler.");
            Task<string> output = process.StandardOutput.ReadToEndAsync();
            Task<string> error = process.StandardError.ReadToEndAsync();
            using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            deadline.CancelAfter(TimeSpan.FromMinutes(2));
            try { await process.WaitForExitAsync(deadline.Token); }
            catch
            {
                try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
                await process.WaitForExitAsync();
                await Task.WhenAll(output, error);
                throw;
            }
            string diagnostics = await output + await error;
            if (process.ExitCode != 0) throw new InvalidDataException("Scenario C# compilation failed: " + diagnostics[..Math.Min(4000, diagnostics.Length)]);
            compiler._context = new AssemblyLoadContext("Scenario-" + Guid.NewGuid().ToString("N"), isCollectible: true);
            var assembly = compiler._context.LoadFromAssemblyPath(Path.Combine(folder, "bin", "Release", $"net{Environment.Version.Major}.0", "ScenarioCode.dll"));
            var type = assembly.GetType("ScenarioFunctions", throwOnError: true)!;
            for (int i = 0; i < definitions.Length; i++)
            {
                var method = type.GetMethod("Tool" + i)!;
                var handler = method.CreateDelegate<Func<JsonElement, IReadOnlyDictionary<string, string>, CancellationToken, Task<string>>>();
                compiler.Tools.Add(new CompiledTool(definitions[i], inputs, handler));
            }
            return compiler;
        }
        catch { compiler.Dispose(); throw; }
    }

    public void Dispose()
    {
        Tools.Clear();
        _context?.Unload();
        _context = null;
    }

    private sealed class CompiledTool(ScenarioCodeTool definition, IReadOnlyDictionary<string, string> inputs,
        Func<JsonElement, IReadOnlyDictionary<string, string>, CancellationToken, Task<string>> handler) : ILilithTool
    {
        public string Name => definition.Name;
        public string Description => definition.Description;
        public JsonElement Parameters => definition.Parameters;
        public Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
            handler(arguments, inputs, cancellationToken);
    }
}