namespace Agent.Core.Messaging;/// <summary>/// Universal transport for one turn of user input and agent output./// Subclass per interface (console, Discord bot, web socket, etc.)./// </summary>public abstract class IncomingOutgoing{ /// <summary> /// Reads one user message, invokes <paramref name="processMessageAsync"/>, then emits any outgoing content. /// Returns false when the host should stop its session loop (e.g. user typed exit). /// </summary> public async Task<bool> HandleAsync( Func<string, Task<IncomingOutgoingTurnResult>> processMessageAsync, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(processMessageAsync); await BeforeReadAsync(cancellationToken).ConfigureAwait(false); string? incoming = await ReadIncomingAsync(cancellationToken).ConfigureAwait(false); if (incoming is null) return true; if (string.IsNullOrWhiteSpace(incoming)) return true; IncomingOutgoingTurnResult turn = await processMessageAsync(incoming).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(turn.AssistantReply)) { await WriteOutgoingAsync( new OutgoingMessage(OutgoingKind.Assistant, turn.AssistantReply), cancellationToken).ConfigureAwait(false); } await AfterTurnAsync(turn, cancellationToken).ConfigureAwait(false); return turn.ContinueRunning; } /// <summary>Runs <see cref="HandleAsync"/> until it returns false.</summary> public async Task RunAsync( Func<string, Task<IncomingOutgoingTurnResult>> processMessageAsync, CancellationToken cancellationToken = default) { bool running = true; while (running) { running = await HandleAsync(processMessageAsync, cancellationToken).ConfigureAwait(false); } } /// <summary>Hook before blocking on input (e.g. print "You: ").</summary> protected virtual Task BeforeReadAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// <summary>Read one user message. Return null to skip processing but keep the session alive.</summary> protected abstract Task<string?> ReadIncomingAsync(CancellationToken cancellationToken); /// <summary>Emit assistant or system text to the transport.</summary> protected virtual Task WriteOutgoingAsync(OutgoingMessage message, CancellationToken cancellationToken) => Task.CompletedTask; /// <summary>Hook after a processed turn (e.g. TTS on assistant reply).</summary> protected virtual Task AfterTurnAsync( IncomingOutgoingTurnResult turn, CancellationToken cancellationToken) => Task.CompletedTask;}/// <summary>Result of processing one user message through the agent.</summary>public sealed record IncomingOutgoingTurnResult(bool ContinueRunning, string? AssistantReply){ public static IncomingOutgoingTurnResult Continue(string? assistantReply = null) => new(true, assistantReply); public static IncomingOutgoingTurnResult Exit() => new(false, null);}/// <summary>Outgoing payload written by <see cref="IncomingOutgoing"/>.</summary>public readonly record struct OutgoingMessage(OutgoingKind Kind, string Text);public enum OutgoingKind{ Assistant, System,}
Documentation
Messaging
IncomingOutgoing is the shared turn loop for any Lilith interface:
1. BeforeRead — prompt (optional) 2. ReadIncoming — one user message 3. Process — host calls the agent (HandleInputAsync, etc.) 4. WriteOutgoing — assistant/system text (optional; console often logs via Logger already) 5. AfterTurn — TTS, metrics, etc.
Subclass for each transport (Discord in the console app is the console adapter; a future Discord.NET bot would subclass the same base).