mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
A trigger run with nobody present only reads unless its trigger holds act.* grants, and until now only the REST API could set them. An agent that proposes a trigger can now ask for acts too: - propose_trigger takes an optional acts list. The names must be act.* grants the agent of the chat holds; anything else is refused and nothing is stored. A read_only proposal cannot carry acts. - The proposal stores the list. Confirming it, in Studio, by a plain yes or by confirm CODE, grants exactly that list. No tool changes the acts of a trigger afterwards. Two proposals that differ only in their acts are kept apart. - The text of a proposal in a messaging app now has a Permissions line, in plain words with sensitive classes marked: read only, or what a run may do. The confirmation repeats it. A proposal that asks for acts is never set up without a yes, whatever the approval mode. - The trigger REST object gains acts_held, read_only and read_only_why, computed with the same rules as the run, so a client can show why a run only reads. Assisted-by: Claude:claude-sonnet-5-5 go ginkgo
396 lines
13 KiB
Go
396 lines
13 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
|
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
// What a timer that a chat sets is bound by.
|
|
const (
|
|
// MinOneOff is the shortest time from now to the run of a one-off trigger.
|
|
MinOneOff = time.Minute
|
|
// MaxOneOffAhead is the farthest time a one-off trigger may be set to.
|
|
MaxOneOffAhead = 365 * 24 * time.Hour
|
|
// cancelWindow is how long after a trigger was made without asking a bare
|
|
// "cancel" in its thread removes it. The code works for as long as the
|
|
// trigger lives.
|
|
cancelWindow = 30 * time.Minute
|
|
)
|
|
|
|
// ---- Times that a chat gives ----
|
|
|
|
var clockRE = regexp.MustCompile(`^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$`)
|
|
|
|
// ParseClock reads a time of day: "18:00", "6pm", "6:30 pm".
|
|
func ParseClock(text string) (hour, minute int, err error) {
|
|
m := clockRE.FindStringSubmatch(strings.ToLower(strings.TrimSpace(text)))
|
|
if m == nil {
|
|
return 0, 0, errors.New(`the time of day must look like "18:00" or "6pm"`)
|
|
}
|
|
hour, _ = strconv.Atoi(m[1])
|
|
if m[2] != "" {
|
|
minute, _ = strconv.Atoi(m[2])
|
|
}
|
|
switch m[3] {
|
|
case "am", "pm":
|
|
if hour < 1 || hour > 12 {
|
|
return 0, 0, errors.New("with am or pm the hour must be 1 to 12")
|
|
}
|
|
hour %= 12
|
|
if m[3] == "pm" {
|
|
hour += 12
|
|
}
|
|
}
|
|
if hour > 23 || minute > 59 {
|
|
return 0, 0, errors.New("the time of day is not a time")
|
|
}
|
|
return hour, minute, nil
|
|
}
|
|
|
|
// timerSchedule turns "in 10 minutes" (in) or "at 18:00" (at) into a one-off
|
|
// schedule text and the zone it is read in. A delay needs no zone (UTC is used
|
|
// when none is given); a time of day needs one, since the host does not know
|
|
// where the person is. A time of day that is less than a minute away, or past,
|
|
// means the next day.
|
|
func timerSchedule(now time.Time, in, at, tz string) (schedule, zone string, err error) {
|
|
if (in == "") == (at == "") {
|
|
return "", "", errors.New(`when needs exactly one of "in" (a delay such as "10 minutes") or "at" (a time of day such as "18:00")`)
|
|
}
|
|
if at != "" && tz == "" {
|
|
return "", "", errors.New(`"at" needs time_zone: ask the person where they are, or use "in" with a delay`)
|
|
}
|
|
if tz == "" {
|
|
tz = "UTC"
|
|
}
|
|
loc, lerr := time.LoadLocation(tz)
|
|
if lerr != nil {
|
|
return "", "", fmt.Errorf("time zone %q is not known", tz)
|
|
}
|
|
var target time.Time
|
|
if in != "" {
|
|
d, derr := ParseDelay(in)
|
|
if derr != nil {
|
|
return "", "", derr
|
|
}
|
|
if d < MinOneOff {
|
|
return "", "", errors.New("a timer from a chat is at least one minute away")
|
|
}
|
|
if d > MaxOneOffAhead {
|
|
return "", "", fmt.Errorf("a timer can be at most %d days ahead", int(MaxOneOffAhead/(24*time.Hour)))
|
|
}
|
|
target = now.Add(d)
|
|
if rounded := target.Truncate(time.Minute); rounded.Before(target) {
|
|
target = rounded.Add(time.Minute)
|
|
}
|
|
} else {
|
|
h, m, cerr := ParseClock(at)
|
|
if cerr != nil {
|
|
return "", "", cerr
|
|
}
|
|
y, mo, d := now.In(loc).Date()
|
|
target = time.Date(y, mo, d, h, m, 0, 0, loc)
|
|
if target.Sub(now) < MinOneOff {
|
|
target = time.Date(y, mo, d+1, h, m, 0, 0, loc)
|
|
}
|
|
}
|
|
return "once " + target.In(loc).Format("2006-01-02 15:04"), tz, nil
|
|
}
|
|
|
|
// ---- Approval ----
|
|
|
|
// ApprovalOf gives the approval of a binding. A binding that cannot be read
|
|
// asks: the host never creates a trigger without asking on a doubt.
|
|
func (c *Connectors) ApprovalOf(ctx context.Context, bindingID string) connectors.Approval {
|
|
b, err := c.o.Store.GetBinding(ctx, bindingID)
|
|
if err != nil {
|
|
return connectors.Approval{}
|
|
}
|
|
return b.Approval
|
|
}
|
|
|
|
// autoLive are the triggers of the owner that the binding made without asking
|
|
// and that are on.
|
|
func autoLive(all []triggers.Trigger, binding string) []triggers.Trigger {
|
|
var out []triggers.Trigger
|
|
for _, t := range all {
|
|
if t.Route.Binding == binding && t.Route.Auto && !t.Proposed && t.Enabled {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runsPerDay is how many runs a day a trigger made without asking uses of the
|
|
// budget of its binding: a recurring trigger as many as its schedule and daily
|
|
// limit allow, a one-off trigger one when it is due in the next day.
|
|
func (h *Triggers) runsPerDay(t triggers.Trigger, now time.Time) int {
|
|
if at, ok := t.OnceAt(); ok {
|
|
if at.Sub(now) <= 24*time.Hour {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
return h.MaxRunsPerDay(t)
|
|
}
|
|
|
|
// autoVerdict says why a proposal may not be made without asking under the
|
|
// approval of its binding; empty means it may. The checks are the owner's
|
|
// limits and, for the read-only mode, what the proposal says about itself.
|
|
func (h *Triggers) autoVerdict(ctx context.Context, ap connectors.Approval, t triggers.Trigger) string {
|
|
lim := ap.Limits()
|
|
now := h.o.Clock()
|
|
if ap.ModeOrDefault() == connectors.ApprovalAutoReadonly {
|
|
switch {
|
|
case !t.Route.ReadOnly:
|
|
return "it does not say that its runs only read and report"
|
|
case t.Writes != "" || t.Widget != "":
|
|
return "its runs write a file"
|
|
}
|
|
}
|
|
if len(t.Acts) > 0 {
|
|
// A run that may act is never set up without a yes.
|
|
return "its runs ask for permission to act on their own"
|
|
}
|
|
if t.Route.TargetOrDefault() == triggers.TargetChannel {
|
|
return "it would write to another channel"
|
|
}
|
|
if t.Kind != triggers.KindSchedule {
|
|
return "a trigger on files always asks"
|
|
}
|
|
all, err := h.o.Store.ListTriggers(ctx, t.Owner)
|
|
if err != nil {
|
|
return "I could not count the timers of this connection"
|
|
}
|
|
live := autoLive(all, t.Route.Binding)
|
|
if len(live) >= lim.MaxPending {
|
|
return fmt.Sprintf("this connection already has %d timers and triggers (the most is %d)", len(live), lim.MaxPending)
|
|
}
|
|
days := time.Duration(lim.MaxLifetimeDays) * 24 * time.Hour
|
|
if at, ok := t.OnceAt(); ok {
|
|
if at.Sub(now) > days {
|
|
return fmt.Sprintf("it is more than %d days away", lim.MaxLifetimeDays)
|
|
}
|
|
} else {
|
|
sch, perr := triggers.ParseSchedule(t.Schedule)
|
|
loc, lerr := t.Location()
|
|
if perr != nil || lerr != nil {
|
|
return "its schedule cannot be read"
|
|
}
|
|
if gap := sch.MinGap(now, loc); gap > 0 && gap < time.Duration(lim.MinGapMinutes)*time.Minute {
|
|
return fmt.Sprintf("it runs every %s (the least is %d minutes)", gapText(gap), lim.MinGapMinutes)
|
|
}
|
|
}
|
|
used := 0
|
|
for _, l := range live {
|
|
used += h.runsPerDay(l, now)
|
|
}
|
|
if need := h.runsPerDay(capped(t, lim), now); used+need > lim.MaxRunsPerDay {
|
|
return fmt.Sprintf("it would make %d runs a day with the others (the most is %d)", used+need, lim.MaxRunsPerDay)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// capped is t with the daily limit of the owner applied.
|
|
func capped(t triggers.Trigger, lim connectors.Limits) triggers.Trigger {
|
|
if !t.OneOff() && t.CapOrDefault() > lim.MaxRunsPerDay {
|
|
t.DailyCap = lim.MaxRunsPerDay
|
|
}
|
|
return t
|
|
}
|
|
|
|
// autoCreate turns a fresh proposal into a trigger that is on, bounded by the
|
|
// limits of the owner, and writes in the chat what was set up and how to
|
|
// cancel it. It reports whether the chat got the text.
|
|
func (h *Triggers) autoCreate(ctx context.Context, ap connectors.Approval, p triggers.Trigger) (triggers.Trigger, bool, error) {
|
|
lim := ap.Limits()
|
|
now := h.o.Clock().UTC()
|
|
t, err := h.confirm(ctx, p.Owner, p.ID, "", func(t *triggers.Trigger) {
|
|
*t = capped(*t, lim)
|
|
t.Route.Auto = true
|
|
if !t.OneOff() {
|
|
t.Route.Until = now.Add(time.Duration(lim.MaxLifetimeDays) * 24 * time.Hour)
|
|
}
|
|
})
|
|
if err != nil {
|
|
return triggers.Trigger{}, false, err
|
|
}
|
|
h.audit(ctx, proposals.ActionConfirm, t, "automatic, mode "+ap.ModeOrDefault())
|
|
return t, h.say(ctx, t, h.autoText(t)), nil
|
|
}
|
|
|
|
// say writes text in the chat and thread where a trigger was proposed.
|
|
func (h *Triggers) say(ctx context.Context, t triggers.Trigger, text string) bool {
|
|
c := h.svc.connectors
|
|
if c == nil || !t.Route.FromConnector() || t.Route.Chat == "" {
|
|
return false
|
|
}
|
|
if _, err := c.Post(ctx, Post{Binding: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread, Text: text}); err != nil {
|
|
h.log.Warn("studio: could not write to the chat of a trigger", "trigger", t.ID, "error", err)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// costText says what a run costs, for the text of a proposal: every run is a
|
|
// model turn.
|
|
func (h *Triggers) costText(t triggers.Trigger) string {
|
|
if t.OneOff() {
|
|
return "one model run"
|
|
}
|
|
return "one model run each time it runs"
|
|
}
|
|
|
|
// autoText is what the host writes in the chat when it made a trigger without
|
|
// asking.
|
|
func (h *Triggers) autoText(t triggers.Trigger) string {
|
|
where := "the Inbox in Studio"
|
|
switch t.Route.TargetOrDefault() {
|
|
case triggers.TargetOrigin:
|
|
where = "this chat"
|
|
case triggers.TargetChannel:
|
|
where = "the channel " + t.Route.Channel
|
|
}
|
|
end := ""
|
|
if !t.Route.Until.IsZero() {
|
|
end = fmt.Sprintf(" It stops by itself on %s.", t.Route.Until.UTC().Format("2006-01-02"))
|
|
}
|
|
return fmt.Sprintf("Set up %q: %s. Cost: %s. Results go to %s.%s\nTo remove it, reply \"cancel\" here, or \"cancel %s\" at any time.",
|
|
t.Name, lowerFirst(h.whenText(t)), h.costText(t), where, end, t.Route.Code)
|
|
}
|
|
|
|
// whenText is summary with the day of a one-off trigger spelled out.
|
|
func (h *Triggers) whenText(t triggers.Trigger) string {
|
|
if at, ok := t.OnceAt(); ok {
|
|
loc, _ := t.Location()
|
|
zone := t.TZ
|
|
if zone == "" {
|
|
zone = "UTC"
|
|
}
|
|
return "Once, " + at.In(loc).Format("Mon 2006-01-02 at 15:04") + " (" + zone + ")"
|
|
}
|
|
return h.summary(t)
|
|
}
|
|
|
|
func lowerFirst(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
return strings.ToLower(s[:1]) + s[1:]
|
|
}
|
|
|
|
// ---- Spent and ended triggers ----
|
|
|
|
// retire removes the one-off trigger of a chat once its run ended, whatever
|
|
// the end was: it can run no more. A trigger made in Studio stays as a record.
|
|
func (h *Triggers) retire(ctx context.Context, t triggers.Trigger) {
|
|
if !t.Route.FromConnector() || !t.OneOff() {
|
|
return
|
|
}
|
|
if err := h.DeleteTrigger(ctx, t.Owner, t.ID); err != nil && !errors.Is(err, triggers.ErrNotFound) {
|
|
h.log.Warn("studio: could not remove a spent one-off trigger", "trigger", t.ID, "error", err)
|
|
return
|
|
}
|
|
h.audit(ctx, proposals.ActionDismiss, t, "one-off trigger ran; removed")
|
|
}
|
|
|
|
// spentGrace is how long after its time a one-off trigger of a chat is kept
|
|
// when no run took it (the capability was off, the host was down). A run that
|
|
// is queued or running keeps it.
|
|
const spentGrace = 15 * time.Minute
|
|
|
|
// sweepTimers removes the one-off triggers of chats whose time has passed
|
|
// without a run to finish them, and the recurring triggers that were made
|
|
// without asking and reached the end of their lifetime. The leader calls it in
|
|
// a pass, at most once a minute.
|
|
func (h *Triggers) sweepTimers(ctx context.Context) {
|
|
h.mu.Lock()
|
|
now := h.o.Clock()
|
|
if now.Sub(h.timersSwept) < time.Minute {
|
|
h.mu.Unlock()
|
|
return
|
|
}
|
|
h.timersSwept = now
|
|
h.mu.Unlock()
|
|
all, err := h.o.Store.AllTriggers(ctx)
|
|
if err != nil {
|
|
h.log.Warn("studio: could not list triggers to sweep the timers", "error", err)
|
|
return
|
|
}
|
|
for _, t := range all {
|
|
if t.Proposed || !t.Route.FromConnector() {
|
|
continue
|
|
}
|
|
switch {
|
|
case !t.Route.Until.IsZero() && !now.Before(t.Route.Until):
|
|
if h.DeleteTrigger(ctx, t.Owner, t.ID) == nil {
|
|
h.audit(ctx, proposals.ActionExpire, t, "lifetime ended; removed")
|
|
h.say(ctx, t, fmt.Sprintf("%q reached the end of its lifetime and was removed.", t.Name))
|
|
}
|
|
case t.OneOff():
|
|
at, _ := t.OnceAt()
|
|
if now.Sub(at) < spentGrace || h.hasActiveRun(ctx, t.ID) {
|
|
continue
|
|
}
|
|
h.retire(ctx, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
// hasActiveRun says whether a run of the trigger is queued or running.
|
|
func (h *Triggers) hasActiveRun(ctx context.Context, id string) bool {
|
|
runs, err := h.o.Store.ListRuns(ctx, id, 3)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
for _, r := range runs {
|
|
if !r.Status.Final() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// afterPropose finishes the proposal that a chat of a messaging app made: under
|
|
// the approval of the binding it makes the trigger at once, within the owner's
|
|
// limits; otherwise it writes the proposal in the chat and waits for an answer.
|
|
// fresh is false for a proposal that already waited.
|
|
func (h *Triggers) afterPropose(ctx context.Context, a workspace.Actor, t triggers.Trigger, fresh bool, here triggers.Route) (string, error) {
|
|
note := ""
|
|
if c := h.svc.connectors; c != nil && fresh {
|
|
if ap := c.ApprovalOf(ctx, t.Route.Binding); ap.Auto() {
|
|
note = h.autoVerdict(ctx, ap, t)
|
|
if note == "" {
|
|
made, wrote, err := h.autoCreate(ctx, ap, t)
|
|
if err == nil {
|
|
h.svc.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindTriggerProposed, IDs: []string{made.ID}, Text: made.Name})
|
|
return h.autoResult(made, wrote), nil
|
|
}
|
|
note = toolErr(err).Error()
|
|
}
|
|
}
|
|
}
|
|
return h.announceProposal(ctx, a, t, here, note)
|
|
}
|
|
|
|
// autoResult words, for the model, what happened when a trigger was made
|
|
// without asking.
|
|
func (h *Triggers) autoResult(t triggers.Trigger, wrote bool) string {
|
|
if wrote {
|
|
return fmt.Sprintf("Set up %q (%s) at once, under the owner's settings. The host told the person in this chat what it is and how to cancel it. "+
|
|
"Say in one short sentence that it is set up; do not repeat the details and do not offer a code.", t.Name, h.whenText(t))
|
|
}
|
|
return fmt.Sprintf("Set up %q (%s) at once, under the owner's settings. The host could not write to this chat. Tell the person it is set up, what it does, "+
|
|
"and that replying \"cancel\" removes it.", t.Name, h.whenText(t))
|
|
}
|