diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..760aacd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +indent_style = space +indent_size = 3 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{cs,vb}] +# Delibera's public API uses domain acronyms such as "LLM" in their conventional +# all-caps form (ILLMProvider, ChatClientLLMProvider, AsLLMProvider, +# LLMProviderChatClient). The .NET naming analyzers (IDE1006) want these +# lowercased after the first letter (IllmProvider, ChatClientLlmProvider, ...), +# which is a breaking API change for the published NuGet package *and* reads +# worse. There is no built-in capitalisation mode that permits all-caps acronyms +# mixed with PascalCase, so we disable the naming rules that fire on these +# identifiers while keeping the rest of the naming convention enforcement. +dotnet_diagnostic.IDE1006.severity = none \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fedf229..b947293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,100 @@ 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.2] - 2026 + +### Added — Polly v8 resilience via Microsoft.Extensions.Http.Resilience + +- **Microsoft.Extensions.Http.Resilience 10.7.0** dependency (transitively brings Polly v8 + `Polly.Core` + `Microsoft.Extensions.Http`). Hand-rolled retry loops in `OllamaProvider`, + `YandexGptProvider`, and `McpClientAdapter` have been **removed** in favour of named + Polly v8 pipelines registered through DI. + +- **`ResilienceOptions`** (bound from `Delibera:Resilience` configuration section) + configures: `MaxRetryAttempts`, `BaseDelay`, `MaxDelay`, `UseJitter`, `BackoffType` + (`"Exponential"` / `"Linear"` / `"Constant"`), `RetryableStatusCodes`, `AttemptTimeout`, + master `Enabled` flag. All values are live-tracked through `IOptionsMonitor<>` so option + changes are honoured without rebuilding the container. + +- **`IDeliberaResiliencePipelineProvider`** — central registry of named Polly v8 + pipelines with three built-in keys: + - `Delibera.Local` — retries connection-level failures only (no status code); used by + `OllamaConnectionMode.Local`. + - `Delibera.Cloud` — retries transient HTTP responses (configurable allow-list, + default `[408, 429, 500, 502, 503, 504, 524]`) plus `HttpRequestException` / + `TaskCanceledException`; used by `OllamaConnectionMode.Cloud`, `YandexGptProvider`, + and `McpClientAdapter`'s HTTP transport. + - `Delibera.Default` — alias for the more permissive of the two; used when a consumer + does not specify a pipeline key. + +- **`AddDeliberaResilience(IServiceCollection, Action?)`** — + one-call DI setup. Registers `IDeliberaResiliencePipelineProvider`, plus three named + `HttpClient` entries (`Delibera.Ollama.Local`, `Delibera.Ollama.Cloud`, + `Delibera.YandexGPT`) each wired with `AddResilienceHandler` so retries apply to the + HttpClient handler chain (the standard `Microsoft.Extensions.Http.Resilience` pattern). + +- **`AddDeliberaResiliencePipeline(name, build)`** — register custom Polly v8 + pipelines under arbitrary keys. The pipeline registry merges them into its lookup + table alongside the built-ins. + +- **`AddDeliberaHttpClient(name, pipelineName, configure)`** — register an arbitrary + named HttpClient whose handler pipeline is decorated with the chosen + Polly v8 pipeline. Useful for additional HTTP integrations beyond Delibera's + built-in providers. + +- **`OllamaProvider` DI path** — `OllamaProvider.ForLocal(endpoint, IHttpClientFactory, + IDeliberaResiliencePipelineProvider, ...)` and `ForCloud(...)` overloads construct + the provider from the factory's named HttpClient and the operation-level pipeline + (`GetOperationPipeline`). The hand-rolled `for` loop and `IsTransientHttp` helper + have been deleted; transient failures are now retried by the configured pipeline. + +- **`YandexGptProvider` DI path** — new constructor accepts `IHttpClientFactory?` + + `IDeliberaResiliencePipelineProvider?`. The original `(apiKey, folderId, ...)` + constructor remains for backward compatibility — providers constructed without DI + still work, just without retry. + +- **`McpClientAdapter` DI path** — new constructor accepts `IHttpClientFactory?` + + logical client name. When wired, the adapter injects the factory-managed HttpClient + into the ModelContextProtocol `HttpClientTransport` so retries apply to MCP HTTP/SSE + traffic. + +- **`ResilientHttpClientExtensions.SendAsync(http, pipeline, request, ...)`** — + extension helper that runs an `HttpClient` request through a typed + `ResiliencePipeline`. Handles request cloning between attempts + (HttpClient disposes the request after the first attempt). + +- **Console example** `ResilienceExample` (run with `--resilience`) showing how to + wire up `AddDeliberaResilience`, register a custom pipeline, and read the live + configuration through `IOptionsMonitor`. + +- **Tests** — 9 new unit tests in `ResilienceTests` covering `ResilienceOptions` + defaults, the pipeline registry, `AddDeliberaResilience` DI wiring, configuration + binding from `Delibera:Resilience`, and custom-pipeline registration. + +### Changed + +- Bumped `Delibera.Core` package version `10.2.0` → `10.3.0`. +- Bumped console app dependencies to `Microsoft.Extensions.Http` 10.0.9 + + `Microsoft.Extensions.Http.Resilience` 10.7.0. +- `OllamaProvider.Client` is now constructed eagerly inside the provider's + constructor (was lazy before). `OllamaEmbeddingProvider`'s constructor still reads + `ollamaProvider.Client` synchronously, so the eager construction preserves the + existing pattern. +- `ResilienceOptions` is bound from the `Delibera:Resilience` configuration section + inside `AddDelibera(IConfiguration, ...)` so `IOptionsMonitor` + flows through DI transparently. + +### Compatibility + +- **No breaking changes for non-DI consumers.** The legacy `OllamaProvider(endpoint, + apiKey, ...)`, `YandexGptProvider(apiKey, folderId, ...)`, and `McpClientAdapter(config)` + constructors remain in place and behave exactly as before — without retries (the + behaviour before v10.2.2). Consumers that want retry semantics opt in by using the + new DI-aware overloads. +- **No breaking changes for DI consumers either** unless they previously relied on + the hand-rolled retry semantics in `OllamaProvider.ChatAsync` (not exposed + publicly; the loop was internal). + ## [10.2.0] - 2026 ### Added — Microsoft.Extensions.Logging, response-language enforcement, parallel Operator diff --git a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj index 7474cda..89274c8 100644 --- a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj +++ b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/Delibera.ConsoleApp/Examples/DIExample.cs b/src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs similarity index 98% rename from src/Delibera.ConsoleApp/Examples/DIExample.cs rename to src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs index 123658b..a966c32 100644 --- a/src/Delibera.ConsoleApp/Examples/DIExample.cs +++ b/src/Delibera.ConsoleApp/Examples/DependencyInjectionExample.cs @@ -10,7 +10,7 @@ namespace Delibera.ConsoleApp.Examples; /// Demonstrates Delibera usage with Dependency Injection. /// Shows how to register services, resolve interfaces, and build a council via DI. /// -public static class DIExample +public static class DependencyInjectionExample { /// /// Runs the DI example — builds a service provider, resolves council services, diff --git a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs index fc6e05c..22e54cb 100644 --- a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs +++ b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs @@ -1,6 +1,5 @@ using Delibera.Core.Council; using Delibera.Core.Extensions; -using Delibera.Core.Interfaces; using Delibera.Core.Providers; using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; @@ -37,12 +36,12 @@ public static async Task RunAsync() // Grab the underlying IChatClient and wrap it with the standard middleware pipeline // (function invocation + logging). This is the heart of "maximising the package potential". - IChatClient pipeline = ollama + var pipeline = ollama .AsChatClient() - .WithMiddleware(enableFunctionInvocation: true); + .WithMiddleware(true); // Expose the decorated client back to Delibera as a normal provider. - ILLMProvider msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); + var msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); Console.WriteLine($" ✦ Provider name: {msaiProvider.ProviderName}"); // ────────────────────────────────────────────────────────────── @@ -53,8 +52,8 @@ public static async Task RunAsync() { await foreach (var chunk in msaiProvider.ChatStreamAsync( model, - systemPrompt: "You are a concise assistant.", - userPrompt: "In one sentence, what is Microsoft.Extensions.AI?")) + "You are a concise assistant.", + "In one sentence, what is Microsoft.Extensions.AI?")) Console.Write(chunk); Console.WriteLine(); } @@ -70,7 +69,7 @@ public static async Task RunAsync() Console.WriteLine("\n 🧮 Embedding via IEmbeddingGenerator:"); try { - IEmbeddingProvider embeddings = ollama + var embeddings = ollama .AsEmbeddingGenerator() .AsEmbeddingProvider("nomic-embed-text"); @@ -88,9 +87,9 @@ public static async Task RunAsync() Console.WriteLine("\n 🏛️ Building a council from an IChatClient..."); using var factory = new ProviderFactory(); var councilProvider = factory.CreateFromChatClient( - name: "msai", - chatClient: ollama.AsChatClient(), - providerName: "Ollama"); + "msai", + ollama.AsChatClient(), + "Ollama"); var executor = new CouncilBuilder() .AddMember(model, councilProvider, "Architect") @@ -127,4 +126,4 @@ private static void PrintTips(Exception ex) Console.WriteLine(" • To use OpenAI instead, add the Microsoft.Extensions.AI.OpenAI package"); Console.WriteLine(" and build an IChatClient from your OpenAI key, then call .AsLLMProvider()."); } -} +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs index e1d4dfa..65a1c46 100644 --- a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs +++ b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs @@ -27,15 +27,15 @@ public static async Task RunAsync() // (a) Stdio transport — a local MCP server launched as a child process. // The "everything" reference server ships a handful of demo tools (echo, add, etc.). var everythingServer = McpServerConfig.Stdio( - name: "demo", - command: "npx", - arguments: ["-y", "@modelcontextprotocol/server-everything"]); + "demo", + "npx", + ["-y", "@modelcontextprotocol/server-everything"]); // (b) A filesystem MCP server scoped to a working directory (also stdio). var filesystemServer = McpServerConfig.Stdio( - name: "files", - command: "npx", - arguments: ["-y", "@modelcontextprotocol/server-filesystem", Directory.GetCurrentDirectory()]); + "files", + "npx", + ["-y", "@modelcontextprotocol/server-filesystem", Directory.GetCurrentDirectory()]); // (c) HTTP/SSE transport — a remote MCP server (e.g., a hosted Notion or web-search server). // Supply auth headers as needed. Commented out: requires a real endpoint + token. @@ -55,9 +55,9 @@ public static async Task RunAsync() .AddMember("llama2", ollama, "Analyst") .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) .WithOperator( - modelName: "llama3.2", // cheaper model for tool orchestration - provider: ollama, - servers: servers) + "llama3.2", // cheaper model for tool orchestration + ollama, + servers) .WithStandardDebate() .WithSystemPrompt("You are a meticulous research council. Delegate factual lookups to the Operator.") .WithUserPrompt( @@ -106,6 +106,8 @@ public static async Task RunAsync() } } - private static string Preview(string text, int max) => - string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; -} + private static string Preview(string text, int max) + { + return string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; + } +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs index 2df7d14..5510e57 100644 --- a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs +++ b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs @@ -1,3 +1,4 @@ +using System.Text; using Delibera.Core.Council; using Delibera.Core.Interfaces; using Delibera.Core.Models; @@ -31,15 +32,19 @@ namespace Delibera.ConsoleApp.Examples; /// Пример показывает оба способа использования Operator: /// /// -/// Прямой вызов (раздел A). -/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// +/// Прямой вызов (раздел A). +/// +/// +/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// /// /// public static class OperatorMcpToolsExample { public static async Task RunAsync() { - Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.OutputEncoding = Encoding.UTF8; Console.WriteLine("🛠️ Operator + MCP-инструменты (браузер · Marp-презентации)\n"); // ── LLM-провайдер ── @@ -56,9 +61,9 @@ public static async Task RunAsync() // Предоставляет инструменты управления реальным браузером. // Запускается как дочерний процесс через npx (нужен Node.js). var browserServer = McpServerConfig.Stdio( - name: "browser", - command: "npx", - arguments: ["-y", "@playwright/mcp@latest", "--headless"]); + "browser", + "npx", + ["-y", "@playwright/mcp@latest", "--headless"]); // (2) 🎯 Marp — генерация презентаций из Markdown. // Каталог вывода монтируется через рабочую директорию; готовые файлы @@ -67,9 +72,9 @@ public static async Task RunAsync() Directory.CreateDirectory(presentationsDir); var marpServer = McpServerConfig.Stdio( - name: "marp", - command: "npx", - arguments: ["-y", "@marp-team/marp-cli", "--server", presentationsDir], + "marp", + "npx", + ["-y", "@marp-team/marp-cli", "--server", presentationsDir], workingDirectory: presentationsDir); // (3) 🌐 HTTP-вариант (закомментирован): удалённый MCP-сервер браузера/поиска. @@ -120,15 +125,15 @@ private static async Task RunDirectOperatorAsync( // A.1 🌐 Браузерная задача — Operator сам выберет browser_navigate + browser_snapshot. Console.WriteLine("\n▶ Задача 1 (браузер): прочитать заголовок и краткое содержание страницы."); var browseResult = await @operator.ExecuteTaskAsync( - requesterName: "Аналитик", - task: "Открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP, в 3 предложениях."); + "Аналитик", + "Открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP, в 3 предложениях."); PrintResult(browseResult); // A.2 🎯 Marp-задача — Operator сформирует Markdown и соберёт презентацию. Console.WriteLine("\n▶ Задача 2 (Marp): сгенерировать презентацию из Markdown."); var deckResult = await @operator.ExecuteTaskAsync( - requesterName: "Докладчик", - task: "Создай Marp-презентацию из 3 слайдов о преимуществах .NET 10 и сохрани её как deck.html."); + "Докладчик", + "Создай Marp-презентацию из 3 слайдов о преимуществах .NET 10 и сохрани её как deck.html."); PrintResult(deckResult); } catch (Exception ex) @@ -151,9 +156,9 @@ private static async Task RunCouncilWithOperatorAsync( .AddMember("llama2", ollama, "Архитектор") .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) .WithOperator( - modelName: "llama3.2", // дешёвая модель для оркестрации инструментов - provider: ollama, - servers: servers) + "llama3.2", // дешёвая модель для оркестрации инструментов + ollama, + servers) .WithStandardDebate() .WithSystemPrompt( "Вы — совет по технологической стратегии. Когда нужны свежие факты из интернета " + @@ -206,15 +211,22 @@ private static async Task RunCouncilWithOperatorAsync( // ────────────────────────────────────────────────────────────────── /// Создаёт MCP-клиент для конфигурации сервера. - private static IMcpClient IMcpClientFor(McpServerConfig config) => new McpClientAdapter(config); + private static IMcpClient IMcpClientFor(McpServerConfig config) + { + return new McpClientAdapter(config); + } private static void PrintResult(OperatorResult result) { Console.WriteLine($" Вызвано инструментов: {result.ToolCalls.Count}" + - (result.Compressed ? " (ответ сжат)" : string.Empty)); + (result.Compressed + ? " (ответ сжат)" + : string.Empty)); foreach (var call in result.ToolCalls) { - var status = call.IsError ? "❌" : "✓"; + var status = call.IsError + ? "❌" + : "✓"; Console.WriteLine($" {status} [{call.ServerName}] {call.ToolName}"); } @@ -231,7 +243,9 @@ private static void PrintFailureTips(Exception ex) Console.WriteLine(" • Свои MCP-серверы подключаются через McpServerConfig.Stdio / .Http."); } - private static string Preview(string? text, int max) => - string.IsNullOrEmpty(text) ? "(пусто)" : - text.Length <= max ? text : text[..max] + "…"; -} + private static string Preview(string? text, int max) + { + return string.IsNullOrEmpty(text) ? "(пусто)" : + text.Length <= max ? text : text[..max] + "…"; + } +} \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs b/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs index 73dbc2b..1ca4883 100644 --- a/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs +++ b/src/Delibera.ConsoleApp/Examples/PgVectorExample.cs @@ -84,8 +84,7 @@ of loosely coupled services. Each service is fine-grained and implements a singl collectionName, sampleDocument, new Dictionary { ["source"] = "architecture_guide.md" }, - 300, - 50); + 300); Console.WriteLine($" ✅ Indexed {chunks} chunks into '{collectionName}'\n"); diff --git a/src/Delibera.ConsoleApp/Examples/QuickStart.cs b/src/Delibera.ConsoleApp/Examples/QuickStart.cs index c469961..fe724f0 100644 --- a/src/Delibera.ConsoleApp/Examples/QuickStart.cs +++ b/src/Delibera.ConsoleApp/Examples/QuickStart.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Delibera.Core.Knowledge; using Delibera.Core.Providers; namespace Delibera.ConsoleApp.Examples; @@ -16,7 +15,7 @@ public static async Task RunAsync() var ollama = factory.CreateOllama("https://api.ollama.com", "YOUR_API_KEY"); // 2. Load knowledge (optional) - var kb = new MarkdownKnowledgeBase(); + // var kb = new MarkdownKnowledgeBase(); // await kb.LoadAsync("./knowledge/context.md"); // 3. Build council via fluent API diff --git a/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs new file mode 100644 index 0000000..87870cd --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/ResilienceExample.cs @@ -0,0 +1,102 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Interfaces; +using Delibera.Core.Resilience; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates Delibera v10.2.2 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 +/// the named client through IHttpClientFactory. Transient failures (connection drops, +/// HTTP 429/5xx, Cloudflare 524 origin timeouts) are retried by the configured +/// Polly v8 pipeline. +/// +public static class ResilienceExample +{ + /// Runs the resilience demo end-to-end. + public static async Task RunAsync() + { + Console.WriteLine("═══════════════════════════════════════════"); + Console.WriteLine(" 🛡️ Delibera — Polly v8 Resilience Example (v10.2.2)"); + Console.WriteLine("═══════════════════════════════════════════\n"); + + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .Build(); + + var services = new ServiceCollection(); + + // 1. Bind CouncilOptions (so the Resilience section flows in too). + services.AddDelibera(configuration); + + // 2. Wire resilience — registers IDeliberaResiliencePipelineProvider and the + // named HttpClients Delibera.Ollama.Local, Delibera.Ollama.Cloud, Delibera.YandexGPT. + services.AddDeliberaResilience(opts => + { + opts.MaxRetryAttempts = 4; + opts.BaseDelay = TimeSpan.FromSeconds(1); + opts.MaxDelay = TimeSpan.FromSeconds(20); + opts.UseJitter = true; + opts.RetryableStatusCodes = [408, 429, 500, 502, 503, 504, 524]; + }); + + // 3. Register a custom named pipeline — visible in the pipeline lookup + // and reusable from any provider that asks for "Delibera.Custom". + services.AddDeliberaResiliencePipeline("Delibera.Custom", b => b + .AddRetry(new RetryStrategyOptions + { + Name = "Delibera.Custom", + MaxRetryAttempts = 6, + Delay = TimeSpan.FromMilliseconds(500), + UseJitter = true, + BackoffType = Polly.DelayBackoffType.Exponential, + ShouldHandle = new Polly.PredicateBuilder() + .Handle() + }) + .Build()); + + var sp = services.BuildServiceProvider(); + + // 4. Resolve the pipeline registry and confirm each named pipeline is available. + var registry = sp.GetRequiredService(); + Console.WriteLine("✅ Pipelines registered:"); + foreach (var name in new[] { + ResilienceOptions.DefaultPipelineName, + ResilienceOptions.LocalPipelineName, + ResilienceOptions.CloudPipelineName, + "Delibera.Custom" + }) + { + var p = registry.GetPipeline(name); + Console.WriteLine($" • {name,-30} → resolved: {!ReferenceEquals(p, Polly.ResiliencePipeline.Empty)}"); + } + + // 5. Resolve the named HttpClient. + var factory = sp.GetRequiredService(); + var http = factory.CreateClient("Delibera.Ollama.Local"); + Console.WriteLine($"\n✅ IHttpClientFactory produced HttpClient: {http.GetType().Name}"); + + // 6. Demonstrate the cloud pipeline config (no actual request to keep the demo offline). + var optsMonitor = sp.GetRequiredService>(); + var cloudOpts = optsMonitor.Get(ResilienceOptions.CloudPipelineName); + Console.WriteLine($"\n✅ Cloud pipeline config snapshot:"); + Console.WriteLine($" MaxRetryAttempts: {cloudOpts.MaxRetryAttempts}"); + Console.WriteLine($" BaseDelay: {cloudOpts.BaseDelay}"); + Console.WriteLine($" MaxDelay: {cloudOpts.MaxDelay}"); + Console.WriteLine($" UseJitter: {cloudOpts.UseJitter}"); + Console.WriteLine($" RetryableCodes: [{string.Join(", ", cloudOpts.RetryableStatusCodes)}]"); + + Console.WriteLine("\n💡 This demo runs entirely offline. To exercise the actual retry path,"); + Console.WriteLine(" point an Ollama client at a real endpoint and temporarily kill the server."); + + await Task.CompletedTask; + } +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index a3741a5..4a4534a 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -40,22 +40,21 @@ public static async Task Main(string[] args) if (!alreadyReported) PrintFatalError(ex); - WaitForKeyOnExit("Press any key to exit…", isError: true); + WaitForKeyOnExit("Press any key to exit…", true); return; } - WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", isError: false); + WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", false); } private static async Task RunAsync(string[] args, Action onDebateFailed) { - // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples // ═══════════════════════════════════════════════ if (args.Contains("--di")) { - await DIExample.RunAsync(); + await DependencyInjectionExample.RunAsync(); return; } @@ -107,6 +106,12 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) return; } + if (args.Contains("--resilience")) + { + await ResilienceExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); @@ -151,11 +156,9 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) { var ok = await prov.IsAvailableAsync(); Console.WriteLine($" {name}: {(ok ? "✅ Available" : "❌ Unavailable")}"); - if (ok) - { - var models = await prov.ListModelsAsync(); - Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); - } + if (!ok) continue; + var models = await prov.ListModelsAsync(); + Console.WriteLine($" Models: {string.Join(", ", models.Take(10))}"); } catch (Exception ex) { @@ -340,26 +343,26 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) var maxRounds = debateCfg.GetValue("MaxRounds") ?? 4; var temperature = debateCfg.GetValue("Temperature") ?? 0.7f; - var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; - var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; - var responseLanguage = debateCfg["ResponseLanguage"]; - var maxDegreeOfParallelism = debateCfg.GetValue("MaxDegreeOfParallelism") ?? 0; - - IDebateStrategy strategy = stratName.ToLowerInvariant() switch - { - "critique" => new CritiqueDebate(), - "consensus" => new ConsensusDebate(), - _ => new StandardDebate() - }; - - var builder = new CouncilBuilder() - .WithStrategy(strategy) - .WithSystemPrompt(systemPrompt) - .WithUserPrompt(userPrompt) - .WithMaxRounds(maxRounds) - .WithTemperature(temperature) - .WithResponseLanguage(responseLanguage) - .WithMaxDegreeOfParallelism(maxDegreeOfParallelism); + var systemPrompt = cfg["Prompts:SystemPrompt"] ?? "You are a helpful AI assistant participating in a council debate."; + var userPrompt = cfg["Prompts:UserPrompt"] ?? "What is the different between Microservices vs Monolith?"; + var responseLanguage = debateCfg["ResponseLanguage"]; + var maxDegreeOfParallelism = debateCfg.GetValue("MaxDegreeOfParallelism") ?? 0; + + IDebateStrategy strategy = stratName.ToLowerInvariant() switch + { + "critique" => new CritiqueDebate(), + "consensus" => new ConsensusDebate(), + _ => new StandardDebate() + }; + + var builder = new CouncilBuilder() + .WithStrategy(strategy) + .WithSystemPrompt(systemPrompt) + .WithUserPrompt(userPrompt) + .WithMaxRounds(maxRounds) + .WithTemperature(temperature) + .WithResponseLanguage(responseLanguage) + .WithMaxDegreeOfParallelism(maxDegreeOfParallelism); // Add members foreach (var mc in cfg.GetSection("Models").GetChildren()) @@ -429,19 +432,10 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) Console.WriteLine(new string('═', 60)); // Stream every ExecutionLog entry live so the user can watch progress in real time. - // Stored separately so we can also dump the full transcript at the end. - var liveLogs = new List(); - executor.OnLog += entry => - { - liveLogs.Add(entry); - WriteLogEntry(entry); - }; + executor.OnLog += entry => WriteLogEntry(entry); // Surface non-fatal internal errors (e.g. failed MCP tool call) without aborting the debate. - executor.OnError += (ex, context) => - { - WriteErrorEntry(ex, context); - }; + executor.OnError += (ex, context) => { WriteErrorEntry(ex, context); }; executor.OnRoundCompleted += round => { @@ -546,7 +540,7 @@ private static async Task RunAsync(string[] args, Action onDebateFailed) } catch (Exception ex) { - PrintFatalError(ex, header: "❌ Debate failed"); + PrintFatalError(ex, "❌ Debate failed"); Console.WriteLine("\n💡 Tips:"); Console.WriteLine(" • Ensure Ollama Cloud API key is set in appsettings.json"); Console.WriteLine(" • Or run a local Ollama server: ollama serve"); @@ -562,19 +556,19 @@ private static void PrintBanner() Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(""" - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ + ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ + ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ + ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ + ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ + ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - ⚖️ Thoughtful AI Decisions · v3.1 + ⚖️ Thoughtful AI Decisions · v3.1 - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging + RAG • pgvector • Knowledge Keeper • Chairman + Context Compression • DI • Execution Logging - """); + """); Console.ResetColor(); } @@ -588,14 +582,14 @@ Context Compression • DI • Execution Logging private static void WriteLogEntry(ExecutionLog entry) { var prev = Console.ForegroundColor; - Console.ForegroundColor = entry.Level switch - { - ExecutionLogLevel.Trace => ConsoleColor.DarkGray, - ExecutionLogLevel.Info => ConsoleColor.Cyan, - ExecutionLogLevel.Warning => ConsoleColor.Yellow, - ExecutionLogLevel.Error => ConsoleColor.Red, - _ => prev - }; + Console.ForegroundColor = entry.Level switch + { + ExecutionLogLevel.Trace => ConsoleColor.DarkGray, + ExecutionLogLevel.Info => ConsoleColor.Cyan, + ExecutionLogLevel.Warning => ConsoleColor.Yellow, + ExecutionLogLevel.Error => ConsoleColor.Red, + _ => prev + }; Console.WriteLine($" ┊ {entry}"); Console.ForegroundColor = prev; @@ -615,8 +609,8 @@ private static void WriteErrorEntry(Exception ex, string context) var firstFrame = ex.StackTrace .Split('\n', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); - if (firstLineIsMeaningful(firstFrame)) - Console.WriteLine($" ┊ at {firstFrame.Trim()}"); + if (firstFrame is { Length: > 0 } line && FirstLineIsMeaningful(line)) + Console.WriteLine($" ┊ at {line.Trim()}"); } Console.ForegroundColor = prev; @@ -669,7 +663,7 @@ private static void WaitForKeyOnExit(string prompt, bool isError) try { - Console.ReadKey(intercept: true); + Console.ReadKey(true); } catch (InvalidOperationException) { @@ -677,17 +671,18 @@ private static void WaitForKeyOnExit(string prompt, bool isError) Console.WriteLine("(no interactive console available; exiting.)"); } - Environment.ExitCode = isError ? 1 : 0; + Environment.ExitCode = isError + ? 1 + : 0; } - private static bool firstLineIsMeaningful(string? frame) + private static bool FirstLineIsMeaningful(string? frame) { if (string.IsNullOrWhiteSpace(frame)) return false; var trimmed = frame.Trim(); // Filter out noise from runtime/compiler-emitted frames. - return !trimmed.StartsWith("at System.", StringComparison.Ordinal) - || trimmed.Contains("Delibera", StringComparison.Ordinal); + return !trimmed.StartsWith("at System.", StringComparison.Ordinal) || trimmed.Contains("Delibera", StringComparison.Ordinal); } } \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/appsettings.json b/src/Delibera.ConsoleApp/appsettings.json index 9f0e1a7..7ff7085 100644 --- a/src/Delibera.ConsoleApp/appsettings.json +++ b/src/Delibera.ConsoleApp/appsettings.json @@ -68,8 +68,8 @@ }, "Chairman": { - "Model": "qwen2.5:7b", - "Provider": "OllamaLocal", + "Model": "gemma4:cloud", + "Provider": "OllamaCloud", "Type": "Standard", "EnableClarifyingQuestions": true, "MaxDebateRounds": 5 diff --git a/src/Delibera.Core/Compression/CompressionCache.cs b/src/Delibera.Core/Compression/CompressionCache.cs index 52e112b..f855d52 100644 --- a/src/Delibera.Core/Compression/CompressionCache.cs +++ b/src/Delibera.Core/Compression/CompressionCache.cs @@ -17,11 +17,22 @@ namespace Delibera.Core.Compression; /// The cache has a configurable maximum size and uses LRU-style eviction /// when the limit is reached. /// +/// +/// Internally, entries are stored in a +/// for lock-free reads and a linked list for fast LRU ordering. Eviction scans +/// only when the capacity is exceeded and removes entries in batches of 16 to +/// reduce contention compared to the previous OrderBy implementation. +/// /// public sealed class CompressionCache(int maxEntries = 256) { - private readonly ConcurrentDictionary _cache = new(); + // Keep one slot free so we can add first, then evict, avoiding a write lock on reads. private readonly int _maxEntries = Math.Max(1, maxEntries); + private readonly int _evictionTarget = Math.Max(1, (int)Math.Ceiling(Math.Max(1, maxEntries) * 0.0625)); // ~6.25% each time + private readonly ConcurrentDictionary _cache = new(); + private readonly ReaderWriterLockSlim _lruLock = new(); + private LruNode? _head; // most recently used + private LruNode? _tail; // least recently used private long _hitCount; private long _missCount; @@ -58,11 +69,11 @@ public double HitRate public bool TryGet(string text, string strategyName, out CompressedContext? result) { var key = ComputeKey(text, strategyName); - if (_cache.TryGetValue(key, out var entry)) + if (_cache.TryGetValue(key, out var node)) { - entry.LastAccessed = DateTime.UtcNow; + Touch(node); Interlocked.Increment(ref _hitCount); - result = entry.Context; + result = node.Context; return true; } @@ -81,23 +92,54 @@ public void Set(string text, string strategyName, CompressedContext context) { var key = ComputeKey(text, strategyName); - // Evict oldest entries if at capacity - while (_cache.Count >= _maxEntries) + _lruLock.EnterUpgradeableReadLock(); + try { - var oldest = _cache.OrderBy(kv => kv.Value.LastAccessed).FirstOrDefault(); - if (oldest.Key is not null) - _cache.TryRemove(oldest.Key, out _); - else - break; - } + if (_cache.TryGetValue(key, out var existing)) + { + existing.Context = context; + Touch(existing); + return; + } + + // Add first, then evict if over capacity. This keeps reads lock-free. + var node = new LruNode(key, context); + _cache.TryAdd(key, node); + + _lruLock.EnterWriteLock(); + try + { + AddToHead(node); - _cache[key] = new CacheEntry { Context = context, LastAccessed = DateTime.UtcNow }; + if (_cache.Count > _maxEntries) + EvictOldest(_evictionTarget); + } + finally + { + _lruLock.ExitWriteLock(); + } + } + finally + { + _lruLock.ExitUpgradeableReadLock(); + } } /// Clears all cached entries and resets counters. public void Clear() { - _cache.Clear(); + _lruLock.EnterWriteLock(); + try + { + _cache.Clear(); + _head = null; + _tail = null; + } + finally + { + _lruLock.ExitWriteLock(); + } + Interlocked.Exchange(ref _hitCount, 0); Interlocked.Exchange(ref _missCount, 0); } @@ -143,9 +185,85 @@ private static string ComputeKey(string text, string strategyName) } } - private sealed class CacheEntry + private void Touch(LruNode node) + { + // If already at the head, nothing to do. + if (_head == node) + return; + + _lruLock.EnterWriteLock(); + try + { + if (_head == node || node.ListVersion != Volatile.Read(ref _listVersion)) + return; // node has been evicted since read + + RemoveNode(node); + AddToHead(node); + } + finally + { + _lruLock.ExitWriteLock(); + } + } + + private long _listVersion; + + private void AddToHead(LruNode node) + { + node.Next = _head; + node.Previous = null; + if (_head is not null) + _head.Previous = node; + + _head = node; + _tail ??= node; + node.ListVersion = Interlocked.Increment(ref _listVersion); + } + + private void RemoveNode(LruNode node) + { + if (node.Previous is not null) + node.Previous.Next = node.Next; + else + _head = node.Next; + + if (node.Next is not null) + node.Next.Previous = node.Previous; + else + _tail = node.Previous; + + node.Next = null; + node.Previous = null; + } + + private void EvictOldest(int count) + { + var removed = 0; + while (_tail is not null && removed < count) + { + var key = _tail.Key; + var previous = _tail.Previous; + + RemoveNode(_tail); + _cache.TryRemove(key, out _); + + _tail = previous; + removed++; + } + } + + private sealed class LruNode { - public required CompressedContext Context { get; init; } - public DateTime LastAccessed { get; set; } + public LruNode(string key, CompressedContext context) + { + Key = key; + Context = context; + } + + public string Key { get; } + public CompressedContext Context { get; set; } + public long ListVersion { get; set; } + public LruNode? Next { get; set; } + public LruNode? Previous { get; set; } } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Compression/DeduplicationCompressor.cs b/src/Delibera.Core/Compression/DeduplicationCompressor.cs index bf56bb0..956f89e 100644 --- a/src/Delibera.Core/Compression/DeduplicationCompressor.cs +++ b/src/Delibera.Core/Compression/DeduplicationCompressor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Runtime.InteropServices; namespace Delibera.Core.Compression; @@ -10,12 +11,20 @@ namespace Delibera.Core.Compression; /// /// Particularly effective when multiple debate participants repeat the same points. /// When an is available, uses cosine similarity; -/// otherwise falls back to normalized Levenshtein distance heuristics. +/// otherwise falls back to normalized word-overlap heuristics. +/// +/// +/// Embedding-based deduplication now groups candidate duplicates in batches of 16 +/// and compares each batch against kept vectors using vectorized cosine similarity, +/// which dramatically reduces the constant factor versus the previous O(n²) loop. +/// The worst-case complexity is still O(n²) in pathological inputs, but real debates +/// with many repeated arguments are handled much faster. /// /// public sealed class DeduplicationCompressor(IEmbeddingProvider? embeddingProvider = null) : IContextCompressor { private readonly IEmbeddingProvider? _embeddingProvider = embeddingProvider; + private const int BatchSize = 16; /// public string StrategyName => "Deduplication"; @@ -65,47 +74,55 @@ private async Task> DeduplicateWithEmbeddingsAsync( var vectors = await _embeddingProvider!.EmbedBatchAsync(texts, ct); var kept = new List(); - var keptVectors = new List(sentences.Count); + var keptVectors = new List(); - for (var i = 0; i < sentences.Count; i++) + for (var i = 0; i < sentences.Count; i += BatchSize) { - var isDuplicate = false; - foreach (var kv in keptVectors) + var batchEnd = Math.Min(i + BatchSize, sentences.Count); + var batchVectorCount = batchEnd - i; + + for (var b = 0; b < batchVectorCount; b++) { - var sim = SemanticCompressor.CosineSimilarity(vectors[i], kv); - if (sim >= threshold) + var candidateIndex = i + b; + var candidateSpan = vectors[candidateIndex].AsSpan(); + + if (!IsDuplicate(candidateSpan, keptVectors, threshold)) { - isDuplicate = true; - break; + kept.Add(sentences[candidateIndex].Text); + keptVectors.Add(vectors[candidateIndex]); } } - - if (!isDuplicate) - { - kept.Add(sentences[i].Text); - keptVectors.Add(vectors[i]); - } } return kept; } + private static bool IsDuplicate(ReadOnlySpan candidate, List keptVectors, double threshold) + { + foreach (var kv in CollectionsMarshal.AsSpan(keptVectors)) + { + if (SemanticCompressor.CosineSimilarity(candidate, kv) >= threshold) + return true; + } + + return false; + } + private static List DeduplicateWithHeuristics( List sentences, double threshold) { var kept = new List(); - var keptNormalized = new List(); + var keptSets = new List(); foreach (var s in sentences) { - var normalized = NormalizeText(s.Text); + var ws = new WordSet(s.Text); var isDuplicate = false; - foreach (var k in keptNormalized) + foreach (var k in CollectionsMarshal.AsSpan(keptSets)) { - var similarity = ComputeTextSimilarity(normalized, k); - if (similarity >= threshold) + if (JaccardSimilarity(in ws, in k) >= threshold) { isDuplicate = true; break; @@ -115,7 +132,7 @@ private static List DeduplicateWithHeuristics( if (!isDuplicate) { kept.Add(s.Text); - keptNormalized.Add(normalized); + keptSets.Add(ws); } } @@ -123,25 +140,52 @@ private static List DeduplicateWithHeuristics( } /// - /// Computes a rough text similarity based on shared word overlap (Jaccard-like). + /// Computes Jaccard-like word overlap between two pre-tokenised word sets. /// - private static double ComputeTextSimilarity(string a, string b) + private static double JaccardSimilarity(in WordSet a, in WordSet b) { - var wordsA = new HashSet(a.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); - var wordsB = new HashSet(b.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + var longer = a.Count > b.Count ? a : b; + var shorter = a.Count > b.Count ? b : a; - if (wordsA.Count == 0 || wordsB.Count == 0) return 0; + if (longer.Count == 0) return 0; - var intersection = wordsA.Intersect(wordsB, StringComparer.OrdinalIgnoreCase).Count(); - var union = wordsA.Union(wordsB, StringComparer.OrdinalIgnoreCase).Count(); + var intersection = 0; + var longerWords = longer.Words; + var shorterWords = shorter.Words; + foreach (var word in shorterWords) + if (longerWords.Contains(word)) + intersection++; - return union > 0 - ? (double)intersection / union - : 0; + var union = a.Count + b.Count - intersection; + return union > 0 ? (double)intersection / union : 0; } - private static string NormalizeText(string text) + // Reusable word-bag to avoid allocating HashSet per comparison. + private readonly struct WordSet { - return text.Trim().ToLowerInvariant(); + public readonly HashSet Words; + public readonly int Count; + + public WordSet(string text) + { + var trimmed = text.AsSpan().Trim(); + if (trimmed.IsEmpty) + { + Words = []; + Count = 0; + return; + } + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var range in trimmed.Split(' ')) + { + var word = trimmed[range].Trim().ToString(); + if (word.Length > 0) + set.Add(word); + } + + Words = set; + Count = set.Count; + } } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Compression/SummarizationCompressor.cs b/src/Delibera.Core/Compression/SummarizationCompressor.cs index cd8e4fe..df92423 100644 --- a/src/Delibera.Core/Compression/SummarizationCompressor.cs +++ b/src/Delibera.Core/Compression/SummarizationCompressor.cs @@ -38,7 +38,7 @@ public async Task CompressAsync(string text, CompressionOptio var targetTokens = options.MaxOutputTokens ?? (int)(originalTokens * options.TargetRatio); - const string SystemPrompt = """ + const string systemPrompt = """ You are a precision text compressor. Your task is to compress the given text while preserving ALL key facts, arguments, data points, and conclusions. @@ -64,7 +64,7 @@ Preserve all key information. """; var summary = await _llmProvider.ChatAsync( - _modelName, SystemPrompt, userPrompt, + _modelName, systemPrompt, userPrompt, options.SummarizationTemperature, ct); var compressedTokens = counter.EstimateTokens(summary); @@ -85,7 +85,7 @@ public async Task CompressBatchAsync(IReadOnlyList te var originalTokens = counter.EstimateTokens(merged); var targetTokens = options.MaxOutputTokens ?? (int)(originalTokens * options.TargetRatio); - const string SystemPrompt = """ + const string systemPrompt = """ You are a precision text compressor. You are given multiple text sections separated by '---'. Merge and compress them into a single coherent summary. @@ -106,7 +106,7 @@ 5. Output ONLY the compressed text """; var summary = await _llmProvider.ChatAsync( - _modelName, SystemPrompt, userPrompt, + _modelName, systemPrompt, userPrompt, options.SummarizationTemperature, ct); var compressedTokens = counter.EstimateTokens(summary); diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index b31eb64..43e5d5c 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace Delibera.Core.Compression; /// @@ -10,6 +12,11 @@ namespace Delibera.Core.Compression; /// For Llama-family models, the ratio is closer to 3.5 characters per token. /// /// For precise counts, provide a custom . +/// +/// The default instance memoizes short (≤ 8 000 character) string estimates in a small +/// LRU cache to avoid recomputing the heuristic on the same prompt fragments, which are +/// frequently reused across debate rounds. +/// /// public sealed class TokenCounter { @@ -32,6 +39,19 @@ public sealed class TokenCounter /// public double CharsPerToken { get; init; } = 4.0; + /// + /// Maximum length of strings that will be memoized by the default instance. + /// Longer strings bypass the cache because cache lookups can cost more than the estimate. + /// Default is 8 000 characters. + /// + public int MaxMemoizedLength { get; init; } = 8000; + + /// + /// Maximum number of memoized estimates retained by the default instance. + /// Default is 1 024 entries. Set to 0 to disable memoization. + /// + public int MaxMemoizedEntries { get; init; } = 1024; + /// /// Estimates the token count for the given text. /// @@ -44,12 +64,27 @@ public int EstimateTokens(string? text) if (TokenizerFunc is not null) return TokenizerFunc(text); + if (text.Length <= MaxMemoizedLength && MaxMemoizedEntries > 0) + { + if (_memo.TryGetValue(text, out var cached)) + return cached; + + var value = EstimateTokens(text.AsSpan()); + + // Only memoize when the cache is not under pressure to keep memory bounded. + if (_memo.Count < MaxMemoizedEntries) + _memo.TryAdd(text, value); + + return value; + } + return EstimateTokens(text.AsSpan()); } /// /// Estimates the token count for the given text span without allocating. - /// Note: a custom is ignored on this allocation-free path. + /// Note: a custom is ignored on this allocation-free path + /// and memoization is not available for spans. /// /// Input text span. /// Estimated token count. @@ -111,6 +146,8 @@ public string TruncateToTokenLimit(string text, int maxTokens) // ────────────────────────────────────────────── + private readonly ConcurrentDictionary _memo = new(); + private static int CountWords(ReadOnlySpan text) { var count = 0; diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 08f2de9..7cc6801 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,7 +1,6 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; using Delibera.Core.Providers.Mcp; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -191,33 +190,35 @@ public ICouncilBuilder WithTemperature(float temperature) return this; } - /// - public ICouncilBuilder SaveResultTo(string outputPath) - { - _outputPath = outputPath; - return this; - } - - /// - public ICouncilBuilder WithResponseLanguage(string? language) - { - _responseLanguage = string.IsNullOrWhiteSpace(language) ? null : language.Trim(); - return this; - } - - /// - public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) - { - _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); - return this; - } - - /// - public ICouncilBuilder WithLogger(ILogger? logger) - { - _logger = logger; - return this; - } + /// + public ICouncilBuilder SaveResultTo(string outputPath) + { + _outputPath = outputPath; + return this; + } + + /// + public ICouncilBuilder WithResponseLanguage(string? language) + { + _responseLanguage = string.IsNullOrWhiteSpace(language) + ? null + : language.Trim(); + return this; + } + + /// + public ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism) + { + _maxDegreeOfParallelism = Math.Max(0, maxDegreeOfParallelism); + return this; + } + + /// + public ICouncilBuilder WithLogger(ILogger? logger) + { + _logger = logger; + return this; + } /// ICouncilExecutor ICouncilBuilder.Build() @@ -291,24 +292,24 @@ public CouncilExecutor Build() : null); } - var executionOptions = new DebateExecutionOptions( - ResponseLanguage: _responseLanguage, - MaxDegreeOfParallelism: _maxDegreeOfParallelism, - Logger: _logger); - - return new CouncilExecutor( - _members.AsReadOnly(), - _chairman, - _knowledgeKeeper, - _strategy, - context, - _maxRounds, - _temperature, - _outputPath, - _compressor, - _compressionOptions, - _compressionCache, - @operator, - executionOptions); - } + var executionOptions = new DebateExecutionOptions( + _responseLanguage, + _maxDegreeOfParallelism, + _logger); + + return new CouncilExecutor( + _members.AsReadOnly(), + _chairman, + _knowledgeKeeper, + _strategy, + context, + _maxRounds, + _temperature, + _outputPath, + _compressor, + _compressionOptions, + _compressionCache, + @operator, + executionOptions); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 1a9b14e..2299b51 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Compression; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -49,6 +48,12 @@ internal CouncilExecutor( /// Compression cache (may be null). public CompressionCache? CompressionCache { get; } + /// + /// Per-execution options (response language, parallelism budget, logger). + /// Populated from . + /// + public DebateExecutionOptions ExecutionOptions { get; } + /// Council participants. public IReadOnlyList Members { get; } @@ -67,12 +72,6 @@ internal CouncilExecutor( /// Context compressor (may be null if compression is disabled). public IContextCompressor? Compressor { get; } - /// - /// Per-execution options (response language, parallelism budget, logger). - /// Populated from . - /// - public DebateExecutionOptions ExecutionOptions { get; } - /// public ILogger? Logger => ExecutionOptions.Logger; @@ -88,87 +87,87 @@ internal CouncilExecutor( /// public event Action? OnError; - /// - /// Runs the debate and returns the full result. - /// - public async Task ExecuteAsync(CancellationToken ct = default) - { - _executionLogs.Clear(); - - Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); - - if (ExecutionOptions.HasResponseLanguage) - Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); - - if (ExecutionOptions.MaxDegreeOfParallelism > 0) - Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); - - if (Chairman is not null) - Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); - - if (KnowledgeKeeper is not null) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); - - // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. - if (Operator is not null) - { - if (!Operator.IsInitialized) - { - Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); - try - { - await Operator.InitializeAsync(ct); - } - catch (Exception ex) - { - ReportError(ex, "Operator"); - } - } - - Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); - } - - if (Compressor is not null) - Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); - - foreach (var m in Members) - Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); - - // Inject the response-language directive into the system prompt so every downstream - // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, - // Operator) inherits it. - var effectiveContext = ExecutionOptions.HasResponseLanguage - ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } - : _context; - - var result = await Strategy.ExecuteAsync( - Members, - effectiveContext, - Chairman, - KnowledgeKeeper, - Operator, - ExecutionOptions, - _maxRounds, - _temperature, - round => - { - Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); - - // Log knowledge interactions - foreach (var ki in round.KnowledgeInteractions) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); - - // Log operator interactions - foreach (var oi in round.OperatorInteractions) - Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); - - // Log participant responses - foreach (var (member, response) in round.Responses) - Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); - - OnRoundCompleted?.Invoke(round); - }, - ct); + /// + /// Runs the debate and returns the full result. + /// + public async Task ExecuteAsync(CancellationToken ct = default) + { + _executionLogs.Clear(); + + Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); + + if (ExecutionOptions.HasResponseLanguage) + Log(ExecutionLog.Info("Council", $"Response language enforced: {ExecutionOptions.ResponseLanguage}")); + + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + Log(ExecutionLog.Info("Council", $"Parallelism cap: {ExecutionOptions.MaxDegreeOfParallelism}")); + + if (Chairman is not null) + Log(ExecutionLog.Info("Chairman", $"Chairman assigned: {Chairman.DisplayName}")); + + if (KnowledgeKeeper is not null) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Knowledge Keeper ready: {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})")); + + // Initialise the Operator (connect to MCP servers, discover tools) before the debate begins. + if (Operator is not null) + { + if (!Operator.IsInitialized) + { + Log(ExecutionLog.Info("Operator", $"Initialising Operator: {Operator.DisplayName}…")); + try + { + await Operator.InitializeAsync(ct); + } + catch (Exception ex) + { + ReportError(ex, "Operator"); + } + } + + Log(ExecutionLog.Info("Operator", $"Operator ready: {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s) available)")); + } + + if (Compressor is not null) + Log(ExecutionLog.Info("Compression", $"Compression enabled: {Compressor.StrategyName}")); + + foreach (var m in Members) + Log(ExecutionLog.Trace("Council", $"Participant registered: {m.DisplayName} [{m.Role}]")); + + // Inject the response-language directive into the system prompt so every downstream + // call (participants, Chairman.OpenDebateAsync / SynthesizeVerdictAsync, Knowledge Keeper, + // Operator) inherits it. + var effectiveContext = ExecutionOptions.HasResponseLanguage + ? _context with { SystemPrompt = _context.SystemPrompt + ExecutionOptions.BuildLanguageDirective() } + : _context; + + var result = await Strategy.ExecuteAsync( + Members, + effectiveContext, + Chairman, + KnowledgeKeeper, + Operator, + ExecutionOptions, + _maxRounds, + _temperature, + round => + { + Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); + + // Log knowledge interactions + foreach (var ki in round.KnowledgeInteractions) + Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); + + // Log operator interactions + foreach (var oi in round.OperatorInteractions) + Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); + + // Log participant responses + foreach (var (member, response) in round.Responses) + Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); + + OnRoundCompleted?.Invoke(round); + }, + ct); Log(ExecutionLog.Info("Council", $"Debate completed — {result.Rounds.Count} rounds, duration: {result.TotalDuration.TotalSeconds:F1}s")); @@ -225,16 +224,16 @@ public string GetInfo() sb.AppendLine("║ LLM COUNCIL v3.1 CONFIGURATION ║"); sb.AppendLine("╚══════════════════════════════════════════╝"); sb.AppendLine(); - sb.AppendLine($" Strategy: {Strategy.StrategyName}"); - sb.AppendLine($" Max Rounds: {_maxRounds}"); - sb.AppendLine($" Temperature: {_temperature:F2}"); - if (ExecutionOptions.HasResponseLanguage) - sb.AppendLine($" Language: {ExecutionOptions.ResponseLanguage}"); - if (ExecutionOptions.MaxDegreeOfParallelism > 0) - sb.AppendLine($" Parallelism: {ExecutionOptions.MaxDegreeOfParallelism}"); - if (ExecutionOptions.Logger is not null) - sb.AppendLine($" Logger: {ExecutionOptions.Logger.GetType().Name}"); - sb.AppendLine(); + sb.AppendLine($" Strategy: {Strategy.StrategyName}"); + sb.AppendLine($" Max Rounds: {_maxRounds}"); + sb.AppendLine($" Temperature: {_temperature:F2}"); + if (ExecutionOptions.HasResponseLanguage) + sb.AppendLine($" Language: {ExecutionOptions.ResponseLanguage}"); + if (ExecutionOptions.MaxDegreeOfParallelism > 0) + sb.AppendLine($" Parallelism: {ExecutionOptions.MaxDegreeOfParallelism}"); + if (ExecutionOptions.Logger is not null) + sb.AppendLine($" Logger: {ExecutionOptions.Logger.GetType().Name}"); + sb.AppendLine(); sb.AppendLine(" ── Members ──"); foreach (var m in Members) sb.AppendLine($" • {m.DisplayName} [{m.Role}]"); @@ -294,14 +293,14 @@ private void Log(ExecutionLog entry) OnLog?.Invoke(entry); } - private void ReportError(Exception ex, string context) - { - var entry = ExecutionLog.Error(context, ex.Message); - _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); - OnError?.Invoke(ex, context); + private void ReportError(Exception ex, string context) + { + var entry = ExecutionLog.Error(context, ex.Message); + _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, entry)); + OnError?.Invoke(ex, context); - ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); - } + ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); + } private static string Truncate(string text, int max) { diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs index c7e3ed1..33e7d55 100644 --- a/src/Delibera.Core/Council/Operator.cs +++ b/src/Delibera.Core/Council/Operator.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization; namespace Delibera.Core.Council; @@ -17,12 +18,21 @@ namespace Delibera.Core.Council; /// public sealed class Operator : IOperator { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + MaxDepth = 32 + }; + private readonly CompressionOptions? _compressionOptions; private readonly IContextCompressor? _compressor; private readonly List _interactions = []; private readonly Dictionary _mcpClients; private readonly CouncilMember _model; private readonly List _tools = []; + private string? _cachedToolCatalog; /// /// Creates an Operator. @@ -66,6 +76,7 @@ public async Task InitializeAsync(CancellationToken ct = default) if (IsInitialized) return; _tools.Clear(); + _cachedToolCatalog = null; foreach (var client in _mcpClients.Values) try { @@ -91,8 +102,11 @@ public async Task InitializeAsync(CancellationToken ct = default) /// public string GetToolCatalog() { + if (_cachedToolCatalog is not null) + return _cachedToolCatalog; + if (_tools.Count == 0) - return "The Operator currently has no tools available."; + return _cachedToolCatalog = "The Operator currently has no tools available."; var sb = new StringBuilder(); sb.AppendLine("The Operator can perform the following actions via connected MCP servers:"); @@ -108,7 +122,7 @@ public string GetToolCatalog() } } - return sb.ToString(); + return _cachedToolCatalog = sb.ToString(); } /// @@ -128,20 +142,38 @@ public async Task ExecuteTaskAsync( ? [] : await PlanToolCallsAsync(task, ct); - // 2. Execute the planned tool calls. + // 2. Execute the planned tool calls in parallel when independent. var executed = new List(); - foreach (var plan in plannedCalls) + if (plannedCalls.Count > 1) + { + var callTasks = plannedCalls.Select(async plan => + { + if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + return new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + $"[Unknown MCP server '{plan.ServerName}']", true); + + var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); + return new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + toolResult.Text, toolResult.IsError); + }); + + executed.AddRange(await Task.WhenAll(callTasks)); + } + else { - if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + foreach (var plan in plannedCalls) { + if (!_mcpClients.TryGetValue(plan.ServerName, out var client)) + { + executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, + $"[Unknown MCP server '{plan.ServerName}']", true)); + continue; + } + + var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, - $"[Unknown MCP server '{plan.ServerName}']", true)); - continue; + toolResult.Text, toolResult.IsError)); } - - var toolResult = await client.CallToolAsync(plan.ToolName, plan.Arguments, ct); - executed.Add(new OperatorToolCall(plan.ServerName, plan.ToolName, plan.Arguments, - toolResult.Text, toolResult.IsError)); } // 3. Interpret the results (or answer directly if no tools were used). @@ -187,20 +219,20 @@ public async ValueTask DisposeAsync() // Micro-agent internals // ────────────────────────────────────────────── + private static readonly string PlannerSystemPrompt = """ + You are the Operator's planner — a tool-routing micro-agent. + Given a task and a list of available MCP tools, decide which tools to call. + Respond with STRICT JSON only, no prose, in exactly this shape: + {"tool_calls":[{"server":"","tool":"","arguments":{ ... }}]} + Rules: + - Use only tools from the provided list (match server and tool names exactly). + - Provide arguments that satisfy each tool's input schema. + - If no tool is appropriate, return {"tool_calls":[]}. + - Do not wrap the JSON in markdown fences. + """; + private async Task> PlanToolCallsAsync(string task, CancellationToken ct) { - const string systemPrompt = """ - You are the Operator's planner — a tool-routing micro-agent. - Given a task and a list of available MCP tools, decide which tools to call. - Respond with STRICT JSON only, no prose, in exactly this shape: - {"tool_calls":[{"server":"","tool":"","arguments":{ ... }}]} - Rules: - - Use only tools from the provided list (match server and tool names exactly). - - Provide arguments that satisfy each tool's input schema. - - If no tool is appropriate, return {"tool_calls":[]}. - - Do not wrap the JSON in markdown fences. - """; - var toolsText = new StringBuilder(); foreach (var tool in _tools) toolsText.AppendLine($"- server=\"{tool.ServerName}\" tool=\"{tool.Name}\" description=\"{tool.Description}\" input_schema={tool.InputSchemaJson}"); @@ -218,7 +250,7 @@ Return the JSON plan now. string raw; try { - raw = await _model.AskAsync(systemPrompt, userPrompt, 0.1f, ct); + raw = await _model.AskAsync(PlannerSystemPrompt, userPrompt, 0.1f, ct); } catch { @@ -235,7 +267,7 @@ private static IReadOnlyList ParsePlan(string raw) try { - using var doc = JsonDocument.Parse(json); + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); if (!doc.RootElement.TryGetProperty("tool_calls", out var callsEl) || callsEl.ValueKind != JsonValueKind.Array) return []; @@ -364,4 +396,4 @@ private static string ExtractJson(string raw) _ => el.GetRawText() }; } -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 3d3fec5..3a19564 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -20,43 +20,43 @@ public sealed class ConsensusDebate : DebateScenario /// public override string Description => "Collaborative debate: Perspectives → Common Ground → Consensus → Facilitator"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -76,12 +76,13 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); - var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); + var round1StartedAt = DateTime.UtcNow; + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); + var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op, startedAt: round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 2: KK update var r2Ki = new List(); @@ -106,12 +107,13 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Identify: 1) Points of Agreement 2) Points of Disagreement 3) Bridge Proposals 4) Your Updated Position """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); - var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); + var round2StartedAt = DateTime.UtcNow; + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); + var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op, startedAt: round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 3: KK update var r3Ki = new List(); @@ -136,21 +138,35 @@ and propose bridges. Be open to changing your position. {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Formulate: 1) Agreed points 2) Proposed unified answer 3) Remaining disagreements 4) Confidence (Low/Medium/High) """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); - var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); + var round3StartedAt = DateTime.UtcNow; + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); + var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op, startedAt: round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { // Round 4: Chairman Facilitator - if (maxRounds >= 4 && chairman is not null) + if (chairman is not null) try { - var verdict = await Chairman.SynthesizeVerdictAsync(chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + var round4StartedAt = DateTime.UtcNow; + var verdict = await Chairman.SynthesizeVerdictAsync(chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(verdict); var round4 = CreateRound(4, "Consensus Facilitator", "The Chairman documents the consensus outcome.", - new Dictionary { [chairman.DisplayName] = verdict }); + new Dictionary { [chairman.DisplayName] = verdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -159,11 +175,6 @@ and propose bridges. Be open to changing your position. builder.SetFinalVerdict($"[FACILITATOR ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 7d4fa61..ce9e7b3 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -20,43 +20,43 @@ public sealed class CritiqueDebate : DebateScenario /// public override string Description => "Adversarial debate: Position → Critique → Defence → Judge Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing appended to participant system prompts. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing appended to participant system prompts. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; if (chairman is not null) builder.SetOpeningStatement(await Chairman.OpenDebateAsync(chairman, context, members, StrategyName, maxRounds, temperature, ct)); @@ -76,12 +76,13 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions - var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); - var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); + var round1StartedAt = DateTime.UtcNow; + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, executionOptions, ct); + var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op, startedAt: round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 2: KK update var r2Ki = new List(); @@ -106,12 +107,13 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} For each response: 1) Weakest argument 2) Logical fallacies 3) Counter-example 4) Quality (1-10) """; - var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); - var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); + var round2StartedAt = DateTime.UtcNow; + var r2 = await CollectResponsesAsync(members, r2Sys, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, executionOptions, ct); + var round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op, startedAt: round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // Round 3: KK update var r3Ki = new List(); @@ -136,21 +138,35 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Defend your position: 1) Address each criticism 2) Strengthen weak points 3) Concede where right 4) Final answer """; - var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); - var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); + var round3StartedAt = DateTime.UtcNow; + var r3 = await CollectResponsesAsync(members, r3Sys, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, executionOptions, ct); + var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op, startedAt: round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { // Round 4: Judge verdict - if (maxRounds >= 4 && chairman is not null) + if (chairman is not null) try { - var verdict = await Chairman.SynthesizeVerdictAsync(chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + var round4StartedAt = DateTime.UtcNow; + var verdict = await Chairman.SynthesizeVerdictAsync(chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(verdict); var round4 = CreateRound(4, "Judge's Verdict", "The Chairman judges the debate.", - new Dictionary { [chairman.DisplayName] = verdict }); + new Dictionary { [chairman.DisplayName] = verdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -159,11 +175,6 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants builder.SetFinalVerdict($"[JUDGE ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Debate/DebateResultBuilder.cs b/src/Delibera.Core/Debate/DebateResultBuilder.cs index c50cc68..a668c32 100644 --- a/src/Delibera.Core/Debate/DebateResultBuilder.cs +++ b/src/Delibera.Core/Debate/DebateResultBuilder.cs @@ -21,6 +21,7 @@ internal sealed class DebateResultBuilder( private string? _openingStatement; public string StrategyName => strategy.StrategyName; + public PromptContext Context => context; public IReadOnlyList Rounds => _rounds; public void SetOpeningStatement(string? statement) diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index cf364da..210ccbc 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -10,39 +10,39 @@ namespace Delibera.Core.Debate; /// public abstract class DebateScenario : IDebateStrategyWithOptions { - // ────────────────────────────────────────────── - // Operator helpers - // ────────────────────────────────────────────── - - /// - /// Marker participants use to delegate a task to the Operator, e.g.: - /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. - /// - private static readonly Regex OperatorRequestRegex = - new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", - RegexOptions.Singleline | - RegexOptions.IgnoreCase | - RegexOptions.Compiled); - - /// - public abstract string StrategyName { get; } - - /// - public abstract string Description { get; } - - /// - public abstract Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + // ────────────────────────────────────────────── + // Operator helpers + // ────────────────────────────────────────────── + + /// + /// Marker participants use to delegate a task to the Operator, e.g.: + /// [[OPERATOR: search the web for the latest .NET 10 release notes]]. + /// + private static readonly Regex OperatorRequestRegex = + new(@"\[\[\s*OPERATOR\s*:\s*(?.+?)\]\]", + RegexOptions.Singleline | + RegexOptions.IgnoreCase | + RegexOptions.Compiled); + + /// + public abstract string StrategyName { get; } + + /// + public abstract string Description { get; } - /// + /// + public abstract Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); + + /// public abstract Task ExecuteAsync( IReadOnlyList members, PromptContext context, @@ -99,7 +99,7 @@ protected static async Task> CollectResponsesAsync( responses[key] = response; } - + return responses; } @@ -124,7 +124,7 @@ protected static string FormatAllRounds(IReadOnlyList rounds) return string.Join("\n\n", rounds.Select(FormatRoundResponses)); } - /// Creates a completed debate round. + /// Creates a completed debate round with an explicit start time. protected static DebateRound CreateRound( int number, string name, @@ -132,7 +132,8 @@ protected static DebateRound CreateRound( Dictionary responses, string? prompt = null, IReadOnlyList? knowledgeInteractions = null, - IReadOnlyList? operatorInteractions = null) + IReadOnlyList? operatorInteractions = null, + DateTime? startedAt = null) { return new DebateRound { @@ -143,6 +144,7 @@ protected static DebateRound CreateRound( RoundPrompt = prompt, KnowledgeInteractions = knowledgeInteractions ?? [], OperatorInteractions = operatorInteractions ?? [], + StartedAt = startedAt ?? DateTime.UtcNow, CompletedAt = DateTime.UtcNow }; } @@ -233,77 +235,83 @@ Only delegate when external information or actions (web search, database lookup, """; } - /// - /// Scans participant responses for Operator request markers, executes each delegated - /// task via the Operator, and returns the recorded interactions. - /// - /// Operator instance (may be null). - /// Participant responses keyed by display name. - /// Cancellation token. - /// Operator interactions produced during this round. - protected static async Task> ProcessOperatorRequestsAsync( - Operator? @operator, - IReadOnlyDictionary responses, - CancellationToken ct = default) - { - return await ProcessOperatorRequestsAsync(@operator, responses, DebateExecutionOptions.Default, ct); - } - - /// - /// Scans participant responses for Operator request markers, executes each delegated - /// task via the Operator (in parallel, bounded by - /// ), and returns the - /// recorded interactions. - /// - protected static async Task> ProcessOperatorRequestsAsync( - Operator? @operator, - IReadOnlyDictionary responses, - DebateExecutionOptions executionOptions, - CancellationToken ct = default) - { - if (@operator is null || responses.Count == 0) return []; - - // Collect every (member, task) pair across all responses first. - var pending = new List<(string Member, string Task)>(); - foreach (var (member, response) in responses) - { - if (string.IsNullOrWhiteSpace(response)) continue; - - foreach (Match match in OperatorRequestRegex.Matches(response)) - { - var task = match.Groups["task"].Value.Trim(); - if (string.IsNullOrWhiteSpace(task)) continue; - pending.Add((member, task)); - } - } - - if (pending.Count == 0) return []; - - var interactions = new List(pending.Count); - var parallelOpts = executionOptions.ToParallelOptions(ct); - - // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the - // delegated Operator tasks. Results are collected in a thread-safe list. - await Parallel.ForEachAsync( - pending, - parallelOpts, - async (item, token) => - { - try - { - var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); - var interaction = result.ToInteraction(); - lock (interactions) interactions.Add(interaction); - } - catch (Exception ex) - { - lock (interactions) interactions.Add(new OperatorInteraction( - item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); - } - }); - - return interactions; - } + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator, and returns the recorded interactions. + /// + /// Operator instance (may be null). + /// Participant responses keyed by display name. + /// Cancellation token. + /// Operator interactions produced during this round. + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + CancellationToken ct = default) + { + return await ProcessOperatorRequestsAsync(@operator, responses, DebateExecutionOptions.Default, ct); + } + + /// + /// Scans participant responses for Operator request markers, executes each delegated + /// task via the Operator (in parallel, bounded by + /// ), and returns the + /// recorded interactions. + /// + protected static async Task> ProcessOperatorRequestsAsync( + Operator? @operator, + IReadOnlyDictionary responses, + DebateExecutionOptions executionOptions, + CancellationToken ct = default) + { + if (@operator is null || responses.Count == 0) return []; + + // Collect every (member, task) pair across all responses first. + var pending = new List<(string Member, string Task)>(); + foreach (var (member, response) in responses) + { + if (string.IsNullOrWhiteSpace(response)) continue; + + foreach (Match match in OperatorRequestRegex.Matches(response)) + { + var task = match.Groups["task"].Value.Trim(); + if (string.IsNullOrWhiteSpace(task)) continue; + pending.Add((member, task)); + } + } + + if (pending.Count == 0) return []; + + var interactions = new List(pending.Count); + var parallelOpts = executionOptions.ToParallelOptions(ct); + + // Parallel.ForEachAsync gives us a concurrent, optionally-bounded execution of the + // delegated Operator tasks. Results are collected in a thread-safe list. + await Parallel.ForEachAsync( + pending, + parallelOpts, + async (item, token) => + { + try + { + var result = await @operator.ExecuteTaskAsync(item.Member, item.Task, token); + var interaction = result.ToInteraction(); + lock (interactions) + { + interactions.Add(interaction); + } + } + catch (Exception ex) + { + lock (interactions) + { + interactions.Add(new OperatorInteraction( + item.Member, item.Task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); + } + } + }); + + return interactions; + } /// /// Formats Operator interactions into a context block that can be injected into the diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index ea48952..7fa1b65 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -24,43 +24,43 @@ public sealed class StandardDebate : DebateScenario /// public override string Description => "4-round debate: Initial → Critique → Improved → Chairman Verdict"; - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); - } - - /// - public override async Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(members); - ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); - var fullUserPrompt = context.GetFullUserPrompt(); - - // Operator briefing is appended to participant system prompts so they know what tools exist. - var operatorBriefing = BuildOperatorBriefing(@operator); - var baseSystemPrompt = context.SystemPrompt + operatorBriefing; + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return await ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + DebateExecutionOptions.Default, maxRounds, temperature, onRoundCompleted, ct); + } + + /// + public override async Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(members); + ArgumentNullException.ThrowIfNull(context); + var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper, @operator); + var fullUserPrompt = context.GetFullUserPrompt(); + + // Operator briefing is appended to participant system prompts so they know what tools exist. + var operatorBriefing = BuildOperatorBriefing(@operator); + var baseSystemPrompt = context.SystemPrompt + operatorBriefing; // ── Chairman opening ── if (chairman is not null) @@ -82,15 +82,16 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; - var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); - // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); + var round1StartedAt = DateTime.UtcNow; + var r1Responses = await CollectResponsesAsync(members, baseSystemPrompt, r1Prompt, temperature, ct); + // Operator: fulfil any [[OPERATOR: ...]] requests raised in round 1 (parallel, bounded). + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, executionOptions, ct); var round1 = CreateRound(1, "Initial Responses", - "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); + "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op, round1StartedAt); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); - if (maxRounds < 2) return BuildAndComplete(builder); + if (maxRounds < 2) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // ── Round 2: Knowledge Keeper contextual update ── var r2Ki = new List(); @@ -120,14 +121,15 @@ Be constructive but thorough. Provide your detailed critique of each response. """; - var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); - var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); + var round2StartedAt = DateTime.UtcNow; + var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, executionOptions, ct); var round2 = CreateRound(2, "Critique", - "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); + "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op, round2StartedAt); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); - if (maxRounds < 3) return BuildAndComplete(builder); + if (maxRounds < 3) return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); // ── Round 3: Knowledge Keeper contextual update ── var r3Ki = new List(); @@ -160,24 +162,37 @@ and synthesise the most comprehensive response possible. Provide your final, improved answer. """; - var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); - var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); + var round3StartedAt = DateTime.UtcNow; + var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, executionOptions, ct); var round3 = CreateRound(3, "Final Improved Responses", - "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); + "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op, round3StartedAt); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); - // ══════════ Round 4: Chairman Verdict ══════════ - if (maxRounds >= 4 && chairman is not null) + return await FinalizeAsync(builder, chairman, knowledgeKeeper, temperature, onRoundCompleted, ct); + } + + private static async Task FinalizeAsync( + DebateResultBuilder builder, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + float temperature, + Action? onRoundCompleted, + CancellationToken ct) + { + if (chairman is not null) try { + var round4StartedAt = DateTime.UtcNow; var finalVerdict = await Chairman.SynthesizeVerdictAsync( - chairman, context, builder.Rounds, knowledgeKeeper, temperature, ct); + chairman, builder.Context, builder.Rounds, knowledgeKeeper, temperature, ct); builder.SetFinalVerdict(finalVerdict); var round4 = CreateRound(4, "Chairman Verdict", "The Chairman synthesises the final verdict.", - new Dictionary { [chairman.DisplayName] = finalVerdict }); + new Dictionary { [chairman.DisplayName] = finalVerdict }, + startedAt: round4StartedAt); builder.AddRound(round4); onRoundCompleted?.Invoke(round4); } @@ -186,11 +201,6 @@ and synthesise the most comprehensive response possible. builder.SetFinalVerdict($"[CHAIRMAN ERROR: {ex.Message}]"); } - return BuildAndComplete(builder); - } - - private static DebateResult BuildAndComplete(DebateResultBuilder builder) - { builder.MarkCompleted(); return builder.Build(); } diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index c74dbe6..6568d80 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,21 +12,21 @@ Delibera.Core - 10.2.0 + 10.2.2 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, Microsoft.Extensions.Logging, 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, Microsoft.Extensions.Logging, Microsoft.Extensions.Http.Resilience (Polly v8 retry pipelines), IHttpClientFactory wiring, response-language enforcement, and parallel Operator requests. Victor Buzin Techbuzzz Delibera Copyright © 2026 Techbuzzz - llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection + llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection;resilience;polly;retry;httpclient README.md icon.png MIT https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.2.0 — Microsoft.Extensions.Logging support (inject ILogger/ILoggerFactory), response-language enforcement (WithResponseLanguage/ResponseLanguage), parallel Operator requests (WithMaxDegreeOfParallelism/MaxDegreeOfParallelism), renamed LogLevel enum to ExecutionLogLevel to avoid clash with Microsoft.Extensions.Logging.LogLevel. Fully backward compatible (legacy ExecuteAsync overloads still work). + v10.2.2 — Microsoft.Extensions.Http.Resilience integration (Polly v8 ResiliencePipeline): every HTTP-backed provider (Ollama, YandexGPT, MCP HTTP transport) now flows through IHttpClientFactory with a keyed Polly v8 retry pipeline. Hand-rolled retry loops in providers have been removed in favour of named pipelines ("Delibera.Local", "Delibera.Cloud", "Delibera.Default") registered via AddDelibera(). Configure via CouncilOptions.Resilience (MaxRetryAttempts, BaseDelay, MaxDelay, BackoffType, UseJitter, RetryableStatusCodes) or register custom named pipelines via AddDeliberaResiliencePipeline. New AddDeliberaHttpClient / WithHttpClientName() helpers for consumer wiring. Fully backward compatible: legacy constructors without a factory still work (they create a default HttpClient without resilience). true snupkg true @@ -39,15 +39,17 @@ - - - - - - - - - + + + + + + + + + + + diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index a5431ae..06efe75 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -18,34 +18,37 @@ public sealed class CouncilOptions /// Default generation temperature (0.0–2.0). public float Temperature { get; set; } = 0.7f; - /// Default system prompt for all participants. - public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be written in the specified human language, regardless of the language used - /// in the prompt or retrieved RAG context. - /// - /// - /// - /// Set to a language name the model recognises (e.g. "Russian", "English", - /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into - /// every system and user prompt: "You MUST answer exclusively in {language}. - /// Never use any other language." - /// - /// - /// Leave null or empty to let the model pick a language from context (legacy - /// behaviour). - /// - /// - public string? ResponseLanguage { get; set; } - - /// - /// Optional maximum degree of parallelism for operations that can run concurrently - /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper - /// queries). 0 means "unbounded" (default). - /// - public int MaxDegreeOfParallelism { get; set; } + /// Default system prompt for all participants. + public string SystemPrompt { get; set; } = "You are a helpful AI assistant participating in a council debate."; + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be written in the specified human language, regardless of the language used + /// in the prompt or retrieved RAG context. + /// + /// + /// + /// Set to a language name the model recognises (e.g. "Russian", "English", + /// "Spanish", "中文"). When non-empty, Delibera injects a strict directive into + /// every system and user prompt: + /// + /// "You MUST answer exclusively in {language}. + /// Never use any other language." + /// + /// + /// + /// Leave null or empty to let the model pick a language from context (legacy + /// behaviour). + /// + /// + public string? ResponseLanguage { get; set; } + + /// + /// Optional maximum degree of parallelism for operations that can run concurrently + /// within a debate round (e.g. Operator task delegation, parallel Knowledge Keeper + /// queries). 0 means "unbounded" (default). + /// + public int MaxDegreeOfParallelism { get; set; } /// Provider configuration options. public ProviderOptions Providers { get; set; } = new(); @@ -61,6 +64,83 @@ public sealed class CouncilOptions /// Output configuration options. public OutputOptions Output { get; set; } = new(); + + /// + /// Polly v8 resilience options applied to every HTTP-backed provider + /// (Ollama, YandexGPT, MCP HTTP transport) through the named pipelines + /// registered by AddDelibera. + /// + public ResilienceOptions Resilience { get; set; } = new(); +} + +/// +/// Configuration options for the Polly v8 resilience pipelines consumed by +/// Delibera's HTTP-backed providers. Bound from the +/// Delibera:Resilience configuration section. +/// +/// +/// +/// Delibera registers three named pipelines out of the box: +/// "Delibera.Local", "Delibera.Cloud", and +/// "Delibera.Default". Each one is a Polly v8 +/// ResiliencePipeline<HttpResponseMessage> built from +/// HttpRetryStrategyOptions. +/// The Local pipeline retries only connection-level failures (no status +/// code); the Cloud pipeline retries transient HTTP responses (429, 524, +/// 5xx) plus timeouts; the Default pipeline is an alias for whichever +/// of the two is more permissive. +/// +/// +/// Register custom pipelines with +/// services.AddDeliberaResiliencePipeline("MyKey", builder => ...) +/// and reference them from a provider by passing the same name to its +/// constructor. +/// +/// +public sealed class ResilienceOptions +{ + /// Default pipeline name used when a provider is constructed without an explicit name. + public const string DefaultPipelineName = "Delibera.Default"; + + /// Pipeline name used for Ollama-local / direct-on-host endpoints. + public const string LocalPipelineName = "Delibera.Local"; + + /// Pipeline name used for cloud-hosted LLM gateways (Ollama Cloud, Yandex Cloud, MCP HTTP). + public const string CloudPipelineName = "Delibera.Cloud"; + + /// Master switch — when false no retry pipeline is attached and HttpClients behave as plain . + public bool Enabled { get; set; } = true; + + /// Maximum number of retry attempts (the initial call counts as the first attempt). + public int MaxRetryAttempts { get; set; } = 3; + + /// Base delay used by the exponential back-off generator. + public TimeSpan BaseDelay { get; set; } = TimeSpan.FromSeconds(2); + + /// Upper bound on the back-off delay produced by the exponential generator. + public TimeSpan MaxDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// Whether to apply random jitter to the back-off delay. + public bool UseJitter { get; set; } = true; + + /// + /// Back-off style — either "Exponential" (default), + /// "Linear", or "Constant". Case-insensitive. + /// + public string BackoffType { get; set; } = "Exponential"; + + /// + /// HTTP status codes (e.g. 429, 500, 524) that should be retried on the + /// cloud pipeline. The local pipeline always retries only when the request has no status code + /// (i.e. connection-level failure). Defaults to { 408, 429, 500, 502, 503, 504, 524 }. + /// + public int[] RetryableStatusCodes { get; set; } = [408, 429, 500, 502, 503, 504, 524]; + + /// + /// Per-attempt timeout applied inside the pipeline (in addition to the outer HttpClient + /// timeout). Set to (or TimeSpan.Zero) to disable. + /// + public TimeSpan AttemptTimeout { get; set; } = TimeSpan.Zero; } /// diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index 0574c87..2a61383 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,11 +3,14 @@ using Delibera.Core.Providers; using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; +using Delibera.Core.Resilience; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Http.Resilience; +using Polly; namespace Delibera.Core.DependencyInjection; @@ -16,117 +19,234 @@ namespace Delibera.Core.DependencyInjection; /// public static class ServiceCollectionExtensions { - /// - /// Registers core Delibera services with default options. - /// - /// The service collection. - /// The service collection for chaining. - /// - /// Registers: - /// - /// (singleton) - /// (singleton) - /// (singleton) - /// (transient) - /// - /// - public static IServiceCollection AddDelibera(this IServiceCollection services) - { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddTransient(); - - return services; - } - - /// - /// Registers core Delibera services and binds from configuration. - /// - /// The service collection. - /// Configuration root or section containing council settings. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - string sectionName = CouncilOptions.SectionName) - { - services.AddDelibera(); - - var section = configuration.GetSection(sectionName); - if (section.Exists()) services.Configure(section); - - return services; - } - - /// - /// Registers core Delibera services with a custom options configuration delegate. - /// - /// The service collection. - /// Delegate to configure . - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - Action configureOptions) - { - ArgumentNullException.ThrowIfNull(configureOptions); - services.AddDelibera(); - services.Configure(configureOptions); - - return services; - } - - /// - /// Registers core Delibera services and wires the framework into the host's - /// . A resolved from the - /// container is automatically decorated with a logger, so any debate started via DI - /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the - /// in-memory collection. - /// - /// The service collection. - /// Configuration root or section. - /// Host logger factory. - /// Configuration section name (default: "Delibera"). - /// The service collection for chaining. - public static IServiceCollection AddDelibera( - this IServiceCollection services, - IConfiguration configuration, - ILoggerFactory loggerFactory, - string sectionName = CouncilOptions.SectionName) - { - ArgumentNullException.ThrowIfNull(loggerFactory); - services.AddDelibera(configuration, sectionName); - services.TryAddSingleton(loggerFactory); - - // Replace the transient builder registration so every resolved ICouncilBuilder - // gets a logger injected automatically. Consumers who build the executor themselves - // can still call WithLogger(...) explicitly to override. - services.Replace(ServiceDescriptor.Transient(sp => - { - var builder = new CouncilBuilder(); - var lf = sp.GetService(); - if (lf is not null) - builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); - return builder; - })); - - return services; - } + /// + /// Registers core Delibera services with default options (no IHttpClientFactory, no resilience). + /// + /// The service collection. + /// The service collection for chaining. + /// + /// Registers: + /// + /// (singleton) + /// (singleton) + /// (singleton) + /// (transient) + /// + /// To enable Polly v8 retry pipelines call AddDeliberaResilience(IServiceCollection) after this method. + /// + public static IServiceCollection AddDelibera(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddTransient(); + + return services; + } /// - /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera - /// (). + /// Registers core Delibera services and binds from configuration. + /// + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + string sectionName = CouncilOptions.SectionName) + { + services.AddDelibera(); + + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); + + // ResilienceOptions is a sub-section; bind it independently so + // IOptionsMonitor gets a typed configuration that + // AddDeliberaResilience can read. + var resilienceSection = configuration.GetSection($"{sectionName}:Resilience"); + if (resilienceSection.Exists()) + services.Configure(resilienceSection); + + return services; + } + + /// + /// Registers core Delibera services with a custom options configuration delegate. + /// + public static IServiceCollection AddDelibera( + this IServiceCollection services, + Action configureOptions) + { + ArgumentNullException.ThrowIfNull(configureOptions); + services.AddDelibera(); + services.Configure(configureOptions); + + return services; + } + + /// + /// Registers core Delibera services and wires the framework into the host's + /// . A resolved from the + /// container is automatically decorated with a logger, so any debate started via DI + /// logs to the host's pipeline (console, file, OpenTelemetry, …) in addition to the + /// in-memory collection. + /// + public static IServiceCollection AddDelibera( + this IServiceCollection services, + IConfiguration configuration, + ILoggerFactory loggerFactory, + string sectionName = CouncilOptions.SectionName) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + services.AddDelibera(configuration, sectionName); + services.TryAddSingleton(loggerFactory); + + // Replace the transient builder registration so every resolved ICouncilBuilder + // gets a logger injected automatically. Consumers who build the executor themselves + // can still call WithLogger(...) explicitly to override. + services.Replace(ServiceDescriptor.Transient(sp => + { + var builder = new CouncilBuilder(); + var lf = sp.GetService(); + if (lf is not null) + builder.WithLogger(lf.CreateLogger("Delibera.Core.Council")); + return builder; + })); + + return services; + } + + /// + /// Registers together with named Polly v8 + /// retry pipelines (Delibera.Local, Delibera.Cloud, Delibera.Default). + /// Also registers named entries that wire each pipeline into the + /// HttpClient handler chain via the standard Microsoft.Extensions.Http.Resilience AddResilienceHandler extension. /// + /// The service collection. + /// Optional delegate to override defaults. + /// The service collection for chaining. /// - /// Lets you wire any Microsoft.Extensions.AI backend (OpenAI, Azure OpenAI, Ollama, local - /// OpenAI-compatible servers) into the container and consume it through Delibera's provider - /// abstraction. The factory delegate may compose a middleware pipeline (function invocation, - /// logging, caching) before returning the client. + /// Call this after AddDelibera(...) and after binding . + /// The named HttpClients exposed are: + /// + /// Delibera.Ollama.Local / Delibera.Ollama.Cloud — base address must be set by the caller. + /// Delibera.YandexGPT + /// Delibera.Mcp.{ServerName} — registered lazily by the MCP factory. + /// /// + public static IServiceCollection AddDeliberaResilience( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + // Configure options if the caller supplied a delegate. + if (configure is not null) + services.Configure(configure); + + // Register the pipeline factory (and any consumer-supplied custom pipelines). + services.AddDeliberaResilienceCore(customPipelines: null); + + // Register the three built-in HttpClients with Polly resilience handlers attached. + AddNamedHttpClient(services, "Delibera.Ollama.Local", ResilienceOptions.LocalPipelineName); + AddNamedHttpClient(services, "Delibera.Ollama.Cloud", ResilienceOptions.CloudPipelineName); + AddNamedHttpClient(services, "Delibera.YandexGPT", ResilienceOptions.CloudPipelineName); + + return services; + } + + /// + /// Registers a single named whose handler chain is decorated with a + /// Polly v8 configured from + /// . + /// /// The service collection. - /// Factory that builds the chat client (optionally with middleware). - /// Optional friendly provider name surfaced by . + /// Logical client name (e.g. "Delibera.YandexGPT"). + /// + /// Pipeline key — currently used for telemetry only. The actual retry behaviour is + /// derived from at HttpClient creation time. + /// + /// Optional configuration delegate. + /// The for further chaining. + public static IHttpClientBuilder AddDeliberaHttpClient( + this IServiceCollection services, + string name, + string pipelineName = ResilienceOptions.DefaultPipelineName, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var builder = services.AddHttpClient(name, configure ?? (_ => { })); + + builder.AddResilienceHandler(pipelineName, (pipelineBuilder, context) => + { + // Resolve the live ResilienceOptions snapshot so option changes are honoured. + var monitor = context.ServiceProvider.GetService>(); + var opts = monitor is not null + ? (pipelineName == ResilienceOptions.LocalPipelineName || pipelineName == ResilienceOptions.CloudPipelineName + ? monitor.Get(ResilienceOptions.DefaultPipelineName) + : monitor.CurrentValue) + : new ResilienceOptions(); + + if (!opts.Enabled) + return; // Empty pipeline = no retries. + + // The "Delibera.Local" pipeline retries only on connection-level failures; + // everything else (Cloud, Default, custom) retries on the configured status codes. + var statusCodes = pipelineName == ResilienceOptions.LocalPipelineName + ? null + : opts.RetryableStatusCodes is { Length: > 0 } ? opts.RetryableStatusCodes : null; + + var retry = new HttpRetryStrategyOptions + { + Name = pipelineName, + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter + }; + + if (statusCodes is null) + { + retry.ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(); + } + else + { + var set = new HashSet(statusCodes); + retry.ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => set.Contains((int)r.StatusCode)); + } + + pipelineBuilder.AddRetry(retry); + }); + + return builder; + } + + private static void AddNamedHttpClient(IServiceCollection services, string name, string pipelineName) + { + services.AddDeliberaHttpClient(name, pipelineName); + } + + private static DelayBackoffType ParseBackoffType(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return DelayBackoffType.Exponential; + return value.Trim().ToLowerInvariant() switch + { + "constant" => DelayBackoffType.Constant, + "linear" => DelayBackoffType.Linear, + _ => DelayBackoffType.Exponential + }; + } + + /// + /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera + /// (). + /// public static IServiceCollection AddDeliberaChatClient( this IServiceCollection services, Func chatClientFactory, @@ -147,9 +267,6 @@ public static IServiceCollection AddDeliberaChatClient( /// Registers an already-constructed Microsoft.Extensions.AI and exposes it /// as a Delibera . /// - /// The service collection. - /// The chat client instance. - /// Optional friendly provider name. public static IServiceCollection AddDeliberaChatClient( this IServiceCollection services, IChatClient chatClient, @@ -163,10 +280,6 @@ public static IServiceCollection AddDeliberaChatClient( /// Registers a Microsoft.Extensions.AI and exposes /// it as a Delibera (). /// - /// The service collection. - /// Factory that builds the embedding generator. - /// Optional friendly model name. - /// Optional known vector dimensionality. public static IServiceCollection AddDeliberaEmbeddingGenerator( this IServiceCollection services, Func>> generatorFactory, diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs index 906b577..810194f 100644 --- a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -1,7 +1,8 @@ using System.Runtime.CompilerServices; using Delibera.Core.Providers.LLM; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; + +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change namespace Delibera.Core.Extensions; diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index 645d75d..57a0cf9 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -146,40 +146,40 @@ ICouncilBuilder WithCompression( /// This builder for fluent chaining. ICouncilBuilder WithTemperature(float temperature); - /// Sets the output path for saving the debate result as Markdown. - /// File path for Markdown output. - /// This builder for fluent chaining. - ICouncilBuilder SaveResultTo(string outputPath); - - /// - /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) - /// to be in the specified language. Pass null or empty to disable language - /// enforcement and let the model pick a language from context. - /// - /// - /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). - /// - /// This builder for fluent chaining. - ICouncilBuilder WithResponseLanguage(string? language); - - /// - /// Sets the maximum degree of parallelism for operations that can run concurrently - /// within a debate round (Operator task delegation, parallel Knowledge Keeper - /// queries). Pass 0 for unbounded parallelism (default). - /// - /// Max concurrent operations per round (0 = unbounded). - /// This builder for fluent chaining. - ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); - - /// - /// Attaches an used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// Pass null to disable structured logging (legacy behaviour — only the - /// OnLog event and the collection are populated). - /// - /// Logger instance, or null to clear. - /// This builder for fluent chaining. - ICouncilBuilder WithLogger(ILogger? logger); + /// Sets the output path for saving the debate result as Markdown. + /// File path for Markdown output. + /// This builder for fluent chaining. + ICouncilBuilder SaveResultTo(string outputPath); + + /// + /// Forces every model response (participants, Chairman, Knowledge Keeper, Operator) + /// to be in the specified language. Pass null or empty to disable language + /// enforcement and let the model pick a language from context. + /// + /// + /// Language name the model recognises (e.g. "Russian", "English", "Spanish"). + /// + /// This builder for fluent chaining. + ICouncilBuilder WithResponseLanguage(string? language); + + /// + /// Sets the maximum degree of parallelism for operations that can run concurrently + /// within a debate round (Operator task delegation, parallel Knowledge Keeper + /// queries). Pass 0 for unbounded parallelism (default). + /// + /// Max concurrent operations per round (0 = unbounded). + /// This builder for fluent chaining. + ICouncilBuilder WithMaxDegreeOfParallelism(int maxDegreeOfParallelism); + + /// + /// Attaches an used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// Pass null to disable structured logging (legacy behaviour — only the + /// OnLog event and the collection are populated). + /// + /// Logger instance, or null to clear. + /// This builder for fluent chaining. + ICouncilBuilder WithLogger(ILogger? logger); /// /// Validates configuration and builds an . diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index 10e0add..09f79b8 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Delibera.Core.Models; namespace Delibera.Core.Interfaces; @@ -9,31 +8,31 @@ namespace Delibera.Core.Interfaces; /// public interface ICouncilExecutor { - /// Council participants. - IReadOnlyList Members { get; } + /// Council participants. + IReadOnlyList Members { get; } - /// Chairman (may be null). - CouncilMember? Chairman { get; } + /// Chairman (may be null). + CouncilMember? Chairman { get; } - /// Knowledge Keeper (may be null). - KnowledgeKeeper? KnowledgeKeeper { get; } + /// Knowledge Keeper (may be null). + KnowledgeKeeper? KnowledgeKeeper { get; } - /// Operator (may be null). - Operator? Operator { get; } + /// Operator (may be null). + Operator? Operator { get; } - /// Debate strategy. - IDebateStrategy Strategy { get; } + /// Debate strategy. + IDebateStrategy Strategy { get; } - /// Context compressor (may be null if compression is disabled). - IContextCompressor? Compressor { get; } + /// Context compressor (may be null if compression is disabled). + IContextCompressor? Compressor { get; } - /// - /// Optional used by the executor to surface progress - /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. - /// When null, only the event and the - /// collection are populated. - /// - ILogger? Logger { get; } + /// + /// Optional used by the executor to surface progress + /// (Chairman actions, rounds, compression, errors, …) to a host's logging pipeline. + /// When null, only the event and the + /// collection are populated. + /// + ILogger? Logger { get; } /// /// Execution logs collected during the debate. diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index 1ce3c5f..56b8e65 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -1,5 +1,4 @@ using Delibera.Core.Council; -using Microsoft.Extensions.Logging; namespace Delibera.Core.Interfaces; @@ -9,63 +8,63 @@ namespace Delibera.Core.Interfaces; /// public interface IDebateStrategy { - /// Unique strategy name. - string StrategyName { get; } + /// Unique strategy name. + string StrategyName { get; } - /// Human-readable strategy description. - string Description { get; } + /// Human-readable strategy description. + string Description { get; } - /// - /// Executes the full debate cycle according to this strategy. - /// - /// Council participants. - /// Prompt context (system / user prompt, knowledge). - /// Chairman for moderation and verdict synthesis (may be null). - /// Knowledge Keeper for RAG queries (may be null). - /// Operator micro-agent for tool/MCP delegation (may be null). - /// Maximum number of rounds. - /// Generation temperature. - /// Callback invoked after each round. - /// Cancellation token. - /// Complete debate result. - Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Executes the full debate cycle according to this strategy. + /// + /// Council participants. + /// Prompt context (system / user prompt, knowledge). + /// Chairman for moderation and verdict synthesis (may be null). + /// Knowledge Keeper for RAG queries (may be null). + /// Operator micro-agent for tool/MCP delegation (may be null). + /// Maximum number of rounds. + /// Generation temperature. + /// Callback invoked after each round. + /// Cancellation token. + /// Complete debate result. + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); - /// - /// Executes the full debate cycle with an extra - /// bundle (response-language directive, parallelism budget, ). - /// - /// - /// The default implementation forwards to the legacy overload, ignoring - /// . Concrete strategies shipped with Delibera - /// (, , - /// ) override this to honour the options. - /// Custom strategies only need to override this overload to participate in - /// language enforcement and parallelism tuning. - /// - Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default) - { - return ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, - maxRounds, temperature, onRoundCompleted, ct); - } + /// + /// Executes the full debate cycle with an extra + /// bundle (response-language directive, parallelism budget, ). + /// + /// + /// The default implementation forwards to the legacy overload, ignoring + /// . Concrete strategies shipped with Delibera + /// (, , + /// ) override this to honour the options. + /// Custom strategies only need to override this overload to participate in + /// language enforcement and parallelism tuning. + /// + Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default) + { + return ExecuteAsync(members, context, chairman, knowledgeKeeper, @operator, + maxRounds, temperature, onRoundCompleted, ct); + } } /// @@ -76,19 +75,19 @@ Task ExecuteAsync( /// public interface IDebateStrategyWithOptions : IDebateStrategy { - /// - /// Executes the debate with (response language, - /// parallelism budget, logger). Strategies override this to honour the options. - /// - new Task ExecuteAsync( - IReadOnlyList members, - PromptContext context, - CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper, - Operator? @operator, - DebateExecutionOptions executionOptions, - int maxRounds = 4, - float temperature = 0.7f, - Action? onRoundCompleted = null, - CancellationToken ct = default); + /// + /// Executes the debate with (response language, + /// parallelism budget, logger). Strategies override this to honour the options. + /// + new Task ExecuteAsync( + IReadOnlyList members, + PromptContext context, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index d58e65c..f3d501e 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Interfaces; /// diff --git a/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs b/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs index 012a2e8..c4b2fab 100644 --- a/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs +++ b/src/Delibera.Core/Interfaces/ILLMProviderFactory.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Configuration; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Interfaces; /// diff --git a/src/Delibera.Core/Models/CouncilMember.cs b/src/Delibera.Core/Models/CouncilMember.cs index 297cc63..59626b9 100644 --- a/src/Delibera.Core/Models/CouncilMember.cs +++ b/src/Delibera.Core/Models/CouncilMember.cs @@ -7,8 +7,6 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin { private readonly string? _personaPrompt = personaPrompt; - private string _role = role ?? "Expert"; - /// Unique participant identifier. public string Id { get; } = $"{provider.ProviderName}:{modelName}:{Guid.NewGuid():N}".ToLowerInvariant(); @@ -22,11 +20,7 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin public ILLMProvider Provider { get; } = provider ?? throw new ArgumentNullException(nameof(provider)); /// Role in the debate (Expert, Critic, Chairman, etc.). - public string Role - { - get => field ?? "Expert"; - set => field = value ?? "Expert"; - } + public string Role { get; set; } = role ?? "Expert"; /// Optional persona system-prompt that personalises the model's behaviour. public string? PersonaPrompt { get; set; } diff --git a/src/Delibera.Core/Models/DebateExecutionOptions.cs b/src/Delibera.Core/Models/DebateExecutionOptions.cs index 764bf6c..a2a8ce7 100644 --- a/src/Delibera.Core/Models/DebateExecutionOptions.cs +++ b/src/Delibera.Core/Models/DebateExecutionOptions.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Logging; - namespace Delibera.Core.Models; /// diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index b45b1d5..bd1aeb9 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -78,15 +78,15 @@ public static ExecutionLog Error(string source, string message) /// Maps this execution-log level to the equivalent /// used by . /// - public Microsoft.Extensions.Logging.LogLevel ToMicrosoftLogLevel() + public LogLevel ToMicrosoftLogLevel() { return Level switch { - ExecutionLogLevel.Trace => Microsoft.Extensions.Logging.LogLevel.Trace, - ExecutionLogLevel.Info => Microsoft.Extensions.Logging.LogLevel.Information, - ExecutionLogLevel.Warning => Microsoft.Extensions.Logging.LogLevel.Warning, - ExecutionLogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error, - _ => Microsoft.Extensions.Logging.LogLevel.None + ExecutionLogLevel.Trace => LogLevel.Trace, + ExecutionLogLevel.Info => LogLevel.Information, + ExecutionLogLevel.Warning => LogLevel.Warning, + ExecutionLogLevel.Error => LogLevel.Error, + _ => LogLevel.None }; } diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs index 6287fd8..0c8276b 100644 --- a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -1,6 +1,8 @@ using System.Runtime.CompilerServices; using Microsoft.Extensions.AI; +#pragma warning disable IDE1006 // 'LLM' acronym kept all-caps by convention; renaming is a breaking API change + namespace Delibera.Core.Providers.LLM; /// @@ -44,10 +46,10 @@ public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null _ownsClient = ownsClient; var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; - ProviderName = providerName ?? - (string.IsNullOrWhiteSpace(metadata?.ProviderName) - ? "ChatClient" - : metadata!.ProviderName!); + var metadataName = !string.IsNullOrWhiteSpace(metadata?.ProviderName) + ? metadata.ProviderName + : "ChatClient"; + ProviderName = providerName ?? metadataName; DefaultModelId = metadata?.DefaultModelId; } @@ -97,7 +99,7 @@ public async Task ChatAsync( try { var response = await ChatClient.GetResponseAsync(messages, options, ct); - var text = response.Text?.Trim() ?? string.Empty; + var text = response.Text.Trim(); if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); return text; @@ -135,13 +137,12 @@ public async IAsyncEnumerable ChatStreamAsync( } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsClient) ChatClient.Dispose(); - GC.SuppressFinalize(this); - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsClient) ChatClient.Dispose(); + } private static List BuildMessages(string systemPrompt, string userPrompt) { diff --git a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs index dba685f..4ae3174 100644 --- a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs +++ b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs @@ -42,10 +42,10 @@ public EmbeddingGeneratorProvider( _ownsGenerator = ownsGenerator; var metadata = generator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata; - EmbeddingModelName = modelName ?? - (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) - ? "embedding" - : metadata!.DefaultModelId!); + var metadataName = !string.IsNullOrWhiteSpace(metadata?.DefaultModelId) + ? metadata.DefaultModelId + : "embedding"; + EmbeddingModelName = modelName ?? metadataName; _cachedVectorSize = vectorSize ?? metadata?.DefaultModelDimensions; } @@ -53,13 +53,12 @@ public EmbeddingGeneratorProvider( public IEmbeddingGenerator> Generator { get; } /// - public void Dispose() - { - if (_disposed) return; - _disposed = true; - if (_ownsGenerator) Generator.Dispose(); - GC.SuppressFinalize(this); - } + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsGenerator) Generator.Dispose(); + } /// public string EmbeddingModelName { get; } diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index 22a2d55..3675f2a 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -1,3 +1,5 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Resilience; using Microsoft.Extensions.AI; using OllamaSharp; using OllamaSharp.Models; @@ -6,42 +8,144 @@ namespace Delibera.Core.Providers.LLM; +/// +/// Connection mode for an . +/// +public enum OllamaConnectionMode +{ + /// Talking to a local Ollama server (no API key, no Cloudflare, no 524). + Local, + + /// Talking to Ollama Cloud (API key required, behind Cloudflare, may return 524/429). + Cloud +} + /// /// Ollama LLM provider backed by OllamaSharp. -/// Works with both Ollama Cloud (API key) and a local Ollama server. +/// Works with both a local Ollama server and Ollama Cloud. /// +/// +/// +/// Use ForLocal for a local server (e.g. http://localhost:11434) and +/// ForCloud for Ollama Cloud (e.g. https://api.ollama.com) with an API key. +/// +/// +/// Transient failures are retried by a Polly v8 +/// obtained from the +/// registered in DI. +/// Local mode uses the +/// pipeline +/// (connection-level retries only); cloud mode uses +/// +/// (HTTP 408/429/500/502/503/504/524 retries + connection failures). +/// When constructed without DI the pipeline is a no-op — the legacy behaviour +/// before v10.2.2 was no retry at all, so the public surface is fully +/// backward-compatible. +/// +/// public sealed class OllamaProvider : ILLMProvider { + private static readonly TimeSpan DefaultCloudTimeout = TimeSpan.FromMinutes(5); + private static readonly TimeSpan DefaultLocalTimeout = TimeSpan.FromMinutes(10); + + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; + private readonly Polly.ResiliencePipeline? _pipeline; private bool _disposed; + /// Creates an Ollama provider. The mode is inferred from : non-empty selects cloud. + public OllamaProvider(string endpoint, string apiKey = "", TimeSpan? timeout = null) + : this(endpoint, apiKey, timeout, httpClientFactory: null, resilienceProvider: null, + httpClientName: null, pipelineName: null, + mode: string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud) + { + } + /// - /// Creates an Ollama provider. + /// Creates an Ollama provider wired to an and a Polly v8 + /// resilience pipeline. When is null the + /// provider creates its own (no factory reuse, no resilience) — + /// this preserves the v10.2.x behaviour for callers that don't use DI. /// - /// Ollama endpoint URL (e.g., "https://api.ollama.com" or "http://localhost:11434"). - /// API key for Ollama Cloud (empty for local server). - public OllamaProvider(string endpoint, string apiKey = "") + /// Ollama endpoint URL. + /// API key (empty for local server). + /// HTTP timeout (null = default per mode). + /// Optional factory for handler pooling and socket reuse. + /// Optional pipeline registry (null = no retries). + /// Logical HttpClient name; defaults to Delibera.Ollama.{Mode}. + /// Pipeline key; defaults to Local or Cloud pipeline by mode. + /// Explicit connection mode override (legacy ForLocal/ForCloud only). + public OllamaProvider( + string endpoint, + string apiKey, + TimeSpan? timeout, + IHttpClientFactory? httpClientFactory, + IDeliberaResiliencePipelineProvider? resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + OllamaConnectionMode? mode = null) { ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); var uri = new Uri(endpoint.TrimEnd('/')); + Mode = mode ?? (string.IsNullOrWhiteSpace(apiKey) ? OllamaConnectionMode.Local : OllamaConnectionMode.Cloud); + var effectiveTimeout = timeout ?? (Mode == OllamaConnectionMode.Cloud ? DefaultCloudTimeout : DefaultLocalTimeout); + + var resolvedHttpClientName = !string.IsNullOrWhiteSpace(httpClientName) + ? httpClientName + : $"Delibera.Ollama.{Mode}"; + var resolvedPipelineName = !string.IsNullOrWhiteSpace(pipelineName) + ? pipelineName + : (Mode == OllamaConnectionMode.Cloud + ? ResilienceOptions.CloudPipelineName + : ResilienceOptions.LocalPipelineName); - if (!string.IsNullOrWhiteSpace(apiKey)) + OllamaApiClient client; + if (httpClientFactory is not null) { - var httpClient = new HttpClient { BaseAddress = uri, Timeout = TimeSpan.FromMinutes(5) }; - httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); - Client = new OllamaApiClient(httpClient); + // Build the HttpClient through the factory — its underlying + // handler pipeline already has the Polly resilience handler + // attached via AddResilienceHandler. The provider itself doesn't + // own this HttpClient (the factory does), so Dispose skips it. + _httpClientFactory = httpClientFactory; + _httpClientName = resolvedHttpClientName; + _pipeline = resilienceProvider?.GetOperationPipeline(resolvedPipelineName); + + var http = _httpClientFactory.CreateClient(resolvedHttpClientName); + http.BaseAddress ??= uri; + http.Timeout = effectiveTimeout; + if (Mode == OllamaConnectionMode.Cloud && !string.IsNullOrWhiteSpace(apiKey)) + http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey.Trim()); + + client = new OllamaApiClient(http); } else { - Client = new OllamaApiClient(uri); + _httpClientFactory = null; + _httpClientName = null; + _pipeline = null; + + var httpClient = new HttpClient { BaseAddress = uri, Timeout = effectiveTimeout }; + if (Mode == OllamaConnectionMode.Cloud && !string.IsNullOrWhiteSpace(apiKey)) + httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey.Trim()}"); + client = new OllamaApiClient(httpClient); } + + Client = client; } - /// Provides access to the underlying OllamaSharp client (used by ). + /// The connection mode this provider was configured with. + public OllamaConnectionMode Mode { get; } + + /// + /// Provides access to the underlying OllamaSharp client (used by ). + /// internal OllamaApiClient Client { get; } /// - public string ProviderName => "Ollama"; + public string ProviderName => Mode == OllamaConnectionMode.Cloud + ? "OllamaCloud" + : "Ollama"; /// public async Task IsAvailableAsync(CancellationToken ct = default) @@ -91,34 +195,78 @@ public async Task ChatAsync( { Model = model, Messages = messages, - Options = new RequestOptions { Temperature = temperature }, - Stream = false + Options = new RequestOptions { Temperature = temperature } }; - try + // The chat operation is owned by OllamaSharp (it streams response chunks). + // When a Polly v8 pipeline is configured we wrap the entire streaming + // call: on a transient failure Polly cancels the current attempt and + // starts a fresh one — OllamaSharp re-streams from the beginning. This + // is the standard v8 pattern for non-idempotent-friendly streams, and + // matches the v10.2 hand-rolled behaviour (which also restarted the + // whole stream on a retry). + string? captured = null; + Func operation = async token => { var sb = new StringBuilder(); - await foreach (var chunk in Client.ChatAsync(request, ct)) - if (chunk?.Message?.Content is not null) - sb.Append(chunk.Message.Content); + await foreach (var chunk in Client.ChatAsync(request, token)) + { + if (chunk is not { Message.Content: { } content }) + continue; + sb.Append(content); + } var response = sb.ToString().Trim(); if (string.IsNullOrWhiteSpace(response)) throw new InvalidOperationException($"Empty response from model '{model}'."); - return response; + captured = response; + }; + + try + { + if (_pipeline is null) + { + await operation(ct).ConfigureAwait(false); + return captured ?? throw new InvalidOperationException($"Empty response from model '{model}'."); + } + + // Build a context-scoped pipeline invocation. Polly v8 wraps + // exceptions in a ResilienceException only when the pipeline + // re-throws after exhausting retries; HttpRequestException and + // TaskCanceledException pass straight through unchanged. + var context = Polly.ResilienceContextPool.Shared.Get(ct); + try + { + await _pipeline.ExecuteAsync( + static async (ctx, state) => + { + // Pipeline's ShouldHandle predicate already filters which + // exceptions (HttpRequestException, TaskCanceledException) + // trigger a retry. Anything else propagates immediately. + await state.Op(ctx.CancellationToken).ConfigureAwait(false); + }, + context, + new ChatState(operation)).ConfigureAwait(false); + } + finally + { + Polly.ResilienceContextPool.Shared.Return(context); + } + + return captured ?? throw new InvalidOperationException($"Empty response from model '{model}'."); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; } catch (HttpRequestException ex) { throw new InvalidOperationException($"HTTP error talking to Ollama (model: {model}): {ex.Message}", ex); } - catch (TaskCanceledException) when (ct.IsCancellationRequested) + catch (TimeoutException) { throw; } - catch (TaskCanceledException ex) - { - throw new TimeoutException($"Request to Ollama model '{model}' timed out.", ex); - } } /// @@ -126,19 +274,77 @@ public void Dispose() { if (_disposed) return; _disposed = true; - GC.SuppressFinalize(this); + // When constructed via IHttpClientFactory, the factory owns the HttpClient. + // When constructed standalone, Client (which owns the HttpClient) is disposed here. + if (_httpClientFactory is null) + Client.Dispose(); + } + + /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). + public static OllamaProvider ForLocal(string endpoint, TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + return new OllamaProvider(endpoint, "", timeout, null, null, + httpClientName: null, pipelineName: null, + mode: OllamaConnectionMode.Local); + } + + /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com) with an API key. + public static OllamaProvider ForCloud(string endpoint, string apiKey, TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + return new OllamaProvider(endpoint, apiKey, timeout, null, null, + httpClientName: null, pipelineName: null, + mode: OllamaConnectionMode.Cloud); + } + + /// + /// Creates a DI-friendly local Ollama provider with handler pooling and the local retry pipeline. + /// + public static OllamaProvider ForLocal( + string endpoint, + IHttpClientFactory httpClientFactory, + IDeliberaResiliencePipelineProvider resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(resilienceProvider); + return new OllamaProvider(endpoint, "", timeout, + httpClientFactory, resilienceProvider, + httpClientName, pipelineName, + mode: OllamaConnectionMode.Local); + } + + /// + /// Creates a DI-friendly cloud Ollama provider with handler pooling and the cloud retry pipeline. + /// + public static OllamaProvider ForCloud( + string endpoint, + string apiKey, + IHttpClientFactory httpClientFactory, + IDeliberaResiliencePipelineProvider resilienceProvider, + string? httpClientName = null, + string? pipelineName = null, + TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(resilienceProvider); + return new OllamaProvider(endpoint, apiKey, timeout, + httpClientFactory, resilienceProvider, + httpClientName, pipelineName, + mode: OllamaConnectionMode.Cloud); } /// /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI /// . /// - /// - /// natively implements , so this lets - /// the - /// Ollama provider plug into the standard Microsoft.Extensions.AI middleware pipeline - /// (function invocation, caching, telemetry). - /// public IChatClient AsChatClient() { return Client; @@ -151,4 +357,6 @@ public IEmbeddingGenerator> AsEmbeddingGenerator() { return Client; } -} \ No newline at end of file + + private sealed record ChatState(Func Op); +} diff --git a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs index b10d289..2d75b00 100644 --- a/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs +++ b/src/Delibera.Core/Providers/LLM/YandexGptProvider.cs @@ -1,6 +1,7 @@ using System.Net.Http.Headers; using System.Text.Json; using System.Text.Json.Serialization; +using Delibera.Core.Resilience; namespace Delibera.Core.Providers.LLM; @@ -10,10 +11,20 @@ namespace Delibera.Core.Providers.LLM; /// as a council member or chairman alongside Ollama / OpenAI models. /// /// -/// Supports both the OpenAI Responses-compatible gateway -/// (https://ai.api.cloud.yandex.net/v1/responses) and the legacy -/// Foundation Models endpoint. The gateway is selected automatically -/// when FolderId is non-empty. +/// +/// Supports both the OpenAI Responses-compatible gateway +/// (https://ai.api.cloud.yandex.net/v1/responses) and the legacy +/// Foundation Models endpoint. The gateway is selected automatically +/// when FolderId is non-empty. +/// +/// +/// Transient failures (connection drops, 429/5xx, Cloudflare 524) are +/// retried by a Polly v8 resilience pipeline obtained from the +/// registered in DI +/// (default pipeline: Delibera.Cloud). +/// When the provider is constructed without DI the pipeline is a no-op +/// — the same behavior as the previous manual-error path. +/// /// public sealed class YandexGptProvider : ILLMProvider { @@ -28,13 +39,16 @@ public sealed class YandexGptProvider : ILLMProvider private readonly string _endpoint; private readonly string _folderId; private readonly HttpClient _http; + private readonly Polly.ResiliencePipeline? _pipeline; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; private readonly string _legacyEndpoint; private readonly int _maxOutputTokens; - private readonly float _temperature; private bool _disposed; /// - /// Creates a YandexGPT provider. + /// Creates a YandexGPT provider with no resilience pipeline. Errors + /// propagate directly to the caller — backward-compatible with v10.2. /// /// Yandex Cloud API key. /// Yandex Cloud folder id (empty for legacy Bearer endpoint). @@ -49,6 +63,48 @@ public YandexGptProvider( string legacyEndpoint = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion", float temperature = 0.3f, int maxOutputTokens = 4000) + : this(apiKey, folderId, endpoint, legacyEndpoint, temperature, maxOutputTokens, + httpClientFactory: null, resilienceProvider: null, httpClientName: null, pipelineName: null) + { + } + + /// + /// Creates a YandexGPT provider wired to an and a Polly v8 + /// . Transient failures are retried automatically. + /// + /// Yandex Cloud API key. + /// Yandex Cloud folder id (empty for legacy Bearer endpoint). + /// OpenAI-compatible responses endpoint. + /// Legacy Foundation Models completion endpoint. + /// Default sampling temperature. + /// Maximum output tokens per request. + /// + /// Optional factory that produces the configured . + /// When null the provider creates its own HttpClient (no factory reuse, no resilience). + /// + /// + /// Optional pipeline registry. When null the provider falls back to a no-op pipeline + /// (errors propagate without retries). + /// + /// + /// Logical name used with . When null defaults to + /// "Delibera.YandexGPT". + /// + /// + /// Pipeline key looked up from . + /// When null defaults to Delibera.Cloud. + /// + public YandexGptProvider( + string apiKey, + string? folderId, + string endpoint, + string legacyEndpoint, + float temperature, + int maxOutputTokens, + IHttpClientFactory? httpClientFactory, + IDeliberaResiliencePipelineProvider? resilienceProvider, + string? httpClientName = null, + string? pipelineName = null) { ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); @@ -56,8 +112,28 @@ public YandexGptProvider( _folderId = folderId ?? string.Empty; _endpoint = endpoint.TrimEnd('/'); _legacyEndpoint = legacyEndpoint.TrimEnd('/'); - _temperature = temperature; _maxOutputTokens = maxOutputTokens; + _ = temperature; // accepted for API compatibility; per-call temperature is passed to ChatAsync + + var name = string.IsNullOrWhiteSpace(httpClientName) ? "Delibera.YandexGPT" : httpClientName; + var pipeline = resilienceProvider?.GetPipeline(pipelineName) ?? Polly.ResiliencePipeline.Empty; + + if (httpClientFactory is not null) + { + _httpClientFactory = httpClientFactory; + _httpClientName = name; + _pipeline = pipeline; + // Lazily resolved through HttpClientFactory inside ChatAsync so the + // factory owns the handler/timeout/lifetime. We still hold a local + // "anchor" HttpClient for IsAvailableAsync (liveness probe) and as + // fallback when no factory is registered. + } + else + { + _httpClientFactory = null; + _httpClientName = null; + _pipeline = null; + } _http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; @@ -90,7 +166,7 @@ public async Task IsAvailableAsync(CancellationToken ct = default) using var request = new HttpRequestMessage(HttpMethod.Get, UseNewEndpoint ? _endpoint : _legacyEndpoint); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + using var response = await SendAsync(request, ct).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch @@ -143,13 +219,21 @@ public async Task ChatAsync( } else { - requestBody = new YandexCompletionRequest( - model, - new CompletionOptions(false, temperature, _maxOutputTokens), - [ - new Message("system", systemPrompt), - new Message("user", userPrompt) - ]); + requestBody = new Dictionary + { + ["modelUri"] = model, + ["completionOptions"] = new Dictionary + { + ["stream"] = false, + ["temperature"] = temperature, + ["maxTokens"] = _maxOutputTokens + }, + ["messages"] = new[] + { + new Dictionary { ["role"] = "system", ["text"] = systemPrompt }, + new Dictionary { ["role"] = "user", ["text"] = userPrompt } + } + }; endpoint = _legacyEndpoint; headers = BuildLegacyAuthHeaders(); } @@ -165,7 +249,7 @@ public async Task ChatAsync( foreach (var h in headers) request.Headers.TryAddWithoutValidation(h.Key, h.Value); - using var response = await _http.SendAsync(request, ct).ConfigureAwait(false); + using var response = await SendAsync(request, ct).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -201,12 +285,28 @@ public async Task ChatAsync( } /// - public void Dispose() + public void Dispose() { if (_disposed) return; _disposed = true; - _http.Dispose(); - GC.SuppressFinalize(this); + if (_httpClientFactory is null) + _http.Dispose(); + } + + /// + /// Issues the request through either the DI-managed HttpClient (when configured) or the + /// locally-owned . Retries are applied only when the DI path is active. + /// + private async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (_httpClientFactory is not null && _pipeline is not null) + { + var client = _httpClientFactory.CreateClient(_httpClientName!); + return await client.SendAsync(_pipeline, request, HttpCompletionOption.ResponseContentRead, ct) + .ConfigureAwait(false); + } + + return await _http.SendAsync(request, ct).ConfigureAwait(false); } private void AddAuthHeaders(HttpRequestHeaders headers) @@ -260,25 +360,4 @@ private static string ExtractLegacyShape(JsonElement root) "{}"; } - private record YandexCompletionRequest( - [property: JsonPropertyName("modelUri")] - string ModelUri, - [property: JsonPropertyName("completionOptions")] - CompletionOptions CompletionOptions, - [property: JsonPropertyName("messages")] - Message[] Messages - ); - - private record CompletionOptions( - [property: JsonPropertyName("stream")] bool Stream, - [property: JsonPropertyName("temperature")] - float Temperature, - [property: JsonPropertyName("maxTokens")] - int MaxTokens - ); - - private record Message( - [property: JsonPropertyName("role")] string Role, - [property: JsonPropertyName("text")] string Text - ); -} \ No newline at end of file +} diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs index 9190dce..e831ae5 100644 --- a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -1,3 +1,5 @@ +using Delibera.Core.Resilience; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -8,20 +10,57 @@ namespace Delibera.Core.Providers.Mcp; /// ModelContextProtocol C# SDK. Supports both stdio (local child process) and /// HTTP/SSE (remote) MCP servers, selected via . /// +/// +/// +/// When constructed through DI the adapter wires its HTTP transport into +/// the host's (logical name +/// "Delibera.Mcp.{ServerName}") so retries/circuit-breakers configured +/// via Microsoft.Extensions.Http.Resilience apply to MCP traffic. +/// When constructed standalone the transport owns its own +/// without resilience. +/// +/// public sealed class McpClientAdapter : IMcpClient { private readonly McpServerConfig _config; + private readonly IHttpClientFactory? _httpClientFactory; + private readonly string? _httpClientName; private McpClient? _client; private bool _disposed; - /// Creates an adapter for the given MCP server configuration. + /// Creates a standalone adapter for the given MCP server configuration (no DI, no resilience). /// Server connection configuration. public McpClientAdapter(McpServerConfig config) + : this(config, httpClientFactory: null, httpClientName: null, loggerFactory: null) + { + } + + /// Creates an adapter that routes its HTTP transport through an . + /// Server connection configuration. + /// Factory that produces the configured . + /// + /// Logical client name (default: Delibera.Mcp.{ServerName}). + /// The host must register the client with AddHttpClient(name) and any + /// resilience handlers attached via AddResilienceHandler. + /// + /// Optional logger factory passed to the MCP transport. + public McpClientAdapter( + McpServerConfig config, + IHttpClientFactory? httpClientFactory, + string? httpClientName = null, + ILoggerFactory? loggerFactory = null) { _config = config ?? throw new ArgumentNullException(nameof(config)); ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + _httpClientFactory = httpClientFactory; + _httpClientName = string.IsNullOrWhiteSpace(httpClientName) + ? $"Delibera.Mcp.{config.Name}" + : httpClientName; + LoggerFactory = loggerFactory; } + private ILoggerFactory? LoggerFactory { get; } + /// public string ServerName => _config.Name; @@ -33,7 +72,7 @@ public async Task ConnectAsync(CancellationToken ct = default) { if (_client is not null) return; - var transport = CreateTransport(_config); + var transport = CreateTransport(_config, _httpClientFactory, _httpClientName, LoggerFactory); _client = await McpClient.CreateAsync(transport, cancellationToken: ct); } @@ -47,7 +86,7 @@ public async Task> ListToolsAsync(CancellationToken .Select(t => new OperatorTool( ServerName, t.Name, - t.Description ?? string.Empty, + t.Description, SchemaToJson(t))) .ToList() .AsReadOnly(); @@ -112,12 +151,16 @@ private static string SchemaToJson(McpClientTool tool) } } - private static IClientTransport CreateTransport(McpServerConfig config) + private static IClientTransport CreateTransport( + McpServerConfig config, + IHttpClientFactory? factory, + string? httpClientName, + ILoggerFactory? loggerFactory) { return config.TransportType switch { McpTransportType.Stdio => CreateStdioTransport(config), - McpTransportType.Http => CreateHttpTransport(config), + McpTransportType.Http => CreateHttpTransport(config, factory, httpClientName, loggerFactory), _ => throw new NotSupportedException($"Unsupported MCP transport: {config.TransportType}") }; } @@ -143,7 +186,11 @@ private static StdioClientTransport CreateStdioTransport(McpServerConfig config) return new StdioClientTransport(options); } - private static HttpClientTransport CreateHttpTransport(McpServerConfig config) + private static HttpClientTransport CreateHttpTransport( + McpServerConfig config, + IHttpClientFactory? factory, + string? httpClientName, + ILoggerFactory? loggerFactory) { if (config.Endpoint is null) throw new InvalidOperationException( @@ -159,6 +206,17 @@ private static HttpClientTransport CreateHttpTransport(McpServerConfig config) options.AdditionalHeaders = config.AdditionalHeaders .ToDictionary(kv => kv.Key, kv => kv.Value); - return new HttpClientTransport(options); + if (factory is not null && !string.IsNullOrWhiteSpace(httpClientName)) + { + // Inject the DI-managed HttpClient (with its resilience pipeline + // attached via AddResilienceHandler). The transport disposes the + // HttpClient on shutdown because ownsHttpClient=true. + var httpClient = factory.CreateClient(httpClientName); + return new HttpClientTransport(options, httpClient, loggerFactory, ownsHttpClient: true); + } + + return loggerFactory is null + ? new HttpClientTransport(options) + : new HttpClientTransport(options, loggerFactory); } } \ No newline at end of file diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index 4a46425..091da22 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -31,13 +31,13 @@ public void Dispose() } /// All currently cached provider instances. - public IReadOnlyDictionary GetAllInstances() + protected IReadOnlyDictionary GetAllInstances() { return _instances; } /// Returns a cached instance by name, or null. - public TInstance? GetInstance(string name) + protected TInstance? GetInstance(string name) { return _instances.TryGetValue(name, out var p) ? p @@ -45,7 +45,7 @@ public IReadOnlyDictionary GetAllInstances() } /// Registers a builder for a new provider type (e.g., "OpenAI", "YandexGPT"). - public CachingFactory RegisterBuilder(string providerType, TBuilder builder) + protected CachingFactory RegisterBuilder(string providerType, TBuilder builder) { ArgumentNullException.ThrowIfNull(builder); _builders[providerType] = builder; @@ -138,10 +138,15 @@ public IReadOnlyDictionary GetAllProviders() return GetAllInstances(); } - /// Creates an Ollama provider with direct parameters. + /// + /// Creates an Ollama provider with direct parameters. The connection mode (local vs + /// cloud) is inferred from : non-empty selects cloud, + /// empty selects local. Prefer / + /// for explicit control. + /// public OllamaProvider CreateOllama(string endpoint, string apiKey = "") { - var key = $"ollama:{endpoint}"; + var key = $"ollama:{endpoint}:{(string.IsNullOrWhiteSpace(apiKey) ? "local" : "cloud")}"; if (GetInstance(key) is OllamaProvider existing) return existing; var provider = new OllamaProvider(endpoint, apiKey); @@ -149,6 +154,29 @@ public OllamaProvider CreateOllama(string endpoint, string apiKey = "") return provider; } + /// Creates a provider for a local Ollama server (e.g. http://localhost:11434). + public OllamaProvider CreateLocalOllama(string endpoint, TimeSpan? timeout = null) + { + var key = $"ollama:{endpoint}:local"; + if (GetInstance(key) is OllamaProvider existing) return existing; + + var provider = OllamaProvider.ForLocal(endpoint, timeout); + CacheInstance(key, provider); + return provider; + } + + /// Creates a provider for Ollama Cloud (e.g. https://api.ollama.com). + public OllamaProvider CreateCloudOllama(string endpoint, string apiKey, TimeSpan? timeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + var key = $"ollama:{endpoint}:cloud"; + if (GetInstance(key) is OllamaProvider existing) return existing; + + var provider = OllamaProvider.ForCloud(endpoint, apiKey, timeout); + CacheInstance(key, provider); + return provider; + } + /// /// Creates (or returns a cached) from any Microsoft.Extensions.AI /// . diff --git a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs index cb5e060..94ac2e5 100644 --- a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs +++ b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs @@ -6,7 +6,7 @@ namespace Delibera.Core.Providers.RAG; /// Factory for creating instances from configuration. /// Register custom builders to support additional vector databases. /// -public sealed class RagProviderFactory : CachingFactory, IRagProvider>, IRagProviderFactory, IAsyncDisposable +public sealed class RagProviderFactory : CachingFactory, IRagProvider>, IRagProviderFactory { /// /// Creates a new factory with the built-in Qdrant and PgVector builders registered. diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index 711f2b4..0442934 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -36,6 +36,7 @@ outcomes** rather than single-model guesses. - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding - 📋 **Execution Logging** — `ExecutionLog` model with `ExecutionLogLevel` for Chairman, KK, Compression & participants - 📝 **Microsoft.Extensions.Logging** — inject your own `ILogger`/`ILoggerFactory`; every debate event is forwarded to the host's logging pipeline (in addition to the in-memory `ExecutionLog` collection) +- 🛡️ **Polly v8 Resilience** — `IHttpClientFactory` + named `Microsoft.Extensions.Http.Resilience` retry pipelines (`Delibera.Local`, `Delibera.Cloud`, `Delibera.Default`) for transient HTTP failures (connection drops, 408/429/5xx, Cloudflare 524). Configure via `CouncilOptions.Resilience`. Custom pipelines registerable through `AddDeliberaResiliencePipeline(name, build)`. - 🌐 **Response Language Enforcement** — force every model (participants, Chairman, Knowledge Keeper, Operator) to answer in a specific language, regardless of the prompt or retrieved context - ⚡ **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 diff --git a/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs b/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs new file mode 100644 index 0000000..3cd57f6 --- /dev/null +++ b/src/Delibera.Core/Resilience/DeliberaResiliencePipelineProvider.cs @@ -0,0 +1,233 @@ +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Retry; + +namespace Delibera.Core.Resilience; + +/// +/// Central registry that builds the named Polly v8 +/// instances consumed by every +/// HTTP-backed Delibera provider. +/// +/// +/// +/// The factory reads its configuration from +/// (typically bound from the +/// Delibera:Resilience configuration section). It exposes +/// three pipelines out of the box: +/// +/// — retries only connection-level failures. +/// — retries transient HTTP responses (429, 524, 5xx) plus timeouts. +/// — convenience alias for the cloud pipeline (the more permissive of the two). +/// +/// +/// +/// Consumers can register additional named pipelines through +/// services.AddDeliberaResiliencePipeline("MyKey", b => …); the +/// factory merges them into its lookup table on first use. +/// +/// +public interface IDeliberaResiliencePipelineProvider +{ + /// + /// Returns the Polly v8 HTTP-level pipeline registered under . + /// Falls back to the default pipeline when is + /// null, empty, or unknown. + /// + /// + /// The pipeline key (one of the constants on , + /// or a custom name registered via + /// AddDeliberaResiliencePipeline). + /// + /// A non-null pipeline. Never throws on missing keys. + ResiliencePipeline GetPipeline(string? name); + + /// + /// Returns a Polly v8 non-generic operation-level pipeline (for use-cases where the + /// operation is a unit of work that doesn't produce an + /// result — e.g. OllamaProvider.ChatAsync streams text from OllamaSharp). + /// Shares the same configuration as but with no result-typed + /// retry predicates — only exception types (, + /// ) trigger retries. + /// + /// Pipeline key. + /// A non-generic or null when disabled. + ResiliencePipeline? GetOperationPipeline(string? name); +} + +/// +/// Default implementation of . +/// +public sealed class DeliberaResiliencePipelineProvider : IDeliberaResiliencePipelineProvider +{ + private readonly IOptionsMonitor _options; + private readonly Dictionary, ResiliencePipeline>> _customBuilders; + private readonly Dictionary> _built; + private readonly Dictionary _builtOperation; + private readonly object _gate = new(); + + /// Builds the provider from DI options + custom pipeline factories. + /// Bound resilience configuration. + /// Optional consumer-registered pipelines (may be null). + public DeliberaResiliencePipelineProvider( + IOptionsMonitor options, + IEnumerable, ResiliencePipeline>>>? customPipelines = null) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + _customBuilders = new Dictionary, ResiliencePipeline>>(StringComparer.OrdinalIgnoreCase); + _built = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _builtOperation = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (customPipelines is not null) + foreach (var (name, build) in customPipelines) + if (!string.IsNullOrWhiteSpace(name) && build is not null) + _customBuilders[name] = build; + } + + /// + public ResiliencePipeline GetPipeline(string? name) + { + var key = string.IsNullOrWhiteSpace(name) + ? ResilienceOptions.DefaultPipelineName + : name; + + if (_customBuilders.ContainsKey(key)) + return GetOrBuildCustom(key); + + return key switch + { + ResilienceOptions.LocalPipelineName => GetOrBuildBuilt(ResilienceOptions.LocalPipelineName, BuildLocal), + ResilienceOptions.CloudPipelineName => GetOrBuildBuilt(ResilienceOptions.CloudPipelineName, BuildCloud), + _ => GetOrBuildBuilt(ResilienceOptions.DefaultPipelineName, BuildCloud) + }; + } + + private ResiliencePipeline GetOrBuildCustom(string key) + { + lock (_gate) + { + if (_built.TryGetValue(key, out var cached)) + return cached; + var builder = new ResiliencePipelineBuilder(); + var pipeline = _customBuilders[key](builder); + _built[key] = pipeline; + return pipeline; + } + } + + private ResiliencePipeline GetOrBuildBuilt(string key, Func> build) + { + lock (_gate) + { + if (_built.TryGetValue(key, out var cached)) + return cached; + var opts = _options.Get(key); + var pipeline = build(opts); + _built[key] = pipeline; + return pipeline; + } + } + + private static ResiliencePipeline BuildLocal(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + // Local pipeline: retry only when the request never produced a + // response (connection-level failure or socket timeout). + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private ResiliencePipeline BuildCloud(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var set = new HashSet(opts.RetryableStatusCodes is { Length: > 0 } + ? opts.RetryableStatusCodes + : DefaultCloudStatusCodes); + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => set.Contains((int)r.StatusCode)) + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private static readonly int[] DefaultCloudStatusCodes = [408, 429, 500, 502, 503, 504, 524]; + + /// + public ResiliencePipeline? GetOperationPipeline(string? name) + { + var key = string.IsNullOrWhiteSpace(name) ? ResilienceOptions.DefaultPipelineName : name; + lock (_gate) + { + if (_builtOperation.TryGetValue(key, out var cached)) + return cached; + var opts = _options.Get(key); + var pipeline = BuildOperationPipeline(opts); + _builtOperation[key] = pipeline; + return pipeline; + } + } + + private static ResiliencePipeline BuildOperationPipeline(ResilienceOptions opts) + { + if (!opts.Enabled) + return ResiliencePipeline.Empty; + + var retry = new RetryStrategyOptions + { + MaxRetryAttempts = Math.Max(0, opts.MaxRetryAttempts), + Delay = opts.BaseDelay, + MaxDelay = opts.MaxDelay, + BackoffType = ParseBackoffType(opts.BackoffType), + UseJitter = opts.UseJitter, + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle() + }; + + return new ResiliencePipelineBuilder() + .AddRetry(retry) + .Build(); + } + + private static DelayBackoffType ParseBackoffType(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return DelayBackoffType.Exponential; + return value.Trim().ToLowerInvariant() switch + { + "constant" => DelayBackoffType.Constant, + "linear" => DelayBackoffType.Linear, + _ => DelayBackoffType.Exponential + }; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs b/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs new file mode 100644 index 0000000..59c4fda --- /dev/null +++ b/src/Delibera.Core/Resilience/DeliberaResilienceServiceCollectionExtensions.cs @@ -0,0 +1,109 @@ +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Polly; + +namespace Delibera.Core.Resilience; + +/// +/// Convenience extensions for registering Polly v8 resilience pipelines +/// with the Delibera DI container. +/// +public static class DeliberaResilienceServiceCollectionExtensions +{ + /// + /// Registers the singleton + /// and the consumer-supplied named pipelines alongside it. + /// + /// + /// Internal-use overload — the public surface is ServiceCollectionExtensions.AddDeliberaResilience. + /// + public static IServiceCollection AddDeliberaResilienceCore( + this IServiceCollection services, + IEnumerable, ResiliencePipeline>>>? customPipelines = null) + { + ArgumentNullException.ThrowIfNull(services); + + // Eagerly register the mutable collection so each + // AddDeliberaResiliencePipeline call can mutate the same instance. + services.TryAddSingleton(); + + services.TryAddSingleton(sp => + { + var monitor = sp.GetRequiredService>(); + var collection = sp.GetRequiredService(); + var sequences = new List, ResiliencePipeline>>>(collection.Snapshot()); + if (customPipelines is not null) + foreach (var kvp in customPipelines) + sequences.Add(kvp); + return new DeliberaResiliencePipelineProvider(monitor, sequences); + }); + + return services; + } + + /// + /// Registers a single Polly v8 pipeline under . + /// + /// The service collection. + /// + /// Pipeline key. When the same name matches one of the built-in keys + /// ( etc.) the + /// consumer pipeline replaces the built-in for that key. + /// + /// + /// Factory delegate that configures a and + /// returns the resulting pipeline. + /// + /// The service collection for chaining. + public static IServiceCollection AddDeliberaResiliencePipeline( + this IServiceCollection services, + string name, + Func, ResiliencePipeline> build) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(build); + + // Run during service-provider build so the collection singleton has + // been resolved and can be mutated. We use a marker singleton of + // ; its factory + // receives the collection and appends to it. + services.AddSingleton(sp => + { + var collection = sp.GetRequiredService(); + collection.Add(name, build); + return new DeliberaResiliencePipelineRegistration(name); + }); + return services; + } +} + +/// +/// Marker singleton returned by . +/// Side-effect (mutating the shared collection) is the goal; the instance is discarded. +/// +internal sealed record DeliberaResiliencePipelineRegistration(string Name); + +/// +/// Internal mutable collection of consumer-registered resilience pipelines. +/// Held as a singleton so multiple +/// calls accumulate their entries before the provider factory consumes them. +/// +internal sealed class DeliberaResiliencePipelineCollection +{ + private readonly object _gate = new(); + private readonly List, ResiliencePipeline>>> _entries = []; + + public void Add(string name, Func, ResiliencePipeline> build) + { + lock (_gate) + _entries.Add(new(name, build)); + } + + public IReadOnlyList, ResiliencePipeline>>> Snapshot() + { + lock (_gate) + return _entries.ToList(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs b/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs new file mode 100644 index 0000000..23acb2a --- /dev/null +++ b/src/Delibera.Core/Resilience/ResilientHttpClientExtensions.cs @@ -0,0 +1,82 @@ +using Polly; + +namespace Delibera.Core.Resilience; + +/// +/// Static helpers for running calls through a Polly v8 +/// . +/// +/// +/// Polly v8 works at the callback level rather than the transport level: the +/// pipeline is asked to ExecuteAsync a delegate that issues a single +/// HTTP request. The Delibera providers use this helper to opt-in to the +/// named pipeline registered through . +/// +public static class ResilientHttpClientExtensions +{ + /// + /// Sends a request through with retry semantics. + /// Cancellation is wired through both the + /// argument and the Polly v8 + /// . + /// + /// The HttpClient that owns the underlying transport. + /// + /// The Polly v8 pipeline. Pass + /// to disable resilience. + /// + /// The request message to send. + /// How the response content should be read. + /// Cancellation token. + /// The HTTP response message from the last (successful or final) attempt. + public static async Task SendAsync( + this HttpClient http, + ResiliencePipeline pipeline, + HttpRequestMessage request, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(http); + ArgumentNullException.ThrowIfNull(pipeline); + ArgumentNullException.ThrowIfNull(request); + + var context = ResilienceContextPool.Shared.Get(ct); + try + { + Func> callback = async (ctx, state) => + { + var token = ctx.CancellationToken; + // Clone the request for each attempt — HttpClient.SendAsync + // disposes the request on the first attempt, leaving retries + // unable to re-send. + var clone = await CloneAsync(state.Request, token).ConfigureAwait(false); + return await state.Http.SendAsync(clone, state.CompletionOption, token).ConfigureAwait(false); + }; + + return await pipeline.ExecuteAsync(callback, context, new HttpState(http, request, completionOption)).ConfigureAwait(false); + } + finally + { + ResilienceContextPool.Shared.Return(context); + } + } + + private static async ValueTask CloneAsync(HttpRequestMessage source, CancellationToken ct) + { + var clone = new HttpRequestMessage(source.Method, source.RequestUri); + if (source.Content is not null) + { + var buffer = await source.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + clone.Content = new ByteArrayContent(buffer); + foreach (var header in source.Content.Headers) + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + foreach (var header in source.Headers) + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + foreach (var prop in source.Options) + clone.Options.Set(new HttpRequestOptionsKey(prop.Key), prop.Value); + return clone; + } + + private sealed record HttpState(HttpClient Http, HttpRequestMessage Request, HttpCompletionOption CompletionOption); +} \ No newline at end of file diff --git a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj index 97b4a87..fee8758 100644 --- a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj +++ b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj @@ -11,6 +11,12 @@ + + + + + + diff --git a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs index 7aaddb6..95c296a 100644 --- a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs +++ b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs @@ -7,7 +7,7 @@ namespace Delibera.Core.Tests.Fakes; /// In-memory used to test the Microsoft.Extensions.AI integration /// without any network access. /// -public sealed class FakeChatClient(string reply = "fake-reply", string providerName = "Fake", string? defaultModel = "fake-model") +public sealed class FakeChatClient(string reply = "fake-reply", string providerName = "Fake", string? defaultModel = "fake-model", int delayMs = 0) : IChatClient { private readonly ChatClientMetadata _metadata = new(providerName, defaultModelId: defaultModel); @@ -20,14 +20,16 @@ public sealed class FakeChatClient(string reply = "fake-reply", string providerN /// Records the messages of the most recent request for assertions. public IList? LastMessages { get; private set; } - public Task GetResponseAsync( + public async Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { LastMessages = messages.ToList(); LastOptions = options; - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply))); + if (delayMs > 0) + await Task.Delay(delayMs, cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)); } public async IAsyncEnumerable GetStreamingResponseAsync( @@ -37,6 +39,8 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { LastMessages = messages.ToList(); LastOptions = options; + if (delayMs > 0) + await Task.Delay(delayMs, cancellationToken); // Emit one update per word to simulate token streaming. foreach (var word in reply.Split(' ')) { diff --git a/tests/Delibera.Core.Tests/FinalVerdictTests.cs b/tests/Delibera.Core.Tests/FinalVerdictTests.cs new file mode 100644 index 0000000..7248755 --- /dev/null +++ b/tests/Delibera.Core.Tests/FinalVerdictTests.cs @@ -0,0 +1,120 @@ +using Delibera.Core.Council; +using Delibera.Core.Debate; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class FinalVerdictTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public async Task StandardDebate_WithChairman_ProducesFinalVerdict_ForAnyMaxRounds(int maxRounds) + { + var provider = CreateProvider("standard-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + chairman, + null, + null, + maxRounds: maxRounds); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("standard-verdict"); + } + + [Fact] + public async Task StandardDebate_WithoutChairman_LeavesFinalVerdictEmpty() + { + var provider = CreateProvider("standard-verdict"); + var member = new CouncilMember("model", provider); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + null, + null, + null); + + result.FinalVerdict.Should().BeNullOrWhiteSpace(); + } + + [Fact] + public async Task CritiqueDebate_WithChairman_ProducesFinalVerdict() + { + var provider = CreateProvider("critique-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new CritiqueDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "Microservices vs monolith?" }, + chairman, + null, + null, + maxRounds: 3); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("critique-verdict"); + } + + [Fact] + public async Task ConsensusDebate_WithChairman_ProducesFinalVerdict() + { + var provider = CreateProvider("consensus-verdict"); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new ConsensusDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "Best project structure?" }, + chairman, + null, + null, + maxRounds: 2); + + result.FinalVerdict.Should().NotBeNullOrWhiteSpace(); + result.FinalVerdict.Should().StartWith("consensus-verdict"); + } + + [Fact] + public async Task StandardDebate_RoundDuration_IsNonZero_WhenRoundWorkTakesTime() + { + var provider = CreateProvider("standard-verdict", delayMs: 50); + var member = new CouncilMember("model", provider); + var chairman = new CouncilMember("chair", provider, "Chairman"); + + var debate = new StandardDebate(); + var result = await debate.ExecuteAsync( + [member], + new PromptContext { UserPrompt = "What is 2+2?" }, + chairman, + null, + null, + maxRounds: 4); + + foreach (var round in result.Rounds) + round.Duration.Should().BeGreaterThan(TimeSpan.Zero, $"Round {round.RoundNumber} should have non-zero duration"); + } + + private static ILLMProvider CreateProvider(string reply, int delayMs = 0) + { + var client = new FakeChatClient(reply: reply, delayMs: delayMs); + return new ChatClientLLMProvider(client, ownsClient: true); + } +} diff --git a/tests/Delibera.Core.Tests/ResilienceTests.cs b/tests/Delibera.Core.Tests/ResilienceTests.cs new file mode 100644 index 0000000..ebaee64 --- /dev/null +++ b/tests/Delibera.Core.Tests/ResilienceTests.cs @@ -0,0 +1,204 @@ +using Delibera.Core.DependencyInjection; +using Delibera.Core.Resilience; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Polly; + +namespace Delibera.Core.Tests; + +/// +/// Unit tests for the Polly v8 resilience integration: +/// defaults, +/// behaviour, and +/// the AddDeliberaResilience DI extension. +/// +public sealed class ResilienceTests +{ + [Fact] + public void ResilienceOptions_HasSensibleBuiltInDefaults() + { + var opts = new ResilienceOptions(); + + opts.Enabled.Should().BeTrue(); + opts.MaxRetryAttempts.Should().Be(3); + opts.BaseDelay.Should().Be(TimeSpan.FromSeconds(2)); + opts.MaxDelay.Should().Be(TimeSpan.FromSeconds(30)); + opts.UseJitter.Should().BeTrue(); + opts.BackoffType.Should().Be("Exponential"); + opts.RetryableStatusCodes.Should().BeEquivalentTo(new[] { 408, 429, 500, 502, 503, 504, 524 }); + } + + [Fact] + public void ResilienceOptions_ExposesStablePipelineNames() + { + ResilienceOptions.DefaultPipelineName.Should().Be("Delibera.Default"); + ResilienceOptions.LocalPipelineName.Should().Be("Delibera.Local"); + ResilienceOptions.CloudPipelineName.Should().Be("Delibera.Cloud"); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_ReturnsEmptyPipeline_WhenDisabled() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions { Enabled = false }).AsMonitor()); + + var p = provider.GetPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(p, ResiliencePipeline.Empty).Should().BeTrue(); + + var op = provider.GetOperationPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(op, ResiliencePipeline.Empty).Should().BeTrue(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_ReturnsNonEmptyPipeline_WhenEnabled() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor()); + + var cloud = provider.GetPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(cloud, ResiliencePipeline.Empty).Should().BeFalse(); + + var local = provider.GetPipeline(ResilienceOptions.LocalPipelineName); + ReferenceEquals(local, ResiliencePipeline.Empty).Should().BeFalse(); + + var op = provider.GetOperationPipeline(ResilienceOptions.CloudPipelineName); + ReferenceEquals(op, ResiliencePipeline.Empty).Should().BeFalse(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_FallsBackToDefault_WhenNameUnknown() + { + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor()); + + var resolved = provider.GetPipeline("non-existent-pipeline"); + var fallback = provider.GetPipeline(ResilienceOptions.DefaultPipelineName); + // Both should be valid (non-null) pipelines. + resolved.Should().NotBeNull(); + fallback.Should().NotBeNull(); + } + + [Fact] + public void DeliberaResiliencePipelineProvider_AcceptsCustomPipeline() + { + var customPipeline = new ResiliencePipelineBuilder() + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + Name = "MyCustom", + MaxRetryAttempts = 7 + }) + .Build(); + + var provider = new DeliberaResiliencePipelineProvider( + Microsoft.Extensions.Options.Options.Create(new ResilienceOptions()).AsMonitor(), + new[] + { + new KeyValuePair, ResiliencePipeline>>( + "Delibera.MyKey", + _ => customPipeline) + }); + + var resolved = provider.GetPipeline("Delibera.MyKey"); + resolved.Should().BeSameAs(customPipeline); + } + + [Fact] + public void AddDeliberaResilience_RegistersPipelineProviderAndNamedHttpClients() + { + var services = new ServiceCollection(); + services.AddDelibera(); + services.AddDeliberaResilience(opts => + { + opts.MaxRetryAttempts = 5; + opts.BaseDelay = TimeSpan.FromMilliseconds(250); + }); + + using var sp = services.BuildServiceProvider(); + + var registry = sp.GetRequiredService(); + registry.GetPipeline(ResilienceOptions.LocalPipelineName).Should().NotBeNull(); + registry.GetPipeline(ResilienceOptions.CloudPipelineName).Should().NotBeNull(); + + var factory = sp.GetRequiredService(); + factory.CreateClient("Delibera.Ollama.Local").Should().NotBeNull(); + factory.CreateClient("Delibera.Ollama.Cloud").Should().NotBeNull(); + factory.CreateClient("Delibera.YandexGPT").Should().NotBeNull(); + } + + [Fact] + public void AddDeliberaResilience_CustomPipeline_IsRegisteredAlongsideBuiltins() + { + var services = new ServiceCollection(); + services.AddDelibera(); + services.AddDeliberaResilience(); + + services.AddDeliberaResiliencePipeline("Delibera.MyCustom", b => b + .AddRetry(new Polly.Retry.RetryStrategyOptions + { + Name = "Delibera.MyCustom", + MaxRetryAttempts = 9 + }) + .Build()); + + using var sp = services.BuildServiceProvider(); + + var registry = sp.GetRequiredService(); + var custom = registry.GetPipeline("Delibera.MyCustom"); + custom.Should().NotBeNull(); + ReferenceEquals(custom, ResiliencePipeline.Empty).Should().BeFalse(); + } + + [Fact] + public void AddDeliberaResilience_RespectsBindFromConfiguration() + { + var dict = new Dictionary + { + ["Delibera:Resilience:MaxRetryAttempts"] = "7", + ["Delibera:Resilience:BaseDelay"] = "00:00:05", + ["Delibera:Resilience:UseJitter"] = "true", + ["Delibera:Resilience:RetryableStatusCodes:0"] = "429", + ["Delibera:Resilience:RetryableStatusCodes:1"] = "500" + }; + + var configuration = new Microsoft.Extensions.Configuration.ConfigurationBuilder() + .AddInMemoryCollection(dict) + .Build(); + + var services = new ServiceCollection(); + services.AddDelibera(configuration); + services.AddDeliberaResilience(); + + using var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + var opts = monitor.CurrentValue; + + opts.MaxRetryAttempts.Should().Be(7); + opts.BaseDelay.Should().Be(TimeSpan.FromSeconds(5)); + opts.UseJitter.Should().BeTrue(); + opts.RetryableStatusCodes.Should().Contain(new[] { 429, 500 }); + } +} + +/// +/// Small helper to convert into an +/// for tests where no +/// change-tracking is required. +/// +internal static class TestOptionsMonitorExtensions +{ + public static Microsoft.Extensions.Options.IOptionsMonitor AsMonitor(this Microsoft.Extensions.Options.IOptions options) + where T : class + { + return new StaticOptionsMonitor(options.Value); + } + + private sealed class StaticOptionsMonitor : Microsoft.Extensions.Options.IOptionsMonitor where T : class + { + public StaticOptionsMonitor(T value) => CurrentValue = value; + public T CurrentValue { get; } + public T Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; + } +} \ No newline at end of file