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.
373 lines
22 KiB
C#
373 lines
22 KiB
C#
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()}**.";
|
||
}
|
||
}
|