feat(studio): add propose_change and revert_change

A model asks to change the person's memory or an agent profile with
propose_change. The host stores a proposal and writes nothing until the
owner confirms; the write is one workspace change set, so revert_change
and the Files history can undo it. A proposal expires after 24 hours,
is refused when the target changed meanwhile, and is capped at 10
pending per person and 5 per hour per agent.

The host holds no platform code. The origin of the chat is recorded as
the source, so a Studio chat and the connector chats of any platform
use the same path. A gate decides if a chat may propose; the default
refuses a chat that runs a saved agent until grants fill it. Every step
is audited with a content hash, not the content.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 09:30:26 +00:00
1 parent b988cae89d
commit d32440096d
7 files changed
+914 -1

No files matched your search

@@ -43,6 +43,9 @@ const (
GroupMedia = "media"
// GroupCollections holds list_collections and search_collection.
GroupCollections = "collections"
// GroupChanges holds propose_change and revert_change: a chat can ask to
// change the person's memory or an agent profile, and the person confirms.
GroupChanges = "changes"
// GroupSubAgents holds spawn_agent.
GroupSubAgents = "subagents"
)
@@ -65,6 +68,9 @@ type Chat struct {
Actor workspace.Actor
// Origin is "" for a chat a person watches, or trigger, connector, job.
Origin string
// OriginID names the trigger, the connector binding or the job that
// started the chat.
OriginID string
// Agent is true when the chat runs a saved agent, whose grants the owner
// chose: an unattended run without one gets no tool that acts at once.
Agent bool
+352
View File
@@ -0,0 +1,352 @@
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/proposals"
"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 {
g = proposals.DefaultGate
}
return &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()}, 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
}
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) {
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}
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
}
rel, err := kind.Path(proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source})
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)
}
cur, exists, err := c.s.d.Files.ReadHome(ctx, p.Owner, rel, maxTargetRead)
if err != nil {
return contract.ChangeProposal{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, rel, err)
}
if exists {
p.BaseHash = hashOf(cur)
}
if exists && cur == in.Content {
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
}
// 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) {
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)
}
cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, p.Target, maxTargetRead)
if err != nil {
return contract.ChangeProposal{}, err
}
have := ""
if exists {
have = hashOf(cur)
}
if have != 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
}
// 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.s.d.Files.WriteHome(ctx, owner, p.Target, p.Content, 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)
}
// 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
}
if p.Status != contract.ProposalApplied || p.Rev == 0 {
return contract.ChangeProposal{}, ErrNotApplied
}
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)
}
// 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.
func (c *Changes) RevertLast(ctx context.Context, user, agent string) (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 })
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.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
}
+392
View File
@@ -0,0 +1,392 @@
package studio_test
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/addons/slack/slacktest"
slack "github.com/mudler/LocalAI/addons/slack/transport"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/studiobot"
)
// proposeArgs is a propose_change call as a model writes it.
func proposeArgs(kind, scope, target, content, summary string) string {
raw, _ := json.Marshal(map[string]string{"kind": kind, "scope": scope, "target": target, "content": content, "summary": summary})
return string(raw)
}
// changeScript proposes a memory note on "remember" and reverts on "forget".
func changeScript(scope, content string) func(workspace.Actor, string) []studiotest.ToolStep {
return func(_ workspace.Actor, text string) []studiotest.ToolStep {
switch text {
case "remember":
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", scope, "index.md", content, "Where the user lives")}, {Say: "ok"}}
case "forget":
return []studiotest.ToolStep{{Tool: "revert_change", Args: `{}`}, {Say: "ok"}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}
}
// grantAll is the gate a grants feature would give: every chat may.
var grantAll = proposals.GateFunc(func(context.Context, proposals.Request) error { return nil })
// changeShared is a shared rig whose service has a clock and a gate.
func changeShared(gate proposals.Gate) *shared {
sh := newShared()
sh.deps = func(d *studio.Deps) {
d.Clock = sh.clock.Now
d.ProposalGate = gate
}
return sh
}
func channelFile(src contract.ChangeSource) string {
return "Memory/channels/" + proposals.ChannelDir(src) + "/index.md"
}
func readHome(r *trigRig, rel string) (string, bool) {
text, ok, err := r.sh.files.ReadHome(context.Background(), alice, rel, 1<<20)
Expect(err).NotTo(HaveOccurred())
return text, ok
}
// onlyProposal returns the one proposal of alice.
func onlyProposal(r *trigRig) contract.ChangeProposal {
list, err := r.svc.Changes().List(context.Background(), alice)
Expect(err).NotTo(HaveOccurred())
Expect(list).To(HaveLen(1), "proposals = %+v", list)
return list[0]
}
// expectProposeConfirmRevert drives the whole life of one proposal that a
// chat made: nothing is written until the owner confirms, a confirmation
// writes one change set, and a revert by the model takes it back.
func expectProposeConfirmRevert(r *trigRig, send func(string), wantKind, wantID string, file func(contract.ChangeSource) string) {
ctx := context.Background()
send("remember")
until("the proposal", func() bool {
res := r.h.ResultsFor("propose_change")
return len(res) == 1
})
res := r.h.ResultsFor("propose_change")
Expect(res[0].Err).To(BeNil(), "result = %+v", res)
Expect(res[0].Out).To(ContainSubstring("Nothing is saved yet"))
p := onlyProposal(r)
Expect(p.Status).To(Equal(contract.ProposalPending))
wantSource := p.Source
Expect(wantSource.Kind).To(Equal(wantKind))
Expect(wantSource.ID).To(Equal(wantID))
Expect(wantSource.Chat).NotTo(BeEmpty())
wantFile := file(p.Source)
Expect(p.Target).To(Equal(wantFile))
_, ok := readHome(r, wantFile)
Expect(ok).To(BeFalse(), "the proposal wrote the file")
_, err := r.svc.Changes().Confirm(ctx, "bob", p.ID)
Expect(err).To(MatchError(proposals.ErrNotFound), "another user confirmed it")
done, err := r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
Expect(done.Status).To(Equal(contract.ProposalApplied))
Expect(done.Rev).To(BeNumerically(">", 0))
text, ok := readHome(r, wantFile)
Expect(ok).To(BeTrue())
Expect(text).To(Equal("- home: Rome\n"))
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrChangeAnswered))
send("forget")
until("the revert", func() bool { return len(r.h.ResultsFor("revert_change")) == 1 })
Expect(r.h.ResultsFor("revert_change")[0].Err).To(BeNil())
_, ok = readHome(r, wantFile)
Expect(ok).To(BeFalse(), "the revert left the file")
back, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(back.Status).To(Equal(contract.ProposalReverted))
Expect(back.RevertRev).To(BeNumerically(">", back.Rev))
events, err := r.svc.Changes().Events(ctx, alice, 20)
Expect(err).NotTo(HaveOccurred())
var actions []string
for _, e := range events {
actions = append(actions, e.Action)
Expect(e.Source).To(Equal(wantSource))
Expect(e.Hash).NotTo(BeEmpty())
Expect(fmt.Sprintf("%+v", e)).NotTo(ContainSubstring("Rome"), "the audit holds content")
}
Expect(actions).To(Equal([]string{"revert", "confirm", "propose"}))
}
var _ = Describe("change proposals", func() {
Describe("from a plain Studio chat", func() {
It("stores, confirms and reverts a memory change", func() {
r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
id := create(r.svc, alice)
expectProposeConfirmRevert(r, func(t string) { sent(r.svc, alice, id, t); idle(r.svc, alice, id) }, "", "",
func(contract.ChangeSource) string { return "Memory/index.md" })
})
It("refuses a target outside Memory, a profile without an agent and too much text", func() {
r := changeShared(nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
ctx := context.Background()
c := r.svc.Changes()
in := studio.ProposeInput{User: alice, Kind: "memory", Scope: "user", Target: "index.md", Content: "x", Summary: "s"}
bad := func(mod func(*studio.ProposeInput)) error {
x := in
mod(&x)
_, err := c.Propose(ctx, x)
return err
}
for name, mod := range map[string]func(*studio.ProposeInput){
"parent": func(x *studio.ProposeInput) { x.Target = "../../x.md" },
"profile": func(x *studio.ProposeInput) { x.Kind, x.Scope = "profile", "agent" },
"big": func(x *studio.ProposeInput) { x.Content = strings.Repeat("a", contract.MaxChangeBytes+1) },
"unknown": func(x *studio.ProposeInput) { x.Kind = "page" },
"scope": func(x *studio.ProposeInput) { x.Scope = "team" },
"no summary": func(x *studio.ProposeInput) { x.Summary = " " },
"binary": func(x *studio.ProposeInput) { x.Content = "a\x00b" },
"agent scope": func(x *studio.ProposeInput) { x.Scope = "agent" },
} {
Expect(bad(mod)).To(MatchError(studio.ErrBadChange), name)
}
list, _ := c.List(ctx, alice)
Expect(list).To(BeEmpty())
})
It("holds a profile to 4 KiB", func() {
r := changeShared(grantAll).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
in := studio.ProposeInput{User: alice, Agent: "ag1", Kind: "profile", Scope: "agent", Content: strings.Repeat("a", contract.MaxProfileBytes+1), Summary: "s"}
_, err := r.svc.Changes().Propose(context.Background(), in)
Expect(err).To(MatchError(studio.ErrBadChange))
in.Content = strings.Repeat("a", contract.MaxProfileBytes)
p, err := r.svc.Changes().Propose(context.Background(), in)
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal("LocalAI/agents/ag1/profile.md"))
})
It("limits 10 pending per user and 5 per hour per agent", func() {
sh := changeShared(nil)
r := sh.rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
ctx := context.Background()
c := r.svc.Changes()
propose := func(user, name string) (contract.ChangeProposal, error) {
return c.Propose(ctx, studio.ProposeInput{User: user, Kind: "memory", Scope: "user", Target: name + ".md", Content: name, Summary: name})
}
var made []contract.ChangeProposal
for i := range contract.MaxChangesPerHour {
p, err := propose(alice, fmt.Sprintf("n%d", i))
Expect(err).NotTo(HaveOccurred())
made = append(made, p)
}
_, err := propose(alice, "extra")
Expect(err).To(MatchError(proposals.ErrRateLimited))
_, err = propose("bob", "bobs")
Expect(err).NotTo(HaveOccurred(), "the rate is per person")
sh.clock.Advance(61 * time.Minute)
for i := range contract.MaxChangesPerHour {
p, err := propose(alice, fmt.Sprintf("m%d", i))
Expect(err).NotTo(HaveOccurred(), "after an hour, number %d", i)
made = append(made, p)
}
_, err = propose(alice, "over")
Expect(err).To(MatchError(proposals.ErrTooManyPending), "10 are waiting")
_, err = c.Dismiss(ctx, alice, made[0].ID)
Expect(err).NotTo(HaveOccurred())
_, err = c.Dismiss(ctx, alice, made[0].ID)
Expect(err).To(MatchError(studio.ErrChangeAnswered))
})
It("expires a proposal after 24 hours", func() {
sh := changeShared(nil)
r := sh.rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
id := create(r.svc, alice)
sent(r.svc, alice, id, "remember")
idle(r.svc, alice, id)
p := onlyProposal(r)
Expect(p.Expires.Sub(p.Created)).To(Equal(24 * time.Hour))
sh.clock.Advance(25 * time.Hour)
_, err := r.svc.Changes().Confirm(context.Background(), alice, p.ID)
Expect(err).To(MatchError(studio.ErrChangeExpired))
got, _ := r.svc.Changes().Get(context.Background(), alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalExpired))
_, ok := readHome(r, "Memory/index.md")
Expect(ok).To(BeFalse())
})
It("refuses a stale proposal and keeps the newer file", func() {
r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
ctx := context.Background()
_, err := r.sh.files.WriteHome(ctx, alice, "Memory/index.md", "- old\n", "seed", 1024)
Expect(err).NotTo(HaveOccurred())
id := create(r.svc, alice)
sent(r.svc, alice, id, "remember")
idle(r.svc, alice, id)
p := onlyProposal(r)
Expect(p.BaseHash).NotTo(BeEmpty())
_, err = r.sh.files.WriteHome(ctx, alice, "Memory/index.md", "- edited by hand\n", "edit", 1024)
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrTargetChanged))
text, _ := readHome(r, "Memory/index.md")
Expect(text).To(Equal("- edited by hand\n"))
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalStale))
})
It("refuses a revert when the file was edited since", func() {
r := changeShared(nil).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
ctx := context.Background()
id := create(r.svc, alice)
sent(r.svc, alice, id, "remember")
idle(r.svc, alice, id)
p, err := r.svc.Changes().Confirm(ctx, alice, onlyProposal(r).ID)
Expect(err).NotTo(HaveOccurred())
_, err = r.sh.files.WriteHome(ctx, alice, "Memory/index.md", "- edited\n", "edit", 1024)
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().Revert(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrCannotRevert))
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalApplied), "a failed revert must leave the change applied")
})
It("is not offered to a sub-agent or an unattended run", func() {
r := changeShared(nil).rig(func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"child job","wait":true}`}, {Say: "done"}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}, studio.TriggerOptions{})
id := create(r.svc, alice)
sent(r.svc, alice, id, "go")
idle(r.svc, alice, id)
Expect(r.h.ToolNames(id)).To(ContainElements("propose_change", "revert_change"))
for _, c := range childrenOf(r.svc, alice, id) {
Expect(r.h.ToolNames(c.ID)).NotTo(ContainElement("propose_change"))
}
tr := r.schedule(studio.TriggerInput{})
r.due()
run := r.waitEnded(tr.ID)
Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("propose_change"))
Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("revert_change"))
})
})
Describe("from a chat that runs a saved agent", func() {
It("is refused by default and allowed by a gate that grants it", func() {
for name, gate := range map[string]proposals.Gate{"default": nil, "granted": grantAll} {
r := changeShared(gate).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{})
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m"})
Expect(err).NotTo(HaveOccurred())
rec, err := r.svc.Create(context.Background(), alice, studio.CreateInput{AgentID: ag.ID})
Expect(err).NotTo(HaveOccurred())
sent(r.svc, alice, rec.ID, "remember")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1), name)
list, _ := r.svc.Changes().List(context.Background(), alice)
if name == "default" {
Expect(res[0].Err).To(HaveOccurred())
Expect(list).To(BeEmpty())
events, _ := r.svc.Changes().Events(context.Background(), alice, 5)
Expect(events).To(HaveLen(1))
Expect(events[0].Action).To(Equal("refuse"))
continue
}
Expect(res[0].Err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Agent).To(Equal(ag.ID))
Expect(list[0].Target).To(Equal("Memory/agents/" + ag.ID + "/index.md"))
}
})
It("cannot revert the change of another agent by its id", func() {
var other string
r := changeShared(grantAll).rig(func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "forget" {
return []studiotest.ToolStep{{Tool: "revert_change", Args: `{"id":"` + other + `"}`}, {Say: "ok"}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}, studio.TriggerOptions{})
ctx := context.Background()
p, err := r.svc.Changes().Propose(ctx, studio.ProposeInput{User: alice, Kind: "memory", Scope: "user", Target: "a.md", Content: "x", Summary: "s"})
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
other = p.ID
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m"})
rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
sent(r.svc, alice, rec.ID, "forget")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("revert_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(HaveOccurred())
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalApplied))
})
})
Describe("from connector chats of two platforms", func() {
// The host has no platform code: the same spec runs over the Telegram
// and the Slack fakes, and the proposal differs only in the binding.
It("works the same over Telegram", func() {
sh, bot := changeShared(grantAll), newScriptedBot()
r := newConnRig(sh, connStore(), bot, changeScript("channel", "- home: Rome\n"), nil)
b := r.bind(studio.ConnectorInput{Allow: []string{"11"}})
n := 0
send := func(text string) {
n++
bot.say(n, 11, "Ada", "ada", text)
waitFor("the answer", func() bool { return len(bot.answers()) == n })
}
expectProposeConfirmRevert(r.trigRig, send, session.OriginConnector, b.ID, channelFile)
})
It("works the same over Slack", func() {
sh := changeShared(grantAll)
f := slacktest.New()
DeferCleanup(f.Close)
f.AddApp(slacktest.App{Bot: slackBot, AppToken: slackApp, UserID: "UBOT"})
f.AddUser("U1", "ada", "Ada")
rt := slack.New(&slack.API{BaseURL: f.URL, FilesHost: f.FilesHost})
DeferCleanup(rt.Close)
r := newConnRigWith(sh, connStore(), studiobot.NewTransport(studiobot.Slack(rt)), changeScript("channel", "- home: Rome\n"), nil)
b := r.bind(studio.ConnectorInput{Kind: "slack", Token: slackBot + " " + slackApp, Allow: []string{"C1"}})
waitFor("the Socket Mode link", func() bool { return f.Connected(slackApp) })
posted := func() int {
return len((&slackRig{connRig: r, slack: f}).answers())
}
n := 0
send := func(text string) {
n++
f.Push(slackApp, slacktest.Mention("C1", "U1", "UBOT", text, ""))
waitFor("the answer", func() bool { return posted() == n })
}
expectProposeConfirmRevert(r.trigRig, send, session.OriginConnector, b.ID, channelFile)
})
It("keeps the connector chat out of the files of its sender when it only proposes", func() {
// A connector chat that runs an agent without a grant gets the
// default refusal: the safe default for every platform.
sh, bot := changeShared(nil), newScriptedBot()
r := newConnRig(sh, connStore(), bot, changeScript("channel", "x"), nil)
r.bind(studio.ConnectorInput{Allow: []string{"11"}})
bot.say(1, 11, "Ada", "ada", "remember")
waitFor("the answer", func() bool { return len(bot.answers()) == 1 })
list, _ := r.svc.Changes().List(context.Background(), alice)
Expect(list).To(BeEmpty())
})
})
})
+144
View File
@@ -0,0 +1,144 @@
package studio
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// ChangeProposalCardKind is the Kind of the tool card that points at a change
// proposal; the card's ID is the proposal's ID.
const ChangeProposalCardKind = "change_proposal"
const (
proposeChangeName = "propose_change"
revertChangeName = "revert_change"
)
const proposeChangeDescription = "Ask to change something that lasts: a note in the user's memory, or the profile of this agent. " +
"Use it when the user asks you to remember, change or forget something, or gives a rule you must keep. " +
"It changes nothing by itself: the owner must confirm in Studio, and the proposal expires after a day. " +
"Until then never say that it is saved. " +
"content is the full new text of the file, not a patch: read the current file first and keep what stays. " +
"kind is memory (a Markdown file under Memory/) or profile (the agent's standing rules, at most 4 KiB, scope agent). " +
"scope is user (every chat of the person), agent (this agent only) or channel (this conversation only). " +
"target is the file name for memory, such as index.md or food.md; a profile has no target. " +
"summary is one line that tells the owner what changes."
const proposeChangeSchema = `{"type":"object","properties":{` +
`"kind":{"type":"string","enum":["memory","profile"]},` +
`"scope":{"type":"string","enum":["user","agent","channel"]},` +
`"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md."},` +
`"content":{"type":"string","description":"The full new text of the file."},` +
`"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` +
`"required":["kind","scope","content","summary"]}`
const revertChangeDescription = "Undo a change that the owner confirmed earlier, for example when the user says to forget that. " +
"Give the id the earlier propose_change returned; without an id the last applied change of this agent is undone. " +
"It works at once because it only restores the earlier text. A file edited since cannot be reverted."
const revertChangeSchema = `{"type":"object","properties":{"id":{"type":"string","description":"The id of an applied proposal."}}}`
// registerChangeTools adds propose_change and revert_change as one switch
// group. A person's chat and a connector chat can have them; an unattended
// run and a sub-agent never do. Whether a chat that runs a saved agent may use
// them is the gate's decision at the call.
func (s *Service) registerChangeTools() error {
type def struct {
id, label, desc, schema string
run func(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error)
}
defs := []def{
{proposeChangeName, "Propose a change", proposeChangeDescription, proposeChangeSchema, s.changes.proposeTool},
{revertChangeName, "Revert a change", revertChangeDescription, revertChangeSchema, s.changes.revertTool},
}
for _, d := range defs {
err := s.builtins.Register(builtins.Tool{
ID: d.id, Group: builtins.GroupChanges, Label: d.label, Description: d.desc, Unlisted: true,
Build: func(_ context.Context, c builtins.Chat) (session.Tool, bool, string) {
switch {
case !s.d.Files.Available():
return session.Tool{}, false, "files are off on this server"
case c.SubAgent:
return session.Tool{}, false, "a sub-agent cannot change the owner's state"
case c.Origin != "" && c.Origin != session.OriginConnector:
return session.Tool{}, false, "a run that started by itself cannot propose changes"
}
return session.Tool{Description: d.desc, Schema: json.RawMessage(d.schema),
Run: func(ctx context.Context, raw json.RawMessage) (string, error) { return d.run(ctx, c, raw) }}, true, ""
},
})
if err != nil {
return err
}
}
return nil
}
func sourceOf(c builtins.Chat) contract.ChangeSource {
return contract.ChangeSource{Kind: c.Origin, ID: c.OriginID, Chat: c.Actor.ChatID}
}
// changeErr words a failure for the model, without the package prefix.
func changeErr(err error) error {
msg := err.Error()
for _, e := range []error{ErrBadChange, proposals.ErrNotGranted} {
msg = strings.TrimPrefix(msg, e.Error()+": ")
}
return errors.New(strings.TrimPrefix(strings.TrimPrefix(msg, "studio: "), "proposals: "))
}
func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error) {
var args struct {
Kind, Scope, Target, Content, Summary string
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
}
p, err := h.Propose(ctx, ProposeInput{User: c.Actor.Principal, Agent: c.AgentID, Source: sourceOf(c),
Kind: args.Kind, Scope: args.Scope, Target: args.Target, Content: args.Content, Summary: args.Summary})
if err != nil {
return "", changeErr(err)
}
// The card lets the interface show Confirm and Dismiss on this tool line,
// also after a reload.
session.SetToolCard(ctx, session.ToolCard{Kind: ChangeProposalCardKind, ID: p.ID})
return fmt.Sprintf("Proposal %s stored: %s (%s, %s). Nothing is saved yet: the owner must confirm it in Studio, and it expires in 24 hours. Do not say it is saved.",
p.ID, p.Summary, p.Kind, p.Scope), nil
}
func (h *Changes) revertTool(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error) {
var args struct {
ID string `json:"id"`
}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
}
}
if err := h.allowRevert(ctx, c.Actor.Principal, c.AgentID, sourceOf(c)); err != nil {
return "", changeErr(err)
}
var p contract.ChangeProposal
var err error
if args.ID != "" {
// An id from another agent's scope is not this chat's to revert.
if q, gerr := h.Get(ctx, c.Actor.Principal, args.ID); gerr == nil && q.Agent != c.AgentID {
return "", errors.New("that change belongs to another agent")
}
p, err = h.Revert(ctx, c.Actor.Principal, args.ID)
} else {
p, err = h.RevertLast(ctx, c.Actor.Principal, c.AgentID)
}
if err != nil {
return "", changeErr(err)
}
return fmt.Sprintf("Reverted %s (%s): the file is back to what it was before.", p.ID, p.Summary), nil
}
+1 -1
View File
@@ -362,7 +362,7 @@ func (s *Service) Builtins() *builtins.Registry { return s.builtins }
// builtinsFor builds the built-in tools of a chat.
func (s *Service) builtinsFor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, subAgent bool) []builtins.Built {
off, on := s.switchesOf(ctx, rec)
return s.builtins.Build(ctx, builtins.Chat{Actor: a, Origin: rec.Origin, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent,
return s.builtins.Build(ctx, builtins.Chat{Actor: a, Origin: rec.Origin, OriginID: rec.OriginID, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent,
Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, GoalActive: goalActive(rec) && !subAgent})
}
+3
View File
@@ -165,6 +165,9 @@ func (s *gormStore) List(ctx context.Context, owner string, statuses ...contract
func (s *gormStore) Settle(ctx context.Context, owner, id string, from, to contract.ProposalStatus, at time.Time, rev int64) (bool, error) {
set := map[string]any{"status": string(to), "answered": at}
if at.IsZero() {
set["answered"] = nil
}
if to == contract.ProposalReverted {
set["revert_rev"] = rev
}
+16
View File
@@ -28,6 +28,7 @@ import (
"github.com/mudler/LocalAI/core/services/studio/nibharness"
"github.com/mudler/LocalAI/core/services/studio/notify"
"github.com/mudler/LocalAI/core/services/studio/pages"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/provider"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/sharedfolders"
@@ -214,6 +215,12 @@ type Deps struct {
// requests the goal's turns make, before the goal pauses. Read when a turn
// ends. Nil or a value below 1: DefaultGoalMaxTurns, DefaultGoalMaxSteps.
GoalLimits func() (turns, steps int)
// Proposals keeps the change proposals (propose_change) and their audit.
// Nil: a store in memory, for a single process and tests.
Proposals proposals.Store
// ProposalGate decides whether a chat may propose or revert a change. Nil:
// proposals.DefaultGate, which refuses a chat that runs a saved agent.
ProposalGate proposals.Gate
// Clock is the time the offer limits use. Nil: time.Now. It is for tests.
Clock func() time.Time
// Harness replaces the nib harness. It is for tests.
@@ -308,6 +315,7 @@ 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
@@ -400,6 +408,14 @@ func New(d Deps) (*Service, error) {
if err := s.registerTriggerTools(); err != nil {
return nil, err
}
changes, err := newChanges(s)
if err != nil {
return nil, err
}
s.changes = changes
if err := s.registerChangeTools(); err != nil {
return nil, err
}
if err := s.registerLoadSkill(); err != nil {
return nil, err
}