diff --git a/README.md b/README.md index d7f55e5..27287dc 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,264 @@ -# Coven - -A tiny, composable **.NET 10** engine for orchestrating multiple agents to achieve big things. - -> ___"With great power comes great responsibility"___ - _Uncle Ben_ ->
If you use this library, don't be evil. - -## Highlights - -* **Typed MagikBlocks**: implement `IMagikBlock.DoMagik(...)` and compose work as pure(ish) functions. -* **Tag‑based routing**: a per‑ritual tag scope steers selection; blocks may also advertise **capabilities**. -* **DI‑first**: one builder on `IServiceCollection` (`BuildCoven`) with `MagikBlock<…>` and `LambdaBlock<…>` helpers; finish with `.Done(pull?: bool)`. -* **Journal Primitives**: reliable, distributable, and seamless to developers. Scriveners MUST support a long position. -* **Spellcasting (optional)**: minimal `ISpell<…>` interfaces + JSON‑schema generation for tool contracts. - -## Quick Start - -### 1) Hello, MagikBlocks (DI) - -```csharp -using Microsoft.Extensions.DependencyInjection; -using Coven.Core; -using Coven.Core.Builder; - -sealed class BuildCodes : IMagikBlock -{ - public Task DoMagik(Empty _, CancellationToken ct = default) - { - int[] codes = new int[] { - 73, 102, 32, 111, 110, 108, 121, 32, 73, 32, 99, 111, 117, 108, 100, 32, 98, 101, 32, 115, 111, 32, 103, 114, 111, 115, 115, 108, 121, 32, 105, 110, 99, 97, 110, 100, 101, 115, 99, 101, 110, 116, 46 - }; - return Task.FromResult(codes); - } -} - -sealed class CodesToChars : IMagikBlock -{ - public Task DoMagik(int[] codes, CancellationToken ct = default) - => Task.FromResult(codes.Select(c => (char)c).ToArray()); -} - -sealed class JoinChars : IMagikBlock -{ - public Task DoMagik(char[] chars, CancellationToken ct = default) => Task.FromResult(new string(chars)); -} - -var services = new ServiceCollection(); -services.BuildCoven(b => -{ - b.MagikBlock(); - b.MagikBlock(); - b.MagikBlock(); - b.Done(); -}); - -using var sp = services.BuildServiceProvider(); - -// Avoid GetRequiredService in production code (unless you know exactly what you are doing). -// Here we use it simply to keep the sample small and clear, but in production you should use a hosted service to run rituals. -var coven = sp.GetRequiredService(); -var result = await coven.Ritual(); - -Console.WriteLine(result); //If only I could be so grossly incandescent. -``` - ---- - -## Repository Layout - -* **src/Coven.Core/** — runtime -* **src/Coven.Core.Tests/** — tests for core -* **src/Coven.Spellcasting/** — minimal spellcasting layer -* **src/Coven.Chat/** — chat primitives -* **architecture/** — flat architecture docs (see below) -* **build/** — CI/release scripts -* **INDEX.md**, **README.md**, **CONTRIBUTING.md**, **AGENTS.md**, license files in repo root - -## Documentation - -Start here: - -* **Architecture Guide** → [`/architecture/README.md`](/architecture/README.md) -* **Core** → [`/architecture/Coven.Core.md`](/architecture/Coven.Core.md) -* **Spellcasting** → [`/architecture/Coven.Spellcasting.md`](/architecture/Coven.Spellcasting.md) -* **Chat** → [`/architecture/Coven.Chat.md`](/architecture/Coven.Chat.md) -* **Daemonology (hosts)** → [`/architecture/Coven.Daemonology.md`](/architecture/Coven.Daemonology.md) -* **Integrations (docs only)** → [`/architecture/Coven.Codex.md`](/architecture/Coven.Codex.md), [`/architecture/Coven.OpenAI.md`](/architecture/Coven.OpenAI.md), [`/architecture/Coven.Spellcasting.MCP.md`](/architecture/Coven.Spellcasting.MCP.md) - ---- - -## Licensing - -**Dual‑license (BUSL‑1.1 + Commercial):** - -* **Community**: Business Source License 1.1 (BUSL‑1.1) with an Additional Use Grant permitting Production Use if you and your affiliates made **< US $100M** in combined gross revenue in the prior fiscal year. See `LICENSE`. -* **Commercial/Enterprise**: available under a separate agreement. See `COMMERCIAL-TERMS.md`. - -*Change Date/License*: `LICENSE` specifies a Change License of **MIT** on **2029‑09‑11**. - -## Support - -* Patreon: [https://www.patreon.com/c/Goldenwitch](https://www.patreon.com/c/Goldenwitch) - -> © 2025 Autumn Wyborny. BUSL 1.1, free for non-profits, individuals, and commercial business under 100m annual revenue. +# Coven + +A minimal, composable **.NET 10** engine for orchestrating multiple agents to achieve big things. + +> ___"With great power comes great responsibility"___ - _Uncle Ben_ +>
If you use this library, don't be evil. + +## Covenants + +* **Journal or it didn't happen** Every thought and output lands in a Scrivener for replay, audit, and time-travel. +* **Compile time validation is better than vibes** Designed from the ground up to minimize side-effects. +* **Daemons behave** Lifecycle, backpressure, graceful shutdown. Async and long-running by design. +* **Hosts over ceremony** Use generic host and DI patterns to painlessly replace or extend functionality. +* **Window/Shatter** Semantic windowing over streamed chats and agents. + +## Quick Start +Run Sample 01 (Discord Agent) to see Coven orchestrate a Discord chat channel with an OpenAI‑backed agent. + +See detailed steps: [Sample 01 — Discord Agent README](src/samples/01.DiscordAgent/README.md). + +- Prerequisites: + - .NET 10 SDK installed. + - Discord Bot: token provisioned, bot invited to your server, Message Content Intent enabled in the Discord Developer Portal, and permission to read/write in a target channel. + - Channel ID: enable Discord Developer Mode, right‑click the target channel → Copy ID. + - OpenAI API key and a valid model (for example, `gpt-5-2025-08-07`). + +### 1) Configure secrets (env vars or defaults) + +- Easiest: set environment variables and keep `Program.cs` unchanged: + - `DISCORD_BOT_TOKEN` + - `DISCORD_CHANNEL_ID` (unsigned integer) + - `OPENAI_API_KEY` + - `OPENAI_MODEL` (defaults to `gpt-5-2025-08-07` if not set) +- Or edit defaults at the top of `src/samples/01.DiscordAgent/Program.cs` (they’re used only if env vars are absent). + +Example from Sample 01 (`Program.cs`): + +```csharp +// Defaults used if env vars are not present +string defaultDiscordToken = ""; // set your Discord bot token +ulong defaultDiscordChannelId = 0; // set your channel id +string defaultOpenAiApiKey = ""; // set your OpenAI API key +string defaultOpenAiModel = "gpt-5-2025-08-07"; // choose the model + +// Environment overrides (optional) +string? envDiscordToken = Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); +string? envDiscordChannelId = Environment.GetEnvironmentVariable("DISCORD_CHANNEL_ID"); +string? envOpenAiApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); +string? envOpenAiModel = Environment.GetEnvironmentVariable("OPENAI_MODEL"); + +ulong channelId = defaultDiscordChannelId; +if (!string.IsNullOrWhiteSpace(envDiscordChannelId) && ulong.TryParse(envDiscordChannelId, out ulong parsed)) +{ + channelId = parsed; +} + +DiscordClientConfig discordConfig = new() +{ + BotToken = string.IsNullOrWhiteSpace(envDiscordToken) ? defaultDiscordToken : envDiscordToken, + ChannelId = channelId +}; + +OpenAIClientConfig openAiConfig = new() +{ + ApiKey = string.IsNullOrWhiteSpace(envOpenAiApiKey) ? defaultOpenAiApiKey : envOpenAiApiKey, + Model = string.IsNullOrWhiteSpace(envOpenAiModel) ? defaultOpenAiModel : envOpenAiModel +}; +``` + +### 2) Wire up Discord + OpenAI and run + +- From repo root: `dotnet run --project src/samples/01.DiscordAgent -c Release` +- The app starts Discord and OpenAI daemons, then bridges chat↔agent in the configured channel. Type in the channel; the bot replies there. + +Minimal wiring from Sample 01 (`Program.cs`): + +```csharp +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddLogging(b => b.AddConsole()); +builder.Services.AddDiscordChat(discordConfig); +builder.Services.AddOpenAIAgents(openAiConfig, registration => +{ + registration.EnableStreaming(); +}); + +// Optional: override OpenAI mapping with templating +builder.Services.AddScoped, DiscordOpenAITemplatingTransmuter>(); + +// Route chat ↔ agent via a simple MagikBlock +builder.Services.BuildCoven(c => c.MagikBlock().Done()); + +IHost host = builder.Build(); +ICoven coven = host.Services.GetRequiredService(); +await coven.Ritual(new Empty()); +``` + +Router logic (Sample 01 `RouterBlock.cs`): + +```csharp +await foreach ((long _, ChatEntry? entry) in _chat.TailAsync(0, cancellationToken)) +{ + if (entry is ChatAfferent inc) + { + await _agents.WriteAsync(new AgentPrompt(inc.Sender, inc.Text), cancellationToken); + } +} + +await foreach ((long _, AgentEntry? entry) in _agents.TailAsync(0, cancellationToken)) +{ + switch (entry) + { + case AgentResponse r: + await _chat.WriteAsync(new ChatEfferentDraft("BOT", r.Text), cancellationToken); + break; + case AgentThought t: + // optionally surface thoughts to chat + break; + } +} +``` + +### Troubleshooting + +- Discord: If no messages appear, verify the bot has access to the channel, Message Content Intent is enabled, and `ChannelId` is correct. +- OpenAI: If errors occur on first response, confirm the API key and model name are valid for your account. +- Networking: Corporate proxies/firewalls can block Discord/OpenAI APIs; ensure outbound HTTPS is allowed. + +### Extensibility + +Window policies: tune output chunking/summarization. Example (from Sample 01 `Program.cs`): + +```csharp +// Paragraph-first + tighter max-length for agent outputs +builder.Services.AddScoped>(_ => + new CompositeWindowPolicy( + new AgentParagraphWindowPolicy(), + new AgentMaxLengthWindowPolicy(1024))); + +// Optionally tune thought chunking independently +// builder.Services.AddScoped>(_ => +// new CompositeWindowPolicy( +// new AgentThoughtSummaryMarkerWindowPolicy(), +// new AgentThoughtMaxLengthWindowPolicy(2048))); +``` + +Custom OpenAI templating: override prompt/response item mapping to inject context (from `DiscordOpenAITemplatingTransmuter.cs`): + +```csharp +internal sealed class DiscordOpenAITemplatingTransmuter : ITransmuter +{ + public Task Transmute(OpenAIEntry Input, CancellationToken cancellationToken = default) + { + return Input switch + { + OpenAIEfferent u => Task.FromResult( + ResponseItem.CreateUserMessageItem($"[discord username:{u.Sender}] {u.Text}")), + OpenAIAfferent a => Task.FromResult( + ResponseItem.CreateAssistantMessageItem($"[assistant:{a.Model}] {a.Text}")), + _ => Task.FromResult(null) + }; + } +} +``` + +Surface agent thoughts: optionally echo internal thinking to the chat (from `RouterBlock.cs`): + +```csharp +case AgentThought t: + // Uncomment to stream thoughts to the channel + // await _chat.WriteAsync(new ChatEfferentDraft("BOT", t.Text), cancellationToken); + break; +``` +### I don't want to make a discord bot. +Don't use Discord? No problem. One line change to swap to using Console as your chat of choice. +```csharp +// Replace +builder.Services.AddDiscordChat(discordConfig); +// with +builder.Services.AddConsoleChat(new ConsoleClientConfig +{ + InputSender = "console", + OutputSender = "BOT" +}); + +// Keep OpenAI registration as-is +builder.Services.AddOpenAIAgents(openAiConfig); +``` + +### I want to configure my model to do different things +You can use any settings available on the OpenAIClientConfig. For example, you could make the model chew longer by setting Effort = ReasoningEffort.High + +```csharp +OpenAIClientConfig openAiConfig = new() +{ + ApiKey = "", + Model = "gpt-5-2025-08-07", + Reasoning = new ReasoningConfig { Effort = ReasoningEffort.High } +}; + +// Then register +builder.Services.AddOpenAIAgents(openAiConfig); +``` + +## Overview +Ever felt like it was too hard to get products that you pay for to talk to each other? Perhaps felt like they should just work together... magically? :P + +You are in the right place. + +### Structure +Every Coven is organized into a "spine" of MagikBlocks, executing one after the other. +Each MagikBlock execution represents a unique scope with a fixed input and output type. +> _Cheatcodes_: Use Empty as an input if you want to route to a MagikBlock with no inputs. + +By starting Daemons and reading journals, your block executes the logic it needs, abstracted from the downstream implementation. The layers that define these abstractions are the "branches" that stretch off of your MagikBlock's execution. Coven offers two convenient abstractions: +- **Coven.Chat**: Multi-user conversations. +- **Coven.Agents**: Working with an AI powered Agent to complete your goals. + +Built on the other side of the "branch" abstractions are Coven's handcrafted integrations with external systems. These integrations are like the "leaves" of our twisted tree, they translate Coven standard abstractions to an external system. +- **Coven.Chat.Discord**: Use discord to chat with your Coven. +- **Coven.Chat.Console**: Use a terminal to chat with your Coven. +- **Coven.Agents.OpenAI**: Send requests to an agent from your Coven. + +### Why use Coven? +Anyone can write new branches or leaves and they will seamlessly integrate with your software. + +Alternatively, because we are the easiest way to get agents to collaborate with users and each other. + +### Vocabulary Cheatsheet +> Core +- MagikBlock: a unit of work with `DoMagik` that reads/writes journals. +- Daemon (`ContractDaemon`): long‑running background service started by a block. +- Scrivener (`IScrivener`): append-only journal for typed entries; supports tailing. +- Transmuter: pure mapping between types; `IBiDirectionalTransmuter` supports both directions. +- Ritual: an invocation that executes a pipeline of MagikBlocks. +- Entry: a record written to a journal (e.g., `ChatEntry`, `AgentEntry`). + +> Streaming and Window/Shatter +- Window Policy: rules that group stream chunks into windows for emission. +- Shatter Policy: rules that split entries into smaller chunks for windowing. +- Chunk: stream fragment (e.g., `AgentAfferentChunk`, `AgentAfferentThoughtChunk`). +- Batch Transmuter: combines a window of chunks into an output (response or thought). + +> Structure +- Leaf: Connects your currently executing block to an external system. Lives at the end of a branch. +- Branch: Services that connect your currently executing block to an external system via an abstraction. For example, Coven.Agents and Coven.Chat +- Spine: Your executing ritual. Each vertebrae is a MagikBlock in your ritual. +- Afferent/Efferent: The direction that a message is traveling. + - Efferent: from spine to leaf. + - Afferent: from leaf to spine. + +## Licensing + +**Dual‑license (BUSL‑1.1 + Commercial):** + +* **Community**: Business Source License 1.1 (BUSL‑1.1) with an Additional Use Grant permitting Production Use if you and your affiliates made **< US $100M** in combined gross revenue in the prior fiscal year. See `LICENSE`. +* **Commercial/Enterprise**: available under a separate agreement. See `COMMERCIAL-TERMS.md`. + +*Change Date/License*: `LICENSE` specifies a Change License of **MIT** on **2029‑09‑11**. + +## Support + +* Patreon: [https://www.patreon.com/c/Goldenwitch](https://www.patreon.com/c/Goldenwitch) + +> © 2025 Autumn Wyborny. BUSL 1.1, free for non-profits, individuals, and commercial business under 100m annual revenue. diff --git a/src/samples/01.DiscordAgent/Program.cs b/src/samples/01.DiscordAgent/Program.cs index 77ba55a..da704d8 100644 --- a/src/samples/01.DiscordAgent/Program.cs +++ b/src/samples/01.DiscordAgent/Program.cs @@ -11,17 +11,35 @@ using Coven.Core.Streaming; using Coven.Agents; -// Configuration +// Configuration (env-first with fallback to defaults below) +// Defaults: edit these to hardcode values when env vars are not present +string defaultDiscordToken = ""; // set your Discord bot token +ulong defaultDiscordChannelId = 0; // set your channel id +string defaultOpenAiApiKey = ""; // set your OpenAI API key +string defaultOpenAiModel = "gpt-5-2025-08-07"; // choose the model + +// Environment overrides (optional) +string? envDiscordToken = Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); +string? envDiscordChannelId = Environment.GetEnvironmentVariable("DISCORD_CHANNEL_ID"); +string? envOpenAiApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"); +string? envOpenAiModel = Environment.GetEnvironmentVariable("OPENAI_MODEL"); + +ulong channelId = defaultDiscordChannelId; +if (!string.IsNullOrWhiteSpace(envDiscordChannelId) && ulong.TryParse(envDiscordChannelId, out ulong parsed)) +{ + channelId = parsed; +} + DiscordClientConfig discordConfig = new() { - BotToken = "", // set your Discord bot token - ChannelId = 0 // set your channel id + BotToken = string.IsNullOrWhiteSpace(envDiscordToken) ? defaultDiscordToken : envDiscordToken, + ChannelId = channelId }; OpenAIClientConfig openAiConfig = new() { - ApiKey = "", // set your OpenAI API key - Model = "gpt-5-2025-08-07" // choose the model + ApiKey = string.IsNullOrWhiteSpace(envOpenAiApiKey) ? defaultOpenAiApiKey : envOpenAiApiKey, + Model = string.IsNullOrWhiteSpace(envOpenAiModel) ? defaultOpenAiModel : envOpenAiModel }; // Register DI diff --git a/src/samples/01.DiscordAgent/README.md b/src/samples/01.DiscordAgent/README.md new file mode 100644 index 0000000..5e793d2 --- /dev/null +++ b/src/samples/01.DiscordAgent/README.md @@ -0,0 +1,104 @@ +# Sample 01 — Discord Agent + +Run a Discord-backed chat agent powered by OpenAI, wired together using Coven’s composable runtime. + +## What This Is (Coven terms) + +- Chat Adapter: Uses `Coven.Chat.Discord` to turn a Discord channel into a stream of `ChatEntry` events (afferent = inbound user messages, efferent = bot drafts/outputs). +- Agent Integration: Uses `Coven.Agents.OpenAI` to map chat to `AgentEntry` prompts/thoughts/responses. Streaming is enabled for responsive output. +- Router (MagikBlock): `RouterBlock` is a simple `IMagikBlock` that bridges chat ↔ agents by reading/writing via `IScrivener` logs (journal-first design). +- Daemons: Discord and OpenAI run as `ContractDaemon`s managed by the host lifecycle (start/shutdown cooperatively). +- Windowing: Output chunking is governed by `IWindowPolicy` for paragraph-first aggregation and max-length capping. +- Transmutation: `DiscordOpenAITemplatingTransmuter` customizes how OpenAI request/response items are templated (e.g., decorating with Discord username/model markers). + +Key files: +- `Program.cs`: configuration, DI registration, window policies, and Coven wiring. +- `RouterBlock.cs`: the chat ↔ agent bridge. +- `DiscordOpenAITemplatingTransmuter.cs`: optional OpenAI request/response templating. + +## Setup + +Prerequisites: +- .NET 10 SDK installed. +- Discord Bot: token provisioned, bot invited to your server, Message Content Intent enabled, and permission to read/write in a target channel. +- Channel ID: enable Discord Developer Mode → right‑click channel → Copy ID. +- OpenAI: API key and a valid model (e.g., `gpt-5-2025-08-07`). + +Configure secrets (env vars preferred): +- `DISCORD_BOT_TOKEN` +- `DISCORD_CHANNEL_ID` (unsigned integer) +- `OPENAI_API_KEY` +- `OPENAI_MODEL` (defaults to `gpt-5-2025-08-07` if not set) + +Alternatively, edit defaults at the top of `Program.cs`; these are used only when env vars are absent: + +```csharp +string defaultDiscordToken = ""; // Discord bot token +ulong defaultDiscordChannelId = 0; // channel id +string defaultOpenAiApiKey = ""; // OpenAI API key +string defaultOpenAiModel = "gpt-5-2025-08-07"; // model +``` + +Run the sample: +- From repo root: `dotnet run --project src/samples/01.DiscordAgent -c Release` +- The app starts Discord and OpenAI daemons, then bridges the configured channel. Type in the channel; the bot responds there. + +Troubleshooting: +- Discord: Verify the bot is in the server, has access to the channel, Message Content Intent is enabled, and `ChannelId` is correct. +- OpenAI: Confirm API key and model are valid for your account. +- Networking: Ensure outbound HTTPS to Discord and OpenAI is allowed. + +## Extend + +Swap Discord for Console chat (one-line change): + +```csharp +// Replace +builder.Services.AddDiscordChat(discordConfig); +// with +builder.Services.AddConsoleChat(new ConsoleClientConfig +{ + InputSender = "console", + OutputSender = "BOT" +}); +``` + +Tune output windowing (paragraph-first + tighter cap): + +```csharp +builder.Services.AddScoped>(_ => + new CompositeWindowPolicy( + new AgentParagraphWindowPolicy(), + new AgentMaxLengthWindowPolicy(1024))); +``` + +Surface agent thoughts to the chat (optional): + +```csharp +// RouterBlock.cs +case AgentThought t: + // Uncomment to stream thoughts + // await _chat.WriteAsync(new ChatEfferentDraft("BOT", t.Text), cancellationToken); + break; +``` + +Customize OpenAI request/response templating: + +```csharp +// DiscordOpenAITemplatingTransmuter.cs (maps OpenAIEntry → ResponseItem) +OpenAIEfferent u => ResponseItem.CreateUserMessageItem( + $"[discord username:{u.Sender}] {u.Text}"); +OpenAIAfferent a => ResponseItem.CreateAssistantMessageItem( + $"[assistant:{a.Model}] {a.Text}"); +``` + +Adjust model behavior (example: increase reasoning effort): + +```csharp +OpenAIClientConfig openAiConfig = new() +{ + ApiKey = "", + Model = "gpt-5-2025-08-07", + Reasoning = new ReasoningConfig { Effort = ReasoningEffort.High } +}; +```