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())
}
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)
})
}
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)
})
}()
}
// 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",
"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,
})
}
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 {
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)
--- /dev/null
+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
+}
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}`))
+}
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())
// 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
}
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)
}
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 {
)
// 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
subagents: subagents,
runs: l.runs,
whatsapp: whatsapp,
+ schedules: schedules,
workdir: agent.config.Workdir,
}
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)