diff --git a/CHANGELOG.md b/CHANGELOG.md index f84b6b6..fedf229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ 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.0] - 2026 + +### Added — Microsoft.Extensions.Logging, response-language enforcement, parallel Operator + +- **Microsoft.Extensions.Logging support** — inject your own `ILogger` / `ILoggerFactory` and every + debate event (Chairman opening, Knowledge Keeper queries, compression, Operator interactions, + participant responses, errors) is forwarded to the host's logging pipeline. New APIs: + - `ICouncilBuilder.WithLogger(ILogger?)` and `CouncilBuilder.WithLogger(...)`. + - `ICouncilExecutor.Logger` property exposing the configured `ILogger`. + - `AddDelibera(IServiceCollection, IConfiguration, ILoggerFactory, string?)` DI overload that + auto-decorates every resolved `ICouncilBuilder` with a logger. + - `DebateExecutionOptions` record bundles the logger (plus response language + parallelism) + threaded through `IDebateStrategy.ExecuteAsync(...)` via a new + `IDebateStrategyWithOptions` interface (default method on `IDebateStrategy` keeps custom + strategies working unchanged). +- **Response-language enforcement** — `ICouncilBuilder.WithResponseLanguage(string?)` and + `CouncilOptions.ResponseLanguage`. When set, Delibera injects a strict directive into every + system and user prompt so all models (participants, Chairman, Knowledge Keeper, Operator) answer + exclusively in the chosen language, regardless of the prompt or retrieved RAG context. +- **Parallel Operator requests** — `[[OPERATOR: …]]` tasks delegated by participants within a round + now run concurrently via `Parallel.ForEachAsync`, bounded by + `ICouncilBuilder.WithMaxDegreeOfParallelism(int)` / `CouncilOptions.MaxDegreeOfParallelism` + (0 = unbounded, default). Delibera-shipped strategies (`StandardDebate`, `CritiqueDebate`, + `ConsensusDebate`) opt in via the new `ExecuteAsync(..., DebateExecutionOptions, ...)` overload. + +### Changed + +- **Renamed `LogLevel` enum to `ExecutionLogLevel`** (in `Delibera.Core.Models`) to avoid a name + clash with `Microsoft.Extensions.Logging.LogLevel`, which is now referenced throughout the + framework. The `ExecutionLog.Level` field, `DebateResult.ToLogsMarkdown()`, and the console + demo all use the renamed enum. `ExecutionLog.ToMicrosoftLogLevel()` maps to the M.E.Logging + severity. This is a source-breaking change for consumers that referenced `LogLevel.Info` etc. + directly; replace with `ExecutionLogLevel.Info`. + ## [10.1.1] - 2026 ### Added — Microsoft.Extensions.AI integration diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index 1fcb618..a3741a5 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -26,6 +26,30 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; PrintBanner(); + // Top-level crash guard: any unhandled exception is printed in full and the + // console is kept open so the user can read the diagnostic before it closes. + // `alreadyReported` prevents double-printing when RunAsync prints its own + // debate-specific diagnostic (with tips) before re-throwing. + var alreadyReported = false; + try + { + await RunAsync(args, () => alreadyReported = true).ConfigureAwait(false); + } + catch (Exception ex) + { + if (!alreadyReported) + PrintFatalError(ex); + + WaitForKeyOnExit("Press any key to exit…", isError: true); + return; + } + + WaitForKeyOnExit("\n🏁 Delibera session complete. Press any key to exit…", isError: false); + } + + private static async Task RunAsync(string[] args, Action onDebateFailed) + { + // ═══════════════════════════════════════════════ // 🆕 v3.1: DI & Separate Files Examples // ═══════════════════════════════════════════════ @@ -316,22 +340,26 @@ public static async Task Main(string[] args) 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?"; - - 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); + 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()) @@ -400,6 +428,21 @@ public static async Task Main(string[] args) Console.WriteLine("🎯 Starting debate...\n"); 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); + }; + + // Surface non-fatal internal errors (e.g. failed MCP tool call) without aborting the debate. + executor.OnError += (ex, context) => + { + WriteErrorEntry(ex, context); + }; + executor.OnRoundCompleted += round => { Console.WriteLine($"\n✅ Round {round.RoundNumber}: {round.RoundName} ({round.Duration.TotalSeconds:F1}s)"); @@ -497,22 +540,21 @@ public static async Task Main(string[] args) if (result.ExecutionLogs.Count > 0) { Console.WriteLine($"\n 📋 Execution Logs ({result.ExecutionLogs.Count} entries):"); - foreach (var log in result.ExecutionLogs.Where(l => l.Level >= LogLevel.Info)) + foreach (var log in result.ExecutionLogs.Where(l => l.Level >= ExecutionLogLevel.Info)) Console.WriteLine($" {log}"); } } catch (Exception ex) { - Console.WriteLine($"\n❌ Debate failed: {ex.Message}"); + PrintFatalError(ex, header: "❌ 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"); Console.WriteLine(" • For Qdrant RAG: docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant"); Console.WriteLine(" • For pgvector RAG: PostgreSQL 15+ with CREATE EXTENSION vector;"); - Console.WriteLine($"\n{ex.StackTrace}"); + onDebateFailed(); + throw; } - - Console.WriteLine("\n🏁 Delibera session complete."); } private static void PrintBanner() @@ -520,19 +562,132 @@ private static void PrintBanner() Console.ForegroundColor = ConsoleColor.Green; Console.WriteLine(""" - ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ - ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ - ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ - ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ - ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ - ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + ██████╗ ███████╗██╗ ██╗██████╗ ███████╗██████╗ █████╗ + ██╔══██╗██╔════╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗ + ██║ ██║█████╗ ██║ ██║██████╔╝█████╗ ██████╔╝███████║ + ██║ ██║██╔══╝ ██║ ██║██╔══██╗██╔══╝ ██╔══██╗██╔══██║ + ██████╔╝███████╗███████╗██║██████╔╝███████╗██║ ██║██║ ██║ + ╚═════╝ ╚══════╝╚══════╝╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + + ⚖️ Thoughtful AI Decisions · v3.1 + + RAG • pgvector • Knowledge Keeper • Chairman + Context Compression • DI • Execution Logging + + """); + Console.ResetColor(); + } + + // ─── Console observability helpers ───────────────────────────────────────────── + + /// + /// Writes a single entry to the console in a + /// colour-coded, single-line format. Safe to call from the executor's + /// streaming events. + /// + 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.WriteLine($" ┊ {entry}"); + Console.ForegroundColor = prev; + } - ⚖️ Thoughtful AI Decisions · v3.1 + /// + /// Writes a non-fatal internal error to the console with a small stack-trace + /// excerpt. The debate continues; this is purely informational. + /// + private static void WriteErrorEntry(Exception ex, string context) + { + var prev = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" ┊ ⚠ {context} error: {ex.Message}"); + if (ex.StackTrace is not null) + { + var firstFrame = ex.StackTrace + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + if (firstLineIsMeaningful(firstFrame)) + Console.WriteLine($" ┊ at {firstFrame.Trim()}"); + } - RAG • pgvector • Knowledge Keeper • Chairman - Context Compression • DI • Execution Logging + Console.ForegroundColor = prev; + } - """); + /// + /// Prints a full diagnostic panel for a fatal exception: type, message, + /// full stack trace and the live log transcript captured so far. + /// + /// The exception that aborted the run. + /// Optional header line; defaults to a generic label. + private static void PrintFatalError(Exception ex, string header = "❌ Unhandled exception") + { + var prev = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(); + Console.WriteLine(new string('═', 60)); + Console.WriteLine($" {header}"); + Console.WriteLine(new string('═', 60)); + Console.WriteLine($" Type: {ex.GetType().FullName}"); + Console.WriteLine($" Message: {ex.Message}"); + Console.WriteLine(); + Console.WriteLine(" ── Stack trace ──"); + Console.WriteLine(ex.StackTrace); + if (ex.InnerException is not null) + { + Console.WriteLine(); + Console.WriteLine(" ── Inner exception ──"); + Console.WriteLine($" Type: {ex.InnerException.GetType().FullName}"); + Console.WriteLine($" Message: {ex.InnerException.Message}"); + Console.WriteLine(ex.InnerException.StackTrace); + } + + Console.ForegroundColor = prev; + } + + /// + /// Pauses the console so the user can read output before the window closes. + /// Honoured in both normal and error paths. When + /// is true the prompt is shown in red and the exit code is set to 1. + /// + private static void WaitForKeyOnExit(string prompt, bool isError) + { + if (isError) + Console.ForegroundColor = ConsoleColor.Red; + + Console.WriteLine(); + Console.WriteLine(prompt); Console.ResetColor(); + + try + { + Console.ReadKey(intercept: true); + } + catch (InvalidOperationException) + { + // No interactive console (e.g. redirected stdin in CI) — fall back gracefully. + Console.WriteLine("(no interactive console available; exiting.)"); + } + + Environment.ExitCode = isError ? 1 : 0; + } + + 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); } } \ No newline at end of file diff --git a/src/Delibera.ConsoleApp/appsettings.json b/src/Delibera.ConsoleApp/appsettings.json index 0db4444..9f0e1a7 100644 --- a/src/Delibera.ConsoleApp/appsettings.json +++ b/src/Delibera.ConsoleApp/appsettings.json @@ -8,6 +8,8 @@ "MaxRounds": 4, "Temperature": 0.7, "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate. Provide thorough, well-reasoned answers.", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0, "Providers": { "DefaultType": "Ollama", @@ -92,7 +94,9 @@ "Strategy": "Standard", "MaxRounds": 4, "Temperature": 0.7, - "OutputDirectory": "./debate_results" + "OutputDirectory": "./debate_results", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0 }, "ContextCompression": { diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index aefb2ce..08f2de9 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,6 +1,7 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; using Delibera.Core.Providers.Mcp; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -16,12 +17,15 @@ public sealed class CouncilBuilder : ICouncilBuilder private IContextCompressor? _compressor; private IKnowledgeBase? _knowledgeBase; private KnowledgeKeeper? _knowledgeKeeper; + private ILogger? _logger; + private int _maxDegreeOfParallelism; private int _maxRounds = 4; private Operator? _operator; private CouncilMember? _operatorModel; private bool _operatorReuseCompression; private IReadOnlyList? _operatorServers; private string? _outputPath; + private string? _responseLanguage; private IDebateStrategy _strategy = new StandardDebate(); private string _systemPrompt = "You are a helpful AI assistant participating in a council debate."; private float _temperature = 0.7f; @@ -187,12 +191,33 @@ public ICouncilBuilder WithTemperature(float temperature) return this; } - /// - public ICouncilBuilder SaveResultTo(string outputPath) - { - _outputPath = outputPath; - 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() @@ -266,18 +291,24 @@ public CouncilExecutor Build() : null); } - return new CouncilExecutor( - _members.AsReadOnly(), - _chairman, - _knowledgeKeeper, - _strategy, - context, - _maxRounds, - _temperature, - _outputPath, - _compressor, - _compressionOptions, - _compressionCache, - @operator); - } + 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); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index 05e8240..1a9b14e 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -1,4 +1,5 @@ using Delibera.Core.Compression; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Council; @@ -27,7 +28,8 @@ internal CouncilExecutor( IContextCompressor? compressor = null, CompressionOptions? compressionOptions = null, CompressionCache? compressionCache = null, - Operator? @operator = null) + Operator? @operator = null, + DebateExecutionOptions? executionOptions = null) { Members = members; Chairman = chairman; @@ -41,6 +43,7 @@ internal CouncilExecutor( Compressor = compressor; _compressionOptions = compressionOptions; CompressionCache = compressionCache; + ExecutionOptions = executionOptions ?? DebateExecutionOptions.Default; } /// Compression cache (may be null). @@ -64,79 +67,108 @@ internal CouncilExecutor( /// Context compressor (may be null if compression is disabled). public IContextCompressor? Compressor { get; } - /// - public IReadOnlyList ExecutionLogs => _executionLogs.AsReadOnly(); - - /// Invoked after each round completes. - public event Action? OnRoundCompleted; - /// - /// Runs the debate and returns the full result. + /// Per-execution options (response language, parallelism budget, logger). + /// Populated from . /// - public async Task ExecuteAsync(CancellationToken ct = default) - { - _executionLogs.Clear(); - - Log(ExecutionLog.Info("Council", $"Starting debate — strategy: {Strategy.StrategyName}, members: {Members.Count}, maxRounds: {_maxRounds}")); - - 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) - { - Log(ExecutionLog.Error("Operator", $"Operator initialisation failed: {ex.Message}")); - } - } + public DebateExecutionOptions ExecutionOptions { get; } - 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}]")); - - var result = await Strategy.ExecuteAsync( - Members, - _context, - Chairman, - KnowledgeKeeper, - Operator, - _maxRounds, - _temperature, - round => - { - Log(ExecutionLog.Info("Council", $"Round {round.RoundNumber} completed: {round.RoundName} ({round.Duration.TotalSeconds:F1}s, {round.Responses.Count} responses)")); + /// + public ILogger? Logger => ExecutionOptions.Logger; - // Log knowledge interactions - foreach (var ki in round.KnowledgeInteractions) - Log(ExecutionLog.Info("KnowledgeKeeper", $"Query: \"{Truncate(ki.Query, 100)}\" → {ki.SourceChunks} chunks")); + /// + public IReadOnlyList ExecutionLogs => _executionLogs.AsReadOnly(); - // Log operator interactions - foreach (var oi in round.OperatorInteractions) - Log(ExecutionLog.Info("Operator", $"{oi.RequesterName} → \"{Truncate(oi.Task, 100)}\" ({oi.ToolCallCount} tool call(s))")); + /// Invoked after each round completes. + public event Action? OnRoundCompleted; - // Log participant responses - foreach (var (member, response) in round.Responses) - Log(ExecutionLog.Trace("Participant", $"{member} responded ({response.Length} chars)")); + /// + public event Action? OnLog; - OnRoundCompleted?.Invoke(round); - }, - ct); + /// + 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); Log(ExecutionLog.Info("Council", $"Debate completed — {result.Rounds.Count} rounds, duration: {result.TotalDuration.TotalSeconds:F1}s")); @@ -193,10 +225,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}"); - 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}]"); @@ -252,9 +290,19 @@ public string GetInfo() private void Log(ExecutionLog entry) { - _executionLogs.Add(entry); + _executionLogs.Add(ExecutionLogSink.Emit(ExecutionOptions.Logger, 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); + + ExecutionOptions.Logger?.LogError(ex, "[{Source}] {Message}", context, ex.Message); + } + private static string Truncate(string text, int max) { return string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; diff --git a/src/Delibera.Core/Council/KnowledgeKeeper.cs b/src/Delibera.Core/Council/KnowledgeKeeper.cs index 280c886..b80d04b 100644 --- a/src/Delibera.Core/Council/KnowledgeKeeper.cs +++ b/src/Delibera.Core/Council/KnowledgeKeeper.cs @@ -51,7 +51,7 @@ public async Task AnswerQuestionAsync( // 1. Retrieve context from RAG var context = await _ragProvider.GetContextAsync(CollectionName, question, limit, ct); - const string SystemPrompt = """ + const string systemPrompt = """ You are the Knowledge Keeper — a librarian and fact-checker for an AI council debate. Your role is to provide accurate, well-sourced answers based ONLY on the context provided. If the context does not contain sufficient information, clearly state what you know @@ -90,7 +90,7 @@ Cite relevant source numbers in your answer. } // 2. Generate answer via dedicated LLM - var answer = await _model.AskAsync(SystemPrompt, userPrompt, temperature, ct); + var answer = await _model.AskAsync(systemPrompt, userPrompt, temperature, ct); // 3. Log the interaction _interactions.Add(new KnowledgeInteraction(question, answer, sourceChunks)); @@ -118,7 +118,7 @@ public async Task AnswerWithContextAsync( sb.AppendLine(); } - const string SystemPrompt = """ + const string systemPrompt = """ You are the Knowledge Keeper — a librarian and fact-checker for an AI council debate. Provide accurate answers based ONLY on the context provided. Cite source numbers. """; @@ -131,7 +131,7 @@ Provide accurate answers based ONLY on the context provided. Cite source numbers {question} """; - var answer = await _model.AskAsync(SystemPrompt, userPrompt, temperature, ct); + var answer = await _model.AskAsync(systemPrompt, userPrompt, temperature, ct); _interactions.Add(new KnowledgeInteraction(question, answer, searchResults.Count)); return answer; } diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs index 883f80e..c7e3ed1 100644 --- a/src/Delibera.Core/Council/Operator.cs +++ b/src/Delibera.Core/Council/Operator.cs @@ -189,7 +189,7 @@ public async ValueTask DisposeAsync() private async Task> PlanToolCallsAsync(string task, CancellationToken ct) { - const string SystemPrompt = """ + 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: @@ -218,7 +218,7 @@ Return the JSON plan now. string raw; try { - raw = await _model.AskAsync(SystemPrompt, userPrompt, 0.1f, ct); + raw = await _model.AskAsync(systemPrompt, userPrompt, 0.1f, ct); } catch { diff --git a/src/Delibera.Core/Debate/ConsensusDebate.cs b/src/Delibera.Core/Debate/ConsensusDebate.cs index 7dbde2e..3d3fec5 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -20,26 +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) - { - 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)); @@ -59,8 +76,8 @@ 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, ct); + 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); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -89,8 +106,8 @@ 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, ct); + 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); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -119,8 +136,8 @@ 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, ct); + 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); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 335b463..7d4fa61 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -20,26 +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) - { - 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)); @@ -59,8 +76,8 @@ 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, ct); + 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); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -89,8 +106,8 @@ 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, ct); + 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); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -119,8 +136,8 @@ 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, ct); + 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); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 7db9001..cf364da 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -8,69 +8,80 @@ namespace Delibera.Core.Debate; /// Provides shared utilities for collecting responses, formatting rounds, /// querying the Knowledge Keeper, and compressing context. /// -public abstract class DebateScenario : IDebateStrategy +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, + CouncilMember? chairman, + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, + DebateExecutionOptions executionOptions, + int maxRounds = 4, + float temperature = 0.7f, + Action? onRoundCompleted = null, + CancellationToken ct = default); // ────────────────────────────────────────────── // Shared helpers // ────────────────────────────────────────────── - /// Collects responses from all members in parallel. - protected static async Task> CollectResponsesAsync( - IReadOnlyList members, - string systemPrompt, - string userPrompt, - float temperature, - CancellationToken ct) - { - var tasks = members.Select(async member => - { - try - { - var response = await member.AskAsync(systemPrompt, userPrompt, temperature, ct); - return (member.Role, member.DisplayName, Response: response); - } - catch (Exception ex) - { - return (member.Role, member.DisplayName, Response: $"[ERROR: {ex.Message}]"); - } - }); - - var results = await Task.WhenAll(tasks); + /// Collects responses from all members in parallel. + protected static async Task> CollectResponsesAsync( + IReadOnlyList members, + string systemPrompt, + string userPrompt, + float temperature, + CancellationToken ct) + { + var tasks = members.Select(async member => + { + try + { + var response = await member.AskAsync(systemPrompt, userPrompt, temperature, ct); + return (member.Role, member.DisplayName, Response: response); + } + catch (Exception ex) + { + return (member.Role, member.DisplayName, Response: $"[ERROR: {ex.Message}]"); + } + }); - //return results.ToDictionary(r => $"{r.Role}: {r.DisplayName}", r => r.Response); - // DisplayName can collide when the same model is registered multiple times. + var results = await Task.WhenAll(tasks); + //return results.ToDictionary(r => r.DisplayName, r => r.Response); // Disambiguate by appending a counter while preserving the original label for unique names. var seen = new HashSet(); var responses = new Dictionary(results.Length); @@ -88,7 +99,7 @@ protected static async Task> CollectResponsesAsync( responses[key] = response; } - + return responses; } @@ -222,46 +233,77 @@ 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) - { - if (@operator is null || responses.Count == 0) return []; + /// + /// 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 []; - var interactions = new List(); - foreach (var (member, response) in responses) - { - if (string.IsNullOrWhiteSpace(response)) continue; + // 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; - - try - { - var result = await @operator.ExecuteTaskAsync(member, task, ct); - interactions.Add(result.ToInteraction()); - } - catch (Exception ex) - { - interactions.Add(new OperatorInteraction( - member, task, $"[Operator error: {ex.Message}]", [], false, DateTime.UtcNow)); - } - } - } + foreach (Match match in OperatorRequestRegex.Matches(response)) + { + var task = match.Groups["task"].Value.Trim(); + if (string.IsNullOrWhiteSpace(task)) continue; + pending.Add((member, task)); + } + } - return interactions; - } + 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 2ed18a8..ea48952 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -24,26 +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) - { - 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) @@ -65,9 +82,9 @@ 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. - var r1Op = await ProcessOperatorRequestsAsync(@operator, r1Responses, ct); + 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); builder.AddRound(round1); @@ -103,8 +120,8 @@ 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, ct); + 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); builder.AddRound(round2); @@ -143,8 +160,8 @@ 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, ct); + 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); builder.AddRound(round3); diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index 2d3a366..c74dbe6 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -12,9 +12,9 @@ Delibera.Core - 10.1.1 + 10.2.0 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, and context compression. + 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. Victor Buzin Techbuzzz Delibera @@ -26,7 +26,7 @@ https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.1.1 — Microsoft.Extensions.AI (10.7.0) integration: ChatClientLLMProvider and EmbeddingGeneratorProvider adapt any IChatClient / IEmbeddingGenerator (OpenAI, Azure OpenAI, Ollama, …), streaming via ChatStreamAsync, middleware composition, and AddDeliberaChatClient DI helpers. Fully backward compatible. + 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). true snupkg true @@ -39,13 +39,15 @@ - - - - - - - + + + + + + + + + diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index 24322bb..a5431ae 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -18,8 +18,34 @@ 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."; + /// 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(); diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index ec5fbe0..0574c87 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace Delibera.Core.DependencyInjection; @@ -15,66 +16,103 @@ 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(); + /// + /// 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; - } + 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(); + /// + /// 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); + var section = configuration.GetSection(sectionName); + if (section.Exists()) services.Configure(section); - return services; - } + 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); + /// + /// 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; - } + 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 a Microsoft.Extensions.AI and exposes it as a Delibera diff --git a/src/Delibera.Core/GlobalUsings.cs b/src/Delibera.Core/GlobalUsings.cs index f7fc8b4..aa0bfcd 100644 --- a/src/Delibera.Core/GlobalUsings.cs +++ b/src/Delibera.Core/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Delibera.Core.Interfaces; global using Delibera.Core.Models; +global using Microsoft.Extensions.Logging; global using System.Text; \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index c3cb4a6..645d75d 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -146,10 +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); + /// 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 74a1362..10e0add 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Delibera.Core.Models; namespace Delibera.Core.Interfaces; @@ -8,23 +9,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; } /// /// Execution logs collected during the debate. @@ -35,6 +44,20 @@ public interface ICouncilExecutor /// Invoked after each round completes. event Action? OnRoundCompleted; + /// + /// Invoked for every entry produced during + /// . Useful for streaming progress to a console + /// or another observer without having to wait until the debate finishes. + /// + event Action? OnLog; + + /// + /// Invoked when a non-fatal error is caught internally (e.g. failed MCP + /// tool call, failed knowledge index). The debate continues. Fatal errors + /// are surfaced via the exception path. + /// + event Action? OnError; + /// /// Runs the debate and returns the full result. /// diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index 9116976..1ce3c5f 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Microsoft.Extensions.Logging; namespace Delibera.Core.Interfaces; @@ -8,33 +9,86 @@ 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); + } +} + +/// +/// Convenience base interface used by Delibera strategies to declare an overload of +/// IDebateStrategy.ExecuteAsync that takes . +/// Concrete strategies derive from , which implements +/// this interface, so they can override the overload. +/// +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); } \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateExecutionOptions.cs b/src/Delibera.Core/Models/DebateExecutionOptions.cs new file mode 100644 index 0000000..764bf6c --- /dev/null +++ b/src/Delibera.Core/Models/DebateExecutionOptions.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; + +namespace Delibera.Core.Models; + +/// +/// Per-execution options threaded through debate strategies alongside the +/// call. Bundles the response-language +/// directive, parallelism budget, and optional so every +/// strategy and helper can act on them without growing the public strategy signature +/// one parameter at a time. +/// +/// +/// When non-empty, every model response (participants, Chairman, Knowledge Keeper, +/// Operator) is forced into this language via a strict prompt directive. +/// +/// +/// Maximum number of concurrent operations within a debate round (Operator task +/// delegation, parallel Knowledge Keeper queries). 0 = unbounded. +/// +/// +/// Optional used by the executor and strategies to surface +/// progress to a host's logging pipeline. null disables structured logging. +/// +public sealed record DebateExecutionOptions( + string? ResponseLanguage = null, + int MaxDegreeOfParallelism = 0, + ILogger? Logger = null) +{ + /// Singleton representing "no extra execution options" (legacy behaviour). + public static DebateExecutionOptions Default { get; } = new(); + + /// Whether a response language directive is configured. + public bool HasResponseLanguage => !string.IsNullOrWhiteSpace(ResponseLanguage); + + /// + /// Builds the language-enforcement directive block that is appended to system and + /// user prompts. Returns an empty string when no language is configured. + /// + public string BuildLanguageDirective() + { + return HasResponseLanguage + ? $"\n\nIMPORTANT: You MUST answer exclusively in {ResponseLanguage}. Never use any other language, regardless of the language used in the question, retrieved context, or other participants' messages." + : string.Empty; + } + + /// + /// Returns a instance reflecting + /// (useful for Parallel.ForEachAsync). + /// + public ParallelOptions ToParallelOptions(CancellationToken ct) + { + var po = new ParallelOptions { CancellationToken = ct }; + if (MaxDegreeOfParallelism > 0) + po.MaxDegreeOfParallelism = MaxDegreeOfParallelism; + return po; + } +} + +/// +/// Internal helper that bridges entries and a host's +/// so the same event is recorded both in the in-memory log +/// collection and the host's logging pipeline. +/// +internal static class ExecutionLogSink +{ + /// + /// Forwards the to using the + /// appropriate , then returns the + /// same entry so the caller can also append it to its + /// collection. + /// + public static ExecutionLog Emit(ILogger? logger, ExecutionLog entry) + { + if (logger is null) return entry; + + var msLevel = entry.ToMicrosoftLogLevel(); + if (!logger.IsEnabled(msLevel)) return entry; + + // Use a structured log event id of 0 (free-form) with named placeholders so + // scopes/external sinks can correlate by Source + Message. + logger.Log(msLevel, new EventId(0, entry.Source), "[{Source}] {Message}", entry.Source, entry.Message); + return entry; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index fa429bc..da9b175 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -232,10 +232,10 @@ public string ToLogsMarkdown() { var icon = log.Level switch { - LogLevel.Trace => "🔍", - LogLevel.Info => "ℹ️", - LogLevel.Warning => "⚠️", - LogLevel.Error => "❌", + ExecutionLogLevel.Trace => "🔍", + ExecutionLogLevel.Info => "ℹ️", + ExecutionLogLevel.Warning => "⚠️", + ExecutionLogLevel.Error => "❌", _ => "•" }; sb.AppendLine($"| {log.Timestamp:HH:mm:ss.fff} | {icon} {log.Level} | {log.Source} | {log.Message} |"); @@ -247,7 +247,7 @@ public string ToLogsMarkdown() sb.AppendLine("## Summary by Source"); sb.AppendLine(); foreach (var group in ExecutionLogs.GroupBy(l => l.Source).OrderBy(g => g.Key)) - sb.AppendLine($"- **{group.Key}**: {group.Count()} entries ({group.Count(l => l.Level == LogLevel.Error)} errors, {group.Count(l => l.Level == LogLevel.Warning)} warnings)"); + sb.AppendLine($"- **{group.Key}**: {group.Count()} entries ({group.Count(l => l.Level == ExecutionLogLevel.Error)} errors, {group.Count(l => l.Level == ExecutionLogLevel.Warning)} warnings)"); sb.AppendLine(); } else diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index 700bc4f..b45b1d5 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -3,7 +3,13 @@ namespace Delibera.Core.Models; /// /// Severity level for execution log entries. /// -public enum LogLevel +/// +/// Renamed from LogLevel in v10.2 to avoid a name clash with +/// , which is now the canonical +/// logging severity used throughout Delibera when +/// is wired up. +/// +public enum ExecutionLogLevel { /// Detailed trace-level messages for debugging. Trace = 0, @@ -31,41 +37,57 @@ public enum LogLevel /// Human-readable description of the event. /// UTC timestamp when the event occurred. public sealed record ExecutionLog( - LogLevel Level, + ExecutionLogLevel Level, string Source, string Message, DateTime Timestamp) { /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Info(string source, string message) { - return new ExecutionLog(LogLevel.Info, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Info, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Trace(string source, string message) { - return new ExecutionLog(LogLevel.Trace, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Trace, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Warn(string source, string message) { - return new ExecutionLog(LogLevel.Warning, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Warning, source, message, DateTime.UtcNow); } /// - /// Creates an with and the current UTC time. + /// Creates an with and the current UTC time. /// public static ExecutionLog Error(string source, string message) { - return new ExecutionLog(LogLevel.Error, source, message, DateTime.UtcNow); + return new ExecutionLog(ExecutionLogLevel.Error, source, message, DateTime.UtcNow); + } + + /// + /// Maps this execution-log level to the equivalent + /// used by . + /// + public Microsoft.Extensions.Logging.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 + }; } /// diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index 5c2d0fd..711f2b4 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -34,7 +34,10 @@ outcomes** rather than single-model guesses. - 🛠️ **Operator (MCP Tools)** — a micro-agent that delegates tasks to MCP servers (web search, file system, Notion, PostgreSQL…) on demand during the debate - 🗜️ **Context Compression** — 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens - 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding -- 📋 **Execution Logging** — `ExecutionLog` model with `LogLevel` for Chairman, KK, Compression & participants +- 📋 **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) +- 🌐 **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 - 🤝 **Microsoft.Extensions.AI** — first-class `IChatClient` / `IEmbeddingGenerator` interop with logging & function-invocation middleware - 🔌 **Interface-First** — clean abstractions for providers, factories, builders and executors @@ -333,6 +336,110 @@ Each strategy is implemented as an `IDebateStrategy` — combine with `Builder`, --- +## 📝 Logging (Microsoft.Extensions.Logging) + +Delibera integrates with the standard .NET logging framework. Every debate event — Chairman +opening, Knowledge Keeper queries, compression operations, Operator interactions, participant +responses, errors — is forwarded to an injected `ILogger` (category `Delibera.Core.Council`) +**in addition to** being recorded in the in-memory `ExecutionLog` collection and the `OnLog` event. + +### Inject your logger via the builder + +```csharp +using Microsoft.Extensions.Logging; + +var loggerFactory = LoggerFactory.Create(builder => +{ + builder.AddConsole(); + builder.SetMinimumLevel(LogLevel.Information); +}); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("…") + .WithLogger(loggerFactory.CreateLogger("Delibera")) + .Build() + .ExecuteAsync(); +``` + +### Inject your logger factory via DI + +```csharp +services.AddDelibera(configuration, loggerFactory, "Delibera"); +// Any ICouncilBuilder resolved from the container is automatically decorated with a logger. +``` + +When no `ILogger` is configured, Delibera falls back to the legacy behaviour: events are only +captured in the `DebateResult.ExecutionLogs` collection and the `OnLog`/`OnError` events. + +--- + +## 🌐 Response Language Enforcement + +Force **every** model response (participants, Chairman, Knowledge Keeper, Operator) to be in a +specific language, regardless of the language used in the prompt or retrieved RAG context. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithResponseLanguage("Russian") // ← force Russian answers + .Build() + .ExecuteAsync(); +``` + +Or via configuration: + +```json +{ + "Delibera": { + "ResponseLanguage": "Russian" + } +} +``` + +Delibera injects a strict directive into every system prompt: +> *IMPORTANT: You MUST answer exclusively in {language}. Never use any other language, regardless +> of the language used in the question, retrieved context, or other participants' messages.* + +Leave empty/null to let the model pick a language from context (legacy behaviour). + +--- + +## ⚡ Performance — Parallel Operator Requests + +When participants delegate multiple tasks to the Operator within a round (via `[[OPERATOR: …]]` +markers), Delibera now executes them **in parallel** using `Parallel.ForEachAsync`, bounded by +`MaxDegreeOfParallelism`. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .WithOperator("llama3.2:3b", ollama, servers) + .WithMaxDegreeOfParallelism(4) // ← cap at 4 concurrent Operator tasks + .Build() + .ExecuteAsync(); +``` + +Or via configuration: + +```json +{ + "Delibera": { + "MaxDegreeOfParallelism": 4 + } +} +``` + +Set `0` (default) for unbounded parallelism — all delegated tasks in a round run concurrently. + +--- + ## 📁 Output Files Each deliberation can be exported as a single file or as three separate Markdown documents: diff --git a/src/README.md b/src/README.md index 5ec7ad6..befa566 100644 --- a/src/README.md +++ b/src/README.md @@ -41,7 +41,10 @@ well-reasoned outcomes** rather than single-model guesses. | **🐘 Qdrant + pgvector** | Pluggable vector stores — use a dedicated DB or your existing PostgreSQL | | **🗜️ Context Compression** | 4 strategies (Semantic, Deduplication, Summarization, Hybrid) save 30–70% of tokens | | **💉 Dependency Injection** | `AddDelibera()` extension for `IServiceCollection` with full options binding | -| **📋 Execution Logging** | `ExecutionLog` model with `LogLevel` — Chairman, KK, Compression & participant events | +| **📋 Execution Logging** | `ExecutionLog` model with `ExecutionLogLevel` — Chairman, KK, Compression & participant events | +| **📝 M.E.Logging** | Inject your own `ILogger`/`ILoggerFactory` — every debate event is forwarded to the host's logging pipeline | +| **🌐 Response Language** | Force every model (participants, Chairman, KK, Operator) to answer in a specific language | +| **⚡ Parallel Operator** | `[[OPERATOR: …]]` tasks within a round run in parallel, bounded by `MaxDegreeOfParallelism` | | **📁 Separate File Output** | Export `result.md`, `statistics.md`, and `logs.md` independently | | **🔌 Interface-First** | Clean abstractions for providers, factories, builders and executors | | **🧱 Modern C# 12** | File-scoped namespaces, records, init-only properties, global usings | @@ -198,6 +201,8 @@ Resolves these interfaces from DI: "MaxRounds": 4, "Temperature": 0.7, "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "ResponseLanguage": "Russian", + "MaxDegreeOfParallelism": 0, "Providers": { "DefaultType": "Ollama", "DefaultEndpoint": "http://localhost:11434", @@ -480,20 +485,101 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN --- +## 📝 Logging (Microsoft.Extensions.Logging) + +Delibera integrates with the standard .NET logging framework. Every debate event — Chairman +opening, Knowledge Keeper queries, compression operations, Operator interactions, participant +responses, errors — is forwarded to an injected `ILogger` (category `Delibera.Core.Council`) +**in addition to** the in-memory `ExecutionLog` collection and the `OnLog`/`OnError` events. + +### Inject your logger via the builder + +```csharp +using Microsoft.Extensions.Logging; + +var loggerFactory = LoggerFactory.Create(b => { b.AddConsole(); b.SetMinimumLevel(LogLevel.Information); }); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("…") + .WithLogger(loggerFactory.CreateLogger("Delibera")) + .Build() + .ExecuteAsync(); +``` + +### Inject your logger factory via DI + +```csharp +services.AddDelibera(configuration, loggerFactory, "Delibera"); +// Any ICouncilBuilder resolved from the container is automatically decorated with a logger. +``` + +When no `ILogger` is configured, Delibera falls back to the legacy behaviour: events are only +captured in `DebateResult.ExecutionLogs` and the `OnLog`/`OnError` events. + +--- + +## 🌐 Response Language Enforcement + +Force **every** model response (participants, Chairman, Knowledge Keeper, Operator) to be in a +specific language, regardless of the language used in the prompt or retrieved RAG context. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithResponseLanguage("Russian") // ← force Russian answers + .Build() + .ExecuteAsync(); +``` + +Or via configuration (`Delibera:ResponseLanguage`). Delibera injects a strict directive into every +system prompt: *“You MUST answer exclusively in {language}. Never use any other language…”*. +Leave empty/null to let the model pick a language from context (legacy behaviour). + +--- + +## ⚡ Performance — Parallel Operator Requests + +When participants delegate multiple tasks to the Operator within a round (via `[[OPERATOR: …]]` +markers), Delibera now executes them **in parallel** using `Parallel.ForEachAsync`, bounded by +`MaxDegreeOfParallelism`. + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .WithOperator("llama3.2:3b", ollama, servers) + .WithMaxDegreeOfParallelism(4) // ← cap at 4 concurrent Operator tasks + .Build() + .ExecuteAsync(); +``` + +Or via configuration (`Delibera:MaxDegreeOfParallelism`). Set `0` (default) for unbounded +parallelism — all delegated tasks in a round run concurrently. + +--- + ## 🏛️ Architecture ``` Delibera.Core -├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper -├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator +├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate, DebateScenario ├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid ├── Providers/ -│ ├── LLM/ ← OllamaProvider, OllamaEmbeddingProvider -│ └── RAG/ ← QdrantRagProvider, PgVectorRagProvider -├── DependencyInjection/ ← AddDelibera() + CouncilOptions +│ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider +│ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP servers) +├── Extensions/ ← MicrosoftAIExtensions (IChatClient ↔ ILLMProvider bridges) +├── DependencyInjection/ ← AddDelibera() / AddDeliberaChatClient() + CouncilOptions ├── Knowledge/ ← MarkdownKnowledgeBase -├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... -└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, ... +├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, DebateExecutionOptions, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IDebateStrategy, ... ``` ---