V5 / CHANGESET
Only the delta.
The exact changes from V4 to V5. 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.props205 B / 4 lines+2-2+1 B+
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>
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- V4 workspace development. V3 alpha is preserved separately. -->
<PropertyGroup><VersionPrefix>4.0.0</VersionPrefix><VersionSuffix>alpha.3</VersionSuffix></PropertyGroup>
</Project>
Interfaces/ConsoleApp/LilithConsole 1 files
ModifiedProgram.cs4,857 B / 90 lines+2-1+68 B+
C#UnchangedRemoved / replacedAdded
using System;
using System.Text;
using System.Threading.Tasks;
using Lilith.Core.Logging;
using Lilith.Agent;
using LilithAgent = Lilith.Agent;
using LilithConsole.Setup;
using Lilith.Core.Kokoro;
namespace LilithConsole
{
partial class Program
{
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
var consoleSink = new TextLogSink(Console.Out, overwriteProgress: !Console.IsOutputRedirected,
width: () => Math.Max(Console.WindowWidth - 1, 1));
Action<LogEntry> onLog = consoleSink.Handle;
var logger = new Logger(onLog);
try
{
string settingsPath = Environment.GetEnvironmentVariable("LILITH_CONSOLE_SETTINGS")
?? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "console-settings.json");
var credentials = await ProviderSetup.RunAsync(Console.In, Console.Out, logger, settingsPath);
if (credentials is null) { logger.SystemWriteLine("Setup stopped. Relaunch to continue."); return; }
if (!Console.IsInputRedirected || Environment.GetEnvironmentVariable("LILITH_TTS") == "1")
{
try
{
credentials = credentials with { Tts = await KokoroSetup.RunAsync(Console.In, Console.Out,
logger, settingsPath + ".kokoro.json") };
}
catch (Exception ex) { logger.SystemWriteLine($"Speech setup failed: {ex.Message}. Relaunch to retry; continuing with text chat.", LogColors.Orange); }
}
Console.Title = $"{credentials.AgentName} — {credentials.Model} Chat";
await using var lilith = new LilithAgent.Lilith(onLog, credentials);
string mode = "ask";
if (!Console.IsInputRedirected)
{
Console.Write("Workspace and scheduling mode for this boot [ask/yolo, default ask]: ");
mode = Console.ReadLine()?.Trim().ToLowerInvariant() ?? "ask";
if (mode.Length == 0) mode = "ask";
if (mode is not ("ask" or "yolo")) throw new ArgumentException("Choose ask or yolo at boot.");
}
else if (args.Contains("--workspace-yolo")) mode = "yolo";
var workspace = Lilith.Core.Workspace.WorkspaceOptions.Default;
string? workspaceRoot = Environment.GetEnvironmentVariable("LILITH_WORKSPACE_ROOT");
lilith.WorkspaceOptions = workspace with { RootDirectory = workspaceRoot ?? workspace.RootDirectory,
Mode = mode == "yolo" ? Lilith.Core.Workspace.WorkspaceAccessMode.Yolo : Lilith.Core.Workspace.WorkspaceAccessMode.Ask };
Console.WriteLine($"Workspace: {lilith.WorkspaceOptions.RootDirectory} ({mode}, fixed until restart). Use /workspace, /workspace review <id>, /approve <id>, /reject <id>.");
await lilith.BootAsync(startServerIfNotRunning: true);
while (true)
{
string? input;
using (consoleSink.BeginInput())
{
lilith.Logger.Write("Input", "You: ", LogColors.Green);
input = Console.ReadLine()?.Trim();
}
if (input is null) break;
if (string.IsNullOrEmpty(input)) continue;
if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
try
{
var command = await lilith.ChatCommands.ExecuteAsync(input);
if (command.Output is { Length: > 0 }) Console.WriteLine(command.Output);
if (command.ExitRequested) break;
if (!command.Handled) await lilith.ChatAsync(input);
}
catch (Exception ex)
{
lilith.Logger.SystemWriteLine($"Request failed: {ex.Message}. Please try again.", LogColors.Red);
}
}
await lilith.StopSchedulingAsync();
lilith.SaveConversation();
lilith.Logger.SystemWriteLine("Goodbye.");
}
catch (Exception ex)
{
logger.SystemWriteLine($"Setup/chat failed: {ex.Message}. Relaunch to retry; completed setup steps are retained.", LogColors.Red);
Environment.ExitCode = 1;
}
finally { consoleSink.Flush(); }
}
}
}
C#UnchangedRemoved / replacedAdded
using System;
using System.Text;
using System.Threading.Tasks;
using Lilith.Core.Logging;
using Lilith.Agent;
using LilithAgent = Lilith.Agent;
using LilithConsole.Setup;
using Lilith.Core.Kokoro;
namespace LilithConsole
{
partial class Program
{
static async Task Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
var consoleSink = new TextLogSink(Console.Out, overwriteProgress: !Console.IsOutputRedirected,
width: () => Math.Max(Console.WindowWidth - 1, 1));
Action<LogEntry> onLog = consoleSink.Handle;
var logger = new Logger(onLog);
try
{
string settingsPath = Environment.GetEnvironmentVariable("LILITH_CONSOLE_SETTINGS")
?? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "console-settings.json");
var credentials = await ProviderSetup.RunAsync(Console.In, Console.Out, logger, settingsPath);
if (credentials is null) { logger.SystemWriteLine("Setup stopped. Relaunch to continue."); return; }
if (!Console.IsInputRedirected || Environment.GetEnvironmentVariable("LILITH_TTS") == "1")
{
try
{
credentials = credentials with { Tts = await KokoroSetup.RunAsync(Console.In, Console.Out,
logger, settingsPath + ".kokoro.json") };
}
catch (Exception ex) { logger.SystemWriteLine($"Speech setup failed: {ex.Message}. Relaunch to retry; continuing with text chat.", LogColors.Orange); }
}
Console.Title = $"{credentials.AgentName} — {credentials.Model} Chat";
await using var lilith = new LilithAgent.Lilith(onLog, credentials);
string mode = "ask";
if (!Console.IsInputRedirected)
{
Console.Write("Workspace mode for this boot [ask/yolo, default ask]: ");
mode = Console.ReadLine()?.Trim().ToLowerInvariant() ?? "ask";
if (mode.Length == 0) mode = "ask";
if (mode is not ("ask" or "yolo")) throw new ArgumentException("Choose ask or yolo at boot.");
}
else if (args.Contains("--workspace-yolo")) mode = "yolo";
var workspace = Lilith.Core.Workspace.WorkspaceOptions.Default;
string? workspaceRoot = Environment.GetEnvironmentVariable("LILITH_WORKSPACE_ROOT");
lilith.WorkspaceOptions = workspace with { RootDirectory = workspaceRoot ?? workspace.RootDirectory,
Mode = mode == "yolo" ? Lilith.Core.Workspace.WorkspaceAccessMode.Yolo : Lilith.Core.Workspace.WorkspaceAccessMode.Ask };
Console.WriteLine($"Workspace: {lilith.WorkspaceOptions.RootDirectory} ({mode}, fixed until restart). Use /workspace, /workspace review <id>, /approve <id>, /reject <id>.");
await lilith.BootAsync(startServerIfNotRunning: true);
while (true)
{
string? input;
using (consoleSink.BeginInput())
{
lilith.Logger.Write("Input", "You: ", LogColors.Green);
input = Console.ReadLine()?.Trim();
}
if (input is null) break;
if (string.IsNullOrEmpty(input)) continue;
if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
try
{
var command = await lilith.ChatCommands.ExecuteAsync(input);
if (command.Output is { Length: > 0 }) Console.WriteLine(command.Output);
if (command.ExitRequested) break;
if (!command.Handled) await lilith.ChatAsync(input);
}
catch (Exception ex)
{
lilith.Logger.SystemWriteLine($"Request failed: {ex.Message}. Please try again.", LogColors.Red);
}
}
lilith.SaveConversation();
lilith.Logger.SystemWriteLine("Goodbye.");
}
catch (Exception ex)
{
logger.SystemWriteLine($"Setup/chat failed: {ex.Message}. Relaunch to retry; completed setup steps are retained.", LogColors.Red);
Environment.ExitCode = 1;
}
finally { consoleSink.Flush(); }
}
}
}
Lilith/Agent 5 files
ModifiedLilith.Boot.cs3,105 B / 78 lines+1-0+144 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
namespace Lilith.Agent;
public partial class Lilith
{
public bool IsBooted { get; private set; }
public async Task BootAsync(bool startServerIfNotRunning = true)
{
using var operation = EnterOperation();
try
{
if (IsBooted) return;
if (string.IsNullOrWhiteSpace(Credentials.Model) || string.IsNullOrWhiteSpace(Credentials.SystemPrompt) ||
string.IsNullOrWhiteSpace(Credentials.AgentName)) throw new ArgumentException("Model, system prompt, and agent name are required.");
await Logger.TimeStageAsync("Total agent boot", () => BootCoreAsync(startServerIfNotRunning));
}
catch
{
_llmclient?.Dispose();
_llmclient = null!;
IsBooted = false;
throw;
}
}
private async Task BootCoreAsync(bool startServerIfNotRunning)
{
IsBooted = false;
await Logger.TimeStageAsync("Client initialization", () => InitializeProviderAsync(startServerIfNotRunning));
await Logger.TimeStageAsync("Chat readiness validation", () => { EnsureChatReadiness(); return Task.CompletedTask; });
if (Credentials.Tts is { Enabled: true } options && _tts is null)
{
try
{
_tts = await Logger.TimeStageAsync("Kokoro initialization", () =>
global::Lilith.Core.Kokoro.KokoroTts.CreateAsync(Logger, options));
}
catch (Exception ex) { Logger.SystemWriteLine($"Speech unavailable: {ex.Message}. Text chat remains available.", LogColors.Orange); }
}
Logger.SystemWriteLine($"=== {Credentials.AgentName} AI Chat ({Credentials.Model} via {Credentials.Provider}) ===");
Logger.SystemWriteLine("Type your message and press Enter. Type 'exit' to quit.\n");
Logger.SystemPromptWriteLine($"SYSTEM: {Credentials.SystemPrompt}");
await Logger.TimeStageAsync("Greeting generation", GreetingCoreAsync);
Logger.SystemWriteLine("", LogColors.White);
IsBooted = true;
if (EnableScenarios) _schedulePump = Task.WhenAll(DetectSchedulesAsync(_scheduleStop.Token), PumpSchedulesAsync(_scheduleStop.Token));
}
public async Task GreetAsync()
{
using var operation = EnterOperation("Boot the agent before greeting.");
await GreetingCoreAsync();
}
private async Task GreetingCoreAsync()
{
if (!EnableScenarios)
{
await ChatCoreAsync("Please greet the user.");
return;
}
string greeting = await Client.RunScenarioAsync("greeting", new Dictionary<string, string>
{
["agent_name"] = Credentials.AgentName
});
if (_tts is not null) await _tts.SpeakAsync(greeting);
}
private void EnsureChatReadiness()
{
_llmclient.IsNull();
this.EnsureSystemPromptContent();
this.EnsureContextInitialized();
}
}
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
namespace Lilith.Agent;
public partial class Lilith
{
public bool IsBooted { get; private set; }
public async Task BootAsync(bool startServerIfNotRunning = true)
{
using var operation = EnterOperation();
try
{
if (IsBooted) return;
if (string.IsNullOrWhiteSpace(Credentials.Model) || string.IsNullOrWhiteSpace(Credentials.SystemPrompt) ||
string.IsNullOrWhiteSpace(Credentials.AgentName)) throw new ArgumentException("Model, system prompt, and agent name are required.");
await Logger.TimeStageAsync("Total agent boot", () => BootCoreAsync(startServerIfNotRunning));
}
catch
{
_llmclient?.Dispose();
_llmclient = null!;
IsBooted = false;
throw;
}
}
private async Task BootCoreAsync(bool startServerIfNotRunning)
{
IsBooted = false;
await Logger.TimeStageAsync("Client initialization", () => InitializeProviderAsync(startServerIfNotRunning));
await Logger.TimeStageAsync("Chat readiness validation", () => { EnsureChatReadiness(); return Task.CompletedTask; });
if (Credentials.Tts is { Enabled: true } options && _tts is null)
{
try
{
_tts = await Logger.TimeStageAsync("Kokoro initialization", () =>
global::Lilith.Core.Kokoro.KokoroTts.CreateAsync(Logger, options));
}
catch (Exception ex) { Logger.SystemWriteLine($"Speech unavailable: {ex.Message}. Text chat remains available.", LogColors.Orange); }
}
Logger.SystemWriteLine($"=== {Credentials.AgentName} AI Chat ({Credentials.Model} via {Credentials.Provider}) ===");
Logger.SystemWriteLine("Type your message and press Enter. Type 'exit' to quit.\n");
Logger.SystemPromptWriteLine($"SYSTEM: {Credentials.SystemPrompt}");
await Logger.TimeStageAsync("Greeting generation", GreetingCoreAsync);
Logger.SystemWriteLine("", LogColors.White);
IsBooted = true;
}
public async Task GreetAsync()
{
using var operation = EnterOperation("Boot the agent before greeting.");
await GreetingCoreAsync();
}
private async Task GreetingCoreAsync()
{
if (!EnableScenarios)
{
await ChatCoreAsync("Please greet the user.");
return;
}
string greeting = await Client.RunScenarioAsync("greeting", new Dictionary<string, string>
{
["agent_name"] = Credentials.AgentName
});
if (_tts is not null) await _tts.SpeakAsync(greeting);
}
private void EnsureChatReadiness()
{
_llmclient.IsNull();
this.EnsureSystemPromptContent();
this.EnsureContextInitialized();
}
}
ModifiedLilith.Chat.cs4,114 B / 100 lines+3-1+152 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using System.Diagnostics;
namespace Lilith.Agent;
public partial class Lilith
{
public async Task ChatAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
{
using var operation = await _operation.EnterAsync(cancellationToken);
ObjectDisposedException.ThrowIf(_disposed, this);
if (!IsBooted) throw new InvalidOperationException("Boot the agent before chatting.");
await ChatCoreAsync(input, images, cancellationToken);
}
private async Task ChatCoreAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
{
var timer = Stopwatch.StartNew();
string reply;
try { reply = await Client.StreamReply(input, images, cancellationToken); }
finally { timer.Stop(); }
this.LogTokenCounts(timer.Elapsed);
if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
}
private T WithConversation<T>(Func<ProviderClient, T> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
return action(Client);
}
private void WithConversation(Action<ProviderClient> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
action(Client);
}
public void NewConversation() => WithConversation(client => client.NewConversation());
public async Task ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation("Boot before approving workspace changes.");
string reply = await Client.ApproveWorkspaceAsync(id, cancellationToken);
if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
}
public async Task RejectWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation("Boot before rejecting workspace changes.");
var result = await Workspace.ExecuteAsync(new("reject", BinId: id), cancellationToken);
Logger.SystemWriteLine(result.Message);
}
public void SaveConversation() => WithConversation(client => client.SaveConversation());
public IReadOnlyList<ChatSessionSummary> ListConversations() => WithConversation(client => client.ListConversations());
public bool LoadConversation(int displayIndex) => WithConversation(client => client.LoadConversation(displayIndex));
public bool LoadConversation(string id) => WithConversation(client => client.LoadConversation(id));
public void HandleHistoryCommand(string input)
{
var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 1)
{
var sessions = ListConversations();
if (sessions.Count == 0)
{
Logger.HistoryWriteLine("No saved chats found.");
return;
}
Logger.HistoryWriteLine("Saved chats:", LogColors.Cyan);
for (int i = 0; i < sessions.Count; i++)
{
var session = sessions[i];
Logger.HistoryWriteLine($"{i + 1}. {session.Title} ({session.UpdatedAt.LocalDateTime:g}, {session.MessageCount} messages)");
}
Logger.HistoryWriteLine("Use /history <number> to load a chat.");
return;
}
if (!int.TryParse(parts[1], out int index))
{
Logger.HistoryWriteLine("Usage: /history or /history <number>", LogColors.Orange);
return;
}
if (LoadConversation(index))
{
Logger.HistoryWriteLine($"Loaded saved chat #{index}.", LogColors.Cyan);
}
else
{
Logger.HistoryWriteLine($"Saved chat #{index} was not found.", LogColors.Orange);
}
}
}
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using System.Diagnostics;
namespace Lilith.Agent;
public partial class Lilith
{
public async Task ChatAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation("Boot the agent before chatting.");
await ChatCoreAsync(input, images, cancellationToken);
}
private async Task ChatCoreAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
{
var timer = Stopwatch.StartNew();
string reply;
try { reply = await Client.StreamReply(input, images, cancellationToken); }
finally { timer.Stop(); }
this.LogTokenCounts(timer.Elapsed);
if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
}
private T WithConversation<T>(Func<ProviderClient, T> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
return action(Client);
}
private void WithConversation(Action<ProviderClient> action)
{
using var operation = EnterOperation("Boot the agent before using its conversation.");
action(Client);
}
public void NewConversation() => WithConversation(client => client.NewConversation());
public async Task ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation("Boot before approving workspace changes.");
string reply = await Client.ApproveWorkspaceAsync(id, cancellationToken);
if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
}
public async Task RejectWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation("Boot before rejecting workspace changes.");
var result = await Workspace.ExecuteAsync(new("reject", BinId: id), cancellationToken);
Logger.SystemWriteLine(result.Message);
}
public void SaveConversation() => WithConversation(client => client.SaveConversation());
public IReadOnlyList<ChatSessionSummary> ListConversations() => WithConversation(client => client.ListConversations());
public bool LoadConversation(int displayIndex) => WithConversation(client => client.LoadConversation(displayIndex));
public bool LoadConversation(string id) => WithConversation(client => client.LoadConversation(id));
public void HandleHistoryCommand(string input)
{
var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 1)
{
var sessions = ListConversations();
if (sessions.Count == 0)
{
Logger.HistoryWriteLine("No saved chats found.");
return;
}
Logger.HistoryWriteLine("Saved chats:", LogColors.Cyan);
for (int i = 0; i < sessions.Count; i++)
{
var session = sessions[i];
Logger.HistoryWriteLine($"{i + 1}. {session.Title} ({session.UpdatedAt.LocalDateTime:g}, {session.MessageCount} messages)");
}
Logger.HistoryWriteLine("Use /history <number> to load a chat.");
return;
}
if (!int.TryParse(parts[1], out int index))
{
Logger.HistoryWriteLine("Usage: /history or /history <number>", LogColors.Orange);
return;
}
if (LoadConversation(index))
{
Logger.HistoryWriteLine($"Loaded saved chat #{index}.", LogColors.Cyan);
}
else
{
Logger.HistoryWriteLine($"Saved chat #{index} was not found.", LogColors.Orange);
}
}
}
NEWLilith.Commands.Schedules.cs1,207 B / 20 lines+20-0+1,207 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Commands;
namespace Lilith.Agent;
public partial class Lilith
{
partial void RegisterScheduleCommands(ChatCommandRegistry commands)
{
commands.Register("schedules", "List schedules; use pending or review <id> for proposals.", context => context.Arguments switch
{
"" => JsonSerializer.Serialize(Schedules.Execute(new("list"))),
"pending" => JsonSerializer.Serialize(Schedules.Execute(new("pending"))),
var args when args.StartsWith("review ", StringComparison.OrdinalIgnoreCase) => JsonSerializer.Serialize(Schedules.Review(args[7..].Trim()), new JsonSerializerOptions { WriteIndented = true }),
_ => "Use /schedules, /schedules pending or /schedules review <id>."
});
commands.RegisterAsync("schedule-approve", "Approve a schedule proposal: /schedule-approve <id>.", async context => { await ApproveScheduleAsync(context.Arguments, context.CancellationToken); return new(true); });
commands.Register("schedule-reject", "Reject a schedule proposal: /schedule-reject <id>.", context => { RejectSchedule(context.Arguments); return null; });
}
}
NEWLilith.Schedules.cs2,535 B / 62 lines+62-0+2,535 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Agent;
public partial class Lilith
{
public async Task StopSchedulingAsync()
{
_scheduleStop.Cancel();
if (_schedulePump is not null) await _schedulePump;
}
private async Task DetectSchedulesAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(ct))
try { Schedules.Tick(); }
catch (Exception ex) { Logger.SystemWriteLine("Schedule detection failed: " + ex.Message, global::Lilith.Core.Logging.LogColors.Red); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
}
private async Task PumpSchedulesAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(ct))
{
try
{
// Detection runs separately; delivery must own conversation and narration access.
if (!await _operation.WaitAsync(0, ct)) continue;
try { await DeliverScheduleEventsAsync(ct); }
finally { _operation.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex) { Logger.SystemWriteLine("Scheduler: " + ex.Message, global::Lilith.Core.Logging.LogColors.Red); }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
}
private Task DeliverScheduleEventsAsync(CancellationToken ct) =>
global::Lilith.Core.Scheduling.ScheduleDelivery.DrainAsync(Client,
(text, token) => _tts is null ? Task.CompletedTask : _tts.SpeakAsync(text, token), ct);
public async Task ApproveScheduleAsync(string id, CancellationToken ct = default)
{
using var operation = EnterOperation("Boot before approving schedule changes.");
string reply = Schedules.Approve(id).ToReply();
Logger.SystemWriteLine(reply);
if (_tts is not null) await _tts.SpeakAsync(reply, ct);
}
public void RejectSchedule(string id)
{
using var operation = EnterOperation("Boot before rejecting schedule changes.");
Schedules.Reject(id);
Logger.SystemWriteLine("Schedule proposal rejected.");
}
}
ModifiedLilith.cs3,952 B / 81 lines+5-0+471 B+
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;
namespace Lilith.Agent;
public partial class Lilith(Action<LogEntry> onLog, LilithCredentials? credentials = null) : IAsyncDisposable
{
private KokoroTts? _tts;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
private readonly CancellationTokenSource _scheduleStop = new();
private Task? _schedulePump;
private ProviderClient _llmclient = null!;
private bool _workspaceLocked;
private global::Lilith.Core.Workspace.WorkspaceOptions _workspaceOptions = global::Lilith.Core.Workspace.WorkspaceOptions.Default;
public global::Lilith.Core.Workspace.WorkspaceOptions WorkspaceOptions
{
get => _workspaceOptions;
set { if (_workspaceLocked) throw new InvalidOperationException("Workspace configuration can only be chosen before boot."); _workspaceOptions = value ?? throw new ArgumentNullException(nameof(value)); }
}
public global::Lilith.Core.Workspace.WorkspaceSession Workspace => Client.Workspace ?? throw new InvalidOperationException("Workspace unavailable.");
public global::Lilith.Core.Scheduling.ScheduleSession Schedules => Client.Schedules ?? throw new InvalidOperationException("Schedules unavailable.");
private readonly LilithCredentials _credentials = credentials ?? new();
public LilithCredentials Credentials => _credentials;
public Logger Logger { get; } = new(onLog);
internal Func<bool, Task<ProviderClient>>? ProviderFactory { get; set; }
private ProviderClient Client
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _llmclient ?? throw new InvalidOperationException("Boot the agent before using its conversation.");
}
}
public ChatHistory History => Client.History;
public int ContextMax => Client.ContextMax;
public int CurrentContextTokens => Client.CurrentContextTokens;
private async Task InitializeProviderAsync(bool startServerIfNotRunning)
{
_workspaceLocked = true;
_llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
_llmclient.ShowSubagentOutput = _showSubagentOutput;
_llmclient.ConfigureWorkspace(_workspaceOptions);
if (EnableScenarios) _llmclient.ConfigureSchedules(new(Path.Combine(_workspaceOptions.RootDirectory, ".lilith", "schedules"), _workspaceOptions.Mode), ScenarioRoot);
foreach (var tool in Tools) _llmclient.RegisterTool(tool);
if (EnableScenarios) global::Lilith.Core.Scenarios.ScenarioTools.Register(_llmclient, ScenarioRoot);
}
public bool EnableScenarios { get; set; } = true;
public string? ScenarioRoot { get; set; }
/// <summary>Configure before boot. The template is deliberately not enabled.</summary>
public List<global::Lilith.Core.Tools.ILilithTool> Tools { get; } = [new global::Lilith.Core.Tools.CurrentTimeTool()];
private IDisposable EnterOperation(string? bootRequiredMessage = null)
{
var operation = _operation.EnterOrThrow("An agent operation is already in progress.");
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (bootRequiredMessage is not null && !IsBooted) throw new InvalidOperationException(bootRequiredMessage);
return operation;
}
catch { operation.Dispose(); throw; }
}
public async ValueTask DisposeAsync()
{
await StopSchedulingAsync();
using var operation = await _operation.EnterAsync();
if (_disposed) return;
_disposed = true;
try { if (_tts is not null) await _tts.DisposeAsync(); }
finally { _llmclient?.Dispose(); IsBooted = false; }
}
}
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;
namespace Lilith.Agent;
public partial class Lilith(Action<LogEntry> onLog, LilithCredentials? credentials = null) : IAsyncDisposable
{
private KokoroTts? _tts;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
private ProviderClient _llmclient = null!;
private bool _workspaceLocked;
private global::Lilith.Core.Workspace.WorkspaceOptions _workspaceOptions = global::Lilith.Core.Workspace.WorkspaceOptions.Default;
public global::Lilith.Core.Workspace.WorkspaceOptions WorkspaceOptions
{
get => _workspaceOptions;
set { if (_workspaceLocked) throw new InvalidOperationException("Workspace configuration can only be chosen before boot."); _workspaceOptions = value ?? throw new ArgumentNullException(nameof(value)); }
}
public global::Lilith.Core.Workspace.WorkspaceSession Workspace => Client.Workspace ?? throw new InvalidOperationException("Workspace unavailable.");
private readonly LilithCredentials _credentials = credentials ?? new();
public LilithCredentials Credentials => _credentials;
public Logger Logger { get; } = new(onLog);
internal Func<bool, Task<ProviderClient>>? ProviderFactory { get; set; }
private ProviderClient Client
{
get
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _llmclient ?? throw new InvalidOperationException("Boot the agent before using its conversation.");
}
}
public ChatHistory History => Client.History;
public int ContextMax => Client.ContextMax;
public int CurrentContextTokens => Client.CurrentContextTokens;
private async Task InitializeProviderAsync(bool startServerIfNotRunning)
{
_workspaceLocked = true;
_llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
_llmclient.ShowSubagentOutput = _showSubagentOutput;
_llmclient.ConfigureWorkspace(_workspaceOptions);
foreach (var tool in Tools) _llmclient.RegisterTool(tool);
if (EnableScenarios) global::Lilith.Core.Scenarios.ScenarioTools.Register(_llmclient, ScenarioRoot);
}
public bool EnableScenarios { get; set; } = true;
public string? ScenarioRoot { get; set; }
/// <summary>Configure before boot. The template is deliberately not enabled.</summary>
public List<global::Lilith.Core.Tools.ILilithTool> Tools { get; } = [new global::Lilith.Core.Tools.CurrentTimeTool()];
private IDisposable EnterOperation(string? bootRequiredMessage = null)
{
var operation = _operation.EnterOrThrow("An agent operation is already in progress.");
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (bootRequiredMessage is not null && !IsBooted) throw new InvalidOperationException(bootRequiredMessage);
return operation;
}
catch { operation.Dispose(); throw; }
}
public async ValueTask DisposeAsync()
{
using var operation = await _operation.EnterAsync();
if (_disposed) return;
_disposed = true;
try { if (_tts is not null) await _tts.DisposeAsync(); }
finally { _llmclient?.Dispose(); IsBooted = false; }
}
}
Lilith/Core 1 files
ModifiedCore.csproj597 B / 15 lines+2-2+46 B+
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Lilith.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cronos" Version="0.13.0" />
<PackageReference Include="KokoroSharp.CPU" Version="0.8.4" />
<Content Include="Scenarios/Definitions/*.xml" Link="Scenarios/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Lilith.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KokoroSharp.CPU" Version="0.8.4" />
<Content Include="Scenarios/Definitions/*.xml" Link="Scenarios/%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Lilith/Core/Kokoro 2 files
DeletedKokoroIdentifiers.cs591 B / 11 lines+0-11-591 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
namespace Lilith.Core.Kokoro;
internal static class KokoroIdentifiers
{
private static readonly string[] Digits = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
// Approval/bin hashes are identifiers, not large integers. Keep display/history untouched.
internal static string Expand(string text) => Regex.Replace(text, @"(?<![A-Za-z0-9])[A-Fa-f0-9]{24,64}(?![A-Za-z0-9])",
match => string.Join(" ", match.Value.Select(c => c is >= '0' and <= '9' ? Digits[c - '0'] : c.ToString())));
}
ModifiedKokoroTtsSanitizer.cs3,774 B / 68 lines+0-1-48 B+
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text;
namespace Lilith.Core.Kokoro;
/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
public static string Sanitize(string text)
{
ArgumentNullException.ThrowIfNull(text);
// Remove fenced code before interpreting its contents as prose, including an unclosed fence.
text = KokoroRegex.CodeFence().Replace(text, " ");
text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
text = KokoroRegex.WebUrl().Replace(text, match =>
{
// Discard the address but retain punctuation ending the surrounding sentence.
string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
int end = url.Length;
while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
return " " + url[end..];
});
for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");
// Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
// Grapheme rendering and the original chat/history text remain untouched.
var spoken = new StringBuilder(text.Length);
foreach (var rune in text.EnumerateRunes())
{
if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
(Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
spoken.Append(unsupported ? " " : rune.ToString());
}
text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
var result = new StringBuilder(text.Length);
bool paragraphBreak = false, previousList = false;
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
bool listItem = KokoroRegex.ListMarker().IsMatch(line);
bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
line = KokoroRegex.ListMarker().Replace(line, "");
line = KokoroRegex.HeadingMarker().Replace(line, "");
line = KokoroRegex.QuoteMarker().Replace(line, "");
line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
if (line.Length == 0) continue;
if (result.Length > 0)
{
// Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
int lastIndex = result.Length - 1;
while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
char last = result[lastIndex];
if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
result.Append(' ');
}
result.Append(line);
paragraphBreak = false;
previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
}
text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
}
}
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text;
namespace Lilith.Core.Kokoro;
/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
public static string Sanitize(string text)
{
ArgumentNullException.ThrowIfNull(text);
// Remove fenced code before interpreting its contents as prose, including an unclosed fence.
text = KokoroRegex.CodeFence().Replace(text, " ");
text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
text = KokoroRegex.WebUrl().Replace(text, match =>
{
// Discard the address but retain punctuation ending the surrounding sentence.
string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
int end = url.Length;
while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
return " " + url[end..];
});
for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");
text = KokoroIdentifiers.Expand(text);
// Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
// Grapheme rendering and the original chat/history text remain untouched.
var spoken = new StringBuilder(text.Length);
foreach (var rune in text.EnumerateRunes())
{
if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
(Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
spoken.Append(unsupported ? " " : rune.ToString());
}
text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
var result = new StringBuilder(text.Length);
bool paragraphBreak = false, previousList = false;
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
bool listItem = KokoroRegex.ListMarker().IsMatch(line);
bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
line = KokoroRegex.ListMarker().Replace(line, "");
line = KokoroRegex.HeadingMarker().Replace(line, "");
line = KokoroRegex.QuoteMarker().Replace(line, "");
line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
if (line.Length == 0) continue;
if (result.Length > 0)
{
// Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
int lastIndex = result.Length - 1;
while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
char last = result[lastIndex];
if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
result.Append(' ');
}
result.Append(line);
paragraphBreak = false;
previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
}
text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
}
}
Lilith/Core/Logging 1 files
ModifiedTextLogSink.cs3,325 B / 95 lines+9-0+415 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
/// <summary>Renders log types consistently; files retain the last update of each progress phase.</summary>
public sealed class TextLogSink(TextWriter writer, bool overwriteProgress = false, Func<int>? width = null)
{
private readonly object _sync = new();
private string? _progress;
private int _drawnLength;
private bool _inputActive;
private readonly List<LogEntry> _deferred = [];
/// <summary>Defer background output until the current console input has finished.</summary>
public IDisposable BeginInput()
{
lock (_sync)
{
if (_inputActive) throw new InvalidOperationException("Console input is already active.");
FinishProgress();
_inputActive = true;
return new TextLogInputLease(EndInput);
}
}
public void Handle(LogEntry entry)
{
lock (_sync)
{
if (_inputActive && entry.Sender == "Scheduler" && entry.Type == LogType.WriteLine)
{
// Idle timer notices must be visible without requiring the user to submit input.
writer.WriteLine();
writer.WriteLine(entry.Description);
writer.Write("You: ");
writer.Flush();
return;
}
if (_inputActive && !(entry.Sender == "Input" && entry.Type == LogType.Write))
{
if (entry.Type == LogType.Progress && _deferred.Count > 0 &&
_deferred[^1].Type == LogType.Progress && _deferred[^1].Sender == entry.Sender)
_deferred[^1] = entry;
else _deferred.Add(entry);
return;
}
if (entry.Type == LogType.Progress)
{
_progress = entry.Description;
if (overwriteProgress)
{
int available = Math.Max(1, width?.Invoke() ?? 120);
string text = entry.Description.Replace('\r', ' ').Replace('\n', ' ');
if (text.Length > available) text = text[..available];
writer.Write("\r" + text.PadRight(Math.Min(available, Math.Max(_drawnLength, text.Length))));
_drawnLength = text.Length;
writer.Flush();
}
return;
}
FinishProgress();
if (entry.Type == LogType.Write) writer.Write(entry.Description);
else writer.WriteLine(entry.Description);
writer.Flush();
}
}
public void Flush()
{
lock (_sync)
{
FinishProgress();
writer.Flush();
}
}
private void FinishProgress()
{
if (_progress is null) return;
if (overwriteProgress) writer.WriteLine();
else writer.WriteLine(_progress);
_progress = null;
_drawnLength = 0;
}
private void EndInput()
{
lock (_sync)
{
_inputActive = false;
if (_deferred.Count > 0) writer.WriteLine();
foreach (var entry in _deferred) Handle(entry);
_deferred.Clear();
}
}
}
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;
/// <summary>Renders log types consistently; files retain the last update of each progress phase.</summary>
public sealed class TextLogSink(TextWriter writer, bool overwriteProgress = false, Func<int>? width = null)
{
private readonly object _sync = new();
private string? _progress;
private int _drawnLength;
private bool _inputActive;
private readonly List<LogEntry> _deferred = [];
/// <summary>Defer background output until the current console input has finished.</summary>
public IDisposable BeginInput()
{
lock (_sync)
{
if (_inputActive) throw new InvalidOperationException("Console input is already active.");
FinishProgress();
_inputActive = true;
return new TextLogInputLease(EndInput);
}
}
public void Handle(LogEntry entry)
{
lock (_sync)
{
if (_inputActive && !(entry.Sender == "Input" && entry.Type == LogType.Write))
{
if (entry.Type == LogType.Progress && _deferred.Count > 0 &&
_deferred[^1].Type == LogType.Progress && _deferred[^1].Sender == entry.Sender)
_deferred[^1] = entry;
else _deferred.Add(entry);
return;
}
if (entry.Type == LogType.Progress)
{
_progress = entry.Description;
if (overwriteProgress)
{
int available = Math.Max(1, width?.Invoke() ?? 120);
string text = entry.Description.Replace('\r', ' ').Replace('\n', ' ');
if (text.Length > available) text = text[..available];
writer.Write("\r" + text.PadRight(Math.Min(available, Math.Max(_drawnLength, text.Length))));
_drawnLength = text.Length;
writer.Flush();
}
return;
}
FinishProgress();
if (entry.Type == LogType.Write) writer.Write(entry.Description);
else writer.WriteLine(entry.Description);
writer.Flush();
}
}
public void Flush()
{
lock (_sync)
{
FinishProgress();
writer.Flush();
}
}
private void FinishProgress()
{
if (_progress is null) return;
if (overwriteProgress) writer.WriteLine();
else writer.WriteLine(_progress);
_progress = null;
_drawnLength = 0;
}
private void EndInput()
{
lock (_sync)
{
_inputActive = false;
if (_deferred.Count > 0) writer.WriteLine();
foreach (var entry in _deferred) Handle(entry);
_deferred.Clear();
}
}
}
Lilith/Core/Providers 1 files
ModifiedProviderClient.cs18,805 B / 333 lines+28-1+1,558 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;
using System.Text.Json;
using Lilith.Core.Tools;
/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract partial class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
private readonly bool _ownsHttp = httpClient is null;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
private readonly Dictionary<string, ILilithTool> _tools = new(StringComparer.Ordinal);
internal IReadOnlyDictionary<string, ILilithTool> RegisteredTools => _tools;
public Lilith.Core.Workspace.WorkspaceSession? Workspace { get; private set; }
public Lilith.Core.Scheduling.ScheduleSession? Schedules { get; private set; }
internal bool ExecutingScheduledJob { get; private set; }
public void ConfigureSchedules(Lilith.Core.Scheduling.ScheduleOptions options, string? scenarioRoot = null, TimeProvider? clock = null)
{
using var operation = EnterOperation();
if (Schedules is not null) throw new InvalidOperationException("Schedules are fixed for this provider session.");
Schedules = new(options, scenarioRoot, clock);
}
public void RecordScheduleEvent(Lilith.Core.Scheduling.ScheduleEvent item)
{
using var operation = EnterOperation();
// Trusted application envelope; reminder text is data, never a new user instruction.
History.Add("system", "Scheduler event (data): " + JsonSerializer.Serialize(new { item.Id, item.Name, item.Kind, item.AtUtc, item.Message }));
SaveCore();
Logger.WriteLine("Scheduler", item.Message, LogColors.Yellow);
}
public async Task<string> RunScheduledScenarioAsync(string name, IReadOnlyDictionary<string, string> inputs, CancellationToken ct = default)
{
using var operation = EnterOperation();
ExecutingScheduledJob = true;
try { return await RunScenarioCoreAsync(name, inputs, ct); }
finally { ExecutingScheduledJob = false; }
}
public void ConfigureWorkspace(Lilith.Core.Workspace.WorkspaceOptions options)
{
using var operation = EnterOperation();
if (Workspace is not null) throw new InvalidOperationException("Workspace mode and root are fixed for this provider session.");
Workspace = new(options);
}
public async Task<string> ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
using var grant = (Workspace ?? throw new InvalidOperationException("No workspace configured.")).Authorize(id);
return await RunScenarioCoreAsync("workspace_approve", new Dictionary<string, string> { ["bin_id"] = id }, cancellationToken);
}
public event Action<Lilith.Core.Scenarios.ScenarioProgress>? ScenarioProgress;
internal void ReportScenario(Lilith.Core.Scenarios.ScenarioProgress progress)
{
ReportSubagent(progress);
if (ScenarioProgress is not { } handlers) return;
foreach (var handler in handlers.GetInvocationList().Cast<Action<Lilith.Core.Scenarios.ScenarioProgress>>())
try { handler(progress); }
catch (Exception ex) { Logger.WriteWarning("Scenario", "Progress observer failed: " + ex.Message); }
}
/// <summary>Fresh context with borrowed transport; never boots or reenters the parent.</summary>
public virtual ProviderClient CreateScenarioBranch(string prompt, Logger branchLogger, ChatHistoryStore store) =>
throw new NotSupportedException("This provider does not support scenario branches.");
protected object[]? ToolDefinitions => _tools.Count == 0 ? null : _tools.Values.Select(t => (object)new
{
type = "function", function = new { name = t.Name, description = t.Description, parameters = t.Parameters }
}).ToArray();
public void RegisterTool(ILilithTool tool)
{
using var operation = EnterOperation();
ArgumentNullException.ThrowIfNull(tool);
if (string.IsNullOrWhiteSpace(tool.Name) || tool.Name.Length > 64 ||
tool.Name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) ||
tool.Parameters.ValueKind != JsonValueKind.Object)
throw new ArgumentException("A tool requires a valid function name and JSON parameter schema.", nameof(tool));
if (!_tools.TryAdd(tool.Name, tool)) throw new ArgumentException($"Tool '{tool.Name}' is already registered.", nameof(tool));
}
public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
public string Model { get; set; } = "gemma4";
protected int NumCtx { get; set; } = 131072;
public int ContextMax => NumCtx;
public string SystemPrompt { get; protected set; } = string.Empty;
/// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
public ChatHistory History { get; } = [];
public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
public Logger Logger { get; } = logger;
public string AgentName { get; set; } = "Lilith";
public int ContextUsagePercent { get; private set; }
public int CurrentContextTokens { get; private set; }
public int ContextWarningThreshold { get; set; } = 60;
public event Action<int>? OnContextHighUsage;
public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);
protected void InitializeConversation()
{
History.Add("system", SystemPrompt);
ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
}
private IDisposable EnterOperation()
{
var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
return operation;
}
public void NewConversation()
{
using var operation = EnterOperation();
SaveCore();
HistoryStore.StartNewSession();
ClearCore();
Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
}
public void SaveConversation() => SaveCurrentConversation();
public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
public bool LoadConversation(string id) => LoadSavedConversation(id);
public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }
private void ClearCore()
{
History.Clear();
ResetUsage();
if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
}
private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }
private void SaveCore()
{
try
{
if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
HistoryStore.SaveSession(Model, History);
}
catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
}
public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
{
using var operation = EnterOperation();
return HistoryStore.ListSessions();
}
public bool LoadSavedConversation(int displayIndex)
{
using var operation = EnterOperation();
var sessions = HistoryStore.ListSessions();
return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
}
public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }
private bool LoadCore(string id)
{
string previousId = HistoryStore.CurrentSessionId;
try
{
var session = HistoryStore.LoadSession(id);
if (session is null) return false;
var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
if (messages[0].Role != "system")
{
if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
messages.Insert(0, new ChatMessage("system", SystemPrompt));
}
History.Clear();
History.AddRange(messages);
ResetUsage();
return true;
}
catch (Exception ex)
{
HistoryStore.UseSession(previousId);
Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
return false;
}
}
protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);
protected virtual Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages, CancellationToken cancellationToken) =>
GenerateReplyAsync(messages);
public async Task<string> StreamReply(string userInput, List<string>? images = null, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
return await StreamReplyCoreAsync(userInput, images, cancellationToken);
}
/// <summary>Application-requested scenario dispatch; no model decision is needed to start it.</summary>
public async Task<string> RunScenarioAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs = null,
CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
return await RunScenarioCoreAsync(scenarioName, inputs, cancellationToken);
}
private async Task<string> RunScenarioCoreAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs,
CancellationToken cancellationToken)
{
if (!_tools.TryGetValue("run_scenario", out var tool) || tool is not Lilith.Core.Scenarios.RunScenarioTool)
throw new InvalidOperationException("Register scenario support before running a scenario.");
var arguments = JsonSerializer.SerializeToElement(new { scenario_name = scenarioName, inputs = inputs ?? new Dictionary<string, string>() });
var call = new ToolCall(new ToolFunction("run_scenario", arguments), "application-" + Guid.NewGuid().ToString("N"));
var dispatch = new ChatChunk(new ChatMessage("assistant", "", ToolCalls: [call]), true, null, null);
Logger.WriteLine("System", "Application requested scenario: " + scenarioName, LogColors.Cyan);
return await StreamReplyCoreAsync(scenarioName == "greeting" ? "Please greet the user using the greeting scenario." : "Run " + scenarioName + " scenario.",
null, cancellationToken, dispatch);
}
private async Task<string> StreamReplyCoreAsync(string userInput, List<string>? images, CancellationToken cancellationToken,
ChatChunk? applicationDispatch = null)
{
bool requireScenarioSuccess = applicationDispatch is not null;
if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
var requestHistory = History.Select(m => m.Snapshot()).ToList();
requestHistory.Add(userMessage);
Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
ChatChunk? chunk;
int originalCount = requestHistory.Count - 1;
int rounds = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
chunk = applicationDispatch ?? await GenerateReplyAsync(requestHistory, cancellationToken);
applicationDispatch = null;
cancellationToken.ThrowIfCancellationRequested();
if (chunk is not { Done: true, Message.Role: "assistant" } ||
chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
throw new InvalidDataException("The model provider returned an invalid assistant response.");
if (chunk.Message.ToolCalls is not { Count: > 0 } calls) break;
if (++rounds > 8 || calls.Count > 16)
throw new InvalidDataException("The model exceeded the tool execution limit.");
foreach (var call in calls)
if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) ||
call.Function.Arguments.ValueKind != JsonValueKind.Object)
throw new InvalidDataException("The model returned a malformed tool call.");
if (calls.Count != 1 && calls.Any(c => _tools.TryGetValue(c.Function.Name, out var candidate) && candidate is ITurnResultTool))
throw new InvalidDataException("Run a scenario in its own tool batch so its final output is unambiguous.");
requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
string? exactReply = null;
foreach (var call in calls)
{
cancellationToken.ThrowIfCancellationRequested();
string result;
if (!_tools.TryGetValue(call.Function.Name, out var tool))
result = JsonSerializer.Serialize(new { error = "Unknown tool: " + call.Function.Name });
else
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(tool is ITurnResultTool timed ? timed.ExecutionTimeout : TimeSpan.FromSeconds(30));
try
{
// Await actual completion: custom tools must cooperate with cancellation.
if (tool is ITurnResultTool turnTool)
{
var outcome = await turnTool.ExecuteResultAsync(call.Function.Arguments, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
if (requireScenarioSuccess && call.Function.Name == "run_scenario")
{
using var status = JsonDocument.Parse(outcome.Content);
string? scenarioStatus = status.RootElement.GetProperty("status").GetString();
if (scenarioStatus != "completed" && !(ExecutingScheduledJob && scenarioStatus == "needs_approval"))
throw new InvalidDataException("Configured scenario did not complete: " + outcome.Content);
}
result = outcome.Content;
if (outcome.ExactReply is { } exact)
{
if (exactReply is not null) throw new InvalidDataException("Only one exact scenario output is allowed per tool batch.");
exactReply = exact;
}
}
else result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex) when (!requireScenarioSuccess && ex is not OutOfMemoryException)
{
result = JsonSerializer.Serialize(new { error = ex is OperationCanceledException ? "Tool execution canceled or timed out." : ex.Message });
}
}
requestHistory.Add(new ChatMessage("tool", result ?? string.Empty, ToolName: call.Function.Name, ToolCallId: call.Id));
}
if (exactReply is not null)
{
chunk = new ChatChunk(new ChatMessage("assistant", exactReply), true, null, null);
break;
}
}
cancellationToken.ThrowIfCancellationRequested();
if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
string reply = chunk.Message.Content;
// Commit only after a successful final answer.
requestHistory[originalCount] = userMessage with { Tokens = chunk.PromptTokens };
History.AddRange(requestHistory.Skip(originalCount).Select(m => m.Snapshot()));
History.Add("assistant", reply, chunk.CompletionTokens);
SaveCore();
Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
ResetUsage();
if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
{
CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
try { handler(ContextUsagePercent); }
catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
}
return reply;
}
public void Dispose()
{
using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
if (_disposed) return;
_disposed = true;
Schedules?.Dispose();
if (_ownsHttp) Http.Dispose();
GC.SuppressFinalize(this);
}
}
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;
using System.Text.Json;
using Lilith.Core.Tools;
/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract partial class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
private readonly bool _ownsHttp = httpClient is null;
private readonly SemaphoreSlim _operation = new(1, 1);
private bool _disposed;
private readonly Dictionary<string, ILilithTool> _tools = new(StringComparer.Ordinal);
internal IReadOnlyDictionary<string, ILilithTool> RegisteredTools => _tools;
public Lilith.Core.Workspace.WorkspaceSession? Workspace { get; private set; }
public void ConfigureWorkspace(Lilith.Core.Workspace.WorkspaceOptions options)
{
using var operation = EnterOperation();
if (Workspace is not null) throw new InvalidOperationException("Workspace mode and root are fixed for this provider session.");
Workspace = new(options);
}
public async Task<string> ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
using var grant = (Workspace ?? throw new InvalidOperationException("No workspace configured.")).Authorize(id);
return await RunScenarioCoreAsync("workspace_approve", new Dictionary<string, string> { ["bin_id"] = id }, cancellationToken);
}
public event Action<Lilith.Core.Scenarios.ScenarioProgress>? ScenarioProgress;
internal void ReportScenario(Lilith.Core.Scenarios.ScenarioProgress progress)
{
ReportSubagent(progress);
if (ScenarioProgress is not { } handlers) return;
foreach (var handler in handlers.GetInvocationList().Cast<Action<Lilith.Core.Scenarios.ScenarioProgress>>())
try { handler(progress); }
catch (Exception ex) { Logger.WriteWarning("Scenario", "Progress observer failed: " + ex.Message); }
}
/// <summary>Fresh context with borrowed transport; never boots or reenters the parent.</summary>
public virtual ProviderClient CreateScenarioBranch(string prompt, Logger branchLogger, ChatHistoryStore store) =>
throw new NotSupportedException("This provider does not support scenario branches.");
protected object[]? ToolDefinitions => _tools.Count == 0 ? null : _tools.Values.Select(t => (object)new
{
type = "function", function = new { name = t.Name, description = t.Description, parameters = t.Parameters }
}).ToArray();
public void RegisterTool(ILilithTool tool)
{
using var operation = EnterOperation();
ArgumentNullException.ThrowIfNull(tool);
if (string.IsNullOrWhiteSpace(tool.Name) || tool.Name.Length > 64 ||
tool.Name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) ||
tool.Parameters.ValueKind != JsonValueKind.Object)
throw new ArgumentException("A tool requires a valid function name and JSON parameter schema.", nameof(tool));
if (!_tools.TryAdd(tool.Name, tool)) throw new ArgumentException($"Tool '{tool.Name}' is already registered.", nameof(tool));
}
public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
public string Model { get; set; } = "gemma4";
protected int NumCtx { get; set; } = 131072;
public int ContextMax => NumCtx;
public string SystemPrompt { get; protected set; } = string.Empty;
/// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
public ChatHistory History { get; } = [];
public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
public Logger Logger { get; } = logger;
public string AgentName { get; set; } = "Lilith";
public int ContextUsagePercent { get; private set; }
public int CurrentContextTokens { get; private set; }
public int ContextWarningThreshold { get; set; } = 60;
public event Action<int>? OnContextHighUsage;
public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);
protected void InitializeConversation()
{
History.Add("system", SystemPrompt);
ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
}
private IDisposable EnterOperation()
{
var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
return operation;
}
public void NewConversation()
{
using var operation = EnterOperation();
SaveCore();
HistoryStore.StartNewSession();
ClearCore();
Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
}
public void SaveConversation() => SaveCurrentConversation();
public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
public bool LoadConversation(string id) => LoadSavedConversation(id);
public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }
private void ClearCore()
{
History.Clear();
ResetUsage();
if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
}
private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }
private void SaveCore()
{
try
{
if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
HistoryStore.SaveSession(Model, History);
}
catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
}
public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
{
using var operation = EnterOperation();
return HistoryStore.ListSessions();
}
public bool LoadSavedConversation(int displayIndex)
{
using var operation = EnterOperation();
var sessions = HistoryStore.ListSessions();
return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
}
public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }
private bool LoadCore(string id)
{
string previousId = HistoryStore.CurrentSessionId;
try
{
var session = HistoryStore.LoadSession(id);
if (session is null) return false;
var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
if (messages[0].Role != "system")
{
if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
messages.Insert(0, new ChatMessage("system", SystemPrompt));
}
History.Clear();
History.AddRange(messages);
ResetUsage();
return true;
}
catch (Exception ex)
{
HistoryStore.UseSession(previousId);
Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
return false;
}
}
protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);
protected virtual Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages, CancellationToken cancellationToken) =>
GenerateReplyAsync(messages);
public async Task<string> StreamReply(string userInput, List<string>? images = null, CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
return await StreamReplyCoreAsync(userInput, images, cancellationToken);
}
/// <summary>Application-requested scenario dispatch; no model decision is needed to start it.</summary>
public async Task<string> RunScenarioAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs = null,
CancellationToken cancellationToken = default)
{
using var operation = EnterOperation();
return await RunScenarioCoreAsync(scenarioName, inputs, cancellationToken);
}
private async Task<string> RunScenarioCoreAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs,
CancellationToken cancellationToken)
{
if (!_tools.TryGetValue("run_scenario", out var tool) || tool is not Lilith.Core.Scenarios.RunScenarioTool)
throw new InvalidOperationException("Register scenario support before running a scenario.");
var arguments = JsonSerializer.SerializeToElement(new { scenario_name = scenarioName, inputs = inputs ?? new Dictionary<string, string>() });
var call = new ToolCall(new ToolFunction("run_scenario", arguments), "application-" + Guid.NewGuid().ToString("N"));
var dispatch = new ChatChunk(new ChatMessage("assistant", "", ToolCalls: [call]), true, null, null);
Logger.WriteLine("System", "Application requested scenario: " + scenarioName, LogColors.Cyan);
return await StreamReplyCoreAsync(scenarioName == "greeting" ? "Please greet the user using the greeting scenario." : "Run " + scenarioName + " scenario.",
null, cancellationToken, dispatch);
}
private async Task<string> StreamReplyCoreAsync(string userInput, List<string>? images, CancellationToken cancellationToken,
ChatChunk? applicationDispatch = null)
{
bool requireScenarioSuccess = applicationDispatch is not null;
if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
var requestHistory = History.Select(m => m.Snapshot()).ToList();
requestHistory.Add(userMessage);
Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
ChatChunk? chunk;
int originalCount = requestHistory.Count - 1;
int rounds = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
chunk = applicationDispatch ?? await GenerateReplyAsync(requestHistory, cancellationToken);
applicationDispatch = null;
cancellationToken.ThrowIfCancellationRequested();
if (chunk is not { Done: true, Message.Role: "assistant" } ||
chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
throw new InvalidDataException("The model provider returned an invalid assistant response.");
if (chunk.Message.ToolCalls is not { Count: > 0 } calls) break;
if (++rounds > 8 || calls.Count > 16)
throw new InvalidDataException("The model exceeded the tool execution limit.");
foreach (var call in calls)
if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) ||
call.Function.Arguments.ValueKind != JsonValueKind.Object)
throw new InvalidDataException("The model returned a malformed tool call.");
if (calls.Count != 1 && calls.Any(c => _tools.TryGetValue(c.Function.Name, out var candidate) && candidate is ITurnResultTool))
throw new InvalidDataException("Run a scenario in its own tool batch so its final output is unambiguous.");
requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
string? exactReply = null;
foreach (var call in calls)
{
cancellationToken.ThrowIfCancellationRequested();
string result;
if (!_tools.TryGetValue(call.Function.Name, out var tool))
result = JsonSerializer.Serialize(new { error = "Unknown tool: " + call.Function.Name });
else
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(tool is ITurnResultTool timed ? timed.ExecutionTimeout : TimeSpan.FromSeconds(30));
try
{
// Await actual completion: custom tools must cooperate with cancellation.
if (tool is ITurnResultTool turnTool)
{
var outcome = await turnTool.ExecuteResultAsync(call.Function.Arguments, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
if (requireScenarioSuccess && call.Function.Name == "run_scenario")
{
using var status = JsonDocument.Parse(outcome.Content);
if (status.RootElement.GetProperty("status").GetString() != "completed")
throw new InvalidDataException("Configured scenario did not complete: " + outcome.Content);
}
result = outcome.Content;
if (outcome.ExactReply is { } exact)
{
if (exactReply is not null) throw new InvalidDataException("Only one exact scenario output is allowed per tool batch.");
exactReply = exact;
}
}
else result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (Exception ex) when (!requireScenarioSuccess && ex is not OutOfMemoryException)
{
result = JsonSerializer.Serialize(new { error = ex is OperationCanceledException ? "Tool execution canceled or timed out." : ex.Message });
}
}
requestHistory.Add(new ChatMessage("tool", result ?? string.Empty, ToolName: call.Function.Name, ToolCallId: call.Id));
}
if (exactReply is not null)
{
chunk = new ChatChunk(new ChatMessage("assistant", exactReply), true, null, null);
break;
}
}
cancellationToken.ThrowIfCancellationRequested();
if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
string reply = chunk.Message.Content;
// Commit only after a successful final answer.
requestHistory[originalCount] = userMessage with { Tokens = chunk.PromptTokens };
History.AddRange(requestHistory.Skip(originalCount).Select(m => m.Snapshot()));
History.Add("assistant", reply, chunk.CompletionTokens);
SaveCore();
Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
ResetUsage();
if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
{
CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
try { handler(ContextUsagePercent); }
catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
}
return reply;
}
public void Dispose()
{
using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
if (_disposed) return;
_disposed = true;
if (_ownsHttp) Http.Dispose();
GC.SuppressFinalize(this);
}
}
Lilith/Core/Scenarios 2 files
ModifiedRunScenarioTool.cs6,543 B / 87 lines+5-0+878 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).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. " +
"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.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)));
}
ModifiedScenarioExecutor.cs7,351 B / 95 lines+10-4+1,173 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)
{
/// <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);
}
}
}
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);
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();
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) && (!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;
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 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.");
var result = new ScenarioRunResult(runId, scenario.Id, last, scenario.ExitMode, exit, storageResult);
await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }), ct);
Report(storageResult?.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);
}
}
}
Lilith/Core/Scenarios/Definitions 7 files
NEWschedule_cancel.xml498 B / 7 lines+7-0+498 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_cancel" title="Schedule cancel">
<inputs><input name="name" required="true" /></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_cancel_operation" /></tools>
<steps><step>Call schedule_cancel_operation with {} now.</step></steps>
<exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_create.xml751 B / 7 lines+7-0+751 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_create" title="Schedule create">
<inputs><input name="name" required="true" /><input name="kind" default="timer" /><input name="seconds" default="0" /><input name="cron" default="" /><input name="timezone" default="" /><input name="message" default="" /><input name="scenario" default="" /><input name="inputs_json" default="{}" /></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_create_operation" /></tools>
<steps><step>Call schedule_create_operation with {} now.</step></steps>
<exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_delete.xml498 B / 7 lines+7-0+498 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_delete" title="Schedule delete">
<inputs><input name="name" required="true" /></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_delete_operation" /></tools>
<steps><step>Call schedule_delete_operation with {} now.</step></steps>
<exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_list.xml559 B / 7 lines+7-0+559 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_list" title="Schedule list">
<inputs></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_list_operation" /></tools>
<steps><step>Call schedule_list_operation with {} now.</step></steps>
<exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_peek.xml596 B / 7 lines+7-0+596 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_peek" title="Schedule peek">
<inputs><input name="name" required="true" /></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_peek_operation" /></tools>
<steps><step>Call schedule_peek_operation with {} now.</step></steps>
<exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_pending.xml571 B / 7 lines+7-0+571 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_pending" title="Schedule pending">
<inputs></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_pending_operation" /></tools>
<steps><step>Call schedule_pending_operation with {} now.</step></steps>
<exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_update.xml751 B / 7 lines+7-0+751 B+
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_update" title="Schedule update">
<inputs><input name="name" required="true" /><input name="kind" default="timer" /><input name="seconds" default="0" /><input name="cron" default="" /><input name="timezone" default="" /><input name="message" default="" /><input name="scenario" default="" /><input name="inputs_json" default="{}" /></inputs>
<systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
<tools><allow name="schedule_update_operation" /></tools>
<steps><step>Call schedule_update_operation with {} now.</step></steps>
<exit mode="copy">Return the verified application result.</exit>
</scenario>
Lilith/Core/Scheduling 4 files
NEWScheduleDelivery.cs1,794 B / 35 lines+35-0+1,794 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Scheduling;
/// <summary>The host calls this only while it owns conversation and narration access.</summary>
public static class ScheduleDelivery
{
public static async Task DrainAsync(ProviderClient provider, Func<string, CancellationToken, Task> narrate,
CancellationToken ct = default)
{
var schedules = provider.Schedules ?? throw new InvalidOperationException("Configure schedules first.");
for (int i = 0; i < 10 && schedules.PeekEvent() is { } item; i++)
{
ct.ThrowIfCancellationRequested();
provider.RecordScheduleEvent(item);
string? failure = null;
Exception? speechFailure = null;
try
{
// A speech failure must not prevent the scheduled action from running.
try { await narrate(item.Message, ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{ speechFailure = ex; }
if (item.Job is { Scenario.Length: > 0 } job)
{
string reply = await provider.RunScheduledScenarioAsync(job.Scenario, job.Inputs, ct);
try { await narrate(reply, ct); }
catch (Exception ex) when (ex is not OperationCanceledException) { speechFailure = ex; }
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) { failure = ex.Message; }
schedules.CompleteEvent(item.Id, failure);
if (speechFailure is not null) throw new InvalidOperationException("Schedule speech failed; the action outcome was retained.", speechFailure);
}
}
}
NEWScheduleModels.cs1,600 B / 27 lines+27-0+1,600 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Workspace;
namespace Lilith.Core.Scheduling;
public sealed record ScheduleOptions(string RootDirectory, WorkspaceAccessMode Mode = WorkspaceAccessMode.Ask);
public sealed record ScheduleRequest(string Operation, string Name = "", string Kind = "timer", int Seconds = 0,
string Cron = "", string TimeZone = "", string Message = "", string Scenario = "", string InputsJson = "{}");
public sealed record ScheduleEntry(string Name, string Kind, int Seconds, string Cron, string TimeZone,
string Message, string Scenario, Dictionary<string, string> Inputs, DateTimeOffset? NextUtc, string Status, int Revision,
string? LastOutcome = null, DateTimeOffset? LastFinishedUtc = null);
public sealed record ScheduleProposal(string Id, ScheduleRequest Request, int? ExpectedRevision);
public sealed record ScheduleEvent(string Id, string Name, string Kind, DateTimeOffset AtUtc, string Message,
ScheduleEntry? Job = null);
public sealed record ScheduleResult(string Status, string Message, string? ProposalId = null, object? Data = null)
{
public string ToReply() => Status == "needs_approval"
? $"{Message}\nReview with /schedules review {ProposalId}; then /schedule-approve {ProposalId} or /schedule-reject {ProposalId}."
: Data is null ? Message : JsonSerializer.Serialize(this);
}
internal sealed class ScheduleState
{
public List<ScheduleEntry> Entries { get; set; } = [];
public List<ScheduleProposal> Pending { get; set; } = [];
public List<ScheduleEvent> Events { get; set; } = [];
}
NEWScheduleSession.cs11,259 B / 225 lines+225-0+11,259 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Scenarios;
using Lilith.Core.Scheduling.Extensions;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;
namespace Lilith.Core.Scheduling;
/// <summary>Persistent, single-host schedule state. Due detection never calls the conversation.</summary>
public sealed class ScheduleSession : IDisposable
{
private readonly object _sync = new();
private readonly FileStream _lease;
private readonly string _file;
private readonly ScenarioFileService _files;
private readonly TimeProvider _clock;
private ScheduleState _state;
private bool _disposed;
private bool _faulted;
public ScheduleOptions Options { get; }
public ScheduleSession(ScheduleOptions options, string? scenarioRoot = null, TimeProvider? clock = null)
{
if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown schedule access mode.");
Options = options with { RootDirectory = Path.GetFullPath(options.RootDirectory) };
_clock = clock ?? TimeProvider.System;
_files = new(scenarioRoot);
Options.RootDirectory.EnsureNoLinks();
Directory.CreateDirectory(Options.RootDirectory);
_lease = new(Path.Combine(Options.RootDirectory, "host.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
_file = Path.Combine(Options.RootDirectory, "schedules.json");
try
{
_state = File.Exists(_file) ? JsonSerializer.Deserialize<ScheduleState>(File.ReadAllText(_file))
?? throw new InvalidDataException("Invalid schedule state.") : new();
Recover();
}
catch { _lease.Dispose(); throw; }
}
public ScheduleResult Execute(ScheduleRequest request)
{
lock (_sync)
{
ThrowIfDisposed();
return request.Operation switch
{
"list" => new("completed", "Schedule status.", Data: _state.Entries.Take(100).Select(e => View(e, false)).ToArray()),
"peek" => new("completed", "Schedule status.", Data: View(Find(request.Name))),
"pending" => new("completed", "Pending schedule changes.", Data: _state.Pending.Select(p => new { p.Id, p.Request.Operation, p.Request.Name }).ToArray()),
"create" or "update" or "cancel" or "delete" => PrepareOrCommit(request),
_ => throw new ArgumentException("Unknown schedule operation.")
};
}
}
public ScheduleProposal Review(string id)
{
lock (_sync) { ThrowIfDisposed(); return _state.Pending.SingleOrDefault(p => p.Id == id) ?? throw new ArgumentException("Unknown proposal."); }
}
// Host-only APIs: neither approval nor rejection is registered as a model tool.
public ScheduleResult Approve(string id)
{
lock (_sync)
{
var proposal = Review(id);
var existing = _state.Entries.SingleOrDefault(e => e.Name.Equals(proposal.Request.Name, StringComparison.OrdinalIgnoreCase));
if (existing?.Revision != proposal.ExpectedRevision) throw new InvalidOperationException("Schedule changed. Reject this proposal and prepare a new one.");
Validate(proposal.Request);
var result = Commit(proposal.Request);
_state.Pending.Remove(proposal);
Save();
return result;
}
}
public void Reject(string id)
{
lock (_sync) { var proposal = Review(id); _state.Pending.Remove(proposal); Save(); }
}
public void Tick()
{
lock (_sync)
{
ThrowIfDisposed();
var now = _clock.GetUtcNow();
var due = _state.Entries.Where(e => e.Status == "active" && e.NextUtc <= now).ToArray();
foreach (var entry in due)
{
// One outstanding run per named job; coalesce late intervals rather than replaying a backlog.
bool outstanding = _state.Events.Any(e => e.Job?.Name == entry.Name);
Replace(entry with
{
NextUtc = entry.Kind == "timer" ? null : entry.NextAfter(now),
Status = entry.Kind == "timer" ? "fired" : "active",
Revision = entry.Revision + 1
});
if (!outstanding) AddEvent(entry, "fired", $"{entry.Name} fired. {entry.Message}", entry);
}
if (due.Length > 0) Save();
}
}
public ScheduleEvent? PeekEvent()
{
lock (_sync) { ThrowIfDisposed(); return _state.Events.FirstOrDefault(); }
}
public void CompleteEvent(string id, string? failure = null)
{
lock (_sync)
{
ThrowIfDisposed();
var item = _state.Events.Single(e => e.Id == id);
_state.Events.Remove(item);
if (item.Job is { } job)
{
var entry = Find(job.Name);
Replace(entry with { Status = entry.Kind == "timer" ? (failure is null ? "completed" : "failed") : entry.Status,
LastOutcome = failure is null ? "completed" : failure[..Math.Min(2000, failure.Length)], LastFinishedUtc = _clock.GetUtcNow() });
AddEvent(job, failure is null ? "completed" : "failed",
failure is null ? $"{job.Name} finished." : $"{job.Name} failed: {failure}");
}
Save();
}
}
private void Recover()
{
var now = _clock.GetUtcNow();
var interrupted = _state.Events.Where(e => e.Job is not null).Select(e => e.Job!.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
_state.Events.RemoveAll(e => e.Job is not null);
foreach (var entry in _state.Entries.ToArray())
{
bool overdue = entry.Status == "active" && entry.NextUtc <= now;
if (!overdue && !interrupted.Contains(entry.Name)) continue;
if (!_state.Events.Any(e => e.Name == entry.Name && e.Kind == "missed"))
AddEvent(entry, "missed", $"{entry.Name}: missed or interrupted occurrence while Lilith was closed. Its action was not replayed.");
if (overdue || (interrupted.Contains(entry.Name) && entry.Kind == "timer")) Replace(entry with { NextUtc = entry.Kind == "timer" ? null : entry.NextAfter(now),
Status = entry.Kind == "timer" ? "missed" : "active", Revision = entry.Revision + 1 });
}
Save();
}
private ScheduleResult PrepareOrCommit(ScheduleRequest request)
{
Validate(request);
if (Options.Mode == WorkspaceAccessMode.Yolo) { var result = Commit(request); Save(); return result; }
if (_state.Pending.Count >= 100) throw new InvalidOperationException("Review existing pending changes first.");
var existing = _state.Entries.SingleOrDefault(e => e.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
string id = Guid.NewGuid().ToString("N");
_state.Pending.Add(new(id, request, existing?.Revision));
Save();
return new("needs_approval", $"Approve schedule {request.Operation}: {request.Name}.", id, request);
}
private void Validate(ScheduleRequest request)
{
switch (request.Operation)
{
case "create":
if (_state.Entries.Count >= 100 || _state.Entries.Any(e => e.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException("Schedule name exists or the 100-schedule limit was reached.");
_ = request.CreateEntry(_clock.GetUtcNow(), 1, _files);
break;
case "update":
_ = request.CreateEntry(_clock.GetUtcNow(), Find(request.Name).Revision + 1, _files);
goto case "cancel";
case "cancel":
case "delete":
_ = Find(request.Name);
if (_state.Events.Any(e => e.Job?.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase) == true))
throw new InvalidOperationException("This occurrence is queued or running; wait for its completion before changing it.");
break;
default: throw new ArgumentException("Unknown mutation.");
}
}
private ScheduleResult Commit(ScheduleRequest request)
{
switch (request.Operation)
{
case "create": _state.Entries.Add(request.CreateEntry(_clock.GetUtcNow(), 1, _files)); break;
case "update": Replace(request.CreateEntry(_clock.GetUtcNow(), Find(request.Name).Revision + 1, _files)); break;
case "cancel": var entry = Find(request.Name); Replace(entry with { Status = "canceled", NextUtc = null, Revision = entry.Revision + 1 }); break;
case "delete": _state.Entries.Remove(Find(request.Name)); break;
default: throw new ArgumentException("Unknown mutation.");
}
return new("completed", $"Schedule {request.Operation} completed: {request.Name}.");
}
private object View(ScheduleEntry entry) => View(entry, true);
private object View(ScheduleEntry entry, bool detail)
{
double? remaining = entry.NextUtc is { } next ? Math.Max(0, (next - _clock.GetUtcNow()).TotalSeconds) : null;
bool queued = _state.Events.Any(e => e.Job?.Name == entry.Name);
return detail
? new { entry.Name, entry.Kind, entry.Status, entry.NextUtc, remaining_seconds = remaining,
entry.TimeZone, entry.Cron, entry.Seconds, entry.Message, entry.Scenario, entry.LastOutcome, entry.LastFinishedUtc, queued_or_running = queued }
: new { entry.Name, entry.Kind, entry.Status, entry.NextUtc, remaining_seconds = remaining, queued_or_running = queued };
}
private ScheduleEntry Find(string name) => _state.Entries.SingleOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("Unknown schedule: " + name);
private void Replace(ScheduleEntry entry) { _state.Entries.Remove(Find(entry.Name)); _state.Entries.Add(entry); }
private void AddEvent(ScheduleEntry entry, string kind, string message, ScheduleEntry? job = null) =>
_state.Events.Add(new(Guid.NewGuid().ToString("N"), entry.Name, kind, _clock.GetUtcNow(), message, job));
private void Save()
{
string temp = _file + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllText(temp, JsonSerializer.Serialize(_state, new JsonSerializerOptions { WriteIndented = true }));
File.Move(temp, _file, true);
}
catch { _faulted = true; throw; }
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_faulted) throw new IOException("Schedule storage failed. Fix storage and restart before continuing.");
}
public void Dispose() { lock (_sync) { if (_disposed) return; _disposed = true; _lease.Dispose(); } }
}
NEWScheduleTool.cs2,134 B / 29 lines+29-0+2,134 B+
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text.Json;
using Lilith.Core.Tools;
namespace Lilith.Core.Scheduling;
internal sealed class ScheduleTool(ScheduleSession session, string operation, IReadOnlyDictionary<string, string> inputs,
Action<ScheduleResult> completed) : ITurnResultTool
{
public string Name => "schedule_" + operation + "_operation";
public string Description => "Perform the scenario's schedule operation once using its bound inputs. Call with {}. Never invent success.";
public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(30);
public JsonElement Parameters => JsonSerializer.SerializeToElement(new { type = "object", properties = new { }, additionalProperties = false });
public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
(await ExecuteResultAsync(arguments, cancellationToken)).Content;
public Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any()) throw new ArgumentException("Expected an empty argument object.");
string Get(string name, string fallback = "") => inputs.GetValueOrDefault(name, fallback);
if (!int.TryParse(Get("seconds", "0"), NumberStyles.None, CultureInfo.InvariantCulture, out int seconds)) throw new ArgumentException("Invalid seconds.");
var result = session.Execute(new(operation, Get("name"), Get("kind", "timer"), seconds, Get("cron"), Get("timezone"), Get("message"), Get("scenario"), Get("inputs_json", "{}")));
completed(result);
string json = JsonSerializer.Serialize(result);
return Task.FromResult(new ToolExecutionResult(json, json));
}
internal static readonly string[] Operations = ["create", "update", "cancel", "delete", "list", "peek", "pending"];
internal static string? Operation(string name) => Operations.FirstOrDefault(op => name == "schedule_" + op + "_operation");
}
Lilith/Core/Scheduling/Extensions 1 files
NEWScheduleExtensions.cs3,282 B / 44 lines+44-0+3,282 B+
C#UnchangedRemoved / replacedAdded
using Cronos;
using Lilith.Core.Scenarios;
using System.Text.Json;
namespace Lilith.Core.Scheduling.Extensions;
internal static class ScheduleExtensions
{
internal static ScheduleEntry CreateEntry(this ScheduleRequest request, DateTimeOffset now, int revision, ScenarioFileService files)
{
if (!ScenarioXmlParser.ValidName(request.Name) || request.Name.Length > 64)
throw new ArgumentException("Use a schedule name of up to 64 letters, digits, underscores or hyphens.");
if (request.Message.Length > 2000 || request.InputsJson.Length > 20000 || request.Cron.Length > 128 || request.TimeZone.Length > 128)
throw new ArgumentException("Schedule input is too large.");
var inputs = JsonSerializer.Deserialize<Dictionary<string, string>>(request.InputsJson) ?? throw new ArgumentException("Expected scenario input object.");
if (inputs.Any(p => p.Value is null)) throw new ArgumentException("Scenario inputs must be strings.");
if (request.Scenario.Length > 0)
{
if (request.Scenario.StartsWith("schedule_", StringComparison.Ordinal)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
var definition = ScenarioXmlParser.Parse(files.LoadScenarioXml(request.Scenario));
if (definition.AllowedTools.Any(n => ScheduleTool.Operation(n) is not null)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
if (inputs.Keys.Any(k => !definition.Inputs.Any(i => i.Name == k)) ||
definition.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(inputs.TryGetValue(i.Name, out var value) ? value : i.Value)))
throw new ArgumentException("Unknown or missing scheduled scenario inputs.");
}
else if (string.IsNullOrWhiteSpace(request.Message)) throw new ArgumentException("A reminder message or saved scenario is required.");
string zone = string.IsNullOrWhiteSpace(request.TimeZone) ? TimeZoneInfo.Local.Id : request.TimeZone;
_ = TimeZoneInfo.FindSystemTimeZoneById(zone);
var entry = new ScheduleEntry(request.Name, request.Kind, request.Seconds, request.Cron, zone,
request.Message, request.Scenario, inputs, null, "active", revision);
return entry with { NextUtc = entry.NextAfter(now) ?? throw new ArgumentException("Schedule has no future occurrence.") };
}
internal static DateTimeOffset? NextAfter(this ScheduleEntry entry, DateTimeOffset now) => entry.Kind switch
{
"timer" or "interval" when entry.Seconds is >= 1 and <= 31536000 => now.AddSeconds(entry.Seconds),
"cron" => ParseCron(entry.Cron).GetNextOccurrence(now, TimeZoneInfo.FindSystemTimeZoneById(entry.TimeZone))?.ToUniversalTime(),
_ => throw new ArgumentException("Choose timer or interval with 1–31536000 seconds, or a five-field cron expression.")
};
private static CronExpression ParseCron(string expression) => expression.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 5
? CronExpression.Parse(expression, CronFormat.Standard)
: throw new ArgumentException("Cron requires exactly five fields: minute hour day month weekday.");
}
No changed files match that search.