]> git.giacomolli.uk - onechat/commitdiff
Give the root agent the schedule tools
authorAnderson A. Giacomolli <redacted>
Mon, 9 Nov 2026 00:29:32 +0000 (21:29 -0300)
committerAnderson A. Giacomolli <redacted>
Mon, 9 Nov 2026 00:29:32 +0000 (21:29 -0300)
The root agent gets three tools. schedule_set adds a schedule with a
task text and either a local time or a cron expression, and answers with
its ID and next time. schedule_list gives each schedule with its ID,
next time, cron expression or once, and text. schedule_remove removes
one by its ID.

The descriptions tell the agent to set a schedule only when the user
asks, to write the text as an order to itself, and to turn a time such
as "tomorrow at 9" into a local time from the Now line. The cron
description gives the limit of 5 minutes between runs. The prompt text
does not change, so it stays near the reference design.

The daemon opens agent/schedules.json at its start. The toolbox of a
subagent has no schedules, so the tools are unknown there, as the
WhatsApp tools are. Compactions send the new tools too, so they keep the
shared prompt cache.

Issue: 60e4e4f

daemon.go
daemon_start_helper_test.go
prompt.go
prompt_test.go
schedule_helper_test.go [new file with mode: 0644]
schedule_test.go
subagent_run_test.go
tools.go
turn.go
turn_test.go

index ca3d5abc27598ca815ec1926e462dc04fc589d7b..2d8301788daf789dd1ae62aa76f3b3f115728aaf 100644 (file)
--- a/daemon.go
+++ b/daemon.go
@@ -93,6 +93,11 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
                err = errors.Join(err, memory.Close())
        }()
 
+       schedules, err := openSchedules(filepath.Join(agent.dir, scheduleFileName), time.Now)
+       if err != nil {
+               return err
+       }
+
        subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), time.Now, subagentNameRandom)
        defer func() {
                err = errors.Join(err, subagents.Close())
@@ -118,7 +123,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) {
        }
 
        return daemonServe(ctx, listener, telegram, whatsapp, func(notify func(Event)) (*TurnLoop, error) {
-               return newTurnLoop(agent, memory, subagents, whatsapp, client, time.Now, notify)
+               return newTurnLoop(agent, memory, subagents, whatsapp, schedules, client, time.Now, notify)
        })
 }
 
index baa527b9d7637851d0db2e0ebc67698a678bd6d1..e7dea6c410dbf7d24de29f7a26053fad249a37a1 100644 (file)
@@ -42,7 +42,7 @@ func daemonStartTestWithWhatsApp(t *testing.T, server *ModelFake, bot *TelegramB
        done := make(chan error)
        go func() {
                done <- daemonServe(ctx, listener, bot, whatsapp, func(notify func(Event)) (*TurnLoop, error) {
-                       return newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, client, clock.Now, notify)
+                       return newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, newTestSchedules(t, clock.Now), client, clock.Now, notify)
                })
        }()
 
index 8210575e99ce2e4db0926cacc6075fa5e322ce6a..465029c927a062107e1c25d54f7aa64f592676cf 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -185,9 +185,9 @@ func promptSubagent(name, instructions string) string {
 }
 
 // promptTools returns the function tools of every call of the agent: the tools
-// of promptSubagentTools, then spawn, tell, and the WhatsApp tools. A
-// compaction sends them too and never calls them, because a change of the tools
-// ends the shared prompt cache.
+// of promptSubagentTools, then spawn, tell, the WhatsApp tools, and the
+// schedule tools. A compaction sends them too and never calls them, because a
+// change of the tools ends the shared prompt cache.
 func promptTools() []ModelTool {
        return append(promptSubagentTools(), ModelTool{
                Type:        "function",
@@ -233,6 +233,30 @@ func promptTools() []ModelTool {
                        "text": promptStringSchema("The text of the message."),
                }),
                Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "schedule_set",
+               Description: `Set a reminder or a timed task, only when the user asks for one. At its time, the text reaches you as a message "[Schedule ID]" that starts a turn, and you do the task then. Give at for one time, or cron for a repeating schedule, and null for the other. Turn a time such as "tomorrow at 9" into a local time from the Now line.`,
+               Parameters: promptObjectSchema(map[string]any{
+                       "text": promptStringSchema(`The task for you at that time, as an order to yourself, with all you need to know, such as "Remind the user to drink water."`),
+                       "at":   promptNullableStringSchema("A local time YYYY-MM-DD HH:MM after now, or null."),
+                       "cron": promptNullableStringSchema(`A cron expression of five fields, minute hour day-of-month month day-of-week, with *, lists, ranges, and steps, such as "0 8 * * 1-5", or null. Its runs must be at least 5 minutes apart.`),
+               }),
+               Strict: true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "schedule_list",
+               Description: "List the schedules by their next time, each as ID | next time | cron expression or once | text.",
+               Parameters:  promptObjectSchema(map[string]any{}),
+               Strict:      true,
+       }, ModelTool{
+               Type:        "function",
+               Name:        "schedule_remove",
+               Description: "Remove a schedule by its ID, when the user asks.",
+               Parameters: promptObjectSchema(map[string]any{
+                       "id": promptIntegerSchema("The ID of the schedule."),
+               }),
+               Strict: true,
        })
 }
 
