Files
VolcanicAgenticCore/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs
EugeneTes bb1c534a7a feat(core): always-on INSTRUCTIONS.md in skill directories
A skill directory may hold INSTRUCTIONS.md, with or without SKILL.md. Its body
(frontmatter cut) goes into every system prompt in <skill_instructions>, re-read
on every run: 16 KB per file, 64 KB in total, truncation marked with the file's
path. /skills marks skills with instructions and lists instruction-only dirs.
2026-10-12 10:15:05 +00:00

330 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
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.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using Telegram.Bot.Types;
using Volcanic.AgenticCore.A2A.Server;
using Volcanic.AgenticCore.A2A.Server.Callers;
using Volcanic.AgenticCore.A2A.Server.Tasks;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Extensions.Scheduler;
using Volcanic.AgenticCore.Features.SystemPrompt;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Telegram.Rendering;
using Volcanic.AgenticCore.Telegram.Scheduling;
using Volcanic.AgenticCore.Telegram.Triggers;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>The bot's own slash commands. Anything else starting with <c>/</c> goes to the session (extension commands).</summary>
public sealed class ChatCommands(TelegramSender sender, ChatTimeZoneService? zones = null, JobScheduler? scheduler = null, TriggerServices? triggers = null,
IOptions<A2AHostOptions>? a2a = null, TimeProvider? time = null, A2AClientSettings? a2aClient = null, A2AWatchRegistry? a2aWatches = null)
{
public static readonly IReadOnlyList<BotCommand> Menu =
[
new() { Command = "new", Description = "Start a new conversation" },
new() { Command = "stop", Description = "Stop the current run" },
new() { Command = "status", Description = "Model, session and context usage" },
new() { Command = "sessions", Description = "Recent conversations" },
new() { Command = "resume", Description = "Resume a conversation: /resume <id>" },
new() { Command = "compact", Description = "Summarise the conversation to free context" },
new() { Command = "thinking", Description = "Show or set the thinking level" },
new() { Command = "timezone", Description = "Show or set this chat's time zone" },
new() { Command = "skills", Description = "Skills in this chat's workspace" },
.. SchedulerSetup.Menu,
TriggersSetup.MenuCommand,
new() { Command = "a2a", Description = "A2A callers, remote agents and tasks of this chat" },
new() { Command = "help", Description = "What this bot does" },
];
/// <summary>The admin's chat gets <see cref="Menu"/> plus this (see <see cref="Impersonation"/>).</summary>
public static readonly BotCommand ImpersonateCommand = new() { Command = "impersonate", Description = "Act as another user: /impersonate <user_id>, 0 stops" };
/// <summary>Split <c>/name@bot args</c>. Null when the text is not a command.</summary>
public static (string Name, string Args)? Parse(string text)
{
if (!text.StartsWith('/') || text.Length < 2) return null;
var space = text.IndexOfAny([' ', '\n']);
var head = space < 0 ? text[1..] : text[1..space];
var args = space < 0 ? "" : text[(space + 1)..].Trim();
var at = head.IndexOf('@');
if (at >= 0) head = head[..at];
return (head.ToLowerInvariant(), args);
}
/// <summary>Run a bot command. False when <paramref name="text"/> is not one of ours.</summary>
public async Task<bool> TryHandleAsync(ChatContext chat, string text, CancellationToken cancellationToken)
{
if (Parse(text) is not { } command) return false;
var session = chat.Session;
switch (command.Name)
{
case "start":
case "help":
await ReplyAsync(chat, Help(chat), cancellationToken).ConfigureAwait(false);
return true;
case "stop":
chat.Inbox.Discard(); // what is still waiting in the window would start the run the user just stopped
if (!session.IsStreaming && !session.IsCompacting)
{
await ReplyAsync(chat, "Nothing is running.", cancellationToken).ConfigureAwait(false);
return true;
}
await session.AbortAsync().ConfigureAwait(false);
return true; // the renderer reports "Stopped."
case "new":
chat.Inbox.Discard(); // drop what the user typed before /new, before a buffered message can flush
if (session.IsStreaming) await session.AbortAsync().ConfigureAwait(false);
var created = await session.NewSessionAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
await ReplyAsync(chat, created.Cancelled ? "An extension cancelled the new session." : "🆕 New conversation.", cancellationToken).ConfigureAwait(false);
return true;
case "status":
await ReplyAsync(chat, Status(chat), cancellationToken).ConfigureAwait(false);
return true;
case "sessions":
await ReplyAsync(chat, await SessionsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "resume":
await ReplyAsync(chat, await ResumeAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "compact":
if (session.IsStreaming)
{
await ReplyAsync(chat, "Wait until the current run finishes, or /stop it.", cancellationToken).ConfigureAwait(false);
return true;
}
_ = Task.Run(async () =>
{
try
{
await session.CompactAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
chat.Report($"Compaction failed: {ex.Message}");
}
}, CancellationToken.None);
return true; // the renderer shows compaction progress
case "thinking":
await ReplyAsync(chat, await ThinkingAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "timezone":
await ReplyAsync(chat, await TimeZoneAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "triggers":
await ReplyAsync(chat, triggers is null ? TriggersSetup.DisabledReply : "**Group triggers**\n" + triggers.Describe(chat.ChatId, chat.Session.TimeZone), cancellationToken).ConfigureAwait(false);
return true;
case "skills":
await ReplyAsync(chat, Skills(chat), cancellationToken).ConfigureAwait(false);
return true;
case "jobs":
await ReplyAsync(chat, Jobs(chat), cancellationToken).ConfigureAwait(false);
return true;
case "a2a":
await ReplyAsync(chat, A2AStatus(chat), cancellationToken).ConfigureAwait(false);
return true;
case "cancel":
await ReplyAsync(chat, await CancelJobAsync(chat, command.Args, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
default:
return false;
}
}
private Task ReplyAsync(ChatContext chat, string text, CancellationToken cancellationToken) =>
sender.SendHtmlAsync(chat.DeliveryChatId, TelegramFormatter.ToHtml(text), text, cancellationToken: cancellationToken);
private static string Help(ChatContext chat) =>
$"""
I am a coding agent running **{chat.Session.Model?.Name ?? "an unconfigured model"}**. Write to me as you would to a colleague; I can read and change files and run shell commands in my workspace `{chat.Session.Cwd}`.
While I am working, a new message steers the current run.
Reply to one of my messages, or quote part of it, to point at what you mean.
/new – start a new conversation
/stop – stop the current run
/status – model, session and context usage
/sessions, /resume <id> – earlier conversations
/compact – summarise the conversation to free context
/thinking [level] – show or set the thinking level
/timezone [IANA name|reset] – this chat's time zone
/skills – skills in my workspace
""" + SchedulerSetup.HelpLines + "\n/a2a – A2A callers, remote agents and recent tasks of this chat" + TriggersSetup.HelpLines + GoogleCalendarSetup.HelpLines(chat.Session.Commands)
+ (chat.Session.Commands.Any(c => c.Name == "mcp_add") ? RemoteMcpSetup.HelpLines : "");
private string Status(ChatContext chat)
{
var session = chat.Session;
var sb = new StringBuilder();
sb.AppendLine(CultureInfo.InvariantCulture, $"**Model:** {session.Model?.Key ?? "none"}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Thinking:** {session.ThinkingLevel.ToString().ToLowerInvariant()}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Session:** `{session.SessionId}`{(session.SessionName is { } name ? $" ({name})" : "")}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Messages:** {session.Messages.Count}");
if (session.GetContextUsage() is { } usage)
sb.AppendLine(CultureInfo.InvariantCulture, $"**Context:** {(usage.Tokens is { } t ? t.ToString("N0", CultureInfo.InvariantCulture) : "?")} / {usage.ContextWindow.ToString("N0", CultureInfo.InvariantCulture)} tokens{(usage.Percent is { } p ? $" ({p:0}%)" : "")}");
if (scheduler is { IsStarted: true })
sb.AppendLine(CultureInfo.InvariantCulture, $"**Jobs:** {scheduler.List(TelegramJobDelivery.TargetOf(chat.ChatId)).Count}");
var zone = zones?.Describe(chat.ChatId) ?? TimeZoneIds.Name(session.TimeZone); // no service (tests): no clock read
sb.AppendLine(CultureInfo.InvariantCulture, $"**Time zone:** {zone}");
sb.AppendLine(CultureInfo.InvariantCulture, $"**Workspace:** `{session.Cwd}`");
sb.Append(session.IsStreaming ? "**State:** running" : session.IsCompacting ? "**State:** compacting" : "**State:** idle");
return sb.ToString();
}
private static async Task<string> SessionsAsync(ChatContext chat, CancellationToken cancellationToken)
{
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
if (sessions.Count == 0) return "No conversations yet.";
var sb = new StringBuilder("**Recent conversations** (newest first):\n");
foreach (var s in sessions.Take(10))
{
var title = s.Name ?? s.FirstUserText ?? "(empty)";
if (title.Length > 60) title = title[..60] + "…";
var current = s.SessionId == chat.Session.SessionId ? " ← current" : "";
sb.Append(CultureInfo.InvariantCulture, $"\n`/resume {s.SessionId}`{current}\n{s.Modified:yyyy-MM-dd HH:mm} · {s.MessageCount} messages · {title}\n");
}
return sb.ToString();
}
private static async Task<string> ResumeAsync(ChatContext chat, string id, CancellationToken cancellationToken)
{
if (id.Length == 0) return "Usage: /resume <id> (see /sessions).";
var sessions = await chat.Session.ListSessionsAsync(cancellationToken).ConfigureAwait(false);
var matches = sessions.Where(s => s.SessionId.StartsWith(id, StringComparison.OrdinalIgnoreCase)).ToList();
if (matches.Count != 1) return matches.Count == 0 ? $"No conversation `{id}`." : $"`{id}` matches several conversations; use more characters.";
chat.Inbox.Discard(); // what was typed before /resume belongs to neither conversation; drop it before it can flush
if (chat.Session.IsStreaming) await chat.Session.AbortAsync().ConfigureAwait(false);
var result = await chat.Session.SwitchSessionAsync(matches[0].SessionId, cancellationToken).ConfigureAwait(false);
return result.Cancelled ? "An extension cancelled the switch." : $"Resumed `{matches[0].SessionId}` ({matches[0].MessageCount} messages).";
}
private async Task<string> TimeZoneAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (zones is null) return "Time zones aren't configured on this bot.";
if (arg.Length == 0) return zones.Show(chat.ChatId);
if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) return await zones.SetAsync(chat.ChatId, null, cancellationToken).ConfigureAwait(false);
return ChatTimeZones.TryParse(arg, out var zone, out var error)
? await zones.SetAsync(chat.ChatId, zone, cancellationToken).ConfigureAwait(false)
: error;
}
private static string Skills(ChatContext chat)
{
var skills = SkillCatalog.Load(chat.Session.Cwd);
var instructions = SkillCatalog.LoadInstructions(chat.Session.Cwd);
var where = $"`{Path.GetRelativePath(chat.Session.Cwd, SkillCatalog.Directory(chat.Session.Cwd))}/<name>/SKILL.md`";
if (skills.Count == 0 && instructions.Count == 0) return $"No skills yet. Ask me to write one, or put it in {where}.";
static string Mark(SkillInstructions i) => i.Truncated ? " (+ INSTRUCTIONS.md, truncated in the prompt)" : " (+ INSTRUCTIONS.md)";
var byDir = instructions.ToDictionary(i => Path.GetDirectoryName(i.Path)!, StringComparer.Ordinal);
var lines = skills.Select(s => $"• **{s.Name}** – {s.Description}" + (byDir.Remove(Path.GetDirectoryName(s.Path)!, out var i) ? Mark(i) : ""))
.Concat(byDir.Values.Select(i => $"• **{i.Name}** – INSTRUCTIONS.md only" + (i.Truncated ? " (truncated in the prompt)" : "")));
return $"**Skills** ({where})\n" + string.Join('\n', lines);
}
private string Jobs(ChatContext chat)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var jobs = scheduler.List(target);
if (jobs.Count == 0) return "No scheduled jobs.";
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
return $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
}
private string A2AStatus(ChatContext chat)
{
var serverOn = a2a?.Value is { Enabled: true };
var clientOn = a2aClient is { Enabled: true };
if (!serverOn && !clientOn)
return "A2A is off. Set `A2A__Enabled=true` (and `A2A__PublicUrl`) to let other agents talk to this assistant, " +
"`A2AClient__Enabled=true` to let it talk to other agents.";
var workspace = chat.Session.Cwd;
var lines = new List<string>();
if (serverOn)
{
var callers = CallersFile.Read(workspace, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
lines.Add("**A2A callers** (.volcanic/a2a/callers.json)");
lines.AddRange(callers.Count == 0 ? ["none"] : callers.Select(c => $"• {c.Name} ({c.Mode}) key {Mask(c.Key)}"));
var tasks = A2ATaskFiles.Recent(workspace, 10);
lines.Add("**Recent A2A tasks**");
lines.AddRange(tasks.Count == 0 ? ["none"] : tasks.Select(t => $"• {t.Caller}: {t.State}{Age(t.Updated)} ({ShortId(t.TaskId)})"));
}
else lines.Add("A2A server: off (`A2A__Enabled`).");
if (clientOn)
{
var agents = AgentsFile.Read(workspace);
lines.Add("**Remote agents** (.volcanic/a2a/agents.json)");
if (agents.Error is not null) lines.Add(agents.Error);
lines.AddRange(agents.Agents.Count == 0 ? ["none"] : agents.Agents.Select(a => $"• {a.Name}: {DisplayUrl(a.Url)}{(a.Key is null ? "" : " key " + Mask(a.Key))}"));
lines.AddRange(agents.Problems.Select(p => $"• skipped: {p}"));
var watches = a2aWatches?.List(TelegramJobDelivery.TargetOf(chat.ChatId)) ?? [];
lines.Add("**Watches** (remote tasks still running)");
lines.AddRange(watches.Count == 0 ? ["none"] : watches.Select(w => $"• {w.Agent}: task {ShortId(w.TaskId)} by {w.Source}{Age(w.StartedAt)}"));
}
else lines.Add("A2A client: off (`A2AClient__Enabled`).");
return string.Join('\n', lines);
}
/// <summary>The last four characters of a key of 12 or more, else nothing: "…abcd" or "…".</summary>
private static string Mask(string key) => key.Length >= 12 ? "…" + key[^4..] : "…";
/// <summary>Scheme, host, port and path only: a query, fragment or user info may carry credentials.</summary>
private static string DisplayUrl(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri)
? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.Path, UriFormat.UriEscaped)
: "(invalid url)";
private static string ShortId(string id) => id[..Math.Min(8, id.Length)];
/// <summary>", 5m ago" from a task's last status time; empty without one.</summary>
private string Age(DateTimeOffset? updated)
{
if (updated is not { } at) return "";
var age = (time ?? TimeProvider.System).GetUtcNow() - at;
return age.TotalMinutes < 1 ? ", just now"
: age.TotalHours < 1 ? $", {(int)age.TotalMinutes}m ago"
: age.TotalDays < 1 ? $", {(int)age.TotalHours}h ago"
: $", {(int)age.TotalDays}d ago";
}
private async Task<string> CancelJobAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var id = arg.Trim().ToLowerInvariant();
if (id.Length == 0) return "Usage: /cancel <id> (see /jobs).";
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
return await scheduler.CancelJobAsync(target, id, cancellationToken).ConfigureAwait(false) is { } job
? $"Cancelled {job.Id} ({job.Name})."
: $"No job {id} in this chat.";
}
private static async Task<string> ThinkingAsync(ChatContext chat, string arg, CancellationToken cancellationToken)
{
var session = chat.Session;
var available = string.Join(", ", session.AvailableThinkingLevels.Select(l => l.ToString().ToLowerInvariant()));
if (arg.Length == 0) return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**. Available: {available}.";
if (!Enum.TryParse<ThinkingLevel>(arg, ignoreCase: true, out var level) || !session.AvailableThinkingLevels.Contains(level))
return $"Unknown level `{arg}`. Available: {available}.";
await session.SetThinkingLevelAsync(level, cancellationToken).ConfigureAwait(false);
return $"Thinking: **{session.ThinkingLevel.ToString().ToLowerInvariant()}**.";
}
}