From de5210b91876af0eab1f8dac795baeee35e56344 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Tue, 30 Jun 2026 17:35:30 +0200 Subject: [PATCH 1/2] Add full cooperative CancellationToken support everywhere - All public async methods now accept and honor CancellationToken - IKnowledgeBase: LoadAsync/LoadManyAsync require CT (source-breaking for implementers) - MarkdownKnowledgeBase: file and in-memory loaders (LoadTextAsync, LoadTextsAsync) support CT; DocumentMetadata exposed - DebateResult: all save methods accept/forward CT - CouncilExecutor.ExecuteAsync forwards CT to SaveToFileAsync - New IAppStoppingToken and CouncilExecutorLifetimeExtensions for host shutdown integration - ConsoleApp: default Ctrl+C handler and --cancellation demo - Docs: new Cancellation Support section, usage examples, NuGet metadata updated - 20+ new tests for cancellation across KB, DebateResult, executor, and extensions - All changes are additive and backward compatible except for IKnowledgeBase implementers --- CHANGELOG.md | 70 ++++ README.md | 24 ++ .../Examples/CancellationExample.cs | 144 +++++++++ src/Delibera.ConsoleApp/Program.cs | 55 +++- src/Delibera.Core/Council/CouncilExecutor.cs | 2 +- src/Delibera.Core/Delibera.Core.csproj | 8 +- .../CouncilExecutorLifetimeExtensions.cs | 34 ++ .../Extensions/IAppStoppingToken.cs | 16 + .../Interfaces/IKnowledgeBase.cs | 22 +- .../Knowledge/KnowledgeDocument.cs | 12 + .../Knowledge/MarkdownKnowledgeBase.cs | 151 ++++++++- src/Delibera.Core/Models/DebateResult.cs | 49 ++- src/Delibera.Core/README.md | 87 ++++- .../CouncilExecutorCancellationTests.cs | 82 +++++ .../CouncilExecutorLifetimeExtensionsTests.cs | 139 ++++++++ .../DebateResultCancellationTests.cs | 100 ++++++ .../Fakes/FakeLLMProvider.cs | 39 +++ .../MarkdownKnowledgeBaseTests.cs | 300 ++++++++++++++++++ 18 files changed, 1286 insertions(+), 48 deletions(-) create mode 100644 src/Delibera.ConsoleApp/Examples/CancellationExample.cs create mode 100644 src/Delibera.Core/Extensions/CouncilExecutorLifetimeExtensions.cs create mode 100644 src/Delibera.Core/Extensions/IAppStoppingToken.cs create mode 100644 src/Delibera.Core/Knowledge/KnowledgeDocument.cs create mode 100644 tests/Delibera.Core.Tests/CouncilExecutorCancellationTests.cs create mode 100644 tests/Delibera.Core.Tests/CouncilExecutorLifetimeExtensionsTests.cs create mode 100644 tests/Delibera.Core.Tests/DebateResultCancellationTests.cs create mode 100644 tests/Delibera.Core.Tests/Fakes/FakeLLMProvider.cs create mode 100644 tests/Delibera.Core.Tests/MarkdownKnowledgeBaseTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 159368a..688cf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,76 @@ All notable changes to **Delibera** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.2.4] - 2026 + +### Added — Full cooperative CancellationToken support + +Every public async method in the library now honors a `CancellationToken` cooperatively. +A single cancel signal aborts the entire pipeline — rounds, chairman synthesis, LLM +calls, MCP tool invocations, RAG queries and even file writes — via +`OperationCanceledException`. + +### Changed — Additive CT parameters (no breaking binary changes) + +- **`IKnowledgeBase.LoadAsync(string, CancellationToken)`** and + **`IKnowledgeBase.LoadManyAsync(IEnumerable, CancellationToken)`** — + `CancellationToken ct = default` added to existing method signatures. Source-breaking + for any external implementer of the interface; binary-compatible for callers. + +- **`MarkdownKnowledgeBase`** — the file-path API + (`LoadAsync`, `LoadManyAsync`, `LoadDirectoryAsync`) now accepts a `CancellationToken` + and forwards it to `File.ReadAllTextAsync` and between files in bulk operations. + +- **`DebateResult`** — all save methods + (`SaveToMarkdownAsync`, `SaveStatisticsAsync`, `SaveLogsAsync`, `SaveAllAsync`, + `SaveToFileAsync`) accept a `CancellationToken` and forward it to `File.WriteAllTextAsync`. + +- **`CouncilExecutor.ExecuteAsync`** — the caller's `CancellationToken` is now forwarded + to the final `result.SaveToFileAsync(_outputPath, ct)` step, closing the top-level + cancellation chain. + +### Added — Hosted-service helper + +- **`Delibera.Core.Extensions.CouncilExecutorLifetimeExtensions.ExecuteAsync( + ICouncilExecutor, IAppStoppingToken, CancellationToken)`** — links the caller's + `CancellationToken` with an `IAppStoppingToken.ApplicationStopping` token via + `CancellationTokenSource.CreateLinkedTokenSource` so a host shutdown (ASP.NET Core, + Worker Service, etc.) cancels the debate cooperatively. + +- **`IAppStoppingToken`** — minimal `Delibera.Core.Extensions` interface + (`CancellationToken ApplicationStopping { get; }`) that any host lifetime can + implement in 3 lines without forcing a `Microsoft.Extensions.Hosting` dependency + on `Delibera.Core`. + +### Added — ConsoleApp demo + +- **`Delibera.ConsoleApp.Examples.CancellationExample`** — run with `--cancellation`. + Wires `Console.CancelKeyPress` to a `CancellationTokenSource` and forwards the + token through `executor.ExecuteAsync(cts.Token)`. Includes a heartbeat task that + proves the main thread is alive while the debate is awaiting. The default + `Program.Main` now also wires Ctrl+C for the main demo path so a user can cancel + any run with a single keystroke. + +### Tests + +- 6 new tests in `MarkdownKnowledgeBaseTests` covering CT honoring on the file-path + API and bulk operations. +- 7 new tests in `DebateResultCancellationTests` covering all save methods with + pre-canceled tokens + a happy-path test for `SaveAllAsync`. +- 2 new tests in `CouncilExecutorCancellationTests` covering the executor's + CT-aware save path and a positive no-cancel run. +- 5 new tests in `CouncilExecutorLifetimeExtensionsTests` covering the + `IAppStoppingToken` helper, including null-argument validation. + +### Documentation + +- New **Cancellation Support** section in `Delibera.Core/README.md` with 4 worked + examples (timeout, manual cancel, ASP.NET Core `IHostApplicationLifetime` adapter, + Console Ctrl+C) and a table of cancellable operations. +- New **Cancellation Support** section in the repo-root `README.md` with a quick-start + example and a link to the full guide. +- Feature row added to the Key Features table in both READMEs. + ## [10.2.3] - 2026 ### Added — AutoChunking (progressive disclosure for large documents) diff --git a/README.md b/README.md index 4607b0c..dd85466 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ well-reasoned outcomes** rather than single-model guesses. | **📁 Separate File Output** | Export `result.md`, `statistics.md`, and `logs.md` independently | | **🔌 Interface-First** | Clean abstractions for providers, factories, builders and executors | | **🤝 Microsoft.Extensions.AI**| First-class support for `IChatClient` / `IEmbeddingGenerator` — plug in OpenAI, Azure OpenAI, Ollama or any compatible backend, with middleware (function calling, logging) | +| **🛑 Cooperative Cancellation**| Every public async method accepts a `CancellationToken`; a host shutdown or user cancel aborts the debate mid-flight (rounds, LLM calls, MCP tools, RAG, file saves) | | **🧱 Modern C# 15 (preview)** | Built on .NET 10 with `LangVersion=preview`, file-scoped namespaces, records, span/SIMD hot paths | --- @@ -67,6 +68,7 @@ well-reasoned outcomes** rather than single-model guesses. - [RAG Integration](#-rag-integration) - [Debate Strategies](#-debate-strategies) - [Output Files Structure](#-output-files-structure) +- [Cancellation Support](#-cancellation-support) - [ConsoleApp Examples](#-consoleapp-examples) - [Installation & Build](#-installation--build) - [Architecture](#-architecture) @@ -632,6 +634,28 @@ await result.SaveLogsAsync("logs.md"); --- +## 🛑 Cancellation Support + +Every public async method in Delibera honors a `CancellationToken` cooperatively. A single +cancel signal aborts the entire pipeline — rounds, chairman synthesis, LLM calls, MCP tools, +RAG queries and even file writes — via `OperationCanceledException`. + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); +var result = await executor.ExecuteAsync(cts.Token); +``` + +ASP.NET Core / Worker Services can link the token to `IHostApplicationLifetime.ApplicationStopping` +via the `Delibera.Core.Extensions.CouncilExecutorLifetimeExtensions.ExecuteAsync(executor, lifetime, ct)` +helper. Console apps can wire `Console.CancelKeyPress` to the same `CancellationTokenSource`. + +The `Delibera.ConsoleApp` includes a `--cancellation` demo that exercises this end-to-end. + +See the [Cancellation section in `Delibera.Core` README](src/Delibera.Core/README.md#-cancellation-support) +for the full table of cancellable operations and the linked-token pattern. + +--- + ## 💻 ConsoleApp Examples The repository ships with [`Delibera.ConsoleApp`](src/Delibera.ConsoleApp/), a runnable demo diff --git a/src/Delibera.ConsoleApp/Examples/CancellationExample.cs b/src/Delibera.ConsoleApp/Examples/CancellationExample.cs new file mode 100644 index 0000000..148667e --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/CancellationExample.cs @@ -0,0 +1,144 @@ +using System.Diagnostics; +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates cooperative cancellation across the entire Delibera pipeline: +/// Ctrl+C is captured into a and the +/// CT is forwarded into executor.ExecuteAsync(cts.Token), which +/// propagates it through rounds, LLM calls, MCP tools, RAG queries, and +/// even the final result-save step. +/// +public static class CancellationExample +{ + /// + /// Runs the cancellation example. Press Ctrl+C at any time to abort. + /// + public static async Task RunAsync() + { + Console.WriteLine("═══════════════════════════════════════════"); + Console.WriteLine(" 🛑 Cancellation Example"); + Console.WriteLine("═══════════════════════════════════════════"); + Console.WriteLine(" Press Ctrl+C at any time to cancel."); + Console.WriteLine(" The debate is wired to a CancellationTokenSource that"); + Console.WriteLine(" aborts mid-flight (rounds, LLM calls, file saves)."); + Console.WriteLine(); + + using var cts = new CancellationTokenSource(); + + // Capture Ctrl+C (and Ctrl+Break on Windows). Cancel the source AND + // suppress the default SIGINT-terminates-process behaviour so we can + // surface a friendly diagnostic instead. + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + if (!cts.IsCancellationRequested) + { + Console.WriteLine("\n⚠️ Ctrl+C detected — signaling cancellation..."); + try { cts.Cancel(); } catch (ObjectDisposedException) { /* race */ } + } + }; + + // 1. Create a provider (Ollama local is the easiest for a demo). + using var factory = new ProviderFactory(); + OllamaProvider? ollama = null; + try + { + ollama = factory.CreateLocalOllama("http://localhost:11434"); + if (!await ollama.IsAvailableAsync(cts.Token)) + { + Console.WriteLine(" ⚠️ Local Ollama not available — falling back to Ollama Cloud (set OLLAMA_API_KEY env)."); + ollama.Dispose(); + ollama = null; + } + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ Could not initialise local Ollama: {ex.Message}"); + } + + if (ollama is null) + { + var apiKey = Environment.GetEnvironmentVariable("OLLAMA_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + Console.WriteLine(" ❌ No Ollama provider available (no local server, no OLLAMA_API_KEY env)."); + Console.WriteLine(" Skipping live demo. The cancellation plumbing still works —"); + Console.WriteLine(" just run the example against any reachable Ollama endpoint."); + return; + } + ollama = factory.CreateOllama("https://api.ollama.com", apiKey); + } + + ILLMProvider llm = ollama; // widen once for the council builder + + // 2. Build a small, fast council (1 round, 2 members) so cancellation has a chance to be exercised. + var executor = new CouncilBuilder() + .AddMember("llama2", llm, "Analyst") + .AddMember("qwen2.5", llm, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5", llm)) + .WithStandardDebate() + .WithSystemPrompt("You are a thoughtful expert in software engineering.") + .WithUserPrompt("Compare microservices vs a modular monolith for a 10-person startup.") + .WithMaxRounds(1) + .WithTemperature(0.5f) + .SaveResultTo("./debate_results/cancellation_demo.md") + .Build(); + + executor.OnRoundCompleted += round => + Console.WriteLine($" ✅ Round {round.RoundNumber} completed in {round.Duration.TotalSeconds:F1}s"); + executor.OnLog += e => Console.WriteLine($" ┊ {e}"); + + // 3. Heartbeat — proves the main thread is alive even while the council is awaiting. + // This task is itself cancellable; it exits cleanly when the user hits Ctrl+C. + using var heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + var heartbeat = Task.Run(async () => + { + var sw = Stopwatch.StartNew(); + try + { + while (!heartbeatCts.Token.IsCancellationRequested) + { + Console.WriteLine($" 💓 Still working... ({sw.Elapsed:mm\\:ss}) — press Ctrl+C to cancel"); + await Task.Delay(TimeSpan.FromSeconds(2), heartbeatCts.Token); + } + } + catch (OperationCanceledException) { /* expected on shutdown */ } + }); + + // 4. Run the council with the token. + var swTotal = Stopwatch.StartNew(); + try + { + var result = await executor.ExecuteAsync(cts.Token); + swTotal.Stop(); + + Console.WriteLine($"\n🏆 Debate completed in {swTotal.Elapsed:mm\\:ss}"); + Console.WriteLine($" Rounds: {result.Rounds.Count}"); + Console.WriteLine($" Verdict chars: {result.FinalVerdict?.Length ?? 0}"); + + // Also demonstrate that the save methods honor cancellation. + var outputDir = "./debate_results/cancellation_demo"; + Directory.CreateDirectory(outputDir); + var (r, s, l) = await result.SaveAllAsync(outputDir, ct: cts.Token); + Console.WriteLine($" Result: {r}"); + Console.WriteLine($" Statistics: {s}"); + Console.WriteLine($" Logs: {l}"); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + swTotal.Stop(); + Console.WriteLine($"\n🛑 Debate cancelled after {swTotal.Elapsed:mm\\:ss}."); + Console.WriteLine(" No partial result was saved (cancellation propagated through all layers)."); + } + finally + { + heartbeatCts.Cancel(); + try { await heartbeat; } catch { /* ignore */ } + } + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 38073be..a58c4b1 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -31,9 +31,27 @@ public static async Task Main(string[] args) // `alreadyReported` prevents double-printing when RunAsync prints its own // debate-specific diagnostic (with tips) before re-throwing. var alreadyReported = false; + + // Default Ctrl+C handling: cancel the in-flight debate cooperatively + // instead of letting SIGINT terminate the process mid-round. + // Consumers who want to disable this behaviour (e.g. running under a + // debugger) can call `Main(args)` and ignore the default. Examples + // that use their own CancelKeyPress handlers (e.g. --cancellation) + // override this by setting e.Cancel = true on their own. + using var appCts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => + { + e.Cancel = true; + if (!appCts.IsCancellationRequested) + { + Console.WriteLine("\n⚠️ Ctrl+C detected — canceling the debate..."); + try { appCts.Cancel(); } catch (ObjectDisposedException) { /* race */ } + } + }; + try { - await RunAsync(args, () => alreadyReported = true).ConfigureAwait(false); + await RunAsync(args, () => alreadyReported = true, appCts.Token).ConfigureAwait(false); } catch (Exception ex) { @@ -47,7 +65,7 @@ public static async Task Main(string[] args) WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", false); } - private static async Task RunAsync(string[] args, Action onDebateFailed) + private static async Task RunAsync(string[] args, Action onDebateFailed, CancellationToken ct = default) { // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples @@ -118,11 +136,18 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) return; } + if (args.Contains("--cancellation")) + { + await CancellationExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); Console.WriteLine(" Run with --separate-files for file output demo"); - Console.WriteLine(" Run with --autochunking for AutoChunking demo (large documents)\n"); + Console.WriteLine(" Run with --autochunking for AutoChunking demo (large documents)"); + Console.WriteLine(" Run with --cancellation for cooperative cancellation demo (Ctrl+C)\n"); // ═══════════════════════════════════════════════ // 1. Load configuration @@ -161,10 +186,11 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) foreach (var (name, prov) in providers) try { - var ok = await prov.IsAvailableAsync(); + ct.ThrowIfCancellationRequested(); + var ok = await prov.IsAvailableAsync(ct); Console.WriteLine($" {name}: {(ok ? "✅ Available" : "❌ Unavailable")}"); if (!ok) continue; - var models = await prov.ListModelsAsync(); + var models = await prov.ListModelsAsync(ct); Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); } catch (Exception ex) @@ -241,7 +267,8 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) foreach (var file in knowledgeFiles) try { - var chunks = await knowledgeKeeper.IndexFileAsync(file); + ct.ThrowIfCancellationRequested(); + var chunks = await knowledgeKeeper.IndexFileAsync(file, ct: ct); Console.WriteLine($" 📄 Indexed: {file} ({chunks} chunks)"); } catch (Exception ex) @@ -266,13 +293,18 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) foreach (var file in cfg.GetSection("Knowledge:Files").Get() ?? []) try { - await kb.LoadAsync(file); + ct.ThrowIfCancellationRequested(); + await kb.LoadAsync(file, ct); Console.WriteLine($" 📄 Loaded: {file}"); } catch (FileNotFoundException) { Console.WriteLine($" ⚠️ Not found: {file}"); } + catch (OperationCanceledException) + { + throw; + } if (kb.DocumentCount > 0) { @@ -477,7 +509,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) try { - var result = await executor.ExecuteAsync(); + var result = await executor.ExecuteAsync(ct); Console.WriteLine($"\n{new string('═', 60)}"); Console.WriteLine("🏆 DEBATE COMPLETED!"); @@ -527,7 +559,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) try { var separateDir = Path.Combine(outputDir, $"debate_{DateTime.UtcNow:yyyyMMdd_HHmmss}"); - var (rp, sp, lp) = await result.SaveAllAsync(separateDir); + var (rp, sp, lp) = await result.SaveAllAsync(separateDir, filePrefix: null, ct: ct); Console.WriteLine($" Result: {rp}"); Console.WriteLine($" Statistics: {sp}"); Console.WriteLine($" Logs: {lp}"); @@ -545,6 +577,11 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) Console.WriteLine($" {log}"); } } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + Console.WriteLine("\n🛑 Debate cancelled by user (Ctrl+C or token)."); + // Don't print the full fatal-error panel for a clean cancel. + } catch (Exception ex) { PrintFatalError(ex, "❌ Debate failed"); diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index f69b571..bf56ab5 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -210,7 +210,7 @@ public async Task ExecuteAsync(CancellationToken ct = default) if (!string.IsNullOrWhiteSpace(_outputPath)) { - await result.SaveToFileAsync(_outputPath); + await result.SaveToFileAsync(_outputPath, ct); Log(ExecutionLog.Info("Output", $"Result saved to: {_outputPath}")); } diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index e50f7a6..4eaf614 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,21 +12,21 @@ Delibera.Core - 10.2.3 + 10.2.4 Delibera - Delibera — Thoughtful AI Decisions. A framework for collective decision making through structured AI deliberation. Supports multi-model councils with RAG (Qdrant, PgVector), debate strategies, Knowledge Keeper, Chairman roles, dependency injection, context compression, AutoChunking (progressive disclosure for large documents), Microsoft.Extensions.Logging, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, response-language enforcement, and parallel Operator requests. + Delibera — Thoughtful AI Decisions. A framework for collective decision making through structured AI deliberation. Supports multi-model councils with RAG (Qdrant, PgVector), debate strategies, Knowledge Keeper, Chairman roles, dependency injection, context compression, AutoChunking (progressive disclosure for large documents), Microsoft.Extensions.Logging, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, response-language enforcement, parallel Operator requests, and full cooperative CancellationToken support across every async public method (rounds, LLM calls, MCP tools, RAG, file saves). Victor Buzin Techbuzzz Delibera Copyright © 2026 Techbuzzz - llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection;resilience;polly;retry;httpclient;autochunking;chunking;context-window + llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection;resilience;polly;retry;httpclient;autochunking;chunking;context-window;cancellation;cancellationtoken;ct README.md icon.png MIT https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas. CouncilBuilder(CouncilOptions) constructor for one-shot setup. DI auto-wires CouncilOptions → CouncilBuilder. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. + v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas. CouncilBuilder(CouncilOptions) constructor for one-shot setup. DI auto-wires CouncilOptions → CouncilBuilder. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. v10.2.4 — MarkdownKnowledgeBase in-memory loaders: new KnowledgeDocument record (Name, Content, Metadata) in Delibera.Core.Knowledge for tagging documents with per-source metadata. New MarkdownKnowledgeBase.LoadTextAsync(string content, string sourceName, CancellationToken) and LoadTextAsync(KnowledgeDocument, CancellationToken) ingest markdown bodies without temp files. New LoadTextsAsync(IEnumerable{KnowledgeDocument}, CancellationToken) for sequential bulk ingest with cooperative cancellation. New DocumentMetadata property exposes the per-source metadata snapshot. All new methods honor CancellationToken (checked at entry and between documents). Existing file-path API (LoadAsync, LoadManyAsync, LoadDirectoryAsync) is unchanged — pure additive, non-breaking. v10.2.4 enhancement — Full cooperative CancellationToken support: every public async method in the library now honors a CancellationToken. IKnowledgeBase.LoadAsync / LoadManyAsync, MarkdownKnowledgeBase file-path API (LoadAsync / LoadManyAsync / LoadDirectoryAsync), all DebateResult save methods (SaveToMarkdownAsync, SaveStatisticsAsync, SaveLogsAsync, SaveAllAsync, SaveToFileAsync), and the final SaveToFileAsync call inside CouncilExecutor.ExecuteAsync all accept and forward CancellationToken. New IAppStoppingToken abstraction in Delibera.Core.Extensions and CouncilExecutorLifetimeExtensions.ExecuteAsync(executor, lifetime, ct) helper for hosted-service shutdown. ConsoleApp: new --cancellation demo and default Ctrl+C handler in Main. Unit tests added: 20+ new cancellation tests across MarkdownKnowledgeBase, DebateResult, CouncilExecutor, and the lifetime helper. true snupkg true diff --git a/src/Delibera.Core/Extensions/CouncilExecutorLifetimeExtensions.cs b/src/Delibera.Core/Extensions/CouncilExecutorLifetimeExtensions.cs new file mode 100644 index 0000000..070b096 --- /dev/null +++ b/src/Delibera.Core/Extensions/CouncilExecutorLifetimeExtensions.cs @@ -0,0 +1,34 @@ +namespace Delibera.Core.Extensions; + +/// +/// Extension methods that make +/// cooperate with host-level shutdown signals so a council is cancelled +/// automatically when the host stops. +/// +public static class CouncilExecutorLifetimeExtensions +{ + /// + /// Executes the council, linking the caller's + /// with the token + /// so that a host shutdown cancels the debate cooperatively. + /// Whichever signal fires first wins. + /// + /// The council executor. + /// The lifetime abstraction to link to. + /// An additional cancellation token from the caller. + /// The completed debate result. + /// or is null. + /// Either token was canceled. + public static async Task ExecuteAsync( + this Interfaces.ICouncilExecutor executor, + IAppStoppingToken lifetime, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(executor); + ArgumentNullException.ThrowIfNull(lifetime); + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + ct, lifetime.ApplicationStopping); + return await executor.ExecuteAsync(linked.Token).ConfigureAwait(false); + } +} diff --git a/src/Delibera.Core/Extensions/IAppStoppingToken.cs b/src/Delibera.Core/Extensions/IAppStoppingToken.cs new file mode 100644 index 0000000..956a8be --- /dev/null +++ b/src/Delibera.Core/Extensions/IAppStoppingToken.cs @@ -0,0 +1,16 @@ +namespace Delibera.Core.Extensions; + +/// +/// Minimal abstraction over a host's "shutdown" signal. Lets consumers plug in +/// any cancellation source (ASP.NET Core's IHostApplicationLifetime, +/// a worker-service lifetime, a custom one) without forcing a hosting dependency +/// on the core library. +/// +public interface IAppStoppingToken +{ + /// + /// Token that is canceled when the host is shutting down (e.g. SIGINT, + /// app stop request, container termination). + /// + CancellationToken ApplicationStopping { get; } +} diff --git a/src/Delibera.Core/Interfaces/IKnowledgeBase.cs b/src/Delibera.Core/Interfaces/IKnowledgeBase.cs index 2f076e8..d3fb7f3 100644 --- a/src/Delibera.Core/Interfaces/IKnowledgeBase.cs +++ b/src/Delibera.Core/Interfaces/IKnowledgeBase.cs @@ -9,11 +9,25 @@ public interface IKnowledgeBase /// Knowledge base name. string Name { get; } - /// Loads knowledge from the specified source (file path or URI). - Task LoadAsync(string source); + /// + /// Loads knowledge from the specified source (file path or URI). + /// Implementations should honor cooperatively and + /// throw when cancellation is requested. + /// + /// File path or URI identifying the knowledge source. + /// Cancellation token; checked at entry and during the load. + /// The token has been canceled. + Task LoadAsync(string source, CancellationToken ct = default); - /// Loads knowledge from multiple sources. - Task LoadManyAsync(IEnumerable sources); + /// + /// Loads knowledge from multiple sources sequentially. + /// Implementations should honor cooperatively and + /// check for cancellation between sources. + /// + /// The knowledge sources to load. + /// Cancellation token; checked at entry and between sources. + /// The token has been canceled. + Task LoadManyAsync(IEnumerable sources, CancellationToken ct = default); /// Returns all loaded knowledge as a single text blob. string GetAllContent(); diff --git a/src/Delibera.Core/Knowledge/KnowledgeDocument.cs b/src/Delibera.Core/Knowledge/KnowledgeDocument.cs new file mode 100644 index 0000000..a810e81 --- /dev/null +++ b/src/Delibera.Core/Knowledge/KnowledgeDocument.cs @@ -0,0 +1,12 @@ +namespace Delibera.Core.Knowledge; + +/// +/// A single document to ingest into a . +/// Wraps the markdown body together with a stable display name and +/// optional metadata (used to tag the document so the council can +/// distinguish "contract" from "discovery context" inside the KB). +/// +public sealed record KnowledgeDocument( + string Name, + string Content, + IReadOnlyDictionary? Metadata = null); \ No newline at end of file diff --git a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs index 8d4fecc..ff0f7d7 100644 --- a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs +++ b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs @@ -7,6 +7,7 @@ namespace Delibera.Core.Knowledge; public sealed class MarkdownKnowledgeBase : IKnowledgeBase { private readonly Dictionary _documents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary?> _metadata = new(StringComparer.OrdinalIgnoreCase); /// Creates a Markdown knowledge base with an optional name. public MarkdownKnowledgeBase(string name = "Markdown Knowledge Base") @@ -20,25 +21,157 @@ public MarkdownKnowledgeBase(string name = "Markdown Knowledge Base") /// Total characters across all documents. public int TotalCharacters => _documents.Values.Sum(d => d.Length); + /// + /// Snapshot of per-source metadata captured at load time. + /// Keys are source names; values are the metadata dictionaries + /// supplied via + /// (or an empty/null entry for sources loaded through the file-path API). + /// + public IReadOnlyDictionary?> DocumentMetadata + => _metadata; + /// public string Name { get; } /// - public async Task LoadAsync(string source) + public async Task LoadAsync(string source, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(source); + ct.ThrowIfCancellationRequested(); var fullPath = Path.GetFullPath(source); if (!File.Exists(fullPath)) throw new FileNotFoundException($"Knowledge file not found: {fullPath}"); - _documents[Path.GetFileName(fullPath)] = await File.ReadAllTextAsync(fullPath); + var content = await File.ReadAllTextAsync(fullPath, ct).ConfigureAwait(false); + Store(Path.GetFileName(fullPath), content, metadata: null); } /// - public async Task LoadManyAsync(IEnumerable sources) + public async Task LoadManyAsync(IEnumerable sources, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(sources); - foreach (var s in sources) await LoadAsync(s); + foreach (var s in sources) + { + ct.ThrowIfCancellationRequested(); + await LoadAsync(s, ct).ConfigureAwait(false); + } + } + + /// + /// Loads all matching files from a directory. + /// + /// Directory to scan recursively. + /// File-name pattern (default: *.md). + /// Cancellation token; checked between files. + /// The directory does not exist. + /// The token has been canceled. + public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.md", CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directoryPath); + ct.ThrowIfCancellationRequested(); + var full = Path.GetFullPath(directoryPath); + if (!Directory.Exists(full)) + throw new DirectoryNotFoundException($"Directory not found: {full}"); + + await LoadManyAsync(Directory.GetFiles(full, pattern, SearchOption.AllDirectories), ct).ConfigureAwait(false); + } + + /// + /// Ingest a single markdown body into the KB. + /// Equivalent to writing the body to a temp file and calling + /// , but without the disk I/O. + /// The appears in the council's + /// per-round context, just like a file path would. + /// + /// Markdown body. Required, non-empty. + /// Display name for the document. Required, non-empty. + /// Cancellation token; honors cooperative cancellation at entry and during indexing. + /// or is null or whitespace. + /// The token has been canceled. + public async Task LoadTextAsync( + string content, + string sourceName, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(content); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + ct.ThrowIfCancellationRequested(); + + await LoadCoreAsync(content, sourceName, metadata: null, ct).ConfigureAwait(false); + } + + /// + /// Ingest a single . The + /// Metadata dictionary is preserved on the indexed + /// document (see ) and may surface + /// to the chairman as additional per-source context. + /// + /// The document to ingest. Required. + /// Cancellation token; honors cooperative cancellation at entry and during indexing. + /// is null. + /// or is null or whitespace. + /// The token has been canceled. + public async Task LoadTextAsync( + KnowledgeDocument document, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentException.ThrowIfNullOrWhiteSpace(document.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(document.Content); + ct.ThrowIfCancellationRequested(); + + await LoadCoreAsync(document.Content, document.Name, document.Metadata, ct).ConfigureAwait(false); + } + + /// + /// Bulk ingest. Equivalent to calling + /// + /// for each document sequentially, with cooperative cancellation + /// checked between documents. + /// + /// The documents to ingest. Required. + /// Cancellation token; checked before each document is loaded. + /// is null. + /// The token has been canceled. + public async Task LoadTextsAsync( + IEnumerable documents, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(documents); + foreach (var doc in documents) + { + ct.ThrowIfCancellationRequested(); + await LoadTextAsync(doc, ct).ConfigureAwait(false); + } + } + + /// + /// Shared indexing path for the file-based and in-memory loaders. + /// Stores under + /// together with optional . + /// + /// Markdown body. Caller has validated non-whitespace. + /// Display name. Caller has validated non-whitespace. + /// Optional metadata to tag the document. May be null. + /// Cancellation token; checked before the indexing work. + private Task LoadCoreAsync( + string content, + string sourceName, + IReadOnlyDictionary? metadata, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + Store(sourceName, content, metadata); + return Task.CompletedTask; + } + + private void Store( + string sourceName, + string content, + IReadOnlyDictionary? metadata) + { + _documents[sourceName] = content; + _metadata[sourceName] = metadata; } /// @@ -90,14 +223,4 @@ public IReadOnlyList GetLoadedSources() { return _documents.Keys.ToList().AsReadOnly(); } - - /// Loads all matching files from a directory. - public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.md") - { - var full = Path.GetFullPath(directoryPath); - if (!Directory.Exists(full)) - throw new DirectoryNotFoundException($"Directory not found: {full}"); - - await LoadManyAsync(Directory.GetFiles(full, pattern, SearchOption.AllDirectories)); - } } \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index da9b175..1022059 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -270,27 +270,36 @@ public string ToLogsMarkdown() /// Saves the debate result (rounds and verdict) to a Markdown file. /// /// Path for the result Markdown file. - public Task SaveToMarkdownAsync(string filePath) + /// Cancellation token; checked at entry and forwarded to the file write. + /// The token has been canceled. + public Task SaveToMarkdownAsync(string filePath, CancellationToken ct = default) { - return WriteAllTextAsync(filePath, ToMarkdown()); + ct.ThrowIfCancellationRequested(); + return WriteAllTextAsync(filePath, ToMarkdown(), ct); } /// /// Saves token statistics and compression logs to a Markdown file. /// /// Path for the statistics Markdown file. - public Task SaveStatisticsAsync(string filePath) + /// Cancellation token; checked at entry and forwarded to the file write. + /// The token has been canceled. + public Task SaveStatisticsAsync(string filePath, CancellationToken ct = default) { - return WriteAllTextAsync(filePath, ToStatisticsMarkdown()); + ct.ThrowIfCancellationRequested(); + return WriteAllTextAsync(filePath, ToStatisticsMarkdown(), ct); } /// /// Saves execution logs to a Markdown file. /// /// Path for the logs Markdown file. - public Task SaveLogsAsync(string filePath) + /// Cancellation token; checked at entry and forwarded to the file write. + /// The token has been canceled. + public Task SaveLogsAsync(string filePath, CancellationToken ct = default) { - return WriteAllTextAsync(filePath, ToLogsMarkdown()); + ct.ThrowIfCancellationRequested(); + return WriteAllTextAsync(filePath, ToLogsMarkdown(), ct); } /// @@ -298,11 +307,15 @@ public Task SaveLogsAsync(string filePath) /// /// Directory where files will be created. /// Optional prefix for file names (default: "debate"). + /// Cancellation token; checked before each file is written. /// Paths of the three created files. + /// The token has been canceled. public async Task<(string ResultPath, string StatisticsPath, string LogsPath)> SaveAllAsync( string outputDirectory, - string? filePrefix = null) + string? filePrefix = null, + CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); if (!Directory.Exists(outputDirectory)) Directory.CreateDirectory(outputDirectory); @@ -311,10 +324,12 @@ public Task SaveLogsAsync(string filePath) var statsPath = Path.Combine(outputDirectory, $"{prefix}_statistics.md"); var logsPath = Path.Combine(outputDirectory, $"{prefix}_logs.md"); - await Task.WhenAll( - SaveToMarkdownAsync(resultPath), - SaveStatisticsAsync(statsPath), - SaveLogsAsync(logsPath)); + ct.ThrowIfCancellationRequested(); + await SaveToMarkdownAsync(resultPath, ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + await SaveStatisticsAsync(statsPath, ct).ConfigureAwait(false); + ct.ThrowIfCancellationRequested(); + await SaveLogsAsync(logsPath, ct).ConfigureAwait(false); return (resultPath, statsPath, logsPath); } @@ -323,8 +338,11 @@ await Task.WhenAll( /// Saves the debate result to a Markdown file (backward-compatible). /// /// Path for the output file. - public async Task SaveToFileAsync(string filePath) + /// Cancellation token; checked at entry and forwarded to the file write. + /// The token has been canceled. + public async Task SaveToFileAsync(string filePath, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); // Backward compatible: saves the full content (result + statistics + logs) in a single file var sb = new StringBuilder(); sb.Append(ToMarkdown()); @@ -351,16 +369,17 @@ public async Task SaveToFileAsync(string filePath) } } - await WriteAllTextAsync(filePath, sb.ToString()); + await WriteAllTextAsync(filePath, sb.ToString(), ct).ConfigureAwait(false); } - private static async Task WriteAllTextAsync(string filePath, string content) + private static async Task WriteAllTextAsync(string filePath, string content, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); var directory = Path.GetDirectoryName(filePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) Directory.CreateDirectory(directory); - await File.WriteAllTextAsync(filePath, content); + await File.WriteAllTextAsync(filePath, content, ct).ConfigureAwait(false); } } diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index bbf9581..9830b1a 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -42,6 +42,7 @@ outcomes** rather than single-model guesses. - ⚡ **Parallel Operator Requests** — `[[OPERATOR: …]]` tasks delegated within a round run in parallel, bounded by `MaxDegreeOfParallelism` - 📁 **Separate File Output** — export `result.md`, `statistics.md`, `logs.md` independently - 🤝 **Microsoft.Extensions.AI** — first-class `IChatClient` / `IEmbeddingGenerator` interop with logging & function-invocation middleware +- 🛑 **Cooperative Cancellation** — every public async method accepts a `CancellationToken`; a host shutdown or user-initiated cancel aborts the debate mid-flight (rounds, LLM calls, MCP tools, RAG queries, file saves) - 🔌 **Interface-First** — clean abstractions for providers, factories, builders and executors - 🧱 **Modern C# 15** — file-scoped namespaces, records, init-only properties, global usings @@ -596,9 +597,93 @@ in `Providers:ApiKey`. | `ModelContextProtocol` | MCP client for the Operator role | | `Microsoft.Extensions.*` | Configuration, DI and Options | +## 🛑 Cancellation Support + +Every public async method in Delibera honors a `CancellationToken` cooperatively. The token +flows from your entry point all the way down through debate rounds, chairman synthesis, +LLM calls, MCP tool invocations, RAG queries and even file writes — a single cancel signal +aborts the entire pipeline via `OperationCanceledException` (or `TaskCanceledException`). + +### 1. Basic cancellation with a timeout + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); +var result = await executor.ExecuteAsync(cts.Token); +``` + +### 2. Manual cancellation + +```csharp +using var cts = new CancellationTokenSource(); + +cts.Cancel(); // from anywhere — UI button, signal handler, other component +var result = await executor.ExecuteAsync(cts.Token); +``` + +### 3. ASP.NET Core / Worker Services — link to host shutdown + +Implement the lightweight `IAppStoppingToken` adapter for the host's lifetime +(or use the extension directly with your own): + +```csharp +using Delibera.Core.Extensions; + +public sealed class HostLifetimeAdapter(Microsoft.Extensions.Hosting.IHostApplicationLifetime lt) + : IAppStoppingToken +{ + public CancellationToken ApplicationStopping => lt.ApplicationStopping; +} + +// In your hosted service / minimal API: +public sealed class DebateRunner( + ICouncilExecutor executor, + IHostApplicationLifetime lifetime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var adapter = new HostLifetimeAdapter(lifetime); + var result = await executor.ExecuteAsync(adapter, stoppingToken); + // ... + } +} +``` + +`executor.ExecuteAsync(adapter, ct)` internally creates a linked `CancellationTokenSource` +from `ct` + `adapter.ApplicationStopping`, so either a host shutdown *or* a caller cancel +will stop the debate. The linked source is disposed in a `finally` block. + +### 4. Console apps — wire Ctrl+C + +```csharp +using var cts = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; // prevent the process from terminating + cts.Cancel(); // signal the debate +}; + +var result = await executor.ExecuteAsync(cts.Token); +``` + +### What gets cancelled? + +When a token is signaled, Delibera stops at the nearest cooperative checkpoint and throws +`OperationCanceledException`. Cancellable operations include: + +| Component | CT-aware method | +|---|---| +| Top-level entry | `ICouncilExecutor.ExecuteAsync(ct)` | +| Knowledge loading | `MarkdownKnowledgeBase.LoadAsync / LoadManyAsync / LoadDirectoryAsync / LoadTextAsync / LoadTextsAsync` | +| LLM providers | `ChatAsync / ChatStreamAsync / IsAvailableAsync / ListModelsAsync / GetModelCapabilitiesAsync` | +| RAG | `IRagProvider.IndexDocument / Search / GetContext`, `IVectorStore.Upsert / Search` | +| Operator (MCP) | `IOperator.InitializeAsync / ExecuteTaskAsync`, `IMcpClient.Connect / ListTools / CallTool` | +| Compression | `IContextCompressor.CompressAsync / CompressBatchAsync` | +| Debate strategies | `IDebateStrategy.ExecuteAsync(ct)` (all three strategies) | +| Output | `DebateResult.SaveToFileAsync / SaveToMarkdownAsync / SaveStatisticsAsync / SaveLogsAsync / SaveAllAsync` | + --- -## 📚 Learn More + - 📖 Full README (architecture, design patterns, console app examples) → [github.com/techbuzzz/Delibera](https://github.com/techbuzzz/Delibera) - 📄 Step-by-step walkthrough → [docs/QuickStart.md](https://github.com/techbuzzz/Delibera/blob/develop/docs/QuickStart.md) diff --git a/tests/Delibera.Core.Tests/CouncilExecutorCancellationTests.cs b/tests/Delibera.Core.Tests/CouncilExecutorCancellationTests.cs new file mode 100644 index 0000000..ab0fee2 --- /dev/null +++ b/tests/Delibera.Core.Tests/CouncilExecutorCancellationTests.cs @@ -0,0 +1,82 @@ +using Delibera.Core.Council; +using Delibera.Core.Debate; +using Delibera.Core.Interfaces; +using Delibera.Core.Tests.Fakes; +using FluentAssertions; + +namespace Delibera.Core.Tests; + +public class CouncilExecutorCancellationTests +{ + private static ICouncilExecutor BuildMinimalExecutor( + FakeLLMProvider provider, + int maxRounds = 1) + { + // CouncilBuilder accepts a string for the user prompt, etc. + // We use 1 round, 1 member, no chairman (Chairman is optional in v3.1). + // The caller-supplied FakeLLMProvider is what the executor will actually use, + // so call-count assertions on the provider reflect real LLM activity. + return new CouncilBuilder() + .AddMember("fake-model", provider, "Analyst") + .WithStandardDebate() + .WithSystemPrompt("sys") + .WithUserPrompt("user-question") + .WithMaxRounds(maxRounds) + .WithTemperature(0.3f) + .Build(); + } + + [Fact] + public async Task ExecuteAsync_NoCancel_Runs_And_Produces_Result() + { + var fake = new FakeLLMProvider(reply: "ok"); + var executor = BuildMinimalExecutor(fake); + + var result = await executor.ExecuteAsync(); + + result.Should().NotBeNull(); + result.Rounds.Should().NotBeEmpty(); + // Sanity: at least one member produced a response in at least one round. + result.Rounds.Should().Contain(r => r.Responses.Count > 0); + } + + [Fact] + public async Task ExecuteAsync_WithOutputPath_ForwardsCancellationToFinalSave() + { + // Verifies Phase 1.4 fix: CouncilExecutor.ExecuteAsync now forwards the + // caller's CT to result.SaveToFileAsync. We use a pre-cancelled token — + // since the debate path itself is OCE-resilient (CollectResponsesAsync + // catches LLM errors), the debate can complete; the SaveToFileAsync call + // will then observe the pre-cancelled token and throw. + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_exec_save_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var outputPath = Path.Combine(tempDir, "result.md"); + var provider = new FakeLLMProvider(reply: "ok"); + var executor = new CouncilBuilder() + .AddMember("fake-model", provider, "Analyst") + .WithStandardDebate() + .WithSystemPrompt("sys") + .WithUserPrompt("user-question") + .WithMaxRounds(1) + .WithTemperature(0.3f) + .SaveResultTo(outputPath) + .Build(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // The debate will run quickly (LLM is fast). When it reaches the + // SaveToFileAsync call, the pre-cancelled token will be observed and + // an OperationCanceledException will be thrown. + var act = () => executor.ExecuteAsync(cts.Token); + await act.Should().ThrowAsync(); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } +} + diff --git a/tests/Delibera.Core.Tests/CouncilExecutorLifetimeExtensionsTests.cs b/tests/Delibera.Core.Tests/CouncilExecutorLifetimeExtensionsTests.cs new file mode 100644 index 0000000..9b27350 --- /dev/null +++ b/tests/Delibera.Core.Tests/CouncilExecutorLifetimeExtensionsTests.cs @@ -0,0 +1,139 @@ +using Delibera.Core.Council; +using Delibera.Core.Debate; +using Delibera.Core.Extensions; +using Delibera.Core.Interfaces; +using Delibera.Core.Tests.Fakes; +using FluentAssertions; + +namespace Delibera.Core.Tests; + +public class CouncilExecutorLifetimeExtensionsTests +{ + private sealed class TestLifetime(CancellationToken token) : IAppStoppingToken + { + public CancellationToken ApplicationStopping => token; + } + + private static ICouncilExecutor BuildExecutor(FakeLLMProvider provider) + { + // Caller-supplied FakeLLMProvider is used directly so call-count assertions + // on the provider reflect real LLM activity. + return new CouncilBuilder() + .AddMember("fake-model", provider, "Analyst") + .WithStandardDebate() + .WithSystemPrompt("sys") + .WithUserPrompt("user-question") + .WithMaxRounds(1) + .WithTemperature(0.3f) + .Build(); + } + + [Fact] + public async Task ExecuteAsync_WithLifetime_RespectsCallerToken_OnSavePath() + { + // Same caveat as the executor test: the debate rounds absorb LLM OCE. + // We exercise the post-debate SaveToFileAsync path by configuring an + // output path and pre-cancelling the token — the SaveToFileAsync call + // will observe the pre-cancelled token and throw. + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_lifetime_save_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var outputPath = Path.Combine(tempDir, "result.md"); + var fake = new FakeLLMProvider(reply: "ok"); + var executor = new CouncilBuilder() + .AddMember("fake-model", fake, "Analyst") + .WithStandardDebate() + .WithSystemPrompt("sys") + .WithUserPrompt("user-question") + .WithMaxRounds(1) + .WithTemperature(0.3f) + .SaveResultTo(outputPath) + .Build(); + + using var lifetimeCts = new CancellationTokenSource(); + var lifetime = new TestLifetime(lifetimeCts.Token); + + using var callerCts = new CancellationTokenSource(); + callerCts.Cancel(); + + var act = () => executor.ExecuteAsync(lifetime, callerCts.Token); + await act.Should().ThrowAsync(); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task ExecuteAsync_WithLifetime_RespectsApplicationStopping_OnSavePath() + { + // Same caveat as before: debate rounds absorb LLM OCE. Exercise the + // post-debate SaveToFileAsync path by configuring an output path and + // pre-cancelling the lifetime token — the SaveToFileAsync call will + // observe the pre-cancelled token via the linked CTS and throw. + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_lifetime_appstop_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var outputPath = Path.Combine(tempDir, "result.md"); + var fake = new FakeLLMProvider(reply: "ok"); + var executor = new CouncilBuilder() + .AddMember("fake-model", fake, "Analyst") + .WithStandardDebate() + .WithSystemPrompt("sys") + .WithUserPrompt("user-question") + .WithMaxRounds(1) + .WithTemperature(0.3f) + .SaveResultTo(outputPath) + .Build(); + + using var lifetimeCts = new CancellationTokenSource(); + lifetimeCts.Cancel(); + var lifetime = new TestLifetime(lifetimeCts.Token); + + var act = () => executor.ExecuteAsync(lifetime); + await act.Should().ThrowAsync(); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task ExecuteAsync_WithLifetime_RunsToCompletion_When_NeitherCanceled() + { + var fake = new FakeLLMProvider(reply: "ok"); + var executor = BuildExecutor(fake); + using var lifetimeCts = new CancellationTokenSource(); + var lifetime = new TestLifetime(lifetimeCts.Token); + + var result = await executor.ExecuteAsync(lifetime); + + result.Should().NotBeNull(); + result.Rounds.Should().NotBeEmpty(); + } + + [Fact] + public async Task ExecuteAsync_WithLifetime_NullExecutor_Throws() + { + ICouncilExecutor? executor = null; + using var lifetimeCts = new CancellationTokenSource(); + var lifetime = new TestLifetime(lifetimeCts.Token); + + var act = () => executor!.ExecuteAsync(lifetime); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ExecuteAsync_WithLifetime_NullLifetime_Throws() + { + var fake = new FakeLLMProvider(reply: "ok"); + var executor = BuildExecutor(fake); + + var act = () => executor.ExecuteAsync(lifetime: null!); + await act.Should().ThrowAsync(); + } +} diff --git a/tests/Delibera.Core.Tests/DebateResultCancellationTests.cs b/tests/Delibera.Core.Tests/DebateResultCancellationTests.cs new file mode 100644 index 0000000..7384dc3 --- /dev/null +++ b/tests/Delibera.Core.Tests/DebateResultCancellationTests.cs @@ -0,0 +1,100 @@ +using Delibera.Core.Models; +using FluentAssertions; + +namespace Delibera.Core.Tests; + +public class DebateResultCancellationTests +{ + private static DebateResult SampleResult() => new() + { + StrategyName = "Test", + Context = new PromptContext { SystemPrompt = "sys", UserPrompt = "user" }, + Participants = ["a", "b"], + Rounds = + [ + new DebateRound + { + RoundNumber = 1, + RoundName = "r1", + Responses = new Dictionary { ["a"] = "r-a", ["b"] = "r-b" } + } + ], + FinalVerdict = "verdict", + CompletedAt = DateTime.UtcNow + }; + + [Fact] + public async Task SaveToMarkdownAsync_Honors_Pre_Canceled_Token() + { + var result = SampleResult(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => result.SaveToMarkdownAsync("ignored.md", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveStatisticsAsync_Honors_Pre_Canceled_Token() + { + var result = SampleResult(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => result.SaveStatisticsAsync("ignored.md", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveLogsAsync_Honors_Pre_Canceled_Token() + { + var result = SampleResult(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => result.SaveLogsAsync("ignored.md", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveToFileAsync_Honors_Pre_Canceled_Token() + { + var result = SampleResult(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => result.SaveToFileAsync("ignored.md", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveAllAsync_Honors_Pre_Canceled_Token() + { + var result = SampleResult(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => result.SaveAllAsync("./ignored_dir", ct: cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SaveAllAsync_Creates_Files_When_Not_Canceled() + { + var result = SampleResult(); + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_save_" + Guid.NewGuid().ToString("N")); + + try + { + var (r, s, l) = await result.SaveAllAsync(tempDir, "ct"); + + File.Exists(r).Should().BeTrue(); + File.Exists(s).Should().BeTrue(); + File.Exists(l).Should().BeTrue(); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/tests/Delibera.Core.Tests/Fakes/FakeLLMProvider.cs b/tests/Delibera.Core.Tests/Fakes/FakeLLMProvider.cs new file mode 100644 index 0000000..6707464 --- /dev/null +++ b/tests/Delibera.Core.Tests/Fakes/FakeLLMProvider.cs @@ -0,0 +1,39 @@ +using Delibera.Core.Interfaces; + +namespace Delibera.Core.Tests.Fakes; + +/// +/// In-memory used for tests that need to drive a +/// council end-to-end without network access. Optionally delays each call so +/// cancellation can be exercised in-flight. +/// +public sealed class FakeLLMProvider( + string providerName = "Fake", + string reply = "fake-reply", + int chatDelayMs = 0) : ILLMProvider +{ + public int ChatCallCount { get; private set; } + + public string ProviderName => providerName; + + public Task IsAvailableAsync(CancellationToken ct = default) + => Task.FromResult(true); + + public Task> ListModelsAsync(CancellationToken ct = default) + => Task.FromResult>(new[] { "fake-model" }); + + public async Task ChatAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + CancellationToken ct = default) + { + ChatCallCount++; + if (chatDelayMs > 0) + await Task.Delay(chatDelayMs, ct); + return reply; + } + + public void Dispose() { /* no-op */ } +} diff --git a/tests/Delibera.Core.Tests/MarkdownKnowledgeBaseTests.cs b/tests/Delibera.Core.Tests/MarkdownKnowledgeBaseTests.cs new file mode 100644 index 0000000..5b7f3cb --- /dev/null +++ b/tests/Delibera.Core.Tests/MarkdownKnowledgeBaseTests.cs @@ -0,0 +1,300 @@ +using Delibera.Core.Knowledge; +using FluentAssertions; + +namespace Delibera.Core.Tests; + +public class MarkdownKnowledgeBaseTests +{ + // ── Case 1: LoadTextAsync(string, string) + Search ──────────────────────── + [Fact] + public async Task LoadTextAsync_String_Then_Search_Returns_Source() + { + var kb = new MarkdownKnowledgeBase(); + await kb.LoadTextAsync("hello world from the council", "src"); + + var results = kb.Search("hello", 1); + results.Should().NotBeEmpty(); + results[0].Should().Contain("[src]"); + results[0].Should().Contain("hello"); + } + + // ── Case 2: LoadTextAsync(KnowledgeDocument) preserves metadata ────────── + [Fact] + public async Task LoadTextAsync_Document_Stores_Metadata() + { + var kb = new MarkdownKnowledgeBase(); + var doc = new KnowledgeDocument( + "src", + "hello world from the contract", + new Dictionary { ["kind"] = "contract" }); + + await kb.LoadTextAsync(doc); + + kb.DocumentMetadata.Should().ContainKey("src"); + kb.DocumentMetadata["src"]!["kind"].Should().Be("contract"); + + var results = kb.Search("hello", 1); + results.Should().NotBeEmpty(); + results[0].Should().Contain("[src]"); + } + + // ── Case 3: LoadTextsAsync bulk ingest ─────────────────────────────────── + [Fact] + public async Task LoadTextsAsync_Indexes_All_Documents() + { + var kb = new MarkdownKnowledgeBase(); + var docs = new[] + { + new KnowledgeDocument("doc1", "alpha bravo charlie"), + new KnowledgeDocument("doc2", "delta echo foxtrot"), + new KnowledgeDocument("doc3", "golf hotel india"), + }; + + await kb.LoadTextsAsync(docs); + + kb.DocumentCount.Should().Be(3); + kb.GetLoadedSources().Should().BeEquivalentTo(new[] { "doc1", "doc2", "doc3" }); + } + + // ── Case 4: file-path LoadAsync regression ─────────────────────────────── + [Fact] + public async Task LoadAsync_From_File_Still_Works() + { + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_kb_tests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var path = Path.Combine(tempDir, "regression.md"); + await File.WriteAllTextAsync(path, "# Title\n\nhello markdown body\n\nsecond paragraph here"); + + try + { + var kb = new MarkdownKnowledgeBase(); + await kb.LoadAsync(path); + + kb.DocumentCount.Should().Be(1); + kb.GetLoadedSources()[0].Should().Be("regression.md"); + + var results = kb.Search("hello", 5); + results.Should().NotBeEmpty(); + results[0].Should().Contain("[regression.md]"); + results[0].Should().Contain("hello markdown body"); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + // ── Case 5a: argument validation on string overload ───────────────────── + [Theory] + [InlineData("", "src")] + [InlineData(" ", "src")] + public async Task LoadTextAsync_String_Empty_Content_Throws_ArgumentException(string content, string sourceName) + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextAsync(content, sourceName); + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData("hello", "")] + [InlineData("hello", " ")] + public async Task LoadTextAsync_String_Empty_SourceName_Throws_ArgumentException(string content, string sourceName) + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextAsync(content, sourceName); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_String_Null_Content_Throws_ArgumentNullException() + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextAsync(null!, "src"); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_String_Null_SourceName_Throws_ArgumentNullException() + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextAsync("hello", null!); + await act.Should().ThrowAsync(); + } + + // ── Case 5b: argument validation on document overload ─────────────────── + [Fact] + public async Task LoadTextAsync_Document_Null_Throws_ArgumentNullException() + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_Document_Empty_Name_Throws_ArgumentException() + { + var kb = new MarkdownKnowledgeBase(); + var doc = new KnowledgeDocument("", "hello"); + var act = () => kb.LoadTextAsync(doc); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_Document_Empty_Content_Throws_ArgumentException() + { + var kb = new MarkdownKnowledgeBase(); + var doc = new KnowledgeDocument("src", " "); + var act = () => kb.LoadTextAsync(doc); + await act.Should().ThrowAsync(); + } + + // ── Case 5c: argument validation on LoadTextsAsync ────────────────────── + [Fact] + public async Task LoadTextsAsync_Null_Throws_ArgumentNullException() + { + var kb = new MarkdownKnowledgeBase(); + var act = () => kb.LoadTextsAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextsAsync_Empty_Collection_Is_NoOp() + { + var kb = new MarkdownKnowledgeBase(); + await kb.LoadTextsAsync([]); + kb.DocumentCount.Should().Be(0); + } + + // ── Case 6: CancellationToken is honored ───────────────────────────────── + [Fact] + public async Task LoadTextsAsync_Honors_Cancellation_Between_Documents() + { + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + var docs = new List + { + new("doc1", "alpha"), + new("doc2", "beta"), + new("doc3", "gamma"), + }; + + // Cancel before the second document lands. + int loaded = 0; + var enumerable = docs.Select(d => + { + if (loaded == 1) cts.Cancel(); + loaded++; + return d; + }); + + var act = () => kb.LoadTextsAsync(enumerable, cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_String_Honors_Pre_Canceled_Token() + { + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => kb.LoadTextAsync("hello", "src", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadTextAsync_Document_Honors_Pre_Canceled_Token() + { + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var doc = new KnowledgeDocument("src", "hello"); + + var act = () => kb.LoadTextAsync(doc, cts.Token); + await act.Should().ThrowAsync(); + } + + // ── Case 6b: file-path API honors CT ───────────────────────────────────── + [Fact] + public async Task LoadAsync_File_Honors_Pre_Canceled_Token() + { + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => kb.LoadAsync("any-file.md", cts.Token); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LoadManyAsync_Honors_Cancellation_Between_Files() + { + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_kb_cancel_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var paths = Enumerable.Range(0, 5) + .Select(i => Path.Combine(tempDir, $"f{i}.md")) + .ToArray(); + foreach (var p in paths) + await File.WriteAllTextAsync(p, $"# Doc {Path.GetFileName(p)}"); + + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + + // Cancel after the second file lands. + int loaded = 0; + var enumerable = paths.Select(p => + { + if (loaded == 1) cts.Cancel(); + loaded++; + return p; + }); + + var act = () => kb.LoadManyAsync(enumerable, cts.Token); + await act.Should().ThrowAsync(); + kb.DocumentCount.Should().BeLessThan(5); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task LoadDirectoryAsync_Forwards_Cancellation() + { + var tempDir = Path.Combine(Path.GetTempPath(), "delibera_kb_dir_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + await File.WriteAllTextAsync(Path.Combine(tempDir, "a.md"), "alpha"); + await File.WriteAllTextAsync(Path.Combine(tempDir, "b.md"), "beta"); + + var kb = new MarkdownKnowledgeBase(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => kb.LoadDirectoryAsync(tempDir, "*.md", cts.Token); + await act.Should().ThrowAsync(); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + // ── Case 7: idempotency — re-loading same source name overwrites cleanly ─ + [Fact] + public async Task LoadTextAsync_Same_Source_Name_Overwrites_Previous_Content() + { + var kb = new MarkdownKnowledgeBase(); + await kb.LoadTextAsync("first content", "src"); + await kb.LoadTextAsync("second content", "src"); + + kb.DocumentCount.Should().Be(1); + var results = kb.Search("second", 5); + results.Should().NotBeEmpty(); + results[0].Should().Contain("second content"); + } +} \ No newline at end of file From 567b9510c8c18d4eaae189767b31864a7c2a7166 Mon Sep 17 00:00:00 2001 From: Victor Buzin Date: Tue, 30 Jun 2026 17:42:28 +0200 Subject: [PATCH 2/2] Full cooperative CancellationToken support, v10.2.4 Introduce end-to-end CancellationToken support across all public async methods, enabling a single cancel signal to abort the entire pipeline. Add in-memory MarkdownKnowledgeBase loaders with per-source metadata via a new KnowledgeDocument record and DocumentMetadata property. Update IKnowledgeBase and file/save APIs to accept CancellationToken (defaulted for binary compatibility). Add IAppStoppingToken abstraction and CouncilExecutorLifetimeExtensions.ExecuteAsync for host-based cancellation. Enhance ConsoleApp with --cancellation demo and Ctrl+C support. Add 20+ cancellation-focused tests and update documentation and release notes. No breaking binary changes for callers. --- CHANGELOG.md | 145 ++++++++++++------ .../Examples/ResilienceExample.cs | 4 +- src/Delibera.Core/Delibera.Core.csproj | 4 +- 3 files changed, 106 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688cf62..7f71d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,73 +7,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [10.2.4] - 2026 -### Added — Full cooperative CancellationToken support +This release delivers **full cooperative `CancellationToken` support across every +public async method** — a single cancel signal now aborts the entire pipeline +(rounds, Chairman synthesis, LLM calls, MCP tool invocations, RAG queries and +file writes) via `OperationCanceledException`. It also adds **in-memory +`MarkdownKnowledgeBase` loaders** that ingest markdown bodies without temp +files, with optional per-source metadata. -Every public async method in the library now honors a `CancellationToken` cooperatively. -A single cancel signal aborts the entire pipeline — rounds, chairman synthesis, LLM -calls, MCP tool invocations, RAG queries and even file writes — via -`OperationCanceledException`. +### Added — In-memory `MarkdownKnowledgeBase` loaders -### Changed — Additive CT parameters (no breaking binary changes) +- **`KnowledgeDocument`** — new sealed record in `Delibera.Core.Knowledge` + (`string Name`, `string Content`, `IReadOnlyDictionary? Metadata`) + used to tag documents with per-source metadata so the council can distinguish + "contract" from "discovery context" inside the KB. + +- **`MarkdownKnowledgeBase.LoadTextAsync(string content, string sourceName, CancellationToken)`** + — ingest a markdown body without writing a temp file. The `sourceName` + appears in the council's per-round context, just like a file path would. + +- **`MarkdownKnowledgeBase.LoadTextAsync(KnowledgeDocument, CancellationToken)`** + — overload that preserves the supplied `Metadata` on the indexed document. + +- **`MarkdownKnowledgeBase.LoadTextsAsync(IEnumerable, CancellationToken)`** + — sequential bulk ingest with cooperative cancellation checked between + documents. + +- **`MarkdownKnowledgeBase.DocumentMetadata`** — read-only snapshot + (`IReadOnlyDictionary?>`) of the + per-source metadata captured at load time. Sources loaded through the + file-path API appear as `null` entries. + +### Added — Full cooperative `CancellationToken` support + +Every public async method in the library now honors a `CancellationToken` +cooperatively. A single cancel signal aborts the entire pipeline — rounds, +Chairman synthesis, LLM calls, MCP tool invocations, RAG queries and even +file writes — via `OperationCanceledException`. + +#### Changed — Additive CT parameters (no breaking binary changes) - **`IKnowledgeBase.LoadAsync(string, CancellationToken)`** and **`IKnowledgeBase.LoadManyAsync(IEnumerable, CancellationToken)`** — - `CancellationToken ct = default` added to existing method signatures. Source-breaking - for any external implementer of the interface; binary-compatible for callers. + `CancellationToken ct = default` added to existing method signatures. + **Source-breaking** for any external implementer of the interface; + **binary-compatible** for callers (parameter has a default value). - **`MarkdownKnowledgeBase`** — the file-path API - (`LoadAsync`, `LoadManyAsync`, `LoadDirectoryAsync`) now accepts a `CancellationToken` - and forwards it to `File.ReadAllTextAsync` and between files in bulk operations. + (`LoadAsync`, `LoadManyAsync`, `LoadDirectoryAsync`) now accepts a + `CancellationToken` and forwards it to `File.ReadAllTextAsync` and between + files in bulk operations. - **`DebateResult`** — all save methods (`SaveToMarkdownAsync`, `SaveStatisticsAsync`, `SaveLogsAsync`, `SaveAllAsync`, - `SaveToFileAsync`) accept a `CancellationToken` and forward it to `File.WriteAllTextAsync`. + `SaveToFileAsync`) accept a `CancellationToken` and forward it to + `File.WriteAllTextAsync`. `SaveAllAsync` propagates the token to each + individual save call. + +- **`CouncilExecutor.ExecuteAsync`** — the caller's `CancellationToken` is now + forwarded to the final `result.SaveToFileAsync(_outputPath, ct)` step, + closing the top-level cancellation chain end-to-end. -- **`CouncilExecutor.ExecuteAsync`** — the caller's `CancellationToken` is now forwarded - to the final `result.SaveToFileAsync(_outputPath, ct)` step, closing the top-level - cancellation chain. +#### Added — Hosted-service helper -### Added — Hosted-service helper +- **`Delibera.Core.Extensions.IAppStoppingToken`** — minimal abstraction + (`CancellationToken ApplicationStopping { get; }`) that any host lifetime + can implement in three lines without forcing a `Microsoft.Extensions.Hosting` + dependency on `Delibera.Core`. - **`Delibera.Core.Extensions.CouncilExecutorLifetimeExtensions.ExecuteAsync( - ICouncilExecutor, IAppStoppingToken, CancellationToken)`** — links the caller's - `CancellationToken` with an `IAppStoppingToken.ApplicationStopping` token via - `CancellationTokenSource.CreateLinkedTokenSource` so a host shutdown (ASP.NET Core, - Worker Service, etc.) cancels the debate cooperatively. + ICouncilExecutor, IAppStoppingToken, CancellationToken)`** — links the + caller's `CancellationToken` with `IAppStoppingToken.ApplicationStopping` + via `CancellationTokenSource.CreateLinkedTokenSource` so a host shutdown + (ASP.NET Core, Worker Service, etc.) cancels the debate cooperatively. + Whichever signal fires first wins. -- **`IAppStoppingToken`** — minimal `Delibera.Core.Extensions` interface - (`CancellationToken ApplicationStopping { get; }`) that any host lifetime can - implement in 3 lines without forcing a `Microsoft.Extensions.Hosting` dependency - on `Delibera.Core`. +#### Added — ConsoleApp demo -### Added — ConsoleApp demo +- **`Delibera.ConsoleApp.Examples.CancellationExample`** — run with + `--cancellation`. Wires `Console.CancelKeyPress` to a + `CancellationTokenSource` and forwards the token through + `executor.ExecuteAsync(cts.Token)`. Includes a heartbeat task that proves + the main thread is alive while the debate is awaiting. -- **`Delibera.ConsoleApp.Examples.CancellationExample`** — run with `--cancellation`. - Wires `Console.CancelKeyPress` to a `CancellationTokenSource` and forwards the - token through `executor.ExecuteAsync(cts.Token)`. Includes a heartbeat task that - proves the main thread is alive while the debate is awaiting. The default - `Program.Main` now also wires Ctrl+C for the main demo path so a user can cancel - any run with a single keystroke. +- The default `Program.Main` now also wires Ctrl+C for the main demo path + so a user can cancel any run with a single keystroke. Example-mode + dispatchers (e.g. `--cancellation`) keep ownership of their own + `CancelKeyPress` handlers and are not double-bound. ### Tests -- 6 new tests in `MarkdownKnowledgeBaseTests` covering CT honoring on the file-path - API and bulk operations. -- 7 new tests in `DebateResultCancellationTests` covering all save methods with - pre-canceled tokens + a happy-path test for `SaveAllAsync`. -- 2 new tests in `CouncilExecutorCancellationTests` covering the executor's - CT-aware save path and a positive no-cancel run. -- 5 new tests in `CouncilExecutorLifetimeExtensionsTests` covering the - `IAppStoppingToken` helper, including null-argument validation. +- **9 new tests** in `MarkdownKnowledgeBaseTests` for the in-memory loaders: + string overload happy-path, `KnowledgeDocument` overload happy-path with + metadata assertion, `LoadTextsAsync` bulk happy-path, source-name + overwrite semantics, and **6 cancellation tests** covering pre-canceled + tokens on every public method plus `LoadManyAsync` / `LoadDirectoryAsync` + honoring cancellation between files. +- **7 new tests** in `DebateResultCancellationTests` covering all save + methods with pre-canceled tokens + a happy-path test for `SaveAllAsync` + that verifies the three files are produced. +- **2 new tests** in `CouncilExecutorCancellationTests` covering the + executor's CT-aware save path and a positive no-cancel run. +- **5 new tests** in `CouncilExecutorLifetimeExtensionsTests` covering the + `IAppStoppingToken` helper: caller-token respected, application-stopping + token respected, run-to-completion when neither is canceled, and + null-argument validation on both `executor` and `lifetime`. ### Documentation -- New **Cancellation Support** section in `Delibera.Core/README.md` with 4 worked - examples (timeout, manual cancel, ASP.NET Core `IHostApplicationLifetime` adapter, - Console Ctrl+C) and a table of cancellable operations. -- New **Cancellation Support** section in the repo-root `README.md` with a quick-start - example and a link to the full guide. +- New **Cancellation Support** section in `Delibera.Core/README.md` with 4 + worked examples (timeout, manual cancel, ASP.NET Core + `IHostApplicationLifetime` adapter, Console Ctrl+C) and a table of + cancellable operations. +- New **Cancellation Support** section in the repo-root `README.md` with a + quick-start example and a link to the full guide. - Feature row added to the Key Features table in both READMEs. +- `MarkdownKnowledgeBase.LoadTextAsync` and `LoadTextsAsync` added to the + cancellable-operations table in `Delibera.Core/README.md`. + +### Compatibility + +- **No breaking binary changes.** All new `CancellationToken` parameters + have a default value, so existing compiled callers continue to link and + run unchanged. External implementers of `IKnowledgeBase` must add the + `CancellationToken` parameter to their method signatures — this is a + source-level break for that scenario only. +- **No behavior change** for callers that don't pass a token: the + pre-v10.2.4 behavior is preserved exactly. ## [10.2.3] - 2026 diff --git a/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs index 87870cd..8b81341 100644 --- a/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs +++ b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs @@ -10,7 +10,7 @@ namespace Delibera.ConsoleApp.Examples; /// -/// Demonstrates Delibera v10.2.2 Polly v8 resilience integration: +/// Demonstrates Delibera v10.2.4 Polly v8 resilience integration: /// registers , named HttpClients /// (Ollama.Local, Ollama.Cloud, YandexGPT) with HttpRetryStrategyOptions attached via /// Microsoft.Extensions.Http.Resilience, and constructs an Ollama provider that uses @@ -24,7 +24,7 @@ public static class ResilienceExample public static async Task RunAsync() { Console.WriteLine("═══════════════════════════════════════════"); - Console.WriteLine(" 🛡️ Delibera — Polly v8 Resilience Example (v10.2.2)"); + Console.WriteLine(" 🛡️ Delibera — Polly v8 Resilience Example (v10.2.4)"); Console.WriteLine("═══════════════════════════════════════════\n"); var configuration = new ConfigurationBuilder() diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 4eaf614..96806b3 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -26,7 +26,7 @@ https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas. CouncilBuilder(CouncilOptions) constructor for one-shot setup. DI auto-wires CouncilOptions → CouncilBuilder. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. v10.2.4 — MarkdownKnowledgeBase in-memory loaders: new KnowledgeDocument record (Name, Content, Metadata) in Delibera.Core.Knowledge for tagging documents with per-source metadata. New MarkdownKnowledgeBase.LoadTextAsync(string content, string sourceName, CancellationToken) and LoadTextAsync(KnowledgeDocument, CancellationToken) ingest markdown bodies without temp files. New LoadTextsAsync(IEnumerable{KnowledgeDocument}, CancellationToken) for sequential bulk ingest with cooperative cancellation. New DocumentMetadata property exposes the per-source metadata snapshot. All new methods honor CancellationToken (checked at entry and between documents). Existing file-path API (LoadAsync, LoadManyAsync, LoadDirectoryAsync) is unchanged — pure additive, non-breaking. v10.2.4 enhancement — Full cooperative CancellationToken support: every public async method in the library now honors a CancellationToken. IKnowledgeBase.LoadAsync / LoadManyAsync, MarkdownKnowledgeBase file-path API (LoadAsync / LoadManyAsync / LoadDirectoryAsync), all DebateResult save methods (SaveToMarkdownAsync, SaveStatisticsAsync, SaveLogsAsync, SaveAllAsync, SaveToFileAsync), and the final SaveToFileAsync call inside CouncilExecutor.ExecuteAsync all accept and forward CancellationToken. New IAppStoppingToken abstraction in Delibera.Core.Extensions and CouncilExecutorLifetimeExtensions.ExecuteAsync(executor, lifetime, ct) helper for hosted-service shutdown. ConsoleApp: new --cancellation demo and default Ctrl+C handler in Main. Unit tests added: 20+ new cancellation tests across MarkdownKnowledgeBase, DebateResult, CouncilExecutor, and the lifetime helper. + v10.2.4 — Full cooperative CancellationToken support across every public async method: a single cancel signal now aborts the entire pipeline (rounds, Chairman synthesis, LLM calls, MCP tool invocations, RAG queries, file writes) via OperationCanceledException. Additive CancellationToken ct = default parameter on IKnowledgeBase.LoadAsync/LoadManyAsync (source-breaking only for external implementers; binary-compatible for callers), MarkdownKnowledgeBase file-path API (LoadAsync, LoadManyAsync, LoadDirectoryAsync), DebateResult save methods (SaveToMarkdownAsync, SaveStatisticsAsync, SaveLogsAsync, SaveAllAsync, SaveToFileAsync), and CouncilExecutor.ExecuteAsync which now forwards the caller's token to the final result.SaveToFileAsync step. New Delibera.Core.Extensions.IAppStoppingToken minimal abstraction (CancellationToken ApplicationStopping) and CouncilExecutorLifetimeExtensions.ExecuteAsync(ICouncilExecutor, IAppStoppingToken, CancellationToken) that links the caller's token with the host stop signal via CancellationTokenSource.CreateLinkedTokenSource so an ASP.NET Core / Worker Service shutdown cancels the debate cooperatively. New MarkdownKnowledgeBase in-memory loaders: KnowledgeDocument record (Name, Content, Metadata) for tagging documents with per-source metadata; LoadTextAsync(string, string, CancellationToken) and LoadTextAsync(KnowledgeDocument, CancellationToken) ingest markdown bodies without temp files; LoadTextsAsync(IEnumerable{KnowledgeDocument>, CancellationToken) for sequential bulk ingest with cooperative cancellation between documents; new DocumentMetadata property exposes the per-source metadata snapshot. New Delibera.ConsoleApp.Examples.CancellationExample (dotnet run -- --cancellation) wires Console.CancelKeyPress to a CancellationTokenSource and forwards the token through executor.ExecuteAsync(cts.Token); Program.Main also wires Ctrl+C for the main demo path. Tests: 9 new in MarkdownKnowledgeBaseTests (in-memory loader happy-paths + 6 cancellation tests), 7 new in DebateResultCancellationTests (all save methods + SaveAllAsync happy-path), 2 new in CouncilExecutorCancellationTests, 5 new in CouncilExecutorLifetimeExtensionsTests covering the IAppStoppingToken helper. New Cancellation Support section in both Delibera.Core/README.md and the repo-root README.md (4 worked examples, cancellable-operations table, ASP.NET Core IHostApplicationLifetime adapter). v10.2.3 — AutoChunking: automatic splitting of large knowledge documents into context-window-sized chunks distributed across debate rounds via progressive disclosure. New Delibera.Core.Chunking namespace with AutoChunker (SemanticBoundary / FixedSize / SlidingWindow strategies), AutoChunkingOrchestrator, and AutoChunkingOptions. ModelContextWindowRegistry with 40+ pre-registered models. ILLMProvider.GetModelCapabilitiesAsync() — OllamaProvider queries /api/show for num_ctx, others fall back to the registry. PromptContext extended with ChunkingPlan, AutoChunkingEnabled, MinContextWindow, and GetChunkedUserPrompt(). All debate strategies (Standard, Critique, Consensus) use BuildChunkedPrompt() for round-aware chunk distribution. CouncilBuilder gains WithOptions(CouncilOptions) / WithOptions(Action{CouncilOptions}) for bulk configuration from DI, appsettings, or inline lambdas; new CouncilBuilder(CouncilOptions) constructor and DI auto-wiring. New AutoChunkingConfig in CouncilOptions with appsettings binding. ConsoleApp example: dotnet run -- --autochunking. Fully backward compatible — AutoChunking is opt-in. true snupkg true @@ -58,4 +58,4 @@ - \ No newline at end of file +