forked from admin/VolcanicAgenticCore
feat(scheduler): schedule tool and per-session SchedulerExtension
This commit is contained in:
@@ -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);
|
||||
|
||||
/// <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 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<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: 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<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
|
||||
{
|
||||
"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<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 = 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));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Extensions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Per session: registers the <c>schedule</c> tool bound to the host's <paramref name="target"/> (a Telegram chat id).
|
||||
/// The host adds it through <c>AgentSessionOptions.ExtraExtensions</c>. Loaded through discovery without a
|
||||
/// host-registered <see cref="JobScheduler"/>, its constructor can't be resolved: an ordinary extension load error.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<SchedulerHarness> 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<ToolResult> 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<ToolExecutionException>(() => 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<ToolExecutionException>(() => 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<InvalidOperationException>(() => ActivatorUtilities.CreateInstance(provider, typeof(SchedulerExtension)));
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler).</summary>
|
||||
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) { }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user