From: Anderson A. Giacomolli Date: Mon, 9 Nov 2026 00:29:32 +0000 (-0300) Subject: Give the root agent the schedule tools X-Git-Url: https://git.giacomolli.uk/onechat/commitdiff_plain/e737005f89c7c663e1636830001730ad79396bdc?ds=inline;hp=396af2e18eb02d4ec1e35edbc62f01e84bae2b46 Give the root agent the schedule tools 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 --- diff --git a/daemon.go b/daemon.go index ca3d5ab..2d83017 100644 --- 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) }) } diff --git a/daemon_start_helper_test.go b/daemon_start_helper_test.go index baa527b..e7dea6c 100644 --- a/daemon_start_helper_test.go +++ b/daemon_start_helper_test.go @@ -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) }) }() diff --git a/prompt.go b/prompt.go index 8210575..465029c 100644 --- 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, }) } diff --git a/prompt_test.go b/prompt_test.go index f1fe8f8..043cc99 100644 --- a/prompt_test.go +++ b/prompt_test.go @@ -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 index 0000000..fbaebe4 --- /dev/null +++ b/schedule_helper_test.go @@ -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 +} diff --git a/schedule_test.go b/schedule_test.go index 5ef174b..c13f023 100644 --- a/schedule_test.go +++ b/schedule_test.go @@ -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}`)) +} diff --git a/subagent_run_test.go b/subagent_run_test.go index 9b8b8c5..02ad961 100644 --- a/subagent_run_test.go +++ b/subagent_run_test.go @@ -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()) diff --git a/tools.go b/tools.go index 7640dee..009e331 100644 --- 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 c6ab5e9..837ac7c 100644 --- 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, } diff --git a/turn_test.go b/turn_test.go index 319dcf3..1d95856 100644 --- a/turn_test.go +++ b/turn_test.go @@ -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)