Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Rendering/ChatRenderer.cs
EugeneTes d51438e665 feat(telegram): several admins via ADMIN_USER_IDS, each impersonating independently
ADMIN_USER_IDS (Telegram:AdminUserIds) takes a comma separated list; the older
ADMIN_USER_ID / Telegram:AdminUserId still works, also takes a list and is merged.
Empty entries and duplicates are ignored, non-numeric entries fail validation.
Impersonation is now per admin: one admin per target, an impersonating admin
can't be impersonated, and every admin's chat menu lists /impersonate.
2026-10-15 14:02:59 +00:00

456 lines
19 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Abstractions.Ui;
using static Volcanic.AgenticCore.Abstractions.Events.AgentSessionEvent;
namespace Volcanic.AgenticCore.Telegram.Rendering;
/// <summary>
/// Renders one chat's session events. Session handlers run on the agent loop, so events are queued into a channel and
/// rendered by a single task. Each assistant message is sent once, complete, when it ends (split into several Telegram
/// messages past the size limit); "typing…" repeats while the agent runs.
/// <para>
/// Host work (file uploads, notices) runs on the same loop through <see cref="RunInOrderAsync{T}"/>, so it stays in
/// order with the rendered output queued before it.
/// </para>
/// <para>
/// While a run is active one status message ("⚙️ Working...") lists the last <see cref="StatusToolCount"/> tool calls
/// with their state, edited at most every <see cref="TelegramOptions.EditInterval"/>. When something was sent below it,
/// the next tool update moves it back to the bottom; it is deleted when the run settles.
/// </para>
/// </summary>
public sealed class ChatRenderer : IAsyncDisposable
{
public const int StatusToolCount = 5;
private const int StatusArgsLength = 80;
private readonly long _chatId;
private readonly Func<long>? _deliverTo;
private readonly TelegramSender _sender;
private readonly TelegramOptions _options;
private readonly ILogger _logger;
private readonly TimeProvider _time;
private abstract record RenderItem;
private sealed record EventItem(AgentSessionEvent Event) : RenderItem;
private sealed record WorkItem(Func<CancellationToken, Task> Run, Action Cancel) : RenderItem;
private readonly Channel<RenderItem> _items = Channel.CreateUnbounded<RenderItem>(new UnboundedChannelOptions { SingleReader = true });
private readonly CancellationTokenSource _stop = new();
private readonly Task _loop;
/// <summary>Delays before resending a reply chunk that failed (1 s, 3 s). Settable for tests.</summary>
internal IReadOnlyList<TimeSpan> ReplyRetryDelays { get; set; } = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)];
private DateTimeOffset _lastFlush = DateTimeOffset.MinValue;
private CancellationTokenSource? _typing;
private int _pending;
// Status message state; touched only by the render loop.
private enum ToolState { Running, Done, Failed }
private sealed record ToolLine(string Id, string Name, string Args) { public ToolState State { get; set; } }
private readonly List<ToolLine> _recentTools = [];
private bool _running;
private int? _statusMessageId;
private long _statusChatId;
private string? _statusSent;
private bool _statusDirty;
private bool _statusBuried;
private bool Dirty => _statusDirty;
/// <param name="deliverTo">Where output goes, read at each send; null = <paramref name="chatId"/> (see <see cref="Chats.Impersonation"/>).</param>
public ChatRenderer(long chatId, TelegramSender sender, TelegramOptions options, ILogger logger, TimeProvider? time = null, Func<long>? deliverTo = null)
{
_chatId = chatId;
_deliverTo = deliverTo;
_sender = sender;
_options = options;
_logger = logger;
_time = time ?? TimeProvider.System;
_loop = Task.Run(RunAsync);
}
/// <summary>The Telegram chat this chat's output goes to now: its own, or an admin's while that admin impersonates it.</summary>
public long DeliveryChatId => _deliverTo?.Invoke() ?? _chatId;
/// <summary>Queue an event; safe to call from any thread (this is the session subscription).</summary>
public void Post(AgentSessionEvent e) => Enqueue(new EventItem(e));
private bool Enqueue(RenderItem item)
{
Interlocked.Increment(ref _pending);
if (_items.Writer.TryWrite(item)) return true;
Interlocked.Decrement(ref _pending);
return false;
}
/// <summary>
/// Run host work (an upload, a notice) on the render loop, after every event queued before it, so it stays in order
/// with rendered output. Afterwards the status message counts as buried. Cancelled when the renderer is disposed first.
/// <paramref name="cancellationToken"/> cancels the returned task at once, even while the work is still queued behind
/// a slow upload; work that hasn't started by then is skipped, and running work sees it through its token.
/// </summary>
public Task<T> RunInOrderAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(work);
if (cancellationToken.IsCancellationRequested) return Task.FromCanceled<T>(cancellationToken);
var done = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var registration = cancellationToken.Register(() => done.TrySetCanceled(cancellationToken));
var item = new WorkItem(async ct =>
{
try
{
if (done.Task.IsCompleted) return; // cancelled while queued
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, cancellationToken);
done.TrySetResult(await work(linked.Token).ConfigureAwait(false));
}
catch (OperationCanceledException) { done.TrySetCanceled(); }
catch (Exception ex) { done.TrySetException(ex); }
finally { await registration.DisposeAsync().ConfigureAwait(false); }
}, () =>
{
registration.Dispose();
done.TrySetCanceled();
});
if (!Enqueue(item))
{
registration.Dispose();
done.TrySetCanceled();
}
return done.Task;
}
/// <summary>Plain text (no parse mode) in order with rendered output; cut to 4096 characters by the sender.</summary>
public Task<int?> SayAsync(string text, CancellationToken cancellationToken = default) =>
RunInOrderAsync(ct => _sender.SendTextAsync(DeliveryChatId, text, cancellationToken: ct), cancellationToken);
/// <summary>
/// A Markdown reply rendered like an assistant message (HTML, split past 4096, each chunk retried), in order with
/// rendered output. Returns the first message id and whether every chunk was delivered.
/// </summary>
internal Task<(int? FirstId, bool Complete)> ReplyInOrderAsync(string markdown, CancellationToken cancellationToken) =>
RunInOrderAsync(ct => SendReplyAsync(markdown, ct), cancellationToken);
/// <summary>Completes once every queued event is rendered. For tests.</summary>
internal async Task DrainAsync()
{
while (Volatile.Read(ref _pending) > 0 || Dirty) await Task.Delay(10).ConfigureAwait(false);
}
private async Task RunAsync()
{
var reader = _items.Reader;
var ct = _stop.Token;
try
{
while (true)
{
if (Dirty)
{
var wait = _lastFlush + _options.EditInterval - _time.GetUtcNow();
if (wait <= TimeSpan.Zero)
{
await FlushAsync(ct).ConfigureAwait(false);
continue;
}
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(wait);
try
{
if (!await reader.WaitToReadAsync(timeout.Token).ConfigureAwait(false)) break;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
continue; // edit interval elapsed: flush on the next iteration
}
}
else if (!await reader.WaitToReadAsync(ct).ConfigureAwait(false))
{
break;
}
while (reader.TryRead(out var item))
{
try
{
switch (item)
{
case EventItem { Event: var e }:
await RenderAsync(e, ct).ConfigureAwait(false);
break;
case WorkItem work when ct.IsCancellationRequested: // disposal gave up waiting
work.Cancel();
break;
case WorkItem work:
await work.Run(ct).ConfigureAwait(false);
BuryStatus();
break;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Rendering {Item} for chat {ChatId} failed", item is EventItem ev ? ev.Event.GetType().Name : "work", _chatId);
}
finally
{
Interlocked.Decrement(ref _pending);
}
}
}
await DeleteStatusAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
finally
{
StopTyping();
while (_items.Reader.TryRead(out var left))
{
if (left is WorkItem work) work.Cancel();
Interlocked.Decrement(ref _pending);
}
}
}
private async Task RenderAsync(AgentSessionEvent e, CancellationToken ct)
{
switch (e)
{
case AgentStart:
StartTyping();
if (!_running)
{
_running = true;
_recentTools.Clear();
await FlushStatusAsync(ct).ConfigureAwait(false);
}
break;
case AgentSettled:
StopTyping();
await DeleteStatusAsync(ct).ConfigureAwait(false);
break;
case MessageEnd { Message: AssistantMessage message }:
_ = await SendReplyAsync(message.Text, ct).ConfigureAwait(false);
if (message.StopReason == StopReason.Error)
await SendLineAsync($"❌ {message.ErrorMessage ?? "The model request failed."}", ct).ConfigureAwait(false);
else if (message.StopReason == StopReason.Aborted)
await SendLineAsync("⏹ Stopped.", ct).ConfigureAwait(false);
break;
case ToolExecutionStart start:
_recentTools.Add(new ToolLine(start.ToolCallId, start.ToolName, FormatArgs(start.Args, StatusArgsLength)));
if (_recentTools.Count > StatusToolCount) _recentTools.RemoveRange(0, _recentTools.Count - StatusToolCount);
_statusDirty = true;
break;
case ToolExecutionEnd end:
if (_recentTools.Find(t => t.Id == end.ToolCallId) is { } tool)
{
tool.State = end.IsError ? ToolState.Failed : ToolState.Done;
_statusDirty = true;
}
break;
case AutoRetryStart retry:
await SendLineAsync($"⏳ {retry.ErrorMessage} — retrying in {retry.Delay.TotalSeconds:0} s ({retry.Attempt}/{retry.MaxAttempts})", ct).ConfigureAwait(false);
break;
case AutoRetryEnd { Success: false } retryEnd:
await SendLineAsync($"❌ Giving up after {retryEnd.Attempt} attempts: {retryEnd.FinalError}", ct).ConfigureAwait(false);
break;
case CompactionStart:
await SendLineAsync("🗜 Compacting the conversation…", ct).ConfigureAwait(false);
break;
case CompactionEnd { ErrorMessage: { } error }:
await SendLineAsync($"❌ Compaction failed: {error}", ct).ConfigureAwait(false);
break;
case CompactionEnd { Result: { } result }:
await SendLineAsync($"🗜 Compacted: ~{result.TokensBefore:N0} → ~{result.EstimatedTokensAfter:N0} tokens.", ct).ConfigureAwait(false);
break;
case Notification notification:
await SendLineAsync($"{Icon(notification.Kind)} {notification.Message}", ct).ConfigureAwait(false);
break;
case ExtensionError error:
_logger.LogWarning("Extension {Extension} failed on {Event}: {Error}", error.ExtensionName, error.Event, error.Error);
await SendLineAsync($"⚠️ {error.ExtensionName}: {error.Error}", ct).ConfigureAwait(false);
break;
}
}
private async Task FlushAsync(CancellationToken ct)
{
_lastFlush = _time.GetUtcNow();
if (_statusDirty) await FlushStatusAsync(ct).ConfigureAwait(false);
}
/// <summary>
/// Send a finished assistant message. A chunk that fails to send is retried a couple of times. Returns the first
/// message id and whether every chunk was delivered.
/// </summary>
private async Task<(int? FirstId, bool Complete)> SendReplyAsync(string text, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(text)) return (null, true);
int? first = null;
var complete = true;
var target = DeliveryChatId;
foreach (var chunk in TelegramFormatter.Split(text))
{
var html = TelegramFormatter.ToHtml(chunk);
var sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false);
foreach (var delay in ReplyRetryDelays)
{
if (sent is not null) break;
await Task.Delay(delay, ct).ConfigureAwait(false);
sent = await _sender.SendHtmlAsync(target, html, chunk, cancellationToken: ct).ConfigureAwait(false);
}
if (sent is null)
{
_logger.LogError("Could not deliver part of a reply to chat {ChatId}", _chatId);
complete = false;
}
else
{
first ??= sent;
BuryStatus();
}
}
return (first, complete);
}
private async Task SendLineAsync(string text, CancellationToken ct)
{
if (await _sender.SendHtmlAsync(DeliveryChatId, TelegramFormatter.Escape(text), text, cancellationToken: ct).ConfigureAwait(false) is not null)
BuryStatus();
}
/// <summary>
/// Something was sent below the status message: the next status update moves it back to the bottom. Not moved right
/// away, since after the final answer the run settles and the status is deleted anyway.
/// </summary>
private void BuryStatus()
{
if (_statusMessageId is not null) _statusBuried = true;
}
/// <summary>Send, move or edit the status message to show the current tool list.</summary>
private async Task FlushStatusAsync(CancellationToken ct)
{
_statusDirty = false;
if (!_running) return;
var html = StatusHtml();
var target = DeliveryChatId;
// Moved to the bottom when buried, and to the other chat when the delivery chat changed (impersonation).
if ((_statusBuried || _statusChatId != target) && _statusMessageId is { } buried)
{
await _sender.DeleteAsync(_statusChatId, buried, ct).ConfigureAwait(false);
_statusMessageId = null;
}
_statusBuried = false;
if (_statusMessageId is not { } id)
{
_statusChatId = target;
_statusMessageId = await _sender.SendHtmlAsync(target, html, StripTags(html), cancellationToken: ct).ConfigureAwait(false);
_statusSent = html;
}
else if (html != _statusSent && await _sender.EditHtmlAsync(_statusChatId, id, html, StripTags(html), ct).ConfigureAwait(false))
{
_statusSent = html;
}
}
private async Task DeleteStatusAsync(CancellationToken ct)
{
_running = false;
_statusDirty = false;
_statusBuried = false;
_recentTools.Clear();
if (_statusMessageId is { } id) await _sender.DeleteAsync(_statusChatId, id, ct).ConfigureAwait(false);
_statusMessageId = null;
_statusSent = null;
}
private string StatusHtml()
{
var html = new System.Text.StringBuilder("⚙️ <b>Working...</b>");
foreach (var tool in _recentTools)
{
var icon = tool.State switch { ToolState.Done => "✓", ToolState.Failed => "✗", _ => "▶" };
html.Append('\n').Append(icon).Append(" <b>").Append(TelegramFormatter.Escape(tool.Name)).Append("</b>").Append(tool.Args);
}
return html.ToString();
}
private void StartTyping()
{
if (_typing is not null) return;
var cts = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token);
_typing = cts;
_ = Task.Run(async () =>
{
try
{
while (!cts.IsCancellationRequested)
{
await _sender.SendTypingAsync(DeliveryChatId, cts.Token).ConfigureAwait(false);
await Task.Delay(_options.TypingInterval, cts.Token).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
}
});
}
private void StopTyping()
{
var typing = Interlocked.Exchange(ref _typing, null);
typing?.Cancel();
typing?.Dispose();
}
/// <summary>": <code>ls -la</code>" for bash, the most telling argument otherwise.</summary>
internal static string FormatArgs(JsonElement args, int max = 200)
{
if (args.ValueKind != JsonValueKind.Object) return "";
string? summary = null;
foreach (var key in new[] { "command", "path", "pattern", "file_path" })
{
if (args.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
{
summary = value.GetString();
break;
}
}
summary ??= args.EnumerateObject().Any() ? args.GetRawText() : null;
if (string.IsNullOrWhiteSpace(summary)) return "";
return $": <code>{TelegramFormatter.Escape(Shorten(summary.ReplaceLineEndings(" "), max))}</code>";
}
private static string Shorten(string text, int max) => text.Length <= max ? text : text[..max] + "…";
private static string StripTags(string html) =>
System.Net.WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", ""));
private static string Icon(NotifyKind kind) => kind switch { NotifyKind.Warning => "⚠️", NotifyKind.Error => "❌", _ => "ℹ️" };
public async ValueTask DisposeAsync()
{
_items.Writer.TryComplete();
try
{
await _loop.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (TimeoutException)
{
}
await _stop.CancelAsync().ConfigureAwait(false);
StopTyping();
_stop.Dispose();
}
}