V12 / ea1357ad1734
ProviderClient.cs
Lilith/Core/Providers/ProviderClient.cs
Unchanged from the preceding versionCS
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.WebResearch.IResearchBrowser? ResearchBrowser { get; private set; }
public void ConfigureResearchBrowser(Lilith.Core.WebResearch.IResearchBrowser browser)
{
using var operation = EnterOperation();
if (ResearchBrowser is not null) throw new InvalidOperationException("Research browser is fixed for this provider session.");
ResearchBrowser = browser ?? throw new ArgumentNullException(nameof(browser));
}
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;
string personalityBasePrompt = requestHistory.FirstOrDefault()?.Content ?? SystemPrompt;
int rounds = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var generationHistory = PreparePersonalityPrompt(requestHistory, personalityBasePrompt, originalCount);
chunk = applicationDispatch ?? await GenerateReplyAsync(generationHistory, 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();
Calendar?.Dispose();
if (_ownsHttp) Http.Dispose();
GC.SuppressFinalize(this);
}
}