mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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>
This commit is contained in:
33 files changed
+1781
-32
No files matched your search
@@ -87,7 +87,7 @@ var triggerRules = []errRule{
|
||||
verbatim(http.StatusConflict, typeConflict, "", studiosvc.ErrTooManyTriggers, studiosvc.ErrTooManyProposals),
|
||||
verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrTooManyChanges, studiosvc.ErrChangeStale),
|
||||
sentinelText(http.StatusConflict, typeConflict, studiosvc.ErrCannotUndo),
|
||||
verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrProposalAnswered),
|
||||
verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrProposalAnswered, studiosvc.ErrProposalExpired),
|
||||
verbatim(http.StatusConflict, typeTriggersOff, "", studiosvc.ErrTriggersOff),
|
||||
fixed(http.StatusServiceUnavailable, typeServerError, "the server is shutting down", studiosvc.ErrTriggersClosed),
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Rückgängig gemacht:",
|
||||
"again": "Erneut planen",
|
||||
"declined": "Abgelehnt:",
|
||||
"expired": "Abgelaufen:",
|
||||
"restore": "Wiederherstellen",
|
||||
"blocked": {
|
||||
"ran": "Er ist schon einmal gelaufen; entferne ihn auf der Seite Trigger.",
|
||||
|
||||
@@ -659,6 +659,7 @@
|
||||
"undone": "Undone:",
|
||||
"again": "Schedule it again",
|
||||
"declined": "Declined:",
|
||||
"expired": "Expired:",
|
||||
"restore": "Restore",
|
||||
"blocked": {
|
||||
"ran": "It already ran once; remove it from the Triggers page.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Deshecho:",
|
||||
"again": "Programarlo de nuevo",
|
||||
"declined": "Rechazado:",
|
||||
"expired": "Caducada:",
|
||||
"restore": "Restaurar",
|
||||
"blocked": {
|
||||
"ran": "Ya se ejecutó una vez; elimínalo desde la página Disparadores.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Annulé :",
|
||||
"again": "Le planifier de nouveau",
|
||||
"declined": "Refusé :",
|
||||
"expired": "Expirée :",
|
||||
"restore": "Restaurer",
|
||||
"blocked": {
|
||||
"ran": "Il s'est déjà exécuté une fois ; supprimez-le depuis la page Déclencheurs.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Diurungkan:",
|
||||
"again": "Jadwalkan lagi",
|
||||
"declined": "Ditolak:",
|
||||
"expired": "Kedaluwarsa:",
|
||||
"restore": "Pulihkan",
|
||||
"blocked": {
|
||||
"ran": "Pemicu ini sudah pernah berjalan; hapus dari halaman Pemicu.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Annullato:",
|
||||
"again": "Pianificalo di nuovo",
|
||||
"declined": "Rifiutato:",
|
||||
"expired": "Scaduta:",
|
||||
"restore": "Ripristina",
|
||||
"blocked": {
|
||||
"ran": "È già stato eseguito una volta; rimuovilo dalla pagina Trigger.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "취소됨:",
|
||||
"again": "다시 예약",
|
||||
"declined": "거절됨:",
|
||||
"expired": "만료됨:",
|
||||
"restore": "복원",
|
||||
"blocked": {
|
||||
"ran": "이미 한 번 실행되었습니다. 트리거 페이지에서 삭제하세요.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "Desfeito:",
|
||||
"again": "Agendar de novo",
|
||||
"declined": "Recusado:",
|
||||
"expired": "Expirada:",
|
||||
"restore": "Restaurar",
|
||||
"blocked": {
|
||||
"ran": "Ele já foi executado uma vez; remova-o na página Gatilhos.",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"undone": "已撤销:",
|
||||
"again": "重新安排",
|
||||
"declined": "已拒绝:",
|
||||
"expired": "已过期:",
|
||||
"restore": "恢复",
|
||||
"blocked": {
|
||||
"ran": "它已经运行过一次;请在触发器页面中删除。",
|
||||
|
||||
@@ -154,13 +154,13 @@ export default function ProposalCard({ id, t, tick = 0 }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (view.kind === 'undone' || view.kind === 'declined') {
|
||||
if (view.kind === 'undone' || view.kind === 'declined' || view.kind === 'expired') {
|
||||
const undone = view.kind === 'undone'
|
||||
return wrap(
|
||||
<div className="cx-receipt cx-receipt--quiet" data-testid="trigger-proposal" data-state={view.kind}>
|
||||
<div className="cx-receipt__main">
|
||||
<p className="cx-receipt__line">
|
||||
<b>{t(undone ? 'proposal.receipt.undone' : 'proposal.receipt.declined')}</b>{' '}
|
||||
<b>{t(undone ? 'proposal.receipt.undone' : view.kind === 'expired' ? 'proposal.receipt.expired' : 'proposal.receipt.declined')}</b>{' '}
|
||||
<span data-testid="receipt-title">{p.name}</span>
|
||||
</p>
|
||||
{alert}
|
||||
|
||||
Vendored
+1
-1
@@ -519,7 +519,7 @@ export const chatTriggersApi = {
|
||||
runs: (id) => fetchJSON(`/api/chats/triggers/${enc2(id)}/runs`),
|
||||
preview: (when, tz) => fetchJSON(`/api/chats/triggers/preview${q({ when, tz, n: 3 })}`),
|
||||
proposals: (chat) => fetchJSON(`/api/chats/triggers/proposals${q({ chat })}`),
|
||||
// One proposal and what became of it: { state: pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked }.
|
||||
// One proposal and what became of it: { state: pending|accepted|declined|expired|undone, trigger, answered_at, can_undo, undo_blocked }.
|
||||
proposal: (id) => fetchJSON(`/api/chats/triggers/proposals/${enc2(id)}`),
|
||||
undoProposal: (id) => postJSON(`/api/chats/triggers/proposals/${enc2(id)}/undo`, {}),
|
||||
reopenProposal: (id) => postJSON(`/api/chats/triggers/proposals/${enc2(id)}/reopen`, {}),
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ export const PROPOSAL_CARD = 'trigger_proposal'
|
||||
|
||||
// What the card of a proposal shows, from the server's answer to
|
||||
// GET /api/chats/triggers/proposals/{id} (or the error that call raised): one of
|
||||
// pending, accepted, declined or undone, as the server keeps it, removed when
|
||||
// pending, accepted, declined, expired or undone, as the server keeps it, removed when
|
||||
// the trigger is gone from the Triggers page (404) and error otherwise. An
|
||||
// accepted proposal says whether Undo is still allowed and, if not, why
|
||||
// ('ran' or 'edited'). A state this code does not know is an error, not a guess.
|
||||
@@ -16,6 +16,7 @@ export function proposalView(res, err) {
|
||||
switch (res?.state) {
|
||||
case 'pending':
|
||||
case 'declined':
|
||||
case 'expired':
|
||||
case 'undone':
|
||||
return { kind: res.state, trigger: res.trigger, at: res.answered_at || '' }
|
||||
case 'accepted':
|
||||
|
||||
@@ -66,6 +66,7 @@ test('trigger proposals: the card says proposed, created (same id) or gone, and
|
||||
const ran = proposalView({ state: 'accepted', trigger: p, can_undo: false, undo_blocked: 'ran' })
|
||||
assert.deepEqual([ran.canUndo, ran.blocked], [false, 'ran'])
|
||||
assert.equal(proposalView({ state: 'undone', trigger: p }).kind, 'undone')
|
||||
assert.equal(proposalView({ state: 'expired', trigger: p }).kind, 'expired')
|
||||
assert.equal(proposalView({ state: 'declined', trigger: p }).kind, 'declined')
|
||||
assert.equal(proposalView(null, Object.assign(new Error('nope'), { status: 404 })).kind, 'removed')
|
||||
const failed = proposalView(null, Object.assign(new Error('boom'), { status: 500 }))
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// The capabilities the host asks about before it lets a connector chat do
|
||||
// something on the owner's behalf. The names are the closed list of the
|
||||
// contract, shared with the manifest and the binding.
|
||||
const (
|
||||
CapTriggersPropose = contract.CapTriggersPropose
|
||||
CapTriggersRun = contract.CapTriggersRun
|
||||
CapProposalsConfirm = contract.CapProposalsConfirm
|
||||
)
|
||||
|
||||
// CapabilityRequest is one question to a CapabilityGate. Binding is the
|
||||
// connector binding, Owner its owner and Agent its agent. Sender is the
|
||||
// platform ID of the person who wrote, and is empty when the question is not
|
||||
// about one message (the tools a turn gets, a run that is due). Kind is the
|
||||
// kind of the proposal for CapProposalsConfirm, such as "trigger". Sensitive
|
||||
// says that the thing asked for changes what the agent may do or reach, so a
|
||||
// gate must not grant it on the word of a platform role alone.
|
||||
type CapabilityRequest struct {
|
||||
Capability string
|
||||
Binding string
|
||||
Owner string
|
||||
Agent string
|
||||
Sender string
|
||||
Kind string
|
||||
Sensitive bool
|
||||
}
|
||||
|
||||
// CapabilityGate decides whether a binding, and for a message its sender, may
|
||||
// use a capability. It is the one seam between the features that act from a
|
||||
// connector chat and the model of who may do what. An implementation reads
|
||||
// the switches of the owner (the grants of the agent, the capabilities of the
|
||||
// binding, pairing and roles) and answers from them. It must be fast and safe
|
||||
// for concurrent use, and it must fail closed: any doubt is false.
|
||||
type CapabilityGate interface {
|
||||
Allow(ctx context.Context, r CapabilityRequest) bool
|
||||
}
|
||||
|
||||
// CapabilityGateFunc makes a function a CapabilityGate.
|
||||
type CapabilityGateFunc func(ctx context.Context, r CapabilityRequest) bool
|
||||
|
||||
// Allow calls f.
|
||||
func (f CapabilityGateFunc) Allow(ctx context.Context, r CapabilityRequest) bool { return f(ctx, r) }
|
||||
|
||||
// DenyCapabilities is the gate a service has until the application sets
|
||||
// another: nothing is allowed. A connector chat therefore gets no trigger
|
||||
// tool, no confirmation by text and no run from a trigger it proposed, until
|
||||
// something enables them.
|
||||
var DenyCapabilities CapabilityGate = CapabilityGateFunc(func(context.Context, CapabilityRequest) bool { return false })
|
||||
|
||||
// SetCapabilityGate gives the service the gate for the capabilities above.
|
||||
// The application calls it once, before the service is shared. Nil restores
|
||||
// DenyCapabilities.
|
||||
func (s *Service) SetCapabilityGate(g CapabilityGate) {
|
||||
if g == nil {
|
||||
g = DenyCapabilities
|
||||
}
|
||||
s.capGate = g
|
||||
}
|
||||
|
||||
// allowCap asks the gate; a service without one denies.
|
||||
func (s *Service) allowCap(ctx context.Context, r CapabilityRequest) bool {
|
||||
if s.capGate == nil {
|
||||
return false
|
||||
}
|
||||
return s.capGate.Allow(ctx, r)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// answerPhrase answers a proposal when the message is exactly "confirm CODE" or
|
||||
// "cancel CODE" and a waiting proposal of the binding has that code. It writes
|
||||
// the answer back and reports true; for any other message it does nothing, so
|
||||
// the text goes to the agent as usual. Who may answer is decided by the
|
||||
// capability gate, not here.
|
||||
func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, say func(string)) bool {
|
||||
h := c.svc.triggers
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
verb, code, ok := ParsePhrase(m.Text)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
reply, handled := h.AnswerByPhrase(ctx, PhraseFrom{Binding: b.ID, Owner: b.Owner, Agent: b.AgentID,
|
||||
Chat: m.Chat, Thread: m.Thread, Sender: m.SenderID}, verb, code)
|
||||
if handled {
|
||||
say(reply)
|
||||
}
|
||||
return handled
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// The outbox is the one door for text that the host writes to a platform on
|
||||
// its own, not as the answer to a message: the report of a trigger run and the
|
||||
// text of a proposal. A platform limits how fast a bot may write, so a binding
|
||||
// sends at most outboxPerMinute of these in a minute. A message over the limit
|
||||
// waits in a queue, in order, and goes when the minute allows; it is not
|
||||
// dropped.
|
||||
const (
|
||||
outboxPerMinute = 20
|
||||
outboxMaxQueue = 100
|
||||
outboxPollEvery = time.Second
|
||||
)
|
||||
|
||||
// Errors of the outbox.
|
||||
var (
|
||||
// ErrBindingOff means the binding is missing or off, so nothing is sent.
|
||||
ErrBindingOff = errors.New("studio: the connector binding is not on")
|
||||
// ErrOutboxFull means a binding has the most messages it may have waiting.
|
||||
ErrOutboxFull = errors.New("studio: too many connector messages are waiting to be sent")
|
||||
)
|
||||
|
||||
// Post is one message the host writes to a chat of a binding.
|
||||
type Post struct {
|
||||
Binding, Chat, Thread, Text string
|
||||
// OnFail is called, from another goroutine, when a message that waited in
|
||||
// the queue could not be sent. A message that goes at once reports its
|
||||
// error as the result of Connectors.Post.
|
||||
OnFail func(error)
|
||||
}
|
||||
|
||||
type outItem struct {
|
||||
token string
|
||||
Post
|
||||
}
|
||||
|
||||
type outbox struct {
|
||||
sent []time.Time
|
||||
queue []outItem
|
||||
draining bool
|
||||
}
|
||||
|
||||
type outboxes struct {
|
||||
mu sync.Mutex
|
||||
by map[string]*outbox
|
||||
}
|
||||
|
||||
func (o *outboxes) get(id string) *outbox {
|
||||
if o.by == nil {
|
||||
o.by = map[string]*outbox{}
|
||||
}
|
||||
q := o.by[id]
|
||||
if q == nil {
|
||||
q = &outbox{}
|
||||
o.by[id] = q
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// prune forgets the sends of more than a minute ago.
|
||||
func (q *outbox) prune(now time.Time) {
|
||||
kept := q.sent[:0]
|
||||
for _, t := range q.sent {
|
||||
if now.Sub(t) < time.Minute {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
q.sent = kept
|
||||
}
|
||||
|
||||
// Post sends text to a chat of a binding. It sends at once when the binding is
|
||||
// under its limit and nothing waits, and otherwise queues the message and
|
||||
// reports queued. The binding must be on.
|
||||
func (c *Connectors) Post(ctx context.Context, p Post) (queued bool, err error) {
|
||||
b, err := c.o.Store.GetBinding(ctx, p.Binding)
|
||||
if err != nil || !b.Enabled {
|
||||
return false, ErrBindingOff
|
||||
}
|
||||
token, err := c.openToken(ctx, b)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
it := outItem{token: token, Post: p}
|
||||
o := &c.outbox
|
||||
o.mu.Lock()
|
||||
q := o.get(b.ID)
|
||||
q.prune(c.o.Clock())
|
||||
if len(q.queue) == 0 && len(q.sent) < outboxPerMinute {
|
||||
q.sent = append(q.sent, c.o.Clock())
|
||||
o.mu.Unlock()
|
||||
return false, c.sendPost(ctx, b, it)
|
||||
}
|
||||
if len(q.queue) >= outboxMaxQueue {
|
||||
o.mu.Unlock()
|
||||
return false, ErrOutboxFull
|
||||
}
|
||||
q.queue = append(q.queue, it)
|
||||
start := !q.draining
|
||||
q.draining = true
|
||||
o.mu.Unlock()
|
||||
if start && !c.spawn(func() { c.drain(b) }) {
|
||||
o.mu.Lock()
|
||||
q.draining = false
|
||||
o.mu.Unlock()
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Connectors) sendPost(ctx context.Context, b connectors.Binding, it outItem) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return c.send(ctx, b, contract.ConnectorRequest{Op: contract.ConnectorOpSend, Token: it.token, Chat: it.Chat, Thread: it.Thread, Text: it.Text})
|
||||
}
|
||||
|
||||
// drain sends the queue of a binding as the limit allows, then stops.
|
||||
func (c *Connectors) drain(b connectors.Binding) {
|
||||
o := &c.outbox
|
||||
poll := c.o.OutboxPoll
|
||||
if poll <= 0 {
|
||||
poll = outboxPollEvery
|
||||
}
|
||||
for {
|
||||
o.mu.Lock()
|
||||
q := o.get(b.ID)
|
||||
q.prune(c.o.Clock())
|
||||
if len(q.queue) == 0 {
|
||||
q.draining = false
|
||||
o.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(q.sent) >= outboxPerMinute {
|
||||
o.mu.Unlock()
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case <-time.After(poll):
|
||||
}
|
||||
continue
|
||||
}
|
||||
it := q.queue[0]
|
||||
q.queue = q.queue[1:]
|
||||
q.sent = append(q.sent, c.o.Clock())
|
||||
o.mu.Unlock()
|
||||
if err := c.sendPost(context.Background(), b, it); err != nil {
|
||||
c.log.Warn("studio: could not send a queued connector message", "binding", b.ID, "error", err)
|
||||
if it.OnFail != nil {
|
||||
it.OnFail(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queued is how many messages of a binding wait for the limit.
|
||||
func (c *Connectors) Queued(bindingID string) int {
|
||||
c.outbox.mu.Lock()
|
||||
defer c.outbox.mu.Unlock()
|
||||
return len(c.outbox.get(bindingID).queue)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// The place of a connector chat is where the platform shows it: the chat and
|
||||
// the thread of the last message the bot answered. A tool that runs in the
|
||||
// turn, and the host when a run reports back, need it to write to the same
|
||||
// place. A connector chat is made for a sender or a thread, not for one
|
||||
// platform chat, so the host keeps the place of each chat next to the chat
|
||||
// mapping, under a key that cannot be the ID of a sender. Nothing in the
|
||||
// store changes: the chat column holds "chat\x1fthread".
|
||||
const (
|
||||
placeKeyPrefix = "place:"
|
||||
placeSep = "\x1f"
|
||||
// maxPlaceValue is the width of the chat column of the stores.
|
||||
maxPlaceValue = 128
|
||||
)
|
||||
|
||||
func placeKey(chatID string) string { return placeKeyPrefix + chatID }
|
||||
|
||||
func encodePlace(chat, thread string) string { return chat + placeSep + thread }
|
||||
|
||||
func decodePlace(v string) (chat, thread string, ok bool) {
|
||||
chat, thread, ok = strings.Cut(v, placeSep)
|
||||
return chat, thread, ok && chat != ""
|
||||
}
|
||||
|
||||
// rememberPlace keeps where the message m was written, for the chat chatID of
|
||||
// binding b. A place that does not fit the store is not kept: the chat then
|
||||
// has no place, and what needs one falls back to the Inbox.
|
||||
func (c *Connectors) rememberPlace(ctx context.Context, b connectors.Binding, chatID string, m contract.ConnectorMessage) {
|
||||
key, val := placeKey(chatID), encodePlace(m.Chat, m.Thread)
|
||||
if len(key) > contract.MaxConnectorSender || len(val) > maxPlaceValue {
|
||||
return
|
||||
}
|
||||
if cur, ok, err := c.o.Store.ChatOf(ctx, b.ID, key); err == nil && ok && cur == val {
|
||||
return
|
||||
}
|
||||
if err := c.o.Store.SetChat(ctx, b.ID, key, val); err != nil {
|
||||
c.log.Warn("studio: could not keep the place of a connector chat", "binding", b.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PlaceOf returns the platform chat and thread where the connector chat chatID
|
||||
// of the binding was last written to. It is false when the chat has none.
|
||||
func (c *Connectors) PlaceOf(ctx context.Context, bindingID, chatID string) (chat, thread string, ok bool) {
|
||||
v, found, err := c.o.Store.ChatOf(ctx, bindingID, placeKey(chatID))
|
||||
if err != nil || !found {
|
||||
return "", "", false
|
||||
}
|
||||
return decodePlace(v)
|
||||
}
|
||||
@@ -102,7 +102,10 @@ type ConnectorOptions struct {
|
||||
// FileCeiling reads the host ceiling of a connector file from the owner
|
||||
// policy, in bytes. Nil or zero: contract.DefaultFileCeilingBytes.
|
||||
FileCeiling func(ctx context.Context) (int64, error)
|
||||
Logger *slog.Logger
|
||||
// OutboxPoll is how often a queued message looks again for room under
|
||||
// the limit of its binding; zero means one second.
|
||||
OutboxPoll time.Duration
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Connectors runs the bindings of the chats. A message becomes a chat of the
|
||||
@@ -140,6 +143,8 @@ type Connectors struct {
|
||||
emu sync.Mutex
|
||||
nextEdit map[string]time.Time // by binding: when its next edit may go
|
||||
|
||||
outbox outboxes // text the host writes on its own, by binding
|
||||
|
||||
amu sync.Mutex
|
||||
attached map[string][]string // by chat: files that attach_file marked for the answer of the turn
|
||||
|
||||
@@ -729,6 +734,11 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str
|
||||
return
|
||||
}
|
||||
}
|
||||
// "confirm 7K2Q" or "cancel 7K2Q" answers a proposal that this binding made;
|
||||
// it is decided here, before any model turn, and the model never reads it.
|
||||
if len(m.Media) == 0 && c.answerPhrase(ctx, b, m, say) {
|
||||
return
|
||||
}
|
||||
// A scoped agent whose folder is missing does not run, on any frontend
|
||||
// or worker: one plain reply, and the binding stays on, so it works
|
||||
// again once the folder is back.
|
||||
@@ -768,6 +778,7 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str
|
||||
say(sentUnavailable)
|
||||
return
|
||||
}
|
||||
c.rememberPlace(ctx, b, chatID, m)
|
||||
// One reply goroutine serves a conversation; the rate limit stays the sender's.
|
||||
// The earlier messages of a thread go to the first turn of a chat that
|
||||
// the thread just started, once.
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/notify"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// postAddon is a connector add-on of a made-up platform. It records what the
|
||||
// host writes, where, so the specs show what a person reads in a chat.
|
||||
type postAddon struct {
|
||||
mu sync.Mutex
|
||||
sent []contract.ConnectorRequest
|
||||
}
|
||||
|
||||
func (a *postAddon) Connector(ctx context.Context, req contract.ConnectorRequest) (contract.ConnectorResponse, error) {
|
||||
switch req.Op {
|
||||
case contract.ConnectorOpPoll:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
return contract.ConnectorResponse{NextOffset: req.Offset}, nil
|
||||
case contract.ConnectorOpSend:
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sent = append(a.sent, req)
|
||||
return contract.ConnectorResponse{MessageID: "out"}, nil
|
||||
}
|
||||
return contract.ConnectorResponse{}, nil
|
||||
}
|
||||
|
||||
// texts are the messages written to a chat and thread, oldest first.
|
||||
func (a *postAddon) texts(chat, thread string) []string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
var out []string
|
||||
for _, r := range a.sent {
|
||||
if r.Chat == chat && r.Thread == thread {
|
||||
out = append(out, r.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *postAddon) count() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return len(a.sent)
|
||||
}
|
||||
|
||||
// capSwitch is a CapabilityGate that a spec turns on and off by name.
|
||||
type capSwitch struct {
|
||||
mu sync.Mutex
|
||||
on map[string]bool
|
||||
ask []studio.CapabilityRequest
|
||||
}
|
||||
|
||||
func (g *capSwitch) Allow(_ context.Context, r studio.CapabilityRequest) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.ask = append(g.ask, r)
|
||||
return g.on[r.Capability]
|
||||
}
|
||||
|
||||
func (g *capSwitch) set(name string, on bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.on == nil {
|
||||
g.on = map[string]bool{}
|
||||
}
|
||||
g.on[name] = on
|
||||
}
|
||||
|
||||
func (g *capSwitch) asked(name string) []studio.CapabilityRequest {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
var out []studio.CapabilityRequest
|
||||
for _, r := range g.ask {
|
||||
if r.Capability == name {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var _ = Describe("triggers proposed from a connector chat", func() {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
chatID = "C1"
|
||||
thread = "T1"
|
||||
)
|
||||
var (
|
||||
fake *postAddon
|
||||
gate *capSwitch
|
||||
r *connRig
|
||||
b connectors.Binding
|
||||
args string // the arguments of the next propose_trigger
|
||||
seq int
|
||||
)
|
||||
script := func(_ workspace.Actor, text string) []studiotest.ToolStep {
|
||||
switch {
|
||||
case text == "propose":
|
||||
return []studiotest.ToolStep{{Tool: "propose_trigger", Args: args}, {Say: "I proposed it."}}
|
||||
case text == "list":
|
||||
return []studiotest.ToolStep{{Tool: "list_triggers", Args: `{}`}, {Say: "listed"}}
|
||||
case strings.Contains(text, "RUN-SILENT"):
|
||||
return []studiotest.ToolStep{{Say: "[SILENT]"}}
|
||||
case strings.Contains(text, "RUN-ERR"):
|
||||
return []studiotest.ToolStep{{Err: errors.New("the model broke")}}
|
||||
case strings.Contains(text, "RUN-FAIL"):
|
||||
return []studiotest.ToolStep{{Say: "@channel done: 42"}}
|
||||
case strings.Contains(text, "RUN-LONG"):
|
||||
return []studiotest.ToolStep{{Say: strings.Repeat("x", 5000)}}
|
||||
case strings.Contains(text, "RUN"):
|
||||
return []studiotest.ToolStep{{Say: "result is 42"}}
|
||||
}
|
||||
return []studiotest.ToolStep{{Say: "ok"}}
|
||||
}
|
||||
proposal := func(name, delivery string) string {
|
||||
return `{"name":"` + name + `","prompt":"RUN do the thing","when":"daily 08:00","time_zone":"UTC"` + delivery + `}`
|
||||
}
|
||||
origin := `,"delivery":{"target":"origin"}`
|
||||
|
||||
setup := func() {
|
||||
fake = &postAddon{}
|
||||
gate = &capSwitch{}
|
||||
tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull}, Runtime: fake})
|
||||
r = newConnRigOpts(newShared(), connStore(), tr, script, nil, func(o *studio.ConnectorOptions) {
|
||||
o.OutboxPoll = 10 * time.Millisecond
|
||||
o.RatePerMinute = 100
|
||||
})
|
||||
r.svc.SetCapabilityGate(gate)
|
||||
b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11"}})
|
||||
args = proposal("Digest", origin)
|
||||
seq = 0
|
||||
}
|
||||
say := func(text string) {
|
||||
seq++
|
||||
id := "m" + string(rune('a'+seq))
|
||||
Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{
|
||||
Chat: chatID, Thread: thread, Mention: true, MessageID: id, SenderID: "11", SenderName: "Cy", Text: text}})).To(Succeed())
|
||||
}
|
||||
answered := func(n int) {
|
||||
waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) >= n })
|
||||
}
|
||||
proposals := func() []triggers.Trigger {
|
||||
ps, err := r.host.Proposals(ctx, alice)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return ps
|
||||
}
|
||||
// propose runs a turn that proposes and waits for the code message and the answer.
|
||||
propose := func() triggers.Trigger {
|
||||
before := len(fake.texts(chatID, thread))
|
||||
say("propose")
|
||||
answered(before + 2)
|
||||
ps := proposals()
|
||||
Expect(ps).NotTo(BeEmpty())
|
||||
return ps[len(ps)-1]
|
||||
}
|
||||
chatTools := func() []string {
|
||||
l, err := r.svc.List(ctx, alice, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(l).NotTo(BeEmpty())
|
||||
return r.h.ToolNames(l[0].ID)
|
||||
}
|
||||
|
||||
Describe("the tools", func() {
|
||||
It("are not offered until the capability is on", func() {
|
||||
setup()
|
||||
say("hello")
|
||||
answered(1)
|
||||
names := chatTools()
|
||||
Expect(names).NotTo(ContainElement("propose_trigger"))
|
||||
Expect(names).NotTo(ContainElement("list_triggers"))
|
||||
asked := gate.asked(studio.CapTriggersPropose)
|
||||
Expect(asked).NotTo(BeEmpty())
|
||||
Expect(asked[0].Binding).To(Equal(b.ID))
|
||||
Expect(asked[0].Agent).To(Equal(r.agent.ID))
|
||||
Expect(asked[0].Owner).To(Equal(alice))
|
||||
})
|
||||
|
||||
It("are propose and list once it is on, and never change or delete", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
say("hello")
|
||||
answered(1)
|
||||
names := chatTools()
|
||||
Expect(names).To(ContainElement("propose_trigger"))
|
||||
Expect(names).To(ContainElement("list_triggers"))
|
||||
Expect(names).NotTo(ContainElement("update_trigger"))
|
||||
Expect(names).NotTo(ContainElement("delete_trigger"))
|
||||
})
|
||||
|
||||
It("are not offered to the run of a trigger", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
tr := r.schedule(studio.TriggerInput{AgentID: r.agent.ID, Prompt: "RUN go"})
|
||||
r.due()
|
||||
run := r.waitEnded(tr.ID)
|
||||
Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("propose_trigger"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("a proposal", func() {
|
||||
It("is shown in the chat with its code and waits for an answer", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
p := propose()
|
||||
Expect(p.Enabled).To(BeFalse())
|
||||
Expect(p.Route.Binding).To(Equal(b.ID))
|
||||
Expect(p.Route.Chat).To(Equal(chatID))
|
||||
Expect(p.Route.Thread).To(Equal(thread))
|
||||
Expect(p.Route.Target).To(Equal(triggers.TargetOrigin))
|
||||
Expect(p.Route.Code).To(MatchRegexp(`^[A-Z2-9]{4}$`))
|
||||
Expect(p.Route.Expires.Sub(p.Route.ProposedAt)).To(Equal(24 * time.Hour))
|
||||
Expect(p.AgentID).To(Equal(r.agent.ID))
|
||||
texts := fake.texts(chatID, thread)
|
||||
Expect(texts[0]).To(ContainSubstring("Proposal " + p.Route.Code))
|
||||
Expect(texts[0]).To(ContainSubstring(`confirm ` + p.Route.Code))
|
||||
Expect(texts[0]).To(ContainSubstring("this chat"))
|
||||
Expect(texts[0]).To(ContainSubstring("RUN do the thing"), "the person reads what the run will do")
|
||||
res := r.h.ResultsFor("propose_trigger")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Out).To(ContainSubstring("not active"))
|
||||
Expect(res[0].Out).NotTo(ContainSubstring(p.Route.Code), "the model does not repeat the code")
|
||||
})
|
||||
|
||||
It("defaults to the Inbox", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
args = proposal("Quiet", "")
|
||||
p := propose()
|
||||
Expect(p.Route.TargetOrDefault()).To(Equal(triggers.TargetInbox))
|
||||
Expect(fake.texts(chatID, thread)[0]).To(ContainSubstring("the Inbox"))
|
||||
})
|
||||
|
||||
It("refuses a fourth proposal in an hour from one binding", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
for i, n := range []string{"One", "Two", "Three"} {
|
||||
args = proposal(n, origin)
|
||||
propose()
|
||||
Expect(proposals()).To(HaveLen(i + 1))
|
||||
}
|
||||
args = proposal("Four", origin)
|
||||
before := len(fake.texts(chatID, thread))
|
||||
say("propose")
|
||||
answered(before + 1)
|
||||
Expect(proposals()).To(HaveLen(3))
|
||||
res := r.h.ResultsFor("propose_trigger")
|
||||
Expect(res[len(res)-1].Err).To(MatchError(ContainSubstring("at most 3")))
|
||||
r.sh.clock.Advance(61 * time.Minute)
|
||||
args = proposal("Five", origin)
|
||||
propose()
|
||||
Expect(proposals()).To(HaveLen(4))
|
||||
})
|
||||
|
||||
It("lapses after 24 hours and cannot be confirmed", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
p := propose()
|
||||
r.sh.clock.Advance(24*time.Hour + time.Minute)
|
||||
Expect(proposals()).To(BeEmpty(), "an expired proposal is not listed as waiting")
|
||||
_, err := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
Expect(err).To(MatchError(studio.ErrProposalExpired))
|
||||
v, err := r.host.ProposalStatus(ctx, alice, p.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(v.State).To(Equal(studio.ProposalExpired))
|
||||
before := len(fake.texts(chatID, thread))
|
||||
say("confirm " + p.Route.Code)
|
||||
Expect(fake.texts(chatID, thread)).To(HaveLen(before), "a code that no waiting proposal has goes to the agent: it answers")
|
||||
})
|
||||
|
||||
It("is swept to expired by a pass", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
p := propose()
|
||||
r.sh.clock.Advance(25 * time.Hour)
|
||||
r.pass()
|
||||
got, err := r.sh.trg.GetTrigger(ctx, p.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.Outcome).To(Equal(triggers.OutcomeExpired))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("an answer by text", func() {
|
||||
It("from an allowed sender in the same chat and thread turns the proposal into a trigger, before any model turn", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
p := propose()
|
||||
chats, _ := r.svc.List(ctx, alice, 10)
|
||||
sentBefore := len(r.h.Sent(chats[0].ID))
|
||||
n := len(fake.texts(chatID, thread))
|
||||
say("Confirm " + strings.ToLower(p.Route.Code))
|
||||
answered(n + 1)
|
||||
last := fake.texts(chatID, thread)
|
||||
Expect(last[len(last)-1]).To(ContainSubstring("Confirmed"))
|
||||
Expect(r.h.Sent(chats[0].ID)).To(HaveLen(sentBefore), "the model never read the phrase")
|
||||
list, err := r.host.ListTriggers(ctx, alice)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(list).To(HaveLen(1))
|
||||
Expect(list[0].Enabled).To(BeTrue())
|
||||
Expect(list[0].Route.Target).To(Equal(triggers.TargetOrigin))
|
||||
asked := gate.asked(studio.CapProposalsConfirm)
|
||||
Expect(asked).NotTo(BeEmpty())
|
||||
Expect(asked[0]).To(Equal(studio.CapabilityRequest{Capability: studio.CapProposalsConfirm, Binding: b.ID, Owner: alice, Agent: r.agent.ID, Sender: "11", Kind: "trigger"}))
|
||||
// Single use: the code is spent.
|
||||
n = len(fake.texts(chatID, thread))
|
||||
say("confirm " + p.Route.Code)
|
||||
answered(n + 1)
|
||||
Expect(r.host.Proposals(ctx, alice)).To(BeEmpty())
|
||||
list, _ = r.host.ListTriggers(ctx, alice)
|
||||
Expect(list).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("cancel declines the proposal", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
p := propose()
|
||||
n := len(fake.texts(chatID, thread))
|
||||
say("cancel " + p.Route.Code)
|
||||
answered(n + 1)
|
||||
v, err := r.host.ProposalStatus(ctx, alice, p.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(v.State).To(Equal(studio.ProposalDeclined))
|
||||
})
|
||||
|
||||
It("is refused when the gate does not allow the sender", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
p := propose()
|
||||
n := len(fake.texts(chatID, thread))
|
||||
say("confirm " + p.Route.Code)
|
||||
answered(n + 1)
|
||||
last := fake.texts(chatID, thread)
|
||||
Expect(last[len(last)-1]).To(ContainSubstring("Studio"))
|
||||
Expect(proposals()).To(HaveLen(1), "it still waits")
|
||||
})
|
||||
|
||||
It("is refused from another thread", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
p := propose()
|
||||
Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{
|
||||
Chat: chatID, Thread: "T2", Mention: true, MessageID: "x1", SenderID: "11", Text: "confirm " + p.Route.Code}})).To(Succeed())
|
||||
waitFor("the refusal", func() bool { return len(fake.texts(chatID, "T2")) == 1 })
|
||||
Expect(fake.texts(chatID, "T2")[0]).To(ContainSubstring("same place"))
|
||||
Expect(proposals()).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("with an unknown code is an ordinary message for the agent", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
propose()
|
||||
chats, _ := r.svc.List(ctx, alice, 10)
|
||||
before := len(r.h.Sent(chats[0].ID))
|
||||
say("confirm ZZZZ")
|
||||
waitFor("the agent", func() bool { return len(r.h.Sent(chats[0].ID)) == before+1 })
|
||||
Expect(proposals()).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("is audited without any text of the prompt", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapProposalsConfirm, true)
|
||||
p := propose()
|
||||
n := len(fake.texts(chatID, thread))
|
||||
say("confirm " + p.Route.Code)
|
||||
answered(n + 1)
|
||||
evs, err := r.svc.Changes().Events(ctx, alice, 20)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var actions []string
|
||||
for _, e := range evs {
|
||||
if e.Kind == "trigger" {
|
||||
actions = append(actions, e.Action)
|
||||
Expect(e.Hash).NotTo(BeEmpty())
|
||||
Expect(e.Detail).NotTo(ContainSubstring("RUN do the thing"))
|
||||
Expect(e.Source.ID).To(Equal(b.ID))
|
||||
}
|
||||
}
|
||||
Expect(actions).To(ContainElements("propose", "confirm"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the report of a run", func() {
|
||||
// confirmed makes a confirmed trigger that reports as asked, and
|
||||
// returns it. The clock stands before its first due time.
|
||||
confirmed := func(name, delivery string) triggers.Trigger {
|
||||
args = proposal(name, delivery)
|
||||
p := propose()
|
||||
t, err := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return t
|
||||
}
|
||||
runOnce := func(t triggers.Trigger) triggers.Run {
|
||||
r.sh.clock.Advance(25 * time.Hour)
|
||||
r.pass()
|
||||
return r.waitEnded(t.ID)
|
||||
}
|
||||
reports := func() []string {
|
||||
var out []string
|
||||
for _, s := range fake.texts(chatID, thread) {
|
||||
if strings.HasPrefix(s, "Digest\n\n") {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
It("is written back to the chat and thread of the proposal", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
t := confirmed("Digest", origin)
|
||||
run := runOnce(t)
|
||||
Expect(run.Status).To(Equal(triggers.StatusDone))
|
||||
waitFor("the report", func() bool { return len(reports()) == 1 })
|
||||
Expect(reports()[0]).To(Equal("Digest\n\nresult is 42"))
|
||||
Expect(r.notes()).To(BeEmpty(), "a report that reached the chat is not repeated in the Inbox")
|
||||
})
|
||||
|
||||
It("goes to the Inbox by default", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
t := confirmed("Digest", "")
|
||||
runOnce(t)
|
||||
waitFor("the notice", func() bool { return len(r.notes()) == 1 })
|
||||
Expect(reports()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("says nothing for a silent run, and logs it", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
args = `{"name":"Digest","prompt":"RUN-SILENT","when":"daily 08:00","time_zone":"UTC","delivery":{"target":"origin"}}`
|
||||
p := propose()
|
||||
t, err := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
run := runOnce(t)
|
||||
Expect(run.Status).To(Equal(triggers.StatusDone))
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
Expect(reports()).To(BeEmpty())
|
||||
Expect(r.notes()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("breaks mentions of everyone and cuts a long text", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
args = `{"name":"Digest","prompt":"RUN-FAIL","when":"daily 08:00","time_zone":"UTC","delivery":{"target":"origin"}}`
|
||||
p := propose()
|
||||
t, _ := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
runOnce(t)
|
||||
waitFor("the report", func() bool { return len(reports()) == 1 })
|
||||
Expect(reports()[0]).NotTo(ContainSubstring("@channel"))
|
||||
Expect(reports()[0]).To(ContainSubstring("done: 42"))
|
||||
})
|
||||
|
||||
It("cuts a long answer", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
args = `{"name":"Digest","prompt":"RUN-LONG","when":"daily 08:00","time_zone":"UTC","delivery":{"target":"origin"}}`
|
||||
p := propose()
|
||||
t, _ := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
runOnce(t)
|
||||
waitFor("the report", func() bool { return len(reports()) == 1 })
|
||||
Expect([]rune(reports()[0])).To(HaveLen(len("Digest\n\n") + studio.MaxReportRunes + 1))
|
||||
})
|
||||
|
||||
It("on_problem stays silent for a good run", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
t := confirmed("Digest", `,"delivery":{"target":"origin","mode":"on_problem"}`)
|
||||
runOnce(t)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
Expect(reports()).To(BeEmpty())
|
||||
Expect(r.notes()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("on_change reports the first run and then only a different answer", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
t := confirmed("Digest", `,"delivery":{"target":"origin","mode":"on_change"}`)
|
||||
runOnce(t)
|
||||
waitFor("the first report", func() bool { return len(reports()) == 1 })
|
||||
runOnce(t)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
Expect(reports()).To(HaveLen(1), "same answer, nothing new")
|
||||
})
|
||||
|
||||
It("falls back to the Inbox when the binding is off", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
t := confirmed("Digest", origin)
|
||||
_, err := r.conns.SetConnectorEnabled(ctx, alice, b.ID, false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
runOnce(t)
|
||||
waitFor("the notice", func() bool { return len(r.notes()) == 1 })
|
||||
Expect(reports()).To(BeEmpty())
|
||||
Expect(r.notes()[0].Kind).To(Equal(notify.KindRunEnded))
|
||||
})
|
||||
|
||||
It("pauses with the capability off, keeps the record, and resumes without replay", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, false)
|
||||
t := confirmed("Digest", origin)
|
||||
r.sh.clock.Advance(25 * time.Hour)
|
||||
r.pass()
|
||||
run := r.waitEnded(t.ID)
|
||||
Expect(run.Status).To(Equal(triggers.StatusSkipped))
|
||||
Expect(run.Reason).To(Equal("paused: capability off"))
|
||||
got, err := r.host.GetTrigger(ctx, alice, t.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.Enabled).To(BeTrue(), "the record is unchanged")
|
||||
Expect(r.notes()).To(BeEmpty(), "a pause is not a failure")
|
||||
// Three more days pass while it is off: nothing is replayed once it is on.
|
||||
r.sh.clock.Advance(72 * time.Hour)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
r.pass()
|
||||
runs, _ := r.host.TriggerRuns(ctx, alice, t.ID, 10)
|
||||
ran := 0
|
||||
for _, x := range runs {
|
||||
if x.Status == triggers.StatusDone || x.Status == triggers.StatusRunning {
|
||||
ran++
|
||||
}
|
||||
}
|
||||
Expect(ran).To(BeNumerically("<=", 1), "runs = %+v", runs)
|
||||
})
|
||||
|
||||
It("alerts once after three failures in a row and not again within six hours", func() {
|
||||
setup()
|
||||
gate.set(studio.CapTriggersPropose, true)
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
args = `{"name":"Digest","prompt":"RUN-ERR","when":"0 * * * *","time_zone":"UTC","delivery":{"target":"origin"}}`
|
||||
p := propose()
|
||||
t, err := r.host.ConfirmProposal(ctx, alice, p.ID, "UTC")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
alerts := func() int {
|
||||
n := 0
|
||||
for _, s := range fake.texts(chatID, thread) {
|
||||
if strings.Contains(s, "failed 3 times in a row") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
failRun := func(advance time.Duration, want int) {
|
||||
r.sh.clock.Advance(advance)
|
||||
r.pass()
|
||||
until("the run to fail", func() bool {
|
||||
runs, _ := r.host.TriggerRuns(ctx, alice, t.ID, 20)
|
||||
failed := 0
|
||||
for _, x := range runs {
|
||||
if x.Status == triggers.StatusFailed {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
return failed >= want
|
||||
})
|
||||
}
|
||||
failRun(61*time.Minute, 1)
|
||||
failRun(60*time.Minute, 2)
|
||||
Expect(alerts()).To(Equal(0))
|
||||
failRun(60*time.Minute, 3)
|
||||
waitFor("the alert", func() bool { return alerts() == 1 })
|
||||
failRun(60*time.Minute, 4)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
Expect(alerts()).To(Equal(1), "throttled")
|
||||
failRun(6*time.Hour, 5)
|
||||
waitFor("the second alert", func() bool { return alerts() == 2 })
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the outbox", func() {
|
||||
It("sends 20 messages a minute for a binding and queues the rest in order", func() {
|
||||
setup()
|
||||
for i := 0; i < 25; i++ {
|
||||
_, err := r.conns.Post(ctx, studio.Post{Binding: b.ID, Chat: "Q", Text: "m" + string(rune('A'+i))})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}
|
||||
Expect(fake.texts("Q", "")).To(HaveLen(20))
|
||||
Expect(r.conns.Queued(b.ID)).To(Equal(5))
|
||||
r.sh.clock.Advance(61 * time.Second)
|
||||
waitFor("the queue to drain", func() bool { return len(fake.texts("Q", "")) == 25 })
|
||||
got := fake.texts("Q", "")
|
||||
Expect(got[20:]).To(Equal([]string{"mU", "mV", "mW", "mX", "mY"}))
|
||||
Expect(r.conns.Queued(b.ID)).To(Equal(0))
|
||||
})
|
||||
|
||||
It("refuses a binding that is off", func() {
|
||||
setup()
|
||||
_, err := r.conns.SetConnectorEnabled(ctx, alice, b.ID, false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = r.conns.Post(ctx, studio.Post{Binding: b.ID, Chat: "Q", Text: "x"})
|
||||
Expect(err).To(MatchError(studio.ErrBindingOff))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,12 @@ const (
|
||||
ActionExpire = "expire"
|
||||
ActionStale = "stale"
|
||||
ActionRevert = "revert"
|
||||
// The actions of the reports that a trigger made from a connector chat
|
||||
// sends: a run delivered, a trigger paused because its capability is
|
||||
// off, and resumed.
|
||||
ActionDeliver = "deliver"
|
||||
ActionPause = "pause"
|
||||
ActionResume = "resume"
|
||||
)
|
||||
|
||||
// Store keeps proposals and their audit. It is safe for concurrent use.
|
||||
|
||||
@@ -20,6 +20,8 @@ const (
|
||||
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,
|
||||
@@ -57,6 +59,8 @@ func stateOf(t triggers.Trigger) ProposalState {
|
||||
return ProposalDeclined
|
||||
case t.Outcome == triggers.OutcomeUndone:
|
||||
return ProposalUndone
|
||||
case t.Outcome == triggers.OutcomeExpired:
|
||||
return ProposalExpired
|
||||
}
|
||||
return ProposalAccepted
|
||||
}
|
||||
@@ -98,6 +102,7 @@ func (h *Triggers) ProposalStatus(ctx context.Context, user, id string) (Proposa
|
||||
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)
|
||||
@@ -173,8 +178,9 @@ func (h *Triggers) ReopenProposal(ctx context.Context, user, id string) (trigger
|
||||
return triggers.Trigger{}, err
|
||||
}
|
||||
pending := 0
|
||||
now := h.o.Clock()
|
||||
for _, m := range mine {
|
||||
if m.Pending() {
|
||||
if m.Pending() && !m.Expired(now) {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
@@ -182,6 +188,10 @@ func (h *Triggers) ReopenProposal(ctx context.Context, user, id string) (trigger
|
||||
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
|
||||
}
|
||||
@@ -238,6 +248,8 @@ func (h *Triggers) ProposalNote(ctx context.Context, user, chatID string) string
|
||||
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, "; ") + "."
|
||||
|
||||
@@ -316,11 +316,12 @@ type Service struct {
|
||||
admit sync.Mutex
|
||||
|
||||
// Sub-agents; see subagent.go.
|
||||
changes *Changes // the change proposals
|
||||
triggers *Triggers // set once by SetTriggers
|
||||
loops *Loops // set once by SetLoops
|
||||
jobs *Jobs // set once by SetJobs
|
||||
connectors *Connectors // set once by SetConnectors
|
||||
changes *Changes // the change proposals
|
||||
triggers *Triggers // set once by SetTriggers
|
||||
loops *Loops // set once by SetLoops
|
||||
jobs *Jobs // set once by SetJobs
|
||||
connectors *Connectors // set once by SetConnectors
|
||||
capGate CapabilityGate // set once by SetCapabilityGate; nil denies
|
||||
ctxfiles *contextfiles.Loader
|
||||
conversion ConversionSource // set once by SetConversion
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/notify"
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// What a trigger that a connector chat proposed is bound by.
|
||||
const (
|
||||
// ProposalTTL is how long such a proposal waits for an answer.
|
||||
ProposalTTL = 24 * time.Hour
|
||||
// MaxProposalsPerHour is how many proposals one binding may make in an
|
||||
// hour, whoever writes to it.
|
||||
MaxProposalsPerHour = 3
|
||||
// MaxReportRunes bounds the text of a run that is written to a chat.
|
||||
MaxReportRunes = 4000
|
||||
// SilentMark is the whole final message of a run that has nothing to
|
||||
// say: it is logged and delivers nothing.
|
||||
SilentMark = "[SILENT]"
|
||||
|
||||
// failureAlertAfter is how many failed runs in a row raise an alert, and
|
||||
// failureAlertEvery how often one trigger may raise it.
|
||||
failureAlertAfter = 3
|
||||
failureAlertEvery = 6 * time.Hour
|
||||
|
||||
reasonCapOff = "paused: capability off"
|
||||
codeLen = 4
|
||||
// codeAlphabet leaves out the letters and digits that look alike.
|
||||
codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
)
|
||||
|
||||
// auditKindTrigger is the Kind of the audit lines about triggers.
|
||||
const auditKindTrigger contract.ChangeKind = "trigger"
|
||||
|
||||
// Errors of the proposals that connector chats make.
|
||||
var (
|
||||
// ErrProposalExpired means nobody answered the proposal in time.
|
||||
ErrProposalExpired = errors.New("studio: this proposal expired")
|
||||
// ErrProposalRate means a binding made the most proposals of the hour.
|
||||
ErrProposalRate = fmt.Errorf("studio: at most %d trigger proposals an hour can come from one chat connection; try again later", MaxProposalsPerHour)
|
||||
)
|
||||
|
||||
// proposalsInHour counts the proposals of a binding made in the hour before
|
||||
// now, answered or not: the limit is about asking, not about waiting.
|
||||
func proposalsInHour(all []triggers.Trigger, binding string, now time.Time) int {
|
||||
n := 0
|
||||
for _, t := range all {
|
||||
if t.Proposed && !t.IsChange() && t.Route.Binding == binding && now.Sub(t.Route.ProposedAt) < time.Hour {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// freshCode returns a code that no waiting proposal of the binding has.
|
||||
func freshCode(all []triggers.Trigger, binding string) string {
|
||||
used := map[string]bool{}
|
||||
for _, t := range all {
|
||||
if t.Route.Binding == binding && t.Route.Code != "" {
|
||||
used[t.Route.Code] = true
|
||||
}
|
||||
}
|
||||
for {
|
||||
var b [codeLen]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
for i := range b {
|
||||
b[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
|
||||
}
|
||||
if c := string(b[:]); !used[c] {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// audit writes one line about a trigger that a connector chat is part of. The
|
||||
// line has a hash of the prompt, never text.
|
||||
func (h *Triggers) audit(ctx context.Context, action string, t triggers.Trigger, detail string) {
|
||||
ch := h.svc.changes
|
||||
if ch == nil {
|
||||
return
|
||||
}
|
||||
ev := proposals.Event{At: h.o.Clock().UTC(), Owner: t.Owner, Action: action, Proposal: t.ID, Kind: auditKindTrigger,
|
||||
Target: t.Name, Agent: t.AgentID, Hash: hashOf(t.Prompt), Detail: detail,
|
||||
Source: contract.ChangeSource{Kind: "connector", ID: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread}}
|
||||
if err := ch.store.AddEvent(ctx, ev); err != nil {
|
||||
h.log.Warn("studio: could not write the trigger audit", "action", action, "trigger", t.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lapse marks a waiting proposal that is past its time as expired, and returns
|
||||
// the record as it is after. Another frontend may have done it first; either
|
||||
// way the record that is returned is the stored one.
|
||||
func (h *Triggers) lapse(ctx context.Context, t triggers.Trigger) triggers.Trigger {
|
||||
now := h.o.Clock().UTC()
|
||||
if !t.Expired(now) {
|
||||
return t
|
||||
}
|
||||
ok, err := h.o.Store.SetOutcome(ctx, t.ID, "", triggers.OutcomeExpired, now)
|
||||
if err != nil {
|
||||
h.log.Warn("studio: could not expire a trigger proposal", "trigger", t.ID, "error", err)
|
||||
return t
|
||||
}
|
||||
if cur, gerr := h.o.Store.GetTrigger(ctx, t.ID); gerr == nil {
|
||||
t = cur
|
||||
}
|
||||
if ok {
|
||||
h.audit(ctx, proposals.ActionExpire, t, "")
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// expireProposals marks every waiting proposal past its time as expired. The
|
||||
// leader calls it in a pass, at most once a minute.
|
||||
func (h *Triggers) expireProposals(ctx context.Context) {
|
||||
h.mu.Lock()
|
||||
if now := h.o.Clock(); now.Sub(h.swept) < time.Minute {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
} else {
|
||||
h.swept = now
|
||||
}
|
||||
h.mu.Unlock()
|
||||
all, err := h.o.Store.AllTriggers(ctx)
|
||||
if err != nil {
|
||||
h.log.Warn("studio: could not list triggers to expire proposals", "error", err)
|
||||
return
|
||||
}
|
||||
now := h.o.Clock()
|
||||
for _, t := range all {
|
||||
if t.Expired(now) {
|
||||
h.lapse(ctx, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Confirmation by text ----
|
||||
|
||||
// Sentences the bot says about a proposal.
|
||||
const (
|
||||
sentProposalGone = "That proposal expired. Ask again if you still want it."
|
||||
sentProposalPlace = "Answer in the same place where it was proposed, or open Studio."
|
||||
sentProposalNoRight = "Only a sender the owner trusts can answer a proposal from here. Open Studio to answer it."
|
||||
sentProposalFailed = "I could not do that: %s"
|
||||
sentProposalCancel = "Cancelled."
|
||||
)
|
||||
|
||||
var phraseRE = regexp.MustCompile(`(?i)^\s*(confirm|cancel)\s+([a-z0-9]{4})\s*$`)
|
||||
|
||||
// ParsePhrase reads "confirm 7K2Q" or "cancel 7K2Q" from the whole text of a
|
||||
// message. The code comes back in upper case.
|
||||
func ParsePhrase(text string) (verb, code string, ok bool) {
|
||||
m := phraseRE.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.ToLower(m[1]), strings.ToUpper(m[2]), true
|
||||
}
|
||||
|
||||
// PhraseFrom is who wrote a confirm or cancel phrase, and where.
|
||||
type PhraseFrom struct {
|
||||
Binding, Owner, Agent string
|
||||
Chat, Thread, Sender string
|
||||
}
|
||||
|
||||
// AnswerByPhrase answers the waiting proposal that has the code, when the
|
||||
// phrase comes from the place it was made, and the sender holds the
|
||||
// capability to confirm. It returns the sentence to write back, and handled
|
||||
// false when no proposal of the binding has that code: the message is then an
|
||||
// ordinary one. A proposal is answered once; every attempt is audited.
|
||||
func (h *Triggers) AnswerByPhrase(ctx context.Context, from PhraseFrom, verb, code string) (reply string, handled bool) {
|
||||
all, err := h.o.Store.ListTriggers(ctx, principal(from.Owner))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var p triggers.Trigger
|
||||
found := false
|
||||
for _, t := range all {
|
||||
if t.Pending() && t.Route.Binding == from.Binding && t.Route.Code == code {
|
||||
p, found = t, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
detail := "by text from sender " + from.Sender
|
||||
if p.Expired(h.o.Clock()) {
|
||||
h.lapse(ctx, p)
|
||||
return sentProposalGone, true
|
||||
}
|
||||
if p.Route.Chat != from.Chat || p.Route.Thread != from.Thread {
|
||||
h.audit(ctx, proposals.ActionRefuse, p, "wrong place; "+detail)
|
||||
return sentProposalPlace, true
|
||||
}
|
||||
if !h.svc.allowCap(ctx, CapabilityRequest{Capability: CapProposalsConfirm, Binding: from.Binding, Owner: p.Owner, Agent: from.Agent,
|
||||
Sender: from.Sender, Kind: "trigger"}) {
|
||||
h.audit(ctx, proposals.ActionRefuse, p, "not allowed; "+detail)
|
||||
return sentProposalNoRight, true
|
||||
}
|
||||
if verb == "cancel" {
|
||||
if err := h.DismissProposal(ctx, p.Owner, p.ID); err != nil {
|
||||
return fmt.Sprintf(sentProposalFailed, toolErr(err)), true
|
||||
}
|
||||
h.audit(ctx, proposals.ActionDismiss, p, detail)
|
||||
return sentProposalCancel, true
|
||||
}
|
||||
t, err := h.ConfirmProposal(ctx, p.Owner, p.ID, "")
|
||||
if err != nil {
|
||||
h.audit(ctx, proposals.ActionRefuse, p, "confirm failed; "+detail)
|
||||
return fmt.Sprintf(sentProposalFailed, toolErr(err)), true
|
||||
}
|
||||
h.audit(ctx, proposals.ActionConfirm, t, detail)
|
||||
return fmt.Sprintf("Confirmed. %q is on: %s.", t.Name, h.summary(t)), true
|
||||
}
|
||||
|
||||
// proposalText is what the host writes in the chat where a proposal was made.
|
||||
// It shows what the run will do, so a person can answer it from there, and
|
||||
// how to answer.
|
||||
func (h *Triggers) proposalText(t triggers.Trigger) string {
|
||||
prompt := strings.Join(strings.Fields(t.Prompt), " ")
|
||||
if utf8.RuneCountInString(prompt) > 280 {
|
||||
prompt = string([]rune(prompt)[:280]) + "…"
|
||||
}
|
||||
where := "the Inbox in Studio"
|
||||
if t.Route.TargetOrDefault() == triggers.TargetOrigin {
|
||||
where = "this chat"
|
||||
}
|
||||
var mode string
|
||||
switch t.Route.ModeOrDefault() {
|
||||
case triggers.ModeOnChange:
|
||||
mode = ", only when the result changes"
|
||||
case triggers.ModeOnProblem:
|
||||
mode = ", only when a run fails"
|
||||
}
|
||||
return fmt.Sprintf("Proposal %s: %q, %s. It will: %s\nResults go to %s%s.\nNothing runs until it is confirmed. Reply \"confirm %s\" or \"cancel %s\" here within 24 hours, or open Studio.",
|
||||
t.Route.Code, t.Name, strings.ToLower(h.summary(t)[:1])+h.summary(t)[1:], prompt, where, mode, t.Route.Code, t.Route.Code)
|
||||
}
|
||||
|
||||
// Announce writes the proposal, with its code, in the chat where it was made.
|
||||
// It reports whether the text was sent or queued.
|
||||
func (h *Triggers) Announce(ctx context.Context, t triggers.Trigger) bool {
|
||||
c := h.svc.connectors
|
||||
if c == nil || !t.Route.FromConnector() || t.Route.Chat == "" {
|
||||
return false
|
||||
}
|
||||
_, err := c.Post(ctx, Post{Binding: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread, Text: h.proposalText(t)})
|
||||
if err != nil {
|
||||
h.log.Warn("studio: could not write a proposal to its chat", "trigger", t.ID, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Pause ----
|
||||
|
||||
// capOff says whether the runs of a trigger that a connector chat proposed
|
||||
// are paused because its capability is off now. A trigger made in Studio never
|
||||
// is. It writes the audit line when the state changes.
|
||||
func (h *Triggers) capOff(ctx context.Context, t triggers.Trigger) bool {
|
||||
if !t.Route.FromConnector() {
|
||||
return false
|
||||
}
|
||||
off := !h.svc.allowCap(ctx, CapabilityRequest{Capability: CapTriggersRun, Binding: t.Route.Binding, Owner: t.Owner, Agent: t.AgentID})
|
||||
h.mu.Lock()
|
||||
was := h.paused[t.ID]
|
||||
if h.paused == nil {
|
||||
h.paused = map[string]bool{}
|
||||
}
|
||||
h.paused[t.ID] = off
|
||||
h.mu.Unlock()
|
||||
switch {
|
||||
case off && !was:
|
||||
h.audit(ctx, proposals.ActionPause, t, reasonCapOff)
|
||||
case !off && was:
|
||||
h.audit(ctx, proposals.ActionResume, t, "")
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
// ---- Delivery ----
|
||||
|
||||
// neutralMentions breaks the forms that notify a whole channel, so a run's
|
||||
// text cannot ping everyone: a zero-width space after the opening sign.
|
||||
var neutralMentions = regexp.MustCompile(`(?i)(@|<!)(channel|here|everyone|all|subteam)`)
|
||||
|
||||
// reportText is the text of a run as it is written to a chat: at most
|
||||
// MaxReportRunes, with the mentions that notify everyone broken.
|
||||
func reportText(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if utf8.RuneCountInString(s) > MaxReportRunes {
|
||||
s = string([]rune(s)[:MaxReportRunes]) + "…"
|
||||
}
|
||||
return neutralMentions.ReplaceAllString(s, "${1}${2}")
|
||||
}
|
||||
|
||||
// report delivers the end of a successful run as its trigger asks, and says
|
||||
// whether the Inbox notice is left out: it is when the run is silent, when its
|
||||
// mode does not call for it, or when it was written to the chat. When the
|
||||
// delivery fails it returns false so that the Inbox gets the notice.
|
||||
func (h *Triggers) report(ctx context.Context, t triggers.Trigger, run triggers.Run, text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == SilentMark {
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "silent")
|
||||
return true
|
||||
}
|
||||
switch t.Route.ModeOrDefault() {
|
||||
case triggers.ModeOnProblem:
|
||||
return true
|
||||
case triggers.ModeOnChange:
|
||||
if !h.changed(ctx, t, run, text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if t.Route.TargetOrDefault() != triggers.TargetOrigin {
|
||||
return false
|
||||
}
|
||||
body := t.Name + "\n\n" + reportText(text)
|
||||
return h.deliverOrigin(ctx, t, body, func() {
|
||||
h.tell(context.WithoutCancel(ctx), t, run.ChatID, notify.KindRunEnded, notify.StatusOK, t.Name+" finished", text)
|
||||
})
|
||||
}
|
||||
|
||||
// deliverOrigin writes body in the chat where the trigger was proposed. It
|
||||
// checks, now and not when the trigger was made, that the binding is on and
|
||||
// the capability is held. fallback is what runs when a queued message cannot
|
||||
// be sent later. It reports whether the message was sent or queued.
|
||||
func (h *Triggers) deliverOrigin(ctx context.Context, t triggers.Trigger, body string, fallback func()) bool {
|
||||
c := h.svc.connectors
|
||||
switch {
|
||||
case c == nil:
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "refused: connectors are off")
|
||||
return false
|
||||
case !h.svc.allowCap(ctx, CapabilityRequest{Capability: CapTriggersRun, Binding: t.Route.Binding, Owner: t.Owner, Agent: t.AgentID}):
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "refused: capability off")
|
||||
return false
|
||||
}
|
||||
queued, err := c.Post(ctx, Post{Binding: t.Route.Binding, Chat: t.Route.Chat, Thread: t.Route.Thread, Text: body,
|
||||
OnFail: func(error) {
|
||||
h.audit(context.Background(), proposals.ActionDeliver, t, "failed; sent to the Inbox")
|
||||
fallback()
|
||||
}})
|
||||
if err != nil {
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "failed; sent to the Inbox: "+err.Error())
|
||||
return false
|
||||
}
|
||||
if queued {
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "origin, queued")
|
||||
} else {
|
||||
h.audit(ctx, proposals.ActionDeliver, t, "origin, sent")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// changed says whether the answer of a run differs from the one of the run
|
||||
// before it. A run with no earlier answer is a change.
|
||||
func (h *Triggers) changed(ctx context.Context, t triggers.Trigger, run triggers.Run, text string) bool {
|
||||
runs, err := h.o.Store.ListRuns(ctx, t.ID, 5)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, r := range runs {
|
||||
if r.ID == run.ID || r.Status != triggers.StatusDone || r.ChatID == "" {
|
||||
continue
|
||||
}
|
||||
prev := strings.TrimSpace(h.svc.finalText(ctx, r.ChatID))
|
||||
return prev == "" || prev != text
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reportFailure raises the alert for a trigger whose last runs all failed: one
|
||||
// message to the chat of a trigger that reports there, at most once in
|
||||
// failureAlertEvery for each trigger. The Inbox gets every failure already.
|
||||
func (h *Triggers) reportFailure(ctx context.Context, t triggers.Trigger, reason string) {
|
||||
if t.Route.TargetOrDefault() != triggers.TargetOrigin {
|
||||
return
|
||||
}
|
||||
runs, err := h.o.Store.ListRuns(ctx, t.ID, failureAlertAfter)
|
||||
if err != nil || len(runs) < failureAlertAfter {
|
||||
return
|
||||
}
|
||||
for _, r := range runs {
|
||||
if r.Status != triggers.StatusFailed {
|
||||
return
|
||||
}
|
||||
}
|
||||
now := h.o.Clock()
|
||||
h.mu.Lock()
|
||||
if h.alerted == nil {
|
||||
h.alerted = map[string]time.Time{}
|
||||
}
|
||||
if last, ok := h.alerted[t.ID]; ok && now.Sub(last) < failureAlertEvery {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.alerted[t.ID] = now
|
||||
h.mu.Unlock()
|
||||
body := fmt.Sprintf("%s failed %d times in a row: %s\nFix or pause it in Studio.", t.Name, failureAlertAfter, reportText(reason))
|
||||
h.deliverOrigin(ctx, t, body, func() {})
|
||||
}
|
||||
@@ -41,6 +41,8 @@ type triggerRow struct {
|
||||
// Change is the JSON of Trigger.Change, empty on a record that is not a
|
||||
// change.
|
||||
Change string `gorm:"size:1024"`
|
||||
// Route is the JSON of Trigger.Route, empty on a trigger made in Studio.
|
||||
Route string `gorm:"size:1024"`
|
||||
}
|
||||
|
||||
func (triggerRow) TableName() string { return "studio_triggers" }
|
||||
@@ -96,7 +98,7 @@ func (r triggerRow) trigger() Trigger {
|
||||
Schedule: r.Schedule, TZ: r.TZ, Folder: r.Folder, Quiet: time.Duration(r.QuietNs), DailyCap: r.DailyCap,
|
||||
Since: utc(r.Since), AgentID: r.AgentID, SourceChatID: r.SourceChatID, Model: r.Model, Proposed: r.Proposed,
|
||||
MinGap: time.Duration(r.MinGapNs), Reads: r.Reads, Writes: r.Writes, Widget: r.Widget, EstTokens: r.EstTokens,
|
||||
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change)}
|
||||
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change), Route: routeOf(r.Route)}
|
||||
}
|
||||
|
||||
func changeOf(raw string) Change {
|
||||
@@ -115,12 +117,28 @@ func changeText(c Change) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func routeOf(raw string) Route {
|
||||
var r Route
|
||||
if raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &r) // a broken value reads as a trigger made in Studio
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func routeText(r Route) string {
|
||||
if r == (Route{}) {
|
||||
return ""
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func (s *gormStore) PutTrigger(ctx context.Context, t Trigger) error {
|
||||
row := triggerRow{ID: t.ID, Owner: t.Owner, Name: t.Name, Prompt: t.Prompt, Enabled: t.Enabled, Kind: string(t.Kind),
|
||||
Schedule: t.Schedule, TZ: t.TZ, Folder: t.Folder, QuietNs: int64(t.Quiet), DailyCap: t.DailyCap,
|
||||
Since: utc(t.Since), AgentID: t.AgentID, SourceChatID: t.SourceChatID, Model: t.Model, Proposed: t.Proposed,
|
||||
MinGapNs: int64(t.MinGap), Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens,
|
||||
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change)}
|
||||
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change), Route: routeText(t.Route)}
|
||||
return s.db.WithContext(ctx).Save(&row).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,11 @@ func utc(t time.Time) time.Time {
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func normTrigger(t Trigger) Trigger { t.Since, t.OutcomeAt = utc(t.Since), utc(t.OutcomeAt); return t }
|
||||
func normTrigger(t Trigger) Trigger {
|
||||
t.Since, t.OutcomeAt = utc(t.Since), utc(t.OutcomeAt)
|
||||
t.Route.ProposedAt, t.Route.Expires = utc(t.Route.ProposedAt), utc(t.Route.Expires)
|
||||
return t
|
||||
}
|
||||
|
||||
func normRun(r Run) Run {
|
||||
r.Due, r.Started, r.Ended = utc(r.Due), utc(r.Started), utc(r.Ended)
|
||||
|
||||
@@ -54,7 +54,12 @@ func same(a, b triggers.Trigger) bool {
|
||||
a.Enabled == b.Enabled && a.Kind == b.Kind && a.Schedule == b.Schedule && a.TZ == b.TZ &&
|
||||
a.Folder == b.Folder && a.Quiet == b.Quiet && a.DailyCap == b.DailyCap && a.Since.Equal(b.Since) &&
|
||||
a.AgentID == b.AgentID && a.SourceChatID == b.SourceChatID && a.Model == b.Model && a.Proposed == b.Proposed &&
|
||||
a.Outcome == b.Outcome && a.OutcomeAt.Equal(b.OutcomeAt) && a.Touched == b.Touched && a.Change == b.Change
|
||||
a.Outcome == b.Outcome && a.OutcomeAt.Equal(b.OutcomeAt) && a.Touched == b.Touched && a.Change == b.Change && sameRoute(a.Route, b.Route)
|
||||
}
|
||||
|
||||
func sameRoute(a, b triggers.Route) bool {
|
||||
return a.Binding == b.Binding && a.Chat == b.Chat && a.Thread == b.Thread && a.Code == b.Code &&
|
||||
a.ProposedAt.Equal(b.ProposedAt) && a.Expires.Equal(b.Expires) && a.Target == b.Target && a.Mode == b.Mode
|
||||
}
|
||||
|
||||
func sameRun(a, b triggers.Run) bool {
|
||||
@@ -83,6 +88,22 @@ func RunStoreSuite(newStore NewStore, opts ...Option) {
|
||||
Expect(same(got, tr)).To(BeTrue(), "GetTrigger(%s) = %+v; want %+v", tr.ID, got, tr)
|
||||
}
|
||||
})
|
||||
It("stores the connector place and the delivery of a trigger", func() {
|
||||
s := newStore()
|
||||
tr := sample("r1", "1", "Morning")
|
||||
tr.Enabled, tr.Proposed = false, true
|
||||
tr.Route = triggers.Route{Binding: "b1", Chat: "C1", Thread: "T1", Code: "7K2Q", ProposedAt: base, Expires: base.Add(24 * time.Hour),
|
||||
Target: triggers.TargetOrigin, Mode: triggers.ModeOnChange}
|
||||
Expect(s.PutTrigger(ctx, tr)).To(Succeed())
|
||||
got, err := s.GetTrigger(ctx, tr.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(same(got, tr)).To(BeTrue(), "GetTrigger = %+v; want %+v", got.Route, tr.Route)
|
||||
plain := sample("r2", "1", "Plain")
|
||||
Expect(s.PutTrigger(ctx, plain)).To(Succeed())
|
||||
got, err = s.GetTrigger(ctx, plain.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.Route).To(Equal(triggers.Route{}), "a trigger made in Studio has no place")
|
||||
})
|
||||
It("stores a change record with its change", func() {
|
||||
s := newStore()
|
||||
tr := sample("c1", "1", "Morning")
|
||||
|
||||
@@ -135,6 +135,98 @@ type Trigger struct {
|
||||
// that a model asked for in a chat (see Change). Such a record is also
|
||||
// Proposed and never enabled, so it never runs. The package only stores it.
|
||||
Change Change
|
||||
// Route says where a trigger that a connector chat proposed came from and
|
||||
// where its runs report. The zero value is a trigger made in Studio, whose
|
||||
// runs go to the Inbox. The package only stores it.
|
||||
Route Route
|
||||
}
|
||||
|
||||
// Where the runs of a trigger report. Inbox is the default: the owner's Inbox
|
||||
// and the chat the owner chose for notices. Origin is the chat and thread
|
||||
// where the trigger was proposed.
|
||||
const (
|
||||
TargetInbox = "inbox"
|
||||
TargetOrigin = "origin"
|
||||
)
|
||||
|
||||
// When a run is reported. Always reports every run, OnChange a run whose
|
||||
// answer differs from the one before, and OnProblem only a run that failed.
|
||||
const (
|
||||
ModeAlways = "always"
|
||||
ModeOnChange = "on_change"
|
||||
ModeOnProblem = "on_problem"
|
||||
)
|
||||
|
||||
// MaxRefBytes bounds the binding, chat and thread of a Route: the width of
|
||||
// the sender column of the connector stores.
|
||||
const MaxRefBytes = 64
|
||||
|
||||
// Route is the connector side of a trigger: the place it was proposed from
|
||||
// and the delivery of its runs. Binding, Chat and Thread name the place in
|
||||
// the host's own words, with no platform in them; they are set only for a
|
||||
// trigger that a connector chat proposed. Code is what a person types in that
|
||||
// place to confirm or cancel the proposal while it waits. Expires is when a
|
||||
// waiting proposal lapses; zero means it never does.
|
||||
type Route struct {
|
||||
Binding string `json:",omitempty"`
|
||||
Chat string `json:",omitempty"`
|
||||
Thread string `json:",omitempty"`
|
||||
Code string `json:",omitempty"`
|
||||
// ProposedAt is when the proposal was made.
|
||||
ProposedAt time.Time `json:",omitzero"`
|
||||
Expires time.Time `json:",omitzero"`
|
||||
// Target and Mode are the delivery; empty means TargetInbox and
|
||||
// ModeAlways.
|
||||
Target string `json:",omitempty"`
|
||||
Mode string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// FromConnector says whether a connector chat proposed the trigger.
|
||||
func (r Route) FromConnector() bool { return r.Binding != "" }
|
||||
|
||||
// TargetOrDefault gives Target, or TargetInbox when it is empty.
|
||||
func (r Route) TargetOrDefault() string {
|
||||
if r.Target == "" {
|
||||
return TargetInbox
|
||||
}
|
||||
return r.Target
|
||||
}
|
||||
|
||||
// ModeOrDefault gives Mode, or ModeAlways when it is empty.
|
||||
func (r Route) ModeOrDefault() string {
|
||||
if r.Mode == "" {
|
||||
return ModeAlways
|
||||
}
|
||||
return r.Mode
|
||||
}
|
||||
|
||||
// Validate checks the delivery and the place of a Route.
|
||||
func (r Route) Validate() error {
|
||||
switch r.Target {
|
||||
case "", TargetInbox:
|
||||
case TargetOrigin:
|
||||
if r.Binding == "" || r.Chat == "" {
|
||||
return errors.New("a run can report to the chat of the proposal only when a connector chat proposed it")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("delivery target must be %q or %q", TargetInbox, TargetOrigin)
|
||||
}
|
||||
switch r.Mode {
|
||||
case "", ModeAlways, ModeOnChange, ModeOnProblem:
|
||||
default:
|
||||
return fmt.Errorf("delivery mode must be %q, %q or %q", ModeAlways, ModeOnChange, ModeOnProblem)
|
||||
}
|
||||
for _, v := range []string{r.Binding, r.Chat, r.Thread, r.Code} {
|
||||
if len(v) > MaxRefBytes || hasControl(v, false) {
|
||||
return errors.New("the connector place of a trigger is too long or has control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Expired says whether t is a proposal that waited past its time at now.
|
||||
func (t Trigger) Expired(now time.Time) bool {
|
||||
return t.Pending() && !t.Route.Expires.IsZero() && !now.Before(t.Route.Expires)
|
||||
}
|
||||
|
||||
// Change says what a change record does to the trigger it names. While it
|
||||
@@ -175,6 +267,8 @@ const (
|
||||
OutcomeAccepted = "accepted"
|
||||
OutcomeDeclined = "declined"
|
||||
OutcomeUndone = "undone"
|
||||
// OutcomeExpired is a proposal that nobody answered in time.
|
||||
OutcomeExpired = "expired"
|
||||
)
|
||||
|
||||
// Pending says whether t is a proposal that waits for an answer.
|
||||
@@ -262,6 +356,9 @@ func (t Trigger) Validate() error {
|
||||
case t.EstTokens < 0 || t.EstTokens > MaxEstTokens:
|
||||
return fmt.Errorf("estimated tokens must be 0 to %d", MaxEstTokens)
|
||||
}
|
||||
if err := t.Route.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch t.Kind {
|
||||
case KindSchedule:
|
||||
if _, err := ParseSchedule(t.Schedule); err != nil {
|
||||
|
||||
@@ -196,3 +196,28 @@ var _ = Describe("Trigger types", func() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Route", func() {
|
||||
It("reads the empty value as the Inbox, always", func() {
|
||||
var r Route
|
||||
Expect(r.TargetOrDefault()).To(Equal(TargetInbox))
|
||||
Expect(r.ModeOrDefault()).To(Equal(ModeAlways))
|
||||
Expect(r.FromConnector()).To(BeFalse())
|
||||
Expect(r.Validate()).To(Succeed())
|
||||
})
|
||||
It("lets a run report to the origin only when a connector chat proposed it", func() {
|
||||
Expect(Route{Target: TargetOrigin}.Validate()).NotTo(Succeed())
|
||||
Expect(Route{Binding: "b", Chat: "C", Target: TargetOrigin, Mode: ModeOnProblem}.Validate()).To(Succeed())
|
||||
Expect(Route{Target: "elsewhere"}.Validate()).NotTo(Succeed())
|
||||
Expect(Route{Mode: "sometimes"}.Validate()).NotTo(Succeed())
|
||||
})
|
||||
It("expires a waiting proposal that has a time, and no other", func() {
|
||||
now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC)
|
||||
p := Trigger{Proposed: true, Route: Route{Expires: now.Add(time.Hour)}}
|
||||
Expect(p.Expired(now)).To(BeFalse())
|
||||
Expect(p.Expired(now.Add(time.Hour))).To(BeTrue())
|
||||
p.Outcome = OutcomeDeclined
|
||||
Expect(p.Expired(now.Add(2*time.Hour))).To(BeFalse(), "an answered proposal does not expire")
|
||||
Expect(Trigger{Proposed: true}.Expired(now.Add(1000*time.Hour))).To(BeFalse(), "a proposal without a time never does")
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/files"
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
"github.com/mudler/LocalAI/core/services/studio/wellknown"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
@@ -54,6 +55,9 @@ type TriggerInput struct {
|
||||
// file, and about how many tokens one run uses. All optional.
|
||||
Reads, Writes, Widget string
|
||||
EstTokens int
|
||||
// Route is the connector place a proposal came from and where its runs
|
||||
// report. A person's form leaves it empty and an edit keeps the old one.
|
||||
Route triggers.Route
|
||||
}
|
||||
|
||||
// keepPlan fills the fields of the plan and the gap that in leaves empty from
|
||||
@@ -117,6 +121,10 @@ func (h *Triggers) pendingProposal(ctx context.Context, user, id string) (trigge
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.Expired(h.o.Clock()) {
|
||||
h.lapse(ctx, t)
|
||||
return triggers.Trigger{}, ErrProposalExpired
|
||||
}
|
||||
if !t.Pending() {
|
||||
return triggers.Trigger{}, ErrProposalAnswered
|
||||
}
|
||||
@@ -140,7 +148,7 @@ func countLive(ts []triggers.Trigger) int {
|
||||
func (h *Triggers) build(ctx context.Context, owner string, in TriggerInput) (triggers.Trigger, error) {
|
||||
t := triggers.Trigger{Owner: owner, Name: strings.TrimSpace(in.Name), Prompt: in.Prompt, Enabled: in.Enabled, Kind: in.Kind,
|
||||
TZ: in.TZ, Quiet: in.Quiet, DailyCap: in.DailyCap, AgentID: in.AgentID, Model: in.Model, SourceChatID: in.SourceChatID,
|
||||
MinGap: in.MinGap, Reads: strings.TrimSpace(in.Reads), Widget: in.Widget, EstTokens: in.EstTokens}
|
||||
MinGap: in.MinGap, Reads: strings.TrimSpace(in.Reads), Widget: in.Widget, EstTokens: in.EstTokens, Route: in.Route}
|
||||
if w := strings.TrimSpace(in.Writes); w != "" {
|
||||
// A path of the home as the person writes it, stored as they see it:
|
||||
// the widget footer and the proposal card show it back.
|
||||
@@ -269,7 +277,7 @@ func (h *Triggers) UpdateTrigger(ctx context.Context, user, id string, in Trigge
|
||||
if err != nil {
|
||||
return triggers.Trigger{}, err
|
||||
}
|
||||
t.ID = old.ID
|
||||
t.ID, t.Route = old.ID, old.Route
|
||||
// The answer to the proposal stays, and the edit is the reason the card
|
||||
// can no longer undo it.
|
||||
t.Outcome, t.OutcomeAt, t.Touched = old.Outcome, old.OutcomeAt, old.Touched || old.Outcome != ""
|
||||
@@ -323,7 +331,7 @@ func (h *Triggers) HasProposal(ctx context.Context, user, chatID string) bool {
|
||||
return true
|
||||
}
|
||||
return slices.ContainsFunc(all, func(t triggers.Trigger) bool {
|
||||
return (t.Pending() && t.SourceChatID == chatID) || (t.IsChange() && t.Outcome == "" && t.Change.Chat == chatID)
|
||||
return (t.Pending() && !t.Expired(h.o.Clock()) && t.SourceChatID == chatID) || (t.IsChange() && t.Outcome == "" && t.Change.Chat == chatID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,14 +348,23 @@ func (h *Triggers) Proposals(ctx context.Context, user string) ([]triggers.Trigg
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.DeleteFunc(all, func(t triggers.Trigger) bool { return !t.Pending() }), nil
|
||||
now := h.o.Clock()
|
||||
return slices.DeleteFunc(all, func(t triggers.Trigger) bool { return !t.Pending() || t.Expired(now) }), nil
|
||||
}
|
||||
|
||||
// ProposeTrigger stores a trigger that a model proposed. It is never enabled.
|
||||
// Nothing runs until the owner confirms it.
|
||||
// Nothing runs until the owner confirms it. A proposal that a connector chat
|
||||
// made (in.Route names the place) also gets a code to confirm it by text and a
|
||||
// time after which it lapses, and counts against the hourly limit of its
|
||||
// binding.
|
||||
func (h *Triggers) ProposeTrigger(ctx context.Context, user string, in TriggerInput) (triggers.Trigger, error) {
|
||||
owner := principal(user)
|
||||
in.Enabled = false
|
||||
now := h.o.Clock()
|
||||
if in.Route.FromConnector() {
|
||||
in.Route.ProposedAt, in.Route.Expires = now.UTC(), now.UTC().Add(ProposalTTL)
|
||||
in.Route.Code = ""
|
||||
}
|
||||
t, err := h.build(ctx, owner, in)
|
||||
if err != nil {
|
||||
return triggers.Trigger{}, err
|
||||
@@ -363,7 +380,7 @@ func (h *Triggers) ProposeTrigger(ctx context.Context, user string, in TriggerIn
|
||||
}
|
||||
pending := 0
|
||||
for _, m := range mine {
|
||||
if !m.Pending() {
|
||||
if !m.Pending() || m.Expired(now) {
|
||||
continue
|
||||
}
|
||||
// A model that proposes the same thing again, in this chat or the next,
|
||||
@@ -380,10 +397,20 @@ func (h *Triggers) ProposeTrigger(ctx context.Context, user string, in TriggerIn
|
||||
case countLive(mine) >= maxTriggersPerUser:
|
||||
return triggers.Trigger{}, ErrTooManyTriggers
|
||||
}
|
||||
if t.Route.FromConnector() {
|
||||
if proposalsInHour(mine, t.Route.Binding, now) >= MaxProposalsPerHour {
|
||||
h.audit(ctx, proposals.ActionRefuse, t, "too many proposals from this binding in the last hour")
|
||||
return triggers.Trigger{}, ErrProposalRate
|
||||
}
|
||||
t.Route.Code = freshCode(mine, t.Route.Binding)
|
||||
}
|
||||
t.ID, t.Proposed = newID(), true
|
||||
if err := h.o.Store.PutTrigger(ctx, t); err != nil {
|
||||
return triggers.Trigger{}, err
|
||||
}
|
||||
if t.Route.FromConnector() {
|
||||
h.audit(ctx, proposals.ActionPropose, t, "")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -497,7 +524,7 @@ func (h *Triggers) EditProposal(ctx context.Context, user, id string, in Trigger
|
||||
if err := h.checkCadence(t); err != nil {
|
||||
return triggers.Trigger{}, err
|
||||
}
|
||||
t.ID, t.Proposed = old.ID, true
|
||||
t.ID, t.Proposed, t.Route = old.ID, true, old.Route
|
||||
if t.SourceChatID == "" {
|
||||
t.SourceChatID = old.SourceChatID
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ type Triggers struct {
|
||||
|
||||
wmu sync.Mutex // serialises writes of triggers
|
||||
|
||||
// Guarded by mu: when the proposals past their time were last swept, the
|
||||
// triggers whose capability to run is off now, and when a trigger last
|
||||
// raised its failure alert.
|
||||
swept time.Time
|
||||
paused map[string]bool
|
||||
alerted map[string]time.Time
|
||||
|
||||
wake chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
@@ -224,6 +231,7 @@ func (h *Triggers) Pass(ctx context.Context) error {
|
||||
}
|
||||
err = r.Sync(ctx)
|
||||
h.reconcile(ctx)
|
||||
h.expireProposals(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -269,7 +277,7 @@ func (h *Triggers) start(ctx context.Context, t triggers.Trigger, run triggers.R
|
||||
if err != nil && ctx.Err() != nil && !errors.As(err, &skip) {
|
||||
return "", ctx.Err() // the host stops: the Runner puts the run back
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil && !(errors.As(err, &skip) && skip.Reason == reasonCapOff) {
|
||||
h.tell(context.WithoutCancel(ctx), t, "", notify.KindRunFailed, notify.StatusFailed, t.Name+" did not run", err.Error())
|
||||
}
|
||||
return id, err
|
||||
@@ -280,6 +288,12 @@ func (h *Triggers) startRun(ctx context.Context, t triggers.Trigger, run trigger
|
||||
if err := h.checkOwner(ctx, t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A trigger that a connector chat proposed runs only while its capability
|
||||
// is on. The record stays and so does Enabled; a run that falls due while
|
||||
// the capability is off is skipped and is not made up for later.
|
||||
if h.capOff(ctx, t) {
|
||||
return "", &triggers.SkipError{Reason: reasonCapOff}
|
||||
}
|
||||
// The folder of a scoped agent is checked before anything is read for
|
||||
// the run: a run never falls back to the whole home.
|
||||
var scope *folderScope
|
||||
@@ -639,12 +653,16 @@ func (h *Triggers) finishRun(run triggers.Run, t triggers.Trigger, chatID string
|
||||
}
|
||||
if status == triggers.StatusFailed {
|
||||
h.tell(ctx, t, chatID, notify.KindRunFailed, notify.StatusFailed, t.Name+" failed", reason)
|
||||
h.reportFailure(ctx, t, reason)
|
||||
return
|
||||
}
|
||||
body := reason
|
||||
if body == "" {
|
||||
body = h.svc.finalText(ctx, chatID)
|
||||
}
|
||||
if h.report(ctx, t, cur, body) {
|
||||
return
|
||||
}
|
||||
h.tell(ctx, t, chatID, notify.KindRunEnded, notify.StatusOK, t.Name+" finished", body)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,32 @@ const proposeTriggerSchema = `{"type":"object","properties":{` +
|
||||
`"estimated_tokens":{"type":"integer","minimum":0,"maximum":10000000,"description":"About how many tokens one run uses, prompt and answer."}},` +
|
||||
`"required":["name","prompt","when"]}`
|
||||
|
||||
// proposeTriggerConnectorSchema is the schema of propose_trigger in a chat of
|
||||
// a messaging app: the same, with the delivery of the runs.
|
||||
const proposeTriggerConnectorSchema = `{"type":"object","properties":{` +
|
||||
`"name":{"type":"string","minLength":1,"maxLength":80,"description":"A short name for the trigger."},` +
|
||||
`"prompt":{"type":"string","minLength":1,"description":"What the agent must do at each run."},` +
|
||||
`"when":{"description":"A schedule text, or an object with the folder to watch.","oneOf":[` +
|
||||
`{"type":"string"},` +
|
||||
`{"type":"object","properties":{"file_arrives_in":{"type":"string","description":"A folder inside the user's files."}},"required":["file_arrives_in"]}]},` +
|
||||
`"time_zone":{"type":"string","description":"An IANA time zone name such as Europe/Rome; when absent, UTC is used."},` +
|
||||
`"daily_cap":{"type":"integer","minimum":0,"maximum":1000,"description":"The most runs in one day; 0 is the default limit."},` +
|
||||
`"reads":{"type":"string","maxLength":200,"description":"What a run reads, in a few words."},` +
|
||||
`"writes":{"type":"string","maxLength":256,"description":"The file in the user's home that a run rewrites."},` +
|
||||
`"widget":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,39}$","description":"The id of the Home widget that shows the file."},` +
|
||||
`"estimated_tokens":{"type":"integer","minimum":0,"maximum":10000000,"description":"About how many tokens one run uses."},` +
|
||||
`"delivery":{"type":"object","description":"Where the answer of each run goes. Default: the owner's Inbox.","properties":{` +
|
||||
`"target":{"type":"string","enum":["inbox","origin"],"description":"origin writes the answer in this chat and thread, inbox in the owner's Inbox."},` +
|
||||
`"mode":{"type":"string","enum":["always","on_change","on_problem"],"description":"always: every run. on_change: only when the answer differs from the run before. on_problem: only when a run fails."}}}},` +
|
||||
`"required":["name","prompt","when"]}`
|
||||
|
||||
// connectorProposeNote is added to the description of propose_trigger in a
|
||||
// chat of a messaging app.
|
||||
const connectorProposeNote = " This chat is in a messaging app. The host writes the proposal and its confirmation code in the chat itself, " +
|
||||
"and the person answers there with confirm or cancel and the code, so do not repeat the code. " +
|
||||
"Use delivery.target origin when the person wants the result in this chat, and a mode that fits (on_change for a watch, on_problem for a check). " +
|
||||
"A proposal lapses after 24 hours, and at most 3 can be made in an hour."
|
||||
|
||||
const listTriggersDescription = "List the triggers of the user: a ref, the name, when it runs or which folder it watches, whether it is on or off, and how its last run ended. " +
|
||||
"It lists at most 50 and shows no prompts. Use the ref to name a trigger in update_trigger and delete_trigger. " +
|
||||
"To read the prompt and the other settings of ONE trigger before you change it, pass its ref and include_prompt: true."
|
||||
@@ -106,13 +132,13 @@ func (s *Service) registerTriggerTools() error {
|
||||
{proposeTriggerName, "Propose a trigger", proposeTriggerDescription, proposeTriggerSchema, false,
|
||||
func(h *Triggers, a workspace.Actor, c builtins.Chat) func(context.Context, json.RawMessage) (string, error) {
|
||||
return func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return h.proposeTool(ctx, a, c.AgentID, c.Model, raw)
|
||||
return h.proposeTool(ctx, a, c, raw)
|
||||
}
|
||||
}},
|
||||
{listTriggersName, "List triggers", listTriggersDescription, listTriggersSchema, true,
|
||||
func(h *Triggers, a workspace.Actor, c builtins.Chat) func(context.Context, json.RawMessage) (string, error) {
|
||||
return func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
return h.listTool(ctx, a, c.AgentID, raw)
|
||||
return h.listTool(ctx, a, c.AgentID, c.Origin == session.OriginConnector, raw)
|
||||
}
|
||||
}},
|
||||
{updateTriggerName, "Change a trigger", updateTriggerDescription, updateTriggerSchema, false,
|
||||
@@ -136,18 +162,35 @@ func (s *Service) registerTriggerTools() error {
|
||||
Enabled: func() bool { return s.triggers != nil },
|
||||
Build: func(ctx context.Context, c builtins.Chat) (session.Tool, bool, string) {
|
||||
h := s.triggers
|
||||
connector := c.Origin == session.OriginConnector
|
||||
switch {
|
||||
case h == nil:
|
||||
return session.Tool{}, false, "triggers are off on this server"
|
||||
case c.SubAgent:
|
||||
return session.Tool{}, false, "a sub-agent cannot set triggers"
|
||||
case c.Origin != "":
|
||||
case c.Origin != "" && !connector:
|
||||
return session.Tool{}, false, "a run that started by itself cannot set triggers"
|
||||
}
|
||||
if err := s.mayUseTriggers(ctx, c.Actor.Principal); err != nil {
|
||||
return session.Tool{}, false, err.Error()
|
||||
}
|
||||
return session.Tool{Description: d.desc, Schema: json.RawMessage(d.schema), Run: d.run(h, c.Actor, c)}, true, ""
|
||||
desc, schema := d.desc, d.schema
|
||||
if connector {
|
||||
// A chat of a messaging app proposes and lists. A change or a
|
||||
// delete needs a card that only Studio shows, so it stays there.
|
||||
switch {
|
||||
case d.id == updateTriggerName || d.id == deleteTriggerName:
|
||||
return session.Tool{}, false, "change or delete a trigger in Studio"
|
||||
case c.AgentID == "":
|
||||
return session.Tool{}, false, "a trigger from a messaging app needs the agent of the connection"
|
||||
case !s.allowCap(ctx, CapabilityRequest{Capability: CapTriggersPropose, Binding: c.OriginID, Owner: c.Actor.Principal, Agent: c.AgentID}):
|
||||
return session.Tool{}, false, "triggers are off for this connection"
|
||||
}
|
||||
if d.id == proposeTriggerName {
|
||||
desc, schema = desc+connectorProposeNote, proposeTriggerConnectorSchema
|
||||
}
|
||||
}
|
||||
return session.Tool{Description: desc, Schema: json.RawMessage(schema), Run: d.run(h, c.Actor, c)}, true, ""
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -242,7 +285,8 @@ func (h *Triggers) toolTriggers(ctx context.Context, a workspace.Actor, agentID
|
||||
return slices.DeleteFunc(all, func(t triggers.Trigger) bool { return t.AgentID != agentID }), nil
|
||||
}
|
||||
|
||||
func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, agentID, model string, raw json.RawMessage) (string, error) {
|
||||
func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, c builtins.Chat, raw json.RawMessage) (string, error) {
|
||||
agentID, model := c.AgentID, c.Model
|
||||
var args struct {
|
||||
Name string `json:"name"`
|
||||
Prompt string `json:"prompt"`
|
||||
@@ -253,6 +297,10 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, agentID,
|
||||
Writes string `json:"writes"`
|
||||
Widget string `json:"widget"`
|
||||
Tokens int `json:"estimated_tokens"`
|
||||
Delivery *struct {
|
||||
Target string `json:"target"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"delivery"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &args); err != nil {
|
||||
return "", fmt.Errorf("bad arguments: %w", err)
|
||||
@@ -272,6 +320,21 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, agentID,
|
||||
default:
|
||||
return "", errors.New("when must be a schedule text or {\"file_arrives_in\": folder}")
|
||||
}
|
||||
var err error
|
||||
connector := c.Origin == session.OriginConnector
|
||||
if connector {
|
||||
if in.Route, err = h.connectorRoute(ctx, c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if d := args.Delivery; d != nil {
|
||||
in.Route.Target, in.Route.Mode = d.Target, d.Mode
|
||||
}
|
||||
if in.Route.Target == "" {
|
||||
in.Route.Target = triggers.TargetInbox
|
||||
}
|
||||
} else if args.Delivery != nil {
|
||||
return "", errors.New("delivery is for a chat in a messaging app; the answer of a run goes to the Inbox")
|
||||
}
|
||||
sc, err := h.toolScope(ctx, a, agentID)
|
||||
if err != nil {
|
||||
return "", toolErr(err)
|
||||
@@ -285,6 +348,9 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, agentID,
|
||||
if err != nil {
|
||||
return "", errors.New(strings.TrimPrefix(err.Error(), ErrBadTrigger.Error()+": "))
|
||||
}
|
||||
if connector {
|
||||
return h.announceProposal(ctx, a, t, in.Route)
|
||||
}
|
||||
// The card lets the interface show Create, Edit and Dismiss on this tool
|
||||
// line, also after a reload; the event tells a client that is watching.
|
||||
session.SetToolCard(ctx, session.ToolCard{Kind: ProposalCardKind, ID: t.ID})
|
||||
@@ -292,7 +358,7 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, agentID,
|
||||
return fmt.Sprintf("Proposed %q (%s). It is not active: the user must confirm it in the chat or on the Triggers page. Do not say it will run until they do.", t.Name, h.summary(t)), nil
|
||||
}
|
||||
|
||||
func (h *Triggers) listTool(ctx context.Context, a workspace.Actor, agentID string, raw json.RawMessage) (string, error) {
|
||||
func (h *Triggers) listTool(ctx context.Context, a workspace.Actor, agentID string, onlyAgent bool, raw json.RawMessage) (string, error) {
|
||||
var args struct {
|
||||
Ref string `json:"ref"`
|
||||
IncludePrompt bool `json:"include_prompt"`
|
||||
@@ -332,6 +398,9 @@ func (h *Triggers) listTool(ctx context.Context, a workspace.Actor, agentID stri
|
||||
if err != nil {
|
||||
return "", errors.New(strings.TrimPrefix(err.Error(), "studio: "))
|
||||
}
|
||||
if onlyAgent && t.AgentID != agentID {
|
||||
return "", errors.New("no such trigger")
|
||||
}
|
||||
all = []triggers.Trigger{t}
|
||||
case args.IncludePrompt:
|
||||
return "", errors.New("include_prompt needs the ref of one trigger")
|
||||
@@ -340,6 +409,11 @@ func (h *Triggers) listTool(ctx context.Context, a workspace.Actor, agentID stri
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if onlyAgent {
|
||||
// A chat of a messaging app is open to more people than the owner: it
|
||||
// lists only what its own agent runs.
|
||||
all = slices.DeleteFunc(all, func(t triggers.Trigger) bool { return t.AgentID != agentID })
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name })
|
||||
if len(all) > listTriggersMax {
|
||||
all = all[:listTriggersMax]
|
||||
@@ -483,3 +557,34 @@ func (h *Triggers) summary(t triggers.Trigger) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// connectorRoute is the place a trigger proposed in the connector chat c comes
|
||||
// from: the binding, and the chat and thread where the person wrote. A chat
|
||||
// with no known place cannot propose, since the proposal could not be shown.
|
||||
func (h *Triggers) connectorRoute(ctx context.Context, c builtins.Chat) (triggers.Route, error) {
|
||||
conns := h.svc.connectors
|
||||
if conns == nil {
|
||||
return triggers.Route{}, errors.New("connections to messaging apps are off on this server")
|
||||
}
|
||||
chat, thread, ok := conns.PlaceOf(ctx, c.OriginID, c.Actor.ChatID)
|
||||
if !ok {
|
||||
return triggers.Route{}, errors.New("I cannot tell where this conversation is written, so I cannot show a proposal here; ask the owner to propose it in Studio")
|
||||
}
|
||||
return triggers.Route{Binding: c.OriginID, Chat: chat, Thread: thread}, nil
|
||||
}
|
||||
|
||||
// announceProposal writes the proposal in the chat it came from, with its code,
|
||||
// and words the result for the model.
|
||||
func (h *Triggers) announceProposal(ctx context.Context, a workspace.Actor, t triggers.Trigger, here triggers.Route) (string, error) {
|
||||
h.svc.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindTriggerProposed, IDs: []string{t.ID}, Text: t.Name})
|
||||
if t.Route.Binding != here.Binding || t.Route.Chat != here.Chat || t.Route.Thread != here.Thread {
|
||||
// The same proposal was made from another place: its code works there.
|
||||
return fmt.Sprintf("A proposal like %q waits already, from another conversation. It is not active. Tell the person to confirm it in Studio.", t.Name), nil
|
||||
}
|
||||
if h.Announce(ctx, t) {
|
||||
return fmt.Sprintf("Proposed %q (%s). It is not active. The host wrote the proposal and its code in this chat; the person answers there with confirm or cancel and the code, or in Studio. "+
|
||||
"Say in one short sentence that you proposed it; do not repeat the code and do not say it will run until they confirm.", t.Name, h.summary(t)), nil
|
||||
}
|
||||
return fmt.Sprintf("Proposed %q (%s). It is not active. The host could not write the code in this chat. Tell the person to reply \"confirm %s\" or \"cancel %s\" here, or to open Studio. "+
|
||||
"Do not say it will run until they confirm.", t.Name, h.summary(t), t.Route.Code, t.Route.Code), nil
|
||||
}
|
||||
@@ -251,8 +251,10 @@ gives the same events without one.
|
||||
|
||||
- **The agent's tools only.** A message gets exactly the tools the agent grants,
|
||||
intersected with what you may use now. It never gets the tool set of a chat, and
|
||||
a message cannot change the agent. A run started by a connector never gets
|
||||
`propose_trigger`.
|
||||
a message cannot change the agent. A run that a trigger started never gets
|
||||
`propose_trigger`. A connector chat gets `propose_trigger` and `list_triggers`
|
||||
only while the binding holds `triggers.propose` (see
|
||||
[Triggers from a connector chat](#triggers-from-a-connector-chat)).
|
||||
- **Your account.** The chat acts as you, so the feature gate, the model
|
||||
allowlist and your quota apply. If the model refuses the request, the sender
|
||||
gets one fixed sentence, for example that your quota is used up.
|
||||
@@ -479,6 +481,33 @@ The host stores and checks these values and shows them to the add-on pages. The
|
||||
features that a capability switches on or off (triggers from chat, talk-back, and so
|
||||
on) read them as they land.
|
||||
|
||||
## Triggers from a connector chat
|
||||
|
||||
When the binding holds the capability `triggers.propose`, the agent can propose a
|
||||
trigger from a chat in a messaging app. The agent gets `propose_trigger` and
|
||||
`list_triggers` (the list shows only the triggers of that agent). Changing or deleting a
|
||||
trigger stays in Studio. Nothing runs before the owner confirms.
|
||||
|
||||
- **The proposal is shown in the chat.** LocalAI writes the name, the schedule, what the
|
||||
run will do, where the results go and a four-character code in the chat and thread
|
||||
where the person wrote. The model does not write it.
|
||||
- **Confirm or cancel by text.** A sender writes `confirm CODE` or `cancel CODE`, as the
|
||||
whole message, in the same chat and thread. LocalAI handles it before any model turn.
|
||||
It needs the capability `proposals.confirm` for that sender (a paired sender, or a role
|
||||
with the grant). A code is used once, and a proposal lapses after 24 hours. Studio
|
||||
always works as well.
|
||||
- **Limits.** A binding can make 3 proposals an hour, and an owner can have 10 waiting.
|
||||
- **Where a run reports.** The proposal sets `delivery`: `inbox` (the default) or
|
||||
`origin`, the chat and thread of the proposal, and a mode: `always`, `on_change` or
|
||||
`on_problem`. LocalAI writes the last message of the run, cut to 4,000 characters,
|
||||
with mentions that notify everyone broken. A final message of exactly `[SILENT]`
|
||||
writes nothing. After three failed runs in a row the chat gets one alert, and not
|
||||
more than one every 6 hours. If the binding is off or the capability is off when a run
|
||||
ends, the Inbox gets the notice. A binding sends 20 of these messages a minute; more
|
||||
wait in a queue and go in order.
|
||||
- **Pause.** While `triggers.run` is off for the binding, its triggers do not run. The
|
||||
records stay and are not replayed when the capability comes back.
|
||||
|
||||
## Notifications in Telegram
|
||||
|
||||
Set a notification chat ID on a binding to receive your run notifications there
|
||||
|
||||
Reference in new issue
Block a user