diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4421a..0bf25a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,12 @@ it moves 0.14.1 → 0.15.0. Desktop from source, where the engine project now resolves its .NET 10 build. - **Engine dependencies moved to current releases**, including Model Context Protocol 2.2.0 and YamlDotNet 18.1.0. -- **Pinned engine commit: `6eb3fc3`** (engine 0.15.0). The exact engine each Desktop release +- **Desktop moved off Semantic Kernel too.** The notes assistant, the snapshot summarizer, and + the skill author each opened their own connection to Ollama through Semantic Kernel; they now + use the same Microsoft.Extensions.AI client the engine standardized on. Same prompts, same + temperatures, same behavior — but Desktop no longer depends on a framework the engine has + removed. Snapshot recaps and note replies are the surfaces to sanity-check. +- **Pinned engine commit: `3b5f667`** (engine 0.15.0). The exact engine each Desktop release ships is recorded by the `MandoCode` submodule. ## [0.14.1] — 2026-07-28 diff --git a/README.md b/README.md index 3cc72c1..0fc7de7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Two problems, one app: Ollama: swap models per agent, run one entirely on your own hardware for free, or reach for a hosted model when you want more ceiling. You're never locked to a single closed API. - **A coding assistant is something you live in all day, not a popup.** So it gets a real native - app built on C#, WinUI 3, and Semantic Kernel's Ollama connector — multiple agents open at once, + app built on C#, WinUI 3, and Microsoft's Agent Framework over Ollama — multiple agents open at once, a real shell, a file tree that actually knows about git, a place to jot down a thought without opening a text editor, and a look you can make your own instead of one fixed dark theme. diff --git a/docs/session-persistence.md b/docs/session-persistence.md index 44b8143..ccd0e34 100644 --- a/docs/session-persistence.md +++ b/docs/session-persistence.md @@ -108,6 +108,6 @@ In rough order of value: - **CLI `--continue`** — `ExportHistoryJson`/`TryRestoreHistoryJson` live in the harness precisely so the CLI can grow its own resume without new plumbing. - **Cross-provider carry verification** — verbatim history with function-call content moving - between Ollama and cloud connectors should map cleanly through Semantic Kernel's generic - content types; it deserves a deliberate test before "Keep memory" is treated as guaranteed - across providers (the graceful fallback already handles failure). + between Ollama and cloud connectors should map cleanly through Microsoft.Extensions.AI's + generic content types; it deserves a deliberate test before "Keep memory" is treated as + guaranteed across providers (the graceful fallback already handles failure). diff --git a/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs b/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs index 392c953..b7c5563 100644 --- a/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs +++ b/src/MandoCode.Desktop.Tests/HistorySummarizerTests.cs @@ -1,6 +1,5 @@ using MandoCode.Desktop.Services; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.Extensions.AI; using Xunit; namespace MandoCode.Desktop.Tests; @@ -12,22 +11,22 @@ namespace MandoCode.Desktop.Tests; /// public sealed class HistorySummarizerTests { - private static ChatMessageContent Sys(string t) => new(AuthorRole.System, t); - private static ChatMessageContent Usr(string t) => new(AuthorRole.User, t); - private static ChatMessageContent Asst(string t) => new(AuthorRole.Assistant, t); + private static ChatMessage Sys(string t) => new(ChatRole.System, t); + private static ChatMessage Usr(string t) => new(ChatRole.User, t); + private static ChatMessage Asst(string t) => new(ChatRole.Assistant, t); [Fact] public void HasContent_False_WhenOnlySystemPrompt() - => Assert.False(HistorySummarizer.HasContent(new List { Sys("you are helpful") })); + => Assert.False(HistorySummarizer.HasContent(new List { Sys("you are helpful") })); [Fact] public void HasContent_True_WhenUserSpoke() - => Assert.True(HistorySummarizer.HasContent(new List { Sys("sys"), Usr("hello") })); + => Assert.True(HistorySummarizer.HasContent(new List { Sys("sys"), Usr("hello") })); [Fact] public void Full_SkipsSystemPrompt_AndKeepsBothTurns() { - var history = new List { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") }; + var history = new List { Sys("SECRET SYSTEM"), Usr("hi there"), Asst("hey back") }; var text = HistorySummarizer.Full(history); @@ -39,16 +38,16 @@ public void Full_SkipsSystemPrompt_AndKeepsBothTurns() [Fact] public void Full_ReturnsPlaceholder_WhenNothingToSummarize() => Assert.Equal("(no prior activity captured)", - HistorySummarizer.Full(new List { Sys("sys") })); + HistorySummarizer.Full(new List { Sys("sys") })); [Fact] public void Full_DescribesFunctionCall_WhenTextIsEmpty() { - var toolTurn = new ChatMessageContent(AuthorRole.Assistant, content: null) + var toolTurn = new ChatMessage(ChatRole.Assistant, new List { - Items = { new FunctionCallContent("read_file", arguments: new KernelArguments { ["path"] = "Program.cs" }) } - }; - var history = new List { Sys("sys"), toolTurn }; + new FunctionCallContent("call-1", "read_file", new Dictionary { ["path"] = "Program.cs" }) + }); + var history = new List { Sys("sys"), toolTurn }; var text = HistorySummarizer.Full(history); diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 41f4259..69498a6 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -11,13 +11,17 @@ + + + config-clone tests) and DiffModels transitively — which is why the direct DiffModels + compile-include below was removed. (Chat history types now come from the + Microsoft.Extensions.AI.Abstractions reference above, not from the harness.) --> diff --git a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs index 01d3fc6..d968d0d 100644 --- a/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs +++ b/src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs @@ -3,7 +3,7 @@ using MandoCode.Desktop.ViewModels; using MandoCode.Models; using MandoCode.Services; -using Microsoft.SemanticKernel; +using Microsoft.Extensions.AI; using Xunit; namespace MandoCode.Desktop.Tests; @@ -66,7 +66,7 @@ public event Action? OnFunctionCompleted { add { } remo public int TryRestoreHistoryJson(string json) => throw new NotSupportedException(); public Task EnterLearnModeAsync() => throw new NotSupportedException(); public Task ClearHistoryAsync() => throw new NotSupportedException(); - public Task> GetHistoryAsync() => throw new NotSupportedException(); + public Task> GetHistoryAsync() => throw new NotSupportedException(); } private static (ResponseStreamer streamer, List blocks) Make(FakeAiService ai) diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs index 4718e6d..53c2902 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs @@ -72,7 +72,7 @@ public async Task RestoreConversationMemoryAsync() { try { - // 1) Full fidelity: rehydrate the harness's ChatHistory verbatim. + // 1) Full fidelity: rehydrate the harness's chat history verbatim. var historyJson = SessionHistoryStore.Load(Session.PersistKey); if (historyJson != null) { diff --git a/src/MandoCode.Desktop/MandoCode.Desktop.csproj b/src/MandoCode.Desktop/MandoCode.Desktop.csproj index 4235a08..2265141 100644 --- a/src/MandoCode.Desktop/MandoCode.Desktop.csproj +++ b/src/MandoCode.Desktop/MandoCode.Desktop.csproj @@ -56,6 +56,17 @@ + + + + diff --git a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs index a8474e8..ea20bd5 100644 --- a/src/MandoCode.Desktop/Services/AiServiceAdapter.cs +++ b/src/MandoCode.Desktop/Services/AiServiceAdapter.cs @@ -1,6 +1,6 @@ using MandoCode.Models; using MandoCode.Services; -using Microsoft.SemanticKernel; +using Microsoft.Extensions.AI; namespace MandoCode.Desktop.Services; @@ -58,5 +58,5 @@ public Func>? OnCommandApprovalRequested public int TryRestoreHistoryJson(string json) => _ai.TryRestoreHistoryJson(json); public Task EnterLearnModeAsync() => _ai.EnterLearnModeAsync(); public Task ClearHistoryAsync() => _ai.ClearHistoryAsync(); - public Task> GetHistoryAsync() => _ai.GetHistoryAsync(); + public Task> GetHistoryAsync() => _ai.GetHistoryAsync(); } diff --git a/src/MandoCode.Desktop/Services/HistorySummarizer.cs b/src/MandoCode.Desktop/Services/HistorySummarizer.cs index 38d82b9..f50c3e2 100644 --- a/src/MandoCode.Desktop/Services/HistorySummarizer.cs +++ b/src/MandoCode.Desktop/Services/HistorySummarizer.cs @@ -1,5 +1,5 @@ using System.Text; -using Microsoft.SemanticKernel; +using Microsoft.Extensions.AI; namespace MandoCode.Desktop.Services; @@ -11,32 +11,38 @@ namespace MandoCode.Desktop.Services; /// (The deterministic per-line/overall truncation this once did — a port of the harness's compaction /// summary — was dropped when snapshots moved to LLM summaries: it kept the oldest turns and cut the /// most recent, which is backwards for a resumption recap.) +/// +/// Messages are (Microsoft.Extensions.AI) since the engine moved off +/// Semantic Kernel. Tool activity lives in alongside any text, so +/// walking Contents — not the Text convenience view — is what surfaces a tool turn. /// public static class HistorySummarizer { private const string Empty = "(no prior activity captured)"; /// True if there is anything worth snapshotting beyond the system prompt at index 0. - public static bool HasContent(IReadOnlyList history, int startIndex = 1) + public static bool HasContent(IReadOnlyList history, int startIndex = 1) { + var names = MapCallIdsToNames(history, startIndex); for (int i = Math.Max(0, startIndex); i < history.Count; i++) - if (!string.IsNullOrEmpty(FormatMessage(history[i], int.MaxValue))) return true; + if (!string.IsNullOrEmpty(FormatMessage(history[i], names, int.MaxValue))) return true; return false; } /// Full untruncated dump — the text handed to the summarizer. - public static string Full(IReadOnlyList history, int startIndex = 1) + public static string Full(IReadOnlyList history, int startIndex = 1) => Build(history, startIndex, lineMax: int.MaxValue, maxChars: int.MaxValue); - private static string Build(IReadOnlyList history, int startIndex, int lineMax, int maxChars) + private static string Build(IReadOnlyList history, int startIndex, int lineMax, int maxChars) { + var names = MapCallIdsToNames(history, startIndex); var sb = new StringBuilder(); for (int i = Math.Max(0, startIndex); i < history.Count; i++) { - var line = FormatMessage(history[i], lineMax); + var line = FormatMessage(history[i], names, lineMax); if (string.IsNullOrEmpty(line)) continue; - sb.Append('[').Append(history[i].Role.Label).Append("] ").AppendLine(line); + sb.Append('[').Append(history[i].Role.Value).Append("] ").AppendLine(line); if (sb.Length > maxChars) { sb.AppendLine("... (older entries truncated)"); @@ -46,17 +52,32 @@ private static string Build(IReadOnlyList history, int start return sb.Length == 0 ? Empty : sb.ToString().TrimEnd(); } + /// + /// Call id → function name, built once per walk. Unlike Semantic Kernel's, MEAI's + /// carries no function name — only the call id it shares + /// with its matching — so a result line would otherwise read + /// as a bare id instead of "read_file → ...". + /// + private static Dictionary MapCallIdsToNames(IReadOnlyList history, int startIndex) + { + var map = new Dictionary(); + for (int i = Math.Max(0, startIndex); i < history.Count; i++) + foreach (var item in history[i].Contents) + if (item is FunctionCallContent fc) map[fc.CallId] = fc.Name; + return map; + } + /// One-line recap of a single message; falls back to function calls/results when the /// text content is empty (a tool turn). - private static string FormatMessage(ChatMessageContent msg, int lineMax) + private static string FormatMessage(ChatMessage msg, Dictionary callIdToName, int lineMax) { - var content = msg.Content?.Trim(); + var content = msg.Text?.Trim(); if (!string.IsNullOrEmpty(content)) return Cap(content, lineMax); - if (msg.Items == null || msg.Items.Count == 0) return ""; + if (msg.Contents.Count == 0) return ""; var parts = new List(); - foreach (var item in msg.Items) + foreach (var item in msg.Contents) { switch (item) { @@ -65,12 +86,15 @@ private static string FormatMessage(ChatMessageContent msg, int lineMax) var args = fc.Arguments is { Count: > 0 } ? string.Join(", ", fc.Arguments.Select(kv => $"{kv.Key}={Truncate(kv.Value?.ToString(), 40)}")) : ""; - parts.Add($"called {fc.FunctionName}({args})"); + parts.Add($"called {fc.Name}({args})"); break; } case FunctionResultContent fr: - parts.Add($"{fr.FunctionName} → {Truncate(fr.Result?.ToString(), 80)}"); + { + var name = callIdToName.TryGetValue(fr.CallId, out var n) ? n : fr.CallId; + parts.Add($"{name} → {Truncate(fr.Result?.ToString(), 80)}"); break; + } case TextContent tc when !string.IsNullOrWhiteSpace(tc.Text): parts.Add(tc.Text.Trim()); break; diff --git a/src/MandoCode.Desktop/Services/IAiService.cs b/src/MandoCode.Desktop/Services/IAiService.cs index 339d12a..6181b59 100644 --- a/src/MandoCode.Desktop/Services/IAiService.cs +++ b/src/MandoCode.Desktop/Services/IAiService.cs @@ -1,6 +1,6 @@ using MandoCode.Models; using MandoCode.Services; -using Microsoft.SemanticKernel; +using Microsoft.Extensions.AI; namespace MandoCode.Desktop.Services; @@ -40,5 +40,5 @@ public interface IAiService int TryRestoreHistoryJson(string json); Task EnterLearnModeAsync(); Task ClearHistoryAsync(); - Task> GetHistoryAsync(); + Task> GetHistoryAsync(); } diff --git a/src/MandoCode.Desktop/Services/NoteAssistant.cs b/src/MandoCode.Desktop/Services/NoteAssistant.cs index 6e375c9..b44ce51 100644 --- a/src/MandoCode.Desktop/Services/NoteAssistant.cs +++ b/src/MandoCode.Desktop/Services/NoteAssistant.cs @@ -1,7 +1,6 @@ using System.Text; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.Extensions.AI; +using OllamaSharp; namespace MandoCode.Desktop.Services; @@ -10,7 +9,7 @@ namespace MandoCode.Desktop.Services; /// that's open, and asking about the pad as a whole. /// /// It has no tools, and that is the design. Like , this builds a -/// bare Ollama kernel with no plugins, filters, or shared history — so it cannot read or write a +/// bare Ollama chat client with no tools, middleware, or shared history — so it cannot read or write a /// single file. "No agent ever touches your note" is therefore true by construction rather than by /// policy: the only route from a reply into a note is the user pressing Insert or Replace. That's also /// why it isn't an AIService agent — those exist to change your files, which is the opposite of @@ -163,9 +162,9 @@ public static int CountBodiesSent(IReadOnlyList full) } /// - /// One streamed round on a throwaway kernel. Stateless by construction: a fresh - /// per call, built from the system prompt, the recent thread, and this - /// message. Deltas arrive on a background thread — the caller marshals. + /// One streamed round on a throwaway chat client. Stateless by construction: a fresh message + /// list per call, built from the system prompt, the recent thread, and this message. Deltas + /// arrive on a background thread — the caller marshals. /// private static async Task StreamAsync( string endpoint, @@ -176,33 +175,25 @@ private static async Task StreamAsync( Action onDelta, CancellationToken ct) { - var kernel = Kernel.CreateBuilder() - .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) - .Build(); + using IChatClient chat = new OllamaApiClient(new Uri(endpoint), model); - var chat = kernel.GetRequiredService(); - - var history = new ChatHistory(); - history.AddSystemMessage(systemPrompt); + var history = new List { new(ChatRole.System, systemPrompt) }; foreach (var turn in thread.TakeLast(ThreadTurns)) - { - if (turn.FromUser) history.AddUserMessage(turn.Text); - else history.AddAssistantMessage(turn.Text); - } + history.Add(new ChatMessage(turn.FromUser ? ChatRole.User : ChatRole.Assistant, turn.Text)); - history.AddUserMessage(message); + history.Add(new ChatMessage(ChatRole.User, message)); // Low temperature: this rewrites the user's own words, so faithful beats inventive. - var settings = new OllamaPromptExecutionSettings { Temperature = 0.3f }; + var options = new ChatOptions { Temperature = 0.3f }; // ConfigureAwait(false): per-chunk continuations must not hop through the caller's UI // dispatcher — a small model can stream hundreds of chunks a second. - await foreach (var chunk in chat.GetStreamingChatMessageContentsAsync(history, settings, kernel, ct) + await foreach (var update in chat.GetStreamingResponseAsync(history, options, ct) .ConfigureAwait(false)) { if (ct.IsCancellationRequested) return; - if (!string.IsNullOrEmpty(chunk.Content)) onDelta(chunk.Content); + if (!string.IsNullOrEmpty(update.Text)) onDelta(update.Text); } } } diff --git a/src/MandoCode.Desktop/Services/SkillAuthor.cs b/src/MandoCode.Desktop/Services/SkillAuthor.cs index 2c5991d..a0935d8 100644 --- a/src/MandoCode.Desktop/Services/SkillAuthor.cs +++ b/src/MandoCode.Desktop/Services/SkillAuthor.cs @@ -1,6 +1,5 @@ -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.Extensions.AI; +using OllamaSharp; namespace MandoCode.Desktop.Services; @@ -58,19 +57,18 @@ public static async Task RefineAsync( private static async Task CompleteAsync( string endpoint, string model, string system, string user, CancellationToken ct) { - var kernel = Kernel.CreateBuilder() - .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) - .Build(); - var chat = kernel.GetRequiredService(); + using IChatClient chat = new OllamaApiClient(new Uri(endpoint), model); - var history = new ChatHistory(); - history.AddSystemMessage(system); - history.AddUserMessage(user); + var history = new List + { + new(ChatRole.System, system), + new(ChatRole.User, user) + }; // A touch of latitude helps phrasing without drifting off-spec. - var settings = new OllamaPromptExecutionSettings { Temperature = 0.4f }; - var result = await chat.GetChatMessageContentAsync(history, settings, kernel, ct); - return result.Content?.Trim() ?? ""; + var options = new ChatOptions { Temperature = 0.4f }; + var result = await chat.GetResponseAsync(history, options, ct); + return result.Text?.Trim() ?? ""; } /// Strips a wrapping ```fenced block if the model added one despite instructions. diff --git a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs index bfa32b6..3050e8f 100644 --- a/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs +++ b/src/MandoCode.Desktop/Services/SnapshotEnhancer.cs @@ -1,7 +1,6 @@ using System.Text; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.ChatCompletion; -using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.Extensions.AI; +using OllamaSharp; namespace MandoCode.Desktop.Services; @@ -71,23 +70,19 @@ public static class SnapshotEnhancer public static async Task SummarizeAsync( string endpoint, string model, string rawHistory, CancellationToken ct = default) { - var kernel = Kernel.CreateBuilder() - .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) - .Build(); - - var chat = kernel.GetRequiredService(); + using IChatClient chat = new OllamaApiClient(new Uri(endpoint), model); var chunks = Chunk(rawHistory); // Short conversation — one pass, straight to a final-shaped recap. if (chunks.Count <= 1) - return await SummarizeOneAsync(chat, kernel, SinglePrompt, rawHistory, ct); + return await SummarizeOneAsync(chat, SinglePrompt, rawHistory, ct); // Map: summarize each segment independently. var partials = new List(chunks.Count); for (int i = 0; i < chunks.Count; i++) { - var part = await SummarizeOneAsync(chat, kernel, MapPrompt, chunks[i], ct); + var part = await SummarizeOneAsync(chat, MapPrompt, chunks[i], ct); if (!string.IsNullOrWhiteSpace(part)) partials.Add($"Segment {i + 1}/{chunks.Count}:\n{part}"); } @@ -95,7 +90,7 @@ public static async Task SummarizeAsync( if (partials.Count == 0) return ""; // Reduce: fold the segment summaries into one recap. - return await SummarizeOneAsync(chat, kernel, ReducePrompt, string.Join("\n\n", partials), ct); + return await SummarizeOneAsync(chat, ReducePrompt, string.Join("\n\n", partials), ct); } private const string NamePrompt = @@ -115,10 +110,7 @@ public static async Task SummarizeAsync( { if (string.IsNullOrWhiteSpace(recap)) return null; - var kernel = Kernel.CreateBuilder() - .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) - .Build(); - var chat = kernel.GetRequiredService(); + using IChatClient chat = new OllamaApiClient(new Uri(endpoint), model); var instruction = NamePrompt; if (avoid.Count > 0) @@ -126,7 +118,7 @@ public static async Task SummarizeAsync( + string.Join("; ", avoid.Take(40)) + "."; // A touch of warmth so titles aren't all phrased alike, but still grounded in the recap. - var raw = await SummarizeOneAsync(chat, kernel, instruction, recap, ct, temperature: 0.4f); + var raw = await SummarizeOneAsync(chat, instruction, recap, ct, temperature: 0.4f); return SnapshotNaming.Clean(raw); } catch @@ -138,18 +130,20 @@ public static async Task SummarizeAsync( /// One chat round: system instruction + the text to summarize. Stateless — a fresh /// history each call, so nothing leaks between chunks. private static async Task SummarizeOneAsync( - IChatCompletionService chat, Kernel kernel, string instruction, string text, CancellationToken ct, + IChatClient chat, string instruction, string text, CancellationToken ct, float temperature = 0.2f) { - var history = new ChatHistory(); - history.AddSystemMessage(instruction); - history.AddUserMessage(text); + var history = new List + { + new(ChatRole.System, instruction), + new(ChatRole.User, text) + }; // Low temperature by default — a recap should be faithful, not creative. Naming nudges higher. - var settings = new OllamaPromptExecutionSettings { Temperature = temperature }; + var options = new ChatOptions { Temperature = temperature }; - var result = await chat.GetChatMessageContentAsync(history, settings, kernel, ct); - return result.Content?.Trim() ?? ""; + var result = await chat.GetResponseAsync(history, options, ct); + return result.Text?.Trim() ?? ""; } /// Splits the history into chunks on line boundaries. Chunk size grows if needed so the