Files
Playa/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
2026-09-29 09:30:40 +00:00

99 lines
5.6 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).";
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(List()),
"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 string List()
{
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)));
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).");
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));
}