Files
LocalAI/core/services/studio/proposalstate.go
T
Ettore Di Giacinto ab78cd0301 feat(studio): propose triggers from a connector chat and report back there
A connector chat can now propose a trigger, the person answers it in the
chat, and the run reports to the chat.

The trigger tools propose_trigger and list_triggers are offered in a
connector chat when a capability gate allows triggers.propose for the
binding. The gate is a small interface on the service with a deny-all
default, so nothing changes until the application sets one. Change and
delete stay in Studio.

A proposal from a chat stores the binding, chat and thread, a four
character code and a 24 hour expiry. The host writes the proposal and the
code in that chat. "confirm CODE" or "cancel CODE", as a whole message in
the same chat and thread, is handled before any model turn and needs
proposals.confirm. A binding may make 3 proposals an hour. Proposals that
lapse are marked expired, and the proposal card shows that state.

A trigger carries a delivery: the Inbox (default) or the chat of the
proposal, with the modes always, on_change and on_problem. The host writes
the last message of the run, cut and with broadcast mentions broken; a
message of exactly [SILENT] writes nothing; three failures in a row raise
one alert per 6 hours. A failed delivery falls back to the Inbox. Host
messages of a binding go through an outbox of 20 a minute that queues the
rest. While triggers.run is off, runs of such a trigger are skipped, not
replayed, and the record is unchanged. The audit records each step with a
hash and no text.

Assisted-by: Claude:claude-sonnet-5-5 go test ginkgo eslint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 14:00:31 +00:00

261 lines
8.1 KiB
Go

