Delibera — Upgrade to .NET 10 and C# 15
This document describes the changes made while migrating the Delibera project to the .NET 10 platform and the C# 15 (preview) language, the performance optimizations, and the new examples of the Operator role working with MCP tools (browser and Marp presentation generation).
All changes were made in the
feature/operator-mcp-rolebranch. The project builds withdotnet buildwithout warnings or errors.
Table of Contents#
- Target Platform and Language Version
- Dependency Updates
- Performance Optimizations (high performance)
- Operator + MCP Tools Examples
- Build and Run
- Summary of Changed Files
1. Target Platform and Language Version#
Both projects were migrated to a single stack:
| Property | Before | After |
|---|---|---|
TargetFramework |
net10.0 |
net10.0 |
LangVersion |
14.0 |
preview (C# 15) |
Nullable |
enable |
enable |
ImplicitUsings |
enable |
enable |
The changes were made in the files:
src/Delibera.Core/Delibera.Core.csprojsrc/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj
⚠️ Important note about "C# 15"#
At the time of the original upgrade, the installed SDK was 10.0.301, whose Roslyn compiler
supports language versions up to and including 14.0; the next one (the future C# 15) was
available only through the preview value. Specifying <LangVersion>15.0</LangVersion>
directly resulted in a compilation error:
error CS1617: Invalid option '15.0' for /langversionTherefore <LangVersion>preview</LangVersion> is used. When an SDK with official 15.0 support
is released, the value can be replaced with 15.0.
What preview actually buys, measured#
LangVersion=preview means different things on different SDKs, because the feature set comes from
the compiler, not from the target framework. Two SDKs of the same major band are not
equivalent here:
| SDK | Roslyn | Stable language level | C# 15 features present |
|---|---|---|---|
| 9.0.318 | 4.14 | C# 13 | none |
| 10.0.112 | 5.0 | C# 14 | none |
| 10.0.401 | 5.9 | C# 14 | collection expression arguments, labeled break/continue, extension indexers |
global.json now pins 10.0.401 (rollForward: latestFeature) so a machine holding only
10.0.112 cannot silently compile this codebase without the features it is written against.
The gate was run by compiling each feature against net10.0, not by reading release notes:
| Feature | Compiles on net10.0? | Note |
|---|---|---|
Collection expression arguments — [with(capacity: n), …] |
yes | used on the hot paths |
Labeled break / continue |
yes | ergonomics only; no hot-path candidate |
| Extension indexers | yes | no candidate in this codebase |
| Union types | no | needs System.Runtime.CompilerServices.IUnion |
| Closed hierarchies | no | needs System.Runtime.CompilerServices.IsClosedTypeAttribute |
Union types and closed hierarchies are blocked by the BCL, not by the compiler: IUnion and
IsClosedTypeAttribute ship with .NET 11, so on net10.0 the compiler rejects them with
error CS0518: Predefined type 'System.Runtime.CompilerServices.IUnion' is not defined and
error CS0656: Missing compiler required member '…IsClosedTypeAttribute..ctor' respectively.
They are therefore deferred to the net11.0 retarget, together with the runtime work that
retarget unlocks (Runtime Async, JIT improvements, System.Text.Json union/JSONL support).
2. Dependency Updates#
All NuGet packages were brought to versions compatible with .NET 10. Most packages were already on
their current versions for .NET 10; Microsoft.SourceLink.GitHub was updated.
Delibera.Core#
| Package | Version | Purpose |
|---|---|---|
Microsoft.Extensions.Configuration.Abstractions |
10.0.9 | Configuration (DI) |
Microsoft.Extensions.Configuration.Json |
10.0.9 | Reading appsettings.json |
Microsoft.Extensions.DependencyInjection |
10.0.9 | DI container |
Microsoft.Extensions.DependencyInjection.Abstractions |
10.0.9 | DI abstractions |
Microsoft.Extensions.Options |
10.0.9 | Options pattern |
Microsoft.Extensions.Options.ConfigurationExtensions |
10.0.9 | Binding Options to configuration |
ModelContextProtocol |
1.4.0 | SDK for MCP servers (Operator role) |
Npgsql |
10.0.3 | PostgreSQL / pgvector |
OllamaSharp |
5.4.25 | Ollama LLM provider |
Pgvector |
0.3.2 | Vector search in PostgreSQL |
Qdrant.Client |
1.18.1 | Qdrant vector store |
Microsoft.SourceLink.GitHub |
10.0.300 (was 10.0.102) | Source Link for debugging |
Delibera.ConsoleApp#
| Package | Version | Purpose |
|---|---|---|
Microsoft.Extensions.Configuration |
10.0.9 | Configuration |
Microsoft.Extensions.Configuration.Binder |
10.0.9 | Configuration binding |
Microsoft.Extensions.Configuration.Json |
10.0.9 | JSON configuration |
Microsoft.Extensions.Configuration.UserSecrets |
10.0.9 | User Secrets |
Microsoft.Extensions.DependencyInjection |
10.0.9 | DI container |
The currency check was performed via
dotnet list package --outdatedand the NuGet flat-container API — at the time of the upgrade there are no newer stable versions for the listed packages.
3. Performance Optimizations (high performance)#
Targeted optimizations of the "hot" paths were made using Span<T>/ReadOnlySpan<T>,
SIMD (System.Numerics.Vector<T>), and ArrayPool<T>. All optimizations preserve the previous
behavior (verified numerically).
3.1. SIMD vectorization of cosine similarity#
File: src/Delibera.Core/Compression/SemanticCompressor.cs → CosineSimilarity
Cosine similarity is the hottest loop during semantic compression and deduplication (it is called for every pair of sentences across all embedding dimensions, typically 768–1536). The implementation was rewritten:
- The signature was changed from
float[]toReadOnlySpan<float>— this is zero-copy and allows passing arrays, slices, andstackallocbuffers without allocations (calling code usingfloat[]works without changes). - The main loop is vectorized via
Vector<float>: each iteration processesVector<float>.Countelements (on modern x64/ARM64 — 8–16 values per cycle). - The scalar "tail" handles the remainder and also serves as a fallback path when there is no
hardware acceleration (
Vector.IsHardwareAccelerated == false).
internal static double CosineSimilarity(ReadOnlySpan<float> a, ReadOnlySpan<float> b)
{
if (a.Length != b.Length || a.IsEmpty) return 0;
float dot = 0, magA = 0, magB = 0;
var i = 0;
if (Vector.IsHardwareAccelerated && a.Length >= Vector<float>.Count)
{
var dotAcc = Vector<float>.Zero;
var magAAcc = Vector<float>.Zero;
var magBAcc = Vector<float>.Zero;
var width = Vector<float>.Count;
for (; i <= a.Length - width; i += width)
{
var va = new Vector<float>(a.Slice(i, width));
var vb = new Vector<float>(b.Slice(i, width));
dotAcc += va * vb;
magAAcc += va * va;
magBAcc += vb * vb;
}
dot = Vector.Dot(dotAcc, Vector<float>.One);
magA = Vector.Dot(magAAcc, Vector<float>.One);
magB = Vector.Dot(magBAcc, Vector<float>.One);
}
for (; i < a.Length; i++) { dot += a[i]*b[i]; magA += a[i]*a[i]; magB += b[i]*b[i]; }
var denom = Math.Sqrt(magA) * Math.Sqrt(magB);
return denom > 0 ? dot / denom : 0;
}Correctness. A numerical comparison with the reference (scalar) implementation on lengths
1, 3, 7, 8, 16, 17, 100, 1536 yielded a maximum absolute error of ≈ 6·10⁻⁸
(at the precision level of float); the result is identical.
3.2. Allocation-free token counting (ReadOnlySpan<char>)#
File: src/Delibera.Core/Compression/TokenCounter.cs
- Added an
EstimateTokens(ReadOnlySpan<char> text)overload — it allows estimating tokens for string slices without allocating substrings. - The internal
CountWordsacceptsReadOnlySpan<char>and iterates over characters using aref structenumerator — without boxing and without copies. - The existing
EstimateTokens(string?)is preserved and now delegates to the span overload (viatext.AsSpan()), so the public API is fully backward compatible.
3.3. Cache key hashing via ArrayPool<T> + stackalloc#
File: src/Delibera.Core/Compression/CompressionCache.cs → ComputeKey
Previously, building the cache key created two extra heap allocations on every call:
the interpolated string "{strategy}:{text}" and a byte array from Encoding.UTF8.GetBytes.
The new implementation:
- Rents a character buffer from
ArrayPool<char>.Sharedand concatenatesstrategy + ':' + textinto it. - Rents a byte buffer from
ArrayPool<byte>.Sharedand encodes UTF-8 directly into it (Encoding.UTF8.GetBytes(span, span)). - Computes SHA-256 into a stack buffer
stackalloc byte[SHA256.HashSizeInBytes]and formats hex. - Guarantees that the rented buffers are returned in
finally.
Result: on the "warm" compression-cache path, intermediate allocations of large strings/arrays are eliminated, which reduces GC pressure for large volumes of debate context.
3.4. ValueTask in the Operator role#
The Operator resource-disposal method is implemented as public async ValueTask DisposeAsync()
(src/Delibera.Core/Council/Operator.cs) — the standard .NET 10 IAsyncDisposable pattern, which
avoids a Task allocation on the MCP-client disposal path. The new example
(OperatorMcpToolsExample) uses await using, correctly exercising this path.
Optimization summary#
| Technique | Where applied | Effect |
|---|---|---|
ReadOnlySpan<T> + SIMD |
SemanticCompressor.CosineSimilarity |
8–16× processing width, no copies |
ReadOnlySpan<char> |
TokenCounter (word/token counting) |
No substring allocations |
ArrayPool<T> + stackalloc |
CompressionCache.ComputeKey |
−2 heap allocations per call, less GC pressure |
ValueTask |
Operator.DisposeAsync |
Disposal without a Task allocation |
4. Operator + MCP Tools Examples#
A new example OperatorMcpToolsExample was added
(src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs), demonstrating the Operator role
working with real MCP servers.
Connected MCP servers#
| Server | Transport | Launch command | What it provides |
|---|---|---|---|
🌐 browser |
stdio | npx -y @playwright/mcp@latest --headless |
Site navigation, page reading, clicks, screenshots |
🎯 marp |
stdio | npx -y @marp-team/marp-cli --server <dir> |
Generating presentations (HTML/PDF/PPTX) from Markdown |
The example also shows (commented out) an HTTP/SSE variant via
McpServerConfig.Http(...)for remote MCP servers with authorization headers.
What the example demonstrates#
Section A — direct Operator call (without a council):
- Manual creation of MCP clients (
McpClientAdapter) and anOperatorobject. await usingfor correct disposal viaValueTask DisposeAsync.InitializeAsync()— connecting to the servers and discovering tools.- Two tasks via
ExecuteTaskAsync:- Browser: open
https://modelcontextprotocol.ioand briefly summarize what MCP is. - Marp: generate a 3-slide presentation and save it as
deck.html.
- Browser: open
Section B — Operator inside a council (delegation via the [[OPERATOR: ...]] marker):
- Building a council via
CouncilBuilderwith participants, a chairman (Chairman), and an Operator (the convenientWithOperator(modelName, provider, servers)overload). - Subscribing to
OnRoundCompletedto print Operator activity per round. - Saving the debate Markdown report and the generated presentations.
Running the example#
# from the project directory
dotnet run --project src/Delibera.ConsoleApp -- --operator-mcpPrerequisites#
- Node.js + npx — required to launch the npx MCP servers (
@playwright/mcp,@marp-team/marp-cli). - For the browser, on first run Playwright will download the engines:
npx playwright install. - Ollama running locally (
ollama serve) with the modelsllama3.2,qwen2.5(the Operator model can be replaced with any "cheap" one).
The example is resilient to a missing environment: on error it prints clear hints, and the process does not crash.
5. Build and Run#
# Build both projects
dotnet build
# Build in Release
dotnet build -c Release
# Run the console application
dotnet run --project src/Delibera.ConsoleApp
# Available demos (flags):
dotnet run --project src/Delibera.ConsoleApp -- --operator # basic Operator example
dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp # 🆕 browser + Marp
dotnet run --project src/Delibera.ConsoleApp -- --compression # context compression
dotnet run --project src/Delibera.ConsoleApp -- --rag # RAG
dotnet run --project src/Delibera.ConsoleApp -- --di # Dependency InjectionBuild result:
Build succeeded.
0 Warning(s)
0 Error(s)Note about localhost. The examples use the address
http://localhost:11434(Ollama) and local paths for presentation output. This is the localhost of the machine where the application runs (the Abacus AI Agent environment), not your computer. To run it locally, download the files via the "Files" icon, go into the downloaded folder, deploy the application on your own system, and run it.
6. Summary of Changed Files#
| File | Change |
|---|---|
src/Delibera.Core/Delibera.Core.csproj |
LangVersion=preview, SourceLink → 10.0.300 |
src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj |
LangVersion=preview |
src/Delibera.Core/Compression/SemanticCompressor.cs |
SIMD vectorization of CosineSimilarity + ReadOnlySpan<float> |
src/Delibera.Core/Compression/TokenCounter.cs |
ReadOnlySpan<char> overload, span-based word counting |
src/Delibera.Core/Compression/CompressionCache.cs |
ArrayPool<T> + stackalloc in ComputeKey |
src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs |
🆕 Operator example with browser and Marp |
src/Delibera.ConsoleApp/Program.cs |
--operator-mcp flag, fixed nullable warning |
Document prepared as part of the Delibera upgrade to .NET 10 / C# 15.
Source: docs/NET10-Upgrade.md on GitHub — edit it there and this page updates on the next deploy.
