Files
LocalAI/core/services/studio/changekinds.go
T
Ettore Di Giacinto 24c837e453 feat(studio): grant built-in capabilities to a saved agent
A saved agent now has a Builtins list: the capabilities its owner gave
it (memory.write, profile.write, skills.write, pages.write,
dashboard.write, triggers.propose, triggers.run, talkback.channels,
talkback.new_channel, files.upload, act.files, act.addons, act.send,
act.spend and the opt-in group saved_agents). The list is empty by
default, and nothing turns a grant on for a template, an import or a
write that omits the field.

A chat of the agent holds the grants the user may use at the turn
(agent scope). A tool exists when the grant is held, the chat did not
switch its group off and the run can hold rights: a sub-agent or a run
that started by itself never does. propose_change and revert_change
now exist only for a chat that holds the grant of at least one kind,
and the schema lists only those kinds.

propose_change gains the kinds skill, page and dashboard. A skill is a
SKILL.md for the personal or the agent layer, and a skill with
locked: true is refused, also as a shadow of a locked personal skill.
A page is a set of files checked against the Pages rules and written
as one change set. The dashboard is a home.json checked against its
schema. All three are re-checked when the owner confirms or edits.

GET /api/chats/agents/builtin-grants lists each grant with its title,
description and sensitive flag. The agent record, the write body and
the export file carry builtins; an old file imports as before and an
unknown name is dropped in an import and refused in a write.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
2026-11-03 14:56:42 +00:00

231 lines
8.0 KiB
Go

