From be0749e4de98cd961638c87aa81751347762747a Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:22:58 +0330 Subject: [PATCH 01/13] feat(pkg/tools/builtin/scheduler/schedule.go): add schedule parser for one-shot and recurring triggers --- pkg/tools/builtin/scheduler/schedule.go | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 pkg/tools/builtin/scheduler/schedule.go diff --git a/pkg/tools/builtin/scheduler/schedule.go b/pkg/tools/builtin/scheduler/schedule.go new file mode 100644 index 000000000..40282039c --- /dev/null +++ b/pkg/tools/builtin/scheduler/schedule.go @@ -0,0 +1,65 @@ +package scheduler + +import ( + "fmt" + "strings" + "time" +) + +var presets = map[string]time.Duration{ + "minutely": time.Minute, + "hourly": time.Hour, + "daily": 24 * time.Hour, + "weekly": 7 * 24 * time.Hour, +} + +func parseWhen(when string, now time.Time) (next time.Time, interval time.Duration, err error) { + s := strings.TrimSpace(when) + if s == "" { + return time.Time{}, 0, fmt.Errorf("empty schedule spec") + } + + if d, ok := presets[strings.ToLower(s)]; ok { + return now.Add(d), d, nil + } + + switch { + case hasPrefixFold(s, "in:"): + d, err := time.ParseDuration(strings.TrimSpace(s[len("in:"):])) + if err != nil { + return time.Time{}, 0, fmt.Errorf("invalid delay in %q: %w", when, err) + } + if d <= 0 { + return time.Time{}, 0, fmt.Errorf("delay must be positive in %q", when) + } + return now.Add(d), 0, nil + + case hasPrefixFold(s, "every:"): + d, err := time.ParseDuration(strings.TrimSpace(s[len("every:"):])) + if err != nil { + return time.Time{}, 0, fmt.Errorf("invalid interval in %q: %w", when, err) + } + if d <= 0 { + return time.Time{}, 0, fmt.Errorf("interval must be positive in %q", when) + } + return now.Add(d), d, nil + + case hasPrefixFold(s, "at:"): + ts, err := time.Parse(time.RFC3339, strings.TrimSpace(s[len("at:"):])) + if err != nil { + return time.Time{}, 0, fmt.Errorf("invalid RFC3339 time in %q: %w", when, err) + } + if !ts.After(now) { + return time.Time{}, 0, fmt.Errorf("scheduled time %s is not in the future", ts.Format(time.RFC3339)) + } + return ts, 0, nil + + default: + return time.Time{}, 0, fmt.Errorf( + "unrecognized schedule %q: use in:, at:, every:, or minutely/hourly/daily/weekly", when) + } +} + +func hasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) +} From 1335e5d82bfa546fb105bf25e6bef1eb84fd5438 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:24:11 +0330 Subject: [PATCH 02/13] feat(pkg/tools/builtin/scheduler/store.go): add in-memory schedule store with recurring rearming --- pkg/tools/builtin/scheduler/store.go | 132 +++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 pkg/tools/builtin/scheduler/store.go diff --git a/pkg/tools/builtin/scheduler/store.go b/pkg/tools/builtin/scheduler/store.go new file mode 100644 index 000000000..5ad5e7c0c --- /dev/null +++ b/pkg/tools/builtin/scheduler/store.go @@ -0,0 +1,132 @@ +package scheduler + +import ( + "fmt" + "slices" + "strings" + "sync" + "time" +) + +type Schedule struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Prompt string `json:"prompt"` + Spec string `json:"spec"` + Interval time.Duration `json:"-"` + NextFire time.Time `json:"next_fire"` +} + +func (s Schedule) Recurring() bool { return s.Interval > 0 } + +type store struct { + mu sync.Mutex + seq int + byID map[string]*Schedule +} + +func newStore() *store { + return &store{byID: make(map[string]*Schedule)} +} + +func (s *store) add(name, prompt, when string, now time.Time) (Schedule, error) { + next, interval, err := parseWhen(when, now) + if err != nil { + return Schedule{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.seq++ + sc := &Schedule{ + ID: fmt.Sprintf("sched-%d", s.seq), + Name: name, + Prompt: prompt, + Spec: strings.TrimSpace(when), + Interval: interval, + NextFire: next, + } + s.byID[sc.ID] = sc + return *sc, nil +} + +func (s *store) list() []Schedule { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]Schedule, 0, len(s.byID)) + for _, sc := range s.byID { + out = append(out, *sc) + } + sortSchedules(out) + return out +} + +func (s *store) cancel(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.byID[id]; !ok { + return false + } + delete(s.byID, id) + return true +} + +func (s *store) popDue(now time.Time) []Schedule { + s.mu.Lock() + defer s.mu.Unlock() + + var fired []Schedule + for id, sc := range s.byID { + if sc.NextFire.After(now) { + continue + } + fired = append(fired, *sc) + if sc.Interval > 0 { + next := sc.NextFire.Add(sc.Interval) + for !next.After(now) { + next = next.Add(sc.Interval) + } + sc.NextFire = next + } else { + delete(s.byID, id) + } + } + sortSchedules(fired) + return fired +} + +func (s *store) untilNext(now time.Time) (time.Duration, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + var soonest time.Time + found := false + for _, sc := range s.byID { + if !found || sc.NextFire.Before(soonest) { + soonest = sc.NextFire + found = true + } + } + if !found { + return 0, false + } + if d := soonest.Sub(now); d > 0 { + return d, true + } + return 0, true +} + +func sortSchedules(list []Schedule) { + slices.SortFunc(list, func(a, b Schedule) int { + if a.NextFire.Equal(b.NextFire) { + return strings.Compare(a.ID, b.ID) + } + if a.NextFire.Before(b.NextFire) { + return -1 + } + return 1 + }) +} From 086d31bd4d6eade8b2725bdf9ac3d41248d82925 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:25:33 +0330 Subject: [PATCH 03/13] feat(pkg/tools/builtin/scheduler/scheduler.go): add scheduler toolset and runtime recall support --- pkg/tools/builtin/scheduler/scheduler.go | 254 +++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 pkg/tools/builtin/scheduler/scheduler.go diff --git a/pkg/tools/builtin/scheduler/scheduler.go b/pkg/tools/builtin/scheduler/scheduler.go new file mode 100644 index 000000000..9b412d172 --- /dev/null +++ b/pkg/tools/builtin/scheduler/scheduler.go @@ -0,0 +1,254 @@ +package scheduler + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/docker/docker-agent/pkg/tools" +) + +const ( + ToolNameCreateSchedule = "create_schedule" + ToolNameListSchedules = "list_schedules" + ToolNameCancelSchedule = "cancel_schedule" + + category = "scheduler" + loopMaxWait = time.Minute +) + +func CreateToolSet() (tools.ToolSet, error) { + return New(), nil +} + +type ToolSet struct { + store *store + now func() time.Time + wake chan struct{} + + mu sync.Mutex + rt tools.Runtime + cancel context.CancelFunc + wg sync.WaitGroup +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Startable = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New() *ToolSet { + return &ToolSet{ + store: newStore(), + now: time.Now, + wake: make(chan struct{}, 1), + } +} + +type CreateScheduleArgs struct { + Prompt string `json:"prompt" jsonschema:"The instruction to deliver to the agent when the schedule fires"` + When string `json:"when" jsonschema:"When to fire: in: (e.g. in:10m), at: (e.g. at:2026-07-14T09:00:00Z), every: (e.g. every:1h), or one of minutely, hourly, daily, weekly"` + Name string `json:"name,omitempty" jsonschema:"Optional human-readable label for the schedule"` +} + +type ListSchedulesArgs struct{} + +type CancelScheduleArgs struct { + ID string `json:"id" jsonschema:"The id of the schedule to cancel (from create_schedule or list_schedules)"` +} + +func (t *ToolSet) createSchedule(_ context.Context, args CreateScheduleArgs, rt tools.Runtime) (*tools.ToolCallResult, error) { + if strings.TrimSpace(args.Prompt) == "" { + return tools.ResultError("Error: prompt is required."), nil + } + if !rt.Supports(tools.CapabilityRecall) { + return tools.ResultError("Error: scheduling requires host recall support, which is unavailable in this session."), nil + } + + sc, err := t.store.add(args.Name, args.Prompt, args.When, t.now()) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + + t.setRuntime(rt) + t.signalWake() + + return tools.ResultSuccess(formatCreated(sc)), nil +} + +func (t *ToolSet) listSchedules(_ context.Context, _ ListSchedulesArgs) (*tools.ToolCallResult, error) { + return tools.ResultSuccess(formatList(t.store.list())), nil +} + +func (t *ToolSet) cancelSchedule(_ context.Context, args CancelScheduleArgs) (*tools.ToolCallResult, error) { + if t.store.cancel(args.ID) { + return tools.ResultSuccess("Cancelled schedule " + args.ID + "."), nil + } + return tools.ResultError("Error: no schedule with id " + args.ID + "."), nil +} + +func (t *ToolSet) fireDue(ctx context.Context, now time.Time) { + rt := t.runtime() + for _, sc := range t.store.popDue(now) { + if rt == nil { + continue + } + if err := rt.Recall(ctx, formatFire(sc)); err != nil { + return + } + } +} + +func (t *ToolSet) Start(context.Context) error { + ctx, cancel := context.WithCancel(context.Background()) + t.mu.Lock() + t.cancel = cancel + t.mu.Unlock() + + t.wg.Add(1) + go t.loop(ctx) + return nil +} + +func (t *ToolSet) Stop(context.Context) error { + t.mu.Lock() + cancel := t.cancel + t.mu.Unlock() + if cancel != nil { + cancel() + } + t.wg.Wait() + return nil +} + +func (t *ToolSet) loop(ctx context.Context) { + defer t.wg.Done() + for { + wait := loopMaxWait + if d, ok := t.store.untilNext(t.now()); ok && d < wait { + wait = d + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-t.wake: + timer.Stop() + case <-timer.C: + t.fireDue(ctx, t.now()) + } + } +} + +func (t *ToolSet) signalWake() { + select { + case t.wake <- struct{}{}: + default: + } +} + +func (t *ToolSet) setRuntime(rt tools.Runtime) { + t.mu.Lock() + t.rt = rt + t.mu.Unlock() +} + +func (t *ToolSet) runtime() tools.Runtime { + t.mu.Lock() + defer t.mu.Unlock() + return t.rt +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: ToolNameCreateSchedule, + Category: category, + Description: "Schedule an instruction to be delivered back to you at a specific time or on a recurring interval. When it fires you receive the instruction and act on it with your normal tools.", + Parameters: tools.MustSchemaFor[CreateScheduleArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewRuntimeHandler(t.createSchedule), + Annotations: tools.ToolAnnotations{Title: "Create Schedule"}, + AddDescriptionParameter: true, + }, + { + Name: ToolNameListSchedules, + Category: category, + Description: "List all active schedules with their id, spec, and next fire time.", + Parameters: tools.MustSchemaFor[ListSchedulesArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.listSchedules), + Annotations: tools.ToolAnnotations{Title: "List Schedules", ReadOnlyHint: true}, + AddDescriptionParameter: true, + }, + { + Name: ToolNameCancelSchedule, + Category: category, + Description: "Cancel a schedule by its id.", + Parameters: tools.MustSchemaFor[CancelScheduleArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.cancelSchedule), + Annotations: tools.ToolAnnotations{Title: "Cancel Schedule"}, + AddDescriptionParameter: true, + }, + }, nil +} + +func (t *ToolSet) Instructions() string { + return `## Scheduler Tool + +Use the scheduler to make something happen at a chosen time or on a repeating +cadence during this session. + +- create_schedule(prompt, when, name?) registers a schedule. When it fires, its + prompt is delivered back to you as a message and you carry out the action with + your normal tools (shell, api, fetch, …). +- when accepts: in: (in:10m), at: (at:2026-07-14T09:00:00Z), + every: (every:1h), or minutely / hourly / daily / weekly. +- list_schedules shows active schedules; cancel_schedule removes one by id. + +Schedules only fire while this session is running and are not persisted across +restarts.` +} + +func formatCreated(sc Schedule) string { + var b strings.Builder + fmt.Fprintf(&b, "Scheduled %s: next fire at %s", label(sc), sc.NextFire.Format(time.RFC3339)) + if sc.Recurring() { + fmt.Fprintf(&b, ", then every %s", sc.Interval) + } + b.WriteString(".") + return b.String() +} + +func formatList(list []Schedule) string { + if len(list) == 0 { + return "No active schedules." + } + var b strings.Builder + fmt.Fprintf(&b, "%d active schedule(s):\n", len(list)) + for _, sc := range list { + recurring := "one-shot" + if sc.Recurring() { + recurring = "every " + sc.Interval.String() + } + fmt.Fprintf(&b, "- %s [%s] next: %s (%s)\n %s\n", + label(sc), sc.Spec, sc.NextFire.Format(time.RFC3339), recurring, sc.Prompt) + } + return b.String() +} + +func formatFire(sc Schedule) string { + return fmt.Sprintf("⏰ Scheduled task %s is due:\n%s", label(sc), sc.Prompt) +} + +func label(sc Schedule) string { + if sc.Name != "" { + return fmt.Sprintf("%q (%s)", sc.Name, sc.ID) + } + return sc.ID +} From d0087cfffd04deb9394738c0dadf9870d6c7a210 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:26:27 +0330 Subject: [PATCH 04/13] feat(pkg/tools/builtin/scheduler): add parser, store, tool handler, firing, and lifecycle tests --- pkg/tools/builtin/scheduler/schedule_test.go | 50 ++++++ pkg/tools/builtin/scheduler/scheduler_test.go | 164 ++++++++++++++++++ pkg/tools/builtin/scheduler/store_test.go | 108 ++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 pkg/tools/builtin/scheduler/schedule_test.go create mode 100644 pkg/tools/builtin/scheduler/scheduler_test.go create mode 100644 pkg/tools/builtin/scheduler/store_test.go diff --git a/pkg/tools/builtin/scheduler/schedule_test.go b/pkg/tools/builtin/scheduler/schedule_test.go new file mode 100644 index 000000000..932507d33 --- /dev/null +++ b/pkg/tools/builtin/scheduler/schedule_test.go @@ -0,0 +1,50 @@ +package scheduler + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var testNow = time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + +func TestParseWhen(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + when string + wantNext time.Time + wantInterval time.Duration + wantErr bool + }{ + {"delay", "in:10m", testNow.Add(10 * time.Minute), 0, false}, + {"at rfc3339", "at:2026-07-13T15:00:00Z", time.Date(2026, 7, 13, 15, 0, 0, 0, time.UTC), 0, false}, + {"every", "every:1h", testNow.Add(time.Hour), time.Hour, false}, + {"minutely", "minutely", testNow.Add(time.Minute), time.Minute, false}, + {"hourly", "hourly", testNow.Add(time.Hour), time.Hour, false}, + {"daily", "daily", testNow.Add(24 * time.Hour), 24 * time.Hour, false}, + {"weekly", "weekly", testNow.Add(7 * 24 * time.Hour), 7 * 24 * time.Hour, false}, + {"whitespace and case", " Hourly ", testNow.Add(time.Hour), time.Hour, false}, + {"empty", "", time.Time{}, 0, true}, + {"garbage", "sometimes", time.Time{}, 0, true}, + {"at in past", "at:2020-01-01T00:00:00Z", time.Time{}, 0, true}, + {"every zero", "every:0s", time.Time{}, 0, true}, + {"every negative", "every:-5m", time.Time{}, 0, true}, + {"in bad duration", "in:abc", time.Time{}, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + next, interval, err := parseWhen(tt.when, testNow) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantNext, next) + require.Equal(t, tt.wantInterval, interval) + }) + } +} diff --git a/pkg/tools/builtin/scheduler/scheduler_test.go b/pkg/tools/builtin/scheduler/scheduler_test.go new file mode 100644 index 000000000..3cb607355 --- /dev/null +++ b/pkg/tools/builtin/scheduler/scheduler_test.go @@ -0,0 +1,164 @@ +package scheduler + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tools" +) + +type fakeRuntime struct { + mu sync.Mutex + recalls []string + recall bool +} + +func (f *fakeRuntime) EmitOutput(context.Context, string) {} + +func (f *fakeRuntime) Recall(_ context.Context, msg string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.recalls = append(f.recalls, msg) + return nil +} + +func (f *fakeRuntime) Supports(c tools.Capability) bool { + return f.recall && c == tools.CapabilityRecall +} + +func (f *fakeRuntime) messages() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.recalls...) +} + +func newTestToolSet() *ToolSet { + ts := New() + ts.now = func() time.Time { return testNow } + return ts +} + +func TestCreateAndListSchedule(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true} + + res, err := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: "check build", When: "every:1h", Name: "ci"}, rt) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + + list, err := ts.listSchedules(context.Background(), ListSchedulesArgs{}) + require.NoError(t, err) + require.Contains(t, list.Output, "check build") + require.Contains(t, list.Output, "ci") +} + +func TestCreateScheduleRequiresRecall(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: false} + + res, err := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: "x", When: "hourly"}, rt) + require.NoError(t, err) + require.True(t, res.IsError) + require.Contains(t, strings.ToLower(res.Output), "recall") + require.Empty(t, ts.store.list()) +} + +func TestCreateScheduleInvalidWhen(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true} + + res, err := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: "x", When: "whenever"}, rt) + require.NoError(t, err) + require.True(t, res.IsError) + require.Empty(t, ts.store.list()) +} + +func TestCreateScheduleRequiresPrompt(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true} + + res, err := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: " ", When: "hourly"}, rt) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestFireDueCallsRecall(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true} + + _, err := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: "run backup", When: "every:1h", Name: "bkp"}, rt) + require.NoError(t, err) + + ts.fireDue(context.Background(), testNow.Add(30*time.Minute)) + require.Empty(t, rt.messages()) + + ts.fireDue(context.Background(), testNow.Add(time.Hour)) + msgs := rt.messages() + require.Len(t, msgs, 1) + require.Contains(t, msgs[0], "run backup") + require.Contains(t, msgs[0], "bkp") + require.Len(t, ts.store.list(), 1) +} + +func TestCancelScheduleTool(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true} + + res, _ := ts.createSchedule(context.Background(), + CreateScheduleArgs{Prompt: "x", When: "hourly"}, rt) + require.False(t, res.IsError) + id := ts.store.list()[0].ID + + cres, err := ts.cancelSchedule(context.Background(), CancelScheduleArgs{ID: id}) + require.NoError(t, err) + require.False(t, cres.IsError) + require.Empty(t, ts.store.list()) + + cres2, _ := ts.cancelSchedule(context.Background(), CancelScheduleArgs{ID: id}) + require.True(t, cres2.IsError) +} + +func TestToolSetImplementsInterfaces(t *testing.T) { + t.Parallel() + + var ts tools.ToolSet = New() + + _, ok := ts.(tools.Startable) + require.True(t, ok, "must implement tools.Startable") + _, ok = ts.(tools.Instructable) + require.True(t, ok, "must implement tools.Instructable") + + toolz, err := ts.Tools(context.Background()) + require.NoError(t, err) + require.Len(t, toolz, 3) +} + +func TestStartStopIsClean(t *testing.T) { + t.Parallel() + + ts := New() + require.NoError(t, ts.Start(context.Background())) + require.NoError(t, ts.Stop(context.Background())) +} diff --git a/pkg/tools/builtin/scheduler/store_test.go b/pkg/tools/builtin/scheduler/store_test.go new file mode 100644 index 000000000..fb71c1554 --- /dev/null +++ b/pkg/tools/builtin/scheduler/store_test.go @@ -0,0 +1,108 @@ +package scheduler + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestStoreAddAndList(t *testing.T) { + t.Parallel() + + s := newStore() + a, err := s.add("later", "do A", "in:10m", testNow) + require.NoError(t, err) + b, err := s.add("sooner", "do B", "in:1m", testNow) + require.NoError(t, err) + + require.NotEqual(t, a.ID, b.ID) + + list := s.list() + require.Len(t, list, 2) + require.Equal(t, b.ID, list[0].ID) + require.Equal(t, a.ID, list[1].ID) + require.Equal(t, testNow.Add(time.Minute), list[0].NextFire) +} + +func TestStoreAddInvalidSpec(t *testing.T) { + t.Parallel() + + s := newStore() + _, err := s.add("bad", "x", "whenever", testNow) + require.Error(t, err) + require.Empty(t, s.list()) +} + +func TestStoreCancel(t *testing.T) { + t.Parallel() + + s := newStore() + sc, err := s.add("x", "do x", "hourly", testNow) + require.NoError(t, err) + + require.False(t, s.cancel("nope")) + require.True(t, s.cancel(sc.ID)) + require.Empty(t, s.list()) + require.False(t, s.cancel(sc.ID)) +} + +func TestStorePopDueOneShot(t *testing.T) { + t.Parallel() + + s := newStore() + sc, err := s.add("once", "do once", "in:10m", testNow) + require.NoError(t, err) + + require.Empty(t, s.popDue(testNow.Add(9*time.Minute))) + fired := s.popDue(testNow.Add(10 * time.Minute)) + require.Len(t, fired, 1) + require.Equal(t, sc.ID, fired[0].ID) + require.Empty(t, s.list()) +} + +func TestStorePopDueRecurringReArms(t *testing.T) { + t.Parallel() + + s := newStore() + _, err := s.add("loop", "tick", "every:1h", testNow) + require.NoError(t, err) + + fired := s.popDue(testNow.Add(time.Hour)) + require.Len(t, fired, 1) + + require.Empty(t, s.popDue(testNow.Add(time.Hour))) + list := s.list() + require.Len(t, list, 1) + require.Equal(t, testNow.Add(2*time.Hour), list[0].NextFire) +} + +func TestStorePopDueSkipsMissedSlots(t *testing.T) { + t.Parallel() + + s := newStore() + _, err := s.add("loop", "tick", "every:1h", testNow) + require.NoError(t, err) + + fired := s.popDue(testNow.Add(3*time.Hour + 30*time.Minute)) + require.Len(t, fired, 1) + require.Equal(t, testNow.Add(4*time.Hour), s.list()[0].NextFire) +} + +func TestStoreUntilNext(t *testing.T) { + t.Parallel() + + s := newStore() + _, ok := s.untilNext(testNow) + require.False(t, ok) + + _, err := s.add("x", "do x", "in:5m", testNow) + require.NoError(t, err) + d, ok := s.untilNext(testNow) + require.True(t, ok) + require.Equal(t, 5*time.Minute, d) + + d, ok = s.untilNext(testNow.Add(10 * time.Minute)) + require.True(t, ok) + require.Equal(t, time.Duration(0), d) +} From ea240d383ed2b8c018e9505f24bf9fdb5fa5e300 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:26:55 +0330 Subject: [PATCH 05/13] feat(pkg/teamloader/toolsets/toolsets.go): register scheduler built-in toolset --- pkg/teamloader/toolsets/toolsets.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 79258250c..8bb7e6333 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/openurl" "github.com/docker/docker-agent/pkg/tools/builtin/plan" "github.com/docker/docker-agent/pkg/tools/builtin/rag" + "github.com/docker/docker-agent/pkg/tools/builtin/scheduler" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" "github.com/docker/docker-agent/pkg/tools/builtin/shell" @@ -58,6 +59,9 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "think": func(_ context.Context, _ latest.Toolset, _ string, _ *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return think.CreateToolSet() }, + "scheduler": func(_ context.Context, _ latest.Toolset, _ string, _ *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return scheduler.CreateToolSet() + }, "shell": func(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return shell.CreateToolSet(ctx, toolset, runConfig) }, From e7eaa73946f00da5b26e0865d1f913b00a870d26 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:29:02 +0330 Subject: [PATCH 06/13] docs(docs/configuration/tools/index.md): add scheduler to built-in toolset catalog --- docs/configuration/tools/index.md | 1 + pkg/teamloader/toolsets/catalog.go | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index 57e683e44..a4526107f 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -20,6 +20,7 @@ Built-in tools are included with docker-agent and require no external dependenci | `filesystem` | Read, write, list, search, navigate | [Filesystem](../../tools/filesystem/index.md) | | `shell` | Execute shell commands synchronously | [Shell](../../tools/shell/index.md) | | `background_jobs` | Run and manage long-running shell commands | [Background Jobs](../../tools/background-jobs/index.md) | +| `scheduler` | Schedule instructions to run at a time or on a recurring interval | [Scheduler](../../tools/scheduler/index.md) | | `think` | Reasoning scratchpad | [Think](../../tools/think/index.md) | | `plan` | Shared persistent scratchpad for multi-agent collaboration | [Plan](../../tools/plan/index.md) | | `session_plan` | Per-session markdown plan for the draft-review-execute workflow | [Session Plan](../../tools/session_plan/index.md) | diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index b6cc9770d..7f0e2db41 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -24,6 +24,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("openapi", "openapi", "Generate tools automatically from an OpenAPI specification"), builtinToolset("plan", "plan", "Shared persistent scratchpad for multi-agent collaboration"), builtinToolset("rag", "rag", "Search document knowledge bases with hybrid retrieval (RAG)"), + builtinToolset("scheduler", "scheduler", "Schedule instructions to run at a time or on a recurring interval"), builtinToolset("script", "script", "Define custom shell scripts as named tools with typed parameters"), builtinToolset("session_context", "session_context", "Reference a previous session as context in the current one"), builtinToolset("session_plan", "session_plan", "Per-session plan tracker for the draft/review/execute workflow"), From 4967597e98d26e942ddabbddcdaecea726cea77c Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Mon, 13 Jul 2026 21:29:50 +0330 Subject: [PATCH 07/13] docs(docs/tools/scheduler/index.md): add scheduler toolset documentation --- docs/tools/scheduler/index.md | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/tools/scheduler/index.md diff --git a/docs/tools/scheduler/index.md b/docs/tools/scheduler/index.md new file mode 100644 index 000000000..792c7ea76 --- /dev/null +++ b/docs/tools/scheduler/index.md @@ -0,0 +1,87 @@ +--- +title: "Scheduler Tool" +description: "Schedule instructions to run at a time or on a recurring interval." +keywords: docker agent, ai agents, tools, toolsets, scheduler tool, cron +linkTitle: "Scheduler" +weight: 135 +canonical: https://docs.docker.com/ai/docker-agent/tools/scheduler/ +--- + +_Schedule instructions to run at a time or on a recurring interval._ + +## Overview + +The scheduler toolset lets an agent make something happen at a chosen time or on a repeating cadence during a session. You give it an instruction and a schedule; when the schedule is due, the instruction is delivered back to the agent, which then carries out the action with its normal tools (`shell`, `api`, `fetch`, and so on). + +The scheduler does not run shell or API calls itself. When a schedule fires it injects the instruction into the agent loop via the runtime's recall mechanism — the same primitive [`background_jobs`](../background-jobs/index.md) uses to report completed work — and the agent decides how to act. This keeps every action under the agent's normal tools and permissions rather than adding a second, unattended +command runner. + +> [!NOTE] +> Schedules only fire while the session is running (interactive TUI or a server mode) and are not persisted across restarts. Scheduling requires a host that supports recall; if it does not, `create_schedule` returns an error. + +## Configuration + +```yaml +toolsets: + - type: scheduler +``` + +No configuration options. + +## Tools + +| Tool | Description | +| --- | --- | +| `create_schedule` | Register an instruction to run at a time or interval. | +| `list_schedules` | List active schedules with their id, spec, and next fire time. | +| `cancel_schedule` | Remove a schedule by id. | + +### `create_schedule` + +| Parameter | Required | Description | +| --- | --- | --- | +| `prompt` | Yes | The instruction to deliver to the agent when the schedule fires. | +| `when` | Yes | When to fire (see [Schedule specs](#schedule-specs)). | +| `name` | No | Optional human-readable label. | + +Returns the new schedule's id and its next fire time. + +### `cancel_schedule` + +| Parameter | Required | Description | +| --- | --- | --- | +| `id` | Yes | The id of the schedule to cancel (from `create_schedule` or `list_schedules`). | + +## Schedule specs + +The `when` argument accepts: + +| Form | Meaning | Example | +| --- | --- | --- | +| `in:` | One-shot, after a delay | `in:10m` | +| `at:` | One-shot, at an absolute future time | `at:2026-07-14T09:00:00Z` | +| `every:` | Recurring, at a fixed interval | `every:1h` | +| `minutely` / `hourly` / `daily` / `weekly` | Recurring preset intervals | `hourly` | + +Durations use Go's duration syntax (`30s`, `15m`, `2h`). Preset and `every:` intervals are measured from the schedule's creation time (for example `hourly` fires every hour after it is created), not aligned to wall-clock slots. + +## Example + +```yaml +agents: + root: + model: openai/gpt-5-mini + description: A monitoring assistant + instruction: | + Every 15 minutes, run `git fetch` and tell me if origin/main moved. + toolsets: + - type: scheduler + - type: shell +``` + +The agent calls `create_schedule(prompt="Run `git fetch` and report if origin/main moved", when="every:15m")`. Every 15 minutes it is reminded, runs the command with the `shell` tool, and reports back. + +> [!TIP] +> **When to use** +> +> Use the scheduler for recurring monitoring, timed one-shots, and unattended housekeeping loops during a long-running session. For work that should run immediately and be awaited, use [`background_jobs`](../background-jobs/index.md) instead. From df0eb3029f1a874275cd6703e3846c9b0c5b40b4 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Tue, 14 Jul 2026 07:10:39 +0330 Subject: [PATCH 08/13] docs(docs/tools/scheduler/index.md): updating up the scheduler docs to fix the lint problems --- docs/tools/scheduler/index.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/tools/scheduler/index.md b/docs/tools/scheduler/index.md index 792c7ea76..628e62c8c 100644 --- a/docs/tools/scheduler/index.md +++ b/docs/tools/scheduler/index.md @@ -79,7 +79,13 @@ agents: - type: shell ``` -The agent calls `create_schedule(prompt="Run `git fetch` and report if origin/main moved", when="every:15m")`. Every 15 minutes it is reminded, runs the command with the `shell` tool, and reports back. +The agent calls: + +```text +create_schedule(prompt="Run git fetch and report if origin/main moved", when="every:15m") +``` + +Every 15 minutes it is reminded, runs the command with the `shell` tool, and reports back. > [!TIP] > **When to use** From f44ba57d3a55ec871d6b3667f5d5010a559a81a5 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 13:03:56 +0330 Subject: [PATCH 09/13] fix(pkg/tools/builtin/scheduler/schedule.go): fixing up the error exception and minRecurringInterval problems on review --- pkg/tools/builtin/scheduler/schedule.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/tools/builtin/scheduler/schedule.go b/pkg/tools/builtin/scheduler/schedule.go index 40282039c..6debf6635 100644 --- a/pkg/tools/builtin/scheduler/schedule.go +++ b/pkg/tools/builtin/scheduler/schedule.go @@ -1,11 +1,14 @@ package scheduler import ( + "errors" "fmt" "strings" "time" ) +const minRecurringInterval = time.Minute + var presets = map[string]time.Duration{ "minutely": time.Minute, "hourly": time.Hour, @@ -16,7 +19,7 @@ var presets = map[string]time.Duration{ func parseWhen(when string, now time.Time) (next time.Time, interval time.Duration, err error) { s := strings.TrimSpace(when) if s == "" { - return time.Time{}, 0, fmt.Errorf("empty schedule spec") + return time.Time{}, 0, errors.New("empty schedule spec") } if d, ok := presets[strings.ToLower(s)]; ok { @@ -42,6 +45,9 @@ func parseWhen(when string, now time.Time) (next time.Time, interval time.Durati if d <= 0 { return time.Time{}, 0, fmt.Errorf("interval must be positive in %q", when) } + if d < minRecurringInterval { + return time.Time{}, 0, fmt.Errorf("recurring interval must be at least %s in %q", minRecurringInterval, when) + } return now.Add(d), d, nil case hasPrefixFold(s, "at:"): From 471287efd956f157382d2325c2f6e01db2d556cc Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 13:04:57 +0330 Subject: [PATCH 10/13] fix(pkg/tools/builtin/scheduler/scheduler.go): fixing the runtime and scheduling review feedbacks --- pkg/tools/builtin/scheduler/scheduler.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/tools/builtin/scheduler/scheduler.go b/pkg/tools/builtin/scheduler/scheduler.go index 9b412d172..5f12c3359 100644 --- a/pkg/tools/builtin/scheduler/scheduler.go +++ b/pkg/tools/builtin/scheduler/scheduler.go @@ -3,6 +3,7 @@ package scheduler import ( "context" "fmt" + "log/slog" "strings" "sync" "time" @@ -15,7 +16,7 @@ const ( ToolNameListSchedules = "list_schedules" ToolNameCancelSchedule = "cancel_schedule" - category = "scheduler" + category = "scheduler" loopMaxWait = time.Minute ) @@ -50,7 +51,7 @@ func New() *ToolSet { type CreateScheduleArgs struct { Prompt string `json:"prompt" jsonschema:"The instruction to deliver to the agent when the schedule fires"` - When string `json:"when" jsonschema:"When to fire: in: (e.g. in:10m), at: (e.g. at:2026-07-14T09:00:00Z), every: (e.g. every:1h), or one of minutely, hourly, daily, weekly"` + When string `json:"when" jsonschema:"When to fire: in: (e.g. in:10m), at: (e.g. at:2026-07-14T09:00:00Z), every: (e.g. every:1h, minimum 1m), or one of minutely, hourly, daily, weekly"` Name string `json:"name,omitempty" jsonschema:"Optional human-readable label for the schedule"` } @@ -68,12 +69,12 @@ func (t *ToolSet) createSchedule(_ context.Context, args CreateScheduleArgs, rt return tools.ResultError("Error: scheduling requires host recall support, which is unavailable in this session."), nil } + t.setRuntime(rt) + sc, err := t.store.add(args.Name, args.Prompt, args.When, t.now()) if err != nil { return tools.ResultError("Error: " + err.Error()), nil } - - t.setRuntime(rt) t.signalWake() return tools.ResultSuccess(formatCreated(sc)), nil @@ -92,12 +93,13 @@ func (t *ToolSet) cancelSchedule(_ context.Context, args CancelScheduleArgs) (*t func (t *ToolSet) fireDue(ctx context.Context, now time.Time) { rt := t.runtime() + if rt == nil { + return + } for _, sc := range t.store.popDue(now) { - if rt == nil { - continue - } if err := rt.Recall(ctx, formatFire(sc)); err != nil { - return + slog.WarnContext(ctx, "Failed to enqueue scheduled recall", + "schedule_id", sc.ID, "schedule_name", sc.Name, "error", err) } } } @@ -209,6 +211,8 @@ cadence during this session. your normal tools (shell, api, fetch, …). - when accepts: in: (in:10m), at: (at:2026-07-14T09:00:00Z), every: (every:1h), or minutely / hourly / daily / weekly. +- Recurring schedules must be at least 1m apart; each fire costs a turn, so + prefer the longest cadence that still meets the need. - list_schedules shows active schedules; cancel_schedule removes one by id. Schedules only fire while this session is running and are not persisted across From 48ed506961385199ef6521b9f11df76e2f182cd8 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 13:05:52 +0330 Subject: [PATCH 11/13] fix(pkg/tools/builtin/scheduler/schedule_test.go): adding some more parsing tests --- pkg/tools/builtin/scheduler/schedule_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/tools/builtin/scheduler/schedule_test.go b/pkg/tools/builtin/scheduler/schedule_test.go index 932507d33..645a495bd 100644 --- a/pkg/tools/builtin/scheduler/schedule_test.go +++ b/pkg/tools/builtin/scheduler/schedule_test.go @@ -32,6 +32,10 @@ func TestParseWhen(t *testing.T) { {"at in past", "at:2020-01-01T00:00:00Z", time.Time{}, 0, true}, {"every zero", "every:0s", time.Time{}, 0, true}, {"every negative", "every:-5m", time.Time{}, 0, true}, + {"every below floor", "every:100ms", time.Time{}, 0, true}, + {"every just below floor", "every:59s", time.Time{}, 0, true}, + {"every at floor", "every:1m", testNow.Add(time.Minute), time.Minute, false}, + {"short one-shot allowed", "in:1s", testNow.Add(time.Second), 0, false}, {"in bad duration", "in:abc", time.Time{}, 0, true}, } From 1c0827b2a05a45d9e6d419589306016782aff79f Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 13:07:08 +0330 Subject: [PATCH 12/13] fix(pkg/tools/builtin/scheduler/scheduler_test.go): fixing up some problems mentioned about scheduler tests --- pkg/tools/builtin/scheduler/scheduler_test.go | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/pkg/tools/builtin/scheduler/scheduler_test.go b/pkg/tools/builtin/scheduler/scheduler_test.go index 3cb607355..04bce0cf3 100644 --- a/pkg/tools/builtin/scheduler/scheduler_test.go +++ b/pkg/tools/builtin/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "errors" "strings" "sync" "testing" @@ -13,9 +14,10 @@ import ( ) type fakeRuntime struct { - mu sync.Mutex - recalls []string - recall bool + mu sync.Mutex + recalls []string + recall bool + recallErr error } func (f *fakeRuntime) EmitOutput(context.Context, string) {} @@ -24,7 +26,7 @@ func (f *fakeRuntime) Recall(_ context.Context, msg string) error { f.mu.Lock() defer f.mu.Unlock() f.recalls = append(f.recalls, msg) - return nil + return f.recallErr } func (f *fakeRuntime) Supports(c tools.Capability) bool { @@ -49,12 +51,12 @@ func TestCreateAndListSchedule(t *testing.T) { ts := newTestToolSet() rt := &fakeRuntime{recall: true} - res, err := ts.createSchedule(context.Background(), + res, err := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: "check build", When: "every:1h", Name: "ci"}, rt) require.NoError(t, err) require.False(t, res.IsError, res.Output) - list, err := ts.listSchedules(context.Background(), ListSchedulesArgs{}) + list, err := ts.listSchedules(t.Context(), ListSchedulesArgs{}) require.NoError(t, err) require.Contains(t, list.Output, "check build") require.Contains(t, list.Output, "ci") @@ -66,7 +68,7 @@ func TestCreateScheduleRequiresRecall(t *testing.T) { ts := newTestToolSet() rt := &fakeRuntime{recall: false} - res, err := ts.createSchedule(context.Background(), + res, err := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: "x", When: "hourly"}, rt) require.NoError(t, err) require.True(t, res.IsError) @@ -80,7 +82,7 @@ func TestCreateScheduleInvalidWhen(t *testing.T) { ts := newTestToolSet() rt := &fakeRuntime{recall: true} - res, err := ts.createSchedule(context.Background(), + res, err := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: "x", When: "whenever"}, rt) require.NoError(t, err) require.True(t, res.IsError) @@ -93,7 +95,7 @@ func TestCreateScheduleRequiresPrompt(t *testing.T) { ts := newTestToolSet() rt := &fakeRuntime{recall: true} - res, err := ts.createSchedule(context.Background(), + res, err := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: " ", When: "hourly"}, rt) require.NoError(t, err) require.True(t, res.IsError) @@ -105,14 +107,14 @@ func TestFireDueCallsRecall(t *testing.T) { ts := newTestToolSet() rt := &fakeRuntime{recall: true} - _, err := ts.createSchedule(context.Background(), + _, err := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: "run backup", When: "every:1h", Name: "bkp"}, rt) require.NoError(t, err) - ts.fireDue(context.Background(), testNow.Add(30*time.Minute)) + ts.fireDue(t.Context(), testNow.Add(30*time.Minute)) require.Empty(t, rt.messages()) - ts.fireDue(context.Background(), testNow.Add(time.Hour)) + ts.fireDue(t.Context(), testNow.Add(time.Hour)) msgs := rt.messages() require.Len(t, msgs, 1) require.Contains(t, msgs[0], "run backup") @@ -120,23 +122,52 @@ func TestFireDueCallsRecall(t *testing.T) { require.Len(t, ts.store.list(), 1) } +func TestFireDueContinuesAfterRecallError(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + rt := &fakeRuntime{recall: true, recallErr: errors.New("host went away")} + + for _, name := range []string{"first", "second"} { + _, err := ts.createSchedule(t.Context(), + CreateScheduleArgs{Prompt: "do " + name, When: "every:1h", Name: name}, rt) + require.NoError(t, err) + } + + ts.fireDue(t.Context(), testNow.Add(time.Hour)) + + require.Len(t, rt.messages(), 2) +} + +func TestFireDueWithoutRuntimeKeepsSchedules(t *testing.T) { + t.Parallel() + + ts := newTestToolSet() + _, err := ts.store.add("orphan", "do it", "in:10m", testNow) + require.NoError(t, err) + + ts.fireDue(t.Context(), testNow.Add(11*time.Minute)) + + require.Len(t, ts.store.list(), 1) +} + func TestCancelScheduleTool(t *testing.T) { t.Parallel() ts := newTestToolSet() rt := &fakeRuntime{recall: true} - res, _ := ts.createSchedule(context.Background(), + res, _ := ts.createSchedule(t.Context(), CreateScheduleArgs{Prompt: "x", When: "hourly"}, rt) require.False(t, res.IsError) id := ts.store.list()[0].ID - cres, err := ts.cancelSchedule(context.Background(), CancelScheduleArgs{ID: id}) + cres, err := ts.cancelSchedule(t.Context(), CancelScheduleArgs{ID: id}) require.NoError(t, err) require.False(t, cres.IsError) require.Empty(t, ts.store.list()) - cres2, _ := ts.cancelSchedule(context.Background(), CancelScheduleArgs{ID: id}) + cres2, _ := ts.cancelSchedule(t.Context(), CancelScheduleArgs{ID: id}) require.True(t, cres2.IsError) } @@ -150,7 +181,7 @@ func TestToolSetImplementsInterfaces(t *testing.T) { _, ok = ts.(tools.Instructable) require.True(t, ok, "must implement tools.Instructable") - toolz, err := ts.Tools(context.Background()) + toolz, err := ts.Tools(t.Context()) require.NoError(t, err) require.Len(t, toolz, 3) } @@ -159,6 +190,6 @@ func TestStartStopIsClean(t *testing.T) { t.Parallel() ts := New() - require.NoError(t, ts.Start(context.Background())) - require.NoError(t, ts.Stop(context.Background())) + require.NoError(t, ts.Start(t.Context())) + require.NoError(t, ts.Stop(t.Context())) } From bf6ecc5041568804245d8eeb845e228e13ffe742 Mon Sep 17 00:00:00 2001 From: Dwin Gharibi Date: Wed, 15 Jul 2026 13:34:21 +0330 Subject: [PATCH 13/13] fix(docs/tools/scheduler/index.md): updating the docs of scheduler according to latest changes --- docs/tools/scheduler/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/tools/scheduler/index.md b/docs/tools/scheduler/index.md index 628e62c8c..16635f7f7 100644 --- a/docs/tools/scheduler/index.md +++ b/docs/tools/scheduler/index.md @@ -65,6 +65,9 @@ The `when` argument accepts: Durations use Go's duration syntax (`30s`, `15m`, `2h`). Preset and `every:` intervals are measured from the schedule's creation time (for example `hourly` fires every hour after it is created), not aligned to wall-clock slots. +> [!IMPORTANT] +> **Recurring schedules have a one-minute minimum.** Every fire injects a message into the agent loop and typically costs an LLM turn, so `every:` values below `1m` are rejected — a typo such as `every:1s` in place of `every:1h` would otherwise become a runaway token burn. One-shot schedules (`in:` / `at:`) are not restricted, since they fire once. + ## Example ```yaml