mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-07 13:03:20 -05:00
A saved agent keeps its own skills in LocalAI/agents/<agent>/skills. Only a chat that runs that agent sees them. They sit between the folder and personal layers, so an agent's version of a skill wins for that agent, and they count as granted without being listed in the agent's skills. An agent writes one by a skill change proposal, as before; the new layer only reads them. A proposal for an agent skill cannot take the name of a core skill or of a document slot (read from slots.json). When writing-skills loads in a chat of a saved agent it starts with "Do not write files. Call propose_change with kind skill.", and the skills.write grant makes it visible to the agent. The Skills list gains the agent layer, with the agent's ID and name, and a skill can be read or removed (to the Trash) with the agent query parameter. Assisted-by: Claude:claude-sonnet-5-5 go swag
215 lines
7.6 KiB
Go
215 lines
7.6 KiB
Go
package files
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/skills"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
var (
|
|
// ErrBadSkill means a skill has no valid name or description.
|
|
ErrBadSkill = errors.New("files: a skill needs a valid name and a description")
|
|
// ErrNotAdmin means only an administrator may change the shared skills.
|
|
ErrNotAdmin = errors.New("files: only an administrator may change the shared skills")
|
|
)
|
|
|
|
// skillDir returns the workspace folder of a skill, and the actor that changes
|
|
// it. A personal skill is changed by its user, a shared one by the server on an
|
|
// administrator's behalf; the built-in layer is not changed through here.
|
|
func (s *Service) skillDir(principal string, layer skills.Layer, name string, write bool) (string, workspace.Actor, error) {
|
|
if !skills.ValidName(name) {
|
|
return "", workspace.Actor{}, fmt.Errorf("%w: %q is not a skill name", ErrBadSkill, name)
|
|
}
|
|
switch layer {
|
|
case skills.LayerPersonal:
|
|
if !validPrincipal(principal) {
|
|
return "", workspace.Actor{}, workspace.ErrBadName
|
|
}
|
|
return session.HomePath(principal) + "/" + skills.SkillsDir + "/" + name, s.Actor(principal), nil
|
|
case skills.LayerShared:
|
|
if write {
|
|
if principal != AdminPrincipal {
|
|
return "", workspace.Actor{}, ErrNotAdmin
|
|
}
|
|
return SharedSkillsPath + "/" + name, s.admin, nil
|
|
}
|
|
return SharedSkillsPath + "/" + name, s.Actor(principal), nil
|
|
case skills.LayerBuiltin:
|
|
if write {
|
|
return "", workspace.Actor{}, fmt.Errorf("%w: the built-in skills ship with the server", ErrBadSkill)
|
|
}
|
|
return BuiltinSkillsPath + "/" + name, s.Actor(principal), nil
|
|
}
|
|
return "", workspace.Actor{}, fmt.Errorf("%w: unknown layer %q", ErrBadSkill, layer)
|
|
}
|
|
|
|
// WriteSkill creates or replaces a skill: a folder with a SKILL.md made of the
|
|
// name, the description and the body. For the shared layer principal must be
|
|
// AdminPrincipal. The description is one line; line breaks and control
|
|
// characters are folded away, so it cannot add frontmatter keys.
|
|
func (s *Service) WriteSkill(ctx context.Context, principal string, layer skills.Layer, name, description, body string) error {
|
|
if !s.Available() {
|
|
return ErrUnavailable
|
|
}
|
|
dir, actor, err := s.skillDir(principal, layer, name, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
description = skills.Clean(description)
|
|
if description == "" {
|
|
return fmt.Errorf("%w: the description is empty", ErrBadSkill)
|
|
}
|
|
text := "---\nname: " + name + "\ndescription: " + yamlQuote(description) + "\n---\n" + strings.TrimLeft(body, "\n")
|
|
if !strings.HasSuffix(text, "\n") {
|
|
text += "\n"
|
|
}
|
|
// What is stored must parse back to what was asked for.
|
|
if n, d, perr := skills.Parse([]byte(text)); perr != nil || n != name || d != description {
|
|
return fmt.Errorf("%w: the text does not make a valid SKILL.md", ErrBadSkill)
|
|
}
|
|
_, err = s.ws.Put(ctx, actor, dir+"/"+skills.SkillFile, strings.NewReader(text), workspace.PutOptions{Overwrite: true})
|
|
return err
|
|
}
|
|
|
|
// ReadSkill returns a skill's description and its body, without the frontmatter.
|
|
func (s *Service) ReadSkill(ctx context.Context, principal string, layer skills.Layer, name string) (description, body string, err error) {
|
|
if !s.Available() {
|
|
return "", "", ErrUnavailable
|
|
}
|
|
dir, actor, err := s.skillDir(principal, layer, name, false)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return s.readSkillAt(ctx, actor, dir)
|
|
}
|
|
|
|
// agentSkillDir returns the workspace folder of a skill of the agent layer.
|
|
func (s *Service) agentSkillDir(principal, agent, name string) (string, error) {
|
|
switch {
|
|
case !skills.ValidName(name):
|
|
return "", fmt.Errorf("%w: %q is not a skill name", ErrBadSkill, name)
|
|
case !validPrincipal(principal), !ValidAgentID(agent):
|
|
return "", workspace.ErrBadName
|
|
}
|
|
return session.HomePath(principal) + "/" + AgentSkillsDir(agent) + "/" + name, nil
|
|
}
|
|
|
|
// ReadAgentSkill reads a skill of the agent layer of one saved agent, like
|
|
// ReadSkill.
|
|
func (s *Service) ReadAgentSkill(ctx context.Context, principal, agent, name string) (description, body string, err error) {
|
|
if !s.Available() {
|
|
return "", "", ErrUnavailable
|
|
}
|
|
dir, err := s.agentSkillDir(principal, agent, name)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return s.readSkillAt(ctx, s.Actor(principal), dir)
|
|
}
|
|
|
|
// DeleteAgentSkill moves a skill of an agent to the Trash. Writing one is a
|
|
// confirmed proposal (kind skill), never a direct call, so there is no writer
|
|
// here.
|
|
func (s *Service) DeleteAgentSkill(ctx context.Context, principal, agent, name string) error {
|
|
if !s.Available() {
|
|
return ErrUnavailable
|
|
}
|
|
dir, err := s.agentSkillDir(principal, agent, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ws.Delete(ctx, s.Actor(principal), dir)
|
|
}
|
|
|
|
// ReadAddonSkill reads a skill of the add-on layer, like ReadSkill: the skill
|
|
// name of the add-on addonID. The layer is read-only for everyone.
|
|
func (s *Service) ReadAddonSkill(ctx context.Context, principal, addonID, name string) (description, body string, err error) {
|
|
if !s.Available() {
|
|
return "", "", ErrUnavailable
|
|
}
|
|
if !skills.ValidName(name) || !skills.ValidName(addonID) {
|
|
return "", "", fmt.Errorf("%w: %q is not a skill of an add-on", ErrBadSkill, addonID+"/"+name)
|
|
}
|
|
return s.readSkillAt(ctx, s.Actor(principal), AddonSkillsPath+"/"+addonID+"/"+name)
|
|
}
|
|
|
|
// ReadSkillFile returns the SKILL.md of the skill folder dir (a workspace path,
|
|
// skills.Skill.Path) as principal reads it: the same bytes the file tools give
|
|
// the model, at most skills.MaxFileSize of them.
|
|
func (s *Service) ReadSkillFile(ctx context.Context, principal, dir string) (string, error) {
|
|
if !s.Available() {
|
|
return "", ErrUnavailable
|
|
}
|
|
r, _, err := s.ws.Open(ctx, s.Actor(principal), dir+"/"+skills.SkillFile)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
data, err := io.ReadAll(io.LimitReader(r, skills.MaxFileSize))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// readSkillAt reads the SKILL.md of the skill folder dir as actor.
|
|
func (s *Service) readSkillAt(ctx context.Context, actor workspace.Actor, dir string) (description, body string, err error) {
|
|
r, _, err := s.ws.Open(ctx, actor, dir+"/"+skills.SkillFile)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
data, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
_, description, err = skills.Parse(data)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("%w: %v", ErrBadSkill, err)
|
|
}
|
|
return description, skillBody(string(data)), nil
|
|
}
|
|
|
|
// DeleteSkill moves a skill's folder to the Trash.
|
|
func (s *Service) DeleteSkill(ctx context.Context, principal string, layer skills.Layer, name string) error {
|
|
if !s.Available() {
|
|
return ErrUnavailable
|
|
}
|
|
dir, actor, err := s.skillDir(principal, layer, name, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.ws.Delete(ctx, actor, dir)
|
|
}
|
|
|
|
// skillBody returns the text after the frontmatter of a SKILL.md.
|
|
func skillBody(text string) string {
|
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
rest, ok := strings.CutPrefix(text, "---\n")
|
|
if !ok {
|
|
return text
|
|
}
|
|
if _, after, found := strings.Cut(rest, "\n---\n"); found {
|
|
return after
|
|
}
|
|
if _, after, found := strings.Cut(rest, "\n---"); found && !strings.Contains(after, "\n") {
|
|
return ""
|
|
}
|
|
return rest
|
|
}
|
|
|
|
// yamlQuote quotes a one-line value for the frontmatter when it has a
|
|
// character that YAML reads as syntax.
|
|
func yamlQuote(s string) string {
|
|
if s != strings.TrimSpace(s) || strings.ContainsAny(s, ":#'\"{}[]&*!|>%@`,") || strings.HasPrefix(s, "-") || strings.HasPrefix(s, "?") {
|
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
|
}
|
|
return s
|
|
}
|