From d32440096da76e8ad7c61ad660f809461b955dad Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:30:26 +0000 Subject: [PATCH] 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 --- core/services/studio/builtins/builtins.go | 6 + core/services/studio/changes.go | 352 +++++++++++++++++++ core/services/studio/changes_test.go | 392 ++++++++++++++++++++++ core/services/studio/changetools.go | 144 ++++++++ core/services/studio/hostbash.go | 2 +- core/services/studio/proposals/gorm.go | 3 + core/services/studio/service.go | 16 + 7 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 core/services/studio/changes.go create mode 100644 core/services/studio/changes_test.go create mode 100644 core/services/studio/changetools.go diff --git a/core/services/studio/builtins/builtins.go b/core/services/studio/builtins/builtins.go index 9fd8996ee..57065ce02 100644 --- a/core/services/studio/builtins/builtins.go +++ b/core/services/studio/builtins/builtins.go @@ -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 diff --git a/core/services/studio/changes.go b/core/services/studio/changes.go new file mode 100644 index 000000000..257d604d8 --- /dev/null +++ b/core/services/studio/changes.go @@ -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 +} diff --git a/core/services/studio/changes_test.go b/core/services/studio/changes_test.go new file mode 100644 index 000000000..6d17c7d51 --- /dev/null +++ b/core/services/studio/changes_test.go @@ -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()) + }) + }) +}) diff --git a/core/services/studio/changetools.go b/core/services/studio/changetools.go new file mode 100644 index 000000000..0f14e0ab0 --- /dev/null +++ b/core/services/studio/changetools.go @@ -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 +} diff --git a/core/services/studio/hostbash.go b/core/services/studio/hostbash.go index e7c9f5b39..b79e90dde 100644 --- a/core/services/studio/hostbash.go +++ b/core/services/studio/hostbash.go @@ -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}) } diff --git a/core/services/studio/proposals/gorm.go b/core/services/studio/proposals/gorm.go index 0df712bfc..25631a091 100644 --- a/core/services/studio/proposals/gorm.go +++ b/core/services/studio/proposals/gorm.go @@ -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 } diff --git a/core/services/studio/service.go b/core/services/studio/service.go index 557a60be1..2827ebf91 100644 --- a/core/services/studio/service.go +++ b/core/services/studio/service.go @@ -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 }