mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(studio): map change kinds to files and add the grant gate
Memory and profile changes write to fixed places in the owner's home; the target a model names is checked against a small pattern so it cannot leave Memory/. Other features register their kinds. The gate is the hook the agent grants fill; by default it refuses a chat that runs a saved agent. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
3 files changed
+276
No files matched your search
@@ -0,0 +1,50 @@
|
||||
package proposals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// ErrNotGranted means the chat may not change this kind of state.
|
||||
var ErrNotGranted = errors.New("proposals: this agent was not given the right to make this change")
|
||||
|
||||
// What a chat asks the gate for.
|
||||
const (
|
||||
GatePropose = "propose"
|
||||
GateRevert = "revert"
|
||||
)
|
||||
|
||||
// Request is one question to the gate. Agent is the saved agent of the chat,
|
||||
// empty for a plain chat. Source says which surface the chat came from, by
|
||||
// the host's own names, never a platform's.
|
||||
type Request struct {
|
||||
Action string
|
||||
User string
|
||||
Agent string
|
||||
Kind contract.ChangeKind
|
||||
Scope contract.ChangeScope
|
||||
Source contract.ChangeSource
|
||||
}
|
||||
|
||||
// Gate decides whether a model may propose or revert in a chat. The grants of
|
||||
// a saved agent plug in here; until they exist the default applies.
|
||||
type Gate interface {
|
||||
Allow(ctx context.Context, r Request) error
|
||||
}
|
||||
|
||||
// GateFunc makes a function a Gate.
|
||||
type GateFunc func(ctx context.Context, r Request) error
|
||||
|
||||
// Allow calls f.
|
||||
func (f GateFunc) Allow(ctx context.Context, r Request) error { return f(ctx, r) }
|
||||
|
||||
// DefaultGate allows a plain chat and refuses a chat that runs a saved agent:
|
||||
// an agent has no right until the owner grants it.
|
||||
var DefaultGate Gate = GateFunc(func(_ context.Context, r Request) error {
|
||||
if r.Agent != "" {
|
||||
return ErrNotGranted
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
package proposals
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// ErrBadTarget means a proposal names a place its kind does not allow.
|
||||
var ErrBadTarget = errors.New("proposals: this target is not allowed")
|
||||
|
||||
// Aim is where a proposal wants to write, as the model and the chat gave it.
|
||||
type Aim struct {
|
||||
Scope contract.ChangeScope
|
||||
Agent string // the saved agent of the chat, "" for a plain chat
|
||||
Target string // the name the model gave
|
||||
Source contract.ChangeSource
|
||||
}
|
||||
|
||||
// Kind says how one kind of change maps to a file in the home of the owner
|
||||
// and how big it may be.
|
||||
type Kind struct {
|
||||
Name contract.ChangeKind
|
||||
// Sensitive kinds are never confirmed from a chat that is not Studio; the
|
||||
// gate and the confirm lanes read it.
|
||||
Sensitive bool
|
||||
// MaxBytes bounds the content; zero means contract.MaxChangeBytes.
|
||||
MaxBytes int
|
||||
// Path returns the file inside the home that the change writes.
|
||||
Path func(Aim) (string, error)
|
||||
}
|
||||
|
||||
// Limit is the content bound of the kind.
|
||||
func (k Kind) Limit() int {
|
||||
if k.MaxBytes <= 0 || k.MaxBytes > contract.MaxChangeBytes {
|
||||
return contract.MaxChangeBytes
|
||||
}
|
||||
return k.MaxBytes
|
||||
}
|
||||
|
||||
// Kinds is the set of kinds a host offers. Later features register theirs.
|
||||
type Kinds struct {
|
||||
mu sync.RWMutex
|
||||
m map[contract.ChangeKind]Kind
|
||||
}
|
||||
|
||||
// NewKinds returns the kinds this package defines: memory and profile.
|
||||
func NewKinds() *Kinds {
|
||||
k := &Kinds{m: map[contract.ChangeKind]Kind{}}
|
||||
for _, d := range []Kind{memoryKind, profileKind} {
|
||||
k.m[d.Name] = d
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// Register adds a kind. It refuses a name the contract does not know, a kind
|
||||
// without Path and a kind that is registered already.
|
||||
func (k *Kinds) Register(d Kind) error {
|
||||
switch {
|
||||
case !contract.ValidChangeKind(string(d.Name)):
|
||||
return fmt.Errorf("proposals: %q is not a change kind", d.Name)
|
||||
case d.Path == nil:
|
||||
return fmt.Errorf("proposals: %s has no Path", d.Name)
|
||||
}
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if _, ok := k.m[d.Name]; ok {
|
||||
return fmt.Errorf("proposals: %s is registered already", d.Name)
|
||||
}
|
||||
k.m[d.Name] = d
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lookup returns the kind of a name.
|
||||
func (k *Kinds) Lookup(name string) (Kind, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
d, ok := k.m[contract.ChangeKind(name)]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// Names lists the registered kinds in contract order.
|
||||
func (k *Kinds) Names() []contract.ChangeKind {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
var out []contract.ChangeKind
|
||||
for _, n := range contract.ChangeKinds() {
|
||||
if _, ok := k.m[n]; ok {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
topicFile = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}\.md$`)
|
||||
idSafe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
)
|
||||
|
||||
// ChannelDir names the memory folder of one connector conversation: the
|
||||
// binding and a short hash of the host chat. It uses no platform name.
|
||||
func ChannelDir(s contract.ChangeSource) string {
|
||||
sum := sha256.Sum256([]byte(s.Chat))
|
||||
return s.ID + "-" + hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
var memoryKind = Kind{
|
||||
Name: contract.ChangeMemory,
|
||||
Path: func(a Aim) (string, error) {
|
||||
if !topicFile.MatchString(a.Target) {
|
||||
return "", fmt.Errorf("%w: a memory target is a file name such as index.md or food.md", ErrBadTarget)
|
||||
}
|
||||
switch a.Scope {
|
||||
case contract.ScopeUser:
|
||||
return "Memory/" + a.Target, nil
|
||||
case contract.ScopeAgent:
|
||||
if !idSafe.MatchString(a.Agent) {
|
||||
return "", fmt.Errorf("%w: agent memory needs a chat that runs a saved agent", ErrBadTarget)
|
||||
}
|
||||
return "Memory/agents/" + a.Agent + "/" + a.Target, nil
|
||||
case contract.ScopeChannel:
|
||||
if !idSafe.MatchString(a.Source.ID) || a.Source.Chat == "" {
|
||||
return "", fmt.Errorf("%w: channel memory needs a chat that came from a connector", ErrBadTarget)
|
||||
}
|
||||
return "Memory/channels/" + ChannelDir(a.Source) + "/" + a.Target, nil
|
||||
}
|
||||
return "", fmt.Errorf("%w: unknown scope %q", ErrBadTarget, a.Scope)
|
||||
},
|
||||
}
|
||||
|
||||
var profileKind = Kind{
|
||||
Name: contract.ChangeProfile,
|
||||
Sensitive: true,
|
||||
MaxBytes: contract.MaxProfileBytes,
|
||||
Path: func(a Aim) (string, error) {
|
||||
if a.Scope != contract.ScopeAgent || !idSafe.MatchString(a.Agent) {
|
||||
return "", fmt.Errorf("%w: a profile belongs to a saved agent: use scope agent in a chat that runs one", ErrBadTarget)
|
||||
}
|
||||
return "LocalAI/agents/" + a.Agent + "/profile.md", nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package proposals_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
var _ = Describe("kinds", func() {
|
||||
kinds := proposals.NewKinds()
|
||||
path := func(kind contract.ChangeKind, a proposals.Aim) (string, error) {
|
||||
k, ok := kinds.Lookup(string(kind))
|
||||
Expect(ok).To(BeTrue())
|
||||
return k.Path(a)
|
||||
}
|
||||
|
||||
It("maps memory to files under Memory/ only", func() {
|
||||
p, err := path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeUser, Target: "index.md"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(p).To(Equal("Memory/index.md"))
|
||||
p, err = path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeAgent, Agent: "ag1", Target: "food.md"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(p).To(Equal("Memory/agents/ag1/food.md"))
|
||||
src := contract.ChangeSource{Kind: "connector", ID: "b1", Chat: "c1"}
|
||||
p, err = path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeChannel, Source: src, Target: "index.md"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(p).To(Equal("Memory/channels/" + proposals.ChannelDir(src) + "/index.md"))
|
||||
Expect(proposals.ChannelDir(src)).To(HavePrefix("b1-"))
|
||||
})
|
||||
|
||||
DescribeTable("refuses a memory target that could leave Memory/",
|
||||
func(a proposals.Aim) {
|
||||
_, err := path(contract.ChangeMemory, a)
|
||||
Expect(err).To(MatchError(proposals.ErrBadTarget))
|
||||
},
|
||||
Entry("a parent", proposals.Aim{Scope: contract.ScopeUser, Target: "../x.md"}),
|
||||
Entry("a nested path", proposals.Aim{Scope: contract.ScopeUser, Target: "a/b.md"}),
|
||||
Entry("no md", proposals.Aim{Scope: contract.ScopeUser, Target: "notes.txt"}),
|
||||
Entry("an upper case name", proposals.Aim{Scope: contract.ScopeUser, Target: "Food.md"}),
|
||||
Entry("agent scope without an agent", proposals.Aim{Scope: contract.ScopeAgent, Target: "a.md"}),
|
||||
Entry("an agent id with a slash", proposals.Aim{Scope: contract.ScopeAgent, Agent: "a/../b", Target: "a.md"}),
|
||||
Entry("channel scope without a connector chat", proposals.Aim{Scope: contract.ScopeChannel, Target: "a.md"}),
|
||||
Entry("an unknown scope", proposals.Aim{Scope: "team", Target: "a.md"}),
|
||||
)
|
||||
|
||||
It("maps a profile to the agent's file and caps it at 4 KiB", func() {
|
||||
k, _ := kinds.Lookup("profile")
|
||||
Expect(k.Sensitive).To(BeTrue())
|
||||
Expect(k.Limit()).To(Equal(4096))
|
||||
p, err := k.Path(proposals.Aim{Scope: contract.ScopeAgent, Agent: "ag1", Target: "ignored"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(p).To(Equal("LocalAI/agents/ag1/profile.md"))
|
||||
_, err = k.Path(proposals.Aim{Scope: contract.ScopeUser})
|
||||
Expect(err).To(MatchError(proposals.ErrBadTarget))
|
||||
m, _ := kinds.Lookup("memory")
|
||||
Expect(m.Limit()).To(Equal(contract.MaxChangeBytes))
|
||||
})
|
||||
|
||||
It("lets later features register a kind once", func() {
|
||||
k := proposals.NewKinds()
|
||||
Expect(k.Names()).To(Equal([]contract.ChangeKind{contract.ChangeMemory, contract.ChangeProfile}))
|
||||
skill := proposals.Kind{Name: contract.ChangeSkill, Path: func(proposals.Aim) (string, error) { return "x", nil }}
|
||||
Expect(k.Register(skill)).To(Succeed())
|
||||
Expect(k.Register(skill)).NotTo(Succeed())
|
||||
Expect(k.Register(proposals.Kind{Name: "nope", Path: skill.Path})).NotTo(Succeed())
|
||||
Expect(k.Register(proposals.Kind{Name: contract.ChangePage})).NotTo(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("the default gate", func() {
|
||||
It("allows a plain chat and refuses a chat that runs an agent", func() {
|
||||
Expect(proposals.DefaultGate.Allow(context.Background(), proposals.Request{Action: proposals.GatePropose})).To(Succeed())
|
||||
err := proposals.DefaultGate.Allow(context.Background(), proposals.Request{Action: proposals.GatePropose, Agent: "ag1"})
|
||||
Expect(err).To(MatchError(proposals.ErrNotGranted))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user