mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 04:53:24 -05:00
The Memory add-on only declared three index files, two skills and a save setting; the host did the rest. Without the add-on a memory proposal worked but nothing was read back, and removing the add-on silently reset the save level. The host now loads the three indexes (user, agent, conversation) with the same caps and data wrapping, serves context_read, ships the two skills as built-in skills with the same text, and keeps the save level as its own per-person setting (ask, auto, never; default ask). The code sits behind a small MemoryProvider interface. The add-on stays installed and harmless: its index files and skills are ignored while the host serves memory, so nothing loads twice. A person who chose a level on its page keeps it until they choose in the host setting; auto still needs the add-on to run. A person who disabled the add-on and never chose keeps memory off. Saved agents still read notes only when their add-ons list names memory, and messaging chats still ask before saving. A person with no note and no add-on gets no extra block in the prompt. Assisted-by: Claude:claude-sonnet-5-5
330 lines
12 KiB
Go
330 lines
12 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/skills"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
)
|
|
|
|
// Add-on skills. An add-on can ship skills: skills/<name>/SKILL.md in its
|
|
// package. The add-on owner copies them into the add-on layer of the
|
|
// workspace (/addon-skills/<add-on>/) at install, upgrade and start, and
|
|
// deletes them at uninstall. The files stay while the add-on is stopped; a
|
|
// turn decides which add-ons contribute a root, so a stopped add-on, one the
|
|
// chat switched off and one the chat's agent does not grant contribute none.
|
|
//
|
|
// A skill of an add-on may replace a built-in skill of the same name, when the
|
|
// name is a slot the add-on declares or a default one (contract.DefaultSlots)
|
|
// and the manifest says so (shadows), as
|
|
// a tool replaces a built-in tool. Only one add-on serves such a name: the one
|
|
// an administrator pinned in the same map as the tools (chats_tool_providers,
|
|
// Settings > Tools), else the first by add-on ID. A pin to an add-on that
|
|
// cannot serve now leaves the name to the built-in skill, and the turn says so.
|
|
//
|
|
// Distributed mode: the files are in the workspace store that every frontend
|
|
// reads (shared state, like the built-in layer), the pins are a runtime
|
|
// setting shared like every other, and which add-ons are usable is read per
|
|
// turn from the broker, as for the tools. Nothing is cached here.
|
|
|
|
// AddonSkillSet is the skills one add-on ships, as the chats see them.
|
|
type AddonSkillSet struct {
|
|
AddonID string
|
|
// Title is the add-on's title, shown as the provider of its skills.
|
|
Title string
|
|
// InstalledAt is when the add-on was first installed, or empty.
|
|
InstalledAt string
|
|
Skills []string
|
|
// Isolated are the skills that run only when the add-on's own commands tool
|
|
// reports isolated execution (a subset of Skills).
|
|
Isolated []string
|
|
// Shadows are the built-in skills it replaces (a subset of Skills).
|
|
Shadows []string
|
|
// Slots are the slots its skills serve, as the add-on declares them. The
|
|
// host reads which names are replaceable and which files they cover from
|
|
// here, so a new format needs no change in the host.
|
|
Slots []contract.Slot
|
|
// Running is true when the add-on can serve now: installed, enabled and
|
|
// running.
|
|
Running bool
|
|
}
|
|
|
|
// AddonSkillLister is implemented by a broker that knows the skills of the
|
|
// add-ons. It lists the installed add-ons that ship skills and that the user
|
|
// can see, with whether each one runs. It is optional; a broker without it
|
|
// gives no add-on skills.
|
|
type AddonSkillLister interface {
|
|
AddonSkills(ctx context.Context, user string) ([]AddonSkillSet, error)
|
|
}
|
|
|
|
// addonSkills is what a chat gets from the add-on layer in one turn.
|
|
type addonSkills struct {
|
|
// usable are the add-ons whose skills the chat may offer.
|
|
usable []AddonSkillSet
|
|
// known are all the add-ons that ship skills and that the user can see.
|
|
known []AddonSkillSet
|
|
// pins are the administrator's choices, by name.
|
|
pins map[string]string
|
|
// hiddenDocuments are the document skills left out because the add-on
|
|
// that ships them runs the chat's commands without isolation.
|
|
hiddenDocuments []string
|
|
// documentsWhy is why that add-on is not known to be isolated, and
|
|
// documentsBy its title.
|
|
documentsWhy, documentsBy string
|
|
}
|
|
|
|
// withNotes adds to notes the line that says why the document skills
|
|
// are missing, so the person is not left to guess.
|
|
// documentNotePrefix starts the note that explains the missing document skills.
|
|
const documentNotePrefix = "the document skills "
|
|
|
|
// hiddenDocumentNote reports whether note is the document note (withNotes)
|
|
// and lists the skill name among the hidden ones.
|
|
func hiddenDocumentNote(note, name string) bool {
|
|
list, _, ok := strings.Cut(strings.TrimPrefix(note, documentNotePrefix), " need isolated")
|
|
return strings.HasPrefix(note, documentNotePrefix) && ok && slices.Contains(strings.Split(list, ", "), name)
|
|
}
|
|
|
|
func (a addonSkills) withNotes(notes []string) []string {
|
|
if len(a.hiddenDocuments) == 0 {
|
|
return notes
|
|
}
|
|
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need isolated command execution by "+a.documentsBy+"; "+a.documentsWhy)
|
|
}
|
|
|
|
// addonSkillsFor lists the add-ons of the user that ship skills and decides
|
|
// which ones the chat may offer: running, usable for the user, not switched
|
|
// off by the chat (addonsOff) and, for a chat with an agent, granted by it. An
|
|
// add-on the broker reports as unavailable is left out. A broker that cannot
|
|
// answer gives none and a warning; it never fails the chat.
|
|
func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []string, sc *scope, command Commands) (addonSkills, error) {
|
|
var out addonSkills
|
|
pins, err := s.toolPins(ctx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.pins = pins
|
|
lister, ok := s.d.AddonTools.(AddonSkillLister)
|
|
if !ok || s.d.AddonTools == nil {
|
|
return out, nil
|
|
}
|
|
sets, err := lister.AddonSkills(ctx, p)
|
|
if err != nil {
|
|
s.log.Warn("studio: add-on skills unavailable", "user", p, "error", err)
|
|
return out, nil
|
|
}
|
|
sort.Slice(sets, func(i, j int) bool { return sets[i].AddonID < sets[j].AddonID })
|
|
off := make(map[string]bool, len(addonsOff))
|
|
for _, id := range addonsOff {
|
|
off[id] = true
|
|
}
|
|
gate, _ := s.d.AddonTools.(AddonGate)
|
|
for _, set := range sets {
|
|
if set.AddonID == MemoryAddonID {
|
|
// The host ships these skills itself (memory.go).
|
|
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return contract.LegacyMemorySkill(set.AddonID, n) })
|
|
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), func(n string) bool { return contract.LegacyMemorySkill(set.AddonID, n) })
|
|
}
|
|
if len(set.Skills) == 0 {
|
|
continue
|
|
}
|
|
out.known = append(out.known, set)
|
|
if !set.Running || off[set.AddonID] || (sc != nil && !sc.addons[set.AddonID]) {
|
|
continue
|
|
}
|
|
if gate != nil && gate.Unavailable(ctx, p, set.AddonID, s.d.Files.Available()) != "" {
|
|
continue
|
|
}
|
|
// A chat that does not exist yet has no status to read, so its list
|
|
// offers what the selected provider ships; the turn of the real chat
|
|
// still gates these skills on the status it reads then.
|
|
draftProvider := command.Draft && command.State == CommandsAvailable && command.Provider == set.AddonID
|
|
if len(set.Isolated) > 0 && !isolatedProvider(command, set.AddonID) && !draftProvider {
|
|
needs := func(n string) bool { return slices.Contains(set.Isolated, n) }
|
|
if command.State == CommandsAvailable && command.Provider == set.AddonID {
|
|
out.documentsWhy = s.documentsWhy(ctx, p, command)
|
|
out.documentsBy = command.Title
|
|
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !needs(n) })...)
|
|
}
|
|
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), needs)
|
|
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), needs)
|
|
}
|
|
out.usable = append(out.usable, set)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// roots returns the add-on layer roots of the usable add-ons.
|
|
func (a addonSkills) roots() []skills.Root {
|
|
roots := make([]skills.Root, 0, len(a.usable))
|
|
for _, set := range a.usable {
|
|
roots = append(roots, files.AddonSkillsRoot(set.AddonID, set.Title))
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// provider decides which add-on serves a replaceable name (skills.Options
|
|
// AddonProvider): the pinned add-on when it ships the name, or none when that
|
|
// add-on cannot serve now; with no pin, the default one (ChooseProvider: the oldest install).
|
|
func (a addonSkills) provider(name string) (string, bool) {
|
|
slot, ok := a.slot(name)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
documents := slot.Kind == contract.SlotKindDocuments
|
|
if pin := a.pins[name]; pin != "" && (documents || shipsReplacement(a.known, pin, name)) {
|
|
if shipsReplacement(a.usable, pin, name) {
|
|
return pin, true
|
|
}
|
|
return "", true
|
|
}
|
|
if id := ChooseProvider(a.shippers(a.usable, name), a.installTimes(), ""); id != "" {
|
|
return id, true
|
|
}
|
|
return "", documents
|
|
}
|
|
|
|
// slot returns the slot called name: one that a known add-on serves, else a
|
|
// default one. The default entries come from data (contract.DefaultSlots).
|
|
func (a addonSkills) slot(name string) (contract.Slot, bool) {
|
|
for _, set := range a.known {
|
|
for _, sl := range set.Slots {
|
|
if sl.Name == name {
|
|
return sl, true
|
|
}
|
|
}
|
|
}
|
|
return contract.DefaultSlot(name)
|
|
}
|
|
|
|
// slotNames lists the names of all the slots: the default ones in data order,
|
|
// then those that known add-ons serve, sorted.
|
|
func (a addonSkills) slotNames() []string {
|
|
return slotNamesOf(a.known)
|
|
}
|
|
|
|
func slotNamesOf(sets []AddonSkillSet) []string {
|
|
seen := map[string]bool{}
|
|
var out, extra []string
|
|
for _, sl := range contract.DefaultSlots() {
|
|
seen[sl.Name] = true
|
|
out = append(out, sl.Name)
|
|
}
|
|
for _, set := range sets {
|
|
for _, sl := range set.Slots {
|
|
if !seen[sl.Name] {
|
|
seen[sl.Name] = true
|
|
extra = append(extra, sl.Name)
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(extra)
|
|
return append(out, extra...)
|
|
}
|
|
|
|
// shippers lists the add-ons of the sets that ship a replacement for name.
|
|
func (a addonSkills) shippers(sets []AddonSkillSet, name string) []string {
|
|
var ids []string
|
|
for _, set := range sets {
|
|
if shipsReplacement([]AddonSkillSet{set}, set.AddonID, name) {
|
|
ids = append(ids, set.AddonID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// installTimes maps each known add-on to its install time, for ChooseProvider.
|
|
func (a addonSkills) installTimes() map[string]string {
|
|
m := make(map[string]string, len(a.known))
|
|
for _, set := range a.known {
|
|
m[set.AddonID] = set.InstalledAt
|
|
}
|
|
return m
|
|
}
|
|
|
|
// options are the add-on parts of skills.Options.
|
|
func (a addonSkills) options(o skills.Options) skills.Options {
|
|
o.AddonProvider = a.provider
|
|
o.Replaceable = func(name string) bool { _, ok := a.slot(name); return ok }
|
|
return o
|
|
}
|
|
|
|
func shipsReplacement(sets []AddonSkillSet, addon, name string) bool {
|
|
for _, set := range sets {
|
|
if set.AddonID != addon {
|
|
continue
|
|
}
|
|
if slices.Contains(set.Shadows, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a addonSkills) replaceable(name string) bool { _, ok := a.slot(name); return ok }
|
|
|
|
// title returns the title of a known add-on, or its ID.
|
|
func (a addonSkills) title(id string) string {
|
|
for _, set := range a.known {
|
|
if set.AddonID == id && set.Title != "" {
|
|
return set.Title
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// markFallbacks sets Fallback on every built-in skill that serves although an
|
|
// add-on the user has replaces it: the add-on is off for the chat, not
|
|
// running, cannot work here, or its own skill is switched off. It returns the
|
|
// one line the turn note gets, or "".
|
|
func (a addonSkills) markFallbacks(list []skills.Skill) string {
|
|
var parts []string
|
|
for i := range list {
|
|
sk := &list[i]
|
|
if sk.Layer != skills.LayerBuiltin || sk.Invalid != "" || sk.Shadowed != "" || !a.replaceable(sk.Name) {
|
|
continue
|
|
}
|
|
var by string
|
|
if pin := a.pins[sk.Name]; pin != "" && shipsReplacement(a.known, pin, sk.Name) {
|
|
by = pin
|
|
} else {
|
|
by = ChooseProvider(a.shippers(a.known, sk.Name), a.installTimes(), "")
|
|
}
|
|
if by == "" {
|
|
continue
|
|
}
|
|
sk.Fallback = a.title(by)
|
|
if !sk.Off {
|
|
parts = append(parts, sk.Name+" ("+sk.Fallback+")")
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return "The built-in skills " + strings.Join(parts, ", ") + " serve because the add-on's own skill is off or not running in this chat."
|
|
}
|
|
|
|
// documentsWhy says why the add-on that runs the chat's commands is not known
|
|
// to be isolated: it runs them on the host, or its status could not be read.
|
|
func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) string {
|
|
if command.Isolation == "host" {
|
|
return "its commands run on the server without isolation"
|
|
}
|
|
value, d := ObserveExecution(ctx, p, command.Addon)
|
|
if value.Readiness == "unavailable" {
|
|
return commandsTitle(command) + " reports that it cannot run commands"
|
|
}
|
|
why := d.Failure.Text()
|
|
if d.Failure == "" {
|
|
why = "its status says isolation is unknown"
|
|
}
|
|
if d.Explanation != "" {
|
|
why += " (" + d.Explanation + ")"
|
|
}
|
|
return "its status could not be read: " + why
|
|
}
|