package studio
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio/triggers"
)
// ProposalState is what became of a trigger the chat proposed.
type ProposalState string
// The states of a proposal. A removed one has no state: the trigger is gone
// from the Triggers page and the lookup answers triggers.ErrNotFound.
const (
ProposalPending ProposalState = "pending"
ProposalAccepted ProposalState = "accepted"
ProposalDeclined ProposalState = "declined"
ProposalUndone ProposalState = "undone"
// ProposalExpired is a proposal that nobody answered in time.
ProposalExpired ProposalState = "expired"
)
// Why an accepted trigger cannot be undone. The codes are for the interface,
// which words them in the person's language; undoText is the English the
// refusal of an undo carries.
const (
UndoRan = "ran"
UndoEdited = "edited"
)
var undoText = map[string]string{
UndoRan: "It already ran once; remove it from the Triggers page.",
UndoEdited: "You edited it since; change or remove it on the Triggers page.",
}
// ProposalView is a proposal as the chat card shows it: its state, the record
// (the proposal itself, or the trigger that accepting made), when it was
// answered, and whether accepting can still be undone.
type ProposalView struct {
State ProposalState
Trigger triggers.Trigger
// At is when the proposal was answered; zero while it waits.
At time.Time
// CanUndo is set on an accepted trigger that nothing has used or changed.
// UndoBlocked says why not otherwise: UndoRan or UndoEdited.
CanUndo bool
UndoBlocked string
}
func stateOf(t triggers.Trigger) ProposalState {
switch {
case t.Pending():
return ProposalPending
case t.Outcome == triggers.OutcomeDeclined:
return ProposalDeclined
case t.Outcome == triggers.OutcomeUndone:
return ProposalUndone
case t.Outcome == triggers.OutcomeExpired:
return ProposalExpired
}
return ProposalAccepted
}
// ownedAny returns a record of the user, whether it is a proposal, an answered
// one or a trigger, but not a change record.
func (h *Triggers) ownedAny(ctx context.Context, user, id string) (triggers.Trigger, error) {
t, err := h.o.Store.GetTrigger(ctx, id)
if err != nil || t.Owner != principal(user) || t.IsChange() {
return triggers.Trigger{}, triggers.ErrNotFound
}
return t, nil
}
// undoBlock says why the trigger that accepting made cannot be taken back, or
// "" when it can: it ran (a run of any kind counts) or the person changed it.
func (h *Triggers) undoBlock(ctx context.Context, t triggers.Trigger) (string, error) {
if t.Touched {
return UndoEdited, nil
}
runs, err := h.o.Store.ListRuns(ctx, t.ID, 1)
if err != nil {
return "", err
}
if len(runs) > 0 {
return UndoRan, nil
}
return "", nil
}
// ProposalStatus reads the state of a proposal of the user. A proposal that
// became a trigger the user removed since, or one that is not theirs, is
// triggers.ErrNotFound.
func (h *Triggers) ProposalStatus(ctx context.Context, user, id string) (ProposalView, error) {
t, err := h.ownedAny(ctx, user, id)
if err != nil {
return ProposalView{}, err
}
if !t.Proposed && t.Outcome != triggers.OutcomeAccepted {
return ProposalView{}, triggers.ErrNotFound // a trigger nobody proposed
}
t = h.lapse(ctx, t)
v := ProposalView{State: stateOf(t), Trigger: t, At: t.OutcomeAt}
if v.State == ProposalAccepted {
block, err := h.undoBlock(ctx, t)
if err != nil {
return ProposalView{}, err
}
v.CanUndo, v.UndoBlocked = block == "", block
}
return v, nil
}
// UndoProposal takes back an accepted proposal: the trigger is switched off and
// leaves the Triggers page, and the proposal stays as a record of the answer so
// it can be scheduled again. It is refused with ErrCannotUndo once the trigger
// ran or the person changed it; the person then removes it on the Triggers page.
func (h *Triggers) UndoProposal(ctx context.Context, user, id string) (triggers.Trigger, error) {
h.wmu.Lock()
defer h.wmu.Unlock()
t, err := h.ownedAny(ctx, user, id)
if err != nil {
return triggers.Trigger{}, err
}
if t.Proposed {
return triggers.Trigger{}, ErrProposalAnswered
}
if t.Outcome != triggers.OutcomeAccepted {
return triggers.Trigger{}, triggers.ErrNotFound
}
block, err := h.undoBlock(ctx, t)
if err != nil {
return triggers.Trigger{}, err
}
if block != "" {
return triggers.Trigger{}, fmt.Errorf("%w: %s", ErrCannotUndo, undoText[block])
}
undone := t
undone.Enabled, undone.Proposed, undone.Touched = false, true, false
undone.Outcome, undone.OutcomeAt = triggers.OutcomeUndone, h.o.Clock().UTC()
if err := h.o.Store.PutTrigger(ctx, undone); err != nil {
return triggers.Trigger{}, err
}
// A pass may have started a run between the check and the write. Then the
// trigger is the person's now: put it back.
if block, err := h.undoBlock(ctx, t); err != nil || block != "" {
if perr := h.o.Store.PutTrigger(ctx, t); perr != nil {
return triggers.Trigger{}, perr
}
if err != nil {
return triggers.Trigger{}, err
}
return triggers.Trigger{}, fmt.Errorf("%w: %s", ErrCannotUndo, undoText[block])
}
h.Notify()
return undone, nil
}
// ReopenProposal makes a declined or undone proposal wait for an answer again.
func (h *Triggers) ReopenProposal(ctx context.Context, user, id string) (triggers.Trigger, error) {
h.wmu.Lock()
defer h.wmu.Unlock()
t, err := h.ownedAny(ctx, user, id)
if err != nil {
return triggers.Trigger{}, err
}
if !t.Proposed {
return triggers.Trigger{}, ErrProposalAnswered
}
if t.Pending() {
return t, nil
}
mine, err := h.o.Store.ListTriggers(ctx, t.Owner)
if err != nil {
return triggers.Trigger{}, err
}
pending := 0
now := h.o.Clock()
for _, m := range mine {
if m.Pending() && !m.Expired(now) {
pending++
}
}
if pending >= maxProposals {
return triggers.Trigger{}, ErrTooManyProposals
}
t.Outcome, t.OutcomeAt, t.Touched = "", time.Time{}, false
if !t.Route.Expires.IsZero() {
// The owner asked for it again: it waits a full time once more.
t.Route.Expires = h.o.Clock().UTC().Add(ProposalTTL)
}
if err := h.o.Store.PutTrigger(ctx, t); err != nil {
return triggers.Trigger{}, err
}
return t, nil
}
// maxProposalNotes is how many answered proposals of a chat the model hears of.
const maxProposalNotes = 3
// ProposalNote is one line for the model at the start of a turn: what the user
// did with the triggers it proposed in this chat, newest first. It is empty
// when there is nothing to say. The model gets the state, not the event, so a
// later turn still knows.
func (h *Triggers) ProposalNote(ctx context.Context, user, chatID string) string {
all, err := h.o.Store.ListTriggers(ctx, principal(user))
if err != nil {
return ""
}
var mine []triggers.Trigger
for _, t := range all {
if t.Outcome == "" {
continue
}
if (t.IsChange() && t.Change.Chat == chatID) || (!t.IsChange() && t.SourceChatID == chatID) {
mine = append(mine, t)
}
}
if len(mine) == 0 {
return ""
}
slices.SortFunc(mine, func(a, b triggers.Trigger) int { return b.OutcomeAt.Compare(a.OutcomeAt) })
if len(mine) > maxProposalNotes {
mine = mine[:maxProposalNotes]
}
parts := make([]string, 0, len(mine))
again := false
for _, t := range mine {
name := t.Name
if len(name) > 60 {
name = name[:60]
}
if t.IsChange() {
if line := changeNote(t, name); line != "" {
parts = append(parts, line)
}
continue
}
switch t.Outcome {
case triggers.OutcomeAccepted:
parts = append(parts, fmt.Sprintf("%q accepted by the user and active (%s)", name, h.summary(t)))
case triggers.OutcomeDeclined:
parts = append(parts, fmt.Sprintf("%q declined by the user", name))
again = true
case triggers.OutcomeUndone:
parts = append(parts, fmt.Sprintf("%q undone by the user after they scheduled it, so it is not active", name))
again = true
case triggers.OutcomeExpired:
parts = append(parts, fmt.Sprintf("%q expired: nobody confirmed it in time, so it is not active", name))
}
}
line := "Trigger proposals and changes in this chat: " + strings.Join(parts, "; ") + "."
if again {
line += " Do not propose a declined or undone trigger again unless the user asks."
}
return line
}