From a0d886eef305c249c0348320241f5b49a9b6838f Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 10:23:10 +0200 Subject: [PATCH] feat(scheduler): schedule tool and per-session SchedulerExtension --- .../ScheduleTool.cs | 100 ++++++++++++++++ .../SchedulerExtension.cs | 22 ++++ .../Scheduler/ScheduleToolTests.cs | 112 ++++++++++++++++++ .../Scheduler/Support/SchedulerFakes.cs | 26 ++++ 4 files changed, 260 insertions(+) create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs create mode 100644 extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerExtension.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs new file mode 100644 index 0000000..f109f1b --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/ScheduleTool.cs @@ -0,0 +1,100 @@ +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: reminder (fixed text sent as is), prompt (text you handle when it fires) 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, reminder/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); + +/// The model's view of the scheduler, bound to one target (chat). It never sees another target's jobs. +public sealed class ScheduleTool(JobScheduler scheduler, string target) : Tool +{ + 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 reminders (fixed text), prompts (you handle them when they fire) and watcher scripts (run without you; their output comes to you only when they print something)."; + + public override string? PromptSnippet => "schedule: reminders, recurring prompts and watcher scripts that message this chat"; + + public override IReadOnlyList 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: use kind reminder for fixed text and prompt when the job needs you to think or use tools.", + "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 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 CreateAsync(ScheduleArgs args, CancellationToken cancellationToken) + { + var kind = (args.Kind ?? "").Trim().ToLowerInvariant() switch + { + "reminder" => JobKind.Reminder, + "prompt" => JobKind.Prompt, + "watcher" => JobKind.Watcher, + _ => throw new JobValidationException("kind must be reminder, 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 CancelAsync(string? id, CancellationToken cancellationToken) + { + var key = id?.Trim().ToLowerInvariant() ?? ""; + if (key.Length == 0) throw Error("cancel needs id (see action=list)."); + var job = scheduler.List(target).FirstOrDefault(v => v.Job.Id == key)?.Job; + if (job is null || !await scheduler.CancelAsync(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)); +} diff --git a/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerExtension.cs b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerExtension.cs new file mode 100644 index 0000000..28a61ff --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SchedulerExtension.cs @@ -0,0 +1,22 @@ +using Volcanic.AgenticCore.Abstractions.Extensions; + +namespace Volcanic.AgenticCore.Extensions.Scheduler; + +/// +/// Per session: registers the schedule tool bound to the host's (a Telegram chat id). +/// The host adds it through AgentSessionOptions.ExtraExtensions. Loaded through discovery without a +/// host-registered , its constructor can't be resolved: an ordinary extension load error. +/// +public sealed class SchedulerExtension(JobScheduler scheduler, string target) : IExtension +{ + public const string ExtensionName = "scheduler"; + + public string Name => ExtensionName; + + public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(api); + api.RegisterTool(new ScheduleTool(scheduler, target)); + return ValueTask.CompletedTask; + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs new file mode 100644 index 0000000..c3b7c8f --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/ScheduleToolTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.DependencyInjection; +using Volcanic.AgenticCore.Abstractions.Json; +using Volcanic.AgenticCore.Abstractions.Messages; +using Volcanic.AgenticCore.Abstractions.Tools; +using Volcanic.AgenticCore.Extensions.Scheduler; +using Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support; + +namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler; + +public sealed class ScheduleToolTests +{ + private static async Task StartedAsync() + { + var h = new SchedulerHarness(); // Sat 2026-09-26 12:03Z + h.Delivery.Zones["111"] = SchedulerHarness.Berlin; + h.Delivery.Zones["222"] = SchedulerHarness.Tokyo; + await h.StartAsync(); + return h; + } + + private static Task Run(SchedulerHarness h, string target, object args) => + new ScheduleTool(h.Scheduler, target).ExecuteAsync( + new ToolCallContent("call-1", ScheduleTool.ToolName, VolcanicJson.ToElement(args)), + new ToolInvocation("call-1", new SchedulerStubContext(), null), CancellationToken.None); + + [Fact] + public async Task Create_list_and_cancel_through_the_tool() + { + await using var h = await StartedAsync(); + + var created = await Run(h, "111", new { action = "create", name = "Call the dentist", kind = "reminder", at = "2026-09-27T09:00", text = "Call the dentist" }); + var id = Assert.Single(h.Scheduler.List("111")).Job.Id; + Assert.Equal( + $"Created: `{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNext run: Sun 2026-09-27 09:00 (Europe/Berlin)\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", + created.TextContent); + + var listed = await Run(h, "111", new { action = "list" }); + Assert.Equal($"`{id}` ⏰ Call the dentist · once 27 Sep 09:00\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", listed.TextContent); + + var cancelled = await Run(h, "111", new { action = "cancel", id }); + Assert.Equal($"Cancelled {id} (Call the dentist).", cancelled.TextContent); + Assert.Equal("No scheduled jobs.\nNow: Sat 2026-09-26 14:03 (Europe/Berlin)", (await Run(h, "111", new { action = "list" })).TextContent); + } + + [Theory] + [InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"0 0 9 * * *","text":"t"}""", "seconds aren't supported")] + [InlineData("""{"action":"create","name":"x","kind":"reminder","at":"2026-09-26T13:00","text":"t"}""", "is in the past")] + [InlineData("""{"action":"create","name":"x","kind":"reminder","cron":"@daily"}""", "A reminder needs text")] + [InlineData("""{"action":"create","name":"x","kind":"alarm","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")] + [InlineData("""{"action":"create","name":"x","cron":"@daily","text":"t"}""", "kind must be reminder, prompt or watcher.")] + [InlineData("""{"action":"snooze"}""", "action must be create, list or cancel.")] + [InlineData("""{"action":"cancel"}""", "cancel needs id (see action=list).")] + [InlineData("""{"action":"cancel","id":"zzzzz"}""", "No job zzzzz in this chat.")] + public async Task Validation_errors_become_error_results(string json, string fragment) + { + await using var h = await StartedAsync(); + var tool = new ScheduleTool(h.Scheduler, "111"); + + var ex = await Assert.ThrowsAsync(() => tool.ExecuteAsync( + new ToolCallContent("c", ScheduleTool.ToolName, VolcanicJson.Parse(json)), new ToolInvocation("c", new SchedulerStubContext(), null), CancellationToken.None)); + + Assert.Contains(fragment, ex.Message); + Assert.Empty(h.Scheduler.List("111")); + } + + [Fact] + public async Task List_and_cancel_never_see_another_targets_jobs() + { + await using var h = await StartedAsync(); + await Run(h, "222", new { action = "create", name = "Theirs", kind = "reminder", cron = "@daily", text = "t" }); + var theirs = Assert.Single(h.Scheduler.List("222")).Job.Id; + + Assert.StartsWith("No scheduled jobs.", (await Run(h, "111", new { action = "list" })).TextContent); + var ex = await Assert.ThrowsAsync(() => Run(h, "111", new { action = "cancel", id = theirs })); + Assert.Equal($"No job {theirs} in this chat.", ex.Message); + Assert.Single(h.Scheduler.List("222")); + } + + [Fact] + public async Task The_now_line_uses_the_targets_zone() + { + await using var h = await StartedAsync(); + Assert.EndsWith("Now: Sat 2026-09-26 21:03 (Asia/Tokyo)", (await Run(h, "222", new { action = "list" })).TextContent); + } + + [Fact] + public async Task An_offset_less_at_is_read_in_the_targets_zone() + { + await using var h = await StartedAsync(); + await Run(h, "222", new { action = "create", name = "Tokyo", kind = "prompt", at = "2026-09-27T09:00", text = "Good morning" }); + Assert.Equal(new DateTimeOffset(2026, 9, 27, 0, 0, 0, TimeSpan.Zero), Assert.Single(h.Scheduler.List("222")).Job.Schedule.At); + } + + [Fact] + public async Task Snippet_and_guidelines_match_the_spec() + { + await using var h = await StartedAsync(); + var tool = new ScheduleTool(h.Scheduler, "111"); + Assert.Equal("schedule", tool.Name); + Assert.Equal("schedule: reminders, recurring prompts and watcher scripts that message this chat", tool.PromptSnippet); + Assert.Equal(4, tool.PromptGuidelines.Count); + Assert.DoesNotContain(tool.PromptGuidelines, g => g.Contains("Europe/", StringComparison.Ordinal)); // zone-free, so the prompt text is stable + Assert.Contains(tool.PromptGuidelines, g => g.Contains("scripts/", StringComparison.Ordinal)); + } + + [Fact] + public void Discovery_without_a_host_scheduler_cannot_construct_the_extension() + { + using var provider = new ServiceCollection().BuildServiceProvider(); + Assert.Throws(() => ActivatorUtilities.CreateInstance(provider, typeof(SchedulerExtension))); + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs index 2ca5623..d90a2e7 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs @@ -1,6 +1,11 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; +using Volcanic.AgenticCore.Abstractions.Extensions; using Volcanic.AgenticCore.Abstractions.Processes; +using Volcanic.AgenticCore.Abstractions.Providers; +using Volcanic.AgenticCore.Abstractions.Sessions; +using Volcanic.AgenticCore.Abstractions.Ui; using Volcanic.AgenticCore.Extensions.Scheduler; namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support; @@ -153,3 +158,24 @@ internal sealed class SchedulerHarness : IAsyncDisposable try { Directory.Delete(Root, recursive: true); } catch (IOException) { } } } + +/// The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler). +internal sealed class SchedulerStubContext : IExtensionContext +{ + public string Cwd => Path.GetTempPath(); + public string SessionId => "scheduler-test"; + public bool HasUi => false; + public IUserInterface Ui => NullUserInterface.Instance; + public IReadOnlySessionManager SessionManager => throw new NotSupportedException(); + public IModelRegistry ModelRegistry => throw new NotSupportedException(); + public Model? Model => null; + public ThinkingLevel ThinkingLevel => ThinkingLevel.Off; + public CancellationToken Signal => CancellationToken.None; + public ILogger Logger => NullLogger.Instance; + public bool IsIdle() => true; + public void Abort() { } + public bool HasPendingMessages() => false; + public ContextUsage? GetContextUsage() => null; + public string GetSystemPrompt() => ""; + public void Compact(CompactOptions? options = null) { } +}