Files
LocalAI/core/services/studio/changes.go
T
Ettore Di Giacinto f0caddf8f1 feat(studio): let the assistant propose a text for Knowledge
Add the change kind `knowledge` and the sensitive grant and capability
`knowledge.write`. A text for a collection goes through the same
proposal flow as the other kinds, but it is never applied unasked:
the Ask, Auto and Always confirm levels all wait for the owner, and the
Never level takes the kind away. Confirm adds one entry to the
collection and Undo removes it. Text is capped at 64 KiB.

A paired sender can answer yes, no or undo in a messaging chat; a
sender who is only on the allowlist cannot. The connection page
explains a block with the shared reason codes, plus knowledge_none and
knowledge_no_embedding. The Suggested changes card names the
collection and previews the text. A kind can now declare a destination
other than the home folder, so core code stays generic.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-05 01:21:17 +00:00

648 lines
25 KiB
Go

package studio
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Change proposals: a model asks to change the person's own state, the person
// confirms, and the host writes it as the person. The host knows no chat
// platform. A connector chat, a Studio chat and an add-on page reach it the
// same way, through the propose_change tool of the chat, and the origin of the
// chat is only recorded as the source.
var (
// ErrChangesOff means the proposals cannot work: there are no files.
ErrChangesOff = errors.New("studio: changes cannot be stored because files are off on this server")
// ErrBadChange means a proposal is not valid. The text says why.
ErrBadChange = errors.New("studio: this change is not valid")
// ErrTargetChanged means the target changed after the proposal.
ErrTargetChanged = errors.New("studio: the file changed since this was proposed")
// ErrChangeExpired means the proposal waited longer than a day.
ErrChangeExpired = errors.New("studio: this proposal expired")
// ErrChangeAnswered means the proposal is not waiting for an answer.
ErrChangeAnswered = errors.New("studio: this proposal was already answered")
// ErrNotApplied means only an applied change can be reverted.
ErrNotApplied = errors.New("studio: only a change that was applied can be reverted")
// ErrCannotRevert means later edits of the file stop the revert.
ErrCannotRevert = errors.New("studio: the file was edited since; change it in Files")
// ErrNothingToRevert means the scope has no applied change.
ErrNothingToRevert = errors.New("studio: there is no applied change to revert")
)
// maxSummaryRunes bounds the one line that tells the person what a change is.
const maxSummaryRunes = 160
// maxTargetRead bounds the target file that a proposal reads for its hash.
const maxTargetRead = 1 << 20
// Changes is the host of the change proposals.
type Changes struct {
s *Service
store proposals.Store
gate proposals.Gate
kinds *proposals.Kinds
}
func newChanges(s *Service) (*Changes, error) {
st := s.d.Proposals
if st == nil {
var err error
if st, err = proposals.NewJSON(""); err != nil {
return nil, err
}
}
g := s.d.ProposalGate
if g == nil {
// The grants of the agent are checked in Propose and Revert, for every
// gate. Without a gate of its own the host adds nothing to them.
g = proposals.GateFunc(func(context.Context, proposals.Request) error { return nil })
}
c := &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()}
if err := c.registerChangeKinds(); err != nil {
return nil, err
}
return c, nil
}
// Changes returns the host of the change proposals.
func (s *Service) Changes() *Changes { return s.changes }
// Kinds is the set of kinds the host offers. A feature registers its own.
func (c *Changes) Kinds() *proposals.Kinds { return c.kinds }
// ProposeInput is a proposal as the chat tool gives it.
type ProposeInput struct {
User, Agent string
Source contract.ChangeSource
Kind, Scope string
Target string
Content string
Summary string
// Auto marks a memory note that the host applies at once because the
// person chose so. Only a chat of the person (Source.Kind "") can set it.
Auto bool
}
func hashOf(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func (c *Changes) audit(ctx context.Context, action string, p contract.ChangeProposal, rev int64, detail string) {
if p.Auto && detail == "" {
detail = "automatic: the person chose to save memory notes at once"
}
ev := proposals.Event{At: c.s.now().UTC(), Owner: p.Owner, Action: action, Proposal: p.ID, Kind: p.Kind, Scope: p.Scope,
Target: p.Target, Agent: p.Agent, Source: p.Source, Rev: rev, Hash: hashOf(p.Content), Detail: detail}
if err := c.store.AddEvent(ctx, ev); err != nil {
c.s.log.Warn("studio: could not write the change audit", "action", action, "proposal", p.ID, "error", err)
}
c.s.log.Info("studio: change proposal", "action", action, "proposal", p.ID, "kind", p.Kind, "scope", p.Scope, "agent", p.Agent, "source", p.Source.Kind, "rev", rev)
}
// sweep marks the proposals that waited too long.
func (c *Changes) sweep(ctx context.Context) {
done, err := c.store.Expire(ctx, c.s.now().UTC())
if err != nil {
c.s.log.Warn("studio: could not expire change proposals", "error", err)
return
}
for _, p := range done {
c.audit(ctx, proposals.ActionExpire, p, 0, "")
}
}
func (c *Changes) ready() error {
if !c.s.d.Files.Available() {
return ErrChangesOff
}
return nil
}
func cleanSummary(s string) string {
s = strings.Join(strings.Fields(s), " ")
if utf8.RuneCountInString(s) > maxSummaryRunes {
s = string([]rune(s)[:maxSummaryRunes])
}
return s
}
// Propose stores a proposal. It changes no file.
func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.ChangeProposal, error) {
if err := c.ready(); err != nil {
return contract.ChangeProposal{}, err
}
kind, ok := c.kinds.Lookup(in.Kind)
if !ok {
return contract.ChangeProposal{}, fmt.Errorf("%w: kind must be one of %v", ErrBadChange, c.kinds.Names())
}
if !contract.ValidChangeScope(in.Scope) {
return contract.ChangeProposal{}, fmt.Errorf("%w: scope must be user, agent or channel", ErrBadChange)
}
p := contract.ChangeProposal{V: contract.ChangeProposalVersion, ID: uuid.NewString(), Owner: principal(in.User), Agent: in.Agent,
Kind: kind.Name, Scope: contract.ChangeScope(in.Scope), Summary: cleanSummary(in.Summary), Content: in.Content, Source: in.Source,
Status: contract.ProposalPending, Auto: in.Auto}
if in.Auto && !c.autoAllowed(ctx, in, kind.Name) {
// Defence in depth: the tool decides this too, but nothing but a memory
// note from the person's own chat, or a note to the conversation of a
// paired sender in a private chat that the person allowed, is ever
// applied unasked.
c.audit(ctx, proposals.ActionRefuse, p, 0, "automatic save is for a memory note from the person's own chat")
return contract.ChangeProposal{}, fmt.Errorf("%w: only a memory note from your own chat can be saved at once", ErrBadChange)
}
if err := c.s.checkChangeGrant(ctx, p.Owner, in.Agent, in.Source, kind.Grant); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
if err := c.gate.Allow(ctx, proposals.Request{Action: proposals.GatePropose, User: p.Owner, Agent: in.Agent, Kind: p.Kind, Scope: p.Scope, Source: in.Source}); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
aim := proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source}
rel, err := kind.Path(aim)
if err != nil {
return contract.ChangeProposal{}, fmt.Errorf("%w: %s", ErrBadChange, strings.TrimPrefix(err.Error(), proposals.ErrBadTarget.Error()+": "))
}
p.Target = rel
switch {
case len(in.Content) > kind.Limit():
return contract.ChangeProposal{}, fmt.Errorf("%w: the content is %d bytes and a %s change may have %d", ErrBadChange, len(in.Content), kind.Name, kind.Limit())
case !utf8.ValidString(in.Content) || strings.ContainsRune(in.Content, 0):
return contract.ChangeProposal{}, fmt.Errorf("%w: the content must be text", ErrBadChange)
case p.Summary == "":
return contract.ChangeProposal{}, fmt.Errorf("%w: give a short summary of the change", ErrBadChange)
}
var snap snap
if kind.Dest == nil {
if snap, err = c.snapshot(ctx, p.Owner, kind, rel, in.Content); err != nil {
return contract.ChangeProposal{}, err
}
}
if err := c.check(ctx, p.Owner, kind, aim, rel, in.Content, snap); err != nil {
return contract.ChangeProposal{}, err
}
if err := c.prepare(ctx, kind, p); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
p.BaseHash = snap.hash
if snap.same {
return contract.ChangeProposal{}, fmt.Errorf("%w: %s has this content already", ErrBadChange, rel)
}
c.sweep(ctx)
now := c.s.now().UTC()
p.Created, p.Expires = now, now.Add(contract.ChangeExpiry)
lim := proposals.Limits{MaxPending: contract.MaxPendingChanges, PerWindow: contract.MaxChangesPerHour, Window: time.Hour}
if err := c.store.Create(ctx, p, lim); err != nil {
if errors.Is(err, proposals.ErrTooManyPending) || errors.Is(err, proposals.ErrRateLimited) {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
}
return contract.ChangeProposal{}, err
}
c.audit(ctx, proposals.ActionPropose, p, 0, "")
return p, nil
}
// prepare asks the destination of a kind that has one whether it can take the
// change now. A kind that writes to the home has nothing to ask.
func (c *Changes) prepare(ctx context.Context, kind proposals.Kind, p contract.ChangeProposal) error {
if kind.Dest == nil || kind.Dest.Prepare == nil {
return nil
}
if err := kind.Dest.Prepare(ctx, proposals.ApplyInput{Owner: p.Owner, Proposal: p}); err != nil {
return fmt.Errorf("%w: %s", ErrBadChange, err)
}
return nil
}
// snap is the state of what a change would write, for the base hash.
type snap struct {
hash string // "" when no target file exists
exists bool
same bool // every file already holds the proposed text
current string
}
// filesOf returns the files a content writes: the one target, or the files of
// a kind that writes a folder.
func filesOf(kind proposals.Kind, rel, content string) ([]proposals.FileWrite, error) {
if kind.Files == nil {
return []proposals.FileWrite{{Path: rel, Content: content}}, nil
}
fs, err := kind.Files(content)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrBadChange, err)
}
for i := range fs {
fs[i].Path = rel + "/" + fs[i].Path
}
return fs, nil
}
// snapshot reads what the change would replace. For one file the hash is the
// hash of the file; for a folder it covers the path and hash of each file the
// content names, so a change to any of them makes the proposal stale.
func (c *Changes) snapshot(ctx context.Context, owner string, kind proposals.Kind, rel, content string) (snap, error) {
fs, err := filesOf(kind, rel, content)
if err != nil {
return snap{}, err
}
var out snap
out.same = true
var sum strings.Builder
for _, f := range fs {
cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, f.Path, maxTargetRead)
if err != nil {
return snap{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, f.Path, err)
}
out.exists = out.exists || exists
out.same = out.same && exists && cur == f.Content
if kind.Files == nil {
out.current = cur
if exists {
out.hash = hashOf(cur)
}
return out, nil
}
h := "-"
if exists {
h = hashOf(cur)
}
sum.WriteString(f.Path + "\x00" + h + "\n")
}
if out.exists {
out.hash = hashOf(sum.String())
}
return out, nil
}
// check runs the kind's own rules on a content.
func (c *Changes) check(ctx context.Context, owner string, kind proposals.Kind, aim proposals.Aim, rel, content string, st snap) error {
if kind.Check == nil {
return nil
}
err := kind.Check(proposals.CheckInput{Aim: aim, Path: rel, Content: content, Current: st.current, Exists: st.exists, Read: func(p string) (string, bool, error) {
return c.s.d.Files.ReadHome(ctx, owner, p, maxTargetRead)
}})
if err != nil {
return fmt.Errorf("%w: %s", ErrBadChange, err)
}
return nil
}
// aimOf rebuilds where a stored proposal writes, for the checks at confirm.
func aimOf(p contract.ChangeProposal) proposals.Aim {
return proposals.Aim{Scope: p.Scope, Agent: p.Agent, Target: targetName(p), Source: p.Source}
}
// targetName is the name the model gave, recovered from the stored path: the
// last folder for a skill or a page, which Check compares with its content.
func targetName(p contract.ChangeProposal) string {
t := strings.TrimSuffix(p.Target, "/")
switch p.Kind {
case contract.ChangeSkill:
t = strings.TrimSuffix(t, "/SKILL.md")
case contract.ChangeMemory, contract.ChangeProfile, contract.ChangeDashboard, contract.ChangeKnowledge:
return t
}
if i := strings.LastIndexByte(t, '/'); i >= 0 {
t = t[i+1:]
}
return t
}
// List returns the proposals of the user, newest first, with the expired ones
// settled first.
func (c *Changes) List(ctx context.Context, user string, statuses ...contract.ProposalStatus) ([]contract.ChangeProposal, error) {
c.sweep(ctx)
return c.store.List(ctx, principal(user), statuses...)
}
// Get returns one proposal of the user.
func (c *Changes) Get(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
c.sweep(ctx)
return c.store.Get(ctx, principal(user), id)
}
// Events returns the audit of the user, newest first.
func (c *Changes) Events(ctx context.Context, user string, limit int) ([]proposals.Event, error) {
return c.store.Events(ctx, principal(user), limit)
}
// notPending says why a proposal cannot be answered.
func notPending(p contract.ChangeProposal) error {
switch p.Status {
case contract.ProposalExpired:
return ErrChangeExpired
case contract.ProposalStale:
return ErrTargetChanged
}
return ErrChangeAnswered
}
// Confirm writes a pending proposal as the owner, in one change set.
func (c *Changes) Confirm(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
return c.confirm(ctx, user, id, nil)
}
// ConfirmEdited writes the pending proposal with the owner's own text in place
// of the model's. The text has the same limits as a proposal. The stored
// proposal keeps what the model asked for; the file and the audit hash hold
// what was written.
func (c *Changes) ConfirmEdited(ctx context.Context, user, id, content string) (contract.ChangeProposal, error) {
return c.confirm(ctx, user, id, &content)
}
func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) (contract.ChangeProposal, error) {
if err := c.ready(); err != nil {
return contract.ChangeProposal{}, err
}
owner := principal(user)
p, err := c.Get(ctx, owner, id)
if err != nil {
return contract.ChangeProposal{}, err
}
if p.Status != contract.ProposalPending {
return contract.ChangeProposal{}, notPending(p)
}
kind, ok := c.kinds.Lookup(string(p.Kind))
if !ok {
return contract.ChangeProposal{}, fmt.Errorf("%w: this server no longer offers %s changes", ErrBadChange, p.Kind)
}
orig := p.Content
if edited != nil {
switch {
case len(*edited) > kind.Limit():
return contract.ChangeProposal{}, fmt.Errorf("%w: the text is %d bytes and a %s change may have %d", ErrBadChange, len(*edited), kind.Name, kind.Limit())
case !utf8.ValidString(*edited) || strings.ContainsRune(*edited, 0):
return contract.ChangeProposal{}, fmt.Errorf("%w: the text must be text", ErrBadChange)
}
p.Content = *edited
}
if kind.Dest != nil {
return c.confirmExternal(ctx, owner, p, kind)
}
// The base hash covers the files the model proposed; an edit by the owner
// may change the files to write, not what the proposal was based on.
was, err := c.snapshot(ctx, owner, kind, p.Target, orig)
if err != nil {
return contract.ChangeProposal{}, err
}
if was.hash != p.BaseHash {
if won, _ := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalStale, c.s.now().UTC(), 0); won {
c.audit(ctx, proposals.ActionStale, p, 0, "")
}
return contract.ChangeProposal{}, ErrTargetChanged
}
// Rules such as a lock set after the proposal apply to the text that is written.
now, err := c.snapshot(ctx, owner, kind, p.Target, p.Content)
if err != nil {
return contract.ChangeProposal{}, err
}
if err := c.check(ctx, owner, kind, aimOf(p), p.Target, p.Content, now); err != nil {
return contract.ChangeProposal{}, err
}
writes, err := filesOf(kind, p.Target, p.Content)
if err != nil {
return contract.ChangeProposal{}, err
}
// Claim the answer first, so two confirmations write once.
won, err := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalApplied, c.s.now().UTC(), 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrChangeAnswered
}
title := string(p.Kind) + ": " + p.Summary
rev, err := c.write(ctx, owner, writes, title, int64(kind.Limit()))
if err != nil {
if _, rerr := c.store.Settle(ctx, owner, id, contract.ProposalApplied, contract.ProposalPending, time.Time{}, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to write", "proposal", id, "error", rerr)
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not write the change: %w", err)
}
if err := c.store.SetRev(ctx, owner, id, rev); err != nil {
c.s.log.Warn("studio: could not record the change set of a change", "proposal", id, "rev", rev, "error", err)
}
p.Status, p.Rev = contract.ProposalApplied, rev
c.audit(ctx, proposals.ActionConfirm, p, rev, "")
return c.store.Get(ctx, owner, id)
}
// confirmExternal applies a proposal of a kind with a destination of its own.
// It has no base hash and no change set: the destination is asked again, since
// it may have changed since the proposal, and the write is claimed first so two
// confirmations apply once.
func (c *Changes) confirmExternal(ctx context.Context, owner string, p contract.ChangeProposal, kind proposals.Kind) (contract.ChangeProposal, error) {
if err := c.check(ctx, owner, kind, aimOf(p), p.Target, p.Content, snap{}); err != nil {
return contract.ChangeProposal{}, err
}
if err := c.prepare(ctx, kind, p); err != nil {
return contract.ChangeProposal{}, err
}
won, err := c.store.Settle(ctx, owner, p.ID, contract.ProposalPending, contract.ProposalApplied, c.s.now().UTC(), 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrChangeAnswered
}
if err := kind.Dest.Apply(ctx, proposals.ApplyInput{Owner: owner, Proposal: p}); err != nil {
if _, rerr := c.store.Settle(ctx, owner, p.ID, contract.ProposalApplied, contract.ProposalPending, time.Time{}, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to apply", "proposal", p.ID, "error", rerr)
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not apply the change: %w", err)
}
p.Status = contract.ProposalApplied
c.audit(ctx, proposals.ActionConfirm, p, 0, "")
return c.store.Get(ctx, owner, p.ID)
}
// write puts the files of a change into the home as one change set.
func (c *Changes) write(ctx context.Context, owner string, fs []proposals.FileWrite, title string, max int64) (int64, error) {
if len(fs) == 1 {
return c.s.d.Files.WriteHome(ctx, owner, fs[0].Path, fs[0].Content, title, max)
}
hf := make([]files.HomeFile, len(fs))
for i, f := range fs {
hf[i] = files.HomeFile{Path: f.Path, Content: f.Content}
}
return c.s.d.Files.WriteHomeFiles(ctx, owner, hf, title, max)
}
// Dismiss answers a pending proposal with no.
func (c *Changes) Dismiss(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
owner := principal(user)
p, err := c.Get(ctx, owner, id)
if err != nil {
return contract.ChangeProposal{}, err
}
won, err := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalDismissed, c.s.now().UTC(), 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, notPending(p)
}
c.audit(ctx, proposals.ActionDismiss, p, 0, "")
return c.store.Get(ctx, owner, id)
}
// Revert undoes an applied change with the change set it wrote.
func (c *Changes) Revert(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
if err := c.ready(); err != nil {
return contract.ChangeProposal{}, err
}
owner := principal(user)
p, err := c.Get(ctx, owner, id)
if err != nil {
return contract.ChangeProposal{}, err
}
kind, _ := c.kinds.Lookup(string(p.Kind))
if p.Status != contract.ProposalApplied || (p.Rev == 0 && kind.Dest == nil) {
return contract.ChangeProposal{}, ErrNotApplied
}
if kind.Dest != nil {
return c.revertExternal(ctx, owner, p, kind)
}
now := c.s.now().UTC()
won, err := c.store.Settle(ctx, owner, id, contract.ProposalApplied, contract.ProposalReverted, now, 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrNotApplied
}
cs, _, err := c.s.d.Files.Store().Undo(ctx, c.s.d.Files.Actor(owner), p.Rev, workspace.UndoOptions{})
if err != nil {
if _, rerr := c.store.Settle(ctx, owner, id, contract.ProposalReverted, contract.ProposalApplied, p.Answered, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to revert", "proposal", id, "error", rerr)
}
if errors.Is(err, workspace.ErrConflict) {
return contract.ChangeProposal{}, ErrCannotRevert
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not revert the change: %w", err)
}
// Record the revision of the undo; a second Settle keeps the status.
if _, err := c.store.Settle(ctx, owner, id, contract.ProposalReverted, contract.ProposalReverted, now, cs.Rev); err != nil {
c.s.log.Warn("studio: could not record the change set of a revert", "proposal", id, "error", err)
}
c.audit(ctx, proposals.ActionRevert, p, cs.Rev, "")
return c.store.Get(ctx, owner, id)
}
// revertExternal takes back a change of a kind with a destination of its own.
func (c *Changes) revertExternal(ctx context.Context, owner string, p contract.ChangeProposal, kind proposals.Kind) (contract.ChangeProposal, error) {
now := c.s.now().UTC()
won, err := c.store.Settle(ctx, owner, p.ID, contract.ProposalApplied, contract.ProposalReverted, now, 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrNotApplied
}
if err := kind.Dest.Undo(ctx, proposals.ApplyInput{Owner: owner, Proposal: p}); err != nil {
if _, rerr := c.store.Settle(ctx, owner, p.ID, contract.ProposalReverted, contract.ProposalApplied, p.Answered, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to revert", "proposal", p.ID, "error", rerr)
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not revert the change: %w", err)
}
c.audit(ctx, proposals.ActionRevert, p, 0, "")
return c.store.Get(ctx, owner, p.ID)
}
// RevertLast reverts the change applied last for the user in the scope of
// agent: the changes proposed in chats of that agent, or in plain chats when
// agent is empty.
// The kinds in skip are left out: a chat that may not touch them does not undo
// them either.
func (c *Changes) RevertLast(ctx context.Context, user, agent string, skip ...contract.ChangeKind) (contract.ChangeProposal, error) {
applied, err := c.List(ctx, user, contract.ProposalApplied)
if err != nil {
return contract.ChangeProposal{}, err
}
applied = slices.DeleteFunc(applied, func(p contract.ChangeProposal) bool { return p.Agent != agent || slices.Contains(skip, p.Kind) })
if len(applied) == 0 {
return contract.ChangeProposal{}, ErrNothingToRevert
}
last := slices.MaxFunc(applied, func(a, b contract.ChangeProposal) int { return a.Answered.Compare(b.Answered) })
return c.Revert(ctx, user, last.ID)
}
// Allow asks the gate about a revert of a chat.
func (c *Changes) allowRevert(ctx context.Context, user, agent string, src contract.ChangeSource) error {
err := c.s.checkAnyChangeGrant(ctx, user, agent, src)
if err == nil {
err = c.gate.Allow(ctx, proposals.Request{Action: proposals.GateRevert, User: principal(user), Agent: agent, Source: src})
}
if err != nil {
c.audit(ctx, proposals.ActionRefuse, contract.ChangeProposal{Owner: principal(user), Agent: agent, Source: src}, 0, err.Error())
}
return err
}
// grantsAKind reports whether the grant is the one of a registered kind.
func (c *Changes) grantsAKind(grant string) bool {
for _, n := range c.kinds.Names() {
if k, ok := c.kinds.Lookup(string(n)); ok && k.Grant == grant {
return true
}
}
return false
}
// kindsHeld lists the kinds a chat may propose: for a chat of a saved agent
// the ones whose grant it holds.
func (c *Changes) kindsHeld(ch builtins.Chat) []contract.ChangeKind {
var out []contract.ChangeKind
for _, n := range c.kinds.Names() {
if k, ok := c.kinds.Lookup(string(n)); ok && (k.Grant == "" || ch.HoldsGrant(k.Grant)) {
out = append(out, n)
}
}
return out
}
// autoAllowed says whether the host lets a proposal be applied unasked. It is
// the second check after the tool: a note from the person's own chat unless
// the level in force is always or never, and a note to the conversation of a
// messaging chat only when the level in force is auto, which needs the row
// paired_private, a paired sender and a one-to-one chat (see
// effectiveSaveLevel). Anything else asks.
func (c *Changes) autoAllowed(ctx context.Context, in ProposeInput, kind contract.ChangeKind) bool {
if kind != contract.ChangeMemory {
return false
}
switch in.Source.Kind {
case "":
level := effectiveSaveLevel(SaveInputs{User: c.storedSaveMode(ctx, in.User), Agent: c.s.agentMemorySave(ctx, in.User, in.Agent)})
return level != SaveAlways && level != SaveNever
case session.OriginConnector:
if in.Scope != string(contract.ScopeChannel) {
return false
}
direct, paired := c.s.messagingFacts(in.Source.Chat)
return effectiveSaveLevel(SaveInputs{User: c.storedSaveMode(ctx, in.User), Agent: c.s.agentMemorySave(ctx, in.User, in.Agent),
Messaging: c.storedMessagingSave(ctx, in.User), Origin: session.OriginConnector, Direct: direct, Paired: paired}) == SaveAuto
}
return false
}