index f1fe8f8cda3c0a94ea5cda119b5b216e48bee7bb..043cc9908e09c24bfe55c83dd39c8825857d2c7c 100644 (file)
@@ -77,7 +77,7 @@ func TestPromptWithoutInstructionsHasNoClause(t *testing.T) {
 
 func TestAgentToolsAreStrictAndStable(t *testing.T) {
        tools := promptTools()
-       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "whatsapp_find", "whatsapp_send"}, promptToolNames(tools))
+       require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "tell", "whatsapp_find", "whatsapp_send", "schedule_set", "schedule_list", "schedule_remove"}, promptToolNames(tools))
        require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools()))
 
        for _, tool := range tools {
@@ -85,7 +85,7 @@ func TestAgentToolsAreStrictAndStable(t *testing.T) {
                properties := schema["properties"].(map[string]any)
                require.True(t, tool.Strict, tool.Name)
                require.Equal(t, false, schema["additionalProperties"], tool.Name)
-               require.Equal(t, slices.Sorted(maps.Keys(properties)), schema["required"], tool.Name)
+               require.ElementsMatch(t, slices.Collect(maps.Keys(properties)), schema["required"], tool.Name)
        }
 
        first, err := json.Marshal(tools)
diff --git a/schedule_helper_test.go b/schedule_helper_test.go
new file mode 100644 (file)
index 0000000..fbaebe4
--- /dev/null
@@ -0,0 +1,19 @@
+package main
+
+import (
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+// newTestSchedules opens the schedules of a new file with clock.
+func newTestSchedules(t *testing.T, clock func() time.Time) *Schedules {
+       t.Helper()
+
+       schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock)
+       require.NoError(t, err)
+
+       return schedules
+}
index 5ef174bbf9090bbccf1c9f47514d64a6b61e98bb..c13f023ff4ed23b29d25b0081e0b82d9732296c0 100644 (file)
@@ -245,3 +245,24 @@ func scheduleTestOpen(t *testing.T) (*Schedules, string) {
 
        return schedules, path
 }
+
+func TestToolboxRunsTheScheduleTools(t *testing.T) {
+       schedules, _ := scheduleTestOpen(t)
+       box := &Toolbox{
+               schedules: schedules,
+       }
+
+       require.Equal(t, "Schedule 1 set. Next: Monday 2026-11-09 08:00 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Tell the user the news.","at":null,"cron":"0 8 * * 1-5"}`))
+       require.Equal(t, "Schedule 2 set. Next: Sunday 2026-11-08 20:02 -03:00.", box.run(t.Context(), "schedule_set", `{"text":"Remind the user to drink water.","at":"2026-11-08 20:02","cron":null}`))
+       require.Equal(t, "2 | Sunday 2026-11-08 20:02 -03:00 | once | Remind the user to drink water.\n1 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", box.run(t.Context(), "schedule_list", `{}`))
+       require.Equal(t, "Schedule 1 removed.", box.run(t.Context(), "schedule_remove", `{"id":1}`))
+       require.Equal(t, "error: no schedule 1", box.run(t.Context(), "schedule_remove", `{"id":1}`))
+       require.Equal(t, "error: give either at or cron, and null for the other", box.run(t.Context(), "schedule_set", `{"text":"x","at":null,"cron":null}`))
+}
+
+func TestSubagentHasNoScheduleTools(t *testing.T) {
+       box := &Toolbox{}
+       require.Equal(t, `error: unknown tool "schedule_set"`, box.run(t.Context(), "schedule_set", `{"text":"x","at":"2026-11-09 09:00","cron":null}`))
+       require.Equal(t, `error: unknown tool "schedule_list"`, box.run(t.Context(), "schedule_list", `{}`))
+       require.Equal(t, `error: unknown tool "schedule_remove"`, box.run(t.Context(), "schedule_remove", `{"id":1}`))
+}
index 9b8b8c5b8279b43c8369a1b26d2856c45e9e7ffa..02ad961f6b879142bbca77d38c865006f49ca368 100644 (file)
@@ -517,7 +517,7 @@ func subagentTestStart(t *testing.T, agent *Agent, server *ModelFake, events *Tu
        subagents := newSubagents(filepath.Join(agent.dir, subagentDirName), clock.Now, subagentNameRandom)
        client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl"))
        whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock)
-       loop, err := newTurnLoop(agent, memory, subagents, whatsapp, client, clock.Now, events.Add)
+       loop, err := newTurnLoop(agent, memory, subagents, whatsapp, newTestSchedules(t, clock.Now), client, clock.Now, events.Add)
        require.NoError(t, err)
 
        ctx, cancel := context.WithCancel(t.Context())
index 7640dee9f3ff18c7ecfa456967c3621239cd370c..009e3312404c646f53a8fea4284371264b420a70 100644 (file)
--- a/tools.go
+++ b/tools.go
@@ -49,16 +49,18 @@ type ToolChat interface {
 // the log as it happens: the call as a tool message, the result as an echo
 // message. The call is durable before its tool runs, so the log never misses an
 // action outside the agent. Commands and files run as the user in the working
-// directory, with no sandbox. Zoom with a name reads the log of a subagent.
-// The toolbox of the agent has the runs of its subagents for spawn and tell,
-// and the WhatsApp link for the WhatsApp tools. The toolbox of a subagent has
-// neither, so these are unknown tools there.
+// directory, with no sandbox. Zoom with a name reads the log of a subagent. The
+// toolbox of the agent has the runs of its subagents for spawn and tell, the
+// WhatsApp link for the WhatsApp tools, and the schedules for the schedule
+// tools. The toolbox of a subagent has none of them, so these are unknown tools
+// there.
 type Toolbox struct {
        log       ToolLog
        chat      ToolChat
        subagents *Subagents
        runs      *SubagentRuns
        whatsapp  *WhatsApp
+       schedules *Schedules
        workdir   string
 }
 
@@ -171,6 +173,32 @@ func (b *Toolbox) run(ctx context.Context, name, arguments string) string {
                case err == nil:
                        output, err = b.whatsapp.Send(ctx, args.ID, args.Chat, args.Text)
                }
+       case "schedule_set":
+               var args ToolScheduleSetArguments
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.schedules.Set(args.Text, args.At, args.Cron)
+               }
+       case "schedule_list":
+               err = toolDecodeArguments(arguments, &struct{}{})
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output = b.schedules.List()
+               }
+       case "schedule_remove":
+               var args ToolScheduleRemoveArguments
+               err = toolDecodeArguments(arguments, &args)
+               switch {
+               case b.schedules == nil:
+                       err = fmt.Errorf("unknown tool %q", name)
+               case err == nil:
+                       output, err = b.schedules.Remove(args.ID)
+               }
        default:
                err = fmt.Errorf("unknown tool %q", name)
        }
@@ -424,6 +452,19 @@ type ToolWhatsAppSendArguments struct {
        Text string `json:"text"`
 }
 
+// ToolScheduleSetArguments are the arguments of schedule_set. Exactly one of At
+// and Cron is not nil.
+type ToolScheduleSetArguments struct {
+       Text string  `json:"text"`
+       At   *string `json:"at"`
+       Cron *string `json:"cron"`
+}
+
+// ToolScheduleRemoveArguments are the arguments of schedule_remove.
+type ToolScheduleRemoveArguments struct {
+       ID int `json:"id"`
+}
+
 // ToolHeadTail keeps the first and the last toolClipBytes bytes written to it,
 // and counts the bytes between them that it drops.
 type ToolHeadTail struct {
diff --git a/turn.go b/turn.go
index c6ab5e9b2dcedf7182fdf3e9d7c0685052f585d7..837ac7c519ecbdd6760bdbc370c7522a622f5422 100644 (file)
--- a/turn.go
+++ b/turn.go
@@ -10,9 +10,9 @@ import (
 )
 
 // newTurnLoop returns the loop of agent with its memory, its subagents, and the
-// WhatsApp link of its tools. Its calls go through client, and notify gets each
-// event. The clock gives the date of a turn.
-func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, whatsapp *WhatsApp, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) {
+// WhatsApp link and the schedules of its tools. Its calls go through client,
+// and notify gets each event. The clock gives the date of a turn.
+func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, whatsapp *WhatsApp, schedules *Schedules, client ChainClient, clock func() time.Time, notify func(Event)) (*TurnLoop, error) {
        layout, err := loadViewLayout(filepath.Join(agent.dir, viewLayoutFileName))
        if err != nil {
                return nil, err
@@ -48,6 +48,7 @@ func newTurnLoop(agent *Agent, memory *Memory, subagents *Subagents, whatsapp *W
                subagents: subagents,
                runs:      l.runs,
                whatsapp:  whatsapp,
+               schedules: schedules,
                workdir:   agent.config.Workdir,
        }
 
index 319dcf39fe2341b75dc2e7485fcba0be0e7a2907..1d958565550ba81be1fdfb4790460a3d7ac9d1ca 100644 (file)
@@ -392,7 +392,7 @@ func newTestTurnLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer)
 
        client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath)
        whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), clock)
-       test.loop, err = newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, client, clock.Now, test.events.Add)
+       test.loop, err = newTurnLoop(agent, memory, newTestSubagents(t), whatsapp, newTestSchedules(t, clock.Now), client, clock.Now, test.events.Add)
        require.NoError(t, err)
        ctx, cancel := context.WithCancel(t.Context())
        done := make(chan error)