Files
Playa/hosts/Volcanic.AgenticCore.Telegram/Chats/ChatCommands.cs
EugeneTes 644f72068c feat(skills): session-start and turn-end hooks and protected paths declared in SKILL.md
A skill's SKILL.md frontmatter may declare session-start (output into
<skill_session_context>, at session start, /new, resume and after compaction),
turn-end (after every run, in the background; output or a failure reaches the
next run as a hidden notice, repeats suppressed until a clean run) and
protected-paths (write/edit refused inside them). Hooks run from the skill
directory with VOLCANIC_WORKSPACE, VOLCANIC_SKILL_DIR and the chat's TZ; waits
are bounded by each hook's timeout and failures never break a run. /skills
shows declared hooks and broken fields.
2026-10-16 12:27:44 +00:00

373 lines
22 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.Abstractions.Skills;
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>Each 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, await SkillsAsync(chat, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false);
return true;
case "jobs":
await ReplyAsync(chat, await JobsAsync(chat, cancellationToken).ConfigureAwait(false), 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;
}
/// <summary>
/// Every skill directory (SKILL.md, INSTRUCTIONS.md or jobs/, in any combination), with what it holds (hooks and
/// protected paths included); a broken hook field or job file is listed with its problem (job files: scheduler on).
/// </summary>
private async Task<string> SkillsAsync(ChatContext chat, CancellationToken cancellationToken)
{
var cwd = chat.Session.Cwd;
var where = $"`{Path.GetRelativePath(cwd, SkillCatalog.Directory(cwd))}/<name>/`";
var dirs = SkillFiles.Directories(cwd).Where(SkillFiles.IsSkill).ToList();
if (dirs.Count == 0) return $"No skills yet. Ask me to write one, or put its SKILL.md, INSTRUCTIONS.md or jobs/ in {where}.";
var skills = SkillCatalog.Load(cwd).ToDictionary(s => Path.GetDirectoryName(s.Path)!, StringComparer.Ordinal);
var instructions = SkillCatalog.LoadInstructions(cwd).ToDictionary(i => Path.GetDirectoryName(i.Path)!, StringComparer.Ordinal);
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var jobs = scheduler is { IsStarted: true } ? await scheduler.SyncSkillJobsAsync(target, cancellationToken).ConfigureAwait(false) : [];
var lines = new List<string>();
foreach (var dir in dirs)
{
var name = Path.GetFileName(dir);
var jobCount = SkillFiles.JobPaths(dir).Count;
var jobsText = jobCount == 1 ? "1 job" : $"{jobCount.ToString(CultureInfo.InvariantCulture)} jobs";
var hasInstructions = instructions.TryGetValue(dir, out var i);
var instructionsText = i is { Truncated: true } ? "INSTRUCTIONS.md, truncated in the prompt" : "INSTRUCTIONS.md";
var hooks = SkillHookFiles.Read(cwd, dir);
var hooksText = hooks?.Summary() ?? "";
if (skills.TryGetValue(dir, out var skill))
{
var extras = new List<string>();
if (hasInstructions) extras.Add(instructionsText);
if (jobCount > 0) extras.Add(jobsText);
if (hooksText.Length > 0) extras.Add(hooksText);
lines.Add($"• **{skill.Name}** – {skill.Description}" + (extras.Count > 0 ? $" (+ {string.Join(", ", extras)})" : ""));
}
else
{
var parts = new List<string>();
if (hasInstructions) parts.Add(instructionsText);
if (jobCount > 0) parts.Add(jobsText);
if (hooksText.Length > 0) parts.Add(hooksText);
if (parts.Count == 0) parts.Add("SKILL.md without a description (not listed to me)");
lines.Add($"• **{name}** – {string.Join(", ", parts)}");
}
foreach (var error in hooks?.Errors ?? [])
lines.Add($" ⚠️ {Path.GetRelativePath(cwd, hooks!.SkillFile)}: {error}");
foreach (var broken in jobs.Where(j => j.Status is SkillJobStatus.Invalid or SkillJobStatus.OverLimit && j.Id.StartsWith(name + "/", StringComparison.Ordinal)))
lines.Add($" ⚠️ {broken.File}: {broken.Problem}");
}
return $"**Skills** ({where})\n" + string.Join('\n', lines);
}
private async Task<string> JobsAsync(ChatContext chat, CancellationToken cancellationToken)
{
if (scheduler is not { IsStarted: true }) return SchedulerSetup.DisabledReply;
var target = TelegramJobDelivery.TargetOf(chat.ChatId);
var skillJobs = await scheduler.SyncSkillJobsAsync(target, cancellationToken).ConfigureAwait(false);
var jobs = scheduler.List(target);
if (jobs.Count == 0 && skillJobs.Count == 0) return "No scheduled jobs.";
var zone = scheduler.TimeZoneFor(target);
var now = scheduler.Now;
var text = $"**Scheduled jobs** ({JobFormat.ZoneName(zone)}, now {JobFormat.DayTime(now, zone)})\n" +
(jobs.Count == 0 ? "none" : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now))));
if (skillJobs.Count > 0)
text += "\n\n**Skill jobs** (`.volcanic/skills/<skill>/jobs/`, edit the files to change them)\n" +
string.Join('\n', skillJobs.Select(j => JobFormat.SkillLine(j, zone, now)));
return text;
}
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);
if (scheduler.FindSkillJob(target, arg) is { } skillJob) return JobFormat.SkillCancelHint(skillJob);
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()}**.";
}
}