package agents import ( "context" "encoding/json" "fmt" "log/slog" "strings" "time" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/ids" "g.fc.run/ai/byre/internal/mcp" "g.fc.run/ai/byre/internal/schedule" "g.fc.run/ai/byre/internal/store" ) func (m *Manager) SaveSchedule(ctx context.Context, projectID, scheduleID string, save api.ScheduleSave, login, actor string) (api.Schedule, error) { sc, err := schedule.Timing(save) if err != nil { return api.Schedule{}, err } sc.Prompt = strings.TrimSpace(deref(save.Prompt)) sc.Model = strings.TrimSpace(deref(save.Model)) sc.Workflow = strings.TrimSpace(deref(save.Workflow)) switch { case sc.Workflow != "" && sc.Model != "": return api.Schedule{}, errModelWithWorkflow case sc.Workflow == "" && sc.Prompt == "": return api.Schedule{}, fmt.Errorf("%w: prompt is required without a workflow", ErrInvalid) case sc.Workflow == "" && sc.Model == "": return api.Schedule{}, fmt.Errorf("%w: model is required without a workflow", ErrInvalid) case sc.Workflow != "": _, err = m.store.GetWorkflow(ctx, sc.Workflow) if err != nil { return api.Schedule{}, err } } if scheduleID != "" { existing, err := m.store.GetSchedule(ctx, scheduleID) if err != nil { return api.Schedule{}, err } sc.Id = existing.Id sc.ProjectId = existing.ProjectId sc.Owner = existing.Owner sc, err = m.store.UpdateSchedule(ctx, sc, actor) if err != nil { return api.Schedule{}, err } m.poke() return sc, nil } user, err := m.store.GetUser(ctx, login) if err != nil { return api.Schedule{}, err } if user.GitName == "" || user.GitEmail == "" { return api.Schedule{}, ErrNoGitIdentity } project, err := m.store.GetProject(ctx, projectID) if err != nil { return api.Schedule{}, err } sc.Id = ids.New(ids.Schedule) sc.ProjectId = project.Id sc.Owner = user.Login sc, err = m.store.CreateSchedule(ctx, sc, actor) if err != nil { return api.Schedule{}, err } m.poke() return sc, nil } func (m *Manager) DeleteSchedule(ctx context.Context, scheduleID, actor string) error { err := m.store.DeleteSchedule(ctx, scheduleID, actor) if err != nil { return err } m.poke() return nil } func (m *Manager) runSchedule(sc api.Schedule) { ctx := context.Background() err := m.startScheduled(ctx, sc) if err == nil { slog.Info("scheduled task started", "schedule", sc.Id, "project", sc.ProjectId) } else { slog.Error("start scheduled task", "schedule", sc.Id, "project", sc.ProjectId, "err", err) m.signalSchedule(ctx, sc, fmt.Sprintf("Schedule %s %q didn't start its run: %v.", sc.Id, sc.Title, err)) } for i := 0; ; i++ { err = m.store.AdvanceSchedule(ctx, sc) if err == nil { break } slog.Error("advance schedule", "schedule", sc.Id, "err", err) if i == 0 { m.signalSchedule(ctx, sc, fmt.Sprintf("Schedule %s %q couldn't move on to its next run: %v. byre retries every minute; the schedule doesn't run again until it does.", sc.Id, sc.Title, err)) } time.Sleep(time.Minute) } m.poke() } func (m *Manager) signalSchedule(ctx context.Context, sc api.Schedule, text string) { err := m.addLeadInput(ctx, store.LeadInput{Owner: sc.Owner, Kind: store.LeadSignal, Actor: "byre", Text: text}) if err != nil { slog.Error("signal lead", "schedule", sc.Id, "err", err) } } func (m *Manager) startScheduled(ctx context.Context, sc api.Schedule) error { project, err := m.store.GetProject(ctx, sc.ProjectId) if err != nil { return err } user, err := m.store.GetUser(ctx, sc.Owner) if err != nil { return err } if user.GitName == "" || user.GitEmail == "" { return ErrNoGitIdentity } if sc.Workflow != "" { _, err = m.StartWorkflow(ctx, project, user, sc.Title, project.DefaultBranch, "byre", sc.Workflow, sc.Prompt, nil, "") return err } task, err := m.CreateTask(ctx, project, sc.Title, user.Login, project.DefaultBranch, "byre") if err != nil { return err } _, err = m.create(ctx, task, user, "", sc.Prompt, sc.Model, "byre", "", "", nil) if err != nil { return fmt.Errorf("task %s created, but its agent did not start: %w", task.Id, err) } return nil } func scheduleText(sc api.Schedule) string { b := &strings.Builder{} fmt.Fprintf(b, "- %s %q: project %s, owner %s, every %s", sc.Id, sc.Title, sc.ProjectId, sc.Owner, sc.Every) switch sc.Every { case api.Hour: fmt.Fprintf(b, " at minute %d", sc.Minute) case api.Week: days := []string{} for _, d := range sc.Weekdays { days = append(days, string(d)) } fmt.Fprintf(b, " on %s at %02d:%02d", strings.Join(days, ", "), sc.Hour, sc.Minute) case api.Month: fmt.Fprintf(b, " on day %d at %02d:%02d", sc.DayOfMonth, sc.Hour, sc.Minute) default: fmt.Fprintf(b, " at %02d:%02d", sc.Hour, sc.Minute) } fmt.Fprintf(b, ", next run %s", sc.NextRun.Local().Format(time.RFC3339)) if sc.Workflow != "" { fmt.Fprintf(b, ", workflow %q", sc.Workflow) } else { fmt.Fprintf(b, ", model %s", sc.Model) } if sc.Prompt != "" { fmt.Fprintf(b, "\n prompt: %s", clip(sc.Prompt, 500)) } return b.String() } func (m *Manager) scheduleTools(l *live, tool func(name, description, schema string, run func(ctx context.Context, user api.User, raw json.RawMessage) (string, error)) mcp.Tool) []mcp.Tool { return []mcp.Tool{ tool("list_schedules", "List the schedules: each one's id, project, owner, title, timing, model or workflow, prompt, and next run in the server's local time. Give project_id for one project's.", `{"type": "object", "properties": {"project_id": {"type": "string"}}}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { ProjectID string `json:"project_id"` }{} err := json.Unmarshal(raw, &args) if err != nil { return "", err } list, err := m.store.ListSchedules(ctx, args.ProjectID) if err != nil { return "", err } if len(list) == 0 { return "No schedules.", nil } lines := []string{} for _, sc := range list { lines = append(lines, scheduleText(sc)) } return strings.Join(lines, "\n"), nil }), tool("save_schedule", "Create a schedule, or with schedule_id replace one's settings (project_id is then ignored). On each run byre creates a task in the project from its default branch, as create_task does, "+ "as the schedule's owner (you, for one you create), and you get its created signal. Times are the server's local time. "+ "every hour runs at minute past each hour (hour 0); day at hour:minute; week at hour:minute on each of weekdays; month at hour:minute on day_of_month, or the month's last day when shorter. "+ "weekdays only for week, day_of_month only for month. Each run's agent sees nothing else, so the prompt must be a complete brief. "+ "With workflow, each run starts that workflow, prompt is optional and is its brief, and model can't be given; the workflow is looked up by name at each run. "+ "A run missed while byre was down happens once when it starts. The human sees and edits schedules on the project's dashboard. Only when the human asks.", `{"type": "object", "properties": { "project_id": {"type": "string"}, "schedule_id": {"type": "string"}, "title": {"type": "string", "description": "The title of each task it creates."}, "every": {"type": "string", "enum": ["hour", "day", "week", "month"]}, "weekdays": {"type": "array", "items": {"type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]}}, "hour": {"type": "integer", "minimum": 0, "maximum": 23}, "minute": {"type": "integer", "minimum": 0, "maximum": 59}, "day_of_month": {"type": "integer", "minimum": 1, "maximum": 31}, "prompt": {"type": "string"}, "model": {"type": "string", "description": "Without workflow, the agent's model; defaults to yours."}, "workflow": {"type": "string", "description": "The name of a workflow for each run to freeze a copy of and run."} }, "required": ["title", "every", "hour", "minute"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { ProjectID string `json:"project_id"` ScheduleID string `json:"schedule_id"` }{} err := json.Unmarshal(raw, &args) if err != nil { return "", err } save := api.ScheduleSave{} err = json.Unmarshal(raw, &save) if err != nil { return "", err } if deref(save.Workflow) == "" { model := m.modelOr(deref(save.Model), l) save.Model = &model } sc, err := m.SaveSchedule(ctx, args.ProjectID, args.ScheduleID, save, user.Login, leadActor(user.Login)) if err != nil { return "", err } return "Saved:\n" + scheduleText(sc), nil }), tool("delete_schedule", "Delete a schedule. Tasks it already created stay. Only when the human asks.", `{"type": "object", "properties": {"schedule_id": {"type": "string"}}, "required": ["schedule_id"]}`, func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) { args := struct { ScheduleID string `json:"schedule_id"` }{} err := json.Unmarshal(raw, &args) if err != nil { return "", err } err = m.DeleteSchedule(ctx, args.ScheduleID, leadActor(user.Login)) if err != nil { return "", err } return fmt.Sprintf("Deleted schedule %s.", args.ScheduleID), nil }), } }