package studio
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"unicode/utf8"
"github.com/mudler/LocalAI/core/services/studio/pages"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/skills"
"github.com/mudler/LocalAI/core/services/studio/wellknown"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The kinds that need more than a file name: a skill, a page and the dashboard.
// Each one is a proposal like a memory note. The model never writes the file;
// the owner confirms, and the host writes as the owner with a change set.
// maxPageFiles bounds the files of one page proposal.
const maxPageFiles = 32
const (
maxPageMeta = 16 << 10
maxPageTitle = 120
maxPageDesc = 500
pageFolderTop = pages.Dir
)
var pageIconRE = regexp.MustCompile(`^[a-z0-9-]{1,40}$`)
// skillRel is the home-relative SKILL.md of a skill in the personal layer.
func skillRel(name string) string { return skills.SkillsDir + "/" + name + "/" + skills.SkillFile }
var skillKind = proposals.Kind{
Name: contract.ChangeSkill,
Grant: contract.CapSkillsWrite,
Sensitive: true,
Path: func(a proposals.Aim) (string, error) {
if !skills.ValidName(a.Target) {
return "", fmt.Errorf("%w: a skill target is its name: lowercase letters, digits and dashes, at most 64", proposals.ErrBadTarget)
}
switch a.Scope {
case contract.ScopeUser:
return skillRel(a.Target), nil
case contract.ScopeAgent:
if !proposals.IDSafe(a.Agent) {
return "", fmt.Errorf("%w: an agent skill needs a chat that runs a saved agent", proposals.ErrBadTarget)
}
return "LocalAI/agents/" + a.Agent + "/skills/" + a.Target + "/" + skills.SkillFile, nil
}
return "", fmt.Errorf("%w: a skill is for the user (personal) or for this agent, not for a channel", proposals.ErrBadTarget)
},
Check: func(in proposals.CheckInput) error {
name, _, err := skills.Parse([]byte(in.Content))
switch {
case err != nil:
return fmt.Errorf("the text is not a valid SKILL.md (%v): it starts with --- lines that hold name and description", err)
case name != in.Aim.Target:
return fmt.Errorf("the name in the frontmatter is %q but the target is %q", name, in.Aim.Target)
case in.Exists && skills.Locked([]byte(in.Current)):
return fmt.Errorf("the skill %q is locked by its owner: ask them to unlock it", in.Aim.Target)
}
// A skill of the agent layer shadows the personal one of the same name,
// so a locked personal skill is locked against it too.
if in.Aim.Scope == contract.ScopeAgent && in.Read != nil {
cur, ok, err := in.Read(skillRel(in.Aim.Target))
if err != nil {
return fmt.Errorf("cannot read the personal skill %q: %v", in.Aim.Target, err)
}
if ok && skills.Locked([]byte(cur)) {
return fmt.Errorf("the personal skill %q is locked by its owner: an agent skill of that name would replace it", in.Aim.Target)
}
}
return nil
},
}
var dashboardKind = proposals.Kind{
Name: contract.ChangeDashboard,
Grant: contract.CapDashboardWrite,
Sensitive: true,
Path: func(a proposals.Aim) (string, error) {
if a.Scope != contract.ScopeUser {
return "", fmt.Errorf("%w: the dashboard is the user's: use scope user", proposals.ErrBadTarget)
}
return strings.TrimPrefix(wellknown.File(wellknown.Home), "/"), nil
},
Check: func(in proposals.CheckInput) error {
if _, bad := wellknown.Validate(wellknown.Home, []byte(in.Content)); len(bad) > 0 {
return fmt.Errorf("home.json does not match its schema: %s", strings.Join(bad, "; "))
}
return nil
},
}
var pageKind = proposals.Kind{
Name: contract.ChangePage,
Grant: contract.CapPagesWrite,
Sensitive: true,
MaxBytes: contract.MaxPageChangeBytes,
Path: func(a proposals.Aim) (string, error) {
switch {
case a.Scope != contract.ScopeUser:
return "", fmt.Errorf("%w: a page is a folder of the user's home: use scope user", proposals.ErrBadTarget)
case !pages.ValidName(a.Target):
return "", fmt.Errorf("%w: a page target is its name: lowercase letters, digits and dashes, at most 64", proposals.ErrBadTarget)
}
return pageFolderTop + "/" + a.Target, nil
},
Check: func(in proposals.CheckInput) error {
_, err := pageFiles(in.Content)
return err
},
Files: pageFiles,
}
// pageFiles reads the content of a page proposal, {"files": {path: text}}, and
// checks every file against the rules of the Pages feature: the paths stay
// inside the page folder, index.html is there, each file is text and fits, and
// page.json is the small JSON object the Pages screen reads.
func pageFiles(content string) ([]proposals.FileWrite, error) {
var doc struct {
Files map[string]string `json:"files"`
}
dec := json.NewDecoder(strings.NewReader(content))
dec.DisallowUnknownFields()
if err := dec.Decode(&doc); err != nil || len(doc.Files) == 0 {
return nil, errors.New(`the content of a page is JSON like {"files": {"index.html": "<html>...", "page.json": "{...}"}}`)
}
if dec.More() {
return nil, errors.New("the content of a page is one JSON object")
}
if len(doc.Files) > maxPageFiles {
return nil, fmt.Errorf("a page has at most %d files", maxPageFiles)
}
out := make([]proposals.FileWrite, 0, len(doc.Files))
seen := map[string]string{}
for rel, text := range doc.Files {
if _, err := pages.CleanPath(rel); err != nil {
return nil, fmt.Errorf("%q is not a file inside the page folder", clipText(rel, 60))
}
key := strings.ToLower(rel)
if other, dup := seen[key]; dup {
return nil, fmt.Errorf("%q and %q are the same file", other, rel)
}
seen[key] = rel
switch {
case len(text) > pages.MaxFileSize:
return nil, fmt.Errorf("%s is larger than %d bytes", rel, pages.MaxFileSize)
case !utf8.ValidString(text) || strings.ContainsRune(text, 0):
return nil, fmt.Errorf("%s must be text", rel)
}
out = append(out, proposals.FileWrite{Path: rel, Content: text})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
// A file cannot also be a folder of another file.
for _, f := range out {
for _, g := range out {
if strings.HasPrefix(g.Path, f.Path+"/") {
return nil, fmt.Errorf("%s is a file and also a folder of %s", f.Path, g.Path)
}
}
}
index := false
for _, f := range out {
switch f.Path {
case pages.IndexFile:
index = strings.TrimSpace(f.Content) != ""
case pages.MetaFile:
if err := checkPageMeta(f.Content); err != nil {
return nil, err
}
}
}
if !index {
return nil, fmt.Errorf("a page needs a %s that is not empty", pages.IndexFile)
}
return out, nil
}
func checkPageMeta(text string) error {
if len(text) > maxPageMeta {
return fmt.Errorf("%s is larger than %d bytes", pages.MetaFile, maxPageMeta)
}
var m struct {
Title string `json:"title"`
Icon string `json:"icon"`
Description string `json:"description"`
Widget *struct {
Size string `json:"size"`
Title string `json:"title"`
} `json:"widget"`
}
if err := json.NewDecoder(bytes.NewReader([]byte(text))).Decode(&m); err != nil {
return fmt.Errorf("%s is not a JSON object: %v", pages.MetaFile, err)
}
switch {
case utf8.RuneCountInString(m.Title) > maxPageTitle:
return fmt.Errorf("the title in %s is longer than %d characters", pages.MetaFile, maxPageTitle)
case utf8.RuneCountInString(m.Description) > maxPageDesc:
return fmt.Errorf("the description in %s is longer than %d characters", pages.MetaFile, maxPageDesc)
case m.Icon != "" && !pageIconRE.MatchString(m.Icon):
return fmt.Errorf("the icon in %s is a name of lowercase letters, digits and dashes", pages.MetaFile)
case m.Widget != nil && !pages.ValidWidgetSize(m.Widget.Size):
return fmt.Errorf("the widget size in %s is small, medium or large", pages.MetaFile)
}
return nil
}
func clipText(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n]) + "..."
}
return s
}
// registerChangeKinds adds the kinds that go beyond a file name to the host.
func (c *Changes) registerChangeKinds() error {
for _, k := range []proposals.Kind{skillKind, pageKind, dashboardKind} {
if err := c.kinds.Register(k); err != nil {
return err
}
}
return nil
}