diff --git a/core/http/endpoints/studio/skills.go b/core/http/endpoints/studio/skills.go index 419ce39d22..448b1f5a12 100644 --- a/core/http/endpoints/studio/skills.go +++ b/core/http/endpoints/studio/skills.go @@ -30,7 +30,7 @@ func skillActor(c echo.Context, fc fileCall, layer skills.Layer) string { func parseLayer(s string) (skills.Layer, bool) { switch skills.Layer(s) { - case skills.LayerPersonal, skills.LayerShared, skills.LayerAddon, skills.LayerBuiltin: + case skills.LayerPersonal, skills.LayerShared, skills.LayerAddon, skills.LayerBuiltin, skills.LayerAgent: return skills.Layer(s), true } return "", false @@ -59,7 +59,8 @@ func (h *Handlers) ListSkills(c echo.Context) error { for _, sk := range list { out.Skills = append(out.Skills, schema.ChatsSkill{ ID: studiosvc.SkillID(sk), Name: sk.Name, Description: sk.Description, Layer: string(sk.Layer), Path: sk.GuestPath, - Addon: sk.Addon, AddonTitle: sk.AddonTitle, ShadowedBy: sk.ShadowedBy, Fallback: sk.Fallback, + Addon: sk.Addon, AddonTitle: sk.AddonTitle, Agent: sk.Agent, AgentName: sk.AgentName, + Removable: sk.Layer == skills.LayerAgent, ShadowedBy: sk.ShadowedBy, Fallback: sk.Fallback, Enabled: !sk.Off, Shadowed: string(sk.Shadowed), Invalid: sk.Invalid, Editable: editable(c, sk.Layer), }) } @@ -96,9 +97,10 @@ func (h *Handlers) CreateSkill(c echo.Context) error { // GetSkill reads a skill. // @Summary Read a skill. // @Tags chats -// @Param layer path string true "personal, shared, addon or builtin" +// @Param layer path string true "personal, shared, agent, addon or builtin" // @Param name path string true "the skill's name" // @Param addon query string false "the add-on's ID, for the addon layer" +// @Param agent query string false "the saved agent's ID, for the agent layer" // @Success 200 {object} schema.ChatsSkillDetail "The skill with its text" // @Failure 400 {object} schema.ErrorResponse "The request is not valid" // @Router /api/chats/skills/{layer}/{name} [get] @@ -119,6 +121,14 @@ func (h *Handlers) GetSkill(c echo.Context) error { } return c.JSON(http.StatusOK, schema.ChatsSkillDetail{Name: name, Layer: string(layer), Description: desc, Body: body}) } + if layer == skills.LayerAgent { + name := c.Param("name") + desc, body, err := fc.f.ReadAgentSkill(c.Request().Context(), fc.p, c.QueryParam("agent"), name) + if err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSkillDetail{Name: name, Layer: string(layer), Description: desc, Body: body}) + } return h.skillDetail(c, fc, layer, c.Param("name")) } @@ -163,9 +173,10 @@ func (h *Handlers) UpdateSkill(c echo.Context) error { // DeleteSkill moves a skill to the Trash. // @Summary Delete a skill. -// @Description The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator. +// @Description The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator. A skill of the agent layer is deleted with the agent query parameter. // @Tags chats -// @Param layer path string true "personal or shared" +// @Param layer path string true "personal, shared or agent" +// @Param agent query string false "the saved agent's ID, for the agent layer" // @Param name path string true "the skill's name" // @Success 200 {object} map[string]bool "deleted" // @Failure 400 {object} schema.ErrorResponse "The request is not valid" @@ -176,8 +187,14 @@ func (h *Handlers) DeleteSkill(c echo.Context) error { return nil } layer, ok := parseLayer(c.Param("layer")) + if ok && layer == skills.LayerAgent { + if err := fc.f.DeleteAgentSkill(c.Request().Context(), fc.p, c.QueryParam("agent"), c.Param("name")); err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"deleted": true}) + } if !ok || !writable(layer) { - return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal or shared") + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal, shared or agent") } if err := fc.f.DeleteSkill(c.Request().Context(), skillActor(c, fc, layer), layer, c.Param("name")); err != nil { return failFile(c, err) diff --git a/core/schema/chats.go b/core/schema/chats.go index bc55adc8a9..a0225256f6 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -768,12 +768,20 @@ type ChatsSkill struct { Name string `json:"name"` Description string `json:"description"` // Layer is where it comes from: folder (a .skills folder of your files), - // personal, shared, addon (an installed add-on ships it) or builtin. + // agent (a saved agent proposed it), personal, shared, addon (an installed + // add-on ships it) or builtin. Layer string `json:"layer"` // Addon and AddonTitle name the add-on that provides a skill of the addon // layer. Addon string `json:"addon,omitempty"` AddonTitle string `json:"addon_title,omitempty"` + // Agent and AgentName name the saved agent that owns a skill of the agent + // layer: the agent proposed it and the owner confirmed it. + Agent string `json:"agent,omitempty"` + AgentName string `json:"agent_name,omitempty"` + // Removable is true when the skill can be deleted from the Skills page + // although it cannot be edited there (a skill of the agent layer). + Removable bool `json:"removable,omitempty"` // Path is the path of its SKILL.md as the model sees it. Path string `json:"path"` // Enabled is false when the chat has switched it off. diff --git a/core/services/studio/actorscan_test.go b/core/services/studio/actorscan_test.go index 2635eaa2ef..710e440c31 100644 --- a/core/services/studio/actorscan_test.go +++ b/core/services/studio/actorscan_test.go @@ -40,6 +40,7 @@ var actorAllowlist = map[string]string{ "agentfolder.go:folderResolver.pathsOf": "finds where an agent's folder is now, for the person and for chatAccess", "chatcommands.go:Service.chatSkills": "lists the skills a person may run as commands", "skillsvc.go:Service.Skills": "lists the user's skills for the skills page", + "skillsvc.go:Service.agentSkillsOf": "lists the agent-layer skills of the user's own agents for the skills page; a person's view, never the model's", "skillsvc.go:Service.chatSkillSet": "reads the index of the skills the chat may use", "loadskill.go:Service.loadSkill": "reads only skills the agent grants, by name", "chatfiles.go:Service.ResolveAttachments": "the attachments a person sees in the transcript are a user surface", diff --git a/core/services/studio/agentscope.go b/core/services/studio/agentscope.go index a9dc30a8b9..b3a3c18375 100644 --- a/core/services/studio/agentscope.go +++ b/core/services/studio/agentscope.go @@ -14,6 +14,8 @@ import ( // intersected with what the user may use at the time of the turn. A nil scope is // a plain chat, which gets everything its user may use. type scope struct { + agentName string + agentID string // the saved agent of the chat; its skills layer is granted instructions string deleted bool // the chat names an agent that no longer exists mcp []string @@ -53,6 +55,7 @@ func (s *Service) scopeOf(ctx context.Context, rec session.ChatRecord) *scope { } held := s.usableGrants(ctx, rec.Principal, ag.Builtins) return &scope{ + agentID: rec.AgentID, agentName: ag.Name, builtins: set(held), builtinNames: held, instructions: strings.TrimSpace(ag.Instructions), mcp: ag.MCPServers, addons: set(ag.Addons), collections: set(ag.Collections), skills: set(ag.Skills), diff --git a/core/services/studio/agentskills_internal_test.go b/core/services/studio/agentskills_internal_test.go new file mode 100644 index 0000000000..3623b12e27 --- /dev/null +++ b/core/services/studio/agentskills_internal_test.go @@ -0,0 +1,154 @@ +package studio + +import ( + "context" + "encoding/json" + "strings" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/proposals" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/skills" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +var _ = Describe("the agent skills layer", func() { + var ( + ctx context.Context + svc *Service + f *files.Service + sales agents.Agent + other agents.Agent + chatID string + ) + put := func(rel string, data []byte) { + abs, err := f.Abs("alice", rel) + Expect(err).NotTo(HaveOccurred()) + _, err = f.Store().Put(ctx, f.Actor("alice"), abs, strings.NewReader(string(data)), workspace.PutOptions{Overwrite: true}) + Expect(err).NotTo(HaveOccurred()) + } + chatOf := func(a agents.Agent) session.ChatRecord { + c, err := svc.Create(ctx, "alice", CreateInput{Model: "m", AgentID: a.ID}) + Expect(err).NotTo(HaveOccurred()) + rec, err := svc.d.Store.GetChat(ctx, "alice", c.ID) + Expect(err).NotTo(HaveOccurred()) + return rec + } + resolveFor := func(rec session.ChatRecord) map[string]skills.Skill { + sc := svc.scopeOf(ctx, rec) + list, _, _, err := svc.chatSkillSet(ctx, "alice", rec.SkillsOff, rec.AddonsOff, sc, Commands{}) + Expect(err).NotTo(HaveOccurred()) + out := map[string]skills.Skill{} + for _, sk := range list { + if sk.Shadowed == "" && !sk.Off { + out[sk.Name] = sk + } + } + return out + } + + BeforeEach(func() { + ctx = context.Background() + svc, f = newSkillService(&skillBroker{}) + st, err := agents.NewJSON("") + Expect(err).NotTo(HaveOccurred()) + svc.d.Agents = st + Expect(f.SyncBuiltin(ctx, fstest.MapFS{ + "writing-skills/SKILL.md": {Data: skillMD("writing-skills", "Built-in writing.", "Writing body.")}, + "review/SKILL.md": {Data: skillMD("review", "Built-in review.", "Built-in review body.")}, + })).To(Succeed()) + sales, err = svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Sales", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + other, err = svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Other", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + put("/.skills/review/SKILL.md", skillMD("review", "Personal review.", "Personal review body.")) + put("/.skills/notes/SKILL.md", skillMD("notes", "Personal notes.", "Personal notes body.")) + put("/"+files.AgentSkillsDir(sales.ID)+"/review/SKILL.md", skillMD("review", "Sales review.", "Sales review body.")) + put("/"+files.AgentSkillsDir(sales.ID)+"/pitch/SKILL.md", skillMD("pitch", "Sales pitch.", "Pitch body.")) + put("/"+files.AgentSkillsDir(other.ID)+"/secret/SKILL.md", skillMD("secret", "Other only.", "Secret body.")) + }) + + It("is nearer than the personal layer for the agent that owns it", func() { + rec := chatOf(sales) + got := resolveFor(rec) + Expect(got["review"].Layer).To(Equal(skills.LayerAgent)) + Expect(got["review"].Description).To(Equal("Sales review.")) + Expect(got["review"].Agent).To(Equal(sales.ID)) + Expect(got["review"].AgentName).To(Equal("Sales")) + Expect(got["review"].GuestPath).To(HaveSuffix("/" + files.AgentSkillsDir(sales.ID) + "/review/SKILL.md")) + }) + + It("counts as granted without being listed in the agent's skills, and keeps ungranted ones out", func() { + got := resolveFor(chatOf(sales)) + Expect(got).To(HaveKey("pitch"), "the agent layer is granted") + Expect(got).NotTo(HaveKey("notes"), "a personal skill the agent was not granted stays out") + Expect(got).NotTo(HaveKey("writing-skills"), "writing-skills needs skills.write") + }) + + It("shows an agent only its own skills", func() { + got := resolveFor(chatOf(other)) + Expect(got).To(HaveKey("secret")) + Expect(got).NotTo(HaveKey("pitch")) + Expect(got).NotTo(HaveKey("review"), "nothing granted, and the other agent's review is not here") + }) + + It("is absent from a plain chat, which sees the personal skill", func() { + c, err := svc.Create(ctx, "alice", CreateInput{Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + rec, err := svc.d.Store.GetChat(ctx, "alice", c.ID) + Expect(err).NotTo(HaveOccurred()) + got := resolveFor(rec) + Expect(got["review"].Layer).To(Equal(skills.LayerPersonal)) + Expect(got).NotTo(HaveKey("pitch")) + }) + + It("exposes writing-skills to an agent that holds skills.write, with the propose preamble", func() { + a, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Writer", Model: "m", Builtins: []string{contract.CapSkillsWrite}}) + Expect(err).NotTo(HaveOccurred()) + rec := chatOf(a) + Expect(resolveFor(rec)).To(HaveKey("writing-skills")) + chatID = rec.ID + args, _ := json.Marshal(map[string]string{"name": "writing-skills"}) + out, err := svc.loadSkillRun(userChatActor("alice", chatID), loadOpts{files: true})(ctx, args) + Expect(err).NotTo(HaveOccurred()) + Expect(out).To(HavePrefix("Do not write files. Call propose_change with kind skill.")) + Expect(out).To(ContainSubstring("Writing body.")) + }) + + It("lists the skills of every agent outside a chat, each with its own agent", func() { + list, _, err := svc.Skills(ctx, "alice", "") + Expect(err).NotTo(HaveOccurred()) + by := map[string]string{} + for _, sk := range list { + if sk.Layer == skills.LayerAgent { + by[sk.Name] = sk.Agent + } + } + Expect(by).To(Equal(map[string]string{"review": sales.ID, "pitch": sales.ID, "secret": other.ID})) + }) + + It("is written only by a proposal that names a skill an agent may own", func() { + path, err := skillKind.Path(proposals.Aim{Scope: contract.ScopeAgent, Agent: sales.ID, Target: "pitch"}) + Expect(err).NotTo(HaveOccurred()) + Expect(path).To(Equal(files.AgentSkillsDir(sales.ID) + "/pitch/SKILL.md")) + check := func(name string) error { + return skillKind.Check(proposals.CheckInput{Aim: proposals.Aim{Scope: contract.ScopeAgent, Agent: sales.ID, Target: name}, + Content: string(skillMD(name, "d", "body"))}) + } + Expect(check("pitch")).To(Succeed()) + Expect(check("writing-skills")).To(MatchError(ContainSubstring("built-in skill")), "a core skill name") + Expect(check("xlsx")).To(MatchError(ContainSubstring("document slot")), "a slot from slots.json") + }) + + It("is deleted to the Trash from the page", func() { + Expect(f.DeleteAgentSkill(ctx, "alice", sales.ID, "pitch")).To(Succeed()) + Expect(resolveFor(chatOf(sales))).NotTo(HaveKey("pitch")) + Expect(f.DeleteAgentSkill(ctx, "alice", "../x", "pitch")).NotTo(Succeed()) + }) +}) diff --git a/core/services/studio/changekinds.go b/core/services/studio/changekinds.go index 14b5f111ac..43298c86b5 100644 --- a/core/services/studio/changekinds.go +++ b/core/services/studio/changekinds.go @@ -10,6 +10,7 @@ import ( "strings" "unicode/utf8" + "github.com/mudler/LocalAI/core/services/studio/files" "github.com/mudler/LocalAI/core/services/studio/pages" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/skills" @@ -36,6 +37,14 @@ 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 } +// reservedSkillName reports a name an agent's own skill may not take: a core +// skill or a document slot (the slots are data, pkg/addons/contract/slots.json). +// An agent skill is nearer than every layer but the folder one, so a skill +// of such a name would silently replace the provider's. +func reservedSkillName(name string) bool { + return contract.ProtectedSkill(name) || contract.ReplaceableSkill(name) +} + var skillKind = proposals.Kind{ Name: contract.ChangeSkill, Grant: contract.CapSkillsWrite, @@ -51,7 +60,7 @@ var skillKind = proposals.Kind{ 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 files.AgentSkillsDir(a.Agent) + "/" + 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) }, @@ -62,6 +71,8 @@ var skillKind = proposals.Kind{ 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.Aim.Scope == contract.ScopeAgent && reservedSkillName(name): + return fmt.Errorf("%q is a built-in skill or a document slot that an add-on serves: an agent skill cannot take its name", name) 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) } diff --git a/core/services/studio/files/layers.go b/core/services/studio/files/layers.go index c47ec0dfaf..042db29c7c 100644 --- a/core/services/studio/files/layers.go +++ b/core/services/studio/files/layers.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "path" + "regexp" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/skills" @@ -48,21 +49,44 @@ func AddonSkillsRoot(addonID, title string) skills.Root { Path: AddonSkillsPath + "/" + addonID, Guest: addonGuest + "/" + addonID} } +// AgentSkillsDir is the folder of an agent's skills, relative to the home of +// its owner. The proposal kind skill writes there. +func AgentSkillsDir(agent string) string { + return "LocalAI/agents/" + agent + "/" + skills.SkillsLayerDir +} + +// AgentSkillsRoot returns the root of the agent layer for one saved agent of +// principal, for skills.Resolve. The agent must be a safe ID (see +// ValidAgentID): the ID becomes a folder name. +func AgentSkillsRoot(principal, agent, name string) skills.Root { + return skills.Root{Layer: skills.LayerAgent, Agent: agent, AgentName: name, + Path: session.HomePath(principal) + "/" + AgentSkillsDir(agent), + Guest: session.GuestRoot + "/" + session.GuestHome + "/" + AgentSkillsDir(agent)} +} + +// ValidAgentID reports whether an agent ID can name a folder of the agent +// layer. +func ValidAgentID(id string) bool { return agentIDRE.MatchString(id) } + +var agentIDRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + // SkillRoots returns the places where the user's skills are looked for, from // the nearest layer to the farthest: any .skills folder of the user's home, the // personal skills, the shared skills and the built-in ones. The Chats folder is // not walked: it holds uploads, not skills. // -// addons are the roots of the add-on layer the chat may use now (see -// AddonSkillsRoot); the caller decides which add-ons are usable. -func (s *Service) SkillRoots(principal string, addons ...skills.Root) []skills.Root { +// extra are the roots of the add-on layer the chat may use now (see +// AddonSkillsRoot) and of the agent layer of the chat's saved agent (see +// AgentSkillsRoot); the caller decides which are usable. Any other root is +// dropped. +func (s *Service) SkillRoots(principal string, extra ...skills.Root) []skills.Root { home := session.HomePath(principal) guestHome := session.GuestRoot + "/" + session.GuestHome roots := []skills.Root{ {Layer: skills.LayerBuiltin, Path: BuiltinSkillsPath, Guest: builtinGuest}, } - for _, r := range addons { - if r.Layer == skills.LayerAddon { + for _, r := range extra { + if r.Layer == skills.LayerAddon || (r.Layer == skills.LayerAgent && ValidAgentID(r.Agent)) { roots = append(roots, r) } } diff --git a/core/services/studio/files/skillfiles.go b/core/services/studio/files/skillfiles.go index 9cca5e7857..d44f2717e4 100644 --- a/core/services/studio/files/skillfiles.go +++ b/core/services/studio/files/skillfiles.go @@ -89,6 +89,44 @@ func (s *Service) ReadSkill(ctx context.Context, principal string, layer skills. 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) { diff --git a/core/services/studio/files/skilllayers_test.go b/core/services/studio/files/skilllayers_test.go index 9f83f99f3f..5c0eb38223 100644 --- a/core/services/studio/files/skilllayers_test.go +++ b/core/services/studio/files/skilllayers_test.go @@ -6,6 +6,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/files" "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/skills" ) var _ = Describe("the skill layers", func() { @@ -19,3 +20,18 @@ var _ = Describe("the skill layers", func() { Expect(files.SharedGuestPath("/workspace/shared-addon-skills/office/xlsx/SKILL.md")).To(Equal(files.AddonSkillsPath + "/office/xlsx/SKILL.md")) }) }) + +var _ = Describe("the agent skills layer roots", func() { + It("puts the agent layer between the folder layer and the personal one, and refuses an unsafe agent ID", func() { + f := newFiles() + roots := f.SkillRoots("alice", files.AgentSkillsRoot("alice", "sales", "Sales"), files.AgentSkillsRoot("alice", "../x", "Bad")) + var agentRoots []string + for _, r := range roots { + if r.Layer == skills.LayerAgent { + agentRoots = append(agentRoots, r.Agent) + } + } + Expect(agentRoots).To(Equal([]string{"sales"})) + Expect(files.AgentSkillsRoot("alice", "sales", "Sales").Path).To(HaveSuffix("/LocalAI/agents/sales/skills")) + }) +}) diff --git a/core/services/studio/loadskill.go b/core/services/studio/loadskill.go index 12cd9fe442..29b14505e2 100644 --- a/core/services/studio/loadskill.go +++ b/core/services/studio/loadskill.go @@ -180,10 +180,33 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte return "", fmt.Errorf("unknown skill %q: %s", skills.Clean(in.Name), notes[i]) } } - return s.loadSkill(ctx, a.Principal, list, in.Name, o) + text, err := s.loadSkill(ctx, a.Principal, list, in.Name, o) + if err != nil { + return text, err + } + return s.writingSkillsPreamble(ctx, a, list, in.Name) + text, nil } } +// agentWritingPreamble leads writing-skills in a chat that runs a saved agent. +// The skill text names the .skills folder, which a saved agent cannot write: +// its skills are proposals the owner confirms. +const agentWritingPreamble = "Do not write files. Call " + proposeChangeName + " with kind skill.\n\n" + +// writingSkillsPreamble returns agentWritingPreamble when the skill loaded is +// the built-in writing-skills and the chat runs a saved agent, else "". +func (s *Service) writingSkillsPreamble(ctx context.Context, a workspace.Actor, list []skills.Skill, name string) string { + sk, ok := skills.Find(list, name) + if !ok || sk.Name != writingSkillsName || sk.Layer != skills.LayerBuiltin { + return "" + } + rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID) + if err != nil || rec.AgentID == "" { + return "" + } + return agentWritingPreamble +} + // loadSkill returns the instructions of the skill of a name among list, read // as the user. A name that is not in list is an error that names the skills // there are, and the near ones first. diff --git a/core/services/studio/skills/resolve.go b/core/services/studio/skills/resolve.go index 577a6a06f8..a8bc54b6db 100644 --- a/core/services/studio/skills/resolve.go +++ b/core/services/studio/skills/resolve.go @@ -19,9 +19,14 @@ import ( // Layer is where a skill comes from. A nearer layer wins over a farther one. type Layer string -// The five layers, nearest first. +// The six layers, nearest first. const ( - LayerFolder Layer = "folder" + LayerFolder Layer = "folder" + // LayerAgent holds the skills of one saved agent, in the owner's home under + // LocalAI/agents//skills. Only a chat that runs that agent sees + // them. It is nearer than the personal layer, so an agent's own version of + // a skill wins over the owner's for that agent. + LayerAgent Layer = "agent" LayerPersonal Layer = "personal" LayerShared Layer = "shared" // LayerAddon holds the skills of the add-ons a chat may use now. It is @@ -35,21 +40,27 @@ func (l Layer) rank() int { switch l { case LayerFolder: return 0 - case LayerPersonal: + case LayerAgent: return 1 - case LayerShared: + case LayerPersonal: return 2 - case LayerAddon: + case LayerShared: return 3 - case LayerBuiltin: + case LayerAddon: return 4 + case LayerBuiltin: + return 5 } - return 5 + return 6 } // SkillsDir is the name of the folder that holds the skills of a folder. const SkillsDir = ".skills" +// SkillsLayerDir is the name of the folder that holds the skills of an agent, +// inside the agent's folder. +const SkillsLayerDir = "skills" + // SkillFile is the name of the file that makes a folder a skill. const SkillFile = "SKILL.md" @@ -86,6 +97,9 @@ type Root struct { // Addon and AddonTitle name the add-on of an add-on layer root: one root // per add-on. They are copied to the skills found there. Addon, AddonTitle string + // Agent names the saved agent of an agent layer root. It is copied to the + // skills found there, with AgentName, its display name. + Agent, AgentName string } // Skill is one skill that Resolve saw. @@ -114,6 +128,8 @@ type Skill struct { // Addon and AddonTitle name the add-on that ships a skill of the add-on // layer. Addon, AddonTitle string + // Agent names the saved agent that owns a skill of the agent layer. + Agent, AgentName string // Fallback, set by the product, names the add-on whose own skill of this // name would replace this built-in one but is off or not running here. Fallback string @@ -152,12 +168,14 @@ var ErrNotReplaceable = errors.New("the name is a built-in skill and cannot be r // container is a folder that holds one subfolder per skill. type container struct { - layer Layer - path string - guest string - depth int // folders between the walk root and the container's parent - addon string - title string + layer Layer + path string + guest string + depth int // folders between the walk root and the container's parent + addon string + title string + agent string + agentName string } // Resolve reads the skills that the actor can see in roots and applies the @@ -210,7 +228,7 @@ func Resolve(ctx context.Context, s workspace.Store, a workspace.Actor, roots [] boxes = append(boxes, found...) continue } - boxes = append(boxes, container{layer: r.Layer, path: cleanPath(r.Path), guest: strings.TrimSuffix(r.Guest, "/"), addon: r.Addon, title: r.AddonTitle}) + boxes = append(boxes, container{layer: r.Layer, path: cleanPath(r.Path), guest: strings.TrimSuffix(r.Guest, "/"), addon: r.Addon, title: r.AddonTitle, agent: r.Agent, agentName: r.AgentName}) } if o.Logger != nil { o.Logger.Debug("skills: folder walk", "folders", walked, "limit", maxWalkFolders) @@ -381,6 +399,8 @@ func readSkill(ctx context.Context, s workspace.Store, a workspace.Actor, b cont depth: b.depth, Addon: b.addon, AddonTitle: b.title, + Agent: b.agent, + AgentName: b.agentName, } rc, node, err := s.Open(ctx, a, dp+"/"+SkillFile) if err != nil { diff --git a/core/services/studio/skillsvc.go b/core/services/studio/skillsvc.go index 0be4cb62cf..02c155fa7f 100644 --- a/core/services/studio/skillsvc.go +++ b/core/services/studio/skillsvc.go @@ -12,6 +12,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/skills" "github.com/mudler/LocalAI/core/services/studio/wellknown" + "github.com/mudler/LocalAI/pkg/addons/contract" ) // ErrBadSkillName means a switch names something that is not a skill name, or @@ -155,6 +156,32 @@ func (s *Service) skillTurn(ctx context.Context, p string, off, addonsOff []stri return skills.Index(on), strings.Join(notes, "; "), hex.EncodeToString(h.Sum(nil)), fallback, nil } +// agentSkillRoots returns the agent layer of the chat's saved agent: none for a +// plain chat or an agent that is gone. +func agentSkillRoots(principal string, sc *scope) []skills.Root { + if sc == nil || sc.deleted || !files.ValidAgentID(sc.agentID) { + return nil + } + return []skills.Root{files.AgentSkillsRoot(principal, sc.agentID, sc.agentName)} +} + +// writingSkillsName is the built-in skill that teaches the model to write a +// skill. The grant skills.write exposes it to a saved agent. +const writingSkillsName = "writing-skills" + +// grantsSkill reports whether a saved agent may use the skill sk: one it was +// granted, anything in its own skills layer (the agent made or was given it), +// and writing-skills when it holds skills.write. +func (sc *scope) grantsSkill(sk skills.Skill) bool { + switch { + case sk.Layer == skills.LayerAgent: + return true + case sk.Layer == skills.LayerBuiltin && sk.Name == writingSkillsName && sc.builtins[contract.CapSkillsWrite]: + return true + } + return sc.skills[sk.Name] +} + // chatSkillSet resolves the skills of one chat: the user's layers, the skills // of the add-ons the chat may use now, the chat's switches and the grants of // its agent. The index of the turn and load_skill both read it, so the model @@ -165,8 +192,8 @@ func (s *Service) chatSkillSet(ctx context.Context, p string, off, addonsOff []s if err != nil { return nil, nil, "", err } - list, notes, err := skills.Resolve(ctx, s.d.Files.Store(), s.d.Files.Actor(p), s.d.Files.SkillRoots(p, ad.roots()...), ad.options(skills.Options{ - Off: func(sk skills.Skill) bool { return skip(sk) || (sc != nil && !sc.skills[sk.Name]) }, + list, notes, err := skills.Resolve(ctx, s.d.Files.Store(), s.d.Files.Actor(p), s.d.Files.SkillRoots(p, append(ad.roots(), agentSkillRoots(p, sc)...)...), ad.options(skills.Options{ + Off: func(sk skills.Skill) bool { return skip(sk) || (sc != nil && !sc.grantsSkill(sk)) }, Logger: s.log, })) if err != nil { @@ -203,7 +230,7 @@ func (s *Service) Skills(ctx context.Context, user, conv string) ([]skills.Skill if err != nil { return nil, nil, err } - list, notes, err := skills.Resolve(ctx, s.d.Files.Store(), s.d.Files.Actor(p), s.d.Files.SkillRoots(p, ad.roots()...), ad.options(skills.Options{ + list, notes, err := skills.Resolve(ctx, s.d.Files.Store(), s.d.Files.Actor(p), s.d.Files.SkillRoots(p, append(ad.roots(), agentSkillRoots(p, sc)...)...), ad.options(skills.Options{ Off: skillOff(off), Logger: s.log, })) @@ -211,9 +238,42 @@ func (s *Service) Skills(ctx context.Context, user, conv string) ([]skills.Skill return nil, nil, err } ad.markFallbacks(list) + if conv == "" { + // Outside a chat the skills of every saved agent are listed, each + // resolved on its own: two agents may keep a skill of one name. + more, err := s.agentSkillsOf(ctx, p) + if err != nil { + return nil, nil, err + } + list = append(list, more...) + } return list, ad.withNotes(notes), nil } +// agentSkillsOf lists the agent-layer skills of every saved agent of p. +func (s *Service) agentSkillsOf(ctx context.Context, p string) ([]skills.Skill, error) { + if s.d.Agents == nil { + return nil, nil + } + all, err := s.d.Agents.List(ctx, p) + if err != nil { + return nil, err + } + var out []skills.Skill + for _, ag := range all { + if !files.ValidAgentID(ag.ID) { + continue + } + list, _, err := skills.Resolve(ctx, s.d.Files.Store(), s.d.Files.Actor(p), + []skills.Root{files.AgentSkillsRoot(p, ag.ID, ag.Name)}, skills.Options{Logger: s.log}) + if err != nil { + return nil, err + } + out = append(out, list...) + } + return out, nil +} + // WellKnown returns the well-known files of the users' homes: persona.json, // user.json and the others. It works on the same files service as the chats. func (s *Service) WellKnown() *wellknown.Service { return wellknown.New(s.d.Files) } diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index da2420807f..5742a7d05c 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -1385,16 +1385,19 @@ A skill is a folder with a `SKILL.md`: a `name`, a one-line `description` and a When the folder of a skill holds other files, such as references and scripts, the answer starts with `Base directory for this skill:` and the folder, and names up to 20 of the files, relative to it, so the model reads them with `read_file`. A sub-agent that has no `read_file` gets the instructions only. A skill with `requires: [exec]` in a chat that cannot run commands answers that it cannot be used there. Every chat with at least one skill has `load_skill`, and a sub-agent gets it with its parent's other tools. It has no switch and Settings > Tools does not list it; the Skills menu chooses what it offers. The Sandbox does not see the skill folders (the built-in `bash` does, see [Commands on this machine](#commands-on-this-machine)). When the model reads a `SKILL.md` with `read_file` at a wrong path, the error names the right path and `load_skill`. A `SKILL.md` over 64 KiB is refused. -Skills come from five places, nearest first, and the nearest wins when two have the same name: +Skills come from six places, nearest first, and the nearest wins when two have the same name: | Layer | Where | Who writes it | |---|---|---| | Folder | any `.skills` folder in your files | you | +| Agent | `LocalAI/agents//skills` in your home folder | the agent proposes, you confirm | | Personal | `.skills` in your home folder | you | | Shared | for every user | an administrator | | Add-on | ships with an installed add-on | the add-on owner | | Built-in | ships with the server | the server | +The agent layer holds the skills of one saved agent. Only a chat that runs that agent sees them, and it needs no grant in the agent's skill list. They win over your personal skill of the same name, for that agent only. An agent writes one by a change proposal of kind `skill` with scope `agent` (see [Agents]({{%relref "features/chats-agents" %}})); nothing is written until you confirm, and the write can be undone. An agent skill cannot take the name of a built-in skill or of a document skill such as `xlsx`, and a skill that you locked in your personal layer stays locked against it. The grant `skills.write` shows the built-in `writing-skills` to the agent, led by the line "Do not write files. Call propose_change with kind skill." The **Skills** page lists agent skills with the agent's name and a **Remove** button; a removed skill goes to the Trash. The API lists them with `layer: "agent"`, `agent` and `agent_name`, and `GET` or `DELETE /api/chats/skills/agent/?agent=` reads or removes one. + The add-on layer holds the skills of the add-ons that the chat can use now: installed, running, usable by you and on for the chat (see [Add-on tools](#add-on-tools)). A chat with a saved agent gets them only for the add-ons the agent grants. The model loads one by its name with `load_skill`; its files are in `/workspace/shared-addon-skills///`, where the add-on ID says where it comes from. Switching the add-on off for a chat takes its skills out of that chat's index, and the skill list shows each one with `addon` and `addon_title`. Its switch id is `addon:`. An add-on can also replace a built-in skill of the same name, for example the Office add-on's own `xlsx`, written for its tools. The add-on's skill then serves and the built-in one shows `shadowed: "addon"` with `shadowed_by`, the add-on's ID. When the add-on is off for the chat or not running, or you switch its skill off (`addon:xlsx`), the built-in skill serves again: the model is told in one line, and the built-in skill in the skill list carries `fallback`, the add-on's title. When several add-ons replace one skill, the one installed first serves until an administrator chooses another in Settings > Tools, under **Skills that add-ons replace**, or with the switch in the add-on's details on the Add-ons page. diff --git a/swagger/docs.go b/swagger/docs.go index 70c87aa9d9..276b502537 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -7247,7 +7247,7 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "personal, shared, addon or builtin", + "description": "personal, shared, agent, addon or builtin", "name": "layer", "in": "path", "required": true @@ -7264,6 +7264,12 @@ const docTemplate = `{ "description": "the add-on's ID, for the addon layer", "name": "addon", "in": "query" + }, + { + "type": "string", + "description": "the saved agent's ID, for the agent layer", + "name": "agent", + "in": "query" } ], "responses": { @@ -7327,7 +7333,7 @@ const docTemplate = `{ } }, "delete": { - "description": "The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator.", + "description": "The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator. A skill of the agent layer is deleted with the agent query parameter.", "tags": [ "chats" ], @@ -7335,11 +7341,17 @@ const docTemplate = `{ "parameters": [ { "type": "string", - "description": "personal or shared", + "description": "personal, shared or agent", "name": "layer", "in": "path", "required": true }, + { + "type": "string", + "description": "the saved agent's ID, for the agent layer", + "name": "agent", + "in": "query" + }, { "type": "string", "description": "the skill's name", @@ -22243,6 +22255,13 @@ const docTemplate = `{ "addon_title": { "type": "string" }, + "agent": { + "description": "Agent and AgentName name the saved agent that owns a skill of the agent\nlayer: the agent proposed it and the owner confirmed it.", + "type": "string" + }, + "agent_name": { + "type": "string" + }, "description": { "type": "string" }, @@ -22267,7 +22286,7 @@ const docTemplate = `{ "type": "string" }, "layer": { - "description": "Layer is where it comes from: folder (a .skills folder of your files),\npersonal, shared, addon (an installed add-on ships it) or builtin.", + "description": "Layer is where it comes from: folder (a .skills folder of your files),\nagent (a saved agent proposed it), personal, shared, addon (an installed\nadd-on ships it) or builtin.", "type": "string" }, "name": { @@ -22277,6 +22296,10 @@ const docTemplate = `{ "description": "Path is the path of its SKILL.md as the model sees it.", "type": "string" }, + "removable": { + "description": "Removable is true when the skill can be deleted from the Skills page\nalthough it cannot be edited there (a skill of the agent layer).", + "type": "boolean" + }, "shadowed": { "description": "Shadowed names the layer of a nearer skill with the same name, which wins.", "type": "string" diff --git a/swagger/swagger.json b/swagger/swagger.json index d05caccd19..ba4698794b 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -7244,7 +7244,7 @@ "parameters": [ { "type": "string", - "description": "personal, shared, addon or builtin", + "description": "personal, shared, agent, addon or builtin", "name": "layer", "in": "path", "required": true @@ -7261,6 +7261,12 @@ "description": "the add-on's ID, for the addon layer", "name": "addon", "in": "query" + }, + { + "type": "string", + "description": "the saved agent's ID, for the agent layer", + "name": "agent", + "in": "query" } ], "responses": { @@ -7324,7 +7330,7 @@ } }, "delete": { - "description": "The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator.", + "description": "The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator. A skill of the agent layer is deleted with the agent query parameter.", "tags": [ "chats" ], @@ -7332,11 +7338,17 @@ "parameters": [ { "type": "string", - "description": "personal or shared", + "description": "personal, shared or agent", "name": "layer", "in": "path", "required": true }, + { + "type": "string", + "description": "the saved agent's ID, for the agent layer", + "name": "agent", + "in": "query" + }, { "type": "string", "description": "the skill's name", @@ -22240,6 +22252,13 @@ "addon_title": { "type": "string" }, + "agent": { + "description": "Agent and AgentName name the saved agent that owns a skill of the agent\nlayer: the agent proposed it and the owner confirmed it.", + "type": "string" + }, + "agent_name": { + "type": "string" + }, "description": { "type": "string" }, @@ -22264,7 +22283,7 @@ "type": "string" }, "layer": { - "description": "Layer is where it comes from: folder (a .skills folder of your files),\npersonal, shared, addon (an installed add-on ships it) or builtin.", + "description": "Layer is where it comes from: folder (a .skills folder of your files),\nagent (a saved agent proposed it), personal, shared, addon (an installed\nadd-on ships it) or builtin.", "type": "string" }, "name": { @@ -22274,6 +22293,10 @@ "description": "Path is the path of its SKILL.md as the model sees it.", "type": "string" }, + "removable": { + "description": "Removable is true when the skill can be deleted from the Skills page\nalthough it cannot be edited there (a skill of the agent layer).", + "type": "boolean" + }, "shadowed": { "description": "Shadowed names the layer of a nearer skill with the same name, which wins.", "type": "string" diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 94082cc618..ec242a82b2 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -5793,6 +5793,13 @@ definitions: type: string addon_title: type: string + agent: + description: |- + Agent and AgentName name the saved agent that owns a skill of the agent + layer: the agent proposed it and the owner confirmed it. + type: string + agent_name: + type: string description: type: string editable: @@ -5818,13 +5825,19 @@ definitions: layer: description: |- Layer is where it comes from: folder (a .skills folder of your files), - personal, shared, addon (an installed add-on ships it) or builtin. + agent (a saved agent proposed it), personal, shared, addon (an installed + add-on ships it) or builtin. type: string name: type: string path: description: Path is the path of its SKILL.md as the model sees it. type: string + removable: + description: |- + Removable is true when the skill can be deleted from the Skills page + although it cannot be edited there (a skill of the agent layer). + type: boolean shadowed: description: Shadowed names the layer of a nearer skill with the same name, which wins. @@ -16968,13 +16981,18 @@ paths: /api/chats/skills/{layer}/{name}: delete: description: The skill's folder goes to the Trash of your files, where it can - be restored. Shared skills need an administrator. + be restored. Shared skills need an administrator. A skill of the agent layer + is deleted with the agent query parameter. parameters: - - description: personal or shared + - description: personal, shared or agent in: path name: layer required: true type: string + - description: the saved agent's ID, for the agent layer + in: query + name: agent + type: string - description: the skill's name in: path name: name @@ -16996,7 +17014,7 @@ paths: - chats get: parameters: - - description: personal, shared, addon or builtin + - description: personal, shared, agent, addon or builtin in: path name: layer required: true @@ -17010,6 +17028,10 @@ paths: in: query name: addon type: string + - description: the saved agent's ID, for the agent layer + in: query + name: agent + type: string responses: "200": description: The skill with its text