mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
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:
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user