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
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:56:42 +00:00
1 parent e790271c63
commit 24c837e453
39 files changed
+1821 -80

No files matched your search

+1
View File
@@ -169,6 +169,7 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/chats/agents", FeatureAgents},
{"POST", "/api/chats/agents", FeatureAgents},
{"GET", "/api/chats/agents/templates", FeatureAgents},
{"GET", "/api/chats/agents/builtin-grants", FeatureAgents},
{"POST", "/api/chats/agents/templates/:id/create", FeatureAgents},
{"POST", "/api/chats/agents/import", FeatureAgents},
{"GET", "/api/chats/agents/:id/export", FeatureAgents},
+1
View File
@@ -41,6 +41,7 @@ var _ = Describe("Chats feature registration", func() {
"GET /api/chats/agents",
"POST /api/chats/agents",
"GET /api/chats/agents/templates",
"GET /api/chats/agents/builtin-grants",
"POST /api/chats/agents/templates/:id/create",
"POST /api/chats/agents/import",
"GET /api/chats/agents/:id/export",
File diff suppressed because one or more lines are too long.
+2 -2
View File
@@ -77,7 +77,7 @@ func agentOut(a agents.Agent) schema.ChatsAgent {
}
return schema.ChatsAgent{ID: a.ID, Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model,
MCPServers: nz(a.MCPServers), Addons: nz(a.Addons), Collections: nz(a.Collections), Skills: nz(a.Skills),
Network: networkOf(a.Network), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Created: a.Created, Updated: a.Updated}
Network: networkOf(a.Network), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Builtins: nz(a.Builtins), Created: a.Created, Updated: a.Updated}
}
// agentIn reads the body. A model the user may not use is refused here, so a
@@ -101,7 +101,7 @@ func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folders *[]studiosvc
}
return agents.Agent{Name: req.Name, Description: req.Description, Instructions: req.Instructions, Model: req.Model,
MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network,
BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn},
BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn, Builtins: req.Builtins},
foldersIn(req), true, nil
}
+17 -3
View File
@@ -47,7 +47,7 @@ func agentFileVersion(folders int, hasSwitches bool) int {
// ExportAgent returns a saved agent as a file.
// @Summary Export a saved agent.
// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.
// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.
// @Tags chats
// @Param id path string true "the agent's id"
// @Success 200 {object} schema.ChatsAgentExport "The agent file"
@@ -59,7 +59,7 @@ func (h *Handlers) ExportAgent(c echo.Context) error {
}
w := schema.ChatsAgentWrite{Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model,
MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network,
BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn}
BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn, Builtins: a.Builtins}
rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), []agents.Agent{a})[a.ID]
for i, f := range a.FolderList() {
// Imports resolve paths in the home only. A followed root outside
@@ -148,7 +148,7 @@ func (h *Handlers) ImportAgent(c echo.Context) error {
}
a := agents.Agent{Name: def.Name, Description: def.Description, Instructions: def.Instructions, Model: def.Model,
MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network,
BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn}
BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn, Builtins: def.Builtins}
var rowsIn []studiosvc.AgentFolderWrite
switch {
case def.Folders != nil:
@@ -221,6 +221,20 @@ func (h *Handlers) AgentTemplates(c echo.Context) error {
return c.JSON(http.StatusOK, out)
}
// BuiltinGrants lists the builtin grants an agent can be given.
// @Summary List the builtin grants of a saved agent.
// @Description Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.
// @Tags chats
// @Success 200 {object} schema.ChatsBuiltinGrantList "The grants, in display order"
// @Router /api/chats/agents/builtin-grants [get]
func (h *Handlers) BuiltinGrants(c echo.Context) error {
out := schema.ChatsBuiltinGrantList{Grants: []schema.ChatsBuiltinGrant{}}
for _, g := range h.svc.BuiltinGrants() {
out.Grants = append(out.Grants, schema.ChatsBuiltinGrant{Name: g.Name, Title: g.Title, Description: g.Description, Sensitive: g.Sensitive})
}
return c.JSON(http.StatusOK, out)
}
// CreateAgentFromTemplate saves a starter agent.
// @Summary Save a starter agent as your own.
// @Tags chats
+1
View File
@@ -242,6 +242,7 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend
e.GET(base+"/:id/mcp-apps/resource", h.GetMCPAppResource)
e.POST(base+"/:id/mcp-apps/call", h.CallMCPAppTool)
e.GET(base+"/agents/templates", h.AgentTemplates)
e.GET(base+"/agents/builtin-grants", h.BuiltinGrants)
e.POST(base+"/agents/templates/:id/create", h.CreateAgentFromTemplate)
e.POST(base+"/agents/import", h.ImportAgent)
e.GET(base+"/agents/:id/export", h.ExportAgent)
@@ -31,6 +31,7 @@ var _ = Describe("Chats agent export, import, templates and history", func() {
Entry("export", http.MethodGet, "/api/chats/agents/a1/export"),
Entry("import", http.MethodPost, "/api/chats/agents/import"),
Entry("templates", http.MethodGet, "/api/chats/agents/templates"),
Entry("builtin grants", http.MethodGet, "/api/chats/agents/builtin-grants"),
Entry("use a template", http.MethodPost, "/api/chats/agents/templates/reviewer/create"),
Entry("runs", http.MethodGet, "/api/chats/agents/a1/runs"),
)
@@ -71,6 +72,62 @@ var _ = Describe("Chats agent export, import, templates and history", func() {
Expect(decodeJSON(s.do("bob", http.MethodGet, "/api/chats/agents", nil))["agents"]).To(HaveLen(1))
})
Describe("builtin grants", func() {
It("lists the declared grants with their sensitivity", func() {
rec := s.do("alice", http.MethodGet, "/api/chats/agents/builtin-grants", nil)
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
list := decodeJSON(rec)["grants"].([]any)
byName := map[string]map[string]any{}
for _, g := range list {
m := g.(map[string]any)
Expect(m["title"]).NotTo(BeEmpty())
Expect(m["description"]).NotTo(BeEmpty())
byName[m["name"].(string)] = m
}
Expect(byName).To(And(HaveKey("memory.write"), HaveKey("skills.write"), HaveKey("pages.write"), HaveKey("dashboard.write"), HaveKey("saved_agents")))
Expect(byName["skills.write"]["sensitive"]).To(BeTrue())
Expect(byName["memory.write"]["sensitive"]).To(BeFalse())
})
It("saves, keeps, clears, exports and imports the grants of an agent", func() {
create := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Maker", "model": "test-model", "builtins": []string{"memory.write", "skills.write"}})
Expect(create.Code).To(Equal(http.StatusCreated), create.Body.String())
a := decodeJSON(create)
id := a["id"].(string)
Expect(a["builtins"]).To(ConsistOf("memory.write", "skills.write"))
// A new agent has none, and the field is always present.
plain := decodeJSON(s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Plain", "model": "test-model"}))
Expect(plain).To(HaveKeyWithValue("builtins", BeEmpty()))
// A write that omits the field keeps the grants; an empty list clears them.
keep := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Maker", "model": "test-model", "instructions": "x"})
Expect(keep.Code).To(Equal(http.StatusOK), keep.Body.String())
Expect(decodeJSON(keep)["builtins"]).To(ConsistOf("memory.write", "skills.write"))
exp := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/agents/"+id+"/export", nil))
Expect(exp["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write", "skills.write"))
imp := s.do("bob", http.MethodPost, "/api/chats/agents/import", exp)
Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String())
Expect(decodeJSON(imp)["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write", "skills.write"))
clear := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Maker", "model": "test-model", "builtins": []string{}})
Expect(decodeJSON(clear)["builtins"]).To(BeEmpty())
})
It("refuses an unknown grant on a write and drops it on an import; an old file imports as before", func() {
bad := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Bad", "model": "test-model", "builtins": []string{"root.shell"}})
Expect(bad.Code).To(Equal(http.StatusBadRequest), bad.Body.String())
file := map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Future", "model": "test-model", "builtins": []string{"memory.write", "from.the.future"}}}
imp := s.do("alice", http.MethodPost, "/api/chats/agents/import", file)
Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String())
Expect(decodeJSON(imp)["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write"))
old := s.do("alice", http.MethodPost, "/api/chats/agents/import", map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Old", "model": "test-model"}})
Expect(old.Code).To(Equal(http.StatusCreated), old.Body.String())
Expect(decodeJSON(old)["agent"].(map[string]any)["builtins"]).To(BeEmpty())
})
})
It("imports a file sent as a multipart form, and drops a model the server does not have", func() {
file := map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Shared", "instructions": "Hi", "model": "model-from-elsewhere"}}
var buf bytes.Buffer
+26
View File
@@ -58,6 +58,11 @@ type ChatsAgent struct {
// lists. Empty for an agent that has every group a chat has by default.
BuiltinsOff []string `json:"builtins_off"`
BuiltinsOn []string `json:"builtins_on"`
// Builtins are the builtin capabilities granted to the agent, by the names
// that GET /api/chats/agents/builtin-grants lists (memory.write,
// skills.write and so on). Empty grants none. A chat of the agent holds
// the grants you may use at that moment.
Builtins []string `json:"builtins"`
// Folder is the one folder that chats with the agent work in. It is
// absent for an agent that works on all your files.
Folder *ChatsAgentFolder `json:"folder,omitempty"`
@@ -128,6 +133,10 @@ type ChatsAgentWrite struct {
// the agent); an empty list clears them.
BuiltinsOff []string `json:"builtins_off,omitempty"`
BuiltinsOn []string `json:"builtins_on,omitempty"`
// Builtins are the builtin grants of the agent (see ChatsAgent). A write
// without them keeps the stored ones; an empty list clears them; a name
// the server does not declare is a 400, except in an import, which drops it.
Builtins []string `json:"builtins,omitempty"`
// Folder chooses the one folder the agent's chats work in. A write
// without it keeps the folder the agent has; {"path": ""} clears it. In
// an export file it is the home-relative path and mode when the file was
@@ -1970,3 +1979,20 @@ type ChatsExecutionStatus struct {
Generation string `json:"generation,omitempty"`
Incarnation string `json:"incarnation,omitempty"`
}
// ChatsBuiltinGrant describes one builtin grant for the agent editor.
type ChatsBuiltinGrant struct {
// Name is the value to put in an agent's builtins list.
Name string `json:"name"`
Title string `json:"title"`
// Description says what the agent can do with the grant, in English.
Description string `json:"description"`
// Sensitive grants change what the agent may do or reach later. They are
// never on by default and the editor marks them.
Sensitive bool `json:"sensitive"`
}
// ChatsBuiltinGrantList is the list of builtin grants, in display order.
type ChatsBuiltinGrantList struct {
Grants []ChatsBuiltinGrant `json:"grants"`
}
@@ -23,7 +23,7 @@ func sample(id, principal, name string) agents.Agent {
return agents.Agent{
ID: id, Principal: principal, Name: name, Description: "d", Instructions: "Be brief.", Model: "m1",
MCPServers: []string{"github"}, Addons: []string{"office", "sandbox"}, Collections: []string{"docs"}, Skills: []string{"review"},
Network: "registries", BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Created: now, Updated: now,
Network: "registries", BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Builtins: []string{"memory.write", "skills.write"}, Created: now, Updated: now,
}
}
@@ -46,6 +46,7 @@ func Run(newStore NewStore) {
Expect(got.Network).To(Equal("registries"))
Expect(got.BuiltinsOff).To(Equal([]string{"triggers", "web"}))
Expect(got.BuiltinsOn).To(Equal([]string{"saved_agents"}))
Expect(got.Builtins).To(Equal([]string{"memory.write", "skills.write"}))
Expect(got.Created.Equal(a.Created)).To(BeTrue(), "got %+v", got)
a.Name, a.Addons, a.Updated = "Reviewer 2", nil, a.Updated.Add(time.Hour)
a.Created = time.Time{}
+6 -4
View File
@@ -40,8 +40,10 @@ type agentRow struct {
// agents made before.
BuiltinsOff string
BuiltinsOn string
Created time.Time
Updated time.Time
// Builtins (the grants) came after them: a JSON list, empty before.
Builtins string
Created time.Time
Updated time.Time
}
func (agentRow) TableName() string { return "studio_agents" }
@@ -123,7 +125,7 @@ func (r agentRow) agent(folders []folderRow) (Agent, error) {
for _, f := range []struct {
dst *[]string
src string
}{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}, {&a.BuiltinsOff, r.BuiltinsOff}, {&a.BuiltinsOn, r.BuiltinsOn}} {
}{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}, {&a.BuiltinsOff, r.BuiltinsOff}, {&a.BuiltinsOn, r.BuiltinsOn}, {&a.Builtins, r.Builtins}} {
if *f.dst, err = listFromJSON(f.src); err != nil {
return a, err
}
@@ -195,7 +197,7 @@ func (s *gormStore) Put(ctx context.Context, a Agent) error {
Description: a.Description, Instructions: a.Instructions, Model: a.Model,
MCPServers: listJSON(a.MCPServers), Addons: listJSON(a.Addons),
Collections: listJSON(a.Collections), Skills: listJSON(a.Skills), Network: a.Network,
FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn),
FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn), Builtins: listJSON(a.Builtins),
Created: a.Created, Updated: a.Updated}
if exists {
row.Created = old.Created
+7 -1
View File
@@ -58,6 +58,11 @@ type Agent struct {
// group a chat has by default, as agents saved before the field.
BuiltinsOff []string
BuiltinsOn []string
// Builtins are the built-in capabilities the owner granted the agent, by
// the names of core/services/studio/builtins (memory.write, skills.write
// and so on). Empty grants nothing: no grant is on by default, and a chat
// gets the intersection with what its user may use at the turn.
Builtins []string
// Network is the network preset of the Sandbox commands of the agent's
// unattended runs (triggers, connectors): off, registries or all, capped
// by the administrator's ceiling. Empty is off. A run never asks a
@@ -178,7 +183,7 @@ func Validate(a Agent) error {
name string
entries []string
addon bool
}{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}, {"built-in tool groups off", a.BuiltinsOff, false}, {"built-in tool groups on", a.BuiltinsOn, false}} {
}{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}, {"built-in tool groups off", a.BuiltinsOff, false}, {"built-in tool groups on", a.BuiltinsOn, false}, {"built-in grants", a.Builtins, false}} {
if len(l.entries) > MaxListEntries {
return bad("%s has more than %d entries", l.name, MaxListEntries)
}
@@ -235,5 +240,6 @@ func (a Agent) clone() Agent {
a.Skills = append([]string(nil), a.Skills...)
a.BuiltinsOff = append([]string(nil), a.BuiltinsOff...)
a.BuiltinsOn = append([]string(nil), a.BuiltinsOn...)
a.Builtins = append([]string(nil), a.Builtins...)
return a.normalizeFolders()
}
+4
View File
@@ -20,6 +20,8 @@ type scope struct {
addons map[string]bool
collections map[string]bool
skills map[string]bool
builtins map[string]bool // the builtin grants the agent holds now
builtinNames []string
skillNames []string
addonNames []string
collNames []string
@@ -49,7 +51,9 @@ func (s *Service) scopeOf(ctx context.Context, rec session.ChatRecord) *scope {
s.log.Warn("studio: agent unavailable", "chat", rec.ID, "agent", rec.AgentID, "error", err)
return &scope{deleted: true}
}
held := s.usableGrants(ctx, rec.Principal, ag.Builtins)
return &scope{
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),
addonNames: ag.Addons, collNames: ag.Collections, skillNames: ag.Skills,
+21 -1
View File
@@ -4,10 +4,12 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/store"
@@ -48,7 +50,12 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent
return agents.Agent{}, ErrAgentsOff
}
p := principal(user)
keepOff, keepOn := a.BuiltinsOff == nil, a.BuiltinsOn == nil
keepOff, keepOn, keepGrants := a.BuiltinsOff == nil, a.BuiltinsOn == nil, a.Builtins == nil
grants, err := cleanGrants(a.Builtins, false)
if err != nil {
return agents.Agent{}, fmt.Errorf("%w: %w", agents.ErrInvalid, err)
}
a.Builtins = grants
if folders != nil {
list, err := s.resolveAgentFolders(ctx, p, *folders)
if err != nil {
@@ -85,6 +92,16 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent
if keepOn {
a.BuiltinsOn = old.BuiltinsOn
}
// Likewise for the grants: a client that does not know them cannot
// widen or narrow the agent.
if keepGrants {
a.Builtins = old.Builtins
}
}
// The grant saved_agents is the opt-in group of the same name: the chats of
// the agent start with it on.
if slices.Contains(a.Builtins, builtins.GrantSavedAgents) && s.builtins.IsGroup(builtins.GroupSavedAgents) && !slices.Contains(a.BuiltinsOn, builtins.GroupSavedAgents) {
a.BuiltinsOn = append(slices.Clone(a.BuiltinsOn), builtins.GroupSavedAgents)
}
if err := s.d.Agents.Put(ctx, a); err != nil {
return agents.Agent{}, err
@@ -143,6 +160,9 @@ func (s *Service) ImportAgent(ctx context.Context, user string, a agents.Agent,
// a row that is not chosen, so the person can see which one to fix.
func (s *Service) ImportAgentFolders(ctx context.Context, user string, a agents.Agent, folders []AgentFolderWrite) (agents.Agent, error) {
a.ID, a.FolderID, a.FolderMode, a.Folders = "", 0, "", nil
// A file from a server that has more grants still imports; the grants
// this server does not declare are dropped.
a.Builtins, _ = cleanGrants(a.Builtins, true)
if len(folders) > agents.MaxFolders {
return agents.Agent{}, folderRefused("an agent may have at most %d folders", agents.MaxFolders)
}
@@ -88,6 +88,10 @@ type Chat struct {
GroupsOff []string
// GroupsOn are the opt-in switch groups the chat switched on.
GroupsOn []string
// Grants are the builtin grants of the chat's saved agent after the
// intersection with what the user may use. Empty for a chat without an
// agent: see HoldsGrant.
Grants []string
// GoalActive is true while the chat works toward an active goal.
GoalActive bool
// Allow answers whether the sender of this turn may use a capability
+111
View File
@@ -0,0 +1,111 @@
package builtins
import (
"slices"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// A grant is a right that the owner gives a saved agent on top of its lists:
// to ask for a change of the owner's own state, to set up a trigger, to reach
// a channel. Grants are the Builtins of an agent. None is on by default, and a
// grant that is sensitive is marked so that every screen that offers it can
// say so. The declarations live here, in code, so the core names no add-on and
// the editor and the chat read one list.
//
// The names are the capability names of the contract where one exists, and
// the opt-in group saved_agents.
// The grant names.
const (
GrantMemoryWrite = contract.CapMemoryWrite
GrantProfileWrite = contract.CapProfileWrite
GrantSkillsWrite = contract.CapSkillsWrite
GrantPagesWrite = contract.CapPagesWrite
GrantDashboardWrite = contract.CapDashboardWrite
GrantTriggersPropose = contract.CapTriggersPropose
GrantTriggersRun = contract.CapTriggersRun
GrantTalkbackChannels = contract.CapTalkbackChannels
GrantTalkbackNewChan = contract.CapTalkbackNewChan
GrantFilesUpload = contract.CapFilesUpload
GrantActFiles = "act.files"
GrantActAddons = "act.addons"
GrantActSend = "act.send"
GrantActSpend = "act.spend"
// GrantSavedAgents is the opt-in group GroupSavedAgents.
GrantSavedAgents = GroupSavedAgents
)
// Grant describes one grant for a person: a title and what the agent can do
// with it. Both are English; the interface may translate them by Name.
type Grant struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
// Sensitive grants change what the agent may do or reach later (a rule, a
// skill, a page, an action): they are never on by default and the editor
// marks them.
Sensitive bool `json:"sensitive"`
}
// grants is the closed list, in the order a screen shows it.
var grants = []Grant{
{GrantMemoryWrite, "Remember things", "The agent can propose a note in your memory, or in its own. You confirm each note.", false},
{GrantProfileWrite, "Change its own rules", "The agent can propose an edit to its profile, the standing rules it follows in every chat. You confirm each edit.", true},
{GrantSkillsWrite, "Write skills", "The agent can propose a new or changed skill for itself or for you. You confirm each one, and a skill you locked stays as it is.", true},
{GrantPagesWrite, "Create pages", "The agent can propose a page: a small app that opens in a sandboxed frame. You see its files and confirm.", true},
{GrantDashboardWrite, "Change the dashboard", "The agent can propose a new layout of your Home dashboard. You confirm each change.", true},
{GrantTriggersPropose, "Propose triggers", "The agent can propose a task that runs by itself, from a messaging app too. You confirm it.", false},
{GrantTriggersRun, "Run triggers", "The triggers of this agent may start from a messaging app.", false},
{GrantTalkbackChannels, "Reply on known channels", "The agent can send a message to a channel it already talks in.", false},
{GrantTalkbackNewChan, "Start new channels", "The agent can start a conversation on a channel it has not used before.", true},
{GrantFilesUpload, "Send files", "The agent can send a file from your folders to a channel.", false},
{GrantActFiles, "Write files on its own", "A run that nobody watches may write files inside the agent's folders.", true},
{GrantActAddons, "Use add-on actions on its own", "A run that nobody watches may call the actions of the add-ons it has.", true},
{GrantActSend, "Send messages on its own", "A run that nobody watches may send messages beyond its delivery target.", true},
{GrantActSpend, "Use paid or external tools on its own", "A run that nobody watches may use paid or external tools and the network.", true},
{GrantSavedAgents, "Call your saved agents", "The agent can list your saved agents and start one as a sub-agent.", false},
}
// Grants returns the declared grants in display order. The caller may change
// the copy.
func Grants() []Grant { return slices.Clone(grants) }
// LookupGrant returns the declaration of a grant name.
func LookupGrant(name string) (Grant, bool) {
for _, g := range grants {
if g.Name == name {
return g, true
}
}
return Grant{}, false
}
// ValidGrant reports whether name is a declared grant.
func ValidGrant(name string) bool { _, ok := LookupGrant(name); return ok }
// SensitiveGrant reports whether name is a declared sensitive grant.
func SensitiveGrant(name string) bool { g, ok := LookupGrant(name); return ok && g.Sensitive }
// holdingOrigin says whether a run that started with this origin can hold the
// rights of its agent: a chat a person watches and a connector chat can. A
// run that started by itself (trigger, job) starts read only; what it may act
// on is the business of the action policy, not of a tool that exists.
func holdingOrigin(origin string) bool { return origin == "" || origin == session.OriginConnector }
// HoldsGrant is the tool-exists rule for a grant: the chat holds it when its run may
// have it at all (not a sub-agent, not a run that started by itself) and, for a
// chat of a saved agent, the owner granted it and the user may still use it
// (Grants is that intersection). A chat without an agent is the user's own and
// holds what the user may use; whether the server offers the feature is the
// tool's own check.
func (c Chat) HoldsGrant(grant string) bool {
if c.SubAgent || !holdingOrigin(c.Origin) {
return false
}
if !c.Agent {
return true
}
return slices.Contains(c.Grants, grant)
}
+230
View File
@@ -0,0 +1,230 @@
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
}
+174 -16
View File
@@ -13,6 +13,8 @@ import (
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/files"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
@@ -67,9 +69,15 @@ func newChanges(s *Service) (*Changes, error) {
}
g := s.d.ProposalGate
if g == nil {
g = proposals.DefaultGate
// The grants of the agent are checked in Propose and Revert, for every
// gate. Without a gate of its own the host adds nothing to them.
g = proposals.GateFunc(func(context.Context, proposals.Request) error { return nil })
}
return &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()}, nil
c := &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()}
if err := c.registerChangeKinds(); err != nil {
return nil, err
}
return c, nil
}
// Changes returns the host of the change proposals.
@@ -144,11 +152,16 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
p := contract.ChangeProposal{V: contract.ChangeProposalVersion, ID: uuid.NewString(), Owner: principal(in.User), Agent: in.Agent,
Kind: kind.Name, Scope: contract.ChangeScope(in.Scope), Summary: cleanSummary(in.Summary), Content: in.Content, Source: in.Source,
Status: contract.ProposalPending}
if err := c.s.checkChangeGrant(ctx, p.Owner, in.Agent, in.Source, kind.Grant); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
if err := c.gate.Allow(ctx, proposals.Request{Action: proposals.GatePropose, User: p.Owner, Agent: in.Agent, Kind: p.Kind, Scope: p.Scope, Source: in.Source}); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
rel, err := kind.Path(proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source})
aim := proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source}
rel, err := kind.Path(aim)
if err != nil {
return contract.ChangeProposal{}, fmt.Errorf("%w: %s", ErrBadChange, strings.TrimPrefix(err.Error(), proposals.ErrBadTarget.Error()+": "))
}
@@ -161,14 +174,15 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
case p.Summary == "":
return contract.ChangeProposal{}, fmt.Errorf("%w: give a short summary of the change", ErrBadChange)
}
cur, exists, err := c.s.d.Files.ReadHome(ctx, p.Owner, rel, maxTargetRead)
snap, err := c.snapshot(ctx, p.Owner, kind, rel, in.Content)
if err != nil {
return contract.ChangeProposal{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, rel, err)
return contract.ChangeProposal{}, err
}
if exists {
p.BaseHash = hashOf(cur)
if err := c.check(ctx, p.Owner, kind, aim, rel, in.Content, snap); err != nil {
return contract.ChangeProposal{}, err
}
if exists && cur == in.Content {
p.BaseHash = snap.hash
if snap.same {
return contract.ChangeProposal{}, fmt.Errorf("%w: %s has this content already", ErrBadChange, rel)
}
c.sweep(ctx)
@@ -185,6 +199,102 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
return p, nil
}
// snap is the state of what a change would write, for the base hash.
type snap struct {
hash string // "" when no target file exists
exists bool
same bool // every file already holds the proposed text
current string
}
// filesOf returns the files a content writes: the one target, or the files of
// a kind that writes a folder.
func filesOf(kind proposals.Kind, rel, content string) ([]proposals.FileWrite, error) {
if kind.Files == nil {
return []proposals.FileWrite{{Path: rel, Content: content}}, nil
}
fs, err := kind.Files(content)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrBadChange, err)
}
for i := range fs {
fs[i].Path = rel + "/" + fs[i].Path
}
return fs, nil
}
// snapshot reads what the change would replace. For one file the hash is the
// hash of the file; for a folder it covers the path and hash of each file the
// content names, so a change to any of them makes the proposal stale.
func (c *Changes) snapshot(ctx context.Context, owner string, kind proposals.Kind, rel, content string) (snap, error) {
fs, err := filesOf(kind, rel, content)
if err != nil {
return snap{}, err
}
var out snap
out.same = true
var sum strings.Builder
for _, f := range fs {
cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, f.Path, maxTargetRead)
if err != nil {
return snap{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, f.Path, err)
}
out.exists = out.exists || exists
out.same = out.same && exists && cur == f.Content
if kind.Files == nil {
out.current = cur
if exists {
out.hash = hashOf(cur)
}
return out, nil
}
h := "-"
if exists {
h = hashOf(cur)
}
sum.WriteString(f.Path + "\x00" + h + "\n")
}
if out.exists {
out.hash = hashOf(sum.String())
}
return out, nil
}
// check runs the kind's own rules on a content.
func (c *Changes) check(ctx context.Context, owner string, kind proposals.Kind, aim proposals.Aim, rel, content string, st snap) error {
if kind.Check == nil {
return nil
}
err := kind.Check(proposals.CheckInput{Aim: aim, Path: rel, Content: content, Current: st.current, Exists: st.exists, Read: func(p string) (string, bool, error) {
return c.s.d.Files.ReadHome(ctx, owner, p, maxTargetRead)
}})
if err != nil {
return fmt.Errorf("%w: %s", ErrBadChange, err)
}
return nil
}
// aimOf rebuilds where a stored proposal writes, for the checks at confirm.
func aimOf(p contract.ChangeProposal) proposals.Aim {
return proposals.Aim{Scope: p.Scope, Agent: p.Agent, Target: targetName(p), Source: p.Source}
}
// targetName is the name the model gave, recovered from the stored path: the
// last folder for a skill or a page, which Check compares with its content.
func targetName(p contract.ChangeProposal) string {
t := strings.TrimSuffix(p.Target, "/")
switch p.Kind {
case contract.ChangeSkill:
t = strings.TrimSuffix(t, "/SKILL.md")
case contract.ChangeMemory, contract.ChangeProfile, contract.ChangeDashboard:
return t
}
if i := strings.LastIndexByte(t, '/'); i >= 0 {
t = t[i+1:]
}
return t
}
// List returns the proposals of the user, newest first, with the expired ones
// settled first.
func (c *Changes) List(ctx context.Context, user string, statuses ...contract.ProposalStatus) ([]contract.ChangeProposal, error) {
@@ -243,6 +353,7 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
if !ok {
return contract.ChangeProposal{}, fmt.Errorf("%w: this server no longer offers %s changes", ErrBadChange, p.Kind)
}
orig := p.Content
if edited != nil {
switch {
case len(*edited) > kind.Limit():
@@ -252,20 +363,30 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
}
p.Content = *edited
}
cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, p.Target, maxTargetRead)
// The base hash covers the files the model proposed; an edit by the owner
// may change the files to write, not what the proposal was based on.
was, err := c.snapshot(ctx, owner, kind, p.Target, orig)
if err != nil {
return contract.ChangeProposal{}, err
}
have := ""
if exists {
have = hashOf(cur)
}
if have != p.BaseHash {
if was.hash != p.BaseHash {
if won, _ := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalStale, c.s.now().UTC(), 0); won {
c.audit(ctx, proposals.ActionStale, p, 0, "")
}
return contract.ChangeProposal{}, ErrTargetChanged
}
// Rules such as a lock set after the proposal apply to the text that is written.
now, err := c.snapshot(ctx, owner, kind, p.Target, p.Content)
if err != nil {
return contract.ChangeProposal{}, err
}
if err := c.check(ctx, owner, kind, aimOf(p), p.Target, p.Content, now); err != nil {
return contract.ChangeProposal{}, err
}
writes, err := filesOf(kind, p.Target, p.Content)
if err != nil {
return contract.ChangeProposal{}, err
}
// Claim the answer first, so two confirmations write once.
won, err := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalApplied, c.s.now().UTC(), 0)
if err != nil {
@@ -275,7 +396,7 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
return contract.ChangeProposal{}, ErrChangeAnswered
}
title := string(p.Kind) + ": " + p.Summary
rev, err := c.s.d.Files.WriteHome(ctx, owner, p.Target, p.Content, title, int64(kind.Limit()))
rev, err := c.write(ctx, owner, writes, title, int64(kind.Limit()))
if err != nil {
if _, rerr := c.store.Settle(ctx, owner, id, contract.ProposalApplied, contract.ProposalPending, time.Time{}, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to write", "proposal", id, "error", rerr)
@@ -290,6 +411,18 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
return c.store.Get(ctx, owner, id)
}
// write puts the files of a change into the home as one change set.
func (c *Changes) write(ctx context.Context, owner string, fs []proposals.FileWrite, title string, max int64) (int64, error) {
if len(fs) == 1 {
return c.s.d.Files.WriteHome(ctx, owner, fs[0].Path, fs[0].Content, title, max)
}
hf := make([]files.HomeFile, len(fs))
for i, f := range fs {
hf[i] = files.HomeFile{Path: f.Path, Content: f.Content}
}
return c.s.d.Files.WriteHomeFiles(ctx, owner, hf, title, max)
}
// Dismiss answers a pending proposal with no.
func (c *Changes) Dismiss(ctx context.Context, user, id string) (contract.ChangeProposal, error) {
owner := principal(user)
@@ -365,9 +498,34 @@ func (c *Changes) RevertLast(ctx context.Context, user, agent string) (contract.
// Allow asks the gate about a revert of a chat.
func (c *Changes) allowRevert(ctx context.Context, user, agent string, src contract.ChangeSource) error {
err := c.gate.Allow(ctx, proposals.Request{Action: proposals.GateRevert, User: principal(user), Agent: agent, Source: src})
err := c.s.checkAnyChangeGrant(ctx, user, agent, src)
if err == nil {
err = c.gate.Allow(ctx, proposals.Request{Action: proposals.GateRevert, User: principal(user), Agent: agent, Source: src})
}
if err != nil {
c.audit(ctx, proposals.ActionRefuse, contract.ChangeProposal{Owner: principal(user), Agent: agent, Source: src}, 0, err.Error())
}
return err
}
// grantsAKind reports whether the grant is the one of a registered kind.
func (c *Changes) grantsAKind(grant string) bool {
for _, n := range c.kinds.Names() {
if k, ok := c.kinds.Lookup(string(n)); ok && k.Grant == grant {
return true
}
}
return false
}
// kindsHeld lists the kinds a chat may propose: for a chat of a saved agent
// the ones whose grant it holds.
func (c *Changes) kindsHeld(ch builtins.Chat) []contract.ChangeKind {
var out []contract.ChangeKind
for _, n := range c.kinds.Names() {
if k, ok := c.kinds.Lookup(string(n)); ok && (k.Grant == "" || ch.HoldsGrant(k.Grant)) {
out = append(out, n)
}
}
return out
}
+27 -13
View File
@@ -54,6 +54,16 @@ func changeShared(gate proposals.Gate) *shared {
return sh
}
// grantMemory makes the saved agent of the rig one that may propose memory.
func (c *connRig) grantMemory() { c.grantBuiltins("memory.write") }
// grantBuiltins gives the saved agent of the rig the builtin grants.
func (c *connRig) grantBuiltins(grants ...string) {
ag, err := c.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m", Addons: []string{"office"}, Builtins: grants})
Expect(err).NotTo(HaveOccurred())
c.agent = ag
}
func channelFile(src contract.ChangeSource) string {
return "Memory/channels/" + proposals.ChannelDir(src) + "/index.md"
}
@@ -168,13 +178,15 @@ var _ = Describe("change proposals", func() {
It("holds a profile to 4 KiB", func() {
r := changeShared(grantAll).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
in := studio.ProposeInput{User: alice, Agent: "ag1", Kind: "profile", Scope: "agent", Content: strings.Repeat("a", contract.MaxProfileBytes+1), Summary: "s"}
_, err := r.svc.Changes().Propose(context.Background(), in)
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m", Builtins: []string{"profile.write"}})
Expect(err).NotTo(HaveOccurred())
in := studio.ProposeInput{User: alice, Agent: ag.ID, Kind: "profile", Scope: "agent", Content: strings.Repeat("a", contract.MaxProfileBytes+1), Summary: "s"}
_, err = r.svc.Changes().Propose(context.Background(), in)
Expect(err).To(MatchError(studio.ErrBadChange))
in.Content = strings.Repeat("a", contract.MaxProfileBytes)
p, err := r.svc.Changes().Propose(context.Background(), in)
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal("LocalAI/agents/ag1/profile.md"))
Expect(p.Target).To(Equal("LocalAI/agents/" + ag.ID + "/profile.md"))
})
It("limits 10 pending per user and 5 per hour per agent", func() {
@@ -307,26 +319,26 @@ var _ = Describe("change proposals", func() {
})
Describe("from a chat that runs a saved agent", func() {
It("is refused by default and allowed by a gate that grants it", func() {
for name, gate := range map[string]proposals.Gate{"default": nil, "granted": grantAll} {
r := changeShared(gate).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{})
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m"})
It("is refused without the grant and allowed with it", func() {
for name, grant := range map[string][]string{"default": nil, "granted": {"memory.write"}} {
r := changeShared(nil).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{})
ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m", Builtins: grant})
Expect(err).NotTo(HaveOccurred())
rec, err := r.svc.Create(context.Background(), alice, studio.CreateInput{AgentID: ag.ID})
Expect(err).NotTo(HaveOccurred())
sent(r.svc, alice, rec.ID, "remember")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1), name)
list, _ := r.svc.Changes().List(context.Background(), alice)
if name == "default" {
Expect(res[0].Err).To(HaveOccurred())
// Without a grant the tool does not exist in the chat.
Expect(r.h.ToolNames(rec.ID)).NotTo(ContainElement("propose_change"))
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(MatchError(ContainSubstring("no tool named")))
Expect(list).To(BeEmpty())
events, _ := r.svc.Changes().Events(context.Background(), alice, 5)
Expect(events).To(HaveLen(1))
Expect(events[0].Action).To(Equal("refuse"))
continue
}
Expect(res).To(HaveLen(1), name)
Expect(res[0].Err).To(BeNil())
Expect(list).To(HaveLen(1))
Expect(list[0].Agent).To(Equal(ag.ID))
@@ -348,7 +360,7 @@ var _ = Describe("change proposals", func() {
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
other = p.ID
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m"})
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m", Builtins: []string{"memory.write"}})
rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
sent(r.svc, alice, rec.ID, "forget")
idle(r.svc, alice, rec.ID)
@@ -366,6 +378,7 @@ var _ = Describe("change proposals", func() {
It("works the same over Telegram", func() {
sh, bot := changeShared(grantAll), newScriptedBot()
r := newConnRig(sh, connStore(), bot, changeScript("channel", "- home: Rome\n"), nil)
r.grantMemory()
b := r.bind(studio.ConnectorInput{Allow: []string{"11"}})
n := 0
send := func(text string) {
@@ -385,6 +398,7 @@ var _ = Describe("change proposals", func() {
rt := slack.New(&slack.API{BaseURL: f.URL, FilesHost: f.FilesHost})
DeferCleanup(rt.Close)
r := newConnRigWith(sh, connStore(), studiobot.NewTransport(studiobot.Slack(rt)), changeScript("channel", "- home: Rome\n"), nil)
r.grantMemory()
b := r.bind(studio.ConnectorInput{Kind: "slack", Token: slackBot + " " + slackApp, Allow: []string{"C1"}})
waitFor("the Socket Mode link", func() bool { return f.Connected(slackApp) })
posted := func() int {
+52 -17
View File
@@ -22,23 +22,32 @@ const (
revertChangeName = "revert_change"
)
const proposeChangeDescription = "Ask to change something that lasts: a note in the user's memory, or the profile of this agent. " +
"Use it when the user asks you to remember, change or forget something, or gives a rule you must keep. " +
const proposeChangeDescription = "Ask to change something that lasts: a note in the user's memory, the profile of this agent, a skill, a page or the Home dashboard. " +
"Use it when the user asks you to remember, change or forget something, gives a rule you must keep, or asks for a skill, a page or a dashboard change. " +
"It changes nothing by itself: the owner must confirm in Studio, and the proposal expires after a day. " +
"Until then never say that it is saved. " +
"content is the full new text of the file, not a patch: read the current file first and keep what stays. " +
"kind is memory (a Markdown file under Memory/) or profile (the agent's standing rules, at most 4 KiB, scope agent). " +
"kind is memory (a Markdown file under Memory/), profile (the agent's standing rules, at most 4 KiB, scope agent), " +
"skill (a full SKILL.md with name and description, target is the skill name, scope user or agent; a skill its owner locked cannot be changed), " +
"page (a small app: target is the page name, scope user, files is an object of path to text and must hold index.html) " +
"or dashboard (the full home.json, scope user; it must follow the schema of the localai-workspace skill). " +
"scope is user (every chat of the person), agent (this agent only) or channel (this conversation only). " +
"target is the file name for memory, such as index.md or food.md; a profile has no target. " +
"target is the file name for memory, such as index.md or food.md, the name for a skill or a page; a profile and the dashboard have no target. " +
"summary is one line that tells the owner what changes."
const proposeChangeSchema = `{"type":"object","properties":{` +
`"kind":{"type":"string","enum":["memory","profile"]},` +
`"scope":{"type":"string","enum":["user","agent","channel"]},` +
`"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md."},` +
`"content":{"type":"string","description":"The full new text of the file."},` +
`"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` +
`"required":["kind","scope","content","summary"]}`
// proposeChangeSchema is the schema of the tool for a chat that may propose
// the given kinds.
func proposeChangeSchema(kinds []contract.ChangeKind) string {
enum, _ := json.Marshal(kinds)
return `{"type":"object","properties":{` +
`"kind":{"type":"string","enum":` + string(enum) + `},` +
`"scope":{"type":"string","enum":["user","agent","channel"]},` +
`"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md; the name of a skill or a page."},` +
`"content":{"type":"string","description":"The full new text of the file. For a page use files instead."},` +
`"files":{"type":"object","additionalProperties":{"type":"string"},"description":"For a page: file paths inside the page folder and their text, such as index.html."},` +
`"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` +
`"required":["kind","scope","summary"]}`
}
const revertChangeDescription = "Undo a change that the owner confirmed earlier, for example when the user says to forget that. " +
"Give the id the earlier propose_change returned; without an id the last applied change of this agent is undone. " +
@@ -49,7 +58,7 @@ const revertChangeSchema = `{"type":"object","properties":{"id":{"type":"string"
// registerChangeTools adds propose_change and revert_change as one switch
// group. A person's chat and a connector chat can have them; an unattended
// run and a sub-agent never do. Whether a chat that runs a saved agent may use
// them is the gate's decision at the call.
// them is the grant of the agent; in a connector chat the sender must also hold the capability.
func (s *Service) registerChangeTools() error {
type def struct {
id, label, desc, schema string
@@ -57,7 +66,7 @@ func (s *Service) registerChangeTools() error {
caps []string
}
defs := []def{
{proposeChangeName, "Propose a change", proposeChangeDescription, proposeChangeSchema, s.changes.proposeTool, changeKindCaps},
{proposeChangeName, "Propose a change", proposeChangeDescription, "", s.changes.proposeTool, changeKindCaps},
{revertChangeName, "Revert a change", revertChangeDescription, revertChangeSchema, s.changes.revertTool, changeKindCaps},
}
for _, d := range defs {
@@ -72,7 +81,17 @@ func (s *Service) registerChangeTools() error {
case c.Origin != "" && c.Origin != session.OriginConnector:
return session.Tool{}, false, "a run that started by itself cannot propose changes"
}
return session.Tool{Description: d.desc, Schema: json.RawMessage(d.schema),
// The tool exists when the chat holds a grant of some kind; it
// offers the kinds it holds and no others.
kinds := s.changes.kindsHeld(c)
if len(kinds) == 0 {
return session.Tool{}, false, "this agent was not given the right to change anything"
}
schema := d.schema
if schema == "" {
schema = proposeChangeSchema(kinds)
}
return session.Tool{Description: d.desc, Schema: json.RawMessage(schema),
Run: func(ctx context.Context, raw json.RawMessage) (string, error) { return d.run(ctx, c, raw) }}, true, ""
},
})
@@ -84,9 +103,9 @@ func (s *Service) registerChangeTools() error {
}
// changeKindCaps are the capabilities that the kinds of change need in a
// connector chat: a memory note and the agent's profile. The tool is offered
// when the sender holds one of them; the call checks the one its kind needs.
var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite}
// connector chat. The tool is offered when the sender holds one of them; the
// call checks the one its kind needs.
var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapSkillsWrite, contract.CapPagesWrite, contract.CapDashboardWrite}
// changeCapability is the capability that a change of a kind needs. A kind
// that is not listed needs one that nobody holds.
@@ -96,6 +115,10 @@ func changeCapability(kind string) string {
return contract.CapMemoryWrite
case "profile":
return contract.CapProfileWrite
case "page":
return contract.CapPagesWrite
case "dashboard":
return contract.CapDashboardWrite
}
return contract.CapSkillsWrite
}
@@ -116,6 +139,7 @@ func changeErr(err error) error {
func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error) {
var args struct {
Kind, Scope, Target, Content, Summary string
Files map[string]string
}
if err := json.Unmarshal(raw, &args); err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
@@ -123,6 +147,17 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw
if err := c.Require(changeCapability(args.Kind)); err != nil {
return "", err
}
if args.Files != nil {
if args.Kind != string(contract.ChangePage) || args.Content != "" {
return "", errors.New("files is for a page only, and a page has no content")
}
// A page is a set of files; the proposal keeps them as one JSON text.
b, err := json.Marshal(map[string]any{"files": args.Files})
if err != nil {
return "", fmt.Errorf("bad arguments: %w", err)
}
args.Content = string(b)
}
p, err := h.Propose(ctx, ProposeInput{User: c.Actor.Principal, Agent: c.AgentID, Source: sourceOf(c),
Kind: args.Kind, Scope: args.Scope, Target: args.Target, Content: args.Content, Summary: args.Summary})
if err != nil {
@@ -60,6 +60,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
sh := changeShared(grantAll)
r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil)
r.bot, r.untrusted = bot, true
r.grantBuiltins(contract.CapMemoryWrite, contract.CapProfileWrite)
b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12"}, Caps: caps, Grants: grants})
got, err := r.store.GetBinding(ctx, b.ID)
Expect(err).NotTo(HaveOccurred())
@@ -413,7 +413,7 @@ var _ = Describe("context files in the prompt of a chat", func() {
m, err := contract.ParseManifest(raw)
Expect(err).NotTo(HaveOccurred())
br.set(AddonContextSet{AddonID: m.ID, Running: true, Files: m.Capabilities.ContextFiles})
ag, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Bot", Model: "m", Addons: []string{m.ID}}, nil)
ag, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Bot", Model: "m", Addons: []string{m.ID}, Builtins: []string{"memory.write"}}, nil)
Expect(err).NotTo(HaveOccurred())
rec, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m", AgentID: ag.ID}, Origin{Kind: session.OriginConnector, ID: "b-tg"})
Expect(err).NotTo(HaveOccurred())
+50
View File
@@ -63,3 +63,53 @@ func (s *Service) WriteHome(ctx context.Context, principal, rel, content, title
s.Written(ctx, n, principal)
return cs.Rev, nil
}
// HomeFile is one file of a change that writes several.
type HomeFile struct {
Path string // inside the home
Content string
}
// WriteHomeFiles replaces several files in the home of principal as one change
// set, so one undo restores all of them. It writes as the user, for a change
// the user confirmed. Each file may be at most max bytes. Nothing is written
// when one file fails.
func (s *Service) WriteHomeFiles(ctx context.Context, principal string, fs []HomeFile, title string, max int64) (int64, error) {
if !s.Available() {
return 0, ErrUnavailable
}
abs := make([]string, len(fs))
for i, f := range fs {
a, err := s.Abs(principal, f.Path)
if err != nil {
return 0, err
}
abs[i] = a
}
if err := s.EnsureHome(ctx, principal); err != nil {
return 0, err
}
title = strings.TrimSpace(title)
tx, err := s.ws.Begin(ctx, s.Actor(principal), title)
if err != nil {
return 0, err
}
nodes := make([]workspace.Node, len(fs))
for i, f := range fs {
n, err := tx.Put(ctx, abs[i], strings.NewReader(f.Content), workspace.PutOptions{Overwrite: true, MaxSize: max, Title: title})
if err != nil {
_ = tx.Rollback()
return 0, err
}
nodes[i] = n
}
cs, err := tx.Commit()
if err != nil && !errors.Is(err, workspace.ErrCommitted) {
return 0, err
}
for _, n := range nodes {
n.Rev = cs.Rev
s.Written(ctx, n, principal)
}
return cs.Rev, nil
}
+260
View File
@@ -0,0 +1,260 @@
package studio_test
import (
"context"
"encoding/json"
"fmt"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func pageContent(files map[string]string) string {
b, _ := json.Marshal(map[string]any{"files": files})
return string(b)
}
var _ = Describe("skill, page and dashboard proposals", func() {
ctx := context.Background()
var (
r *trigRig
ag agents.Agent
)
BeforeEach(func() {
r = changeShared(nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
var err error
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Maker", Model: "m",
Builtins: []string{"skills.write", "pages.write", "dashboard.write"}})
Expect(err).NotTo(HaveOccurred())
})
propose := func(kind, scope, target, content string) (contract.ChangeProposal, error) {
return r.svc.Changes().Propose(ctx, studio.ProposeInput{User: alice, Agent: ag.ID, Kind: kind, Scope: scope, Target: target, Content: content, Summary: "s"})
}
write := func(rel, text string) {
_, err := r.sh.files.WriteHome(ctx, alice, rel, text, "seed", 1<<20)
Expect(err).NotTo(HaveOccurred())
}
Describe("a page", func() {
good := map[string]string{"index.html": "<h1>Hi</h1>", "page.json": `{"title":"Hi","icon":"home","widget":{"size":"small"}}`, "data/items.json": "[]"}
It("is written as one change set after the owner confirms, and one undo removes it", func() {
p, err := propose("page", "user", "hello", pageContent(good))
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal("pages/hello"))
_, ok := readHome(r, "pages/hello/index.html")
Expect(ok).To(BeFalse())
done, err := r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
for rel, text := range good {
got, ok := readHome(r, "pages/hello/"+rel)
Expect(ok).To(BeTrue(), rel)
Expect(got).To(Equal(text))
}
Expect(done.Rev).To(BeNumerically(">", 0))
_, err = r.svc.Changes().Revert(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
for rel := range good {
_, ok := readHome(r, "pages/hello/"+rel)
Expect(ok).To(BeFalse(), rel+" survived the undo")
}
})
DescribeTable("refuses a bad page",
func(target, scope string, files map[string]string) {
_, err := propose("page", scope, target, pageContent(files))
Expect(err).To(MatchError(studio.ErrBadChange))
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty())
},
Entry("a name with upper case", "Hello", "user", good),
Entry("a name with a slash", "a/b", "user", good),
Entry("a name that climbs", "..", "user", good),
Entry("no name", "", "user", good),
Entry("the agent scope", "hello", "agent", good),
Entry("the channel scope", "hello", "channel", good),
Entry("no index.html", "hello", "user", map[string]string{"page.json": "{}"}),
Entry("an empty index.html", "hello", "user", map[string]string{"index.html": " "}),
Entry("a parent path", "hello", "user", map[string]string{"index.html": "x", "../evil.html": "x"}),
Entry("a nested parent path", "hello", "user", map[string]string{"index.html": "x", "a/../../evil": "x"}),
Entry("an absolute path", "hello", "user", map[string]string{"index.html": "x", "/etc/passwd": "x"}),
Entry("a backslash path", "hello", "user", map[string]string{"index.html": "x", `a\b`: "x"}),
Entry("an empty segment", "hello", "user", map[string]string{"index.html": "x", "a//b": "x"}),
Entry("a dot segment", "hello", "user", map[string]string{"index.html": "x", "./a": "x"}),
Entry("a file that is also a folder", "hello", "user", map[string]string{"index.html": "x", "a": "x", "a/b": "x"}),
Entry("two spellings of one file", "hello", "user", map[string]string{"index.html": "x", "A.js": "x", "a.js": "x"}),
Entry("a binary file", "hello", "user", map[string]string{"index.html": "x", "a.bin": "a\x00b"}),
Entry("page.json that is not an object", "hello", "user", map[string]string{"index.html": "x", "page.json": "[1]"}),
Entry("page.json with a bad widget size", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"widget":{"size":"huge"}}`}),
Entry("page.json with a long title", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"title":"` + strings.Repeat("t", 121) + `"}`}),
Entry("page.json with a bad icon", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"icon":"Bad Icon"}`}),
)
It("refuses content that is not a files object, too many files and more than 256 KiB", func() {
for name, content := range map[string]string{
"plain html": "<h1>hi</h1>",
"no files": `{"files":{}}`,
"extra key": `{"files":{"index.html":"x"},"run":"rm"}`,
"two docs": `{"files":{"index.html":"x"}} {"files":{}}`,
} {
_, err := propose("page", "user", "hello", content)
Expect(err).To(MatchError(studio.ErrBadChange), name)
}
many := map[string]string{"index.html": "x"}
for i := range 32 {
many[fmt.Sprintf("f%d.json", i)] = "{}"
}
_, err := propose("page", "user", "hello", pageContent(many))
Expect(err).To(MatchError(studio.ErrBadChange))
_, err = propose("page", "user", "hello", pageContent(map[string]string{"index.html": strings.Repeat("a", contract.MaxPageChangeBytes)}))
Expect(err).To(MatchError(studio.ErrBadChange), "the JSON wrapper pushes it over the limit")
_, err = propose("page", "user", "hello", pageContent(map[string]string{"index.html": strings.Repeat("a", contract.MaxPageChangeBytes-1024)}))
Expect(err).NotTo(HaveOccurred())
})
It("is stale when a file of the page changed after the proposal", func() {
write("pages/hello/index.html", "<h1>old</h1>")
p, err := propose("page", "user", "hello", pageContent(good))
Expect(err).NotTo(HaveOccurred())
Expect(p.BaseHash).NotTo(BeEmpty())
write("pages/hello/index.html", "<h1>hand made</h1>")
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrTargetChanged))
got, _ := readHome(r, "pages/hello/index.html")
Expect(got).To(Equal("<h1>hand made</h1>"))
})
It("refuses the owner's edit when it breaks the rules", func() {
p, err := propose("page", "user", "hello", pageContent(good))
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, pageContent(map[string]string{"page.json": "{}"}))
Expect(err).To(MatchError(studio.ErrBadChange))
})
})
Describe("the dashboard", func() {
ok := `{"widgets":[{"id":"c","type":"chats","title":"Chats"}]}`
It("writes LocalAI/home.json after the owner confirms and undoes it", func() {
p, err := propose("dashboard", "user", "", ok)
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal("LocalAI/home.json"))
_, found := readHome(r, "LocalAI/home.json")
Expect(found).To(BeFalse())
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
text, _ := readHome(r, "LocalAI/home.json")
Expect(text).To(Equal(ok))
_, err = r.svc.Changes().Revert(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
_, found = readHome(r, "LocalAI/home.json")
Expect(found).To(BeFalse())
})
DescribeTable("refuses a file that breaks the schema",
func(scope, content string) {
_, err := propose("dashboard", scope, "", content)
Expect(err).To(MatchError(studio.ErrBadChange))
},
Entry("not JSON", "user", "widgets"),
Entry("not an object", "user", "[]"),
Entry("an unknown widget type", "user", `{"widgets":[{"id":"c","type":"rocket","title":"x"}]}`),
Entry("a page widget with a bad page name", "user", `{"widgets":[{"id":"p","type":"page","page":"Bad Name"}]}`),
Entry("too many page widgets", "user", `{"widgets":[`+strings.TrimSuffix(strings.Repeat(`{"id":"p1","type":"page","page":"news"},`, 4), ",")+`]}`),
Entry("the agent scope", "agent", ok),
Entry("the channel scope", "channel", ok),
)
It("is checked again when the owner edits it", func() {
p, err := propose("dashboard", "user", "", ok)
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, `{"widgets":[{"id":"p","type":"page","page":"Bad Name"}]}`)
Expect(err).To(MatchError(studio.ErrBadChange))
})
})
Describe("a skill", func() {
Describe("of the personal layer", func() {
It("is written to .skills when the owner confirms", func() {
p, err := propose("skill", "user", "daily-note", skillText)
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal(".skills/daily-note/SKILL.md"))
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
text, _ := readHome(r, ".skills/daily-note/SKILL.md")
Expect(text).To(Equal(skillText))
})
It("is refused when the owner locked the existing skill", func() {
write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: true\n---\nMy steps.\n")
_, err := propose("skill", "user", "daily-note", skillText)
Expect(err).To(MatchError(studio.ErrBadChange))
Expect(err.Error()).To(ContainSubstring("locked"))
})
It("is refused at confirm when the owner locks it after the proposal", func() {
write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\n---\nMy steps.\n")
p, err := propose("skill", "user", "daily-note", skillText)
Expect(err).NotTo(HaveOccurred())
// The owner marks the skill as locked, which changes the file, so the
// base hash moves as well: either answer keeps the file as it is.
write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: true\n---\nMy steps.\n")
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(HaveOccurred())
text, _ := readHome(r, ".skills/daily-note/SKILL.md")
Expect(text).To(ContainSubstring("My steps."))
})
It("may change a skill that is not locked", func() {
write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: false\n---\nOld.\n")
_, err := propose("skill", "user", "daily-note", skillText)
Expect(err).NotTo(HaveOccurred())
})
})
Describe("of the agent layer", func() {
It("is written under the folder of the agent", func() {
p, err := propose("skill", "agent", "daily-note", skillText)
Expect(err).NotTo(HaveOccurred())
Expect(p.Target).To(Equal("LocalAI/agents/" + ag.ID + "/skills/daily-note/SKILL.md"))
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
})
It("cannot replace a personal skill the owner locked", func() {
write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: \"true\"\n---\nMy steps.\n")
_, err := propose("skill", "agent", "daily-note", skillText)
Expect(err).To(MatchError(studio.ErrBadChange))
Expect(err.Error()).To(ContainSubstring("locked"))
})
})
DescribeTable("refuses a bad skill",
func(scope, target, content string) {
_, err := propose("skill", scope, target, content)
Expect(err).To(MatchError(studio.ErrBadChange))
},
Entry("the channel scope", "channel", "daily-note", skillText),
Entry("a name with upper case", "user", "Daily", strings.Replace(skillText, "daily-note", "Daily", 2)),
Entry("a path as the name", "user", "../x", skillText),
Entry("no name", "user", "", skillText),
Entry("no frontmatter", "user", "daily-note", "just text"),
Entry("no description", "user", "daily-note", "---\nname: daily-note\n---\nbody\n"),
Entry("a name that differs from the target", "user", "other-note", skillText),
)
It("is not offered for a shared or built-in layer: the only places are the personal and the agent layer", func() {
for _, scope := range []string{"shared", "builtin", "team"} {
_, err := propose("skill", scope, "daily-note", skillText)
Expect(err).To(MatchError(studio.ErrBadChange), scope)
}
})
})
})
+282
View File
@@ -0,0 +1,282 @@
package studio_test
import (
"context"
"encoding/json"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
const skillText = "---\nname: daily-note\ndescription: Write the daily note.\n---\nList what changed today.\n"
// grantCase is one builtin grant with a proposal that only it allows.
type grantCase struct {
grant string
args map[string]any
// plainOK is false where a chat without an agent cannot use the kind at all.
plainOK bool
file string // the file or folder the proposal names
}
func pageFilesJSON() map[string]string {
return map[string]string{"index.html": "<h1>Notes</h1>", "page.json": `{"title":"Notes"}`}
}
var grantCases = []grantCase{
{"memory.write", map[string]any{"kind": "memory", "scope": "user", "target": "a.md", "content": "- a\n", "summary": "s"}, true, "Memory/a.md"},
{"profile.write", map[string]any{"kind": "profile", "scope": "agent", "content": "Be brief.", "summary": "s"}, false, ""},
{"skills.write", map[string]any{"kind": "skill", "scope": "user", "target": "daily-note", "content": skillText, "summary": "s"}, true, ".skills/daily-note/SKILL.md"},
{"pages.write", map[string]any{"kind": "page", "scope": "user", "target": "notes", "files": pageFilesJSON(), "summary": "s"}, true, "pages/notes"},
{"dashboard.write", map[string]any{"kind": "dashboard", "scope": "user", "content": `{"widgets":[{"id":"c","type":"chats","title":"Chats"}]}`, "summary": "s"}, true, "LocalAI/home.json"},
}
func argsJSON(m map[string]any) string {
b, _ := json.Marshal(m)
return string(b)
}
func grantScript(c grantCase) func(workspace.Actor, string) []studiotest.ToolStep {
return func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "propose_change", Args: argsJSON(c.args)}, {Say: "ok"}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}
}
var _ = Describe("builtin grants", func() {
ctx := context.Background()
Describe("the declarations", func() {
It("names every grant of the spec once, with a title and a description", func() {
var names []string
for _, g := range builtins.Grants() {
names = append(names, g.Name)
Expect(g.Title).NotTo(BeEmpty(), g.Name)
Expect(g.Description).NotTo(BeEmpty(), g.Name)
}
Expect(names).To(Equal([]string{"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", "saved_agents"}))
})
It("marks the sensitive ones and shares the sensitivity of the contract", func() {
for _, g := range builtins.Grants() {
if contract.KnownCapability(g.Name) {
Expect(g.Sensitive).To(Equal(contract.SensitiveCapability(g.Name)), g.Name)
}
}
for _, n := range []string{"profile.write", "skills.write", "pages.write", "dashboard.write", "talkback.new_channel", "act.files", "act.addons", "act.send", "act.spend"} {
Expect(builtins.SensitiveGrant(n)).To(BeTrue(), n)
}
Expect(builtins.SensitiveGrant("memory.write")).To(BeFalse())
})
It("turns on nothing by default: a new agent, a template and an import have no grants", func() {
r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil })
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Plain", Model: "m"})
Expect(err).NotTo(HaveOccurred())
Expect(ag.Builtins).To(BeEmpty())
for _, t := range agents.Templates() {
made, err := r.svc.CreateAgentFromTemplate(ctx, alice, t.ID, "From "+t.ID)
Expect(err).NotTo(HaveOccurred())
Expect(made.Builtins).To(BeEmpty(), t.ID)
}
imp, err := r.svc.ImportAgent(ctx, alice, agents.Agent{Name: "Old file", Model: "m"}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(imp.Builtins).To(BeEmpty())
})
})
Describe("saving an agent", func() {
It("refuses a grant that is not declared, keeps the grants of a write that omits them and clears them with an empty list", func() {
r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil })
_, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "root.shell"}})
Expect(err).To(MatchError(studio.ErrUnknownGrant))
Expect(err).To(MatchError(agents.ErrInvalid))
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"skills.write", "memory.write", "skills.write"}})
Expect(err).NotTo(HaveOccurred())
Expect(ag.Builtins).To(Equal([]string{"skills.write", "memory.write"}))
ag.Builtins = nil
kept, err := r.svc.SaveAgent(ctx, alice, ag)
Expect(err).NotTo(HaveOccurred())
Expect(kept.Builtins).To(Equal([]string{"skills.write", "memory.write"}), "a client that does not know the field must not narrow or widen")
ag.Builtins = []string{}
cleared, err := r.svc.SaveAgent(ctx, alice, ag)
Expect(err).NotTo(HaveOccurred())
Expect(cleared.Builtins).To(BeEmpty())
})
It("drops a grant of a newer server in an import and turns saved_agents on as the opt-in group", func() {
r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil })
ag, err := r.svc.ImportAgent(ctx, alice, agents.Agent{Name: "Imported", Model: "m", Builtins: []string{"memory.write", "future.grant", "saved_agents"}}, nil)
Expect(err).NotTo(HaveOccurred())
Expect(ag.Builtins).To(Equal([]string{"memory.write", "saved_agents"}))
Expect(ag.BuiltinsOn).To(ContainElement("saved_agents"))
})
})
Describe("the grants an agent holds", func() {
It("are the granted ones the user may use now", func() {
r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil })
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "triggers.propose", "act.files"}})
Expect(err).NotTo(HaveOccurred())
held := r.svc.AgentGrants(ctx, alice, ag.ID)
Expect(held).To(ContainElements("memory.write", "triggers.propose", "act.files"))
Expect(r.svc.AgentGrants(ctx, alice, "missing")).To(BeEmpty())
Expect(r.svc.AgentGrants(ctx, "bob", ag.ID)).To(BeEmpty(), "another user's agent")
})
})
Describe("propose_change by grant and by kind of chat", func() {
for _, c := range grantCases {
c := c
Describe(c.grant, func() {
It("exists in a chat of an agent that holds it, and the proposal waits for the owner", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}})
Expect(err).NotTo(HaveOccurred())
rec, err := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
Expect(err).NotTo(HaveOccurred())
sent(r.svc, alice, rec.ID, "go")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(BeNil(), "%+v", res)
p := onlyProposal(r)
Expect(p.Status).To(Equal(contract.ProposalPending))
Expect(p.Agent).To(Equal(ag.ID))
if c.file != "" {
Expect(p.Target).To(Equal(c.file))
_, ok := readHome(r, c.file)
Expect(ok).To(BeFalse(), "the proposal wrote the file")
}
})
It("does not exist in a chat of an agent without it", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m"})
Expect(err).NotTo(HaveOccurred())
rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
sent(r.svc, alice, rec.ID, "go")
idle(r.svc, alice, rec.ID)
Expect(r.h.ToolNames(rec.ID)).NotTo(ContainElement("propose_change"))
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty())
})
It("is not enough for the kinds of other grants", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
other := "triggers.propose"
if c.grant == other {
other = "act.files"
}
// memory.write is the grant of the memory kind, so any other
// case holds a different grant than the one the call needs.
held := "memory.write"
if c.grant == held {
held = "skills.write"
}
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{held, other}})
rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
sent(r.svc, alice, rec.ID, "go")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(HaveOccurred())
Expect(res[0].Err.Error()).To(ContainSubstring("not given the right"))
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty())
})
It("exists in a chat without an agent, which holds what the user may use", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
id := create(r.svc, alice)
sent(r.svc, alice, id, "go")
idle(r.svc, alice, id)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
if c.plainOK {
Expect(res[0].Err).To(BeNil(), "%+v", res)
Expect(onlyProposal(r).Kind).To(Equal(contract.ChangeKind(c.args["kind"].(string))))
} else {
Expect(res[0].Err).To(HaveOccurred())
}
})
It("is never held by an unattended run of an agent that has it", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}})
tr := r.schedule(studio.TriggerInput{AgentID: ag.ID})
r.due()
run := r.waitEnded(tr.ID)
Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("propose_change"))
Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("revert_change"))
})
It("is held by a connector chat of an agent that has it and refused when the source is a run that started by itself", func() {
r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{})
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}})
in := proposeInputOf(c, ag.ID)
in.Source = contract.ChangeSource{Kind: session.OriginConnector, ID: "b1", Chat: "c1"}
p, err := r.svc.Changes().Propose(ctx, in)
Expect(err).NotTo(HaveOccurred())
Expect(p.Source.Kind).To(Equal(session.OriginConnector))
Expect(r.svc.Changes().Dismiss(ctx, alice, p.ID)).Error().NotTo(HaveOccurred())
in.Source = contract.ChangeSource{Kind: session.OriginTrigger, ID: "t1", Chat: "c2"}
_, err = r.svc.Changes().Propose(ctx, in)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("started by itself"))
})
})
}
})
Describe("builtins.Chat.HoldsGrant", func() {
DescribeTable("the tool-exists rule",
func(c builtins.Chat, want bool) { Expect(c.HoldsGrant("skills.write")).To(Equal(want)) },
Entry("a chat without an agent", builtins.Chat{}, true),
Entry("an agent without the grant", builtins.Chat{Agent: true, Grants: []string{"memory.write"}}, false),
Entry("an agent with the grant", builtins.Chat{Agent: true, Grants: []string{"skills.write"}}, true),
Entry("a connector chat of an agent with the grant", builtins.Chat{Agent: true, Origin: session.OriginConnector, Grants: []string{"skills.write"}}, true),
Entry("a trigger run of an agent with the grant", builtins.Chat{Agent: true, Origin: session.OriginTrigger, Grants: []string{"skills.write"}}, false),
Entry("a job run", builtins.Chat{Agent: true, Origin: session.OriginJob, Grants: []string{"skills.write"}}, false),
Entry("a sub-agent of an agent with the grant", builtins.Chat{Agent: true, SubAgent: true, Grants: []string{"skills.write"}}, false),
)
})
Describe("the schema of propose_change", func() {
It("lists the kinds the chat holds and no others", func() {
r := changeShared(nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "pages.write"}})
rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
sent(r.svc, alice, rec.ID, "hi")
idle(r.svc, alice, rec.ID)
schema := r.h.ToolSchema(rec.ID, "propose_change")
Expect(schema).To(ContainSubstring(`"enum":["memory","page"]`))
Expect(strings.Contains(schema, `"skill"`)).To(BeFalse())
})
})
})
// proposeInputOf is the direct call of the case for an agent.
func proposeInputOf(c grantCase, agent string) studio.ProposeInput {
in := studio.ProposeInput{User: alice, Agent: agent, Kind: c.args["kind"].(string), Scope: c.args["scope"].(string), Summary: "s"}
in.Target, _ = c.args["target"].(string)
in.Content, _ = c.args["content"].(string)
if files, ok := c.args["files"].(map[string]string); ok {
b, _ := json.Marshal(map[string]any{"files": files})
in.Content = string(b)
}
return in
}
+141
View File
@@ -0,0 +1,141 @@
package studio
import (
"context"
"errors"
"fmt"
"slices"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Builtin grants. An agent lists the grants the owner gave it (Builtins). What
// a chat of the agent holds is the intersection of that list with what the user
// may use at the turn, like the add-ons. A tool then exists in a chat when the
// grant is held, the chat did not switch its group off and the run's authority
// can have it (builtins.Chat.Holds).
// ErrUnknownGrant means an agent names a builtin grant this server does not
// declare.
var ErrUnknownGrant = errors.New("studio: unknown builtin grant")
// cleanGrants checks the grant names of an agent and removes repeats. With
// drop it silently leaves out a name the server does not know (an import from
// a newer server); otherwise it refuses the first one. A nil list stays nil.
func cleanGrants(names []string, drop bool) ([]string, error) {
if names == nil {
return nil, nil
}
out := make([]string, 0, len(names))
for _, n := range names {
switch {
case !builtins.ValidGrant(n):
if !drop {
return nil, fmt.Errorf("%w: %q (GET /api/chats/agents/builtin-grants lists them)", ErrUnknownGrant, n)
}
case !slices.Contains(out, n):
out = append(out, n)
}
}
return out, nil
}
// grantUsable says whether the user may use a grant on this server now: the
// server offers the feature behind it and the user's account has it.
func (s *Service) grantUsable(ctx context.Context, user, grant string) bool {
switch grant {
case builtins.GrantMemoryWrite, builtins.GrantProfileWrite, builtins.GrantSkillsWrite, builtins.GrantPagesWrite, builtins.GrantDashboardWrite:
if !s.d.Files.Available() || s.changes == nil {
return false
}
return s.changes.grantsAKind(grant)
case builtins.GrantTriggersPropose, builtins.GrantTriggersRun:
return s.triggers != nil && s.mayUseTriggers(ctx, user) == nil
case builtins.GrantFilesUpload, builtins.GrantActFiles:
return s.d.Files.Available()
case builtins.GrantSavedAgents:
return s.d.Agents != nil
}
return builtins.ValidGrant(grant)
}
// usableGrants keeps the granted names the user may use, in the order of the
// declarations.
func (s *Service) usableGrants(ctx context.Context, user string, granted []string) []string {
var out []string
for _, g := range builtins.Grants() {
if slices.Contains(granted, g.Name) && s.grantUsable(ctx, user, g.Name) {
out = append(out, g.Name)
}
}
return out
}
// AgentGrants returns the builtin grants that the saved agent holds now: what
// the owner granted, intersected with what the owner may use. A missing agent
// holds none. It is the answer for any gate that asks whether an agent has a
// right.
func (s *Service) AgentGrants(ctx context.Context, user, agentID string) []string {
if s.d.Agents == nil || agentID == "" {
return nil
}
ag, err := s.d.Agents.Get(ctx, principal(user), agentID)
if err != nil {
return nil
}
return s.usableGrants(ctx, principal(user), ag.Builtins)
}
// chatGrants are the grants a chat of a saved agent holds, for the tools.
func (s *Service) chatGrants(ctx context.Context, rec session.ChatRecord) []string {
if rec.AgentID == "" {
return nil
}
if sc := s.scopeOf(ctx, rec); sc != nil {
return sc.builtinNames
}
return nil
}
// BuiltinGrants lists the declared grants for the editor and the chat.
func (s *Service) BuiltinGrants() []builtins.Grant { return builtins.Grants() }
// checkChangeGrant refuses a proposal of an agent that does not hold the
// grant of its kind, or a run that cannot hold rights (one that started by
// itself). A chat without an agent is the user's own and is not asked.
func (s *Service) checkChangeGrant(ctx context.Context, user, agentID string, src contract.ChangeSource, grant string) error {
if src.Kind != "" && src.Kind != session.OriginConnector {
return fmt.Errorf("%w: a run that started by itself cannot change this", proposals.ErrNotGranted)
}
if agentID == "" {
return nil
}
held := s.AgentGrants(ctx, user, agentID)
if grant == "" {
return nil
}
if !slices.Contains(held, grant) {
return fmt.Errorf("%w (%s)", proposals.ErrNotGranted, grant)
}
return nil
}
// checkAnyChangeGrant is the check for a revert: any change grant will do,
// because an undo only restores an earlier text.
func (s *Service) checkAnyChangeGrant(ctx context.Context, user, agentID string, src contract.ChangeSource) error {
if src.Kind != "" && src.Kind != session.OriginConnector {
return fmt.Errorf("%w: a run that started by itself cannot change this", proposals.ErrNotGranted)
}
if agentID == "" {
return nil
}
for _, g := range s.AgentGrants(ctx, user, agentID) {
if s.changes.grantsAKind(g) {
return nil
}
}
return proposals.ErrNotGranted
}
+1 -1
View File
@@ -367,7 +367,7 @@ func (s *Service) Builtins() *builtins.Registry { return s.builtins }
func (s *Service) builtinsFor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, subAgent bool) []builtins.Built {
off, on := s.switchesOf(ctx, rec)
chat := builtins.Chat{Actor: a, Origin: rec.Origin, OriginID: rec.OriginID, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent,
Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, GoalActive: goalActive(rec) && !subAgent}
Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, Grants: s.chatGrants(ctx, rec), GoalActive: goalActive(rec) && !subAgent}
if rec.Origin == session.OriginConnector {
// A connector chat acts for the sender of the turn, not for its owner:
// the tools that need a right ask what that sender holds.
+43 -3
View File
@@ -31,14 +31,49 @@ type Kind struct {
Sensitive bool
// MaxBytes bounds the content; zero means contract.MaxChangeBytes.
MaxBytes int
// Path returns the file inside the home that the change writes.
// Path returns the file inside the home that the change writes. For a kind
// with Files it is the folder that holds the files.
Path func(Aim) (string, error)
// Grant is the builtin grant that lets a saved agent propose this kind
// (memory.write for memory). Empty: no grant is needed.
Grant string
// Check judges the content before the proposal is stored and again when
// the owner confirms, against what the target holds then. Nil: any text
// that fits the limit.
Check func(CheckInput) error
// Files turns the content into the files the change writes, as paths
// relative to the folder Path returned. Nil: the content is the one file
// Path returned.
Files func(content string) ([]FileWrite, error)
}
// FileWrite is one file of a change that writes several.
type FileWrite struct {
Path string // relative to the folder of the change
Content string
}
// CheckInput is what Kind.Check judges.
type CheckInput struct {
Aim Aim
Path string // what Kind.Path returned
Content string
// Current is the text of the target now, for a kind with one file.
Current string
Exists bool
// Read reads another file of the owner's home by its home-relative path,
// for a rule that depends on a neighbour such as a locked skill of
// another layer.
Read func(rel string) (text string, ok bool, err error)
}
// Limit is the content bound of the kind.
func (k Kind) Limit() int {
if k.MaxBytes <= 0 || k.MaxBytes > contract.MaxChangeBytes {
switch {
case k.MaxBytes <= 0:
return contract.MaxChangeBytes
case k.MaxBytes > contract.MaxPageChangeBytes:
return contract.MaxPageChangeBytes
}
return k.MaxBytes
}
@@ -102,6 +137,9 @@ var (
idSafe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
)
// IDSafe reports whether s can name a saved agent or a binding inside a path.
func IDSafe(s string) bool { return idSafe.MatchString(s) }
// 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 {
@@ -110,7 +148,8 @@ func ChannelDir(s contract.ChangeSource) string {
}
var memoryKind = Kind{
Name: contract.ChangeMemory,
Name: contract.ChangeMemory,
Grant: contract.CapMemoryWrite,
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)
@@ -135,6 +174,7 @@ var memoryKind = Kind{
var profileKind = Kind{
Name: contract.ChangeProfile,
Grant: contract.CapProfileWrite,
Sensitive: true,
MaxBytes: contract.MaxProfileBytes,
Path: func(a Aim) (string, error) {
@@ -78,3 +78,16 @@ var _ = Describe("the default gate", func() {
Expect(err).To(MatchError(proposals.ErrNotGranted))
})
})
var _ = Describe("kind grants and limits", func() {
It("ties memory and profile to their grants and lets a page kind carry 256 KiB", func() {
kinds := proposals.NewKinds()
m, _ := kinds.Lookup("memory")
p, _ := kinds.Lookup("profile")
Expect(m.Grant).To(Equal("memory.write"))
Expect(p.Grant).To(Equal("profile.write"))
Expect((proposals.Kind{MaxBytes: contract.MaxPageChangeBytes}).Limit()).To(Equal(contract.MaxPageChangeBytes))
Expect((proposals.Kind{MaxBytes: contract.MaxPageChangeBytes + 1}).Limit()).To(Equal(contract.MaxPageChangeBytes))
Expect((proposals.Kind{}).Limit()).To(Equal(contract.MaxChangeBytes))
})
})
+1 -1
View File
@@ -120,7 +120,7 @@ func (s *Service) toolFallbacks(ctx context.Context, rec session.ChatRecord, too
offList, onList := s.switchesOf(ctx, rec)
off, on := set(offList), set(onList)
c := builtins.Chat{Actor: s.chatActorFor(ctx, rec), Origin: rec.Origin, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: rec.Kind == session.KindSubAgent,
Admin: s.isAdmin(ctx, rec.Principal)}
Admin: s.isAdmin(ctx, rec.Principal), Grants: s.chatGrants(ctx, rec)}
byID := map[string]builtins.Tool{}
for _, t := range s.builtins.Tools() {
byID[t.ID] = t
+14
View File
@@ -105,6 +105,20 @@ func Requires(data []byte) []string {
return out
}
// Locked reads the optional "locked" key of the frontmatter: the owner's mark
// that a skill is theirs to change and no agent may propose an edit of it. Only
// the value true (any case) locks; a file without frontmatter is not locked.
func Locked(data []byte) bool {
if len(data) > MaxFileSize {
return false
}
fm, ok := frontmatter(string(data))
if !ok {
return false
}
return strings.EqualFold(strings.TrimSpace(keyValues(fm)["locked"]), "true")
}
// Clean drops invalid UTF-8, control characters and format characters from s,
// turns every run of white space into one space and trims the ends.
func Clean(s string) string {
+13
View File
@@ -76,3 +76,16 @@ var _ = Describe("Requires", func() {
Entry("no frontmatter", "Body only.\n", nil),
)
})
var _ = Describe("Locked", func() {
DescribeTable("reads the owner's mark from the frontmatter",
func(text string, want bool) { Expect(Locked([]byte(text))).To(Equal(want)) },
Entry("true", "---\nname: a\ndescription: d\nlocked: true\n---\nb\n", true),
Entry("quoted and upper case", "---\nname: a\ndescription: d\nlocked: \"True\"\n---\nb\n", true),
Entry("false", "---\nname: a\ndescription: d\nlocked: false\n---\nb\n", false),
Entry("absent", "---\nname: a\ndescription: d\n---\nb\n", false),
Entry("in the body only", "---\nname: a\ndescription: d\n---\nlocked: true\n", false),
Entry("no frontmatter", "locked: true\n", false),
Entry("yes is not true", "---\nname: a\ndescription: d\nlocked: yes\n---\nb\n", false),
)
})
+16
View File
@@ -43,6 +43,7 @@ type ToolHarness struct {
results []ToolResult
sent map[string][]string
tools map[string][]string
schemas map[string]map[string]string
prompts map[string]string
systems map[string]string
resumes map[string][]byte
@@ -104,6 +105,14 @@ func (h *ToolHarness) TurnPrompt(chat string) string {
return h.prompts[chat]
}
// ToolSchema returns the JSON schema of a tool the chat's session was last
// started with, or "".
func (h *ToolHarness) ToolSchema(chat, tool string) string {
h.mu.Lock()
defer h.mu.Unlock()
return h.schemas[chat][tool]
}
// Start implements session.Harness.
func (h *ToolHarness) Start(_ context.Context, env session.HarnessEnv) (session.HarnessSession, error) {
h.mu.Lock()
@@ -115,6 +124,13 @@ func (h *ToolHarness) Start(_ context.Context, env session.HarnessEnv) (session.
names[i] = t.Name
}
h.tools[env.Actor.ChatID] = names
if h.schemas == nil {
h.schemas = map[string]map[string]string{}
}
h.schemas[env.Actor.ChatID] = map[string]string{}
for _, t := range env.Tools {
h.schemas[env.Actor.ChatID][t.Name] = string(t.Schema)
}
if h.prompts == nil {
h.prompts = map[string]string{}
}
+1 -1
View File
@@ -176,7 +176,7 @@ func (s *Service) builtinGroups(ctx context.Context, rec session.ChatRecord, too
off, on := set(offList), set(onList)
a := s.chatActorFor(ctx, rec)
c := builtins.Chat{Actor: a, Origin: rec.Origin, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: rec.Kind == session.KindSubAgent,
Admin: s.isAdmin(ctx, rec.Principal), GroupsOff: offList, GroupsOn: onList, GoalActive: goalActive(rec)}
Admin: s.isAdmin(ctx, rec.Principal), GroupsOff: offList, GroupsOn: onList, Grants: s.chatGrants(ctx, rec), GoalActive: goalActive(rec)}
var out []BuiltinGroup
byGroup := map[string]int{}
for _, t := range s.builtins.Tools() {
+31 -7
View File
@@ -2486,14 +2486,32 @@ How it works for an add-on author:
The record is `contract.ChangeProposal` in `pkg/addons/contract` and is
versioned (`v`: 1). Readers ignore fields they do not know. The kinds are
`memory`, `profile`, `skill`, `page` and `dashboard`; this host offers `memory`
and `profile` and other features register theirs. A memory change writes under
`memory`, `profile`, `skill`, `page` and `dashboard`; this host offers all five.
A memory change writes under
`Memory/` (`Memory/<file>.md` for scope `user`, `Memory/agents/<agent>/` for
`agent`, `Memory/channels/<binding>-<hash>/` for `channel`). A profile writes
`LocalAI/agents/<agent>/profile.md` and is at most 4 KiB. Other contents are at
most 16 KiB. A target that is not a plain lower-case file name is refused, so a
proposal cannot leave `Memory/`.
The other three kinds follow the same flow:
- `skill`: the content is a full `SKILL.md` (name and description in the
frontmatter; the name must equal the target). Scope `user` writes
`.skills/<name>/SKILL.md`, scope `agent` writes
`LocalAI/agents/<agent>/skills/<name>/SKILL.md`; no other layer is offered.
A skill with `locked: true` in its frontmatter cannot be changed by a
proposal, and a locked personal skill cannot be replaced by an agent skill of
the same name. The check runs again when the owner confirms.
- `page`: the target is the page name (lower-case letters, digits and dashes)
and the files are an object of paths to text, with an `index.html`. The paths
must stay inside the page folder, at most 32 files, all text, `page.json`
checked like the Pages screen reads it, 256 KiB in total. The owner's
confirmation writes `pages/<name>/` as one change set, so one undo removes it.
- `dashboard`: the content is the full `LocalAI/home.json`, validated against the
same schema as the Home settings. A file that breaks the schema is refused
when proposed and when edited at confirmation.
Limits and safety:
| Rule | Value |
@@ -2503,12 +2521,18 @@ Limits and safety:
| Proposals of one agent (or of plain chats) in an hour | 5 |
| Target changed after the proposal | the confirmation is refused (409) |
| Run started by a trigger, a job or a page, and sub-agents | never offered the tools |
| Chat that runs a saved agent | refused until a grant allows it |
| Chat that runs a saved agent | refused until the owner grants the kind (see below) |
A grant plugs in as a `proposals.Gate` in the host: it is asked with the
action (`propose` or `revert`), the agent, the kind, the scope and the source
before a tool call acts. The default gate allows a plain chat and refuses a chat
that runs a saved agent, so an agent has no right until the owner gives it.
A saved agent proposes a kind only when its `builtins` list holds the grant of
that kind: `memory.write`, `profile.write`, `skills.write`, `pages.write` or
`dashboard.write`. The tool exists in a chat when the agent holds the grant, the
user may still use it and the chat did not switch the `changes` group off; its
schema lists only the kinds the chat holds. A plain chat holds what its user
may use. A run that started by itself never holds a grant. `GET
/api/chats/agents/builtin-grants` lists every grant with its title, description
and `sensitive` flag. A host can add a `proposals.Gate` on top: it is asked with
the action (`propose` or `revert`), the agent, the kind, the scope and the
source, after the grant check.
Every step is audited (propose, refuse, confirm, dismiss, expire, stale,
revert) with the source and a SHA-256 of the text, never the text.
+19 -1
View File
@@ -244,6 +244,24 @@ assistant, a code reviewer, a daily digest, a writing editor). They grant
nothing. `POST /api/chats/agents/templates/{id}/create` saves one as your own
(`{"name": "..."}` is optional); then grant it tools with `PUT`.
### Builtin grants
An agent can be given built-in capabilities in its `builtins` list. The list is
empty by default, no template or import turns a grant on, and a chat of the
agent holds only the grants you may use at that moment.
`GET /api/chats/agents/builtin-grants` returns `{grants: [{name, title,
description, sensitive}]}` for the editor. The names are `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
`saved_agents` (the opt-in group of that name; granting it also switches the
group on). A sensitive grant changes what the agent may do or reach later; the
editor marks it. The five `*.write` grants let the agent propose a memory note,
its profile, a skill, a page or a dashboard change with `propose_change`; you
confirm every proposal, and you can undo it. A write to the agent without the
`builtins` field keeps them, an empty list clears them and an unknown name is
refused (an import drops it).
### Export and import
```
@@ -253,7 +271,7 @@ curl -X POST .../api/chats/agents/import -F file=@reviewer.json
```
The file holds the definition only (kind `localai-agent`): no ID, owner or
time. An agent with tool switches (`builtins_off`, `builtins_on`) is version 4, so that a server that does not know them refuses the file instead of giving the agent every tool. An agent without a folder or tool switches is version 1. An agent with one folder is
time. An agent with tool switches (`builtins_off`, `builtins_on`) is version 4, so that a server that does not know them refuses the file instead of giving the agent every tool. The `builtins` list is an extra field of any version; a server that does not know it ignores it, so the agent arrives without the grants. An agent without a folder or tool switches is version 1. An agent with one folder is
version 2 and carries `folder` with the folder's path at export time and the
mode, for example `{"path": "/Projects/Acme", "mode": "read"}` (the path is
empty when the folder is missing, not chosen, or now outside your home).
+3
View File
@@ -126,6 +126,9 @@ const (
MaxChangesPerHour = 5
// MaxChangeBytes is the largest content of a proposal.
MaxChangeBytes = 16 << 10
// MaxPageChangeBytes is the largest content of a page proposal: all the
// files of the page together.
MaxPageChangeBytes = 256 << 10
// MaxProfileBytes is the largest agent profile.
MaxProfileBytes = 4 << 10
)
+63 -1
View File
@@ -4503,6 +4503,23 @@ const docTemplate = `{
}
}
},
"/api/chats/agents/builtin-grants": {
"get": {
"description": "Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.",
"tags": [
"chats"
],
"summary": "List the builtin grants of a saved agent.",
"responses": {
"200": {
"description": "The grants, in display order",
"schema": {
"$ref": "#/definitions/schema.ChatsBuiltinGrantList"
}
}
}
}
},
"/api/chats/agents/import": {
"post": {
"description": "Send the export file (version 1, 2 for an agent with a folder, or 3 for an agent with several) as the JSON body, or as the file field of a multipart form. A bare agent definition (the body of POST /api/chats/agents) is accepted too. The agent is always new: a name you already use gets a number, and a model this server does not have, or you may not use, is dropped with a warning. A folder is looked up by its path in your files; when you have no such folder the agent keeps the mode without a folder (folder state unset) and does not start until you choose one, and a warning says so.",
@@ -4696,7 +4713,7 @@ const docTemplate = `{
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -18846,6 +18863,13 @@ const docTemplate = `{
"type": "string"
}
},
"builtins": {
"description": "Builtins are the builtin capabilities granted to the agent, by the names\nthat GET /api/chats/agents/builtin-grants lists (memory.write,\nskills.write and so on). Empty grants none. A chat of the agent holds\nthe grants you may use at that moment.",
"type": "array",
"items": {
"type": "string"
}
},
"builtins_off": {
"description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.",
"type": "array",
@@ -19117,6 +19141,13 @@ const docTemplate = `{
"type": "string"
}
},
"builtins": {
"description": "Builtins are the builtin grants of the agent (see ChatsAgent). A write\nwithout them keeps the stored ones; an empty list clears them; a name\nthe server does not declare is a 400, except in an import, which drops it.",
"type": "array",
"items": {
"type": "string"
}
},
"builtins_off": {
"description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.",
"type": "array",
@@ -19222,6 +19253,37 @@ const docTemplate = `{
}
}
},
"schema.ChatsBuiltinGrant": {
"type": "object",
"properties": {
"description": {
"description": "Description says what the agent can do with the grant, in English.",
"type": "string"
},
"name": {
"description": "Name is the value to put in an agent's builtins list.",
"type": "string"
},
"sensitive": {
"description": "Sensitive grants change what the agent may do or reach later. They are\nnever on by default and the editor marks them.",
"type": "boolean"
},
"title": {
"type": "string"
}
}
},
"schema.ChatsBuiltinGrantList": {
"type": "object",
"properties": {
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsBuiltinGrant"
}
}
}
},
"schema.ChatsBuiltinGroup": {
"type": "object",
"properties": {
+63 -1
View File
@@ -4500,6 +4500,23 @@
}
}
},
"/api/chats/agents/builtin-grants": {
"get": {
"description": "Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.",
"tags": [
"chats"
],
"summary": "List the builtin grants of a saved agent.",
"responses": {
"200": {
"description": "The grants, in display order",
"schema": {
"$ref": "#/definitions/schema.ChatsBuiltinGrantList"
}
}
}
}
},
"/api/chats/agents/import": {
"post": {
"description": "Send the export file (version 1, 2 for an agent with a folder, or 3 for an agent with several) as the JSON body, or as the file field of a multipart form. A bare agent definition (the body of POST /api/chats/agents) is accepted too. The agent is always new: a name you already use gets a number, and a model this server does not have, or you may not use, is dropped with a warning. A folder is looked up by its path in your files; when you have no such folder the agent keeps the mode without a folder (folder state unset) and does not start until you choose one, and a warning says so.",
@@ -4693,7 +4710,7 @@
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -18843,6 +18860,13 @@
"type": "string"
}
},
"builtins": {
"description": "Builtins are the builtin capabilities granted to the agent, by the names\nthat GET /api/chats/agents/builtin-grants lists (memory.write,\nskills.write and so on). Empty grants none. A chat of the agent holds\nthe grants you may use at that moment.",
"type": "array",
"items": {
"type": "string"
}
},
"builtins_off": {
"description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.",
"type": "array",
@@ -19114,6 +19138,13 @@
"type": "string"
}
},
"builtins": {
"description": "Builtins are the builtin grants of the agent (see ChatsAgent). A write\nwithout them keeps the stored ones; an empty list clears them; a name\nthe server does not declare is a 400, except in an import, which drops it.",
"type": "array",
"items": {
"type": "string"
}
},
"builtins_off": {
"description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.",
"type": "array",
@@ -19219,6 +19250,37 @@
}
}
},
"schema.ChatsBuiltinGrant": {
"type": "object",
"properties": {
"description": {
"description": "Description says what the agent can do with the grant, in English.",
"type": "string"
},
"name": {
"description": "Name is the value to put in an agent's builtins list.",
"type": "string"
},
"sensitive": {
"description": "Sensitive grants change what the agent may do or reach later. They are\nnever on by default and the editor marks them.",
"type": "boolean"
},
"title": {
"type": "string"
}
}
},
"schema.ChatsBuiltinGrantList": {
"type": "object",
"properties": {
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsBuiltinGrant"
}
}
}
},
"schema.ChatsBuiltinGroup": {
"type": "object",
"properties": {
+61 -4
View File
@@ -3210,6 +3210,15 @@ definitions:
items:
type: string
type: array
builtins:
description: |-
Builtins are the builtin capabilities granted to the agent, by the names
that GET /api/chats/agents/builtin-grants lists (memory.write,
skills.write and so on). Empty grants none. A chat of the agent holds
the grants you may use at that moment.
items:
type: string
type: array
builtins_off:
description: |-
BuiltinsOff are the groups of built-in tools (web, files, triggers...)
@@ -3427,6 +3436,14 @@ definitions:
items:
type: string
type: array
builtins:
description: |-
Builtins are the builtin grants of the agent (see ChatsAgent). A write
without them keeps the stored ones; an empty list clears them; a name
the server does not declare is a 400, except in an import, which drops it.
items:
type: string
type: array
builtins_off:
description: |-
BuiltinsOff and BuiltinsOn are the switches of the built-in tools of
@@ -3514,6 +3531,29 @@ definitions:
skipped:
type: integer
type: object
schema.ChatsBuiltinGrant:
properties:
description:
description: Description says what the agent can do with the grant, in English.
type: string
name:
description: Name is the value to put in an agent's builtins list.
type: string
sensitive:
description: |-
Sensitive grants change what the agent may do or reach later. They are
never on by default and the editor marks them.
type: boolean
title:
type: string
type: object
schema.ChatsBuiltinGrantList:
properties:
grants:
items:
$ref: '#/definitions/schema.ChatsBuiltinGrant'
type: array
type: object
schema.ChatsBuiltinGroup:
properties:
description:
@@ -14990,16 +15030,18 @@ paths:
- chats
/api/chats/agents/{id}/export:
get:
description: Returns the definition as a JSON file (version, kind localai-agent,
description: 'Returns the definition as a JSON file (version, kind localai-agent,
agent). It holds no ID, owner or time. An agent with one folder is version
2 and carries folder {path, mode}; an agent with several is version 3 and
carries folders [{path, mode}], the folder's home-relative path now (empty
carries folders [{path, mode}], the folder''s home-relative path now (empty
when the folder is missing, not chosen, or outside your home, even if readable);
importing an empty path preserves the scope as unset with a warning; a server
that knows only version 1 refuses it. An agent with switches of built-in tool
groups (builtins_off, builtins_on) is version 4. An agent without a folder
or switches is version 1. POST /api/chats/agents/import reads it. This replaces
the old /api/agents/{name}/export.
or switches is version 1. The builtins list (builtin grants) is an extra field
of any version: a server that does not know it ignores it, so the agent comes
back without the grants. POST /api/chats/agents/import reads it. This replaces
the old /api/agents/{name}/export.'
parameters:
- description: the agent's id
in: path
@@ -15037,6 +15079,21 @@ paths:
summary: Show what an agent has run.
tags:
- chats
/api/chats/agents/builtin-grants:
get:
description: Each grant has a name (the value for builtins in an agent), a title,
a description and sensitive. A sensitive grant changes what the agent may
do or reach later (profile, skills, pages, dashboard, new channels, actions
of unattended runs); none is on by default. A chat of the agent holds only
the grants you may use at that moment.
responses:
"200":
description: The grants, in display order
schema:
$ref: '#/definitions/schema.ChatsBuiltinGrantList'
summary: List the builtin grants of a saved agent.
tags:
- chats
/api/chats/agents/import:
post:
consumes: