Add scheduled tasks with a Project tab section and lead tools
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
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 in the Project tab. 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
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user