diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f84b6b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +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.1.1] - 2026 + +### Added — Microsoft.Extensions.AI integration + +- **`Microsoft.Extensions.AI` 10.7.0** dependency in `Delibera.Core`. +- **`ChatClientLLMProvider`** — adapts any Microsoft.Extensions.AI `IChatClient` to Delibera's + `ILLMProvider`. Works with OpenAI, Azure OpenAI, Ollama, Anthropic and local OpenAI-compatible + servers (LM Studio, LocalAI, vLLM) without a bespoke provider per vendor. +- **`EmbeddingGeneratorProvider`** — adapts any `IEmbeddingGenerator>` + to Delibera's `IEmbeddingProvider` for RAG indexing/querying. +- **`ILLMProvider.ChatStreamAsync(...)`** — additive default interface method for token-by-token + streaming. `ChatClientLLMProvider` overrides it for true streaming; existing providers fall back + to a single `ChatAsync` call, so nothing breaks. +- **`MicrosoftAIExtensions`** bridge helpers: + - `IChatClient.AsLLMProvider(...)`, `IEmbeddingGenerator.AsEmbeddingProvider(...)` + - `ILLMProvider.AsChatClient(...)` (reverse bridge so Delibera providers can join a + Microsoft.Extensions.AI middleware pipeline) + - `IChatClient.WithMiddleware(...)` to compose function invocation + logging. +- **`ProviderFactory.CreateFromChatClient(...)`** — build a cached provider directly from an `IChatClient`. +- **`OllamaProvider.AsChatClient()` / `AsEmbeddingGenerator()`** — expose the underlying + OllamaSharp client (which natively implements the Microsoft.Extensions.AI interfaces). +- **DI helpers** `AddDeliberaChatClient(...)` and `AddDeliberaEmbeddingGenerator(...)` in + `ServiceCollectionExtensions`. +- **Console example** `MicrosoftExtensionsAiExample` (run with `--msai`). +- **Unit test project** `tests/Delibera.Core.Tests` (xUnit) covering the new providers, the bridge + adapters and the factory — plus a solution file `Delibera.slnx`. + +### Changed + +- Bumped `Delibera.Core` package version `10.1.0` → `10.1.1`. +- Documentation: `README.md` / `README-RU.md` and `docs/QuickStart*.md` updated with a + Microsoft.Extensions.AI section. + +### Compatibility + +- **No breaking changes.** The public API is fully backward compatible; all existing + `ILLMProvider` / `IEmbeddingProvider` consumers continue to work unchanged. + +## [10.1.0] - 2026 + +- Operator (MCP tools) role, .NET 10 / C# 15 (preview) upgrade, high-performance hot-path + optimizations (SIMD cosine similarity, allocation-free token counting, pooled cache hashing). +- Dependency injection, context compression, and RAG (Qdrant, pgvector) support. diff --git a/Delibera.slnx b/Delibera.slnx new file mode 100644 index 0000000..0534774 --- /dev/null +++ b/Delibera.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/README-RU.md b/README-RU.md new file mode 100644 index 0000000..02d0b84 --- /dev/null +++ b/README-RU.md @@ -0,0 +1,773 @@ +
+ +Delibera + +# Delibera + +### ⚖️ Продуманные решения с помощью ИИ + +**Коллективное принятие решений через структурированное обсуждение ИИ — с RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP-инструменты), Chairman, 🔥 сжатием контекста, 💉 Dependency Injection и 📋 журналированием выполнения** + +[![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) +[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) +[![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) +[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) + +🇬🇧 [English version (README.md)](README.md) + +
+ +--- + +## 📖 Обзор + +**Delibera** — это фреймворк на C# / .NET 10, который оркеструет **многомодельные обсуждения** +между LLM. Несколько моделей ИИ рассуждают над вопросом в течение структурированных раундов, +критикуют ответы друг друга, а **Chairman** (председатель) взвешивает аргументы, чтобы +синтезировать сбалансированный финальный вердикт — обогащённый **Knowledge Keeper** на базе +**Qdrant** или **PostgreSQL/pgvector** (RAG), с **интеллектуальным сжатием контекста** для +минимизации расхода токенов. + +Название происходит от слова *deliberation* — тщательного взвешивания доказательств и точек зрения +перед принятием решения. Delibera привносит эту дисциплину в ИИ, помогая командам приходить к +**продуманным, хорошо обоснованным результатам**, а не к догадкам одной модели. + +--- + +## ✨ Ключевые возможности + +| Возможность | Описание | +| --------------------------------- | --------------------------------------------------------------------------------- | +| **🏛️ Многомодельные советы** | Оркестрация любого числа LLM-участников по структурированным раундам дебатов | +| **⚖️ Синтез Chairman** | Выделенный модератор открывает, регулирует и синтезирует финальный вердикт | +| **📚 Knowledge Keeper (RAG)** | Семантический поиск по раундам со структурированными ответами и цитированием | +| **🛠️ Operator (MCP-инструменты)** | Микроагент, делегирующий задачи MCP-серверам (веб, файлы, Marp, Notion, …) по запросу в ходе дебатов | +| **🐘 Qdrant + pgvector** | Подключаемые векторные хранилища — отдельная БД или ваш существующий PostgreSQL | +| **🗜️ Сжатие контекста** | 4 стратегии (Semantic, Deduplication, Summarization, Hybrid) экономят 30–70% токенов | +| **💉 Dependency Injection** | Расширение `AddDelibera()` для `IServiceCollection` с полной привязкой опций | +| **📋 Журналирование выполнения** | Модель `ExecutionLog` с `LogLevel` — события Chairman, KK, сжатия и участников | +| **📁 Раздельный вывод файлов** | Экспорт `result.md`, `statistics.md` и `logs.md` по отдельности | +| **🔌 Interface-First** | Чистые абстракции для провайдеров, фабрик, билдеров и исполнителей | +| **🤝 Microsoft.Extensions.AI** | Поддержка `IChatClient` / `IEmbeddingGenerator` — подключайте OpenAI, Azure OpenAI, Ollama и любые совместимые бэкенды, с middleware (function calling, логирование) | +| **🧱 Современный C# 15 (preview)** | Построено на .NET 10 с `LangVersion=preview`, file-scoped namespaces, records, span/SIMD горячие пути | + +--- + +## 📑 Содержание + +- [Быстрый старт](#-быстрый-старт) + - [Требования и модели](#требования-и-модели) + - [Установка](#установка) + - [Минимальный пример](#минимальный-пример) + - [Запуск](#запуск) +- [Dependency Injection](#-dependency-injection) +- [Operator (MCP-инструменты)](#️-operator-mcp-инструменты) +- [Сжатие контекста](#️-сжатие-контекста) +- [Интеграция RAG](#-интеграция-rag) +- [Стратегии дебатов](#️-стратегии-дебатов) +- [Структура выходных файлов](#-структура-выходных-файлов) +- [Примеры ConsoleApp](#-примеры-consoleapp) +- [Установка и сборка](#️-установка-и-сборка) +- [Архитектура](#️-архитектура) +- [Участие в разработке](#-участие-в-разработке) +- [Лицензия](#-лицензия) + +--- + +## 🚀 Быстрый старт + +### Требования и модели + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — проект нацелен на + `net10.0` и собирается с `LangVersion=preview` для включения возможностей **C# 15**. См. + [docs/NET10-Upgrade-RU.md](docs/NET10-Upgrade-RU.md) для полных заметок о миграции. +- Запущенный экземпляр [Ollama](https://ollama.com) — локально (`ollama serve`) или + [Ollama Cloud](https://ollama.com/cloud) (только API-ключ, без установки). +- Минимальный набор моделей, перечисленный ниже. +- **Опционально — для роли Operator:** [Node.js + npx](https://nodejs.org) для запуска MCP-серверов + (например, `@playwright/mcp`, `@marp-team/marp-cli`). См. [Operator (MCP-инструменты)](#️-operator-mcp-инструменты). + +#### 🟢 Минимальный — для небольших дебатов (≈ 2 ГБ всего) + +Подходит для smoke-тестов, слабого железа и быстрых запусков из CLI. + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | ------------------- | ------- | ---------------------------------- | +| Участник совета | `llama3.2:1b` | 1.3 ГБ | `ollama pull llama3.2:1b` | +| Участник совета | `qwen2.5:1.5b` | 1.1 ГБ | `ollama pull qwen2.5:1.5b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +#### 🟡 Стандартный — рекомендуется для большинства случаев (≈ 7 ГБ всего) + +Хорошее качество рассуждений при низкой задержке. **Это набор по умолчанию, используемый по всему README.** + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | -------------------- | ------- | ----------------------------------- | +| Участник совета | `llama3.2:3b` | 2.0 ГБ | `ollama pull llama3.2:3b` | +| Участник совета | `qwen2.5:7b` | 4.7 ГБ | `ollama pull qwen2.5:7b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +#### 🔴 Высокопроизводительный — для продакшн-уровня дебатов (≈ 30+ ГБ) + +Более тяжёлые локальные модели, рекомендуются на GPU с ≥ 24 ГБ VRAM или на Ollama Cloud. + +| Назначение | Модель | Размер | Команда загрузки | +| ------------------- | ---------------------------- | --------- | --------------------------------------------- | +| Участник совета | `llama3.1:8b` | 4.9 ГБ | `ollama pull llama3.1:8b` | +| Участник совета | `qwen2.5:14b` | 9.0 ГБ | `ollama pull qwen2.5:14b` | +| Участник совета | `mistral:7b` | 4.4 ГБ | `ollama pull mistral:7b` | +| Chairman | `qwen2.5:14b` *(или больше)* | 9.0 ГБ | `ollama pull qwen2.5:14b` | +| Эмбеддинги (RAG) | `nomic-embed-text` | 274 МБ | `ollama pull nomic-embed-text` | + +> 💡 **Ollama Cloud** использует те же имена моделей, но не требует места на локальном диске — нужен +> только API-ключ. См. [раздел конфигурации](#-dependency-injection) для его настройки. + +### Установка + +```bash +dotnet add package Delibera.Core +``` + +### Минимальный пример + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers; + +using var factory = new ProviderFactory(); +var ollama = factory.CreateOllama("http://localhost:11434"); + +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() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +> 📄 См. [docs/QuickStart-RU.md](docs/QuickStart-RU.md) для пошагового руководства. + +### Запуск + +```bash +dotnet run +``` + +Delibera проведёт структурированные многораундовые дебаты и запишет полную стенограмму и вердикт +председателя в файл `deliberation.md`. + +--- + +## 💉 Dependency Injection + +Зарегистрируйте все сервисы Delibera одной строкой: + +```csharp +using Delibera.Core.DependencyInjection; + +// Вариант A: С привязкой конфигурации (привязывает секцию "Delibera") +services.AddDelibera(configuration, "Delibera"); + +// Вариант B: С делегатом опций +services.AddDelibera(options => +{ + options.Strategy = "Standard"; + options.MaxRounds = 4; + options.Temperature = 0.7f; + options.Compression.Enabled = true; + options.Compression.Strategy = "Hybrid"; + options.Compression.TargetRatio = 0.5; +}); + +// Вариант C: Только значения по умолчанию +services.AddDelibera(); +``` + +Резолвит из DI следующие интерфейсы: + +| Интерфейс | Реализация | Время жизни | +| --------------------- | -------------------- | ----------- | +| `ILLMProviderFactory` | `ProviderFactory` | Singleton | +| `IRagProviderFactory` | `RagProviderFactory` | Singleton | +| `ICompressionFactory` | `CompressionService` | Singleton | +| `ICouncilBuilder` | `CouncilBuilder` | Transient | + +### Конфигурация (`appsettings.json`) + +```json +{ + "Delibera": { + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge", + "ConnectionString": null + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true, + "FilePrefix": null + } + } +} +``` + +Чтобы использовать **Ollama Cloud**, задайте `Providers:DefaultEndpoint` равным +`https://api.ollama.com` и поместите ключ в `Providers:ApiKey` (или `OllamaCloud:ApiKey` в секции +`DeliberaApp`, используемой консольным приложением — см. [Примеры ConsoleApp](#-примеры-consoleapp)). + +--- + +## 🛠️ Operator (MCP-инструменты) + +**Operator** — это лёгкий микроагент, который соединяет совет с внешним миром через серверы +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io). Он предоставляет участникам +дебатов любые инструменты, которые дают эти серверы — веб-навигацию, доступ к файловой системе, +генерацию Marp-презентаций, Notion, PostgreSQL и т. д. + +**Как это работает** + +1. Operator подключается к одному или нескольким MCP-серверам и обнаруживает их инструменты при + `InitializeAsync`. +2. Участникам сообщается (в их системном промпте), что умеет Operator, и они могут делегировать + задачу в **любой момент** дебатов, написав маркер в своём сообщении: + + ``` + [[OPERATOR: открой https://modelcontextprotocol.io и кратко перескажи, что такое MCP]] + ``` + +3. Operator интерпретирует запрос своей **собственной (более дешёвой) LLM-моделью**, выбирает и + вызывает нужные MCP-инструменты, интерпретирует результаты и возвращает краткий ответ, который + внедряется в следующий раунд. +4. Если совет использует **сжатие контекста**, Operator может переиспользовать ту же стратегию для + сжатия больших выводов инструментов перед их возвратом в дебаты. Его `DisposeAsync` — + это `ValueTask` (паттерн `IAsyncDisposable` из .NET 10), поэтому MCP-клиенты освобождаются без + аллокации `Task`. + +Все взаимодействия с Operator записываются по раундам и отображаются в финальном Markdown-отчёте в +блоке **🛠️ Operator Interactions**. + +### Настройка MCP-серверов + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // stdio-транспорт — запускает локальный процесс MCP-сервера + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …или HTTP/SSE-транспорт для удалённого MCP-сервера + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Сервер | Транспорт | Команда запуска | Что даёт | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +### Быстрое использование (внутри совета) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +var council = new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Optimist") + .AddMember("qwen2.5:7b", ollama, "Skeptic") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator использует свою более дешёвую модель; reuseCompression разделяет компрессор совета + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary.") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +Предпочитаете создать `Operator` самостоятельно? Передайте готовый экземпляр: + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.Mcp; + +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // необязательный IContextCompressor + compressionOptions: null); // необязательные CompressionOptions + +var council = new CouncilBuilder() + /* …участники… */ + .WithOperator(@operator) + .Build(); +``` + +### Прямое использование (без совета) + +```csharp +await using var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); + +var result = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); + +Console.WriteLine(result.FinalAnswer); +``` + +### Dependency Injection + +Настройте Operator декларативно в `appsettings.json` в секции `Delibera:Operator`: + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + } + } +} +``` + +> ▶️ Полный рабочий пример находится в +> [`OperatorMcpToolsExample.cs`](src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs). +> Запустите его командой `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> Полное техническое описание см. в [docs/NET10-Upgrade-RU.md](docs/NET10-Upgrade-RU.md). + +--- + +## 🗜️ Сжатие контекста + +Автоматически сжимайте контекст между раундами обсуждения — экономьте **30–70% токенов** без потери смысла. + +| Стратегия | Как работает | Лучше всего для | +| ----------------- | -------------------------------------------------------- | -------------------------------- | +| **Semantic** | Эмбеддинг предложений, ранжирование по релевантности, топ-N | Большие контексты знаний | +| **Deduplication** | Удаляет семантически похожие предложения между участниками | Многомодельные дебаты с пересечениями | +| **Summarization** | LLM создаёт краткое резюме, сохраняя ключевые факты | Максимальная степень сжатия | +| **Hybrid** | Конвейер Dedup → Semantic → Summarize | Лучшее общее качество | +| **None** | Без изменений (когда отключено) | Отладка | + +```csharp +using Delibera.Core.Compression; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionOptions(new CompressionOptions { TargetRatio = 0.5 }) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.TokenStats?.ToSummary()); +``` + +### Конвейер сжатия + +``` +IContextCompressor +├── SemanticCompressor ← Ранжирование предложений на основе эмбеддингов +├── DeduplicationCompressor ← Удаление дубликатов по схожести +├── SummarizationCompressor ← Резюмирование с помощью LLM +├── HybridCompressor ← Многоэтапный конвейер (Dedup → Semantic → Summarize) +└── PassThroughCompressor ← Без операций (когда отключено) + +CompressionFactory ← Статическая фабрика (Create по enum или строке) +CompressionService ← DI-дружественная обёртка над CompressionFactory +CompressionCache ← LRU-кэш с ключами SHA-256 +TokenCounter ← Эвристическая оценка токенов +``` + +--- + +## 📚 Интеграция RAG + +Используйте выделенный экземпляр **Qdrant** или вашу существующую базу **PostgreSQL/pgvector** в +качестве векторного хранилища. + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.RAG; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +// pgvector — просто добавьте строку подключения +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreatePgVector( + embeddings, + "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); + +await rag.IndexDocumentAsync("my_collection", documentText); +var results = await rag.SearchAsync("my_collection", "query", limit: 5); + +// Подключаем к Knowledge Keeper +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "my_knowledge"); +await keeper.IndexFileAsync("./docs/architecture.md"); +``` + +``` +IRagProvider +├── QdrantRagProvider +│ └── QdrantVectorStore ← Qdrant gRPC +└── PgVectorRagProvider + └── PgVectorStore ← PostgreSQL/pgvector + +IEmbeddingProvider +└── OllamaEmbeddingProvider +``` + +Затем Knowledge Keeper присоединяется к совету через `WithKnowledgeKeeper(...)` — см. +[пример RAG](src/Delibera.ConsoleApp/Examples/RagExample.cs) для полного рабочего демо. + +--- + +## 🗣️ Стратегии дебатов + +| Стратегия | Поток | Сценарий использования | +| --------------------- | ------------------------------------------------------- | ----------------------- | +| **StandardDebate** | Initial → Critique → Improved → Verdict | Общий анализ | +| **CritiqueDebate** | Position → Attack → Defence → Judge | Проверка гипотез | +| **ConsensusDebate** | Perspectives → Common Ground → Consensus → Facilitator | Поиск оптимального решения | + +Каждая стратегия реализована как `IDebateStrategy` — см. +[`src/Delibera.Core/Debate/`](src/Delibera.Core/Debate/) для полного исходного кода. + +### Паттерны проектирования + +| Паттерн | Использование | +| ------------------- | ------------------------------------------------------------------------- | +| **Factory** | `ProviderFactory`, `RagProviderFactory`, `CompressionFactory`, `Chairman` | +| **Strategy** | `IDebateStrategy`, `IContextCompressor` | +| **Builder** | Fluent API `CouncilBuilder` | +| **Template Method** | Абстрактный базовый класс `DebateScenario` | +| **Cache** | `CompressionCache` с ключами SHA-256 | +| **Observer** | Событие `OnRoundCompleted` у `CouncilExecutor` | + +--- + +## 📁 Структура выходных файлов + +Каждое обсуждение можно экспортировать как один файл или как три отдельных Markdown-документа: + +```csharp +var result = await executor.ExecuteAsync(); + +// Сохранить в 3 отдельных файла +var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); +// Создаёт: debate_20260604_120000_result.md +// debate_20260604_120000_statistics.md +// debate_20260604_120000_logs.md + +// Или сохранить по отдельности +await result.SaveToMarkdownAsync("result.md"); +await result.SaveStatisticsAsync("statistics.md"); +await result.SaveLogsAsync("logs.md"); +``` + +| Файл | Содержимое | +| ----------------- | ----------------------------------------------------------------------------- | +| `*_result.md` | Полная стенограмма обсуждения, раунды и финальный вердикт председателя | +| `*_statistics.md` | Статистика использования токенов с разбивкой по раундам | +| `*_logs.md` | Журналы выполнения (`ExecutionLog`) для Chairman, KK, сжатия и участников | + +--- + +## 💻 Примеры ConsoleApp + +В репозитории есть [`Delibera.ConsoleApp`](src/Delibera.ConsoleApp/) — запускаемый демо-проект, +который задействует каждую возможность. Запускайте его из корня репозитория: + +```bash +# Клонировать репозиторий +git clone https://github.com/delibera/Delibera.git +cd Delibera/src/Delibera.ConsoleApp + +# Запустить конкретный пример +dotnet run -- --di # Dependency Injection +dotnet run -- --separate-files # Сохранить result.md, statistics.md, logs.md +dotnet run -- --compression # Демо сжатия контекста +dotnet run -- --multiprovider # Совет с несколькими провайдерами (cloud + local) +dotnet run -- --rag # RAG на базе Qdrant с Knowledge Keeper +dotnet run -- --pgvector # RAG на базе pgvector +dotnet run -- --operator # Основы роли Operator (MCP-инструменты) +dotnet run -- --operator-mcp # 🆕 Operator с MCP-серверами browser + Marp + +# Или запустить полное демо по умолчанию (читает appsettings.json) +dotnet run +``` + +Консольное приложение читает [`appsettings.json`](src/Delibera.ConsoleApp/appsettings.json), который +использует секцию `DeliberaApp` — по умолчанию она указывает на Ollama Cloud и показывает, как +подключить несколько провайдеров, RAG и сжатие в одном месте. + +--- + +## 🛠️ Установка и сборка + +### Клонирование и сборка + +```bash +git clone https://github.com/delibera/Delibera.git +cd Delibera + +# Собрать всё решение +dotnet build --configuration Release + +# Запустить консольное демо +cd src/Delibera.ConsoleApp +dotnet run +``` + +### Инфраструктура одной командой (Docker Compose) + +Поднимите **Qdrant + PostgreSQL/pgvector** за один шаг. (Ollama намеренно не включена — +[установите её нативно](https://ollama.com/download), чтобы GPU-драйвер использовался напрямую.) + +```bash +# Из корня репозитория +docker compose up -d + +# В другом терминале запустите консольное приложение +cd src/Delibera.ConsoleApp +dotnet run +``` + +Compose-стек предоставляет: + +| Сервис | URL | Назначение | +| ------------- | -------------------------- | ----------------------------- | +| Qdrant (REST) | `http://localhost:6333` | UI / REST векторного хранилища | +| Qdrant (gRPC) | `localhost:6334` | gRPC-клиент (используется Delibera) | +| PostgreSQL | `localhost:5432` | RAG-хранилище pgvector | + +Учётные данные по умолчанию: `postgres` / `postgres`, база `council_vectors`. + +> Если эти сервисы уже запущены нативно, просто пропустите `docker compose` и направьте консольное +> приложение на них — `appsettings.json` по умолчанию использует `localhost`. + +### Альтернатива вручную (по одному контейнеру) + +```bash +# Запуск с Qdrant (Docker) +docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant + +# Запуск с pgvector (Docker) +docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres pgvector/pgvector:pg16 + +# Затем включите расширение pgvector +docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTENSION IF NOT EXISTS vector;" +``` + +### NuGet-зависимости + +| Пакет | Назначение | +| ------------------------ | -------------------------------- | +| `OllamaSharp` | Клиент API Ollama | +| `Qdrant.Client` | gRPC-клиент векторной БД Qdrant | +| `Npgsql` | ADO.NET-провайдер PostgreSQL | +| `Pgvector` | Поддержка типов pgvector для Npgsql | +| `ModelContextProtocol` | MCP-клиент для роли Operator | +| `Microsoft.Extensions.AI`| Унифицированные AI-абстракции `IChatClient` / `IEmbeddingGenerator` и middleware | +| `Microsoft.Extensions.*` | Конфигурация, DI и Options | + +--- + +## 🤝 Microsoft.Extensions.AI + +Delibera интегрирована с [**Microsoft.Extensions.AI**](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +(v10.7.0) — стандартным слоем абстракций .NET для генеративного AI. Это позволяет запускать совет +с **любым** бэкендом, реализующим `IChatClient` (OpenAI, Azure OpenAI, Ollama, Anthropic, +LM Studio / LocalAI / vLLM, …) и собирать **конвейер middleware** (вызов функций/инструментов, +логирование, кэширование, телеметрия) — без написания отдельного провайдера под каждого поставщика. + +### Что вы получаете + +| Тип | Роль | +| ---- | ---- | +| `ChatClientLLMProvider` | Адаптирует любой `IChatClient` к `ILLMProvider` Delibera (со **стримингом** через `ChatStreamAsync`) | +| `EmbeddingGeneratorProvider` | Адаптирует любой `IEmbeddingGenerator>` к `IEmbeddingProvider` для RAG | +| `MicrosoftAIExtensions` | Мосты: `AsLLMProvider()`, `AsEmbeddingProvider()`, `AsChatClient()`, `WithMiddleware()` | +| `ProviderFactory.CreateFromChatClient(...)` | Создаёт кэшируемый провайдер прямо из `IChatClient` | +| `AddDeliberaChatClient(...)` / `AddDeliberaEmbeddingGenerator(...)` | Регистрация стандартных AI-сервисов в DI | + +### Использование любого `IChatClient` как провайдера Delibera + +```csharp +using Delibera.Core.Extensions; +using Microsoft.Extensions.AI; + +// 1) Подключите свой IChatClient. Для OpenAI: +// dotnet add package Microsoft.Extensions.AI.OpenAI +IChatClient client = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + +// 2) (опционально) middleware — вызов функций + логирование +client = client.WithMiddleware(enableFunctionInvocation: true, loggerFactory); + +// 3) отдайте его Delibera +ILLMProvider provider = client.AsLLMProvider("OpenAI"); + +var executor = new CouncilBuilder() + .AddMember("gpt-4o-mini", provider, "Архитектор") + .AddMember("gpt-4o-mini", provider, "Скептик") + .SetChairman(Chairman.CreateStandard("gpt-4o-mini", provider)) + .WithStandardDebate() + .WithUserPrompt("Модульный монолит или микросервисы для команды из 5 человек?") + .Build(); +``` + +Ollama работает «из коробки», так как `OllamaApiClient` из OllamaSharp нативно реализует +`IChatClient` и `IEmbeddingGenerator`: + +```csharp +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chat = ollama.AsChatClient(); // готов к middleware +ILLMProvider provider = chat.AsLLMProvider("Ollama"); +IEmbeddingProvider embeddings = ollama.AsEmbeddingGenerator().AsEmbeddingProvider("nomic-embed-text"); +``` + +### Стриминг + +```csharp +await foreach (var chunk in provider.ChatStreamAsync("gpt-4o-mini", systemPrompt, userPrompt)) + Console.Write(chunk); +``` + +`ChatStreamAsync` — это additive-метод по умолчанию в `ILLMProvider`: провайдеры на базе +`IChatClient` стримят токен за токеном, а старые провайдеры прозрачно используют один вызов. + +### Внедрение зависимостей (DI) + +```csharp +services.AddDeliberaChatClient( + sp => new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey) + .AsIChatClient() + .WithMiddleware(enableFunctionInvocation: true), + providerName: "OpenAI"); + +services.AddDeliberaEmbeddingGenerator( + sp => /* ваш IEmbeddingGenerator */, + modelName: "text-embedding-3-small"); +``` + +> Попробуйте: `dotnet run --project src/Delibera.ConsoleApp -- --msai` + +--- + +## 🏛️ Архитектура + +``` +Delibera.Core +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator +├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate +├── Compression/ ← Semantic / Deduplication / Summarization / Hybrid +├── Providers/ +│ ├── LLM/ ← OllamaProvider, ChatClientLLMProvider, EmbeddingGeneratorProvider +│ ├── RAG/ ← QdrantRagProvider, PgVectorRagProvider +│ └── Mcp/ ← McpClientAdapter (Operator ↔ MCP-серверы) +├── Extensions/ ← MicrosoftAIExtensions (мосты IChatClient ↔ ILLMProvider) +├── DependencyInjection/ ← AddDelibera() / AddDeliberaChatClient() + CouncilOptions +├── Knowledge/ ← MarkdownKnowledgeBase +├── Models/ ← CouncilMember, DebateResult, DebateRound, TokenStatistics, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... +``` + +--- + +## 🤝 Участие в разработке + +Мы приветствуем вклад! Пожалуйста, прочитайте [CONTRIBUTING.md](CONTRIBUTING.md) для рекомендаций по +настройке окружения разработки, стандартам кодирования и процессу pull-request. + +--- + +## 📄 Лицензия + +Delibera распространяется под [лицензией MIT](LICENSE). + +Copyright © 2026 Delibera Project. + +--- + +
+ +**⚖️ Delibera — Продуманные решения с помощью ИИ** + +*Создано с заботой о коллективном интеллекте на базе ИИ* + +
diff --git a/README.md b/README.md index 5ec7ad6..d180f50 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,14 @@ ### ⚖️ Thoughtful AI Decisions -**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Chairman, 🔥 Context Compression, 💉 Dependency Injection & 📋 Execution Logging** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, 🛠️ Operator (MCP tools), Chairman, 🔥 Context Compression, 💉 Dependency Injection & 📋 Execution Logging** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) [![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) -[![C# 14](https://img.shields.io/badge/C%23-14.0-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) +[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) + +[EN](README.md) / [RU](README-RU.md) @@ -38,13 +40,15 @@ well-reasoned outcomes** rather than single-model guesses. | **🏛️ Multi-Model Councils** | Orchestrate any number of LLM participants across structured debate rounds | | **⚖️ Chairman Synthesis** | A dedicated moderator opens, regulates, and synthesises the final verdict | | **📚 Knowledge Keeper (RAG)** | Per-round semantic retrieval with structured, cited responses | +| **🛠️ Operator (MCP Tools)** | A micro-agent that delegates tasks to MCP servers (web, files, Marp, Notion, …) on demand during the debate | | **🐘 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 | | **📁 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 | +| **🤝 Microsoft.Extensions.AI**| First-class support for `IChatClient` / `IEmbeddingGenerator` — plug in OpenAI, Azure OpenAI, Ollama or any compatible backend, with middleware (function calling, logging) | +| **🧱 Modern C# 15 (preview)** | Built on .NET 10 with `LangVersion=preview`, file-scoped namespaces, records, span/SIMD hot paths | --- @@ -56,6 +60,7 @@ well-reasoned outcomes** rather than single-model guesses. - [Minimal Example](#minimal-example) - [Run](#run) - [Dependency Injection](#-dependency-injection) +- [Operator (MCP Tools)](#️-operator-mcp-tools) - [Context Compression](#-context-compression) - [RAG Integration](#-rag-integration) - [Debate Strategies](#-debate-strategies) @@ -72,10 +77,14 @@ well-reasoned outcomes** rather than single-model guesses. ### Prerequisites & Models -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — the project targets + `net10.0` and builds with `LangVersion=preview` to enable **C# 15** features. See + [docs/NET10-Upgrade.md](docs/NET10-Upgrade.md) for the full migration notes. - A running [Ollama](https://ollama.com) instance — local (`ollama serve`) or [Ollama Cloud](https://ollama.com/cloud) (just an API key, no install). - The minimal set of models listed below. +- **Optional — for the Operator role:** [Node.js + npx](https://nodejs.org) to launch MCP servers + (e.g. `@playwright/mcp`, `@marp-team/marp-cli`). See [Operator (MCP Tools)](#️-operator-mcp-tools). #### 🟢 Minimal — for a tiny debate (≈ 2 GB total) @@ -234,6 +243,156 @@ console app — see [ConsoleApp Examples](#-consoleapp-examples)). --- +## 🛠️ Operator (MCP Tools) + +The **Operator** is a lightweight micro-agent that connects the council to the outside world through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers. It exposes whatever tools +those servers provide — web browsing, file system access, Marp presentation generation, Notion, +PostgreSQL, etc. — to the debate participants. + +**How it works** + +1. The Operator connects to one or more MCP servers and discovers their tools on `InitializeAsync`. +2. Participants are told (in their system prompt) what the Operator can do, and can delegate a task at + **any moment** during the debate by writing a marker in their message: + + ``` + [[OPERATOR: open https://modelcontextprotocol.io and summarize what MCP is]] + ``` + +3. The Operator interprets the request with its **own (cheaper) LLM model**, picks and calls the right + MCP tools, interprets the results, and returns a concise answer that is injected into the next round. +4. If the council uses **context compression**, the Operator can reuse the same strategy to compress + large tool outputs before they re-enter the debate. Its `DisposeAsync` is a `ValueTask` (.NET 10 + `IAsyncDisposable`) so MCP clients are released without a `Task` allocation. + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### Configuring MCP servers + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // stdio transport — launches a local MCP server process + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …or an HTTP/SSE transport for a remote MCP server + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Server | Transport | Launch command | What it provides | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +### Quick usage (inside a council) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +var council = new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Optimist") + .AddMember("qwen2.5:7b", ollama, "Skeptic") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary.") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +Prefer to build the `Operator` yourself? Pass a pre-built instance: + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.Mcp; + +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // optional IContextCompressor + compressionOptions: null); // optional CompressionOptions + +var council = new CouncilBuilder() + /* …members… */ + .WithOperator(@operator) + .Build(); +``` + +### Direct usage (without a council) + +```csharp +await using var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); + +var result = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); + +Console.WriteLine(result.FinalAnswer); +``` + +### Dependency Injection + +Configure the Operator declaratively in `appsettings.json` under `Delibera:Operator`: + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + } + } +} +``` + +> ▶️ A complete, runnable demo lives in +> [`OperatorMcpToolsExample.cs`](src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs). +> Run it with `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> For the full technical write-up, see [docs/NET10-Upgrade.md](docs/NET10-Upgrade.md). + +--- + ## 🗜️ Context Compression Automatically compress context between deliberation rounds — save **30–70% of tokens** without losing meaning. @@ -401,6 +560,8 @@ dotnet run -- --compression # Context compression demo dotnet run -- --multiprovider # Multi-provider (cloud + local) council dotnet run -- --rag # Qdrant-backed RAG with Knowledge Keeper dotnet run -- --pgvector # pgvector-backed RAG +dotnet run -- --operator # Operator role basics (MCP tools) +dotnet run -- --operator-mcp # 🆕 Operator with browser + Marp MCP servers # Or run the full default demo (reads appsettings.json) dotnet run @@ -476,24 +637,109 @@ docker exec -it psql -U postgres -d council_vectors -c "CREATE EXTEN | `Qdrant.Client` | Qdrant vector DB gRPC client | | `Npgsql` | PostgreSQL ADO.NET provider | | `Pgvector` | pgvector type support for Npgsql | +| `ModelContextProtocol` | MCP client for the Operator role | +| `Microsoft.Extensions.AI`| Unified `IChatClient` / `IEmbeddingGenerator` AI abstractions & middleware | | `Microsoft.Extensions.*` | Configuration, DI and Options | --- +## 🤝 Microsoft.Extensions.AI + +Delibera integrates with [**Microsoft.Extensions.AI**](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +(v10.7.0) — the standard .NET abstraction layer for generative-AI services. This lets you drive a +council with **any** backend that ships an `IChatClient` (OpenAI, Azure OpenAI, Ollama, Anthropic, +LM Studio / LocalAI / vLLM, …) and compose a **middleware pipeline** (function/tool invocation, +logging, caching, telemetry) — all without writing a provider per vendor. + +### What you get + +| Type | Role | +| ---- | ---- | +| `ChatClientLLMProvider` | Adapts any `IChatClient` to Delibera's `ILLMProvider` (with **streaming** via `ChatStreamAsync`) | +| `EmbeddingGeneratorProvider` | Adapts any `IEmbeddingGenerator>` to `IEmbeddingProvider` for RAG | +| `MicrosoftAIExtensions` | Bridge helpers: `AsLLMProvider()`, `AsEmbeddingProvider()`, `AsChatClient()`, `WithMiddleware()` | +| `ProviderFactory.CreateFromChatClient(...)` | Build a cached provider straight from an `IChatClient` | +| `AddDeliberaChatClient(...)` / `AddDeliberaEmbeddingGenerator(...)` | Register the standard AI services in DI | + +### Use any `IChatClient` as a Delibera provider + +```csharp +using Delibera.Core.Extensions; +using Microsoft.Extensions.AI; + +// 1) Bring your own IChatClient. For OpenAI: +// dotnet add package Microsoft.Extensions.AI.OpenAI +IChatClient client = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + +// 2) (optional) compose middleware — function calling + logging +client = client.WithMiddleware(enableFunctionInvocation: true, loggerFactory); + +// 3) expose it to Delibera +ILLMProvider provider = client.AsLLMProvider("OpenAI"); + +var executor = new CouncilBuilder() + .AddMember("gpt-4o-mini", provider, "Architect") + .AddMember("gpt-4o-mini", provider, "Skeptic") + .SetChairman(Chairman.CreateStandard("gpt-4o-mini", provider)) + .WithStandardDebate() + .WithUserPrompt("Modular monolith or microservices for a 5-person team?") + .Build(); +``` + +Ollama works out of the box because OllamaSharp's `OllamaApiClient` natively implements +`IChatClient` and `IEmbeddingGenerator`: + +```csharp +using var ollama = new OllamaProvider("http://localhost:11434"); +IChatClient chat = ollama.AsChatClient(); // ready for middleware +ILLMProvider provider = chat.AsLLMProvider("Ollama"); +IEmbeddingProvider embeddings = ollama.AsEmbeddingGenerator().AsEmbeddingProvider("nomic-embed-text"); +``` + +### Streaming + +```csharp +await foreach (var chunk in provider.ChatStreamAsync("gpt-4o-mini", systemPrompt, userPrompt)) + Console.Write(chunk); +``` + +`ChatStreamAsync` is an additive default method on `ILLMProvider` — providers backed by an +`IChatClient` stream token-by-token, while older providers transparently fall back to a single call. + +### Dependency Injection + +```csharp +services.AddDeliberaChatClient( + sp => new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey) + .AsIChatClient() + .WithMiddleware(enableFunctionInvocation: true), + providerName: "OpenAI"); + +services.AddDeliberaEmbeddingGenerator( + sp => /* your IEmbeddingGenerator */, + modelName: "text-embedding-3-small"); +``` + +> Try it: `dotnet run --project src/Delibera.ConsoleApp -- --msai` + +--- + ## 🏛️ Architecture ``` Delibera.Core -├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper +├── Council/ ← CouncilBuilder, CouncilExecutor, Chairman, KnowledgeKeeper, Operator ├── Debate/ ← StandardDebate, CritiqueDebate, ConsensusDebate ├── 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, ... +└── Interfaces/ ← ILLMProvider, IRagProvider, IContextCompressor, IOperator, IMcpClient, ... ``` --- diff --git a/docs/NET10-Upgrade-RU.docx b/docs/NET10-Upgrade-RU.docx new file mode 100644 index 0000000..6daba5d Binary files /dev/null and b/docs/NET10-Upgrade-RU.docx differ diff --git a/docs/NET10-Upgrade-RU.md b/docs/NET10-Upgrade-RU.md new file mode 100644 index 0000000..aaa2a59 --- /dev/null +++ b/docs/NET10-Upgrade-RU.md @@ -0,0 +1,301 @@ +# Delibera — обновление до .NET 10 и C# 15 + +Документ описывает изменения, внесённые при переводе проекта **Delibera** на платформу +**.NET 10** и язык **C# 15 (preview)**, оптимизации производительности и новые примеры работы +роли **Operator** с MCP-инструментами (браузер и генерация Marp-презентаций). + +> Все изменения выполнены в ветке `feature/operator-mcp-role`. Проект собирается командой +> `dotnet build` без предупреждений и ошибок. + +--- + +## Содержание + +1. [Целевая платформа и версия языка](#1-целевая-платформа-и-версия-языка) +2. [Обновление зависимостей](#2-обновление-зависимостей) +3. [Оптимизации производительности (high performance)](#3-оптимизации-производительности-high-performance) +4. [Примеры Operator + MCP-инструменты](#4-примеры-operator--mcp-инструменты) +5. [Сборка и запуск](#5-сборка-и-запуск) +6. [Краткая сводка изменённых файлов](#6-краткая-сводка-изменённых-файлов) + +--- + +## 1. Целевая платформа и версия языка + +Оба проекта переведены на единый стек: + +| Свойство | Было | Стало | +|---------------------|-------------|----------------| +| `TargetFramework` | `net10.0` | `net10.0` | +| `LangVersion` | `14.0` | `preview` *(C# 15)* | +| `Nullable` | `enable` | `enable` | +| `ImplicitUsings` | `enable` | `enable` | + +Изменения внесены в файлы: + +- `src/Delibera.Core/Delibera.Core.csproj` +- `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` + +### ⚠️ Важно про «C# 15» + +На момент обновления установлен GA-релиз **.NET SDK 10.0.301**, в котором компилятор Roslyn +поддерживает версии языка до **14.0** включительно, а следующая (будущая C# 15) доступна +**только** через значение `preview`. Прямое указание `15.0` приводит +к ошибке компиляции: + +``` +error CS1617: Invalid option '15.0' for /langversion +``` + +Поэтому используется `preview` — это включает максимально доступный +набор языковых возможностей следующей версии (C# 15). Когда выйдет SDK с официальной поддержкой +`15.0`, значение можно будет заменить на `15.0` без других изменений в коде. + +--- + +## 2. Обновление зависимостей + +Все NuGet-пакеты приведены к версиям, совместимым с .NET 10. Большинство пакетов уже находились +на актуальных версиях для .NET 10; обновлён `Microsoft.SourceLink.GitHub`. + +### Delibera.Core + +| Пакет | Версия | Назначение | +|--------------------------------------------------------|-----------|---------------------------------------| +| `Microsoft.Extensions.Configuration.Abstractions` | 10.0.9 | Конфигурация (DI) | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | Чтение `appsettings.json` | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | Контейнер DI | +| `Microsoft.Extensions.DependencyInjection.Abstractions`| 10.0.9 | Абстракции DI | +| `Microsoft.Extensions.Options` | 10.0.9 | Паттерн Options | +| `Microsoft.Extensions.Options.ConfigurationExtensions` | 10.0.9 | Привязка Options к конфигурации | +| `ModelContextProtocol` | 1.4.0 | SDK для MCP-серверов (роль Operator) | +| `Npgsql` | 10.0.3 | PostgreSQL / pgvector | +| `OllamaSharp` | 5.4.25 | LLM-провайдер Ollama | +| `Pgvector` | 0.3.2 | Векторный поиск в PostgreSQL | +| `Qdrant.Client` | 1.18.1 | Векторное хранилище Qdrant | +| `Microsoft.SourceLink.GitHub` | **10.0.300** *(было 10.0.102)* | Source Link для отладки | + +### Delibera.ConsoleApp + +| Пакет | Версия | Назначение | +|------------------------------------------------------|--------|-----------------------------| +| `Microsoft.Extensions.Configuration` | 10.0.9 | Конфигурация | +| `Microsoft.Extensions.Configuration.Binder` | 10.0.9 | Привязка конфигурации | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | JSON-конфигурация | +| `Microsoft.Extensions.Configuration.UserSecrets` | 10.0.9 | User Secrets | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | Контейнер DI | + +> Проверка актуальности выполнена через `dotnet list package --outdated` и NuGet flat-container +> API — на момент обновления более свежих стабильных версий для перечисленных пакетов нет. + +--- + +## 3. Оптимизации производительности (high performance) + +Внесены целенаправленные оптимизации «горячих» путей с применением `Span`/`ReadOnlySpan`, +SIMD (`System.Numerics.Vector`) и `ArrayPool`. Все оптимизации сохраняют прежнее поведение +(проверено численно). + +### 3.1. SIMD-векторизация косинусной близости + +**Файл:** `src/Delibera.Core/Compression/SemanticCompressor.cs` → `CosineSimilarity` + +Косинусная близость — самый «горячий» цикл при семантическом сжатии и дедупликации (вызывается +для каждой пары предложений по всем измерениям эмбеддинга, обычно 768–1536). Реализация переписана: + +- Сигнатура изменена с `float[]` на `ReadOnlySpan` — это zero-copy и позволяет передавать + массивы, срезы и `stackalloc`-буферы без аллокаций (вызывающий код с `float[]` работает без изменений). +- Основной цикл векторизован через `Vector`: за одну итерацию обрабатывается + `Vector.Count` элементов (на современных x64/ARM64 — 8–16 значений за такт). +- Скалярный «хвост» обрабатывает остаток, а также служит запасным путём при отсутствии аппаратного + ускорения (`Vector.IsHardwareAccelerated == false`). + +```csharp +internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) +{ + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + for (; i < a.Length; i++) { dot += a[i]*b[i]; magA += a[i]*a[i]; magB += b[i]*b[i]; } + + var denom = Math.Sqrt(magA) * Math.Sqrt(magB); + return denom > 0 ? dot / denom : 0; +} +``` + +**Корректность.** Численное сравнение с эталонной (скалярной) реализацией на длинах +`1, 3, 7, 8, 16, 17, 100, 1536` дало максимальную абсолютную погрешность ≈ `6·10⁻⁸` +(на уровне точности `float`), результат идентичен. + +### 3.2. Подсчёт токенов без аллокаций (`ReadOnlySpan`) + +**Файл:** `src/Delibera.Core/Compression/TokenCounter.cs` + +- Добавлена перегрузка `EstimateTokens(ReadOnlySpan text)` — позволяет оценивать токены + для срезов строк без выделения подстрок. +- Внутренний `CountWords` принимает `ReadOnlySpan` и перебирает символы по `ref struct` + enumerator'у — без боксинга и без копий. +- Существующий `EstimateTokens(string?)` сохранён и теперь делегирует в span-перегрузку + (через `text.AsSpan()`), поэтому публичный API полностью обратно совместим. + +### 3.3. Хеширование ключа кэша через `ArrayPool` + `stackalloc` + +**Файл:** `src/Delibera.Core/Compression/CompressionCache.cs` → `ComputeKey` + +Раньше построение ключа кэша создавало две лишние кучи-аллокации на каждый вызов: +интерполированную строку `"{strategy}:{text}"` и массив байтов из `Encoding.UTF8.GetBytes`. +Новая реализация: + +- Арендует буфер символов из `ArrayPool.Shared`, склеивает в него `strategy + ':' + text`. +- Арендует буфер байтов из `ArrayPool.Shared` и кодирует UTF-8 напрямую в него + (`Encoding.UTF8.GetBytes(span, span)`). +- Считает SHA-256 в стек-буфер `stackalloc byte[SHA256.HashSizeInBytes]` и форматирует hex. +- Гарантированно возвращает арендованные буферы в `finally`. + +Итог: на «тёплом» пути кэширования сжатия исключены промежуточные аллокации больших строк/массивов, +что снижает нагрузку на GC при больших объёмах контекста дебатов. + +### 3.4. `ValueTask` в роли Operator + +Метод освобождения ресурсов Operator реализован как `public async ValueTask DisposeAsync()` +(`src/Delibera.Core/Council/Operator.cs`) — стандартный для .NET 10 паттерн `IAsyncDisposable`, +позволяющий избежать аллокации `Task` на пути освобождения MCP-клиентов. Новый пример +(`OperatorMcpToolsExample`) использует `await using`, корректно задействуя этот путь. + +### Сводка оптимизаций + +| Приём | Где применён | Эффект | +|---------------------|------------------------------------------------|---------------------------------------------| +| `ReadOnlySpan` + SIMD | `SemanticCompressor.CosineSimilarity` | 8–16× ширина обработки, без копий | +| `ReadOnlySpan`| `TokenCounter` (подсчёт слов/токенов) | Нет аллокаций подстрок | +| `ArrayPool` + `stackalloc` | `CompressionCache.ComputeKey` | −2 кучи-аллокации на вызов, меньше нагрузки на GC | +| `ValueTask` | `Operator.DisposeAsync` | Освобождение без аллокации `Task` | + +--- + +## 4. Примеры Operator + MCP-инструменты + +Добавлен новый пример **`OperatorMcpToolsExample`** +(`src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs`), демонстрирующий работу роли +**Operator** с реальными MCP-серверами. + +### Подключаемые MCP-серверы + +| Сервер | Транспорт | Команда запуска | Что даёт | +|------------|-----------|------------------------------------------------------------|-----------------------------------------------------| +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +> Также в примере показан (закомментирован) HTTP/SSE-вариант через `McpServerConfig.Http(...)` +> для удалённых MCP-серверов с заголовками авторизации. + +### Что демонстрирует пример + +**Раздел A — прямой вызов Operator** (без совета): + +- Ручное создание MCP-клиентов (`McpClientAdapter`) и объекта `Operator`. +- `await using` для корректного освобождения через `ValueTask DisposeAsync`. +- `InitializeAsync()` — подключение к серверам и обнаружение инструментов. +- Две задачи через `ExecuteTaskAsync`: + 1. **Браузер:** открыть `https://modelcontextprotocol.io` и кратко пересказать, что такое MCP. + 2. **Marp:** сгенерировать презентацию из 3 слайдов и сохранить как `deck.html`. + +**Раздел B — Operator внутри совета** (делегирование маркером `[[OPERATOR: ...]]`): + +- Построение совета через `CouncilBuilder` с участниками, председателем (`Chairman`) и Operator'ом + (удобная перегрузка `WithOperator(modelName, provider, servers)`). +- Подписка на `OnRoundCompleted` для вывода активности Operator по раундам. +- Сохранение Markdown-отчёта дебатов и готовых презентаций. + +### Запуск примера + +```bash +# из каталога проекта +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp +``` + +#### Предварительные требования + +- **Node.js + npx** — нужны для запуска npx-MCP-серверов (`@playwright/mcp`, `@marp-team/marp-cli`). +- Для браузера при первом запуске Playwright скачает движки: `npx playwright install`. +- **Ollama** запущен локально (`ollama serve`) с моделями `llama3.2`, `qwen2.5` + (модель Operator можно заменить на любую «дешёвую»). + +> Пример устойчив к отсутствию окружения: при ошибке выводятся понятные подсказки на русском, +> процесс не падает аварийно. + +--- + +## 5. Сборка и запуск + +```bash +# Сборка обоих проектов +dotnet build + +# Сборка в Release +dotnet build -c Release + +# Запуск консольного приложения +dotnet run --project src/Delibera.ConsoleApp + +# Доступные демонстрации (флаги): +dotnet run --project src/Delibera.ConsoleApp -- --operator # базовый пример Operator +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp # 🆕 браузер + Marp +dotnet run --project src/Delibera.ConsoleApp -- --compression # сжатие контекста +dotnet run --project src/Delibera.ConsoleApp -- --rag # RAG +dotnet run --project src/Delibera.ConsoleApp -- --di # Dependency Injection +``` + +Результат сборки: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +> **Примечание о localhost.** В примерах используется адрес `http://localhost:11434` (Ollama) и +> локальные пути для вывода презентаций. Это localhost той машины, где запускается приложение +> (среда агента Abacus AI), а не вашего компьютера. Чтобы запустить локально, скачайте файлы через +> иконку «Files», перейдите в скачанную папку, разверните приложение на своей системе и запустите его. + +--- + +## 6. Краткая сводка изменённых файлов + +| Файл | Изменение | +|-----------------------------------------------------------------|--------------------------------------------------------------| +| `src/Delibera.Core/Delibera.Core.csproj` | `LangVersion=preview`, `SourceLink → 10.0.300` | +| `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` | `LangVersion=preview` | +| `src/Delibera.Core/Compression/SemanticCompressor.cs` | SIMD-векторизация `CosineSimilarity` + `ReadOnlySpan` | +| `src/Delibera.Core/Compression/TokenCounter.cs` | Перегрузка `ReadOnlySpan`, span-подсчёт слов | +| `src/Delibera.Core/Compression/CompressionCache.cs` | `ArrayPool` + `stackalloc` в `ComputeKey` | +| `src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs` | 🆕 Пример Operator с браузером и Marp | +| `src/Delibera.ConsoleApp/Program.cs` | Флаг `--operator-mcp`, исправлено nullable-предупреждение | + +--- + +*Документ подготовлен в рамках перевода Delibera на .NET 10 / C# 15.* diff --git a/docs/NET10-Upgrade-RU.pdf b/docs/NET10-Upgrade-RU.pdf new file mode 100644 index 0000000..26f7eec Binary files /dev/null and b/docs/NET10-Upgrade-RU.pdf differ diff --git a/docs/NET10-Upgrade.docx b/docs/NET10-Upgrade.docx new file mode 100644 index 0000000..70a6577 Binary files /dev/null and b/docs/NET10-Upgrade.docx differ diff --git a/docs/NET10-Upgrade.md b/docs/NET10-Upgrade.md new file mode 100644 index 0000000..c6bee4d --- /dev/null +++ b/docs/NET10-Upgrade.md @@ -0,0 +1,306 @@ +# Delibera — Upgrade to .NET 10 and C# 15 + +This document describes the changes made while migrating the **Delibera** project to the +**.NET 10** platform and the **C# 15 (preview)** language, the performance optimizations, and the +new examples of the **Operator** role working with MCP tools (browser and Marp presentation +generation). + +> All changes were made in the `feature/operator-mcp-role` branch. The project builds with +> `dotnet build` without warnings or errors. + +--- + +## Table of Contents + +1. [Target Platform and Language Version](#1-target-platform-and-language-version) +2. [Dependency Updates](#2-dependency-updates) +3. [Performance Optimizations (high performance)](#3-performance-optimizations-high-performance) +4. [Operator + MCP Tools Examples](#4-operator--mcp-tools-examples) +5. [Build and Run](#5-build-and-run) +6. [Summary of Changed Files](#6-summary-of-changed-files) + +--- + +## 1. Target Platform and Language Version + +Both projects were migrated to a single stack: + +| Property | Before | After | +|---------------------|-------------|----------------| +| `TargetFramework` | `net10.0` | `net10.0` | +| `LangVersion` | `14.0` | `preview` *(C# 15)* | +| `Nullable` | `enable` | `enable` | +| `ImplicitUsings` | `enable` | `enable` | + +The changes were made in the files: + +- `src/Delibera.Core/Delibera.Core.csproj` +- `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` + +### ⚠️ Important note about "C# 15" + +At the time of the upgrade, the GA release **.NET SDK 10.0.301** is installed, in which the Roslyn +compiler supports language versions up to and including **14.0**, while the next one (the future +C# 15) is available **only** through the `preview` value. Specifying `15.0` +directly results in a compilation error: + +``` +error CS1617: Invalid option '15.0' for /langversion +``` + +Therefore `preview` is used — this enables the maximum available set of +language features of the next version (C# 15). When an SDK with official `15.0` support is released, +the value can be replaced with `15.0` without any other changes to the code. + +--- + +## 2. Dependency Updates + +All NuGet packages were brought to versions compatible with .NET 10. Most packages were already on +their current versions for .NET 10; `Microsoft.SourceLink.GitHub` was updated. + +### Delibera.Core + +| Package | Version | Purpose | +|--------------------------------------------------------|-----------|---------------------------------------| +| `Microsoft.Extensions.Configuration.Abstractions` | 10.0.9 | Configuration (DI) | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | Reading `appsettings.json` | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | DI container | +| `Microsoft.Extensions.DependencyInjection.Abstractions`| 10.0.9 | DI abstractions | +| `Microsoft.Extensions.Options` | 10.0.9 | Options pattern | +| `Microsoft.Extensions.Options.ConfigurationExtensions` | 10.0.9 | Binding Options to configuration | +| `ModelContextProtocol` | 1.4.0 | SDK for MCP servers (Operator role) | +| `Npgsql` | 10.0.3 | PostgreSQL / pgvector | +| `OllamaSharp` | 5.4.25 | Ollama LLM provider | +| `Pgvector` | 0.3.2 | Vector search in PostgreSQL | +| `Qdrant.Client` | 1.18.1 | Qdrant vector store | +| `Microsoft.SourceLink.GitHub` | **10.0.300** *(was 10.0.102)* | Source Link for debugging | + +### Delibera.ConsoleApp + +| Package | Version | Purpose | +|------------------------------------------------------|---------|-----------------------------| +| `Microsoft.Extensions.Configuration` | 10.0.9 | Configuration | +| `Microsoft.Extensions.Configuration.Binder` | 10.0.9 | Configuration binding | +| `Microsoft.Extensions.Configuration.Json` | 10.0.9 | JSON configuration | +| `Microsoft.Extensions.Configuration.UserSecrets` | 10.0.9 | User Secrets | +| `Microsoft.Extensions.DependencyInjection` | 10.0.9 | DI container | + +> The currency check was performed via `dotnet list package --outdated` and the NuGet flat-container +> API — at the time of the upgrade there are no newer stable versions for the listed packages. + +--- + +## 3. Performance Optimizations (high performance) + +Targeted optimizations of the "hot" paths were made using `Span`/`ReadOnlySpan`, +SIMD (`System.Numerics.Vector`), and `ArrayPool`. All optimizations preserve the previous +behavior (verified numerically). + +### 3.1. SIMD vectorization of cosine similarity + +**File:** `src/Delibera.Core/Compression/SemanticCompressor.cs` → `CosineSimilarity` + +Cosine similarity is the hottest loop during semantic compression and deduplication (it is called +for every pair of sentences across all embedding dimensions, typically 768–1536). The implementation +was rewritten: + +- The signature was changed from `float[]` to `ReadOnlySpan` — this is zero-copy and allows + passing arrays, slices, and `stackalloc` buffers without allocations (calling code using `float[]` + works without changes). +- The main loop is vectorized via `Vector`: each iteration processes + `Vector.Count` elements (on modern x64/ARM64 — 8–16 values per cycle). +- The scalar "tail" handles the remainder and also serves as a fallback path when there is no + hardware acceleration (`Vector.IsHardwareAccelerated == false`). + +```csharp +internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) +{ + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + for (; i < a.Length; i++) { dot += a[i]*b[i]; magA += a[i]*a[i]; magB += b[i]*b[i]; } + + var denom = Math.Sqrt(magA) * Math.Sqrt(magB); + return denom > 0 ? dot / denom : 0; +} +``` + +**Correctness.** A numerical comparison with the reference (scalar) implementation on lengths +`1, 3, 7, 8, 16, 17, 100, 1536` yielded a maximum absolute error of ≈ `6·10⁻⁸` +(at the precision level of `float`); the result is identical. + +### 3.2. Allocation-free token counting (`ReadOnlySpan`) + +**File:** `src/Delibera.Core/Compression/TokenCounter.cs` + +- Added an `EstimateTokens(ReadOnlySpan text)` overload — it allows estimating tokens for + string slices without allocating substrings. +- The internal `CountWords` accepts `ReadOnlySpan` and iterates over characters using a + `ref struct` enumerator — without boxing and without copies. +- The existing `EstimateTokens(string?)` is preserved and now delegates to the span overload + (via `text.AsSpan()`), so the public API is fully backward compatible. + +### 3.3. Cache key hashing via `ArrayPool` + `stackalloc` + +**File:** `src/Delibera.Core/Compression/CompressionCache.cs` → `ComputeKey` + +Previously, building the cache key created two extra heap allocations on every call: +the interpolated string `"{strategy}:{text}"` and a byte array from `Encoding.UTF8.GetBytes`. +The new implementation: + +- Rents a character buffer from `ArrayPool.Shared` and concatenates `strategy + ':' + text` + into it. +- Rents a byte buffer from `ArrayPool.Shared` and encodes UTF-8 directly into it + (`Encoding.UTF8.GetBytes(span, span)`). +- Computes SHA-256 into a stack buffer `stackalloc byte[SHA256.HashSizeInBytes]` and formats hex. +- Guarantees that the rented buffers are returned in `finally`. + +Result: on the "warm" compression-cache path, intermediate allocations of large strings/arrays are +eliminated, which reduces GC pressure for large volumes of debate context. + +### 3.4. `ValueTask` in the Operator role + +The Operator resource-disposal method is implemented as `public async ValueTask DisposeAsync()` +(`src/Delibera.Core/Council/Operator.cs`) — the standard .NET 10 `IAsyncDisposable` pattern, which +avoids a `Task` allocation on the MCP-client disposal path. The new example +(`OperatorMcpToolsExample`) uses `await using`, correctly exercising this path. + +### Optimization summary + +| Technique | Where applied | Effect | +|---------------------|------------------------------------------------|---------------------------------------------| +| `ReadOnlySpan` + SIMD | `SemanticCompressor.CosineSimilarity` | 8–16× processing width, no copies | +| `ReadOnlySpan`| `TokenCounter` (word/token counting) | No substring allocations | +| `ArrayPool` + `stackalloc` | `CompressionCache.ComputeKey` | −2 heap allocations per call, less GC pressure | +| `ValueTask` | `Operator.DisposeAsync` | Disposal without a `Task` allocation | + +--- + +## 4. Operator + MCP Tools Examples + +A new example **`OperatorMcpToolsExample`** was added +(`src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs`), demonstrating the **Operator** role +working with real MCP servers. + +### Connected MCP servers + +| Server | Transport | Launch command | What it provides | +|------------|-----------|------------------------------------------------------------|-----------------------------------------------------| +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +> The example also shows (commented out) an HTTP/SSE variant via `McpServerConfig.Http(...)` +> for remote MCP servers with authorization headers. + +### What the example demonstrates + +**Section A — direct Operator call** (without a council): + +- Manual creation of MCP clients (`McpClientAdapter`) and an `Operator` object. +- `await using` for correct disposal via `ValueTask DisposeAsync`. +- `InitializeAsync()` — connecting to the servers and discovering tools. +- Two tasks via `ExecuteTaskAsync`: + 1. **Browser:** open `https://modelcontextprotocol.io` and briefly summarize what MCP is. + 2. **Marp:** generate a 3-slide presentation and save it as `deck.html`. + +**Section B — Operator inside a council** (delegation via the `[[OPERATOR: ...]]` marker): + +- Building a council via `CouncilBuilder` with participants, a chairman (`Chairman`), and an Operator + (the convenient `WithOperator(modelName, provider, servers)` overload). +- Subscribing to `OnRoundCompleted` to print Operator activity per round. +- Saving the debate Markdown report and the generated presentations. + +### Running the example + +```bash +# from the project directory +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp +``` + +#### Prerequisites + +- **Node.js + npx** — required to launch the npx MCP servers (`@playwright/mcp`, `@marp-team/marp-cli`). +- For the browser, on first run Playwright will download the engines: `npx playwright install`. +- **Ollama** running locally (`ollama serve`) with the models `llama3.2`, `qwen2.5` + (the Operator model can be replaced with any "cheap" one). + +> The example is resilient to a missing environment: on error it prints clear hints, and the +> process does not crash. + +--- + +## 5. Build and Run + +```bash +# Build both projects +dotnet build + +# Build in Release +dotnet build -c Release + +# Run the console application +dotnet run --project src/Delibera.ConsoleApp + +# Available demos (flags): +dotnet run --project src/Delibera.ConsoleApp -- --operator # basic Operator example +dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp # 🆕 browser + Marp +dotnet run --project src/Delibera.ConsoleApp -- --compression # context compression +dotnet run --project src/Delibera.ConsoleApp -- --rag # RAG +dotnet run --project src/Delibera.ConsoleApp -- --di # Dependency Injection +``` + +Build result: + +``` +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +> **Note about localhost.** The examples use the address `http://localhost:11434` (Ollama) and +> local paths for presentation output. This is the localhost of the machine where the application +> runs (the Abacus AI Agent environment), not your computer. To run it locally, download the files +> via the "Files" icon, go into the downloaded folder, deploy the application on your own system, +> and run it. + +--- + +## 6. Summary of Changed Files + +| File | Change | +|-----------------------------------------------------------------|--------------------------------------------------------------| +| `src/Delibera.Core/Delibera.Core.csproj` | `LangVersion=preview`, `SourceLink → 10.0.300` | +| `src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj` | `LangVersion=preview` | +| `src/Delibera.Core/Compression/SemanticCompressor.cs` | SIMD vectorization of `CosineSimilarity` + `ReadOnlySpan` | +| `src/Delibera.Core/Compression/TokenCounter.cs` | `ReadOnlySpan` overload, span-based word counting | +| `src/Delibera.Core/Compression/CompressionCache.cs` | `ArrayPool` + `stackalloc` in `ComputeKey` | +| `src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs` | 🆕 Operator example with browser and Marp | +| `src/Delibera.ConsoleApp/Program.cs` | `--operator-mcp` flag, fixed nullable warning | + +--- + +*Document prepared as part of the Delibera upgrade to .NET 10 / C# 15.* diff --git a/docs/NET10-Upgrade.pdf b/docs/NET10-Upgrade.pdf new file mode 100644 index 0000000..82103f1 Binary files /dev/null and b/docs/NET10-Upgrade.pdf differ diff --git a/docs/QuickStart-RU.md b/docs/QuickStart-RU.md new file mode 100644 index 0000000..c68ac59 --- /dev/null +++ b/docs/QuickStart-RU.md @@ -0,0 +1,452 @@ +
+ +Delibera + +# Delibera — Быстрый старт + +### ⚖️ Продуманные решения с помощью ИИ + +🇬🇧 [English version (QuickStart.md)](QuickStart.md) + +
+ +Это руководство за несколько минут проведёт вас через ваше первое обсуждение с помощью ИИ в +**Delibera**. По его завершении у вас будут запущенные локально многомодельные дебаты и +Markdown-стенограмма на диске. + +--- + +## 1. Требования + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — проект нацелен на + `net10.0` и собирается с `LangVersion=preview` для включения возможностей **C# 15**. См. + [NET10-Upgrade-RU.md](NET10-Upgrade-RU.md) для полных заметок о миграции. +- Запущенный экземпляр [Ollama](https://ollama.com) — либо: + - **Локально:** [установите Ollama](https://ollama.com/download) и запустите `ollama serve`, либо + - **Cloud:** зарегистрируйтесь в [Ollama Cloud](https://ollama.com/cloud) и получите API-ключ + (без установки, без GPU). +- **Опционально — для роли Operator:** [Node.js + npx](https://nodejs.org) для запуска MCP-серверов + (например, `@playwright/mcp`, `@marp-team/marp-cli`). См. [раздел 7](#7-использование-operator-mcp-инструменты). + +### Модели для установки + +Выберите **один** из наборов моделей ниже в зависимости от вашего железа. Набор 🟡 «Стандартный» +рекомендуется для большинства случаев. + +#### 🟢 Минимальный — слабое железо / smoke-тесты (≈ 2 ГБ всего) + +```bash +ollama pull llama3.2:1b +ollama pull qwen2.5:1.5b +ollama pull nomic-embed-text +``` + +#### 🟡 Стандартный — рекомендуется (≈ 7 ГБ всего) + +```bash +ollama pull llama3.2:3b +ollama pull qwen2.5:7b +ollama pull nomic-embed-text +``` + +#### 🔴 Высокопроизводительный — GPU с ≥ 24 ГБ VRAM или Ollama Cloud (≈ 30+ ГБ) + +```bash +ollama pull llama3.1:8b +ollama pull qwen2.5:14b +ollama pull mistral:7b +ollama pull nomic-embed-text +``` + +> ℹ️ Ollama Cloud использует те же имена моделей — локальная загрузка не нужна, только API-ключ. + +--- + +## 2. Установка пакета + +```bash +dotnet add package Delibera.Core +``` + +Или клонируйте репозиторий и сошлитесь на `Delibera.Core` напрямую: + +```bash +git clone https://github.com/delibera/Delibera.git +cd Delibera +``` + +--- + +## 3. Ваше первое обсуждение + +Создайте консольный проект и вставьте следующее: + +```csharp +// Program.cs +using Delibera.Core.Council; +using Delibera.Core.Providers; + +using var factory = new ProviderFactory(); +var ollama = factory.CreateOllama("http://localhost:11434"); + +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() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +Запустите: + +```bash +dotnet run +``` + +Delibera проведёт структурированные многораундовые дебаты и запишет полную стенограмму и вердикт +председателя в файл `deliberation.md`. + +> ☁️ **Используете Ollama Cloud?** Замените endpoint на `https://api.ollama.com` и передайте API-ключ: +> `factory.CreateOllama("https://api.ollama.com", "YOUR_API_KEY")`. + +--- + +## 4. Использование Dependency Injection + +```csharp +using Delibera.Core.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); + +// Привязывает секцию "Delibera" из appsettings.json +services.AddDelibera(configuration, "Delibera"); + +var provider = services.BuildServiceProvider(); +var builder = provider.GetRequiredService(); +``` + +`appsettings.json`: + +```json +{ + "Delibera": { + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge", + "ConnectionString": null + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true, + "FilePrefix": null + } + } +} +``` + +> Для **Ollama Cloud** задайте `Providers:DefaultEndpoint` равным `https://api.ollama.com` и +> `Providers:ApiKey` равным вашему ключу. + +--- + +## 5. Добавление сжатия контекста + +```csharp +using Delibera.Core.Compression; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.TokenStats?.ToSummary()); +``` + +Сжатие экономит примерно **30–70% токенов** без потери смысла контекста дебатов. + +--- + +## 6. Добавление RAG с Qdrant + +```bash +docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant +``` + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.RAG; + +var ollama = new OllamaProvider("http://localhost:11434"); +var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); + +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreateQdrant(embeddings, "localhost", 6334); + +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "architecture_kb"); +await keeper.IndexFileAsync("./docs/architecture.md"); + +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Backend Expert") + .AddMember("qwen2.5:7b", ollama, "DevOps Expert") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithKnowledgeKeeper(keeper) + .WithStandardDebate() + .WithUserPrompt("Our startup has 4 developers. Microservices or monolith?") + .WithMaxRounds(4) + .Build() + .ExecuteAsync(); +``` + +> 💡 Замените `CreateQdrant(...)` на `CreatePgVector(embeddings, connectionString)`, чтобы +> использовать PostgreSQL/pgvector. + +--- + +## 7. Использование Operator (MCP-инструменты) + +**Operator** — это микроагент, который соединяет совет с внешними инструментами через серверы +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) — веб-навигация, доступ к +файловой системе, генерация Marp-презентаций и многое другое. Участники делегируют задачу в любой +момент, написав маркер `[[OPERATOR: ...]]`, и Operator запускает нужные инструменты и возвращает +результат в следующий раунд. + +### 7.1. Установка требований + +Серверы из примеров ниже запускаются через `npx`, поэтому вам нужны **Node.js + npx**: + +```bash +# Проверьте, что Node.js / npx доступны +node --version +npx --version + +# Для сервера браузера один раз установите движки Playwright: +npx playwright install +``` + +Также нужна запущенная **Ollama** с дешёвой моделью для Operator (например, `llama3.2`) — Operator +использует свою собственную модель, отдельную от участников совета. + +### 7.2. Настройка MCP-серверов + +Каждый MCP-сервер описывается через `McpServerConfig`. Используйте `Stdio(...)` для локальных +процессов или `Http(...)` для удалённых серверов: + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // 🌐 Браузер — навигация, чтение страниц, клики, скриншоты + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + // 🎯 Marp — генерация презентаций HTML/PDF/PPTX из Markdown + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …или удалённый HTTP/SSE MCP-сервер с заголовками авторизации: + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Сервер | Транспорт | Команда запуска | Что даёт | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Навигация по сайтам, чтение страниц, клики, скриншоты | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Генерация презентаций (HTML/PDF/PPTX) из Markdown | + +### 7.3. Прямое использование (без совета) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.Mcp; + +var ollama = new OllamaProvider("http://localhost:11434"); + +await using var @operator = new Operator( + new CouncilMember("llama3.2", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); // подключается к серверам и обнаруживает инструменты + +// 1) Открыть сайт и пересказать его +var browse = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); +Console.WriteLine(browse.FinalAnswer); + +// 2) Сгенерировать презентацию из 3 слайдов и сохранить как deck.html +var deck = await @operator.ExecuteTaskAsync( + "Generate a 3-slide Marp presentation about .NET 10 and save it as deck.html."); +Console.WriteLine(deck.FinalAnswer); +``` + +> `await using` освобождает MCP-клиентов через `ValueTask DisposeAsync` у Operator. + +### 7.4. Operator внутри дебатов + +Добавьте Operator в совет с помощью удобной перегрузки `WithOperator(...)`. Участники затем +делегируют работу через маркер `[[OPERATOR: ...]]`, и их результаты внедряются в последующие раунды: + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Researcher") + .AddMember("qwen2.5:7b", ollama, "Reviewer") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator использует свою более дешёвую модель; reuseCompression разделяет компрессор совета + .WithOperator("llama3.2", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithSystemPrompt( + "You may delegate research or presentation tasks to the Operator by writing " + + "[[OPERATOR: ]] in your message.") + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary deck.") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +Сообщение участника может выглядеть так: + +``` +I think we should highlight the runtime improvements. +[[OPERATOR: open https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10 and list the top 5 features]] +``` + +Все взаимодействия с Operator записываются по раундам и отображаются в финальном Markdown-отчёте в +блоке **🛠️ Operator Interactions**. + +### 7.5. Настройка через `appsettings.json` + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "marp", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@marp-team/marp-cli", "--server", "./out" ] + } + ] + } + } +} +``` + +> ▶️ Запустите полное демо: `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> См. [`OperatorMcpToolsExample.cs`](../src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs) +> и [NET10-Upgrade-RU.md](NET10-Upgrade-RU.md) для деталей. + +--- + +## 8. Сохранение раздельных выходных файлов + +```csharp +var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); +// → debate__result.md +// → debate__statistics.md +// → debate__logs.md +``` + +Каждый файл — это обычный Markdown, идеальный для коммита в git, публикации в чат-инструменты или +передачи обратно в другую LLM. + +--- + +## 9. Изучите ConsoleApp + +В репозитории есть запускаемое демо в +[`src/Delibera.ConsoleApp/`](../src/Delibera.ConsoleApp/), которое задействует каждую возможность: + +| Пример | Команда | Что демонстрирует | +| -------------------- | -------------------------------- | ---------------------------------------------- | +| Быстрый старт | `dotnet run` | Дебаты по умолчанию из `appsettings.json` | +| Dependency injection | `dotnet run -- --di` | `AddDelibera()` + резолвинг `ICouncilBuilder` | +| Раздельный вывод | `dotnet run -- --separate-files` | `SaveAllAsync()` — три Markdown-файла | +| Сжатие контекста | `dotnet run -- --compression` | Все 4 стратегии + кэш + статистика токенов | +| Несколько провайдеров| `dotnet run -- --multiprovider` | Cloud + локальные модели в одном совете | +| RAG (Qdrant) | `dotnet run -- --rag` | Индексирование документов + Knowledge Keeper | +| RAG (pgvector) | `dotnet run -- --pgvector` | Векторный поиск на базе PostgreSQL | +| Operator (основы) | `dotnet run -- --operator` | Роль Operator с MCP-инструментами | +| Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 MCP-серверы browser + Marp в совете | +| Microsoft.Extensions.AI | `dotnet run -- --msai` | 🆕 `IChatClient`/`IEmbeddingGenerator` + middleware | + +--- + +## Дальнейшие шаги + +- Прочитайте [README](../README-RU.md) для полного обзора возможностей, архитектуры и паттернов проектирования. +- См. [CONTRIBUTING.md](../CONTRIBUTING.md), если хотите внести вклад. + +--- + +
+ +**⚖️ Delibera — Продуманные решения с помощью ИИ** + +
diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 56fbdb0..f1fda84 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -6,6 +6,8 @@ ### ⚖️ Thoughtful AI Decisions +🇷🇺 [Русская версия (QuickStart-RU.md)](QuickStart-RU.md) + This guide walks you through your first AI deliberation with **Delibera** in a few minutes. @@ -15,11 +17,15 @@ By the end you'll have a multi-model debate running locally and a Markdown trans ## 1. Prerequisites -- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (≥ 10.0.301) — the project targets + `net10.0` and builds with `LangVersion=preview` to enable **C# 15** features. See + [NET10-Upgrade.md](NET10-Upgrade.md) for the full migration notes. - A running [Ollama](https://ollama.com) instance — either: - **Local:** [install Ollama](https://ollama.com/download) and run `ollama serve`, or - **Cloud:** sign up for [Ollama Cloud](https://ollama.com/cloud) and grab an API key (no install, no GPU required). +- **Optional — for the Operator role:** [Node.js + npx](https://nodejs.org) to launch MCP servers + (e.g. `@playwright/mcp`, `@marp-team/marp-cli`). See [section 7](#7-using-the-operator-mcp-tools). ### Models to install @@ -239,7 +245,165 @@ var result = await new CouncilBuilder() --- -## 7. Saving Separate Output Files +## 7. Using the Operator (MCP Tools) + +The **Operator** is a micro-agent that connects the council to external tools through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers — web browsing, +file system access, Marp presentation generation, and more. Participants delegate a task at any +moment by writing a `[[OPERATOR: ...]]` marker, and the Operator runs the right tools and feeds the +result back into the next round. + +### 7.1. Install the prerequisites + +The example servers below are launched via `npx`, so you need **Node.js + npx**: + +```bash +# Verify Node.js / npx are available +node --version +npx --version + +# For the browser server, install the Playwright engines once: +npx playwright install +``` + +You also need **Ollama** running with a cheap model for the Operator (e.g. `llama3.2`) — the +Operator uses its own model, separate from the council members. + +### 7.2. Configure MCP servers + +Each MCP server is described by an `McpServerConfig`. Use `Stdio(...)` for local processes or +`Http(...)` for remote servers: + +```csharp +using Delibera.Core.Models; + +var servers = new[] +{ + // 🌐 Browser — navigation, page reading, clicks, screenshots + McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: new[] { "-y", "@playwright/mcp@latest", "--headless" }), + + // 🎯 Marp — generate HTML/PDF/PPTX presentations from Markdown + McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: new[] { "-y", "@marp-team/marp-cli", "--server", "./out" }), + + // …or a remote HTTP/SSE MCP server with auth headers: + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; +``` + +| Server | Transport | Launch command | What it provides | +| ------------ | --------- | ----------------------------------------------- | ---------------------------------------------------- | +| 🌐 `browser` | stdio | `npx -y @playwright/mcp@latest --headless` | Site navigation, page reading, clicks, screenshots | +| 🎯 `marp` | stdio | `npx -y @marp-team/marp-cli --server ` | Generating presentations (HTML/PDF/PPTX) from Markdown | + +### 7.3. Direct usage (without a council) + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Providers.Mcp; + +var ollama = new OllamaProvider("http://localhost:11434"); + +await using var @operator = new Operator( + new CouncilMember("llama3.2", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }); + +await @operator.InitializeAsync(); // connects to servers and discovers tools + +// 1) Browse a website and summarize it +var browse = await @operator.ExecuteTaskAsync( + "Open https://modelcontextprotocol.io and briefly summarize what MCP is."); +Console.WriteLine(browse.FinalAnswer); + +// 2) Generate a 3-slide presentation and save it as deck.html +var deck = await @operator.ExecuteTaskAsync( + "Generate a 3-slide Marp presentation about .NET 10 and save it as deck.html."); +Console.WriteLine(deck.FinalAnswer); +``` + +> `await using` releases the MCP clients through the Operator's `ValueTask DisposeAsync`. + +### 7.4. Operator inside a debate + +Add the Operator to a council with the convenience `WithOperator(...)` overload. Participants then +delegate work via the `[[OPERATOR: ...]]` marker, and their results are injected into later rounds: + +```csharp +var result = await new CouncilBuilder() + .AddMember("llama3.2:3b", ollama, "Researcher") + .AddMember("qwen2.5:7b", ollama, "Reviewer") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2", ollama, servers, reuseCompression: true) + .WithStandardDebate() + .WithSystemPrompt( + "You may delegate research or presentation tasks to the Operator by writing " + + "[[OPERATOR: ]] in your message.") + .WithUserPrompt("Research the latest .NET 10 features and prepare a short summary deck.") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); + +Console.WriteLine(result.FinalVerdict); +``` + +A participant message might look like: + +``` +I think we should highlight the runtime improvements. +[[OPERATOR: open https://learn.microsoft.com/dotnet/core/whats-new/dotnet-10 and list the top 5 features]] +``` + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### 7.5. Configure via `appsettings.json` + +```json +{ + "Delibera": { + "Operator": { + "Enabled": true, + "ModelName": "llama3.2", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "browser", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@playwright/mcp@latest", "--headless" ] + }, + { + "Name": "marp", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@marp-team/marp-cli", "--server", "./out" ] + } + ] + } + } +} +``` + +> ▶️ Run the full demo: `dotnet run --project src/Delibera.ConsoleApp -- --operator-mcp`. +> See [`OperatorMcpToolsExample.cs`](../src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs) +> and [NET10-Upgrade.md](NET10-Upgrade.md) for details. + +--- + +## 8. Saving Separate Output Files ```csharp var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); @@ -253,7 +417,7 @@ back into another LLM. --- -## 8. Explore the ConsoleApp +## 9. Explore the ConsoleApp The repository ships with a runnable demo under [`src/Delibera.ConsoleApp/`](../src/Delibera.ConsoleApp/) that exercises every feature: @@ -267,6 +431,9 @@ The repository ships with a runnable demo under | Multi-provider | `dotnet run -- --multiprovider` | Cloud + local models in one council | | RAG (Qdrant) | `dotnet run -- --rag` | Document indexing + Knowledge Keeper | | RAG (pgvector) | `dotnet run -- --pgvector` | PostgreSQL-backed vector search | +| Operator (basics) | `dotnet run -- --operator` | Operator role with MCP tools | +| Operator + MCP | `dotnet run -- --operator-mcp` | 🆕 Browser + Marp MCP servers in a council | +| Microsoft.Extensions.AI | `dotnet run -- --msai` | 🆕 `IChatClient`/`IEmbeddingGenerator` + middleware | --- diff --git a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj index 07e29a6..7474cda 100644 --- a/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj +++ b/src/Delibera.ConsoleApp/Delibera.ConsoleApp.csproj @@ -5,7 +5,7 @@ net10.0 enable enable - 14.0 + preview Delibera Delibera.ConsoleApp $(NoWarn);CS9057 diff --git a/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs new file mode 100644 index 0000000..fc6e05c --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/MicrosoftExtensionsAiExample.cs @@ -0,0 +1,130 @@ +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; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Demonstrates the Microsoft.Extensions.AI integration: using the standard +/// / abstractions +/// inside Delibera, composing middleware, and bridging providers in both directions. +/// +/// +/// Run with: dotnet run --project src/Delibera.ConsoleApp -- --msai +/// +public static class MicrosoftExtensionsAiExample +{ + public static async Task RunAsync() + { + Console.WriteLine("🤝 Microsoft.Extensions.AI integration demo\n"); + + // ────────────────────────────────────────────────────────────── + // A. Use ANY IChatClient as a Delibera ILLMProvider + // ────────────────────────────────────────────────────────────── + // OllamaSharp's OllamaApiClient natively implements IChatClient, so we use it here. + // The same pattern applies to OpenAI / Azure OpenAI clients from Microsoft.Extensions.AI.OpenAI: + // + // IChatClient openAi = new OpenAI.Chat.ChatClient("gpt-4o-mini", apiKey).AsIChatClient(); + // ILLMProvider provider = openAi.AsLLMProvider("OpenAI"); + // + const string endpoint = "http://localhost:11434"; // local Ollama (agent VM) + const string model = "llama3.2"; + + using var ollama = new OllamaProvider(endpoint); + + // 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 + .AsChatClient() + .WithMiddleware(enableFunctionInvocation: true); + + // Expose the decorated client back to Delibera as a normal provider. + ILLMProvider msaiProvider = pipeline.AsLLMProvider("Ollama (via Microsoft.Extensions.AI)"); + Console.WriteLine($" ✦ Provider name: {msaiProvider.ProviderName}"); + + // ────────────────────────────────────────────────────────────── + // B. Streaming via the unified abstraction + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 💬 Streaming response (token-by-token):\n"); + try + { + await foreach (var chunk in msaiProvider.ChatStreamAsync( + model, + systemPrompt: "You are a concise assistant.", + userPrompt: "In one sentence, what is Microsoft.Extensions.AI?")) + Console.Write(chunk); + Console.WriteLine(); + } + catch (Exception ex) + { + PrintTips(ex); + return; + } + + // ────────────────────────────────────────────────────────────── + // C. Embeddings through IEmbeddingGenerator + // ────────────────────────────────────────────────────────────── + Console.WriteLine("\n 🧮 Embedding via IEmbeddingGenerator:"); + try + { + IEmbeddingProvider embeddings = ollama + .AsEmbeddingGenerator() + .AsEmbeddingProvider("nomic-embed-text"); + + var vector = await embeddings.EmbedAsync("Delibera deliberates."); + Console.WriteLine($" dimensions = {vector.Length}"); + } + catch (Exception ex) + { + Console.WriteLine($" ⚠️ {ex.Message} (embedding model may not be pulled)"); + } + + // ────────────────────────────────────────────────────────────── + // D. Plug the standard client into a full council via the factory + // ────────────────────────────────────────────────────────────── + 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"); + + var executor = new CouncilBuilder() + .AddMember(model, councilProvider, "Architect") + .AddMember(model, councilProvider, "Skeptic") + .SetChairman(Chairman.CreateStandard(model, councilProvider)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Is Microsoft.Extensions.AI a good abstraction for multi-provider apps?") + .WithMaxRounds(2) + .SaveResultTo("./results/msai_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + try + { + var result = await executor.ExecuteAsync(); + Console.WriteLine($"\n🏆 Completed in {result.TotalDuration.TotalSeconds:F1}s"); + Console.WriteLine("📁 Saved to: ./results/msai_debate.md"); + } + catch (Exception ex) + { + PrintTips(ex); + } + } + + private static void PrintTips(Exception ex) + { + Console.WriteLine($"\n❌ {ex.Message}"); + Console.WriteLine("\n💡 Tips:"); + Console.WriteLine(" • Start a local Ollama server: ollama serve"); + Console.WriteLine(" • Pull the model: ollama pull llama3.2"); + Console.WriteLine(" • Pull an embedding model: ollama pull nomic-embed-text"); + 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()."); + } +} diff --git a/src/Delibera.ConsoleApp/Examples/OperatorExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs new file mode 100644 index 0000000..e1d4dfa --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/OperatorExample.cs @@ -0,0 +1,111 @@ +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Operator example — demonstrates the 🛠️ Operator role: a micro-agent that connects to one or +/// more MCP (Model Context Protocol) servers and exposes their tools to the debate participants. +/// During the debate, participants delegate natural-language tasks to the Operator using the +/// [[OPERATOR: ...]] marker (e.g., web search, database lookups, writing to Notion, etc.). +/// The Operator uses its own (cheaper) model to plan tool calls, execute them, and synthesise an +/// answer that is fed back into the next round. +/// +public static class OperatorExample +{ + public static async Task RunAsync() + { + Console.WriteLine("🛠️ Operator (MCP) Example\n"); + + // ── 1. LLM providers ── + // Participants typically use stronger (more expensive) models; the Operator uses a cheaper one. + using var factory = new ProviderFactory(); + var ollama = factory.CreateOllama("http://localhost:11434"); + + // ── 2. Configure MCP servers ── + // (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"]); + + // (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()]); + + // (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. + // var notionServer = McpServerConfig.Http( + // name: "notion", + // endpoint: new Uri("https://mcp.notion.com/sse"), + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }); + + var servers = new[] { everythingServer, filesystemServer }; + + // ── 3. Build the council with an Operator ── + // The convenience overload creates the Operator from a (cheap) model + MCP servers and wires + // it through the whole pipeline. The Operator is DI-friendly and initialised automatically + // (connects to the servers and discovers their tools) before the debate starts. + var executor = new CouncilBuilder() + .AddMember("qwen2.5", ollama, "Researcher") + .AddMember("llama2", ollama, "Analyst") + .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) + .WithOperator( + modelName: "llama3.2", // cheaper model for tool orchestration + provider: ollama, + servers: servers) + .WithStandardDebate() + .WithSystemPrompt("You are a meticulous research council. Delegate factual lookups to the Operator.") + .WithUserPrompt( + "Summarise the key risks of adopting microservices for a 4-developer startup. " + + "If you need an external lookup or calculation, delegate it to the Operator.") + .WithMaxRounds(4) + .SaveResultTo("./results/operator_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + // ── 4. Observe Operator activity per round ── + executor.OnRoundCompleted += round => + { + Console.WriteLine($"✅ Round {round.RoundNumber}: {round.RoundName}"); + foreach (var oi in round.OperatorInteractions) + { + Console.WriteLine($" 🛠️ {oi.RequesterName} → \"{oi.Task}\""); + Console.WriteLine($" tools: {oi.ToolCallCount}, answer: {Preview(oi.Answer, 120)}"); + } + }; + + // ── 5. Run the debate ── + try + { + var result = await executor.ExecuteAsync(); + + Console.WriteLine($"\n🏆 Debate completed in {result.TotalDuration.TotalSeconds:F1}s"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); + Console.WriteLine("📁 Saved to: ./results/operator_debate.md"); + + if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) + { + Console.WriteLine("\n══ FINAL VERDICT ══\n"); + Console.WriteLine(result.FinalVerdict); + } + } + catch (Exception ex) + { + Console.WriteLine($"\n❌ Operator example failed: {ex.Message}"); + Console.WriteLine("\n💡 Tips:"); + Console.WriteLine(" • Node.js + npx must be installed for the npx-based MCP servers."); + Console.WriteLine(" • Ensure a local Ollama server is running: ollama serve"); + Console.WriteLine(" • Swap in your own MCP servers via McpServerConfig.Stdio / .Http."); + } + } + + private static string Preview(string text, int max) => + string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; +} diff --git a/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs new file mode 100644 index 0000000..2df7d14 --- /dev/null +++ b/src/Delibera.ConsoleApp/Examples/OperatorMcpToolsExample.cs @@ -0,0 +1,237 @@ +using Delibera.Core.Council; +using Delibera.Core.Interfaces; +using Delibera.Core.Models; +using Delibera.Core.Providers; +using Delibera.Core.Providers.Mcp; + +namespace Delibera.ConsoleApp.Examples; + +/// +/// Расширенный пример работы 🛠️ Operator с реальными MCP-инструментами. +/// +/// Демонстрирует два практических MCP-сервера: +/// +/// +/// +/// +/// Браузер — официальный сервер @playwright/mcp, который даёт Operator'у +/// инструменты для навигации по сайтам, чтения содержимого страниц, кликов и снятия +/// скриншотов (browser_navigate, browser_snapshot, browser_click и т. д.). +/// +/// +/// +/// +/// Marp-презентации — сервер на базе Marp CLI (@marp-team/marp-cli), +/// запускаемый как stdio-MCP, который превращает Markdown в готовые презентации +/// (HTML/PDF/PPTX). Operator формирует Markdown по запросу участника и собирает слайды. +/// +/// +/// +/// +/// Пример показывает оба способа использования Operator: +/// +/// +/// Прямой вызов (раздел A). +/// Делегирование внутри совета через маркер [[OPERATOR: ...]] (раздел B). +/// +/// +public static class OperatorMcpToolsExample +{ + public static async Task RunAsync() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine("🛠️ Operator + MCP-инструменты (браузер · Marp-презентации)\n"); + + // ── LLM-провайдер ── + // Для оркестрации инструментов достаточно «дешёвой» модели — её задача планировать + // и интерпретировать вызовы, а не вести глубокие рассуждения. + using var factory = new ProviderFactory(); + var ollama = factory.CreateOllama("http://localhost:11434"); + + // ════════════════════════════════════════════════════════════════ + // Конфигурация MCP-серверов + // ════════════════════════════════════════════════════════════════ + + // (1) 🌐 Браузер — Playwright MCP. + // Предоставляет инструменты управления реальным браузером. + // Запускается как дочерний процесс через npx (нужен Node.js). + var browserServer = McpServerConfig.Stdio( + name: "browser", + command: "npx", + arguments: ["-y", "@playwright/mcp@latest", "--headless"]); + + // (2) 🎯 Marp — генерация презентаций из Markdown. + // Каталог вывода монтируется через рабочую директорию; готовые файлы + // (например, deck.html / deck.pdf) появятся в ./results/presentations. + var presentationsDir = Path.Combine(Directory.GetCurrentDirectory(), "results", "presentations"); + Directory.CreateDirectory(presentationsDir); + + var marpServer = McpServerConfig.Stdio( + name: "marp", + command: "npx", + arguments: ["-y", "@marp-team/marp-cli", "--server", presentationsDir], + workingDirectory: presentationsDir); + + // (3) 🌐 HTTP-вариант (закомментирован): удалённый MCP-сервер браузера/поиска. + // Передавайте заголовки авторизации при необходимости. + // var remoteBrowser = McpServerConfig.Http( + // name: "browser-remote", + // endpoint: new Uri("https://mcp.example.com/sse"), + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }); + + var servers = new[] { browserServer, marpServer }; + + // ════════════════════════════════════════════════════════════════ + // Раздел A. Прямое использование Operator (без совета) + // ════════════════════════════════════════════════════════════════ + await RunDirectOperatorAsync(ollama, servers); + + // ════════════════════════════════════════════════════════════════ + // Раздел B. Operator внутри совета (делегирование [[OPERATOR: ...]]) + // ════════════════════════════════════════════════════════════════ + await RunCouncilWithOperatorAsync(ollama, servers); + } + + // ────────────────────────────────────────────────────────────────── + // A. Прямой Operator: формируем MCP-клиентов вручную и вызываем задачи + // ────────────────────────────────────────────────────────────────── + private static async Task RunDirectOperatorAsync( + ILLMProvider ollama, + IReadOnlyList servers) + { + Console.WriteLine("══ Раздел A. Прямой вызов Operator ══\n"); + + // Operator использует «дешёвую» модель для планирования инструментов. + var operatorModel = new CouncilMember("llama3.2", ollama, "Operator"); + + // Создаём по одному MCP-клиенту на каждый сервер. + var clients = servers.Select(IMcpClientFor).ToArray(); + + await using var @operator = new Operator(operatorModel, clients); + + try + { + await @operator.InitializeAsync(); + Console.WriteLine($"✅ Operator инициализирован: {@operator.DisplayName}"); + Console.WriteLine($" Обнаружено инструментов: {@operator.AvailableTools.Count}"); + foreach (var tool in @operator.AvailableTools.Take(8)) + Console.WriteLine($" • [{tool.ServerName}] {tool.Name} — {Preview(tool.Description, 60)}"); + + // A.1 🌐 Браузерная задача — Operator сам выберет browser_navigate + browser_snapshot. + Console.WriteLine("\n▶ Задача 1 (браузер): прочитать заголовок и краткое содержание страницы."); + var browseResult = await @operator.ExecuteTaskAsync( + requesterName: "Аналитик", + task: "Открой 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."); + PrintResult(deckResult); + } + catch (Exception ex) + { + PrintFailureTips(ex); + } + } + + // ────────────────────────────────────────────────────────────────── + // B. Совет с Operator: участники делегируют задачи маркером [[OPERATOR: ...]] + // ────────────────────────────────────────────────────────────────── + private static async Task RunCouncilWithOperatorAsync( + ILLMProvider ollama, + IReadOnlyList servers) + { + Console.WriteLine("\n══ Раздел B. Operator внутри совета ══\n"); + + var executor = new CouncilBuilder() + .AddMember("qwen2.5", ollama, "Исследователь") + .AddMember("llama2", ollama, "Архитектор") + .SetChairman(Chairman.CreateStandard("qwen2.5", ollama)) + .WithOperator( + modelName: "llama3.2", // дешёвая модель для оркестрации инструментов + provider: ollama, + servers: servers) + .WithStandardDebate() + .WithSystemPrompt( + "Вы — совет по технологической стратегии. Когда нужны свежие факты из интернета " + + "или сборка презентации — делегируйте задачу Operator'у маркером [[OPERATOR: задача]].") + .WithUserPrompt( + "Подготовьте краткий обзор «Что нового в .NET 10 для high-performance кода». " + + "Сначала проверьте актуальную информацию через браузер, затем соберите итоговую " + + "Marp-презентацию из 4 слайдов.") + .WithMaxRounds(4) + .SaveResultTo("./results/operator_mcp_debate.md") + .Build(); + + Console.WriteLine(executor.GetInfo()); + + // Наблюдаем за активностью Operator по раундам. + executor.OnRoundCompleted += round => + { + Console.WriteLine($"✅ Раунд {round.RoundNumber}: {round.RoundName}"); + foreach (var oi in round.OperatorInteractions) + { + Console.WriteLine($" 🛠️ {oi.RequesterName} → \"{Preview(oi.Task, 70)}\""); + Console.WriteLine($" инструментов: {oi.ToolCallCount}, ответ: {Preview(oi.Answer, 100)}"); + } + }; + + try + { + var result = await executor.ExecuteAsync(); + + Console.WriteLine($"\n🏆 Дебаты завершены за {result.TotalDuration.TotalSeconds:F1} c"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); + Console.WriteLine("📁 Отчёт: ./results/operator_mcp_debate.md"); + Console.WriteLine("📁 Презентации: ./results/presentations/"); + + if (!string.IsNullOrWhiteSpace(result.FinalVerdict)) + { + Console.WriteLine("\n══ ИТОГОВОЕ РЕШЕНИЕ ══\n"); + Console.WriteLine(result.FinalVerdict); + } + } + catch (Exception ex) + { + PrintFailureTips(ex); + } + } + + // ────────────────────────────────────────────────────────────────── + // Вспомогательные методы + // ────────────────────────────────────────────────────────────────── + + /// Создаёт MCP-клиент для конфигурации сервера. + private static IMcpClient IMcpClientFor(McpServerConfig config) => new McpClientAdapter(config); + + private static void PrintResult(OperatorResult result) + { + Console.WriteLine($" Вызвано инструментов: {result.ToolCalls.Count}" + + (result.Compressed ? " (ответ сжат)" : string.Empty)); + foreach (var call in result.ToolCalls) + { + var status = call.IsError ? "❌" : "✓"; + Console.WriteLine($" {status} [{call.ServerName}] {call.ToolName}"); + } + + Console.WriteLine($" 💬 Ответ: {Preview(result.Answer, 240)}"); + } + + private static void PrintFailureTips(Exception ex) + { + Console.WriteLine($"\n❌ Пример завершился ошибкой: {ex.Message}"); + Console.WriteLine("\n💡 Подсказки:"); + Console.WriteLine(" • Установите Node.js + npx (нужны для npx-MCP-серверов)."); + Console.WriteLine(" • Для браузера: первый запуск Playwright скачает движки (npx playwright install)."); + Console.WriteLine(" • Запустите локальный Ollama: ollama serve (модели llama3.2, qwen2.5)."); + Console.WriteLine(" • Свои MCP-серверы подключаются через McpServerConfig.Stdio / .Http."); + } + + private static string Preview(string? text, int max) => + string.IsNullOrEmpty(text) ? "(пусто)" : + text.Length <= max ? text : text[..max] + "…"; +} diff --git a/src/Delibera.ConsoleApp/Program.cs b/src/Delibera.ConsoleApp/Program.cs index c608f45..1fcb618 100644 --- a/src/Delibera.ConsoleApp/Program.cs +++ b/src/Delibera.ConsoleApp/Program.cs @@ -65,6 +65,24 @@ public static async Task Main(string[] args) return; } + if (args.Contains("--operator")) + { + await OperatorExample.RunAsync(); + return; + } + + if (args.Contains("--operator-mcp")) + { + await OperatorMcpToolsExample.RunAsync(); + return; + } + + if (args.Contains("--msai")) + { + await MicrosoftExtensionsAiExample.RunAsync(); + return; + } + // Quick DI showcase before main demo Console.WriteLine("🆕 v3.1 DI Quick Demo:"); Console.WriteLine(" Run with --di for full DI example"); @@ -78,7 +96,7 @@ public static async Task Main(string[] args) var configuration = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", false, true) - .AddUserSecrets(Assembly.GetEntryAssembly()) + .AddUserSecrets(Assembly.GetEntryAssembly()!) .Build(); var cfg = configuration.GetSection("DeliberaApp"); @@ -394,6 +412,13 @@ public static async Task Main(string[] args) Console.WriteLine($" Q: {ki.Query[..Math.Min(80, ki.Query.Length)]}… → {ki.SourceChunks} chunks"); } + if (round.OperatorInteractions.Count > 0) + { + Console.WriteLine(" 🛠️ Operator interactions:"); + foreach (var oi in round.OperatorInteractions) + Console.WriteLine($" {oi.RequesterName}: {oi.Task[..Math.Min(80, oi.Task.Length)]}… → {oi.ToolCallCount} tool call(s)"); + } + foreach (var (member, response) in round.Responses) { var preview = response.Length > 300 @@ -421,6 +446,8 @@ public static async Task Main(string[] args) Console.WriteLine($" Participants: {string.Join(", ", result.Participants)}"); if (result.KnowledgeKeeperName is not null) Console.WriteLine($" Knowledge Keeper: {result.KnowledgeKeeperName}"); + if (result.OperatorName is not null) + Console.WriteLine($" Operator: {result.OperatorName}"); // Token statistics if (result.TokenStats is not null) Console.WriteLine($"\n{result.TokenStats.ToSummary()}"); diff --git a/src/Delibera.Core/Compression/CompressedContextFactory.cs b/src/Delibera.Core/Compression/CompressedContextFactory.cs index 702b85b..fb79287 100644 --- a/src/Delibera.Core/Compression/CompressedContextFactory.cs +++ b/src/Delibera.Core/Compression/CompressedContextFactory.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace Delibera.Core.Compression; /// @@ -12,8 +10,9 @@ internal static class CompressedContextFactory /// /// Builds a result describing text that was passed through unchanged. /// - public static CompressedContext PassThrough(string text, int tokens, string strategyName, TimeSpan duration) => - new() + public static CompressedContext PassThrough(string text, int tokens, string strategyName, TimeSpan duration) + { + return new CompressedContext { Text = text, OriginalLength = text.Length, @@ -23,6 +22,7 @@ public static CompressedContext PassThrough(string text, int tokens, string stra StrategyUsed = strategyName, Duration = duration }; + } /// /// Builds a result describing text that was actually compressed. @@ -33,8 +33,9 @@ public static CompressedContext Compressed( int originalTokens, int compressedTokens, string strategyName, - TimeSpan duration) => - new() + TimeSpan duration) + { + return new CompressedContext { Text = compressed, OriginalLength = original.Length, @@ -44,4 +45,5 @@ public static CompressedContext Compressed( StrategyUsed = strategyName, Duration = duration }; -} + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionCache.cs b/src/Delibera.Core/Compression/CompressionCache.cs index 8ebecf7..52e112b 100644 --- a/src/Delibera.Core/Compression/CompressionCache.cs +++ b/src/Delibera.Core/Compression/CompressionCache.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Collections.Concurrent; using System.Security.Cryptography; @@ -104,15 +105,42 @@ public void Clear() /// /// Returns a formatted summary of cache performance. /// - public string GetSummary() => $"Cache: {Count} entries, {HitCount} hits / {MissCount} misses ({HitRate:P1} hit rate)"; + public string GetSummary() + { + return $"Cache: {Count} entries, {HitCount} hits / {MissCount} misses ({HitRate:P1} hit rate)"; + } // ────────────────────────────────────────────── private static string ComputeKey(string text, string strategyName) { - var input = $"{strategyName}:{text}"; - var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - return Convert.ToHexString(hashBytes); + // Compose "{strategy}:{text}" into a pooled char buffer, then UTF-8 encode + // into a pooled byte buffer — avoids two intermediate heap allocations + // (the interpolated string and the GetBytes array) on this hot path. + var charCount = strategyName.Length + 1 + text.Length; + var chars = ArrayPool.Shared.Rent(charCount); + byte[]? bytes = null; + try + { + strategyName.AsSpan().CopyTo(chars); + chars[strategyName.Length] = ':'; + text.AsSpan().CopyTo(chars.AsSpan(strategyName.Length + 1)); + var charSpan = chars.AsSpan(0, charCount); + + var maxBytes = Encoding.UTF8.GetMaxByteCount(charCount); + bytes = ArrayPool.Shared.Rent(maxBytes); + var byteCount = Encoding.UTF8.GetBytes(charSpan, bytes); + + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(bytes.AsSpan(0, byteCount), hash); + return Convert.ToHexString(hash); + } + finally + { + ArrayPool.Shared.Return(chars); + if (bytes is not null) + ArrayPool.Shared.Return(bytes); + } } private sealed class CacheEntry @@ -120,4 +148,4 @@ private sealed class CacheEntry public required CompressedContext Context { get; init; } public DateTime LastAccessed { get; set; } } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionFactory.cs b/src/Delibera.Core/Compression/CompressionFactory.cs index 2e61113..c7773d8 100644 --- a/src/Delibera.Core/Compression/CompressionFactory.cs +++ b/src/Delibera.Core/Compression/CompressionFactory.cs @@ -24,18 +24,21 @@ public static IContextCompressor Create( CompressionStrategy strategy, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => strategy switch + IEmbeddingProvider? embeddingProvider = null) { - CompressionStrategy.None => PassThroughCompressor.Instance, - CompressionStrategy.Semantic => new SemanticCompressor( - embeddingProvider ?? throw new ArgumentException("Semantic compression requires an IEmbeddingProvider.", nameof(embeddingProvider))), - CompressionStrategy.Deduplication => new DeduplicationCompressor(embeddingProvider), - CompressionStrategy.Summarization => new SummarizationCompressor( - llmProvider ?? throw new ArgumentException("Summarization compression requires an ILLMProvider.", nameof(llmProvider)), - modelName ?? throw new ArgumentException("Summarization compression requires a modelName.", nameof(modelName))), - CompressionStrategy.Hybrid => new HybridCompressor(llmProvider, modelName, embeddingProvider), - _ => throw new ArgumentOutOfRangeException(nameof(strategy), $"Unknown compression strategy: {strategy}") - }; + return strategy switch + { + CompressionStrategy.None => PassThroughCompressor.Instance, + CompressionStrategy.Semantic => new SemanticCompressor( + embeddingProvider ?? throw new ArgumentException("Semantic compression requires an IEmbeddingProvider.", nameof(embeddingProvider))), + CompressionStrategy.Deduplication => new DeduplicationCompressor(embeddingProvider), + CompressionStrategy.Summarization => new SummarizationCompressor( + llmProvider ?? throw new ArgumentException("Summarization compression requires an ILLMProvider.", nameof(llmProvider)), + modelName ?? throw new ArgumentException("Summarization compression requires a modelName.", nameof(modelName))), + CompressionStrategy.Hybrid => new HybridCompressor(llmProvider, modelName, embeddingProvider), + _ => throw new ArgumentOutOfRangeException(nameof(strategy), $"Unknown compression strategy: {strategy}") + }; + } /// /// Creates a compressor from a strategy name string (case-insensitive). @@ -55,12 +58,14 @@ public static IContextCompressor Create( /// /// No-op compressor — passes text through unchanged. Used when compression is disabled. -/// +/// internal sealed class PassThroughCompressor : IContextCompressor { public static readonly PassThroughCompressor Instance = new(); - private PassThroughCompressor() { } + private PassThroughCompressor() + { + } /// public string StrategyName => "None"; @@ -82,4 +87,4 @@ public Task CompressBatchAsync(IReadOnlyList texts, C var merged = string.Join("\n\n", texts); return CompressAsync(merged, options, ct); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/CompressionService.cs b/src/Delibera.Core/Compression/CompressionService.cs index 873fc34..f68d2d9 100644 --- a/src/Delibera.Core/Compression/CompressionService.cs +++ b/src/Delibera.Core/Compression/CompressionService.cs @@ -15,14 +15,18 @@ public IContextCompressor Create( CompressionStrategy strategy, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => - CompressionFactory.Create(strategy, llmProvider, modelName, embeddingProvider); + IEmbeddingProvider? embeddingProvider = null) + { + return CompressionFactory.Create(strategy, llmProvider, modelName, embeddingProvider); + } /// public IContextCompressor Create( string strategyName, ILLMProvider? llmProvider = null, string? modelName = null, - IEmbeddingProvider? embeddingProvider = null) => - CompressionFactory.Create(strategyName, llmProvider, modelName, embeddingProvider); -} + IEmbeddingProvider? embeddingProvider = null) + { + return CompressionFactory.Create(strategyName, llmProvider, modelName, embeddingProvider); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/DeduplicationCompressor.cs b/src/Delibera.Core/Compression/DeduplicationCompressor.cs index 84c3bdd..bf56bb0 100644 --- a/src/Delibera.Core/Compression/DeduplicationCompressor.cs +++ b/src/Delibera.Core/Compression/DeduplicationCompressor.cs @@ -35,7 +35,7 @@ public async Task CompressAsync(string text, CompressionOptio if (sentences.Count <= 1) return CompressedContextFactory.PassThrough(text, originalTokens, StrategyName, sw.Elapsed); - List unique = _embeddingProvider is not null + var unique = _embeddingProvider is not null ? await DeduplicateWithEmbeddingsAsync(sentences, options.DeduplicationThreshold, ct) : DeduplicateWithHeuristics(sentences, options.DeduplicationThreshold); @@ -140,5 +140,8 @@ private static double ComputeTextSimilarity(string a, string b) : 0; } - private static string NormalizeText(string text) => text.Trim().ToLowerInvariant(); -} + private static string NormalizeText(string text) + { + return text.Trim().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/HybridCompressor.cs b/src/Delibera.Core/Compression/HybridCompressor.cs index 641b08a..d49b8de 100644 --- a/src/Delibera.Core/Compression/HybridCompressor.cs +++ b/src/Delibera.Core/Compression/HybridCompressor.cs @@ -109,4 +109,4 @@ public async Task CompressBatchAsync(IReadOnlyList te var merged = string.Join("\n\n", texts); return await CompressAsync(merged, options, ct); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/SemanticCompressor.cs b/src/Delibera.Core/Compression/SemanticCompressor.cs index 8000cdf..082c66a 100644 --- a/src/Delibera.Core/Compression/SemanticCompressor.cs +++ b/src/Delibera.Core/Compression/SemanticCompressor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Numerics; namespace Delibera.Core.Compression; @@ -151,11 +152,38 @@ internal static List SplitSentences(string text) return sentences; } - internal static double CosineSimilarity(float[] a, float[] b) + internal static double CosineSimilarity(ReadOnlySpan a, ReadOnlySpan b) { - if (a.Length != b.Length) return 0; - double dot = 0, magA = 0, magB = 0; - for (var i = 0; i < a.Length; i++) + if (a.Length != b.Length || a.IsEmpty) return 0; + + float dot = 0, magA = 0, magB = 0; + var i = 0; + + // SIMD fast-path: process Vector.Count lanes at a time. + // On modern x64/ARM64 this is 8–16 floats per iteration. + if (Vector.IsHardwareAccelerated && a.Length >= Vector.Count) + { + var dotAcc = Vector.Zero; + var magAAcc = Vector.Zero; + var magBAcc = Vector.Zero; + var width = Vector.Count; + + for (; i <= a.Length - width; i += width) + { + var va = new Vector(a.Slice(i, width)); + var vb = new Vector(b.Slice(i, width)); + dotAcc += va * vb; + magAAcc += va * va; + magBAcc += vb * vb; + } + + dot = Vector.Dot(dotAcc, Vector.One); + magA = Vector.Dot(magAAcc, Vector.One); + magB = Vector.Dot(magBAcc, Vector.One); + } + + // Scalar tail (and full loop when SIMD is unavailable). + for (; i < a.Length; i++) { dot += a[i] * b[i]; magA += a[i] * a[i]; @@ -173,4 +201,4 @@ internal static double CosineSimilarity(float[] a, float[] b) /// Sentence with computed relevance score. private readonly record struct ScoredSentence(int Index, double Score, string Text); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/SummarizationCompressor.cs b/src/Delibera.Core/Compression/SummarizationCompressor.cs index 3575b12..cd8e4fe 100644 --- a/src/Delibera.Core/Compression/SummarizationCompressor.cs +++ b/src/Delibera.Core/Compression/SummarizationCompressor.cs @@ -114,4 +114,4 @@ 5. Output ONLY the compressed text sw.Stop(); return CompressedContextFactory.Compressed(merged, summary, originalTokens, compressedTokens, StrategyName, sw.Elapsed); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Compression/TokenCounter.cs b/src/Delibera.Core/Compression/TokenCounter.cs index 7d90948..b31eb64 100644 --- a/src/Delibera.Core/Compression/TokenCounter.cs +++ b/src/Delibera.Core/Compression/TokenCounter.cs @@ -44,6 +44,19 @@ public int EstimateTokens(string? text) if (TokenizerFunc is not null) return TokenizerFunc(text); + 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. + /// + /// Input text span. + /// Estimated token count. + public int EstimateTokens(ReadOnlySpan text) + { + if (text.IsEmpty) return 0; + // Heuristic: count words + account for sub-word tokenization // Blend word-count and char-count estimates for better accuracy var wordCount = CountWords(text); @@ -69,7 +82,10 @@ public int EstimateTokens(IEnumerable texts) /// /// Returns true if the text exceeds the specified token limit. /// - public bool ExceedsLimit(string text, int tokenLimit) => EstimateTokens(text) > tokenLimit; + public bool ExceedsLimit(string text, int tokenLimit) + { + return EstimateTokens(text) > tokenLimit; + } /// /// Truncates text to approximately fit within the specified token limit. @@ -95,7 +111,7 @@ public string TruncateToTokenLimit(string text, int maxTokens) // ────────────────────────────────────────────── - private static int CountWords(string text) + private static int CountWords(ReadOnlySpan text) { var count = 0; var inWord = false; @@ -112,4 +128,4 @@ private static int CountWords(string text) return count; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/Chairman.cs b/src/Delibera.Core/Council/Chairman.cs index d7e49f8..41432a7 100644 --- a/src/Delibera.Core/Council/Chairman.cs +++ b/src/Delibera.Core/Council/Chairman.cs @@ -12,36 +12,44 @@ public static class Chairman // ────────────────────────────────────────────── /// Creates a standard, neutral Chairman. - public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) => - new(modelName, provider, "Chairman", + public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Chairman", """ You are the Chairman of an AI Council debate — neutral, objective and fair. You organise the debate process, ensure every participant is heard, synthesise diverse viewpoints and produce clear, actionable verdicts. Prioritise accuracy, completeness and practical usefulness. """); + } /// Creates a strict, evidence-focused Chairman. - public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) => - new(modelName, provider, "Strict Chairman", + public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Strict Chairman", """ You are a strict, evidence-focused Chairman. You demand factual accuracy, reject unsupported claims and penalise logical fallacies. If no participant provides a satisfactory answer, say so clearly. """); + } /// Creates a creative Chairman who encourages novel thinking. - public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) => - new(modelName, provider, "Creative Chairman", + public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) + { + return new CouncilMember(modelName, provider, "Creative Chairman", """ You are a creative Chairman who looks beyond conventional answers. While maintaining accuracy, you seek innovative connections between ideas, encourage thinking outside the box, and surface unexpected insights. """); + } /// Creates a Chairman with a custom persona prompt. - public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) => - new(modelName, provider, "Chairman", personaPrompt); + public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) + { + return new CouncilMember(modelName, provider, "Chairman", personaPrompt); + } // ────────────────────────────────────────────── // Specialised Chairman actions (each has its own prompt) @@ -278,14 +286,26 @@ public static async Task SynthesizeVerdictAsync( public static class Moderator { /// - public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) => Chairman.CreateStandard(modelName, provider); + public static CouncilMember CreateStandard(string modelName, ILLMProvider provider) + { + return Chairman.CreateStandard(modelName, provider); + } /// - public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) => Chairman.CreateStrict(modelName, provider); + public static CouncilMember CreateStrict(string modelName, ILLMProvider provider) + { + return Chairman.CreateStrict(modelName, provider); + } /// - public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) => Chairman.CreateCreative(modelName, provider); + public static CouncilMember CreateCreative(string modelName, ILLMProvider provider) + { + return Chairman.CreateCreative(modelName, provider); + } /// - public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) => Chairman.CreateCustom(modelName, provider, personaPrompt); -} + public static CouncilMember CreateCustom(string modelName, ILLMProvider provider, string personaPrompt) + { + return Chairman.CreateCustom(modelName, provider, personaPrompt); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilBuilder.cs b/src/Delibera.Core/Council/CouncilBuilder.cs index 72f6f2d..aefb2ce 100644 --- a/src/Delibera.Core/Council/CouncilBuilder.cs +++ b/src/Delibera.Core/Council/CouncilBuilder.cs @@ -1,5 +1,6 @@ using Delibera.Core.Compression; using Delibera.Core.Debate; +using Delibera.Core.Providers.Mcp; namespace Delibera.Core.Council; @@ -16,6 +17,10 @@ public sealed class CouncilBuilder : ICouncilBuilder private IKnowledgeBase? _knowledgeBase; private KnowledgeKeeper? _knowledgeKeeper; private int _maxRounds = 4; + private Operator? _operator; + private CouncilMember? _operatorModel; + private bool _operatorReuseCompression; + private IReadOnlyList? _operatorServers; private string? _outputPath; private IDebateStrategy _strategy = new StandardDebate(); private string _systemPrompt = "You are a helpful AI assistant participating in a council debate."; @@ -57,15 +62,6 @@ public ICouncilBuilder SetChairman(string modelName, ILLMProvider provider, stri return this; } - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(CouncilMember moderator) => SetChairman(moderator); - - /// Backward-compatible alias for . - [Obsolete("Use SetChairman instead.")] - public ICouncilBuilder SetModerator(string modelName, ILLMProvider provider, string? persona = null) - => SetChairman(modelName, provider, persona); - // ── Knowledge Keeper ── /// @@ -75,17 +71,34 @@ public ICouncilBuilder WithKnowledgeKeeper(KnowledgeKeeper knowledgeKeeper) return this; } - /// Creates and attaches a Knowledge Keeper from a RAG provider, model and collection. - public ICouncilBuilder WithKnowledgeKeeper( - IRagProvider ragProvider, + // ── Operator (MCP tool micro-agent) ── + + /// + public ICouncilBuilder WithOperator(Operator @operator) + { + _operator = @operator ?? throw new ArgumentNullException(nameof(@operator)); + return this; + } + + /// + public ICouncilBuilder WithOperator( string modelName, - ILLMProvider llmProvider, - string collectionName = "council_knowledge") + ILLMProvider provider, + IEnumerable servers, + bool reuseCompression = true) { - ArgumentNullException.ThrowIfNull(ragProvider); - ArgumentNullException.ThrowIfNull(llmProvider); - var member = new CouncilMember(modelName, llmProvider, "Knowledge Keeper"); - _knowledgeKeeper = new KnowledgeKeeper(ragProvider, member, collectionName); + ArgumentException.ThrowIfNullOrWhiteSpace(modelName); + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(servers); + + var serverList = servers.ToList(); + if (serverList.Count == 0) + throw new ArgumentException("At least one MCP server configuration is required.", nameof(servers)); + + // Operator construction is deferred to Build() so it can reuse the council's compressor. + _operatorModel = new CouncilMember(modelName, provider, "Operator"); + _operatorServers = serverList; + _operatorReuseCompression = reuseCompression; return this; } @@ -181,6 +194,40 @@ public ICouncilBuilder SaveResultTo(string outputPath) return this; } + /// + ICouncilExecutor ICouncilBuilder.Build() + { + return Build(); + } + + /// Backward-compatible alias for . + [Obsolete("Use SetChairman instead.")] + public ICouncilBuilder SetModerator(CouncilMember moderator) + { + return SetChairman(moderator); + } + + /// Backward-compatible alias for . + [Obsolete("Use SetChairman instead.")] + public ICouncilBuilder SetModerator(string modelName, ILLMProvider provider, string? persona = null) + { + return SetChairman(modelName, provider, persona); + } + + /// Creates and attaches a Knowledge Keeper from a RAG provider, model and collection. + public ICouncilBuilder WithKnowledgeKeeper( + IRagProvider ragProvider, + string modelName, + ILLMProvider llmProvider, + string collectionName = "council_knowledge") + { + ArgumentNullException.ThrowIfNull(ragProvider); + ArgumentNullException.ThrowIfNull(llmProvider); + var member = new CouncilMember(modelName, llmProvider, "Knowledge Keeper"); + _knowledgeKeeper = new KnowledgeKeeper(ragProvider, member, collectionName); + return this; + } + // ── Build ── /// @@ -202,6 +249,23 @@ public CouncilExecutor Build() KnowledgeFiles = _knowledgeBase?.GetLoadedSources().ToList() ?? [] }; + // Build the deferred Operator (if configured via the convenience overload) so it can reuse + // the council's compressor when requested. + var @operator = _operator; + if (@operator is null && _operatorModel is not null && _operatorServers is not null) + { + var clients = _operatorServers.Select(s => (IMcpClient)new McpClientAdapter(s)).ToList(); + @operator = new Operator( + _operatorModel, + clients, + _operatorReuseCompression + ? _compressor + : null, + _operatorReuseCompression + ? _compressionOptions + : null); + } + return new CouncilExecutor( _members.AsReadOnly(), _chairman, @@ -213,9 +277,7 @@ public CouncilExecutor Build() _outputPath, _compressor, _compressionOptions, - _compressionCache); + _compressionCache, + @operator); } - - /// - ICouncilExecutor ICouncilBuilder.Build() => Build(); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/CouncilExecutor.cs b/src/Delibera.Core/Council/CouncilExecutor.cs index bc91974..05e8240 100644 --- a/src/Delibera.Core/Council/CouncilExecutor.cs +++ b/src/Delibera.Core/Council/CouncilExecutor.cs @@ -26,11 +26,13 @@ internal CouncilExecutor( string? outputPath, IContextCompressor? compressor = null, CompressionOptions? compressionOptions = null, - CompressionCache? compressionCache = null) + CompressionCache? compressionCache = null, + Operator? @operator = null) { Members = members; Chairman = chairman; KnowledgeKeeper = knowledgeKeeper; + Operator = @operator; Strategy = strategy; _context = context; _maxRounds = maxRounds; @@ -53,6 +55,9 @@ internal CouncilExecutor( /// Knowledge Keeper (may be null). public KnowledgeKeeper? KnowledgeKeeper { get; } + /// Operator (may be null). + public Operator? Operator { get; } + /// Debate strategy. public IDebateStrategy Strategy { get; } @@ -80,6 +85,25 @@ public async Task ExecuteAsync(CancellationToken ct = default) 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}")); + } + } + + 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}")); @@ -91,6 +115,7 @@ public async Task ExecuteAsync(CancellationToken ct = default) _context, Chairman, KnowledgeKeeper, + Operator, _maxRounds, _temperature, round => @@ -101,6 +126,10 @@ public async Task ExecuteAsync(CancellationToken ct = default) 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)")); @@ -186,6 +215,13 @@ public string GetInfo() sb.AppendLine($" 📚 {KnowledgeKeeper.DisplayName} (collection: {KnowledgeKeeper.CollectionName})"); } + if (Operator is not null) + { + sb.AppendLine(); + sb.AppendLine(" ── Operator ──"); + sb.AppendLine($" 🛠️ {Operator.DisplayName} ({Operator.AvailableTools.Count} tool(s))"); + } + if (Compressor is not null) { sb.AppendLine(); @@ -214,8 +250,13 @@ public string GetInfo() return sb.ToString(); } - private void Log(ExecutionLog entry) => _executionLogs.Add(entry); + private void Log(ExecutionLog entry) + { + _executionLogs.Add(entry); + } - private static string Truncate(string text, int max) => - string.IsNullOrEmpty(text) ? "(empty)" : text.Length <= max ? text : text[..max] + "…"; -} + private static string Truncate(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.Core/Council/KnowledgeKeeper.cs b/src/Delibera.Core/Council/KnowledgeKeeper.cs index 23ac08c..280c886 100644 --- a/src/Delibera.Core/Council/KnowledgeKeeper.cs +++ b/src/Delibera.Core/Council/KnowledgeKeeper.cs @@ -7,9 +7,9 @@ namespace Delibera.Core.Council; /// public sealed class KnowledgeKeeper(IRagProvider ragProvider, CouncilMember model, string collectionName) { - private readonly IRagProvider _ragProvider = ragProvider ?? throw new ArgumentNullException(nameof(ragProvider)); - private readonly CouncilMember _model = model ?? throw new ArgumentNullException(nameof(model)); private readonly List _interactions = []; + private readonly CouncilMember _model = model ?? throw new ArgumentNullException(nameof(model)); + private readonly IRagProvider _ragProvider = ragProvider ?? throw new ArgumentNullException(nameof(ragProvider)); /// RAG collection used for searching. public string CollectionName { get; } = collectionName ?? throw new ArgumentNullException(nameof(collectionName)); @@ -28,8 +28,10 @@ public sealed class KnowledgeKeeper(IRagProvider ragProvider, CouncilMember mode /// Cancellation token. /// Scored search results from the vector store. public Task> SearchKnowledgeAsync( - string query, int limit = 5, CancellationToken ct = default) => - _ragProvider.SearchAsync(CollectionName, query, limit, ct: ct); + string query, int limit = 5, CancellationToken ct = default) + { + return _ragProvider.SearchAsync(CollectionName, query, limit, ct: ct); + } /// /// Generates an answer to a question using RAG context. @@ -221,8 +223,10 @@ public Task IndexDocumentAsync( Dictionary? metadata = null, int chunkSize = 500, int chunkOverlap = 50, - CancellationToken ct = default) => - _ragProvider.IndexDocumentAsync(CollectionName, documentText, metadata, chunkSize, chunkOverlap, ct); + CancellationToken ct = default) + { + return _ragProvider.IndexDocumentAsync(CollectionName, documentText, metadata, chunkSize, chunkOverlap, ct); + } /// /// Indexes a file into the Knowledge Keeper's collection. @@ -231,6 +235,8 @@ public Task IndexFileAsync( string filePath, int chunkSize = 500, int chunkOverlap = 50, - CancellationToken ct = default) => - _ragProvider.IndexFileAsync(CollectionName, filePath, chunkSize, chunkOverlap, ct); -} + CancellationToken ct = default) + { + return _ragProvider.IndexFileAsync(CollectionName, filePath, chunkSize, chunkOverlap, ct); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Council/Operator.cs b/src/Delibera.Core/Council/Operator.cs new file mode 100644 index 0000000..883f80e --- /dev/null +++ b/src/Delibera.Core/Council/Operator.cs @@ -0,0 +1,367 @@ +using System.Text.Json; + +namespace Delibera.Core.Council; + +/// +/// The Operator — a special council role implemented as a self-contained micro-agent. +/// It connects to one or more MCP (Model Context Protocol) servers, exposes their tools +/// to debate participants, and fulfils natural-language tasks delegated by participants: +/// it interprets the task with its own (typically cheaper) LLM model, selects and invokes +/// the appropriate MCP tools, interprets the raw results, optionally compresses them using +/// the debate's compression strategy, and returns a concise answer to the requester. +/// +/// +/// The Operator deliberately uses a less expensive model than the debate participants — +/// its job is orchestration and interpretation of tool output, not deep reasoning, so a +/// cheaper model keeps tool usage economical. +/// +public sealed class Operator : IOperator +{ + private readonly CompressionOptions? _compressionOptions; + private readonly IContextCompressor? _compressor; + private readonly List _interactions = []; + private readonly Dictionary _mcpClients; + private readonly CouncilMember _model; + private readonly List _tools = []; + + /// + /// Creates an Operator. + /// + /// The (cheaper) LLM model the Operator uses to plan and interpret tool output. + /// MCP server clients the Operator can use. + /// Optional compressor used to shrink large tool results before returning them. + /// Optional compression options. + public Operator( + CouncilMember model, + IEnumerable mcpClients, + IContextCompressor? compressor = null, + CompressionOptions? compressionOptions = null) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + ArgumentNullException.ThrowIfNull(mcpClients); + + _mcpClients = mcpClients.ToDictionary(c => c.ServerName, StringComparer.OrdinalIgnoreCase); + _compressor = compressor; + _compressionOptions = compressionOptions; + + if (_model.Role is null or "Expert") + _model.Role = "Operator"; + } + + /// + public string DisplayName => $"🛠️ Operator ({_model.ModelName})"; + + /// + public bool IsInitialized { get; private set; } + + /// + public IReadOnlyList AvailableTools => _tools.AsReadOnly(); + + /// + public IReadOnlyList Interactions => _interactions.AsReadOnly(); + + /// + public async Task InitializeAsync(CancellationToken ct = default) + { + if (IsInitialized) return; + + _tools.Clear(); + foreach (var client in _mcpClients.Values) + try + { + await client.ConnectAsync(ct); + var tools = await client.ListToolsAsync(ct); + _tools.AddRange(tools); + } + catch (Exception ex) + { + // A failing server should not abort the whole debate — skip it but keep going. + _interactions.Add(new OperatorInteraction( + "System", + $"Connect to MCP server '{client.ServerName}'", + $"[Failed to initialise server '{client.ServerName}': {ex.Message}]", + [], + false, + DateTime.UtcNow)); + } + + IsInitialized = true; + } + + /// + public string GetToolCatalog() + { + if (_tools.Count == 0) + return "The Operator currently has no tools available."; + + var sb = new StringBuilder(); + sb.AppendLine("The Operator can perform the following actions via connected MCP servers:"); + foreach (var byServer in _tools.GroupBy(t => t.ServerName)) + { + sb.AppendLine($"\n• Server \"{byServer.Key}\":"); + foreach (var tool in byServer) + { + var desc = string.IsNullOrWhiteSpace(tool.Description) + ? "(no description)" + : tool.Description.Trim(); + sb.AppendLine($" - {tool.QualifiedName}: {desc}"); + } + } + + return sb.ToString(); + } + + /// + public async Task ExecuteTaskAsync( + string requesterName, + string task, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(requesterName); + ArgumentException.ThrowIfNullOrWhiteSpace(task); + + if (!IsInitialized) + await InitializeAsync(ct); + + // 1. Plan: ask the model which tool(s) to call. + var plannedCalls = _tools.Count == 0 + ? [] + : await PlanToolCallsAsync(task, ct); + + // 2. Execute the planned tool calls. + var executed = new List(); + 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, + toolResult.Text, toolResult.IsError)); + } + + // 3. Interpret the results (or answer directly if no tools were used). + var answer = await InterpretAsync(requesterName, task, executed, ct); + + // 4. Optionally compress the answer using the debate's compression strategy. + var compressed = false; + if (_compressor is not null && !string.IsNullOrWhiteSpace(answer)) + try + { + var result = await _compressor.CompressAsync(answer, _compressionOptions, ct); + if (!string.IsNullOrWhiteSpace(result.Text)) + { + answer = result.Text; + compressed = true; + } + } + catch + { + // Compression is best-effort; keep the uncompressed answer on failure. + } + + var operatorResult = new OperatorResult(requesterName, task, answer, executed.AsReadOnly(), compressed); + _interactions.Add(operatorResult.ToInteraction()); + return operatorResult; + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var client in _mcpClients.Values) + try + { + await client.DisposeAsync(); + } + catch + { + // ignore disposal errors + } + } + + // ────────────────────────────────────────────── + // Micro-agent internals + // ────────────────────────────────────────────── + + 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}"); + + var userPrompt = $""" + ### Available tools: + {toolsText} + + ### Task: + {task} + + Return the JSON plan now. + """; + + string raw; + try + { + raw = await _model.AskAsync(SystemPrompt, userPrompt, 0.1f, ct); + } + catch + { + return []; + } + + return ParsePlan(raw); + } + + private static IReadOnlyList ParsePlan(string raw) + { + var json = ExtractJson(raw); + if (string.IsNullOrWhiteSpace(json)) return []; + + try + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("tool_calls", out var callsEl) || + callsEl.ValueKind != JsonValueKind.Array) + return []; + + var calls = new List(); + foreach (var callEl in callsEl.EnumerateArray()) + { + var server = callEl.TryGetProperty("server", out var s) + ? s.GetString() + : null; + var tool = callEl.TryGetProperty("tool", out var t) + ? t.GetString() + : null; + if (string.IsNullOrWhiteSpace(server) || string.IsNullOrWhiteSpace(tool)) + continue; + + var args = new Dictionary(); + if (callEl.TryGetProperty("arguments", out var argsEl) && argsEl.ValueKind == JsonValueKind.Object) + foreach (var prop in argsEl.EnumerateObject()) + args[prop.Name] = JsonElementToObject(prop.Value); + + // ResultText is filled in after execution; placeholder here. + calls.Add(new OperatorToolCall(server, tool, args, string.Empty, false)); + } + + return calls; + } + catch + { + return []; + } + } + + private async Task InterpretAsync( + string requesterName, + string task, + IReadOnlyList toolCalls, + CancellationToken ct) + { + string systemPrompt; + string userPrompt; + + if (toolCalls.Count == 0) + { + systemPrompt = """ + You are the Operator — an assistant micro-agent serving an AI council debate. + No MCP tool was applicable to this task. Answer the requester as best you can + from general knowledge, and clearly state that the answer is not tool-sourced. + Be concise and factual. + """; + userPrompt = $""" + Requester: {requesterName} + Task: {task} + + Provide a concise answer. + """; + } + else + { + var resultsText = new StringBuilder(); + for (var i = 0; i < toolCalls.Count; i++) + { + var c = toolCalls[i]; + resultsText.AppendLine($"[Tool {i + 1}] {c.ServerName}.{c.ToolName}{(c.IsError ? " (ERROR)" : "")}"); + resultsText.AppendLine(c.ResultText); + resultsText.AppendLine(); + } + + systemPrompt = """ + You are the Operator — an assistant micro-agent serving an AI council debate. + You delegated the requester's task to MCP tools and received their raw output below. + Interpret and synthesise the results into a clear, concise answer for the requester. + Cite which tool(s) produced the information. Do not invent facts beyond the tool output. + If a tool errored or returned nothing useful, say so plainly. + """; + userPrompt = $""" + Requester: {requesterName} + Task: {task} + + ### Raw tool output: + {resultsText} + + Synthesise the final answer for the requester now. + """; + } + + try + { + return await _model.AskAsync(systemPrompt, userPrompt, 0.3f, ct); + } + catch (Exception ex) + { + return $"[Operator failed to interpret results: {ex.Message}]"; + } + } + + // ────────────────────────────────────────────── + // JSON helpers + // ────────────────────────────────────────────── + + /// Extracts the first JSON object from a model response, tolerating markdown fences/prose. + private static string ExtractJson(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) return string.Empty; + + var start = raw.IndexOf('{'); + var end = raw.LastIndexOf('}'); + return start >= 0 && end > start + ? raw[start..(end + 1)] + : string.Empty; + } + + private static object? JsonElementToObject(JsonElement el) + { + return el.ValueKind switch + { + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var l) + ? l + : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => el.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)), + _ => 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 de994fa..7dbde2e 100644 --- a/src/Delibera.Core/Debate/ConsensusDebate.cs +++ b/src/Delibera.Core/Debate/ConsensusDebate.cs @@ -26,6 +26,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -33,9 +34,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + 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)); @@ -54,8 +59,9 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Perspectives - var r1 = await CollectResponsesAsync(members, context.SystemPrompt, enrichedPrompt, temperature, ct); - var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var round1 = CreateRound(1, "Initial Perspectives", "Each model shares their perspective.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); if (maxRounds < 2) return BuildAndComplete(builder); @@ -70,8 +76,9 @@ public override async Task ExecuteAsync( // Round 2: Finding Common Ground var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} Work collaboratively. Find COMMON GROUND. Identify agreements, disagreements, and propose bridges. Be open to changing your position. """; @@ -79,10 +86,12 @@ and propose bridges. Be open to changing your position. Original question: {fullUserPrompt} All perspectives: {r1Text} + {(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 round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, ct); + var round2 = CreateRound(2, "Finding Common Ground", "Models identify agreements and disagreements.", r2, knowledgeInteractions: r2Ki, operatorInteractions: r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); if (maxRounds < 3) return BuildAndComplete(builder); @@ -97,8 +106,9 @@ and propose bridges. Be open to changing your position. // Round 3: Consensus Building var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} This is the CONSENSUS round. Formulate a single, unified answer. If full consensus is impossible, state what can be agreed upon. """; @@ -106,10 +116,12 @@ and propose bridges. Be open to changing your position. Original question: {fullUserPrompt} Perspectives: {r1Text} Common ground: {r2Text} + {(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 round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var round3 = CreateRound(3, "Consensus Building", "Models attempt a unified answer.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); @@ -138,4 +150,4 @@ private static DebateResult BuildAndComplete(DebateResultBuilder builder) builder.MarkCompleted(); return builder.Build(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/CritiqueDebate.cs b/src/Delibera.Core/Debate/CritiqueDebate.cs index 1fb3ed0..335b463 100644 --- a/src/Delibera.Core/Debate/CritiqueDebate.cs +++ b/src/Delibera.Core/Debate/CritiqueDebate.cs @@ -26,6 +26,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -33,9 +34,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + 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)); @@ -54,8 +59,9 @@ public override async Task ExecuteAsync( : $"{fullUserPrompt}\n\n📚 Knowledge:\n{knowledgeCtx}"; // Round 1: Initial Positions - var r1 = await CollectResponsesAsync(members, context.SystemPrompt, enrichedPrompt, temperature, ct); - var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki); + var r1 = await CollectResponsesAsync(members, baseSystemPrompt, enrichedPrompt, temperature, ct); + var r1Op = await ProcessOperatorRequestsAsync(@operator, r1, ct); + var round1 = CreateRound(1, "Initial Positions", "Each model states their initial position.", r1, knowledgeInteractions: r1Ki, operatorInteractions: r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); if (maxRounds < 2) return BuildAndComplete(builder); @@ -70,8 +76,9 @@ public override async Task ExecuteAsync( // Round 2: Directed Critique var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} You are a sharp analytical critic. Find the WEAKEST points in other participants' arguments. Challenge assumptions, find logical flaws, identify missing evidence, propose counter-examples. """; @@ -79,10 +86,12 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants Original question: {fullUserPrompt} Responses to critique: {r1Text} + {(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 round2 = CreateRound(2, "Directed Critique", "Models attack weaknesses in each other's positions.", r2, knowledgeInteractions: r2Ki); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2, 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); if (maxRounds < 3) return BuildAndComplete(builder); @@ -97,8 +106,9 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants // Round 3: Defence var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3Sys = $""" - {context.SystemPrompt} + {baseSystemPrompt} Defend your position. Address every critique, strengthen weak arguments, provide additional evidence, and concede where critics are right. """; @@ -106,10 +116,12 @@ You are a sharp analytical critic. Find the WEAKEST points in other participants Original question: {fullUserPrompt} Initial responses: {r1Text} Critiques: {r2Text} + {(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 round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3, ct); + var round3 = CreateRound(3, "Defence & Counter-Arguments", "Models defend their positions.", r3, knowledgeInteractions: r3Ki, operatorInteractions: r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); @@ -138,4 +150,4 @@ private static DebateResult BuildAndComplete(DebateResultBuilder builder) builder.MarkCompleted(); return builder.Build(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/DebateResultBuilder.cs b/src/Delibera.Core/Debate/DebateResultBuilder.cs index 1b81201..c50cc68 100644 --- a/src/Delibera.Core/Debate/DebateResultBuilder.cs +++ b/src/Delibera.Core/Debate/DebateResultBuilder.cs @@ -12,34 +12,51 @@ internal sealed class DebateResultBuilder( IReadOnlyList members, PromptContext context, CouncilMember? chairman, - KnowledgeKeeper? knowledgeKeeper) + KnowledgeKeeper? knowledgeKeeper, + Operator? @operator = null) { private readonly List _rounds = []; - private string? _openingStatement; - private string? _finalVerdict; private DateTime? _completedAt; + private string? _finalVerdict; + private string? _openingStatement; public string StrategyName => strategy.StrategyName; public IReadOnlyList Rounds => _rounds; - public void SetOpeningStatement(string? statement) => _openingStatement = statement; + public void SetOpeningStatement(string? statement) + { + _openingStatement = statement; + } - public void AddRound(DebateRound round) => _rounds.Add(round); + public void AddRound(DebateRound round) + { + _rounds.Add(round); + } - public void SetFinalVerdict(string? verdict) => _finalVerdict = verdict; + public void SetFinalVerdict(string? verdict) + { + _finalVerdict = verdict; + } - public void MarkCompleted() => _completedAt = DateTime.UtcNow; + public void MarkCompleted() + { + _completedAt = DateTime.UtcNow; + } - public DebateResult Build() => new() + public DebateResult Build() { - StrategyName = strategy.StrategyName, - Context = context, - Participants = members.Select(m => m.DisplayName).ToList(), - ChairmanName = chairman?.DisplayName, - KnowledgeKeeperName = knowledgeKeeper?.DisplayName, - OpeningStatement = _openingStatement, - Rounds = _rounds, - FinalVerdict = _finalVerdict, - CompletedAt = _completedAt - }; -} + return new DebateResult + { + StrategyName = strategy.StrategyName, + Context = context, + Participants = members.Select(m => m.DisplayName).ToList(), + ChairmanName = chairman?.DisplayName, + KnowledgeKeeperName = knowledgeKeeper?.DisplayName, + OperatorName = @operator?.DisplayName, + OpeningStatement = _openingStatement, + Rounds = _rounds, + FinalVerdict = _finalVerdict, + CompletedAt = _completedAt + }; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/DebateScenario.cs b/src/Delibera.Core/Debate/DebateScenario.cs index 286d22b..bf09067 100644 --- a/src/Delibera.Core/Debate/DebateScenario.cs +++ b/src/Delibera.Core/Debate/DebateScenario.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Delibera.Core.Council; namespace Delibera.Core.Debate; @@ -9,6 +10,20 @@ namespace Delibera.Core.Debate; /// public abstract class DebateScenario : IDebateStrategy { + // ────────────────────────────────────────────── + // 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; } @@ -21,6 +36,7 @@ public abstract Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -71,8 +87,10 @@ protected static string FormatRoundResponses(DebateRound round) } /// Formats all rounds into a single text block. - protected static string FormatAllRounds(IReadOnlyList rounds) => - string.Join("\n\n", rounds.Select(FormatRoundResponses)); + protected static string FormatAllRounds(IReadOnlyList rounds) + { + return string.Join("\n\n", rounds.Select(FormatRoundResponses)); + } /// Creates a completed debate round. protected static DebateRound CreateRound( @@ -81,8 +99,10 @@ protected static DebateRound CreateRound( string? description, Dictionary responses, string? prompt = null, - IReadOnlyList? knowledgeInteractions = null) => - new() + IReadOnlyList? knowledgeInteractions = null, + IReadOnlyList? operatorInteractions = null) + { + return new DebateRound { RoundNumber = number, RoundName = name, @@ -90,8 +110,10 @@ protected static DebateRound CreateRound( Responses = responses, RoundPrompt = prompt, KnowledgeInteractions = knowledgeInteractions ?? [], + OperatorInteractions = operatorInteractions ?? [], CompletedAt = DateTime.UtcNow }; + } /// /// Optionally queries the Knowledge Keeper for context relevant to the debate topic. @@ -155,4 +177,91 @@ protected static DebateRound CreateRound( return (string.Empty, null); } } -} + + /// + /// Builds an "Operator briefing" describing the Operator's tools and how to delegate + /// tasks to it. Appended to the participants' system prompt so they know what is available. + /// Returns an empty string when no Operator is configured. + /// + protected static string BuildOperatorBriefing(Operator? @operator) + { + if (@operator is null) return string.Empty; + + return $""" + + ── OPERATOR (tools available) ── + A shared Operator agent is available to all participants. + {@operator.GetToolCatalog()} + + To delegate a task to the Operator, include a line in your response using this exact marker: + [[OPERATOR: ]] + For example: [[OPERATOR: search the web for recent benchmarks comparing PostgreSQL and MySQL]] + The Operator's answer will be provided to all participants in the next round. + Only delegate when external information or actions (web search, database lookup, writing to Notion, etc.) are genuinely needed. + """; + } + + /// + /// 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 []; + + var interactions = new List(); + 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)); + } + } + } + + return interactions; + } + + /// + /// Formats Operator interactions into a context block that can be injected into the + /// next round's prompt so participants can use the Operator's findings. + /// + protected static string FormatOperatorInteractions(IReadOnlyList interactions) + { + if (interactions is not { Count: > 0 }) return string.Empty; + + var sb = new StringBuilder(); + sb.AppendLine("🛠️ Operator results (requested by participants):"); + foreach (var i in interactions) + { + var tools = i.ToolCalls.Count > 0 + ? string.Join(", ", i.ToolCalls.Select(c => $"{c.ServerName}.{c.ToolName}")) + : "no tools"; + sb.AppendLine($"\n• {i.RequesterName} asked: {i.Task}"); + sb.AppendLine($" Tools used: {tools}"); + sb.AppendLine($" Answer: {i.Answer}"); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Debate/StandardDebate.cs b/src/Delibera.Core/Debate/StandardDebate.cs index 1694070..2ed18a8 100644 --- a/src/Delibera.Core/Debate/StandardDebate.cs +++ b/src/Delibera.Core/Debate/StandardDebate.cs @@ -30,6 +30,7 @@ public override async Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, @@ -37,9 +38,13 @@ public override async Task ExecuteAsync( { ArgumentNullException.ThrowIfNull(members); ArgumentNullException.ThrowIfNull(context); - var builder = new DebateResultBuilder(this, members, context, chairman, knowledgeKeeper); + 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) builder.SetOpeningStatement(await Chairman.OpenDebateAsync( @@ -60,9 +65,11 @@ public override async Task ExecuteAsync( ? fullUserPrompt : $"{fullUserPrompt}\n\n📚 Knowledge Keeper context:\n{knowledgeContext}"; - var r1Responses = await CollectResponsesAsync(members, context.SystemPrompt, r1Prompt, temperature, ct); + 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 round1 = CreateRound(1, "Initial Responses", - "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki); + "All models provide their initial answers.", r1Responses, r1Prompt, r1Ki, r1Op); builder.AddRound(round1); onRoundCompleted?.Invoke(round1); @@ -79,8 +86,9 @@ public override async Task ExecuteAsync( // ══════════ Round 2: Critique ══════════ var r1Text = FormatRoundResponses(round1); + var r1OpText = FormatOperatorInteractions(r1Op); var r2System = $""" - {context.SystemPrompt} + {baseSystemPrompt} You are now in critique mode. Critically analyse the responses from other council members. Identify strengths, weaknesses, factual errors, logical gaps, and areas for improvement. @@ -91,13 +99,14 @@ Be constructive but thorough. Initial responses: {r1Text} - + {(string.IsNullOrWhiteSpace(r1OpText) ? "" : $"\n{r1OpText}")} Provide your detailed critique of each response. """; var r2Responses = await CollectResponsesAsync(members, r2System, r2Prompt, temperature, ct); + var r2Op = await ProcessOperatorRequestsAsync(@operator, r2Responses, ct); var round2 = CreateRound(2, "Critique", - "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki); + "Models critically analyse each other's responses.", r2Responses, r2Prompt, r2Ki, r2Op); builder.AddRound(round2); onRoundCompleted?.Invoke(round2); @@ -114,8 +123,9 @@ Provide your detailed critique of each response. // ══════════ Round 3: Improved Responses ══════════ var r2Text = FormatRoundResponses(round2); + var r2OpText = FormatOperatorInteractions(r2Op); var r3System = $""" - {context.SystemPrompt} + {baseSystemPrompt} You have received critiques. Provide your FINAL, IMPROVED answer. Take the best ideas from all participants, address the critiques, @@ -129,13 +139,14 @@ and synthesise the most comprehensive response possible. Critiques: {r2Text} - + {(string.IsNullOrWhiteSpace(r1OpText) && string.IsNullOrWhiteSpace(r2OpText) ? "" : $"\n{r1OpText}\n{r2OpText}")} Provide your final, improved answer. """; var r3Responses = await CollectResponsesAsync(members, r3System, r3Prompt, temperature, ct); + var r3Op = await ProcessOperatorRequestsAsync(@operator, r3Responses, ct); var round3 = CreateRound(3, "Final Improved Responses", - "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki); + "Models provide refined answers incorporating critiques.", r3Responses, r3Prompt, r3Ki, r3Op); builder.AddRound(round3); onRoundCompleted?.Invoke(round3); @@ -166,4 +177,4 @@ private static DebateResult BuildAndComplete(DebateResultBuilder builder) builder.MarkCompleted(); return builder.Build(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Delibera.Core.csproj b/src/Delibera.Core/Delibera.Core.csproj index a216279..2d3a366 100644 --- a/src/Delibera.Core/Delibera.Core.csproj +++ b/src/Delibera.Core/Delibera.Core.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - 14.0 + preview true true true @@ -12,7 +12,7 @@ Delibera.Core - 10.1.0 + 10.1.1 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. Victor Buzin @@ -21,11 +21,12 @@ Copyright © 2026 Techbuzzz llm;ai;debate;council;deliberation;rag;qdrant;pgvector;ollama;openai;vector-search;embeddings;dependency-injection README.md + icon.png MIT https://github.com/techbuzzz/Delibera git https://github.com/techbuzzz/Delibera - v10.1.0 — Major version update with improved dependency injection, context compression, and RAG support. + 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. true snupkg true @@ -33,22 +34,24 @@ - + + + - + \ No newline at end of file diff --git a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs index 202144f..24322bb 100644 --- a/src/Delibera.Core/DependencyInjection/CouncilOptions.cs +++ b/src/Delibera.Core/DependencyInjection/CouncilOptions.cs @@ -30,6 +30,9 @@ public sealed class CouncilOptions /// RAG configuration options. public RagOptions Rag { get; set; } = new(); + /// Operator (MCP tool micro-agent) configuration options. + public OperatorConfig Operator { get; set; } = new(); + /// Output configuration options. public OutputOptions Output { get; set; } = new(); } @@ -101,6 +104,85 @@ public sealed class RagOptions public string? ConnectionString { get; set; } } +/// +/// Configuration options for the Operator (MCP tool micro-agent). +/// +/// +/// Bind this from configuration (e.g., Delibera:Operator) and use +/// to materialise instances +/// for ICouncilBuilder.WithOperator(...). +/// +public sealed class OperatorConfig +{ + /// Whether the Operator is enabled. + public bool Enabled { get; set; } + + /// Model name used by the Operator (typically a cheaper model than the participants). + public string ModelName { get; set; } = string.Empty; + + /// Whether the Operator reuses the council's compressor for large tool results. + public bool ReuseCompression { get; set; } = true; + + /// MCP servers the Operator connects to. + public List McpServers { get; set; } = []; + + /// Materialises the configured MCP servers into instances. + public IReadOnlyList ToServerConfigs() + { + return McpServers.Select(s => s.ToConfig()).ToList(); + } +} + +/// +/// Configuration for a single MCP server bound from configuration. +/// +public sealed class McpServerOptions +{ + /// Logical server name surfaced to participants (e.g., "web", "notion", "postgres"). + public string Name { get; set; } = string.Empty; + + /// Transport type: "Stdio" (default) or "Http". + public string Transport { get; set; } = "Stdio"; + + // ── Stdio ── + + /// Executable command for stdio transport (e.g., "npx", "uvx", "dotnet"). + public string? Command { get; set; } + + /// Command-line arguments for stdio transport. + public List Arguments { get; set; } = []; + + /// Working directory for the spawned stdio process. + public string? WorkingDirectory { get; set; } + + /// Environment variables passed to the stdio process. + public Dictionary EnvironmentVariables { get; set; } = []; + + // ── Http ── + + /// Endpoint URL for HTTP/SSE transport. + public string? Endpoint { get; set; } + + /// Additional HTTP headers (e.g., authorization) for HTTP transport. + public Dictionary AdditionalHeaders { get; set; } = []; + + /// Converts these options into a . + public McpServerConfig ToConfig() + { + return string.Equals(Transport, "Http", StringComparison.OrdinalIgnoreCase) + ? McpServerConfig.Http( + Name, + new Uri(Endpoint ?? throw new InvalidOperationException($"MCP server '{Name}' uses Http transport but has no Endpoint.")), + AdditionalHeaders) + : McpServerConfig.Stdio( + Name, + Command ?? throw new InvalidOperationException($"MCP server '{Name}' uses Stdio transport but has no Command."), + Arguments, + EnvironmentVariables, + WorkingDirectory); + } +} + /// /// Configuration options for debate output files. /// diff --git a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs index b9e37be..ec5fbe0 100644 --- a/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Delibera.Core/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using Delibera.Core.Compression; using Delibera.Core.Council; using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; using Delibera.Core.Providers.RAG; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -73,4 +75,77 @@ public static IServiceCollection AddDelibera( return services; } -} + + /// + /// Registers a Microsoft.Extensions.AI and exposes it as a Delibera + /// (). + /// + /// + /// 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. + /// + /// The service collection. + /// Factory that builds the chat client (optionally with middleware). + /// Optional friendly provider name surfaced by . + public static IServiceCollection AddDeliberaChatClient( + this IServiceCollection services, + Func chatClientFactory, + string? providerName = null) + { + ArgumentNullException.ThrowIfNull(chatClientFactory); + + services.AddDelibera(); + services.TryAddSingleton(chatClientFactory); + // The DI container owns the IChatClient lifetime, so the provider must not dispose it. + services.TryAddSingleton(sp => + new ChatClientLLMProvider(sp.GetRequiredService(), providerName, false)); + + return services; + } + + /// + /// 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, + string? providerName = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + return services.AddDeliberaChatClient(_ => chatClient, providerName); + } + + /// + /// 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, + string? modelName = null, + int? vectorSize = null) + { + ArgumentNullException.ThrowIfNull(generatorFactory); + + services.AddDelibera(); + services.TryAddSingleton(generatorFactory); + services.TryAddSingleton(sp => + new EmbeddingGeneratorProvider( + sp.GetRequiredService>>(), + modelName, + vectorSize, + false)); + + return services; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs new file mode 100644 index 0000000..906b577 --- /dev/null +++ b/src/Delibera.Core/Extensions/MicrosoftAIExtensions.cs @@ -0,0 +1,160 @@ +using System.Runtime.CompilerServices; +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Delibera.Core.Extensions; + +/// +/// Bridging helpers between Delibera's provider abstractions (, +/// ) and the Microsoft.Extensions.AI abstractions +/// (, ). +/// +/// +/// These extensions let the two worlds interoperate in either direction: +/// +/// +/// Adopt any Microsoft.Extensions.AI client as a Delibera provider via / +/// . +/// +/// +/// Expose a Delibera provider as a Microsoft.Extensions.AI via +/// , so it can participate in the standard middleware pipeline. +/// +/// Compose middleware (function invocation, logging) with . +/// +/// +public static class MicrosoftAIExtensions +{ + /// + /// Adopts a Microsoft.Extensions.AI as a Delibera . + /// + /// The chat client to wrap. + /// Optional friendly provider name (defaults to client metadata). + /// Whether disposing the provider also disposes the client. + public static ILLMProvider AsLLMProvider(this IChatClient chatClient, string? providerName = null, bool ownsClient = true) + { + return new ChatClientLLMProvider(chatClient, providerName, ownsClient); + } + + /// + /// Adopts a Microsoft.Extensions.AI as a + /// Delibera . + /// + public static IEmbeddingProvider AsEmbeddingProvider( + this IEmbeddingGenerator> generator, + string? modelName = null, + int? vectorSize = null, + bool ownsGenerator = true) + { + return new EmbeddingGeneratorProvider(generator, modelName, vectorSize, ownsGenerator); + } + + /// + /// Exposes a Delibera as a Microsoft.Extensions.AI . + /// + /// + /// Use this to drop an existing Delibera provider into a Microsoft.Extensions.AI middleware pipeline + /// (caching, telemetry, function invocation). If the provider already is a + /// , its underlying client is returned directly to avoid a needless layer. + /// + /// The Delibera provider to expose. + /// Model id used when a request does not specify one. + public static IChatClient AsChatClient(this ILLMProvider provider, string? defaultModel = null) + { + ArgumentNullException.ThrowIfNull(provider); + if (provider is ChatClientLLMProvider ccp) return ccp.ChatClient; + return new LLMProviderChatClient(provider, defaultModel); + } + + /// + /// Composes a standard Microsoft.Extensions.AI middleware pipeline around an . + /// + /// The inner client. + /// Add automatic function (tool) invocation middleware. + /// When supplied, adds logging middleware. + /// The decorated client. + public static IChatClient WithMiddleware( + this IChatClient chatClient, + bool enableFunctionInvocation = false, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + + var builder = chatClient.AsBuilder(); + if (loggerFactory is not null) builder = builder.UseLogging(loggerFactory); + if (enableFunctionInvocation) builder = builder.UseFunctionInvocation(loggerFactory); + return builder.Build(); + } + + /// + /// Minimal adapter over a Delibera . + /// + private sealed class LLMProviderChatClient(ILLMProvider provider, string? defaultModel) : IChatClient + { + private readonly ChatClientMetadata _metadata = new(provider.ProviderName, defaultModelId: defaultModel); + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var (system, user) = SplitMessages(messages); + var text = await provider.ChatAsync( + ResolveModel(options), + system, + user, + options?.Temperature ?? 0.7f, + cancellationToken); + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, text)) { ModelId = ResolveModel(options) }; + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var (system, user) = SplitMessages(messages); + await foreach (var chunk in provider.ChatStreamAsync( + ResolveModel(options), system, user, options?.Temperature ?? 0.7f, cancellationToken)) + yield return new ChatResponseUpdate(ChatRole.Assistant, chunk); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + if (serviceKey is null && serviceType.IsInstanceOfType(_metadata)) return _metadata; + if (serviceKey is null && serviceType.IsInstanceOfType(provider)) return provider; + return null; + } + + public void Dispose() + { + provider.Dispose(); + } + + private string ResolveModel(ChatOptions? options) + { + return options?.ModelId is { Length: > 0 } m + ? m + : defaultModel ?? string.Empty; + } + + private static (string System, string User) SplitMessages(IEnumerable messages) + { + var system = new StringBuilder(); + var user = new StringBuilder(); + foreach (var message in messages) + { + var target = message.Role == ChatRole.System + ? system + : user; + if (target.Length > 0) target.Append('\n'); + target.Append(message.Text); + } + + return (system.ToString(), user.ToString()); + } + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IContextCompressor.cs b/src/Delibera.Core/Interfaces/IContextCompressor.cs index 7033409..fa44a36 100644 --- a/src/Delibera.Core/Interfaces/IContextCompressor.cs +++ b/src/Delibera.Core/Interfaces/IContextCompressor.cs @@ -131,4 +131,4 @@ public sealed record CompressedContext /// Percentage of tokens saved. public double TokensSavedPercent => (1.0 - CompressionRatio) * 100.0; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs index a5c9fd4..c3cb4a6 100644 --- a/src/Delibera.Core/Interfaces/ICouncilBuilder.cs +++ b/src/Delibera.Core/Interfaces/ICouncilBuilder.cs @@ -1,4 +1,5 @@ using Delibera.Core.Council; +using Delibera.Core.Debate; namespace Delibera.Core.Interfaces; @@ -43,19 +44,52 @@ public interface ICouncilBuilder /// This builder for fluent chaining. ICouncilBuilder WithKnowledgeKeeper(KnowledgeKeeper knowledgeKeeper); + /// Attaches a pre-configured Operator (MCP tool micro-agent). + /// Configured Operator instance. + /// This builder for fluent chaining. + ICouncilBuilder WithOperator(Operator @operator); + + /// + /// Creates and attaches an Operator from a (cheaper) model and one or more MCP server + /// configurations. The Operator connects to the servers, discovers their tools, and lets + /// participants delegate natural-language tasks to it during the debate. + /// + /// Model name used by the Operator (typically a cheaper model). + /// LLM provider for the Operator model. + /// MCP server configurations the Operator connects to. + /// + /// When true (default), the Operator reuses the council's configured compressor + /// (if any) to compress large tool results before returning them. + /// + /// This builder for fluent chaining. + ICouncilBuilder WithOperator( + string modelName, + ILLMProvider provider, + IEnumerable servers, + bool reuseCompression = true); + /// Sets the debate strategy. /// Strategy implementation. /// This builder for fluent chaining. ICouncilBuilder WithStrategy(IDebateStrategy strategy); /// Uses the standard 4-round debate strategy. - ICouncilBuilder WithStandardDebate() => WithStrategy(new Debate.StandardDebate()); + ICouncilBuilder WithStandardDebate() + { + return WithStrategy(new StandardDebate()); + } /// Uses the adversarial critique debate strategy. - ICouncilBuilder WithCritiqueDebate() => WithStrategy(new Debate.CritiqueDebate()); + ICouncilBuilder WithCritiqueDebate() + { + return WithStrategy(new CritiqueDebate()); + } /// Uses the consensus-building debate strategy. - ICouncilBuilder WithConsensusDebate() => WithStrategy(new Debate.ConsensusDebate()); + ICouncilBuilder WithConsensusDebate() + { + return WithStrategy(new ConsensusDebate()); + } /// Attaches a legacy knowledge base for prompt injection. /// Knowledge base instance. @@ -123,4 +157,4 @@ ICouncilBuilder WithCompression( /// Configured council executor ready for debate execution. /// When required configuration is missing. ICouncilExecutor Build(); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs index 0d0e29a..74a1362 100644 --- a/src/Delibera.Core/Interfaces/ICouncilExecutor.cs +++ b/src/Delibera.Core/Interfaces/ICouncilExecutor.cs @@ -17,6 +17,9 @@ public interface ICouncilExecutor /// Knowledge Keeper (may be null). KnowledgeKeeper? KnowledgeKeeper { get; } + /// Operator (may be null). + Operator? Operator { get; } + /// Debate strategy. IDebateStrategy Strategy { get; } diff --git a/src/Delibera.Core/Interfaces/IDebateStrategy.cs b/src/Delibera.Core/Interfaces/IDebateStrategy.cs index f79c518..9116976 100644 --- a/src/Delibera.Core/Interfaces/IDebateStrategy.cs +++ b/src/Delibera.Core/Interfaces/IDebateStrategy.cs @@ -21,6 +21,7 @@ public interface IDebateStrategy /// 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. @@ -31,6 +32,7 @@ Task ExecuteAsync( PromptContext context, CouncilMember? chairman, KnowledgeKeeper? knowledgeKeeper, + Operator? @operator, int maxRounds = 4, float temperature = 0.7f, Action? onRoundCompleted = null, diff --git a/src/Delibera.Core/Interfaces/ILLMProvider.cs b/src/Delibera.Core/Interfaces/ILLMProvider.cs index fc08380..d58e65c 100644 --- a/src/Delibera.Core/Interfaces/ILLMProvider.cs +++ b/src/Delibera.Core/Interfaces/ILLMProvider.cs @@ -1,3 +1,5 @@ +using System.Runtime.CompilerServices; + namespace Delibera.Core.Interfaces; /// @@ -30,4 +32,30 @@ Task ChatAsync( string userPrompt, float temperature = 0.7f, CancellationToken ct = default); + + /// + /// Streams a chat-completion response incrementally as text chunks. + /// + /// + /// This is an additive capability introduced alongside the Microsoft.Extensions.AI integration. + /// The default implementation falls back to a single call and yields the + /// whole answer at once, so existing providers keep working unchanged. Providers backed by an + /// IChatClient (see ) override it to deliver + /// true token-by-token streaming. + /// + /// Model name (e.g., "llama3.2", "gpt-4o-mini"). + /// System prompt that defines the model's role / context. + /// User prompt with the question or task. + /// Generation temperature (0.0 = deterministic, 1.0+ = creative). + /// Cancellation token. + /// An asynchronous stream of text chunks that together form the full response. + async IAsyncEnumerable ChatStreamAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + [EnumeratorCancellation] CancellationToken ct = default) + { + yield return await ChatAsync(model, systemPrompt, userPrompt, temperature, ct); + } } \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IMcpClient.cs b/src/Delibera.Core/Interfaces/IMcpClient.cs new file mode 100644 index 0000000..791ee24 --- /dev/null +++ b/src/Delibera.Core/Interfaces/IMcpClient.cs @@ -0,0 +1,41 @@ +namespace Delibera.Core.Interfaces; + +/// +/// Thin abstraction over a connection to a single MCP (Model Context Protocol) server. +/// Allows the micro-agent to discover and invoke tools +/// without coupling the rest of the framework to a specific MCP SDK, and keeps the +/// Operator unit-testable via fakes. +/// +public interface IMcpClient : IAsyncDisposable +{ + /// Logical name of the server (e.g., "web", "notion", "postgres"). + string ServerName { get; } + + /// Whether the client has been connected via . + bool IsConnected { get; } + + /// + /// Establishes the connection to the MCP server and performs the initial handshake. + /// + /// Cancellation token. + Task ConnectAsync(CancellationToken ct = default); + + /// + /// Lists the tools exposed by the connected MCP server. + /// + /// Cancellation token. + /// Discovered tools, tagged with this client's . + Task> ListToolsAsync(CancellationToken ct = default); + + /// + /// Invokes a tool on the MCP server. + /// + /// Name of the tool to call. + /// Arguments passed to the tool. + /// Cancellation token. + /// The tool's textual result and error flag. + Task CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IOperator.cs b/src/Delibera.Core/Interfaces/IOperator.cs new file mode 100644 index 0000000..dd8a23a --- /dev/null +++ b/src/Delibera.Core/Interfaces/IOperator.cs @@ -0,0 +1,51 @@ +namespace Delibera.Core.Interfaces; + +/// +/// The Operator — a special council role implemented as a self-contained micro-agent. +/// During a debate, participants can delegate tasks to the Operator in natural language +/// (e.g., "search the web for …", "save the debate summary to Notion", "query the +/// PostgreSQL employee table for …"). The Operator interprets the task with its own +/// (typically cheaper) LLM model, selects and invokes the appropriate MCP tools, +/// interprets the raw tool output, optionally compresses it, and returns a concise +/// answer to the requesting participant. +/// +public interface IOperator : IAsyncDisposable +{ + /// Display name shown in debate logs (e.g., "🛠️ Operator (llama3.2)"). + string DisplayName { get; } + + /// Whether has completed successfully. + bool IsInitialized { get; } + + /// All tools discovered across the configured MCP servers. + IReadOnlyList AvailableTools { get; } + + /// All interactions recorded during this session. + IReadOnlyList Interactions { get; } + + /// + /// Connects to every configured MCP server and discovers their tools. + /// Safe to call multiple times — subsequent calls are no-ops once initialised. + /// + /// Cancellation token. + Task InitializeAsync(CancellationToken ct = default); + + /// + /// Returns a human-readable catalogue of the Operator's capabilities, suitable for + /// injection into participant prompts so they understand what they can delegate. + /// + string GetToolCatalog(); + + /// + /// Executes a natural-language task on behalf of a participant: selects MCP tools, + /// invokes them, interprets the results, optionally compresses, and returns an answer. + /// + /// Display name of the requesting participant. + /// The natural-language task to fulfil. + /// Cancellation token. + /// The Operator's result, including the answer and the tool calls performed. + Task ExecuteTaskAsync( + string requesterName, + string task, + CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/Delibera.Core/Interfaces/IVectorStore.cs b/src/Delibera.Core/Interfaces/IVectorStore.cs index 9101205..527d677 100644 --- a/src/Delibera.Core/Interfaces/IVectorStore.cs +++ b/src/Delibera.Core/Interfaces/IVectorStore.cs @@ -70,4 +70,4 @@ Task> SearchAsync( /// Returns the total number of points in a collection. Task CountAsync(string collectionName, CancellationToken ct = default); -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs index f3efd7a..8d4fecc 100644 --- a/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs +++ b/src/Delibera.Core/Knowledge/MarkdownKnowledgeBase.cs @@ -86,7 +86,10 @@ public IReadOnlyList Search(string query, int maxResults = 5) } /// - public IReadOnlyList GetLoadedSources() => _documents.Keys.ToList().AsReadOnly(); + public IReadOnlyList GetLoadedSources() + { + return _documents.Keys.ToList().AsReadOnly(); + } /// Loads all matching files from a directory. public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.md") @@ -97,4 +100,4 @@ public async Task LoadDirectoryAsync(string directoryPath, string pattern = "*.m await LoadManyAsync(Directory.GetFiles(full, pattern, SearchOption.AllDirectories)); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/CompressionLog.cs b/src/Delibera.Core/Models/CompressionLog.cs index 5361d06..42531c7 100644 --- a/src/Delibera.Core/Models/CompressionLog.cs +++ b/src/Delibera.Core/Models/CompressionLog.cs @@ -33,4 +33,4 @@ public sealed record CompressionLog public double Ratio => OriginalTokens > 0 ? (double)CompressedTokens / OriginalTokens : 1.0; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/CouncilMember.cs b/src/Delibera.Core/Models/CouncilMember.cs index 03b9398..297cc63 100644 --- a/src/Delibera.Core/Models/CouncilMember.cs +++ b/src/Delibera.Core/Models/CouncilMember.cs @@ -5,6 +5,10 @@ namespace Delibera.Core.Models; /// public sealed class CouncilMember(string modelName, ILLMProvider provider, string? role = null, string? personaPrompt = null) { + private readonly string? _personaPrompt = personaPrompt; + + private string _role = role ?? "Expert"; + /// Unique participant identifier. public string Id { get; } = $"{provider.ProviderName}:{modelName}:{Guid.NewGuid():N}".ToLowerInvariant(); @@ -17,8 +21,6 @@ public sealed class CouncilMember(string modelName, ILLMProvider provider, strin /// LLM provider that serves this model. public ILLMProvider Provider { get; } = provider ?? throw new ArgumentNullException(nameof(provider)); - private string _role = role ?? "Expert"; - /// Role in the debate (Expert, Critic, Chairman, etc.). public string Role { @@ -26,14 +28,8 @@ public string Role set => field = value ?? "Expert"; } - private string? _personaPrompt = personaPrompt; - /// Optional persona system-prompt that personalises the model's behaviour. - public string? PersonaPrompt - { - get => field; - set => field = value; - } + public string? PersonaPrompt { get; set; } /// Sends a request to the underlying model. public Task AskAsync( @@ -53,5 +49,8 @@ public Task AskAsync( } /// - public override string ToString() => DisplayName; -} + public override string ToString() + { + return DisplayName; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateResult.cs b/src/Delibera.Core/Models/DebateResult.cs index 4e76932..fa429bc 100644 --- a/src/Delibera.Core/Models/DebateResult.cs +++ b/src/Delibera.Core/Models/DebateResult.cs @@ -1,5 +1,3 @@ -using System.Text; - namespace Delibera.Core.Models; /// @@ -25,6 +23,9 @@ public sealed record DebateResult /// Knowledge Keeper name (if assigned). public string? KnowledgeKeeperName { get; init; } + /// Operator name (if assigned). + public string? OperatorName { get; init; } + /// All debate rounds. public IReadOnlyList Rounds { get; init; } = []; @@ -97,6 +98,8 @@ public string ToMarkdown() sb.AppendLine($"**Chairman:** {ChairmanName}"); if (!string.IsNullOrEmpty(KnowledgeKeeperName)) sb.AppendLine($"**Knowledge Keeper:** {KnowledgeKeeperName}"); + if (!string.IsNullOrEmpty(OperatorName)) + sb.AppendLine($"**Operator:** {OperatorName}"); sb.AppendLine(); // Opening statement @@ -129,6 +132,23 @@ public string ToMarkdown() } } + // Operator interactions + if (round.OperatorInteractions is { Count: > 0 }) + { + sb.AppendLine("### 🛠️ Operator Interactions"); + sb.AppendLine(); + foreach (var oi in round.OperatorInteractions) + { + var tools = oi.ToolCalls.Count > 0 + ? string.Join(", ", oi.ToolCalls.Select(c => $"`{c.ServerName}.{c.ToolName}`")) + : "none"; + sb.AppendLine($"> **{oi.RequesterName}** requested: {oi.Task}"); + sb.AppendLine($"> **Tools used:** {tools}"); + sb.AppendLine($"> **Answer:** {oi.Answer}{(oi.Compressed ? " *(compressed)*" : "")}"); + sb.AppendLine(); + } + } + foreach (var (member, response) in round.Responses) { sb.AppendLine($"### {member}"); @@ -250,19 +270,28 @@ public string ToLogsMarkdown() /// Saves the debate result (rounds and verdict) to a Markdown file. /// /// Path for the result Markdown file. - public Task SaveToMarkdownAsync(string filePath) => WriteAllTextAsync(filePath, ToMarkdown()); + public Task SaveToMarkdownAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToMarkdown()); + } /// /// Saves token statistics and compression logs to a Markdown file. /// /// Path for the statistics Markdown file. - public Task SaveStatisticsAsync(string filePath) => WriteAllTextAsync(filePath, ToStatisticsMarkdown()); + public Task SaveStatisticsAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToStatisticsMarkdown()); + } /// /// Saves execution logs to a Markdown file. /// /// Path for the logs Markdown file. - public Task SaveLogsAsync(string filePath) => WriteAllTextAsync(filePath, ToLogsMarkdown()); + public Task SaveLogsAsync(string filePath) + { + return WriteAllTextAsync(filePath, ToLogsMarkdown()); + } /// /// Saves all three files (result.md, statistics.md, logs.md) to the specified directory. @@ -363,4 +392,4 @@ public static void WriteTokenStatistics(StringBuilder sb, TokenStatistics stats) sb.AppendLine(); } } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/DebateRound.cs b/src/Delibera.Core/Models/DebateRound.cs index 5a2495d..33ca3ef 100644 --- a/src/Delibera.Core/Models/DebateRound.cs +++ b/src/Delibera.Core/Models/DebateRound.cs @@ -55,6 +55,9 @@ public sealed record DebateRound /// Knowledge Keeper queries & answers that occurred during this round. public IReadOnlyList KnowledgeInteractions { get; init; } = []; + /// Operator tasks delegated by participants that occurred during this round. + public IReadOnlyList OperatorInteractions { get; init; } = []; + /// Timestamp when the round started. public DateTime StartedAt { get; init; } = DateTime.UtcNow; @@ -63,4 +66,4 @@ public sealed record DebateRound /// Round duration. public TimeSpan Duration => (CompletedAt ?? DateTime.UtcNow) - StartedAt; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/ExecutionLog.cs b/src/Delibera.Core/Models/ExecutionLog.cs index ebf8a0f..700bc4f 100644 --- a/src/Delibera.Core/Models/ExecutionLog.cs +++ b/src/Delibera.Core/Models/ExecutionLog.cs @@ -39,25 +39,40 @@ public sealed record ExecutionLog( /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Info(string source, string message) => new(LogLevel.Info, source, message, DateTime.UtcNow); + public static ExecutionLog Info(string source, string message) + { + return new ExecutionLog(LogLevel.Info, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Trace(string source, string message) => new(LogLevel.Trace, source, message, DateTime.UtcNow); + public static ExecutionLog Trace(string source, string message) + { + return new ExecutionLog(LogLevel.Trace, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Warn(string source, string message) => new(LogLevel.Warning, source, message, DateTime.UtcNow); + public static ExecutionLog Warn(string source, string message) + { + return new ExecutionLog(LogLevel.Warning, source, message, DateTime.UtcNow); + } /// /// Creates an with and the current UTC time. /// - public static ExecutionLog Error(string source, string message) => new(LogLevel.Error, source, message, DateTime.UtcNow); + public static ExecutionLog Error(string source, string message) + { + return new ExecutionLog(LogLevel.Error, source, message, DateTime.UtcNow); + } /// /// Formats the log entry as a single-line string. /// - public override string ToString() => $"[{Timestamp:HH:mm:ss.fff}] [{Level,-7}] [{Source}] {Message}"; -} + public override string ToString() + { + return $"[{Timestamp:HH:mm:ss.fff}] [{Level,-7}] [{Source}] {Message}"; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/OperatorModels.cs b/src/Delibera.Core/Models/OperatorModels.cs new file mode 100644 index 0000000..e9a71ea --- /dev/null +++ b/src/Delibera.Core/Models/OperatorModels.cs @@ -0,0 +1,182 @@ +namespace Delibera.Core.Models; + +/// +/// Transport type used to connect to an MCP (Model Context Protocol) server. +/// +public enum McpTransportType +{ + /// Local server launched as a child process and communicating over standard I/O. + Stdio = 0, + + /// Remote server reached over HTTP / Server-Sent Events. + Http = 1 +} + +/// +/// Configuration describing how the connects to a single +/// MCP server. Supports both local (stdio) and remote (HTTP/SSE) servers. +/// +/// +/// Examples: +/// +/// +/// +/// Stdio: McpServerConfig.Stdio("web", "npx", ["-y", "@modelcontextprotocol/server-everything"]) +/// +/// +/// +/// +/// HTTP: McpServerConfig.Http("notion", new Uri("https://mcp.notion.com/sse")) +/// +/// +/// +/// +public sealed record McpServerConfig +{ + /// Logical server name surfaced to participants (e.g., "web", "notion", "postgres"). + public required string Name { get; init; } + + /// Transport used to reach this server. + public McpTransportType TransportType { get; init; } = McpTransportType.Stdio; + + // ── Stdio transport ── + + /// Executable command for stdio transport (e.g., "npx", "dotnet", "uvx"). + public string? Command { get; init; } + + /// Command-line arguments for stdio transport. + public IReadOnlyList Arguments { get; init; } = []; + + /// Working directory for the spawned stdio process. + public string? WorkingDirectory { get; init; } + + /// Environment variables passed to the stdio process (e.g., API keys). + public IReadOnlyDictionary EnvironmentVariables { get; init; } = + new Dictionary(); + + // ── HTTP transport ── + + /// Endpoint URI for HTTP/SSE transport. + public Uri? Endpoint { get; init; } + + /// Additional HTTP headers (e.g., authorization) for HTTP transport. + public IReadOnlyDictionary AdditionalHeaders { get; init; } = + new Dictionary(); + + /// Creates a stdio MCP server configuration. + public static McpServerConfig Stdio( + string name, + string command, + IReadOnlyList? arguments = null, + IReadOnlyDictionary? environmentVariables = null, + string? workingDirectory = null) + { + return new McpServerConfig + { + Name = name, + TransportType = McpTransportType.Stdio, + Command = command, + Arguments = arguments ?? [], + EnvironmentVariables = environmentVariables ?? new Dictionary(), + WorkingDirectory = workingDirectory + }; + } + + /// Creates an HTTP/SSE MCP server configuration. + public static McpServerConfig Http( + string name, + Uri endpoint, + IReadOnlyDictionary? additionalHeaders = null) + { + return new McpServerConfig + { + Name = name, + TransportType = McpTransportType.Http, + Endpoint = endpoint, + AdditionalHeaders = additionalHeaders ?? new Dictionary() + }; + } +} + +/// +/// Describes a single tool exposed by an MCP server, as discovered by the Operator. +/// +/// Logical name of the server that owns this tool. +/// Tool name (as defined by the MCP server). +/// Human-readable tool description. +/// JSON schema for the tool's input arguments (may be empty). +public sealed record OperatorTool( + string ServerName, + string Name, + string Description, + string InputSchemaJson) +{ + /// Fully-qualified tool reference used when selecting tools (e.g., "web.search"). + public string QualifiedName => $"{ServerName}.{Name}"; +} + +/// +/// A single tool invocation performed by the Operator while fulfilling a request. +/// +/// Server that owns the tool. +/// Name of the invoked tool. +/// Arguments passed to the tool. +/// Textual result returned by the tool. +/// Whether the tool reported an error. +public sealed record OperatorToolCall( + string ServerName, + string ToolName, + IReadOnlyDictionary Arguments, + string ResultText, + bool IsError); + +/// +/// Result of a single MCP tool call at the transport level. +/// +/// Concatenated text content blocks returned by the tool. +/// Whether the tool reported an error. +public sealed record McpToolResult(string Text, bool IsError); + +/// +/// Records one complete Operator interaction during a debate — a participant's task, +/// the tools used to fulfil it, and the Operator's synthesised answer. +/// +/// Display name of the participant who requested the Operator. +/// The natural-language task delegated to the Operator. +/// The Operator's synthesised answer returned to the requester. +/// All MCP tool calls performed while fulfilling the task. +/// Whether the answer was compressed before being returned. +/// UTC timestamp when the interaction completed. +public sealed record OperatorInteraction( + string RequesterName, + string Task, + string Answer, + IReadOnlyList ToolCalls, + bool Compressed, + DateTime Timestamp) +{ + /// Number of tools invoked for this interaction. + public int ToolCallCount => ToolCalls.Count; +} + +/// +/// The Operator's response to a delegated task. +/// +/// Display name of the requester. +/// The original task. +/// The synthesised answer. +/// Tool calls performed. +/// Whether the answer was compressed. +public sealed record OperatorResult( + string RequesterName, + string Task, + string Answer, + IReadOnlyList ToolCalls, + bool Compressed) +{ + /// Converts this result into a loggable . + public OperatorInteraction ToInteraction() + { + return new OperatorInteraction(RequesterName, Task, Answer, ToolCalls, Compressed, DateTime.UtcNow); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/PromptContext.cs b/src/Delibera.Core/Models/PromptContext.cs index cd5d385..adba2b6 100644 --- a/src/Delibera.Core/Models/PromptContext.cs +++ b/src/Delibera.Core/Models/PromptContext.cs @@ -31,11 +31,11 @@ public string GetFullUserPrompt() return UserPrompt; return $$""" - ### Context (Knowledge Base): - {{KnowledgeContent}} + ### Context (Knowledge Base): + {{KnowledgeContent}} - ### Question: - {{UserPrompt}} - """; + ### Question: + {{UserPrompt}} + """; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Models/TokenStatistics.cs b/src/Delibera.Core/Models/TokenStatistics.cs index cec8871..d7413b9 100644 --- a/src/Delibera.Core/Models/TokenStatistics.cs +++ b/src/Delibera.Core/Models/TokenStatistics.cs @@ -52,13 +52,16 @@ public sealed record TokenStatistics /// /// Formats the statistics as a human-readable summary. /// - public string ToSummary() => $""" - 📊 Token Statistics: - Original tokens: {TotalOriginalTokens:N0} - Compressed tokens: {TotalCompressedTokens:N0} - Response tokens: {TotalResponseTokens:N0} - Tokens saved: {TokensSaved:N0} ({SavedPercent:F1}%) - Compression ratio: {OverallCompressionRatio:P1} - Grand total: {GrandTotal:N0} - """; -} + public string ToSummary() + { + return $""" + 📊 Token Statistics: + Original tokens: {TotalOriginalTokens:N0} + Compressed tokens: {TotalCompressedTokens:N0} + Response tokens: {TotalResponseTokens:N0} + Tokens saved: {TokensSaved:N0} ({SavedPercent:F1}%) + Compression ratio: {OverallCompressionRatio:P1} + Grand total: {GrandTotal:N0} + """; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs new file mode 100644 index 0000000..6287fd8 --- /dev/null +++ b/src/Delibera.Core/Providers/LLM/ChatClientLLMProvider.cs @@ -0,0 +1,166 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Providers.LLM; + +/// +/// A universal implemented on top of the Microsoft.Extensions.AI +/// abstraction. +/// +/// +/// +/// This provider lets Delibera talk to any chat backend that ships an +/// implementation — Ollama (via OllamaSharp), OpenAI / Azure OpenAI +/// (via Microsoft.Extensions.AI.OpenAI), Anthropic, local OpenAI-compatible servers +/// (LM Studio, LocalAI, vLLM), and so on — without writing a bespoke provider for each one. +/// +/// +/// Because it wraps an , it also benefits from the +/// Microsoft.Extensions.AI middleware pipeline (function invocation, caching, telemetry, +/// logging). Build a decorated client with helpers and +/// hand it to this provider. +/// +/// +public sealed class ChatClientLLMProvider : ILLMProvider +{ + private readonly bool _ownsClient; + private bool _disposed; + + /// + /// Wraps an existing as an . + /// + /// The Microsoft.Extensions.AI chat client to wrap. + /// + /// Friendly provider name. When null the name is taken from the client metadata + /// (), falling back to "ChatClient". + /// + /// + /// When true (default) the wrapped client is disposed together with this provider. + /// Set to false when the client lifetime is owned elsewhere (e.g., a DI container). + /// + public ChatClientLLMProvider(IChatClient chatClient, string? providerName = null, bool ownsClient = true) + { + ChatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + _ownsClient = ownsClient; + + var metadata = chatClient.GetService(typeof(ChatClientMetadata)) as ChatClientMetadata; + ProviderName = providerName ?? + (string.IsNullOrWhiteSpace(metadata?.ProviderName) + ? "ChatClient" + : metadata!.ProviderName!); + DefaultModelId = metadata?.DefaultModelId; + } + + /// The default model id reported by the underlying client metadata (may be null). + public string? DefaultModelId { get; } + + /// Exposes the wrapped for advanced scenarios and middleware composition. + public IChatClient ChatClient { get; } + + /// + public string ProviderName { get; } + + /// + public Task IsAvailableAsync(CancellationToken ct = default) + { + // A generic IChatClient has no universal health-check. We treat a live client as available; + // concrete transports surface failures on the first ChatAsync call. + return Task.FromResult(!_disposed); + } + + /// + /// + /// The Microsoft.Extensions.AI abstraction does not define a model-enumeration contract, + /// so this returns the default model id when known, otherwise an empty list. + /// + public Task> ListModelsAsync(CancellationToken ct = default) + { + IReadOnlyList models = string.IsNullOrWhiteSpace(DefaultModelId) + ? [] + : [DefaultModelId!]; + return Task.FromResult(models); + } + + /// + public async Task ChatAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); + + var messages = BuildMessages(systemPrompt, userPrompt); + var options = BuildOptions(model, temperature); + + try + { + var response = await ChatClient.GetResponseAsync(messages, options, ct); + var text = response.Text?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(text)) + throw new InvalidOperationException($"Empty response from model '{model}' ({ProviderName})."); + return text; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + throw new InvalidOperationException( + $"Error talking to {ProviderName} (model: {model}): {ex.Message}", ex); + } + } + + /// + public async IAsyncEnumerable ChatStreamAsync( + string model, + string systemPrompt, + string userPrompt, + float temperature = 0.7f, + [EnumeratorCancellation] CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userPrompt); + + var messages = BuildMessages(systemPrompt, userPrompt); + var options = BuildOptions(model, temperature); + + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages, options, ct)) + { + var text = update.Text; + if (!string.IsNullOrEmpty(text)) + yield return text; + } + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsClient) ChatClient.Dispose(); + GC.SuppressFinalize(this); + } + + private static List BuildMessages(string systemPrompt, string userPrompt) + { + var messages = new List(2); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + messages.Add(new ChatMessage(ChatRole.User, userPrompt)); + return messages; + } + + private ChatOptions BuildOptions(string model, float temperature) + { + return new ChatOptions + { + // Prefer the explicitly requested model; fall back to the client's default. + ModelId = string.IsNullOrWhiteSpace(model) + ? DefaultModelId + : model, + Temperature = temperature + }; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs new file mode 100644 index 0000000..dba685f --- /dev/null +++ b/src/Delibera.Core/Providers/LLM/EmbeddingGeneratorProvider.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Providers.LLM; + +/// +/// A universal implemented on top of the Microsoft.Extensions.AI +/// abstraction. +/// +/// +/// Wraps any IEmbeddingGenerator<string, Embedding<float>> — Ollama (OllamaSharp), +/// OpenAI / Azure OpenAI, local OpenAI-compatible servers, etc. — so RAG indexing and querying +/// work with the standard .NET AI embedding contract instead of a provider-specific SDK call. +/// +public sealed class EmbeddingGeneratorProvider : IEmbeddingProvider, IDisposable +{ + private readonly bool _ownsGenerator; + private int? _cachedVectorSize; + private bool _disposed; + + /// + /// Wraps an existing . + /// + /// The Microsoft.Extensions.AI embedding generator to wrap. + /// + /// Friendly model name. When null it is read from the generator metadata + /// (), falling back to "embedding". + /// + /// + /// Known vector dimensionality. When null it is auto-detected from metadata or on the + /// first call. + /// + /// + /// When true (default) the wrapped generator is disposed together with this provider. + /// + public EmbeddingGeneratorProvider( + IEmbeddingGenerator> generator, + string? modelName = null, + int? vectorSize = null, + bool ownsGenerator = true) + { + Generator = generator ?? throw new ArgumentNullException(nameof(generator)); + _ownsGenerator = ownsGenerator; + + var metadata = generator.GetService(typeof(EmbeddingGeneratorMetadata)) as EmbeddingGeneratorMetadata; + EmbeddingModelName = modelName ?? + (string.IsNullOrWhiteSpace(metadata?.DefaultModelId) + ? "embedding" + : metadata!.DefaultModelId!); + _cachedVectorSize = vectorSize ?? metadata?.DefaultModelDimensions; + } + + /// Exposes the wrapped generator for advanced scenarios and middleware composition. + public IEmbeddingGenerator> Generator { get; } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsGenerator) Generator.Dispose(); + GC.SuppressFinalize(this); + } + + /// + public string EmbeddingModelName { get; } + + /// + public int VectorSize => _cachedVectorSize ?? + throw new InvalidOperationException( + "Vector size unknown — call EmbedAsync at least once so the provider can probe the model."); + + /// + public async Task EmbedAsync(string text, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + + var result = await Generator.GenerateAsync([text], cancellationToken: ct); + var embedding = result.FirstOrDefault() ?? throw new InvalidOperationException($"No embedding returned by model '{EmbeddingModelName}'."); + + var vector = embedding.Vector.ToArray(); + _cachedVectorSize ??= vector.Length; + return vector; + } + + /// + public async Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(texts); + if (texts.Count == 0) return []; + + var result = await Generator.GenerateAsync(texts, cancellationToken: ct); + + var vectors = new List(texts.Count); + foreach (var embedding in result) + { + var vector = embedding.Vector.ToArray(); + _cachedVectorSize ??= vector.Length; + vectors.Add(vector); + } + + return vectors.AsReadOnly(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs index 9f1c74e..d16acff 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaEmbeddingProvider.cs @@ -81,4 +81,4 @@ public async Task> EmbedBatchAsync(IReadOnlyList return result.AsReadOnly(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs index b9489cd..22a2d55 100644 --- a/src/Delibera.Core/Providers/LLM/OllamaProvider.cs +++ b/src/Delibera.Core/Providers/LLM/OllamaProvider.cs @@ -1,6 +1,8 @@ +using Microsoft.Extensions.AI; using OllamaSharp; using OllamaSharp.Models; using OllamaSharp.Models.Chat; +using ChatRole = OllamaSharp.Models.Chat.ChatRole; namespace Delibera.Core.Providers.LLM; @@ -126,4 +128,27 @@ public void Dispose() _disposed = true; GC.SuppressFinalize(this); } -} + + /// + /// 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; + } + + /// + /// Exposes the underlying OllamaSharp client as a Microsoft.Extensions.AI embedding generator. + /// + public IEmbeddingGenerator> AsEmbeddingGenerator() + { + return Client; + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs new file mode 100644 index 0000000..9190dce --- /dev/null +++ b/src/Delibera.Core/Providers/Mcp/McpClientAdapter.cs @@ -0,0 +1,164 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace Delibera.Core.Providers.Mcp; + +/// +/// Default implementation backed by the official +/// ModelContextProtocol C# SDK. Supports both stdio (local child process) and +/// HTTP/SSE (remote) MCP servers, selected via . +/// +public sealed class McpClientAdapter : IMcpClient +{ + private readonly McpServerConfig _config; + private McpClient? _client; + private bool _disposed; + + /// Creates an adapter for the given MCP server configuration. + /// Server connection configuration. + public McpClientAdapter(McpServerConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + } + + /// + public string ServerName => _config.Name; + + /// + public bool IsConnected => _client is not null; + + /// + public async Task ConnectAsync(CancellationToken ct = default) + { + if (_client is not null) return; + + var transport = CreateTransport(_config); + _client = await McpClient.CreateAsync(transport, cancellationToken: ct); + } + + /// + public async Task> ListToolsAsync(CancellationToken ct = default) + { + EnsureConnected(); + var tools = await _client!.ListToolsAsync(cancellationToken: ct); + + return tools + .Select(t => new OperatorTool( + ServerName, + t.Name, + t.Description ?? string.Empty, + SchemaToJson(t))) + .ToList() + .AsReadOnly(); + } + + /// + public async Task CallToolAsync( + string toolName, + IReadOnlyDictionary arguments, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + ArgumentNullException.ThrowIfNull(arguments); + EnsureConnected(); + + CallToolResult result; + try + { + result = await _client!.CallToolAsync(toolName, arguments, cancellationToken: ct); + } + catch (Exception ex) + { + return new McpToolResult($"[MCP tool '{toolName}' failed: {ex.Message}]", true); + } + + var text = string.Join( + "\n", + result.Content.OfType().Select(b => b.Text)); + + if (string.IsNullOrWhiteSpace(text)) + text = "[Tool returned no textual content]"; + + return new McpToolResult(text, result.IsError ?? false); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + if (_client is not null) + await _client.DisposeAsync(); + } + + private void EnsureConnected() + { + if (_client is null) + throw new InvalidOperationException( + $"MCP client for server '{ServerName}' is not connected. Call ConnectAsync() first."); + } + + private static string SchemaToJson(McpClientTool tool) + { + try + { + return tool.JsonSchema.GetRawText(); + } + catch + { + return "{}"; + } + } + + private static IClientTransport CreateTransport(McpServerConfig config) + { + return config.TransportType switch + { + McpTransportType.Stdio => CreateStdioTransport(config), + McpTransportType.Http => CreateHttpTransport(config), + _ => throw new NotSupportedException($"Unsupported MCP transport: {config.TransportType}") + }; + } + + private static StdioClientTransport CreateStdioTransport(McpServerConfig config) + { + if (string.IsNullOrWhiteSpace(config.Command)) + throw new InvalidOperationException( + $"MCP server '{config.Name}' uses stdio transport but no Command was provided."); + + var options = new StdioClientTransportOptions + { + Name = config.Name, + Command = config.Command, + Arguments = config.Arguments.ToList(), + WorkingDirectory = config.WorkingDirectory + }; + + if (config.EnvironmentVariables.Count > 0) + options.EnvironmentVariables = config.EnvironmentVariables + .ToDictionary(kv => kv.Key, kv => (string?)kv.Value); + + return new StdioClientTransport(options); + } + + private static HttpClientTransport CreateHttpTransport(McpServerConfig config) + { + if (config.Endpoint is null) + throw new InvalidOperationException( + $"MCP server '{config.Name}' uses HTTP transport but no Endpoint was provided."); + + var options = new HttpClientTransportOptions + { + Name = config.Name, + Endpoint = config.Endpoint + }; + + if (config.AdditionalHeaders.Count > 0) + options.AdditionalHeaders = config.AdditionalHeaders + .ToDictionary(kv => kv.Key, kv => kv.Value); + + return new HttpClientTransport(options); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/ProviderFactory.cs b/src/Delibera.Core/Providers/ProviderFactory.cs index e88bc5c..4a46425 100644 --- a/src/Delibera.Core/Providers/ProviderFactory.cs +++ b/src/Delibera.Core/Providers/ProviderFactory.cs @@ -1,4 +1,5 @@ using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; namespace Delibera.Core.Providers; @@ -19,11 +20,29 @@ public abstract class CachingFactory /// Registered provider type names (read-only view). public IReadOnlyCollection RegisteredTypes => _builders.Keys.ToList().AsReadOnly(); + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + DisposeInstances(); + ClearInstances(); + GC.SuppressFinalize(this); + } + /// All currently cached provider instances. - public IReadOnlyDictionary GetAllInstances() => _instances; + public IReadOnlyDictionary GetAllInstances() + { + return _instances; + } /// Returns a cached instance by name, or null. - public TInstance? GetInstance(string name) => _instances.TryGetValue(name, out var p) ? p : null; + public TInstance? GetInstance(string name) + { + return _instances.TryGetValue(name, out var p) + ? p + : null; + } /// Registers a builder for a new provider type (e.g., "OpenAI", "YandexGPT"). public CachingFactory RegisterBuilder(string providerType, TBuilder builder) @@ -60,19 +79,15 @@ protected TInstance CacheInstance(string name, TInstance instance) } /// Iterates every cached instance — used by the derived Dispose methods. - protected IEnumerable EnumerateInstances() => _instances.Values; + protected IEnumerable EnumerateInstances() + { + return _instances.Values; + } /// Clears the cache without disposing anything. - protected void ClearInstances() => _instances.Clear(); - - /// - public void Dispose() + protected void ClearInstances() { - if (_disposed) return; - _disposed = true; - DisposeInstances(); - ClearInstances(); - GC.SuppressFinalize(this); + _instances.Clear(); } /// Disposes every cached instance. Subclasses override to call the correct dispose method. @@ -106,14 +121,22 @@ ILLMProviderFactory ILLMProviderFactory.RegisterBuilder(string providerType, Fun /// /// Creates (or returns a cached) provider from configuration. /// - public ILLMProvider Create(string name, string providerType, IConfigurationSection config) => - GetOrCreate(name, providerType, b => b(config)); + public ILLMProvider Create(string name, string providerType, IConfigurationSection config) + { + return GetOrCreate(name, providerType, b => b(config)); + } /// Returns a cached provider by name, or null. - public ILLMProvider? GetProvider(string name) => GetInstance(name); + public ILLMProvider? GetProvider(string name) + { + return GetInstance(name); + } /// All created provider instances. - public IReadOnlyDictionary GetAllProviders() => GetAllInstances(); + public IReadOnlyDictionary GetAllProviders() + { + return GetAllInstances(); + } /// Creates an Ollama provider with direct parameters. public OllamaProvider CreateOllama(string endpoint, string apiKey = "") @@ -126,9 +149,38 @@ public OllamaProvider CreateOllama(string endpoint, string apiKey = "") return provider; } + /// + /// Creates (or returns a cached) from any Microsoft.Extensions.AI + /// . + /// + /// + /// This is the entry point for plugging arbitrary Microsoft.Extensions.AI backends — OpenAI, Azure + /// OpenAI, Anthropic, local OpenAI-compatible servers — into a Delibera council through the standard + /// .NET AI abstractions. + /// + /// Unique instance name used for caching. + /// The chat client to wrap. + /// Optional friendly provider name (defaults to client metadata). + /// Whether disposing the factory also disposes the client. + public ChatClientLLMProvider CreateFromChatClient( + string name, + IChatClient chatClient, + string? providerName = null, + bool ownsClient = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(chatClient); + + if (GetInstance(name) is ChatClientLLMProvider existing) return existing; + + var provider = new ChatClientLLMProvider(chatClient, providerName, ownsClient); + CacheInstance(name, provider); + return provider; + } + /// protected override void DisposeInstances() { foreach (var p in EnumerateInstances()) p.Dispose(); } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs b/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs index 9c613b2..f21ec07 100644 --- a/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/BaseRagProvider.cs @@ -8,15 +8,6 @@ namespace Delibera.Core.Providers.RAG; /// public abstract class BaseRagProvider : IRagProvider { - /// Abstract implementations describe themselves. - public abstract string ProviderName { get; } - - /// - public IVectorStore VectorStore { get; } - - /// - public IEmbeddingProvider EmbeddingProvider { get; } - /// /// Initialises a base RAG provider with the supplied vector store and embedding provider. /// @@ -26,6 +17,15 @@ protected BaseRagProvider(IVectorStore vectorStore, IEmbeddingProvider embedding EmbeddingProvider = embeddingProvider ?? throw new ArgumentNullException(nameof(embeddingProvider)); } + /// Abstract implementations describe themselves. + public abstract string ProviderName { get; } + + /// + public IVectorStore VectorStore { get; } + + /// + public IEmbeddingProvider EmbeddingProvider { get; } + /// public virtual async Task IndexDocumentAsync( string collectionName, @@ -124,5 +124,8 @@ public virtual async Task GetContextAsync( } /// - public virtual ValueTask DisposeAsync() => VectorStore.DisposeAsync(); -} + public virtual ValueTask DisposeAsync() + { + return VectorStore.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs b/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs index 903e8e3..ef10cfc 100644 --- a/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/PgVectorRagProvider.cs @@ -36,4 +36,4 @@ public PgVectorRagProvider(IEmbeddingProvider embeddingProvider, string connecti /// public override string ProviderName => "PgVectorRAG"; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/PgVectorStore.cs b/src/Delibera.Core/Providers/RAG/PgVectorStore.cs index b118fb5..40cba43 100644 --- a/src/Delibera.Core/Providers/RAG/PgVectorStore.cs +++ b/src/Delibera.Core/Providers/RAG/PgVectorStore.cs @@ -110,8 +110,12 @@ public async Task UpsertAsync(string collectionName, IReadOnlyList foreach (var p in points) { - var id = Guid.TryParse(p.Id, out var guid) ? guid : Guid.NewGuid(); - var metadataJson = p.Metadata is { Count: > 0 } ? JsonSerializer.Serialize(p.Metadata) : null; + var id = Guid.TryParse(p.Id, out var guid) + ? guid + : Guid.NewGuid(); + var metadataJson = p.Metadata is { Count: > 0 } + ? JsonSerializer.Serialize(p.Metadata) + : null; var cmd = new NpgsqlBatchCommand($""" INSERT INTO {tableName} (id, text, metadata, embedding) @@ -164,7 +168,9 @@ public async Task> SearchAsync( { var id = reader.GetGuid(0).ToString(); var text = reader.GetString(1); - var metaJson = reader.IsDBNull(2) ? null : reader.GetString(2); + var metaJson = reader.IsDBNull(2) + ? null + : reader.GetString(2); var score = (float)reader.GetDouble(3); Dictionary? metadata = null; @@ -229,4 +235,4 @@ private static string SanitizeTableName(string collectionName) collectionName.ToLowerInvariant(), @"[^a-z0-9_]", "_"); return $"vc_{safe}"; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs b/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs index 17c20b1..bb7e832 100644 --- a/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs +++ b/src/Delibera.Core/Providers/RAG/QdrantRagProvider.cs @@ -32,4 +32,4 @@ public QdrantRagProvider( /// public override string ProviderName => "QdrantRAG"; -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs b/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs index aee80ac..f6be109 100644 --- a/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs +++ b/src/Delibera.Core/Providers/RAG/QdrantVectorStore.cs @@ -83,14 +83,18 @@ public async Task> SearchAsync( collectionName, queryVector, limit: (ulong)limit, - scoreThreshold: scoreThreshold > 0 ? scoreThreshold : null, + scoreThreshold: scoreThreshold > 0 + ? scoreThreshold + : null, cancellationToken: ct); var results = new List(scored.Count); foreach (var s in scored) { - var text = s.Payload.TryGetValue("text", out var tv) ? tv.StringValue : string.Empty; + var text = s.Payload.TryGetValue("text", out var tv) + ? tv.StringValue + : string.Empty; var meta = new Dictionary(); foreach (var (k, v) in s.Payload) @@ -101,15 +105,19 @@ public async Task> SearchAsync( s.Id.Uuid, text, s.Score, - meta.Count > 0 ? meta : null)); + meta.Count > 0 + ? meta + : null)); } return results.AsReadOnly(); } /// - public Task DeleteCollectionAsync(string collectionName, CancellationToken ct = default) => - _client.DeleteCollectionAsync(collectionName, cancellationToken: ct); + public Task DeleteCollectionAsync(string collectionName, CancellationToken ct = default) + { + return _client.DeleteCollectionAsync(collectionName, cancellationToken: ct); + } /// public async Task CountAsync(string collectionName, CancellationToken ct = default) @@ -124,4 +132,4 @@ public ValueTask DisposeAsync() _client.Dispose(); return ValueTask.CompletedTask; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs index 90df63b..cb5e060 100644 --- a/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs +++ b/src/Delibera.Core/Providers/RAG/RagProviderFactory.cs @@ -16,7 +16,9 @@ public RagProviderFactory() RegisterBuilder("Qdrant", (config, embeddings) => { var host = config["Host"] ?? "localhost"; - var port = int.TryParse(config["Port"], out var p) ? p : 6334; + var port = int.TryParse(config["Port"], out var p) + ? p + : 6334; var https = bool.TryParse(config["Https"], out var h) && h; var apiKey = config["ApiKey"]; @@ -42,11 +44,24 @@ IRagProviderFactory IRagProviderFactory.RegisterBuilder( /// /// Creates (or returns cached) a RAG provider instance. /// - public IRagProvider Create(string name, string providerType, IConfigurationSection config, IEmbeddingProvider embeddingProvider) => - GetOrCreate(name, providerType, b => b(config, embeddingProvider)); + public IRagProvider Create(string name, string providerType, IConfigurationSection config, IEmbeddingProvider embeddingProvider) + { + return GetOrCreate(name, providerType, b => b(config, embeddingProvider)); + } /// Returns a cached provider by name. - public IRagProvider? GetProvider(string name) => GetInstance(name); + public IRagProvider? GetProvider(string name) + { + return GetInstance(name); + } + + /// + public async ValueTask DisposeAsync() + { + foreach (var p in EnumerateInstances()) + await p.DisposeAsync(); + ClearInstances(); + } /// /// Creates a Qdrant RAG provider with direct parameters. @@ -85,12 +100,4 @@ protected override void DisposeInstances() foreach (var p in EnumerateInstances()) p.DisposeAsync().AsTask().GetAwaiter().GetResult(); } - - /// - public async ValueTask DisposeAsync() - { - foreach (var p in EnumerateInstances()) - await p.DisposeAsync(); - ClearInstances(); - } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/Providers/RAG/TextChunker.cs b/src/Delibera.Core/Providers/RAG/TextChunker.cs index 0b6cda5..761b165 100644 --- a/src/Delibera.Core/Providers/RAG/TextChunker.cs +++ b/src/Delibera.Core/Providers/RAG/TextChunker.cs @@ -59,4 +59,4 @@ public static List SplitIntoChunks(string text, int chunkSize, int chunk return chunks; } -} +} \ No newline at end of file diff --git a/src/Delibera.Core/README.md b/src/Delibera.Core/README.md index b9b9d6b..5c2d0fd 100644 --- a/src/Delibera.Core/README.md +++ b/src/Delibera.Core/README.md @@ -1,25 +1,27 @@ -
- -# Delibera +# [Delibera](https://www.nuget.org/packages/Delibera.Core) ### ⚖️ Thoughtful AI Decisions -**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Chairman, Context Compression, Dependency Injection & Execution Logging.** +**Collective decision making through structured AI deliberation — with RAG, pgvector, Knowledge Keeper, Operator (MCP tools), Chairman, Context Compression, Dependency Injection & Execution Logging.** [![NuGet](https://img.shields.io/nuget/v/Delibera.Core.svg)](https://www.nuget.org/packages/Delibera.Core) -[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](../LICENSE) +[![License: MIT](https://img.shields.io/badge/License-MIT-10B981.svg)](https://github.com/techbuzzz/Delibera/blob/develop/LICENSE) [![.NET 10](https://img.shields.io/badge/.NET-10.0-1F2937.svg)](https://dotnet.microsoft.com) - -
+[![C# 15](https://img.shields.io/badge/C%23-15.0--preview-239120.svg)](https://learn.microsoft.com/dotnet/csharp/) --- ## What is Delibera? **Delibera** is a C# / .NET 10 framework that orchestrates **multi-model deliberations** between LLMs. -Multiple models reason through a question across structured rounds, critique each other, and a -**Chairman** synthesises a balanced final verdict — optionally enriched by a **Knowledge Keeper** -backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compression** to minimise tokens. +Multiple AI models reason through a question across structured rounds, critique each other's answers, +and a **Chairman** weighs the arguments to synthesise a balanced final verdict — enriched by a +**Knowledge Keeper** backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **intelligent context +compression** to minimise token usage. + +The name comes from *deliberation* — the careful weighing of evidence and viewpoints before reaching +a decision. Delibera brings that discipline to AI, helping teams reach **thoughtful, well-reasoned +outcomes** rather than single-model guesses. --- @@ -28,12 +30,15 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio - 🏛️ **Multi-Model Councils** — orchestrate any number of LLM participants across structured debate rounds - ⚖️ **Chairman Synthesis** — a dedicated moderator opens, regulates and synthesises the final verdict - 📚 **Knowledge Keeper (RAG)** — per-round semantic retrieval with Qdrant or pgvector +- 🐘 **Qdrant + pgvector** — pluggable vector stores (dedicated DB or your existing PostgreSQL) +- 🛠️ **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` -- 📋 **Execution Logging** — `ExecutionLog` with `LogLevel` for Chairman, KK, Compression & participants +- 💉 **Dependency Injection** — `AddDelibera()` extension for `IServiceCollection` with full options binding +- 📋 **Execution Logging** — `ExecutionLog` model with `LogLevel` for Chairman, KK, Compression & participants - 📁 **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 -- 🧱 **Modern C# 12** — file-scoped namespaces, records, init-only properties, global usings +- 🧱 **Modern C# 15** — file-scoped namespaces, records, init-only properties, global usings --- @@ -45,7 +50,12 @@ backed by **Qdrant** or **PostgreSQL/pgvector** (RAG), with **context compressio dotnet add package Delibera.Core ``` -### 2. Pull the recommended models +### 2. Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- A running [Ollama](https://ollama.com) instance — local (`ollama serve`) or [Ollama Cloud](https://ollama.com/cloud) (just an API key, no install). + +### 3. Pull the recommended models ```bash ollama pull llama3.2:3b # council member @@ -54,9 +64,9 @@ ollama pull nomic-embed-text # embeddings (RAG) ``` > ☁️ To use **Ollama Cloud** instead, just pass an API key — no local models required. -> See the [Configuration section](#-dependency-injection) below. +> See the [Configuration section](#-configuration-appsettingsjson) below. -### 3. Run your first deliberation +### 4. Run your first deliberation ```csharp using Delibera.Core.Council; @@ -66,16 +76,16 @@ using var factory = new ProviderFactory(); var ollama = factory.CreateOllama("http://localhost:11434"); 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() - .WithSystemPrompt("You are a software architecture expert.") - .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") - .WithMaxRounds(4) - .SaveResultTo("./deliberation.md") - .Build() - .ExecuteAsync(); + .AddMember("llama3.2:3b", ollama, "Analyst") + .AddMember("qwen2.5:7b", ollama, "Strategist") + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithStandardDebate() + .WithSystemPrompt("You are a software architecture expert.") + .WithUserPrompt("Microservices vs Monolith for a 5-person startup?") + .WithMaxRounds(4) + .SaveResultTo("./deliberation.md") + .Build() + .ExecuteAsync(); Console.WriteLine(result.FinalVerdict); ``` @@ -84,19 +94,23 @@ Console.WriteLine(result.FinalVerdict); ## 💉 Dependency Injection +Register all Delibera services in one line: + ```csharp using Delibera.Core.DependencyInjection; -// A: with configuration binding +// A: with configuration binding (binds the "Delibera" section) services.AddDelibera(configuration, "Delibera"); // B: with options delegate services.AddDelibera(options => { - options.Strategy = "Standard"; - options.MaxRounds = 4; - options.Compression.Enabled = true; - options.Compression.Strategy = "Hybrid"; + options.Strategy = "Standard"; + options.MaxRounds = 4; + options.Temperature = 0.7f; + options.Compression.Enabled = true; + options.Compression.Strategy = "Hybrid"; + options.Compression.TargetRatio = 0.5; }); // C: defaults only @@ -114,8 +128,54 @@ Resolved services: --- +## 🤝 Microsoft.Extensions.AI Interop + +Delibera integrates with [`Microsoft.Extensions.AI`](https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai) +through `ChatClientLLMProvider` and `EmbeddingGeneratorProvider`. Adapt **any** `IChatClient` / +`IEmbeddingGenerator` (OpenAI, Azure OpenAI, Ollama, custom…) and use the full middleware pipeline +(logging, function invocation, telemetry). + +```csharp +using Delibera.Core.Providers.LLM; +using Microsoft.Extensions.AI; +using OpenAI; + +var openAi = new OpenAIClient(apiKey); + +// IChatClient — works with any MEAI-compatible client +var chatClient = openAi.GetChatClient("gpt-4o").AsIChatClient(); +var llm = new ChatClientLLMProvider(chatClient); + +// IEmbeddingGenerator — for Knowledge Keeper / compression +var embeddingGen = openAi.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); +var embeddings = new EmbeddingGeneratorProvider(embeddingGen); + +var result = await new CouncilBuilder() + .AddMember("gpt-4o", llm, "Analyst") + .SetChairman(Chairman.CreateStandard("gpt-4o", llm)) + .WithStandardDebate() + .WithUserPrompt("Should we adopt event sourcing?") + .Build() + .ExecuteAsync(); +``` + +Streaming via `ChatStreamAsync` and DI helpers (`AddDeliberaChatClient`) are also included. Fully +backward compatible with the existing `OllamaProvider` / `OllamaEmbeddingProvider` APIs. + +--- + ## 🗜️ Context Compression +Automatically compress context between deliberation rounds — save **30–70% of tokens** without losing meaning. + +| Strategy | How It Works | Best For | +| ----------------- | ---------------------------------------------------------- | -------------------------------- | +| **Semantic** | Embeds sentences, ranks by relevance to topic, keeps top-N | Large knowledge contexts | +| **Deduplication** | Removes semantically similar sentences across participants | Multi-model debates with overlap | +| **Summarization** | LLM produces a concise summary preserving key facts | Maximum compression ratio | +| **Hybrid** | Dedup → Semantic → Summarize pipeline | Best overall quality | +| **None** | Pass-through (when disabled) | Debugging | + ```csharp using Delibera.Core.Compression; using Delibera.Core.Providers.LLM; @@ -124,26 +184,27 @@ var ollama = new OllamaProvider("http://localhost:11434"); var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); var result = await new CouncilBuilder() - .AddMember("llama3.2:3b", ollama) - .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) - .WithCompression(CompressionStrategy.Hybrid, - llmProvider: ollama, - modelName: "llama3.2:3b", - embeddingProvider: embeddings) - .WithCompressionCache() - .WithUserPrompt("Analyze our architecture options...") - .Build() - .ExecuteAsync(); + .AddMember("llama3.2:3b", ollama) + .SetChairman(Chairman.CreateStandard("qwen2.5:7b", ollama)) + .WithCompression(CompressionStrategy.Hybrid, + llmProvider: ollama, + modelName: "llama3.2:3b", + embeddingProvider: embeddings) + .WithCompressionOptions(new CompressionOptions { TargetRatio = 0.5 }) + .WithCompressionCache() + .WithUserPrompt("Analyze our architecture options...") + .Build() + .ExecuteAsync(); Console.WriteLine(result.TokenStats?.ToSummary()); ``` -Strategies: **Semantic**, **Deduplication**, **Summarization**, **Hybrid** (Dedup → Semantic → Summarize). - --- ## 📚 RAG Integration +Use a dedicated **Qdrant** instance or your existing **PostgreSQL/pgvector** database as a vector store. + ```csharp using Delibera.Core.Council; using Delibera.Core.Models; @@ -153,22 +214,109 @@ using Delibera.Core.Providers.RAG; var ollama = new OllamaProvider("http://localhost:11434"); var embeddings = new OllamaEmbeddingProvider(ollama, "nomic-embed-text"); -// pgvector — point at your existing PostgreSQL -var rag = new RagProviderFactory().CreatePgVector( - embeddings, - "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); +// pgvector — just add a connection string +var ragFactory = new RagProviderFactory(); +var rag = ragFactory.CreatePgVector( + embeddings, + "Host=localhost;Database=council_vectors;Username=postgres;Password=postgres"); -// …or Qdrant -// var rag = new RagProviderFactory().CreateQdrant(embeddings, "localhost", 6334); - -var keeper = new KnowledgeKeeper( - rag, - new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"), - "my_knowledge"); +await rag.IndexDocumentAsync("my_collection", documentText); +var results = await rag.SearchAsync("my_collection", "query", limit: 5); +// Wire into a Knowledge Keeper +var kkMember = new CouncilMember("llama3.2:3b", ollama, "Knowledge Keeper"); +var keeper = new KnowledgeKeeper(rag, kkMember, "my_knowledge"); await keeper.IndexFileAsync("./docs/architecture.md"); ``` +…or use **Qdrant**: + +```csharp +var rag = ragFactory.CreateQdrant(embeddings, "localhost", 6334); +``` + +--- + +## 🛠️ Operator (MCP Tools) + +The **Operator** is a lightweight micro-agent that connects the council to the outside world through +[**MCP (Model Context Protocol)**](https://modelcontextprotocol.io) servers. It exposes whatever tools +those servers provide — web search, file system access, Notion, PostgreSQL, a debate-history store, etc. — +to the debate participants. + +**How it works** + +1. The Operator connects to one or more MCP servers and discovers their tools on `InitializeAsync`. +2. Participants are told (in their system prompt) what the Operator can do, and can delegate a task at + **any moment** during the debate by writing a marker in their message: + + ``` + [[OPERATOR: search the web for the latest EU AI Act timeline and save it to Notion]] + ``` + +3. The Operator interprets the request with its **own (cheaper) LLM model**, picks and calls the right + MCP tools, interprets the results, and returns a concise answer that is injected into the next round. +4. If the council uses **context compression**, the Operator can reuse the same strategy to compress + large tool outputs before they re-enter the debate. + +All Operator interactions are recorded per round and rendered in the final Markdown report under a +**🛠️ Operator Interactions** block. + +### Quick usage + +```csharp +using Delibera.Core.Council; +using Delibera.Core.Models; +using Delibera.Core.Providers.LLM; + +var ollama = new OllamaProvider("http://localhost:11434"); + +// Define the MCP servers the Operator may use +var servers = new[] +{ + McpServerConfig.Stdio( + name: "everything", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" }), + + McpServerConfig.Stdio( + name: "filesystem", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-filesystem", "/data" }), + + // McpServerConfig.Http( + // name: "remote", + // endpoint: "https://my-mcp-host.example.com/mcp", + // additionalHeaders: new Dictionary { ["Authorization"] = "Bearer " }), +}; + +var council = new CouncilBuilder() + .AddMember("gpt-oss:20b", ollama, "Optimist") + .AddMember("llama3.1:8b", ollama, "Skeptic") + .WithChairman("gpt-oss:20b", ollama) + // Operator uses its own cheaper model; reuseCompression shares the council's compressor + .WithOperator("llama3.2:3b", ollama, servers, reuseCompression: true) + .WithTopic("Should we migrate the data pipeline to Kafka?") + .Build(); + +var result = await council.ExecuteAsync(); +``` + +Prefer to build the `Operator` yourself? Pass a pre-built instance: + +```csharp +var @operator = new Operator( + new CouncilMember("llama3.2:3b", ollama, "Operator"), + new IMcpClient[] { new McpClientAdapter(servers[0]), new McpClientAdapter(servers[1]) }, + compressor: null, // optional IContextCompressor + compressionOptions: null); // optional CompressionOptions + +var council = new CouncilBuilder() + /* …members… */ + .WithOperator(@operator) + .Build(); +``` + --- ## 🗣️ Debate Strategies @@ -179,10 +327,16 @@ await keeper.IndexFileAsync("./docs/architecture.md"); | **CritiqueDebate** | Position → Attack → Defence → Judge | Hypothesis testing | | **ConsensusDebate** | Perspectives → Common Ground → Consensus → Facilitator | Optimal solution search | +Each strategy is implemented as an `IDebateStrategy` — combine with `Builder`, `Template Method` +(`DebateScenario`) and `Factory` patterns (`ProviderFactory`, `RagProviderFactory`, `CompressionFactory`, +`Chairman`) to compose custom flows. + --- ## 📁 Output Files +Each deliberation can be exported as a single file or as three separate Markdown documents: + ```csharp var result = await executor.ExecuteAsync(); @@ -196,6 +350,12 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); // → debate__logs.md (ExecutionLog) ``` +| File | Contents | +| ----------------- | ---------------------------------------------------------------------------- | +| `*_result.md` | Full deliberation transcript, rounds, and the Chairman's final verdict | +| `*_statistics.md` | Token usage statistics with per-round breakdown | +| `*_logs.md` | Execution logs (`ExecutionLog`) for Chairman, KK, compression & participants | + --- ## 📦 Configuration (`appsettings.json`) @@ -203,18 +363,53 @@ var (resultPath, statsPath, logsPath) = await result.SaveAllAsync("./output"); ```json { "Delibera": { - "Strategy": "Standard", - "MaxRounds": 4, - "Temperature": 0.7, - "Providers": { - "DefaultType": "Ollama", - "DefaultEndpoint": "http://localhost:11434", - "ApiKey": "", - "EmbeddingModel": "nomic-embed-text" - }, - "Compression": { "Enabled": true, "Strategy": "Hybrid", "TargetRatio": 0.5 }, - "Rag": { "Enabled": false, "ProviderType": "Qdrant", "Host": "localhost", "Port": 6334 }, - "Output": { "Directory": "./debate_results", "SeparateFiles": true } + "Strategy": "Standard", + "MaxRounds": 4, + "Temperature": 0.7, + "SystemPrompt": "You are a knowledgeable AI expert participating in a council debate.", + "Providers": { + "DefaultType": "Ollama", + "DefaultEndpoint": "http://localhost:11434", + "ApiKey": "", + "EmbeddingModel": "nomic-embed-text" + }, + "Compression": { + "Enabled": true, + "Strategy": "Hybrid", + "TargetRatio": 0.5, + "EnableCache": true, + "MaxCacheEntries": 256 + }, + "Rag": { + "Enabled": false, + "ProviderType": "Qdrant", + "Host": "localhost", + "Port": 6334, + "CollectionName": "council_knowledge" + }, + "Operator": { + "Enabled": true, + "ModelName": "llama3.2:3b", + "ReuseCompression": true, + "McpServers": [ + { + "Name": "filesystem", + "Transport": "Stdio", + "Command": "npx", + "Arguments": [ "-y", "@modelcontextprotocol/server-filesystem", "/data" ] + }, + { + "Name": "remote", + "Transport": "Http", + "Endpoint": "https://my-mcp-host.example.com/mcp", + "AdditionalHeaders": { "Authorization": "Bearer " } + } + ] + }, + "Output": { + "Directory": "./debate_results", + "SeparateFiles": true + } } } ``` @@ -226,29 +421,30 @@ in `Providers:ApiKey`. ## 📦 NuGet Dependencies -| Package | Purpose | -| ------------------------ | -------------------------------- | -| `OllamaSharp` | Ollama API client | -| `Qdrant.Client` | Qdrant vector DB gRPC client | -| `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | -| `Microsoft.Extensions.*` | Configuration, DI and Options | +| Package | Purpose | +| ------------------------- | ----------------------------------------------------------- | +| `Microsoft.Extensions.AI` | `IChatClient` / `IEmbeddingGenerator` abstractions & middleware | +| `OllamaSharp` | Ollama API client | +| `Qdrant.Client` | Qdrant vector DB gRPC client | +| `Npgsql` / `Pgvector` | PostgreSQL/pgvector support | +| `ModelContextProtocol` | MCP client for the Operator role | +| `Microsoft.Extensions.*` | Configuration, DI and Options | --- ## 📚 Learn More -- 📖 Full README (architecture, design patterns, console app examples) → [github.com/delibera/Delibera](https://github.com/delibera/Delibera) -- 📄 Step-by-step walkthrough → [docs/QuickStart.md](https://github.com/delibera/Delibera/blob/main/docs/QuickStart.md) -- 🤝 [CONTRIBUTING.md](https://github.com/delibera/Delibera/blob/main/CONTRIBUTING.md) +- 📖 Full README (architecture, design patterns, console app examples) → [github.com/techbuzzz/Delibera](https://github.com/techbuzzz/Delibera) +- 📄 Step-by-step walkthrough → [docs/QuickStart.md](https://github.com/techbuzzz/Delibera/blob/develop/docs/QuickStart.md) +- 💻 Console app examples → [src/Delibera.ConsoleApp](https://github.com/techbuzzz/Delibera/tree/develop/src/Delibera.ConsoleApp) +- 🤝 [CONTRIBUTING.md](https://github.com/techbuzzz/Delibera/blob/develop/CONTRIBUTING.md) --- ## 📄 License -[MIT](../LICENSE) — Copyright © 2026 Delibera Project. - -
+[MIT](https://github.com/techbuzzz/Delibera/blob/develop/LICENSE) — Copyright © 2026 Delibera Project. **⚖️ Delibera — Thoughtful AI Decisions** -
+*Built with care for AI-powered collective intelligence* \ No newline at end of file diff --git a/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs b/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs new file mode 100644 index 0000000..98a34c3 --- /dev/null +++ b/tests/Delibera.Core.Tests/ChatClientLLMProviderTests.cs @@ -0,0 +1,119 @@ +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class ChatClientLLMProviderTests +{ + [Fact] + public void ProviderName_TakenFromClientMetadata_WhenNotSpecified() + { + using var client = new FakeChatClient(providerName: "Contoso"); + using var provider = new ChatClientLLMProvider(client); + + Assert.Equal("Contoso", provider.ProviderName); + } + + [Fact] + public void ProviderName_ExplicitOverridesMetadata() + { + using var client = new FakeChatClient(providerName: "Contoso"); + using var provider = new ChatClientLLMProvider(client, "MyName"); + + Assert.Equal("MyName", provider.ProviderName); + } + + [Fact] + public async Task ChatAsync_ReturnsClientText_AndForwardsModelAndTemperature() + { + var client = new FakeChatClient(reply: "hello world"); + using var provider = new ChatClientLLMProvider(client); + + var answer = await provider.ChatAsync("gpt-test", "system", "user", temperature: 0.42f); + + Assert.Equal("hello world", answer); + Assert.Equal("gpt-test", client.LastOptions?.ModelId); + Assert.Equal(0.42f, client.LastOptions?.Temperature); + } + + [Fact] + public async Task ChatAsync_PutsSystemAndUserMessagesInOrder() + { + var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + await provider.ChatAsync("m", "the-system", "the-user"); + + Assert.NotNull(client.LastMessages); + Assert.Equal(2, client.LastMessages!.Count); + Assert.Equal(ChatRole.System, client.LastMessages[0].Role); + Assert.Equal("the-system", client.LastMessages[0].Text); + Assert.Equal(ChatRole.User, client.LastMessages[1].Role); + Assert.Equal("the-user", client.LastMessages[1].Text); + } + + [Fact] + public async Task ChatAsync_OmitsSystemMessage_WhenSystemPromptEmpty() + { + var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + await provider.ChatAsync("m", "", "only-user"); + + Assert.Single(client.LastMessages!); + Assert.Equal(ChatRole.User, client.LastMessages![0].Role); + } + + [Fact] + public async Task ChatStreamAsync_YieldsChunks_ThatConcatenateToFullReply() + { + var client = new FakeChatClient(reply: "alpha beta gamma"); + using var provider = new ChatClientLLMProvider(client); + + var chunks = new List(); + await foreach (var chunk in provider.ChatStreamAsync("m", "s", "u")) + chunks.Add(chunk); + + Assert.True(chunks.Count > 1, "Streaming should produce multiple chunks."); + Assert.Equal("alpha beta gamma", string.Concat(chunks).Trim()); + } + + [Fact] + public async Task ChatAsync_Throws_OnEmptyReply() + { + var client = new FakeChatClient(reply: " "); + using var provider = new ChatClientLLMProvider(client); + + await Assert.ThrowsAsync(() => provider.ChatAsync("m", "s", "u")); + } + + [Fact] + public void Dispose_DisposesClient_WhenOwned() + { + var client = new FakeChatClient(); + var provider = new ChatClientLLMProvider(client, ownsClient: true); + provider.Dispose(); + Assert.True(client.Disposed); + } + + [Fact] + public void Dispose_LeavesClient_WhenNotOwned() + { + var client = new FakeChatClient(); + var provider = new ChatClientLLMProvider(client, ownsClient: false); + provider.Dispose(); + Assert.False(client.Disposed); + } + + [Fact] + public async Task ListModelsAsync_ReturnsDefaultModel() + { + using var client = new FakeChatClient(defaultModel: "default-x"); + using var provider = new ChatClientLLMProvider(client); + + var models = await provider.ListModelsAsync(); + + Assert.Contains("default-x", models); + } +} diff --git a/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj new file mode 100644 index 0000000..edddd78 --- /dev/null +++ b/tests/Delibera.Core.Tests/Delibera.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs b/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs new file mode 100644 index 0000000..8f5305d --- /dev/null +++ b/tests/Delibera.Core.Tests/EmbeddingAndBridgeTests.cs @@ -0,0 +1,142 @@ +using Delibera.Core.Extensions; +using Delibera.Core.Interfaces; +using Delibera.Core.Providers; +using Delibera.Core.Providers.LLM; +using Delibera.Core.Tests.Fakes; +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests; + +public class EmbeddingGeneratorProviderTests +{ + [Fact] + public void VectorSize_ReadFromMetadata() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 8); + using var provider = new EmbeddingGeneratorProvider(gen); + + Assert.Equal(8, provider.VectorSize); + Assert.Equal("fake-embed", provider.EmbeddingModelName); + } + + [Fact] + public async Task EmbedAsync_ReturnsVectorOfCorrectSize() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 5); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vector = await provider.EmbedAsync("hello"); + + Assert.Equal(5, vector.Length); + } + + [Fact] + public async Task EmbedBatchAsync_ReturnsOneVectorPerInput() + { + using var gen = new FakeEmbeddingGenerator(dimensions: 3); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vectors = await provider.EmbedBatchAsync(["a", "bb", "ccc"]); + + Assert.Equal(3, vectors.Count); + Assert.All(vectors, v => Assert.Equal(3, v.Length)); + } + + [Fact] + public async Task EmbedBatchAsync_EmptyInput_ReturnsEmpty() + { + using var gen = new FakeEmbeddingGenerator(); + using var provider = new EmbeddingGeneratorProvider(gen); + + var vectors = await provider.EmbedBatchAsync([]); + + Assert.Empty(vectors); + } +} + +public class MicrosoftAIExtensionsTests +{ + [Fact] + public void AsLLMProvider_WrapsChatClient() + { + using var client = new FakeChatClient(providerName: "Z"); + using var provider = client.AsLLMProvider(); + + Assert.IsType(provider); + Assert.Equal("Z", provider.ProviderName); + } + + [Fact] + public void AsChatClient_OnChatClientProvider_ReturnsUnderlyingClient() + { + using var client = new FakeChatClient(); + using var provider = new ChatClientLLMProvider(client); + + var roundTrip = provider.AsChatClient(); + + Assert.Same(client, roundTrip); + } + + [Fact] + public async Task AsChatClient_OnArbitraryProvider_BridgesCalls() + { + // Use a custom provider that is NOT a ChatClientLLMProvider to force the adapter path. + ILLMProvider custom = new EchoProvider(); + var chatClient = custom.AsChatClient("echo-model"); + + var response = await chatClient.GetResponseAsync([new ChatMessage(ChatRole.User, "ping")]); + + Assert.Equal("echo:ping", response.Text); + Assert.Equal("echo-model", response.ModelId); + } + + [Fact] + public async Task AsChatClient_Streaming_BridgesChunks() + { + ILLMProvider custom = new EchoProvider(); + var chatClient = custom.AsChatClient("echo-model"); + + var text = ""; + await foreach (var update in chatClient.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "ping")])) + text += update.Text; + + Assert.Equal("echo:ping", text); + } + + /// Minimal provider that echoes the user prompt, to exercise the bridge adapter. + private sealed class EchoProvider : ILLMProvider + { + public string ProviderName => "Echo"; + public Task IsAvailableAsync(CancellationToken ct = default) => Task.FromResult(true); + public Task> ListModelsAsync(CancellationToken ct = default) => Task.FromResult>([]); + public Task ChatAsync(string model, string systemPrompt, string userPrompt, float temperature = 0.7f, CancellationToken ct = default) + => Task.FromResult($"echo:{userPrompt}"); + public void Dispose() { } + } +} + +public class ProviderFactoryChatClientTests +{ + [Fact] + public void CreateFromChatClient_CachesByName() + { + using var factory = new ProviderFactory(); + using var client = new FakeChatClient(); + + var a = factory.CreateFromChatClient("x", client); + var b = factory.CreateFromChatClient("x", client); + + Assert.Same(a, b); + } + + [Fact] + public void CreateFromChatClient_ReturnsChatClientProvider() + { + using var factory = new ProviderFactory(); + using var client = new FakeChatClient(providerName: "P"); + + var provider = factory.CreateFromChatClient("y", client, "Custom"); + + Assert.Equal("Custom", provider.ProviderName); + } +} diff --git a/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs new file mode 100644 index 0000000..7aaddb6 --- /dev/null +++ b/tests/Delibera.Core.Tests/Fakes/FakeChatClient.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +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") + : IChatClient +{ + private readonly ChatClientMetadata _metadata = new(providerName, defaultModelId: defaultModel); + + public bool Disposed { get; private set; } + + /// Records the options of the most recent request for assertions. + public ChatOptions? LastOptions { get; private set; } + + /// Records the messages of the most recent request for assertions. + public IList? LastMessages { get; private set; } + + public 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))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + LastMessages = messages.ToList(); + LastOptions = options; + // Emit one update per word to simulate token streaming. + foreach (var word in reply.Split(' ')) + { + await Task.Yield(); + yield return new ChatResponseUpdate(ChatRole.Assistant, word + " "); + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(_metadata) ? _metadata : null; + } + + public void Dispose() => Disposed = true; +} diff --git a/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs b/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs new file mode 100644 index 0000000..7fe6b70 --- /dev/null +++ b/tests/Delibera.Core.Tests/Fakes/FakeEmbeddingGenerator.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.AI; + +namespace Delibera.Core.Tests.Fakes; + +/// +/// In-memory producing deterministic vectors. +/// +public sealed class FakeEmbeddingGenerator(int dimensions = 4, string modelName = "fake-embed") : IEmbeddingGenerator> +{ + private readonly EmbeddingGeneratorMetadata _metadata = new("Fake", defaultModelId: modelName, defaultModelDimensions: dimensions); + + public bool Disposed { get; private set; } + + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + var embeddings = new GeneratedEmbeddings>(); + foreach (var value in values) + { + var vector = new float[dimensions]; + for (var i = 0; i < dimensions; i++) + vector[i] = (value.Length + i) % 7 / 7f; + embeddings.Add(new Embedding(vector)); + } + + return Task.FromResult(embeddings); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(_metadata) ? _metadata : null; + } + + public void Dispose() => Disposed = true; +}