V2 / CHANGESET
Only the delta.
The exact changes from V1 to V2. 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.props217 B / 7 lines+2-2-6 B+
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- Shared V2 release version. V1 remains on release/v1 and v1.0.0. -->
<PropertyGroup>
<VersionPrefix>2.0.1</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
<!-- V1 baseline. Set VersionPrefix=2.0.0 and VersionSuffix=alpha.1 on v2. -->
<PropertyGroup>
<VersionPrefix>1.0.1</VersionPrefix>
<VersionSuffix></VersionSuffix>
</PropertyGroup>
</Project>
Lilith/Agent 1 files
ModifiedLilith.cs2,448 B / 61 lines+7-1+298 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 ProviderClient _llmclient = null!;
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)
{
_llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
foreach (var tool in Tools) _llmclient.RegisterTool(tool);
}
/// <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; }
}
}
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 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) =>
_llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
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/Chat 5 files
ModifiedChatHistoryStore.cs11,173 B / 278 lines+33-2+2,035 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
using System.Text.Json;
using Lilith.Core.Tools;
namespace Lilith.Core.Chat;
public class ChatHistoryStore
{
public string CurrentSessionId { get; private set; } = NewSessionId();
public string RootDirectory { get; }
public ChatHistoryStore() : this(null)
{
}
/// <param name="rootDirectoryOverride">Optional folder for tests or custom deployments.</param>
public ChatHistoryStore(string? rootDirectoryOverride)
{
if (!string.IsNullOrWhiteSpace(rootDirectoryOverride))
{
RootDirectory = rootDirectoryOverride;
return;
}
string? envRoot = Environment.GetEnvironmentVariable("AGENT_CORE_CHAT_HISTORY_ROOT");
if (!string.IsNullOrWhiteSpace(envRoot))
{
RootDirectory = envRoot;
return;
}
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
if (string.IsNullOrWhiteSpace(appData))
{
appData = AppContext.BaseDirectory;
}
RootDirectory = Path.Combine(appData, "AgentCore", DetectVersionLabel(), "ChatHistory");
}
public void StartNewSession() => CurrentSessionId = NewSessionId();
public void UseSession(string id)
{
if (!string.IsNullOrWhiteSpace(id))
{
CurrentSessionId = SanitizeId(id);
}
}
public string SaveSession(string model, IEnumerable<ChatMessage> history) =>
SaveSession(CurrentSessionId, model, history);
public string SaveSession(string? sessionId, string model, IEnumerable<ChatMessage> history)
{
Directory.CreateDirectory(RootDirectory);
string id = string.IsNullOrWhiteSpace(sessionId) ? NewSessionId() : SanitizeId(sessionId);
string path = SessionPath(id);
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset createdAt = ReadCreatedAt(path) ?? now;
var messages = history.Select(m => m.ToSavedMessage()).ToList();
var doc = new XDocument(
new XElement("chatSession",
new XAttribute("id", id),
new XAttribute("title", MakeTitle(messages)),
new XAttribute("model", model),
new XAttribute("createdAt", createdAt.ToString("O")),
new XAttribute("updatedAt", now.ToString("O")),
new XElement("messages",
messages.Select(m =>
new XElement("message",
new XAttribute("role", m.Role),
m.Tokens.HasValue ? new XAttribute("tokens", m.Tokens.Value) : null,
new XElement("content", m.Content),
m.ToolCalls is { Count: > 0 } ? new XElement("toolCalls", JsonSerializer.Serialize(m.ToolCalls)) : null,
m.ToolName is not null ? new XElement("toolName", m.ToolName) : null,
m.ToolCallId is not null ? new XElement("toolCallId", m.ToolCallId) : null,
m.Images is { Count: > 0 }
? new XElement("images", m.Images.Select(i => new XElement("image", i)))
: null)))));
AtomicFile.Write(path, doc.Save);
CurrentSessionId = id;
return id;
}
public IReadOnlyList<ChatSessionSummary> ListSessions()
{
if (!Directory.Exists(RootDirectory))
{
return [];
}
return Directory.EnumerateFiles(RootDirectory, "*.xml")
.Select(ReadSummary)
.Where(s => s is not null)
.Select(s => s!)
.OrderByDescending(s => s.UpdatedAt)
.ToList();
}
public SavedChatSession? LoadSession(string id)
{
string path = SessionPath(SanitizeId(id));
if (!File.Exists(path))
{
return null;
}
var session = ReadSession(path, SanitizeId(id));
CurrentSessionId = session.Id;
return session;
}
private static SavedChatSession ReadSession(string path, string id)
{
XDocument doc = XDocument.Load(path);
XElement root = doc.Root ?? throw new InvalidDataException("Chat XML has no root.");
if (root.Name != "chatSession" || root.Element("messages") is null ||
((string?)root.Attribute("id") is { } embedded && embedded != id))
throw new InvalidDataException("Invalid chat session structure or identity.");
var session = new SavedChatSession
{
Id = id,
Title = (string?)root.Attribute("title") ?? "New chat",
Model = (string?)root.Attribute("model") ?? string.Empty,
CreatedAt = ParseDate((string?)root.Attribute("createdAt")) ?? DateTimeOffset.Now,
UpdatedAt = ParseDate((string?)root.Attribute("updatedAt")) ?? DateTimeOffset.Now,
Messages = root.Element("messages")?.Elements("message").Select(ReadMessage).ToList() ?? []
};
if (session.Messages.Count == 0) throw new InvalidDataException("Chat has no messages.");
var pending = new Queue<ToolCall>();
for (int i = 0; i < session.Messages.Count; i++)
{
var message = session.Messages[i];
if (message.Role is not ("system" or "user" or "assistant" or "tool") || message.Tokens is < 0 ||
(message.Role == "system" && (i != 0 || string.IsNullOrWhiteSpace(message.Content))))
throw new InvalidDataException("Chat contains invalid messages.");
if (message.Role == "tool")
{
if (!pending.TryDequeue(out var call) || message.ToolName != call.Function.Name || message.ToolCallId != call.Id ||
message.ToolCalls is { Count: > 0 })
throw new InvalidDataException("Chat contains an unmatched tool result.");
}
else
{
if (pending.Count != 0 || message.ToolName is not null || message.ToolCallId is not null)
throw new InvalidDataException("Chat contains an incomplete tool exchange.");
if (message.ToolCalls is { Count: > 0 } calls)
{
if (message.Role != "assistant") throw new InvalidDataException("Only assistants may call tools.");
foreach (var call in calls)
{
if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) || call.Function.Arguments.ValueKind != JsonValueKind.Object)
throw new InvalidDataException("Invalid saved tool call.");
pending.Enqueue(call);
}
}
}
}
if (pending.Count != 0) throw new InvalidDataException("Chat ends with pending tool calls.");
return session;
}
private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml");
private static SavedChatMessage ReadMessage(XElement element)
{
int? tokens = null;
if (element.Attribute("tokens") is { } attribute)
{
if (!int.TryParse(attribute.Value, out int value)) throw new InvalidDataException("Invalid token count.");
tokens = value;
}
var images = element.Element("images")?.Elements("image")
.Select(i => i.Value)
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToList();
return new SavedChatMessage
{
Role = (string?)element.Attribute("role") ?? string.Empty,
Content = element.Element("content")?.Value ?? string.Empty,
Tokens = tokens,
Images = images is { Count: > 0 } ? images : null,
ToolCalls = element.Element("toolCalls") is { } calls ? JsonSerializer.Deserialize<List<ToolCall>>(calls.Value) : null,
ToolName = element.Element("toolName")?.Value,
ToolCallId = element.Element("toolCallId")?.Value
};
}
private static ChatSessionSummary? ReadSummary(string path)
{
try
{
string id = Path.GetFileNameWithoutExtension(path);
var session = ReadSession(path, id);
return new ChatSessionSummary(
id,
session.Title, session.CreatedAt, session.UpdatedAt, session.Messages.Count, session.Model);
}
catch
{
return null;
}
}
private static DateTimeOffset? ReadCreatedAt(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
XDocument doc = XDocument.Load(path);
return ParseDate((string?)doc.Root?.Attribute("createdAt"));
}
catch
{
return null;
}
}
private static DateTimeOffset? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static string MakeTitle(IReadOnlyList<SavedChatMessage> messages)
{
string? firstUser = messages
.Where(m => m.Role.Equals("user", StringComparison.OrdinalIgnoreCase))
.Select(m => m.Content.Trim())
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
!m.StartsWith("Please greet", StringComparison.OrdinalIgnoreCase) &&
!m.Equals("hello", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(firstUser))
{
firstUser = messages.FirstOrDefault(m => m.Role != "system" && !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim();
}
if (string.IsNullOrWhiteSpace(firstUser))
{
return "New chat";
}
firstUser = RegexPatterns.Whitespace().Replace(firstUser, " ");
return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "...";
}
private static string DetectVersionLabel()
{
foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
var match = RegexPatterns.VersionLabel().Match(source);
if (match.Success)
{
return RegexPatterns.Whitespace().Replace(match.Value, " ");
}
}
return "Default";
}
private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff") + "-" + Guid.NewGuid().ToString("N");
private static string SanitizeId(string id) =>
string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
}
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class ChatHistoryStore
{
public string CurrentSessionId { get; private set; } = NewSessionId();
public string RootDirectory { get; }
public ChatHistoryStore() : this(null)
{
}
/// <param name="rootDirectoryOverride">Optional folder for tests or custom deployments.</param>
public ChatHistoryStore(string? rootDirectoryOverride)
{
if (!string.IsNullOrWhiteSpace(rootDirectoryOverride))
{
RootDirectory = rootDirectoryOverride;
return;
}
string? envRoot = Environment.GetEnvironmentVariable("AGENT_CORE_CHAT_HISTORY_ROOT");
if (!string.IsNullOrWhiteSpace(envRoot))
{
RootDirectory = envRoot;
return;
}
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
if (string.IsNullOrWhiteSpace(appData))
{
appData = AppContext.BaseDirectory;
}
RootDirectory = Path.Combine(appData, "AgentCore", DetectVersionLabel(), "ChatHistory");
}
public void StartNewSession() => CurrentSessionId = NewSessionId();
public void UseSession(string id)
{
if (!string.IsNullOrWhiteSpace(id))
{
CurrentSessionId = SanitizeId(id);
}
}
public string SaveSession(string model, IEnumerable<ChatMessage> history) =>
SaveSession(CurrentSessionId, model, history);
public string SaveSession(string? sessionId, string model, IEnumerable<ChatMessage> history)
{
Directory.CreateDirectory(RootDirectory);
string id = string.IsNullOrWhiteSpace(sessionId) ? NewSessionId() : SanitizeId(sessionId);
string path = SessionPath(id);
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset createdAt = ReadCreatedAt(path) ?? now;
var messages = history.Select(m => m.ToSavedMessage()).ToList();
var doc = new XDocument(
new XElement("chatSession",
new XAttribute("id", id),
new XAttribute("title", MakeTitle(messages)),
new XAttribute("model", model),
new XAttribute("createdAt", createdAt.ToString("O")),
new XAttribute("updatedAt", now.ToString("O")),
new XElement("messages",
messages.Select(m =>
new XElement("message",
new XAttribute("role", m.Role),
m.Tokens.HasValue ? new XAttribute("tokens", m.Tokens.Value) : null,
new XElement("content", m.Content),
m.Images is { Count: > 0 }
? new XElement("images", m.Images.Select(i => new XElement("image", i)))
: null)))));
AtomicFile.Write(path, doc.Save);
CurrentSessionId = id;
return id;
}
public IReadOnlyList<ChatSessionSummary> ListSessions()
{
if (!Directory.Exists(RootDirectory))
{
return [];
}
return Directory.EnumerateFiles(RootDirectory, "*.xml")
.Select(ReadSummary)
.Where(s => s is not null)
.Select(s => s!)
.OrderByDescending(s => s.UpdatedAt)
.ToList();
}
public SavedChatSession? LoadSession(string id)
{
string path = SessionPath(SanitizeId(id));
if (!File.Exists(path))
{
return null;
}
var session = ReadSession(path, SanitizeId(id));
CurrentSessionId = session.Id;
return session;
}
private static SavedChatSession ReadSession(string path, string id)
{
XDocument doc = XDocument.Load(path);
XElement root = doc.Root ?? throw new InvalidDataException("Chat XML has no root.");
if (root.Name != "chatSession" || root.Element("messages") is null ||
((string?)root.Attribute("id") is { } embedded && embedded != id))
throw new InvalidDataException("Invalid chat session structure or identity.");
var session = new SavedChatSession
{
Id = id,
Title = (string?)root.Attribute("title") ?? "New chat",
Model = (string?)root.Attribute("model") ?? string.Empty,
CreatedAt = ParseDate((string?)root.Attribute("createdAt")) ?? DateTimeOffset.Now,
UpdatedAt = ParseDate((string?)root.Attribute("updatedAt")) ?? DateTimeOffset.Now,
Messages = root.Element("messages")?.Elements("message").Select(ReadMessage).ToList() ?? []
};
if (session.Messages.Count == 0) throw new InvalidDataException("Chat has no messages.");
for (int i = 0; i < session.Messages.Count; i++)
{
var message = session.Messages[i];
if (message.Role is not ("system" or "user" or "assistant") || message.Tokens is < 0 ||
(message.Role == "system" && (i != 0 || string.IsNullOrWhiteSpace(message.Content))))
throw new InvalidDataException("Chat contains invalid messages.");
}
return session;
}
private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml");
private static SavedChatMessage ReadMessage(XElement element)
{
int? tokens = null;
if (element.Attribute("tokens") is { } attribute)
{
if (!int.TryParse(attribute.Value, out int value)) throw new InvalidDataException("Invalid token count.");
tokens = value;
}
var images = element.Element("images")?.Elements("image")
.Select(i => i.Value)
.Where(i => !string.IsNullOrWhiteSpace(i))
.ToList();
return new SavedChatMessage
{
Role = (string?)element.Attribute("role") ?? string.Empty,
Content = element.Element("content")?.Value ?? string.Empty,
Tokens = tokens,
Images = images is { Count: > 0 } ? images : null
};
}
private static ChatSessionSummary? ReadSummary(string path)
{
try
{
string id = Path.GetFileNameWithoutExtension(path);
var session = ReadSession(path, id);
return new ChatSessionSummary(
id,
session.Title, session.CreatedAt, session.UpdatedAt, session.Messages.Count, session.Model);
}
catch
{
return null;
}
}
private static DateTimeOffset? ReadCreatedAt(string path)
{
if (!File.Exists(path))
{
return null;
}
try
{
XDocument doc = XDocument.Load(path);
return ParseDate((string?)doc.Root?.Attribute("createdAt"));
}
catch
{
return null;
}
}
private static DateTimeOffset? ParseDate(string? value) =>
DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
private static string MakeTitle(IReadOnlyList<SavedChatMessage> messages)
{
string? firstUser = messages
.Where(m => m.Role.Equals("user", StringComparison.OrdinalIgnoreCase))
.Select(m => m.Content.Trim())
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
!m.StartsWith("Please greet", StringComparison.OrdinalIgnoreCase) &&
!m.Equals("hello", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(firstUser))
{
firstUser = messages.FirstOrDefault(m => m.Role != "system" && !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim();
}
if (string.IsNullOrWhiteSpace(firstUser))
{
return "New chat";
}
firstUser = RegexPatterns.Whitespace().Replace(firstUser, " ");
return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "...";
}
private static string DetectVersionLabel()
{
foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
var match = RegexPatterns.VersionLabel().Match(source);
if (match.Success)
{
return RegexPatterns.Whitespace().Replace(match.Value, " ");
}
}
return "Default";
}
private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff") + "-" + Guid.NewGuid().ToString("N");
private static string SanitizeId(string id) =>
string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
}
ModifiedChatMessage.cs748 B / 12 lines+4-1+349 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
public record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<string>? Images = null,
[property: JsonIgnore] int? Tokens = null,
[property: JsonPropertyName("tool_calls"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<Lilith.Core.Tools.ToolCall>? ToolCalls = null,
[property: JsonPropertyName("tool_name"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolName = null,
[property: JsonIgnore] string? ToolCallId = null);
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
public record ChatMessage(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("images"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<string>? Images = null,
[property: JsonIgnore] int? Tokens = null);
ModifiedChatMessageExtensions.cs1,081 B / 24 lines+9-3+350 B+
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Chat;
public static class ChatMessageExtensions
{
public static ChatMessage Snapshot(this ChatMessage message) =>
message with
{
Images = message.Images is null ? null : [.. message.Images],
ToolCalls = message.ToolCalls?.Select(c => c with { Function = c.Function with { Arguments = c.Function.Arguments.Clone() } }).ToList()
};
public static SavedChatMessage ToSavedMessage(this ChatMessage message) => new()
{
Role = message.Role,
Content = message.Content ?? string.Empty,
Tokens = message.Tokens,
Images = message.Images is { Count: > 0 } ? [.. message.Images] : null,
ToolCalls = message.Snapshot().ToolCalls, ToolName = message.ToolName, ToolCallId = message.ToolCallId
};
public static ChatMessage ToChatMessage(this SavedChatMessage message) =>
new ChatMessage(message.Role, message.Content, message.Images, message.Tokens,
message.ToolCalls, message.ToolName, message.ToolCallId).Snapshot();
}
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Chat;
public static class ChatMessageExtensions
{
public static ChatMessage Snapshot(this ChatMessage message) =>
message with { Images = message.Images is null ? null : [.. message.Images] };
public static SavedChatMessage ToSavedMessage(this ChatMessage message) => new()
{
Role = message.Role,
Content = message.Content ?? string.Empty,
Tokens = message.Tokens,
Images = message.Images is { Count: > 0 } ? [.. message.Images] : null
};
public static ChatMessage ToChatMessage(this SavedChatMessage message) =>
new(message.Role, message.Content, message.Images is null ? null : [.. message.Images], message.Tokens);
}
ModifiedChatRequest.cs493 B / 10 lines+2-1+128 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ChatRequest(
[property: JsonPropertyName("model")] string Model,
[property: JsonPropertyName("messages")] List<ChatMessage> Messages,
[property: JsonPropertyName("stream")] bool Stream,
[property: JsonPropertyName("options")] ChatOptions? Options = null,
[property: JsonPropertyName("tools"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] object[]? Tools = null);
C#UnchangedRemoved / replacedAdded
using System.Text.Json.Serialization;
namespace Lilith.Core.Chat;
internal record ChatRequest(
[property: JsonPropertyName("model")] string Model,
[property: JsonPropertyName("messages")] List<ChatMessage> Messages,
[property: JsonPropertyName("stream")] bool Stream,
[property: JsonPropertyName("options")] ChatOptions? Options = null);
ModifiedSavedChatMessage.cs487 B / 15 lines+3-0+158 B+
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class SavedChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? Tokens { get; set; }
public List<string>? Images { get; set; }
public List<Lilith.Core.Tools.ToolCall>? ToolCalls { get; set; }
public string? ToolName { get; set; }
public string? ToolCallId { get; set; }
}
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace Lilith.Core.Chat;
public class SavedChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public int? Tokens { get; set; }
public List<string>? Images { get; set; }
}
Lilith/Core/LMStudio 1 files
ModifiedLMStudioClient.cs5,848 B / 105 lines+42-5+2,057 B+
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Headers;
using System.Text.Json;
using Lilith.Core.Tools;
namespace Lilith.Core.LMStudio;
public sealed class LMStudioClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null)
: ProviderClient(logger, historyStore, httpClient)
{
public string BaseUrl { get; init; } = LMStudioSetup.BaseUrl;
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
if (messages.Any(m => m.Images?.Count > 0))
throw new NotSupportedException("LM Studio image input is not implemented in this interface.");
using var request = HttpExtensions.JsonRequest(BaseUrl + "/v1/chat/completions", new
{
model = Model,
stream = false,
messages = messages.Select(ToWireMessage),
tools = ToolDefinitions
});
LMStudioSetup.Authorize(request);
using var json = await Http.SendJsonDocumentAsync(request);
var choice = json.RootElement.GetProperty("choices")[0];
string? finish = choice.GetProperty("finish_reason").GetString();
if (finish is not ("stop" or "tool_calls"))
throw new InvalidDataException("LM Studio did not return a complete text reply.");
var message = choice.GetProperty("message");
if (message.GetProperty("role").GetString() != "assistant")
throw new InvalidDataException("LM Studio returned an invalid assistant message.");
List<ToolCall>? calls = null;
if (message.TryGetProperty("tool_calls", out var toolCalls) && toolCalls.ValueKind != JsonValueKind.Null)
{
calls = [];
foreach (var call in toolCalls.EnumerateArray())
{
if (call.GetProperty("type").GetString() != "function" ||
string.IsNullOrWhiteSpace(call.GetProperty("id").GetString()))
throw new InvalidDataException("Invalid LM Studio function call.");
var function = call.GetProperty("function");
using var arguments = JsonDocument.Parse(function.GetProperty("arguments").GetString()!);
calls.Add(new ToolCall(new ToolFunction(function.GetProperty("name").GetString()!, arguments.RootElement.Clone()),
call.GetProperty("id").GetString()));
}
if (calls.Select(c => c.Id).Distinct().Count() != calls.Count)
throw new InvalidDataException("Duplicate LM Studio tool call IDs.");
}
if ((finish == "tool_calls") != (calls is { Count: > 0 }))
throw new InvalidDataException("LM Studio tool calls do not match the finish reason.");
string? content = message.TryGetProperty("content", out var text) && text.ValueKind == JsonValueKind.String ? text.GetString() : null;
if (calls is not { Count: > 0 } && content is null)
throw new InvalidDataException("LM Studio returned no assistant content.");
int? prompt = null, completion = null;
if (json.RootElement.TryGetProperty("usage", out var usage))
{
if (usage.TryGetProperty("prompt_tokens", out var p)) prompt = p.GetInt32();
if (usage.TryGetProperty("completion_tokens", out var c)) completion = c.GetInt32();
}
return new ChatChunk(new ChatMessage("assistant", content ?? string.Empty, ToolCalls: calls), true, prompt, completion);
}
private static object ToWireMessage(ChatMessage message)
{
var wire = new Dictionary<string, object?> { ["role"] = message.Role, ["content"] = message.Content };
if (message.ToolCallId is { } id) wire["tool_call_id"] = id;
if (message.ToolCalls is { Count: > 0 } calls)
wire["tool_calls"] = calls.Select(c => new
{
id = c.Id, type = "function",
function = new { name = c.Function.Name, arguments = c.Function.Arguments.GetRawText() }
}).ToArray();
return wire;
}
public static async Task<LMStudioClient> CreateClient(string model, string systemPrompt, Logger logger,
bool startServerIfNotRunning, string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
await LMStudioSetup.EnsureServerAsync(logger, startServerIfNotRunning);
var client = new LMStudioClient(logger, historyStore) { Model = model, SystemPrompt = systemPrompt, AgentName = agentName };
try
{
var models = await LMStudioSetup.ListModelsAsync();
var selected = models.FirstOrDefault(m => m.Id == model)
?? throw new InvalidOperationException($"LM Studio model '{model}' is not downloaded. Run setup first.");
if (selected.InstanceId is not null && selected.ContextLength is > 0)
{
client.Model = selected.InstanceId;
client.NumCtx = selected.ContextLength.Value;
}
else
{
using var request = HttpExtensions.JsonRequest(client.BaseUrl + "/api/v1/models/load", new { model, echo_load_config = true });
LMStudioSetup.Authorize(request);
using var json = await client.Http.SendJsonDocumentAsync(request);
client.Model = json.RootElement.GetProperty("instance_id").GetString()!;
client.NumCtx = json.RootElement.GetProperty("load_config").GetProperty("context_length").GetInt32();
}
if (client.NumCtx <= 0) throw new InvalidDataException("LM Studio reported an invalid context window.");
client.InitializeConversation();
return client;
}
catch { client.Dispose(); throw; }
}
}
C#UnchangedRemoved / replacedAdded
using System.Net.Http.Headers;
using System.Text.Json;
namespace Lilith.Core.LMStudio;
public sealed class LMStudioClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null)
: ProviderClient(logger, historyStore, httpClient)
{
public string BaseUrl { get; init; } = LMStudioSetup.BaseUrl;
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
if (messages.Any(m => m.Images?.Count > 0))
throw new NotSupportedException("LM Studio image input is not implemented in this interface.");
using var request = HttpExtensions.JsonRequest(BaseUrl + "/v1/chat/completions", new
{
model = Model,
stream = false,
messages = messages.Select(m => new { role = m.Role, content = m.Content })
});
LMStudioSetup.Authorize(request);
using var json = await Http.SendJsonDocumentAsync(request);
var choice = json.RootElement.GetProperty("choices")[0];
// A length limit/tool-call response is not a complete text turn.
if (choice.GetProperty("finish_reason").GetString() != "stop")
throw new InvalidDataException("LM Studio did not return a complete text reply.");
var message = choice.GetProperty("message");
if (message.GetProperty("role").GetString() != "assistant" || message.GetProperty("content").ValueKind != JsonValueKind.String)
throw new InvalidDataException("LM Studio returned an invalid assistant message.");
int? prompt = null, completion = null;
if (json.RootElement.TryGetProperty("usage", out var usage))
{
if (usage.TryGetProperty("prompt_tokens", out var p)) prompt = p.GetInt32();
if (usage.TryGetProperty("completion_tokens", out var c)) completion = c.GetInt32();
}
return new ChatChunk(new ChatMessage("assistant", message.GetProperty("content").GetString()!), true, prompt, completion);
}
public static async Task<LMStudioClient> CreateClient(string model, string systemPrompt, Logger logger,
bool startServerIfNotRunning, string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
await LMStudioSetup.EnsureServerAsync(logger, startServerIfNotRunning);
var client = new LMStudioClient(logger, historyStore) { Model = model, SystemPrompt = systemPrompt, AgentName = agentName };
try
{
var models = await LMStudioSetup.ListModelsAsync();
var selected = models.FirstOrDefault(m => m.Id == model)
?? throw new InvalidOperationException($"LM Studio model '{model}' is not downloaded. Run setup first.");
if (selected.InstanceId is not null && selected.ContextLength is > 0)
{
client.Model = selected.InstanceId;
client.NumCtx = selected.ContextLength.Value;
}
else
{
using var request = HttpExtensions.JsonRequest(client.BaseUrl + "/api/v1/models/load", new { model, echo_load_config = true });
LMStudioSetup.Authorize(request);
using var json = await client.Http.SendJsonDocumentAsync(request);
client.Model = json.RootElement.GetProperty("instance_id").GetString()!;
client.NumCtx = json.RootElement.GetProperty("load_config").GetProperty("context_length").GetInt32();
}
if (client.NumCtx <= 0) throw new InvalidDataException("LM Studio reported an invalid context window.");
client.InitializeConversation();
return client;
}
catch { client.Dispose(); throw; }
}
}
Lilith/Core/Ollama 1 files
ModifiedOllamaClient.cs13,205 B / 325 lines+2-1+37 B+
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
namespace Lilith.Core.Ollama;
public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : ProviderClient(logger, historyStore, httpClient)
{
private const string StatusBlue = "#AAAAFF";
private const string StatusGray = "#AAAAAA";
private bool _contextKnown;
public string BaseUrl { get; } = "http://localhost:11434";
public string ChatApiUrl => $"{BaseUrl}/api/chat";
public string ShowApiUrl => $"{BaseUrl}/api/show";
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
var request = new ChatRequest(Model, messages, Stream: false, Options: _contextKnown ? new ChatOptions(NumCtx) : null,
Tools: ToolDefinitions);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);
return await Http.SendJsonAsync<ChatChunk>(httpReq);
}
/// <summary>Connects to a prepared model; never installs Ollama or downloads a model.</summary>
public static async Task<OllamaClient> CreateClient(
string model,
string systemPrompt,
Logger logger,
bool startOllamaIfNotRunning,
string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
var client = new OllamaClient(logger, historyStore)
{
Model = model,
SystemPrompt = systemPrompt,
AgentName = agentName
};
client.InitializeConversation();
client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);
try
{
await logger.TimeStageAsync("Server readiness", () => EnsureServerAsync(logger, startOllamaIfNotRunning));
bool available = await logger.TimeStageAsync("Model readiness", () => IsModelAvailableAsync(model));
if (!available) throw new InvalidOperationException($"Model '{model}' is not installed. Run console setup to choose and download a model.");
client.NumCtx = await logger.TimeStageAsync("Model specifications", client.GetModelContextSizeAsync);
client.PrintStatus(client._contextKnown ? $"Context window: {client.NumCtx:N0} tokens." :
$"Context size is unknown; usage uses a {client.NumCtx:N0}-token estimate and Ollama selects its own request default.",
client._contextKnown ? LogColors.Green : LogColors.Orange);
return client;
}
catch
{
client.Http.Dispose();
throw;
}
}
internal async Task<int> GetModelContextSizeAsync()
{
_contextKnown = false;
try
{
var request = new ShowModelRequest(Model);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);
using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);
if (!httpResp.IsSuccessStatusCode)
{
PrintStatus("Could not retrieve model specs; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
string json = await httpResp.Content.ReadAsStringAsync();
ShowModelResponse? response = json.Deserialize<ShowModelResponse>();
if (response?.ModelInfo is { } info)
{
foreach (var kv in info)
{
if (kv.Key.EndsWith(".context_length", StringComparison.Ordinal) && kv.Value.TryGetInt32(out int ctx) && ctx > 0)
{
_contextKnown = true;
return ctx;
}
}
}
}
catch (Exception ex)
{
PrintStatus($"Model spec query failed ({ex.Message}); retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
}
PrintStatus("Valid model context metadata is unavailable; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
if (await http.IsReachableAsync("http://localhost:11434/")) { logger.WriteInfo("Ollama", "Ollama server already running; skipping server start."); return; }
if (!startIfMissing) throw new InvalidOperationException("Ollama server is not running.");
var psi = new ProcessStartInfo(ResolveExecutable()) { UseShellExecute = false, CreateNoWindow = true };
psi.ArgumentList.Add("serve");
using var server = Process.Start(psi) ?? throw new InvalidOperationException("Could not start Ollama server.");
var timer = Stopwatch.StartNew();
while (timer.Elapsed < TimeSpan.FromSeconds(60))
{
if (await http.IsReachableAsync("http://localhost:11434/")) return;
// The installer/app may already own the port while still discovering GPUs.
// An exited second serve process does not prove that the API cannot become ready.
await Task.Delay(500);
}
if (!server.HasExited) server.Kill(entireProcessTree: true);
throw new TimeoutException(server.HasExited
? $"Ollama API did not become ready within 60 seconds (startup process exit {server.ExitCode})."
: "Ollama server did not become ready within 60 seconds.");
}
private static string ResolveExecutable()
{
string installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Ollama", "ollama.exe");
return File.Exists(installed) ? installed : "ollama";
}
public static bool IsOllamaInstalled()
{
try
{
var psi = new ProcessStartInfo(ResolveExecutable(), "--version")
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
Task stdout = proc.StandardOutput.ReadToEndAsync();
Task stderr = proc.StandardError.ReadToEndAsync();
if (!proc.WaitForExit(5000))
{
try
{
proc.Kill();
}
catch
{
// ignore
}
return false;
}
Task.WaitAll(stdout, stderr);
return proc.ExitCode == 0;
}
catch
{
return false;
}
}
private static async Task<bool> PullModelAsync(OllamaClient client)
{
try
{
client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);
var psi = new ProcessStartInfo
{
FileName = ResolveExecutable(),
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("pull");
psi.ArgumentList.Add(client.Model);
using var proc = Process.Start(psi);
if (proc is null)
{
client.PrintStatus("Could not start ollama pull process.", LogColors.Red);
return false;
}
var tracker = new OllamaCliProgressTracker(client.Model);
Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);
Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);
DateTime started = DateTime.UtcNow;
DateTime deadline = started.AddMinutes(30);
while (!proc.HasExited && DateTime.UtcNow < deadline)
{
TimeSpan elapsed = DateTime.UtcNow - started;
TimeSpan remaining = deadline - DateTime.UtcNow;
client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);
await Task.Delay(250);
}
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
throw new TimeoutException("Model download exceeded 30 minutes.");
}
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))
{
client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);
return false;
}
if (await IsModelAvailableAsync(client.Model))
{
client.PrintStatus(
$"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",
LogColors.Green);
return true;
}
client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
}
return false;
}
catch (Exception ex)
{
client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);
return false;
}
}
public static async Task<bool> IsModelAvailableAsync(string model)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = HttpExtensions.JsonRequest("http://localhost:11434/api/show", new ShowModelRequest(model));
using var response = await http.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false;
response.EnsureSuccessStatusCode();
return true;
}
public static async Task InstallAsync(Logger logger)
{
using var client = new OllamaClient(logger);
if (!await InstallOllamaAsync(client)) throw new InvalidOperationException("Ollama installation failed. Relaunch setup to retry.");
}
public static async Task DownloadModelAsync(string model, Logger logger)
{
using var client = new OllamaClient(logger) { Model = model };
if (!await PullModelAsync(client)) throw new InvalidOperationException($"Model '{model}' download failed. Relaunch setup to retry.");
}
private static async Task<bool> InstallOllamaAsync(OllamaClient client)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +
"\"irm https://ollama.com/install.ps1 | iex\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
proc.OutputDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, StatusGray);
}
};
proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, LogColors.Orange);
}
};
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitOrKillAsync(TimeSpan.FromMinutes(10), "Ollama installation timed out.");
return proc.ExitCode == 0 && IsOllamaInstalled();
}
catch (Exception ex)
{
client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);
return false;
}
}
private void PrintStatus(string message, string color) =>
Logger.WriteLine("Ollama", message, color);
internal void PrintInstallerOutput(string message, string color)
{
if (RegexPatterns.InstallerProgress().IsMatch(message))
PrintProgress(message.Trim(), color);
else
PrintStatus(message, color);
}
private void PrintProgress(string message, string color) =>
Logger.WriteProgress("Ollama", message, color);
}
C#UnchangedRemoved / replacedAdded
using System.Diagnostics;
namespace Lilith.Core.Ollama;
public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : ProviderClient(logger, historyStore, httpClient)
{
private const string StatusBlue = "#AAAAFF";
private const string StatusGray = "#AAAAAA";
private bool _contextKnown;
public string BaseUrl { get; } = "http://localhost:11434";
public string ChatApiUrl => $"{BaseUrl}/api/chat";
public string ShowApiUrl => $"{BaseUrl}/api/show";
protected override async Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages)
{
var request = new ChatRequest(Model, messages, Stream: false, Options: _contextKnown ? new ChatOptions(NumCtx) : null);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);
return await Http.SendJsonAsync<ChatChunk>(httpReq);
}
/// <summary>Connects to a prepared model; never installs Ollama or downloads a model.</summary>
public static async Task<OllamaClient> CreateClient(
string model,
string systemPrompt,
Logger logger,
bool startOllamaIfNotRunning,
string agentName = "Lilith", ChatHistoryStore? historyStore = null)
{
var client = new OllamaClient(logger, historyStore)
{
Model = model,
SystemPrompt = systemPrompt,
AgentName = agentName
};
client.InitializeConversation();
client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);
try
{
await logger.TimeStageAsync("Server readiness", () => EnsureServerAsync(logger, startOllamaIfNotRunning));
bool available = await logger.TimeStageAsync("Model readiness", () => IsModelAvailableAsync(model));
if (!available) throw new InvalidOperationException($"Model '{model}' is not installed. Run console setup to choose and download a model.");
client.NumCtx = await logger.TimeStageAsync("Model specifications", client.GetModelContextSizeAsync);
client.PrintStatus(client._contextKnown ? $"Context window: {client.NumCtx:N0} tokens." :
$"Context size is unknown; usage uses a {client.NumCtx:N0}-token estimate and Ollama selects its own request default.",
client._contextKnown ? LogColors.Green : LogColors.Orange);
return client;
}
catch
{
client.Http.Dispose();
throw;
}
}
internal async Task<int> GetModelContextSizeAsync()
{
_contextKnown = false;
try
{
var request = new ShowModelRequest(Model);
using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);
using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);
if (!httpResp.IsSuccessStatusCode)
{
PrintStatus("Could not retrieve model specs; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
string json = await httpResp.Content.ReadAsStringAsync();
ShowModelResponse? response = json.Deserialize<ShowModelResponse>();
if (response?.ModelInfo is { } info)
{
foreach (var kv in info)
{
if (kv.Key.EndsWith(".context_length", StringComparison.Ordinal) && kv.Value.TryGetInt32(out int ctx) && ctx > 0)
{
_contextKnown = true;
return ctx;
}
}
}
}
catch (Exception ex)
{
PrintStatus($"Model spec query failed ({ex.Message}); retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
}
PrintStatus("Valid model context metadata is unavailable; retaining Ollama defaults (context estimate: 4,096 tokens).", LogColors.Orange);
return 4096;
}
public static async Task EnsureServerAsync(Logger logger, bool startIfMissing = true)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
if (await http.IsReachableAsync("http://localhost:11434/")) { logger.WriteInfo("Ollama", "Ollama server already running; skipping server start."); return; }
if (!startIfMissing) throw new InvalidOperationException("Ollama server is not running.");
var psi = new ProcessStartInfo(ResolveExecutable()) { UseShellExecute = false, CreateNoWindow = true };
psi.ArgumentList.Add("serve");
using var server = Process.Start(psi) ?? throw new InvalidOperationException("Could not start Ollama server.");
var timer = Stopwatch.StartNew();
while (timer.Elapsed < TimeSpan.FromSeconds(60))
{
if (await http.IsReachableAsync("http://localhost:11434/")) return;
// The installer/app may already own the port while still discovering GPUs.
// An exited second serve process does not prove that the API cannot become ready.
await Task.Delay(500);
}
if (!server.HasExited) server.Kill(entireProcessTree: true);
throw new TimeoutException(server.HasExited
? $"Ollama API did not become ready within 60 seconds (startup process exit {server.ExitCode})."
: "Ollama server did not become ready within 60 seconds.");
}
private static string ResolveExecutable()
{
string installed = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Ollama", "ollama.exe");
return File.Exists(installed) ? installed : "ollama";
}
public static bool IsOllamaInstalled()
{
try
{
var psi = new ProcessStartInfo(ResolveExecutable(), "--version")
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
Task stdout = proc.StandardOutput.ReadToEndAsync();
Task stderr = proc.StandardError.ReadToEndAsync();
if (!proc.WaitForExit(5000))
{
try
{
proc.Kill();
}
catch
{
// ignore
}
return false;
}
Task.WaitAll(stdout, stderr);
return proc.ExitCode == 0;
}
catch
{
return false;
}
}
private static async Task<bool> PullModelAsync(OllamaClient client)
{
try
{
client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);
var psi = new ProcessStartInfo
{
FileName = ResolveExecutable(),
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("pull");
psi.ArgumentList.Add(client.Model);
using var proc = Process.Start(psi);
if (proc is null)
{
client.PrintStatus("Could not start ollama pull process.", LogColors.Red);
return false;
}
var tracker = new OllamaCliProgressTracker(client.Model);
Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);
Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);
DateTime started = DateTime.UtcNow;
DateTime deadline = started.AddMinutes(30);
while (!proc.HasExited && DateTime.UtcNow < deadline)
{
TimeSpan elapsed = DateTime.UtcNow - started;
TimeSpan remaining = deadline - DateTime.UtcNow;
client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);
await Task.Delay(250);
}
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
throw new TimeoutException("Model download exceeded 30 minutes.");
}
await proc.WaitForExitAsync();
await Task.WhenAll(stdoutTask, stderrTask);
if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))
{
client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);
return false;
}
if (await IsModelAvailableAsync(client.Model))
{
client.PrintStatus(
$"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",
LogColors.Green);
return true;
}
client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
}
return false;
}
catch (Exception ex)
{
client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);
return false;
}
}
public static async Task<bool> IsModelAvailableAsync(string model)
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var request = HttpExtensions.JsonRequest("http://localhost:11434/api/show", new ShowModelRequest(model));
using var response = await http.SendAsync(request);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return false;
response.EnsureSuccessStatusCode();
return true;
}
public static async Task InstallAsync(Logger logger)
{
using var client = new OllamaClient(logger);
if (!await InstallOllamaAsync(client)) throw new InvalidOperationException("Ollama installation failed. Relaunch setup to retry.");
}
public static async Task DownloadModelAsync(string model, Logger logger)
{
using var client = new OllamaClient(logger) { Model = model };
if (!await PullModelAsync(client)) throw new InvalidOperationException($"Model '{model}' download failed. Relaunch setup to retry.");
}
private static async Task<bool> InstallOllamaAsync(OllamaClient client)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +
"\"irm https://ollama.com/install.ps1 | iex\"",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var proc = Process.Start(psi);
if (proc == null)
{
return false;
}
proc.OutputDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, StatusGray);
}
};
proc.ErrorDataReceived += (_, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
client.PrintInstallerOutput(e.Data, LogColors.Orange);
}
};
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitOrKillAsync(TimeSpan.FromMinutes(10), "Ollama installation timed out.");
return proc.ExitCode == 0 && IsOllamaInstalled();
}
catch (Exception ex)
{
client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);
return false;
}
}
private void PrintStatus(string message, string color) =>
Logger.WriteLine("Ollama", message, color);
internal void PrintInstallerOutput(string message, string color)
{
if (RegexPatterns.InstallerProgress().IsMatch(message))
PrintProgress(message.Trim(), color);
else
PrintStatus(message, color);
}
private void PrintProgress(string message, string color) =>
Logger.WriteProgress("Ollama", message, color);
}
Lilith/Core/Providers 1 files
ModifiedProviderClient.cs11,042 B / 217 lines+61-2+3,483 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 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);
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);
public async Task<string> StreamReply(string userInput, List<string>? images = null)
{
using var operation = EnterOperation();
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)
{
chunk = await GenerateReplyAsync(requestHistory);
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.");
requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
foreach (var call in calls)
{
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 = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
// Await actual completion: custom tools must cooperate with cancellation.
result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
}
catch (Exception ex) when (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 (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);
}
}
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;
/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract 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;
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);
public async Task<string> StreamReply(string userInput, List<string>? images = null)
{
using var operation = EnterOperation();
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 = await GenerateReplyAsync(requestHistory);
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;
History.Add(userMessage with { Tokens = chunk.PromptTokens });
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/Tools 4 files
NEWCurrentTimeTool.cs1,125 B / 26 lines+26-0+1,125 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
namespace Lilith.Core.Tools;
public sealed class CurrentTimeTool : ILilithTool
{
public string Name => "get_current_time";
public string Description => "Get the current date and time on this computer, including its time zone and UTC time.";
public JsonElement Parameters => JsonSerializer.SerializeToElement(new
{
type = "object", properties = new { }, additionalProperties = false
});
public Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any())
throw new ArgumentException("get_current_time expects an empty argument object.");
var now = DateTimeOffset.UtcNow;
return Task.FromResult(JsonSerializer.Serialize(new
{
local_time = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Local).ToString("O"),
utc_time = now.ToString("O"), time_zone = TimeZoneInfo.Local.Id
}));
}
}
NEWILilithTool.cs409 B / 12 lines+12-0+409 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
namespace Lilith.Core.Tools;
/// <summary>Only explicitly registered tools can be executed. Implementations validate their arguments.</summary>
public interface ILilithTool
{
string Name { get; }
string Description { get; }
JsonElement Parameters { get; }
Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default);
}
NEWToolCall.cs484 B / 12 lines+12-0+484 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Lilith.Core.Tools;
public sealed record ToolFunction(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("arguments")] JsonElement Arguments);
public sealed record ToolCall(
[property: JsonPropertyName("function")] ToolFunction Function,
[property: JsonPropertyName("id"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Id = null);
NEWToolTemplate.cs1,337 B / 27 lines+27-0+1,337 B+
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
namespace Lilith.Core.Tools;
/// <summary>Copy and rename this class to create a tool. Not registered by default.</summary>
public sealed class ToolTemplate : ILilithTool
{
public string Name => "example_echo";
public string Description => "Return the supplied text. Replace this description with your tool's purpose.";
public JsonElement Parameters => JsonSerializer.SerializeToElement(new
{
type = "object",
properties = new { text = new { type = "string", description = "Text to return." } },
required = new[] { "text" }, additionalProperties = false
});
public Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (arguments.ValueKind != JsonValueKind.Object ||
arguments.EnumerateObject().Count() != 1 ||
!arguments.TryGetProperty("text", out var text) || text.ValueKind != JsonValueKind.String)
throw new ArgumentException("Expected exactly one string argument named 'text'.");
// Replace this with your implementation; await asynchronous work and honor cancellation.
return Task.FromResult(JsonSerializer.Serialize(new { text = text.GetString() }));
}
}
No changed files match that search.