mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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:
39 files changed
+1821
-80
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user