forked from admin/VolcanicAgenticCore
Skill directories may now declare prompt or watcher jobs as files (cron or one-off at). The file is the source of truth: the scheduler re-reads every chat's jobs/ every minute, around agent runs and before /jobs and /skills, and schedules, re-plans or removes them. Ids are <skill>/<job>; run state lives in .volcanic/scheduler/skill-jobs-state.json, never in jobs.json. Watchers run from the skill directory with VOLCANIC_WORKSPACE and VOLCANIC_SKILL_DIR. Broken files are shown with the reason in /jobs, /skills, schedule list and the new <skill_jobs> prompt section; cancel answers with the file to edit. Every part of a skill directory is optional; file reading and frontmatter parsing move to Abstractions.
103 lines
6.2 KiB
C#
103 lines
6.2 KiB
C#
using System.ComponentModel;
|
|
using System.Text;
|
|
using Volcanic.AgenticCore.Abstractions.Tools;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
|
|
|
public sealed record ScheduleArgs(
|
|
[property: Description("create, list or cancel")] string Action,
|
|
[property: Description("cancel: the job id (see list)")] string? Id = null,
|
|
[property: Description("create: a short label, e.g. \"Call the dentist\"")] string? Name = null,
|
|
[property: Description("create: prompt (text you handle when it fires, e.g. a reminder you pass on) or watcher (a script; you get its output when it prints something)")] string? Kind = null,
|
|
[property: Description("create, one-off: ISO 8601 date-time; without an offset it's the chat's local time")] string? At = null,
|
|
[property: Description("create, recurring: 5-field cron in the chat's zone (minute hour day-of-month month day-of-week), e.g. \"0 8 * * 1-5\"")] string? Cron = null,
|
|
[property: Description("create, prompt: the text (at most 4000 characters)")] string? Text = null,
|
|
[property: Description("create, watcher: shell command run from the workspace")] string? Command = null,
|
|
[property: Description("create, watcher: timeout in seconds, 1-3600 (default 300)")] int? TimeoutSeconds = null);
|
|
|
|
/// <summary>The model's view of the scheduler, bound to one target (chat). It never sees another target's jobs.</summary>
|
|
public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool<ScheduleArgs>
|
|
{
|
|
public const string ToolName = "schedule";
|
|
|
|
public override string Name => ToolName;
|
|
|
|
public override string Description =>
|
|
"Create, list or cancel this chat's scheduled jobs: one-off or recurring prompts (you handle them when they fire, e.g. to remind the user) and watcher scripts (run without you; their output comes to you only when they print something). list also shows skill jobs, which are files (see <skill_jobs>) and can't be created or cancelled here.";
|
|
|
|
public override string? PromptSnippet => "schedule: one-off and recurring prompts and watcher scripts that message this chat";
|
|
|
|
public override IReadOnlyList<string> PromptGuidelines =>
|
|
[
|
|
"schedule: times are in the chat's time zone (see the environment section); at is ISO 8601, cron has 5 fields (minute hour day-of-month month day-of-week).",
|
|
"schedule: for a reminder use kind prompt with text telling you what to remind the user of; when it fires, write the reminder yourself.",
|
|
"schedule: a watcher's command runs with the same shell as bash, from the workspace, without you. It should print nothing and exit 0 while the condition isn't met (that costs nothing), and print the facts only when it is met: the output comes back to you as a prompt and you write the message. Exit non-zero only on a real error.",
|
|
"schedule: put reusable scripts in scripts/ and test them with bash before scheduling.",
|
|
];
|
|
|
|
protected override async Task<ToolResult> ExecuteAsync(ScheduleArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return (args.Action ?? "").Trim().ToLowerInvariant() switch
|
|
{
|
|
"create" => await CreateAsync(args, cancellationToken).ConfigureAwait(false),
|
|
"list" => ToolResult.Text(await ListAsync(cancellationToken).ConfigureAwait(false)),
|
|
"cancel" => await CancelAsync(args.Id, cancellationToken).ConfigureAwait(false),
|
|
_ => throw Error("action must be create, list or cancel."),
|
|
};
|
|
}
|
|
catch (JobValidationException ex)
|
|
{
|
|
throw Error(ex.Message);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
throw Error(ex.Message); // e.g. the scheduler isn't running
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> CreateAsync(ScheduleArgs args, CancellationToken cancellationToken)
|
|
{
|
|
var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch
|
|
{
|
|
"prompt" => JobKind.Prompt,
|
|
"watcher" => JobKind.Watcher,
|
|
_ => throw new JobValidationException("kind must be prompt or watcher."),
|
|
};
|
|
var view = await scheduler.CreateAsync(target, new NewJob(args.Name ?? "", kind, args.At, args.Cron, args.Text, args.Command, args.TimeoutSeconds), cancellationToken).ConfigureAwait(false);
|
|
var zone = scheduler.TimeZoneFor(target);
|
|
var now = scheduler.Now;
|
|
var sb = new StringBuilder("Created: ").Append(JobFormat.Line(view, zone, now));
|
|
if (view.NextRunAt is { } next) sb.Append("\nNext run: ").Append(JobFormat.Stamp(next, zone)).Append(" (").Append(JobFormat.ZoneName(zone)).Append(')');
|
|
sb.Append('\n').Append(NowLine(zone, now));
|
|
return ToolResult.Text(sb.ToString());
|
|
}
|
|
|
|
private async Task<string> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
var skillJobs = await scheduler.SyncSkillJobsAsync(target, cancellationToken).ConfigureAwait(false);
|
|
var zone = scheduler.TimeZoneFor(target);
|
|
var now = scheduler.Now;
|
|
var jobs = scheduler.List(target);
|
|
var body = jobs.Count == 0 ? "No scheduled jobs." : string.Join('\n', jobs.Select(j => JobFormat.Line(j, zone, now)));
|
|
if (skillJobs.Count > 0)
|
|
body += "\nSkill jobs (change them by editing their files):\n" + string.Join('\n', skillJobs.Select(j => JobFormat.SkillLine(j, zone, now, withFile: true)));
|
|
return body + "\n" + NowLine(zone, now);
|
|
}
|
|
|
|
private async Task<ToolResult> CancelAsync(string? id, CancellationToken cancellationToken)
|
|
{
|
|
var key = id?.Trim().ToLowerInvariant() ?? "";
|
|
if (key.Length == 0) throw Error("cancel needs id (see action=list).");
|
|
if (scheduler.FindSkillJob(target, id!) is { } skillJob) throw Error(JobFormat.SkillCancelHint(skillJob));
|
|
var job = await scheduler.CancelJobAsync(target, key, cancellationToken).ConfigureAwait(false)
|
|
?? throw Error($"No job {key} in this chat.");
|
|
return ToolResult.Text($"Cancelled {job.Id} ({job.Name}).");
|
|
}
|
|
|
|
private static string NowLine(TimeZoneInfo zone, DateTimeOffset now) => $"Now: {JobFormat.Stamp(now, zone)} ({JobFormat.ZoneName(zone)})";
|
|
|
|
private static ToolExecutionException Error(string message) => new(ToolResult.Text(message));
|
|
}
|