feat(studio): accept a list of agent folders in the API and the agent file

POST and PUT on /api/chats/agents take folders, a list of path and mode
rows, up to eight. The old folder field still works and is read as a
list with one entry. Responses carry folders, the effective mode of each
row and a notice with the count of shared folders a chat does not mount.

A folder inside another folder of the same agent takes the mode of the
outer one, because the file store gives a node the highest mode among
the roots above it and cannot hold a read only folder inside a write
folder. The editor data shows that mode.

An agent with one folder still exports as version 2. An agent with
several exports as version 3 with folders. A server that reads version 2
refuses version 3. Import keeps a folder that is not found as a row
that is not chosen.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:02:02 +00:00
1 parent 6338b4c0c6
commit db596e1696
11 files changed
+216 -62

No files matched your search

+44 -13
View File
@@ -18,7 +18,25 @@ func folderOut(st studiosvc.AgentFolderState, ok bool) *schema.ChatsAgentFolder
if !ok {
return nil
}
return &schema.ChatsAgentFolder{Path: st.Path, Name: st.Name, Mode: st.Mode, State: st.State}
return &schema.ChatsAgentFolder{Path: st.Path, Name: st.Name, Mode: st.Mode, Effective: st.Effective, State: st.State}
}
// foldersIn is the folder choice of a write as a list: folders when sent, else
// the one folder. Nil means the body has none, which keeps the stored folders.
// {"folders": []} is an empty, non-nil list and clears them.
func foldersIn(req schema.ChatsAgentWrite) *[]studiosvc.AgentFolderWrite {
if req.Folders == nil && req.Folder == nil {
return nil
}
out := []studiosvc.AgentFolderWrite{}
if req.Folders != nil {
for _, f := range req.Folders {
out = append(out, studiosvc.AgentFolderWrite{Path: f.Path, Mode: f.Mode})
}
} else if req.Folder.Path != "" {
out = append(out, studiosvc.AgentFolderWrite{Path: req.Folder.Path, Mode: req.Folder.Mode})
}
return &out
}
// folderIn is the folder choice of a write, or nil when the body has none.
@@ -32,10 +50,24 @@ func folderIn(f *schema.ChatsAgentFolderWrite) *studiosvc.AgentFolderWrite {
// agentFull is agentOut with the folder resolved for the user.
func (h *Handlers) agentFull(c echo.Context, a agents.Agent) schema.ChatsAgent {
out := agentOut(a)
out.Folder = folderOut(h.svc.AgentFolder(c.Request().Context(), userID(c), a))
h.addFolders(c, &out, a, h.svc.AgentFolderRows(c.Request().Context(), userID(c), []agents.Agent{a})[a.ID])
return out
}
// addFolders puts the folder rows of an agent, and the notice, in its output.
func (h *Handlers) addFolders(c echo.Context, out *schema.ChatsAgent, a agents.Agent, rows []studiosvc.AgentFolderState) {
if len(rows) == 0 {
return
}
out.Folder = folderOut(rows[0], true)
for _, r := range rows {
out.Folders = append(out.Folders, *folderOut(r, true))
}
if n := h.svc.SharedNotMounted(c.Request().Context(), userID(c), a); n > 0 {
out.FolderNotice = &schema.ChatsAgentFolderNotice{NotMounted: n}
}
}
func agentOut(a agents.Agent) schema.ChatsAgent {
nz := func(l []string) []string {
if l == nil {
@@ -52,7 +84,7 @@ func agentOut(a agents.Agent) schema.ChatsAgent {
// saved agent never names one the service would refuse at the first turn.
// When ok is false the refusal is already written and err is the write result,
// so the caller returns err without writing a second body.
func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folder *studiosvc.AgentFolderWrite, ok bool, err error) {
func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folders *[]studiosvc.AgentFolderWrite, ok bool, err error) {
var req schema.ChatsAgentWrite
if err := c.Bind(&req); err != nil {
return a, nil, false, fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON")
@@ -69,7 +101,7 @@ func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folder *studiosvc.Ag
}
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},
folderIn(req.Folder), true, nil
foldersIn(req), true, nil
}
// networkOf is the network preset of an agent as the API shows it: off when
@@ -92,11 +124,10 @@ func (h *Handlers) ListAgents(c echo.Context) error {
return failFor(c, err)
}
out := schema.ChatsAgentList{Agents: make([]schema.ChatsAgent, 0, len(list))}
folders := h.svc.AgentFolders(c.Request().Context(), userID(c), list)
rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), list)
for _, a := range list {
o := agentOut(a)
st, ok := folders[a.ID]
o.Folder = folderOut(st, ok)
h.addFolders(c, &o, a, rows[a.ID])
out.Agents = append(out.Agents, o)
}
return c.JSON(http.StatusOK, out)
@@ -104,18 +135,18 @@ func (h *Handlers) ListAgents(c echo.Context) error {
// CreateAgent saves a new agent.
// @Summary Save an agent.
// @Description An agent is a definition: instructions and exact lists of the MCP servers, add-ons, collections and skills a chat with it may use. A chat gets what the lists name and you may use at that moment, never more. Start a chat with it by passing agent_id to POST /api/chats. folder {path, mode} gives the agent one folder of your files, read or write; without it the agent works on all your files.
// @Description An agent is a definition: instructions and exact lists of the MCP servers, add-ons, collections and skills a chat with it may use. A chat gets what the lists name and you may use at that moment, never more. Start a chat with it by passing agent_id to POST /api/chats. folders [{path, mode}] gives the agent up to 8 folders of your files, each read or write (folder {path, mode} is the same with one entry); without them the agent works on all your files.
// @Tags chats
// @Param request body schema.ChatsAgentWrite true "the agent"
// @Success 201 {object} schema.ChatsAgent "The saved agent"
// @Failure 400 {object} schema.ErrorResponse "The definition or the folder is not valid"
// @Router /api/chats/agents [post]
func (h *Handlers) CreateAgent(c echo.Context) error {
a, folder, ok, err := h.agentIn(c)
a, folders, ok, err := h.agentIn(c)
if !ok {
return err
}
saved, err := h.svc.SaveAgentWithFolder(c.Request().Context(), userID(c), a, folder)
saved, err := h.svc.SaveAgentWithFolders(c.Request().Context(), userID(c), a, folders)
if err != nil {
return failFor(c, err)
}
@@ -139,7 +170,7 @@ func (h *Handlers) GetAgent(c echo.Context) error {
// UpdateAgent replaces a saved agent.
// @Summary Replace a saved agent.
// @Description The body replaces every field but folder: a body without folder keeps the folder the agent has, so a client that does not know the field cannot widen the agent, and folder {path: ""} clears it. Chats that already run with the agent use the new definition from their next turn.
// @Description The body replaces every field but folder: a body without folder or folders keeps the folders the agent has, so a client that does not know the fields cannot widen the agent, and folders [] (or folder {path: ""}) clears them. Chats that already run with the agent use the new definition from their next turn.
// @Tags chats
// @Param id path string true "the agent's id"
// @Param request body schema.ChatsAgentWrite true "the agent"
@@ -148,12 +179,12 @@ func (h *Handlers) GetAgent(c echo.Context) error {
// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's"
// @Router /api/chats/agents/{id} [put]
func (h *Handlers) UpdateAgent(c echo.Context) error {
a, folder, ok, err := h.agentIn(c)
a, folders, ok, err := h.agentIn(c)
if !ok {
return err
}
a.ID = c.Param("id")
saved, err := h.svc.SaveAgentWithFolder(c.Request().Context(), userID(c), a, folder)
saved, err := h.svc.SaveAgentWithFolders(c.Request().Context(), userID(c), a, folders)
if err != nil {
return failFor(c, err)
}
+43 -17
View File
@@ -13,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/schema"
studiosvc "github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
)
@@ -20,16 +21,20 @@ const (
agentExportKind = "localai-agent"
maxAgentImportBody = 1 << 20
// maxAgentFileVersion is the newest version of the agent file this server
// reads. Version 2 is an agent with a folder: it got its own version so
// that a server that knows only version 1 refuses the file instead of
// importing the agent with access to all the files.
maxAgentFileVersion = 2
// reads. Version 2 is an agent with a folder, version 3 an agent with
// several: each got its own version so that an older server refuses the
// file instead of importing the agent with more access than it had.
maxAgentFileVersion = 3
)
// agentFileVersion is the version an export writes: 1 for an agent without a
// folder, so older servers keep importing it, and 2 for one with a folder.
func agentFileVersion(hasFolder bool) int {
if hasFolder {
// folder, so older servers keep importing it, 2 for one with one folder and 3
// for one with several.
func agentFileVersion(folders int) int {
switch {
case folders > 1:
return 3
case folders == 1:
return 2
}
return 1
@@ -37,7 +42,7 @@ func agentFileVersion(hasFolder 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 a folder is version 2 and carries folder {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 without a folder 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 without a folder is version 1. 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"
@@ -49,17 +54,26 @@ 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}
if st, ok := h.svc.AgentFolder(c.Request().Context(), userID(c), a); ok {
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
// it must stay scoped but unset, not bind to a same-spelled home path.
exportPath := ""
if abs, live := h.svc.FolderResolver().PathOf(c.Request().Context(), h.svc.Principal(userID(c)), a.FolderID); live {
if abs, live := h.svc.FolderResolver().PathOf(c.Request().Context(), h.svc.Principal(userID(c)), f.Node); live {
exportPath = h.svc.Files().Rel(h.svc.Principal(userID(c)), abs)
}
w.Folder = &schema.ChatsAgentFolderWrite{Path: exportPath, Mode: st.Mode}
mode := f.Mode
if i < len(rows) {
mode = rows[i].Mode
}
w.Folders = append(w.Folders, schema.ChatsAgentFolderWrite{Path: exportPath, Mode: mode})
}
if len(w.Folders) == 1 {
// One folder stays in the version 2 shape, which older servers read.
w.Folder, w.Folders = &w.Folders[0], nil
}
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%q", fileSafe(a.Name)+".json"))
return c.JSON(http.StatusOK, schema.ChatsAgentExport{Version: agentFileVersion(a.HasFolder()), Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w})
return c.JSON(http.StatusOK, schema.ChatsAgentExport{Version: agentFileVersion(len(a.FolderList())), Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w})
}
// fileSafe keeps a name usable as a file name in a header.
@@ -81,7 +95,7 @@ func fileSafe(name string) string {
// ImportAgent saves an agent from a file.
// @Summary Import a saved agent.
// @Description Send the export file (version 1, or 2 for an agent with a folder) 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.
// @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.
// @Tags chats
// @Accept json,mpfd
// @Param request body schema.ChatsAgentExport true "the agent file"
@@ -128,16 +142,28 @@ 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}
saved, err := h.svc.ImportAgent(c.Request().Context(), userID(c), a, folderIn(def.Folder))
var rowsIn []studiosvc.AgentFolderWrite
switch {
case def.Folders != nil:
for _, f := range def.Folders {
rowsIn = append(rowsIn, studiosvc.AgentFolderWrite{Path: f.Path, Mode: f.Mode})
}
case def.Folder != nil:
rowsIn = []studiosvc.AgentFolderWrite{{Path: def.Folder.Path, Mode: def.Folder.Mode}}
}
saved, err := h.svc.ImportAgentFolders(c.Request().Context(), userID(c), a, rowsIn)
if err != nil {
return failFor(c, err)
}
if saved.Name != strings.TrimSpace(def.Name) {
warnings = append(warnings, fmt.Sprintf("you already have an agent named %q, so the new one is named %q", strings.TrimSpace(def.Name), saved.Name))
}
if saved.HasFolder() && saved.FolderID == 0 {
if def.Folder.Path != "" {
warnings = append(warnings, fmt.Sprintf("The folder %s was not found. Choose a folder before you use this agent.", def.Folder.Path))
for i, f := range saved.FolderList() {
if f.Node != 0 {
continue
}
if rowsIn[i].Path != "" {
warnings = append(warnings, fmt.Sprintf("The folder %s was not found. Choose a folder before you use this agent.", rowsIn[i].Path))
} else {
warnings = append(warnings, "The file names no folder. Choose a folder before you use this agent.")
}
+15 -4
View File
@@ -35,7 +35,7 @@ var _ = Describe("agent files", func() {
It("refuses a version it does not know", func() {
for _, raw := range []string{
`{"version":3,"kind":"localai-agent","agent":{"name":"x"}}`,
`{"version":4,"kind":"localai-agent","agent":{"name":"x"}}`,
`{"version":0,"kind":"localai-agent","agent":{"name":"x"}}`,
} {
_, err := readAgentFile([]byte(raw))
@@ -43,8 +43,19 @@ var _ = Describe("agent files", func() {
}
})
It("exports version 2 only for an agent with a folder", func() {
Expect(agentFileVersion(false)).To(Equal(1))
Expect(agentFileVersion(true)).To(Equal(2))
It("exports version 2 for one folder and version 3 for several", func() {
Expect(agentFileVersion(0)).To(Equal(1))
Expect(agentFileVersion(1)).To(Equal(2))
Expect(agentFileVersion(2)).To(Equal(3))
})
It("reads the folders of a version 3 file, and a version 2 reader refuses it", func() {
multi := []byte(`{"version":3,"kind":"localai-agent","agent":{"name":"M","folders":[{"path":"/A","mode":"write"},{"path":"/B","mode":"read"}]}}`)
def, err := readAgentFile(multi)
Expect(err).NotTo(HaveOccurred())
Expect(def.Folders).To(HaveLen(2))
Expect(def.Folders[1].Path).To(Equal("/B"))
_, err = readAgentFileUpTo(multi, 2)
Expect(err).To(MatchError(ContainSubstring("version")))
})
})
+2 -11
View File
@@ -2,7 +2,6 @@ package studio
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -14,7 +13,6 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/schema"
studiosvc "github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/files"
@@ -28,7 +26,7 @@ import (
)
var _ = Describe("folder capacity", func() {
It("refuses create and send with a fixed capacity response", func() {
It("starts create and send although more shared folders exist than fit", func() {
ctx := context.Background()
b, err := blob.NewFS(filepath.Join(GinkgoT().TempDir(), "blobs"))
Expect(err).NotTo(HaveOccurred())
@@ -79,14 +77,7 @@ var _ = Describe("folder capacity", func() {
err = h.Create(c)
}
Expect(err).NotTo(HaveOccurred())
var response schema.ErrorResponse
Expect(json.Unmarshal(out.Body.Bytes(), &response)).To(Succeed())
Expect(out.Code).To(Equal(http.StatusConflict), "send=%v: %s", send, out.Body.String())
Expect(response.Error).NotTo(BeNil())
Expect(response.Error.Type).To(Equal("agent_folder_limit"))
Expect(response.Error.Code).To(BeEquivalentTo(http.StatusConflict))
Expect(response.Error.Message).To(Equal("This agent cannot start because its file access exceeds the folder limit. Ask an administrator to reduce the shared folders available to you, then try again."))
Expect(out.Code).To(BeNumerically("<", 300), "send=%v: %s", send, out.Body.String())
}
Expect(harness.Starts()).To(BeZero(), "refused request started a harness")
})
})
+3 -3
View File
@@ -130,7 +130,7 @@ var _ = Describe("Chats agents endpoints", func() {
rec := saveIn("alice", "Scoped", map[string]string{"path": "/Projects/Acme", "mode": "read"})
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
a := decodeJSON(rec)
Expect(a["folder"]).To(Equal(map[string]any{"path": "/Projects/Acme", "name": "Acme", "mode": "read", "state": "ok"}))
Expect(a["folder"]).To(Equal(map[string]any{"path": "/Projects/Acme", "name": "Acme", "mode": "read", "effective": "read", "state": "ok"}))
Expect(rec.Body.String()).NotTo(ContainSubstring("folder_id"), "the node never leaves the server")
Expect(s.do("alice", http.MethodPost, "/api/chats/files/move", map[string]string{"from": "/Projects/Acme", "to": "/Projects/Acme Corp"}).Code).To(Equal(http.StatusOK))
@@ -150,7 +150,7 @@ var _ = Describe("Chats agents endpoints", func() {
id := decodeJSON(saveIn("alice", "Scoped", map[string]string{"path": "/Notes", "mode": "write"}))["id"].(string)
put := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Scoped 2"})
Expect(put.Code).To(Equal(http.StatusOK), put.Body.String())
Expect(decodeJSON(put)["folder"]).To(Equal(map[string]any{"path": "/Notes", "name": "Notes", "mode": "write", "state": "ok"}))
Expect(decodeJSON(put)["folder"]).To(Equal(map[string]any{"path": "/Notes", "name": "Notes", "mode": "write", "effective": "write", "state": "ok"}))
})
It("clears the folder with an empty path", func() {
@@ -164,7 +164,7 @@ var _ = Describe("Chats agents endpoints", func() {
It("takes the whole home as the folder", func() {
rec := saveIn("alice", "Reader", map[string]string{"path": "/", "mode": "read"})
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
Expect(decodeJSON(rec)["folder"]).To(Equal(map[string]any{"path": "/", "mode": "read", "state": "ok"}))
Expect(decodeJSON(rec)["folder"]).To(Equal(map[string]any{"path": "/", "mode": "read", "effective": "read", "state": "ok"}))
})
It("refuses a file, a missing path, Chats, LocalAI, another home and a bad mode as the folder", func() {
+42 -2
View File
@@ -179,7 +179,7 @@ var _ = Describe("Chats agent export, import, templates and history", func() {
mkdir("alice", "/shared/Team/Acme")
got := s.do("alice", http.MethodGet, "/api/chats/agents/"+id, nil)
Expect(got.Code).To(Equal(http.StatusOK))
Expect(decodeJSON(got)["folder"]).To(Equal(map[string]any{"path": "/shared/Team/Acme", "name": "Acme", "mode": "read", "state": "ok"}))
Expect(decodeJSON(got)["folder"]).To(Equal(map[string]any{"path": "/shared/Team/Acme", "name": "Acme", "mode": "read", "effective": "read", "state": "ok"}))
a, err := s.svc.Agent(ctx, "alice", id)
Expect(err).NotTo(HaveOccurred())
followed, ok := s.svc.FolderResolver().PathOf(ctx, "alice", a.FolderID)
@@ -215,6 +215,46 @@ var _ = Describe("Chats agent export, import, templates and history", func() {
Expect(file["agent"]).To(HaveKeyWithValue("folder", Equal(map[string]any{"path": "", "mode": "read"})))
})
It("saves several folders, shows them in order and exports them as version 3", func() {
mkdir("alice", "/Projects/Acme")
mkdir("alice", "/Notes")
rec := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Multi",
"folders": []map[string]string{{"path": "/Projects/Acme", "mode": "write"}, {"path": "/Notes", "mode": "read"}}})
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
a := decodeJSON(rec)
Expect(a["folders"]).To(HaveLen(2))
Expect(a["folder"]).To(HaveKeyWithValue("path", "/Projects/Acme"), "folder is the first")
id := a["id"].(string)
file := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/agents/"+id+"/export", nil))
Expect(file).To(HaveKeyWithValue("version", BeNumerically("==", 3)))
Expect(file["agent"]).To(HaveKeyWithValue("folders", Equal([]any{
map[string]any{"path": "/Projects/Acme", "mode": "write"}, map[string]any{"path": "/Notes", "mode": "read"}})))
Expect(file["agent"]).NotTo(HaveKey("folder"))
mkdir("bob", "/Projects/Acme")
imp := decodeJSON(s.do("bob", http.MethodPost, "/api/chats/agents/import", file))
Expect(imp["agent"]).To(HaveKeyWithValue("folders", HaveLen(2)))
Expect(imp["warnings"]).To(ConsistOf("The folder /Notes was not found. Choose a folder before you use this agent."))
})
It("refuses nine folders and keeps the folders when a write has none", func() {
var nine []map[string]string
for i := 0; i < 9; i++ {
p := "/F" + string(rune('A'+i))
mkdir("alice", p)
nine = append(nine, map[string]string{"path": p, "mode": "read"})
}
Expect(s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Nine", "folders": nine}).Code).To(Equal(http.StatusBadRequest))
rec := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Eight", "folders": nine[:8]})
Expect(rec.Code).To(Equal(http.StatusCreated))
id := decodeJSON(rec)["id"].(string)
put := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Eight"})
Expect(decodeJSON(put)["folders"]).To(HaveLen(8))
cleared := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Eight", "folders": []any{}})
Expect(decodeJSON(cleared)).NotTo(HaveKey("folders"))
})
It("exports an agent without a folder as version 1", func() {
id := save("alice")
file := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/agents/"+id+"/export", nil))
@@ -248,7 +288,7 @@ var _ = Describe("Chats agent export, import, templates and history", func() {
Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String())
res := decodeJSON(imp)
Expect(res).NotTo(HaveKey("warnings"))
Expect(res["agent"]).To(HaveKeyWithValue("folder", Equal(map[string]any{"path": "/Projects/Acme", "name": "Acme", "mode": "read", "state": "ok"})))
Expect(res["agent"]).To(HaveKeyWithValue("folder", Equal(map[string]any{"path": "/Projects/Acme", "name": "Acme", "mode": "read", "effective": "read", "state": "ok"})))
})
It("imports a version 2 file whose folder is missing as unset with a warning", func() {
+24 -3
View File
@@ -54,9 +54,21 @@ type ChatsAgent struct {
Network string `json:"network"`
// 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"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Folder *ChatsAgentFolder `json:"folder,omitempty"`
// Folders are all the folders of the agent (at most 8), in order; Folder
// is the first of them. Absent for an agent that works on all your files.
Folders []ChatsAgentFolder `json:"folders,omitempty"`
// FolderNotice is set when shared folders do not fit the access limit of
// a chat of this agent and are left out.
FolderNotice *ChatsAgentFolderNotice `json:"folder_notice,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// ChatsAgentFolderNotice says how many shared folders a chat of the agent
// does not mount.
type ChatsAgentFolderNotice struct {
NotMounted int `json:"not_mounted"`
}
// ChatsAgentFolder is the folder of a saved agent as it is now. The server
@@ -73,6 +85,10 @@ type ChatsAgentFolder struct {
// Mode is read (the agent's chats can read the folder) or write (they can
// read and change it).
Mode string `json:"mode"`
// Effective is the mode the folder has in a chat: read for a shared
// folder, and the mode of the outer folder when the folder is inside
// another folder of the same agent.
Effective string `json:"effective,omitempty"`
// State is ok; missing when the folder was deleted or you can no longer
// read it; or unset when no folder is chosen yet (an imported agent whose
// folder was not found). An agent whose folder is missing or unset does
@@ -107,6 +123,11 @@ type ChatsAgentWrite struct {
// made. A missing, unchosen or non-home folder has an empty export path;
// import preserves its mode as unset and warns instead of clearing scope.
Folder *ChatsAgentFolderWrite `json:"folder,omitempty"`
// Folders chooses up to 8 folders, each with its own mode. When both are
// sent, folders wins. A write without folders and folder keeps the folders
// the agent has; "folders": [] clears them. Where a folder is inside
// another one, it gets the outer folder's mode.
Folders []ChatsAgentFolderWrite `json:"folders,omitempty"`
}
// ChatsAgentList is the response of the agent list endpoint.
+2 -2
View File
@@ -171,8 +171,8 @@ type ChatsMCPRef struct {
}
// ChatsAgentExport is a saved agent as a file. Kind is localai-agent. Version
// is 1, or 2 for an agent with a folder: a server that knows only version 1
// refuses that file instead of importing the agent without its folder. The
// is 1, 2 for an agent with one folder (folder), or 3 for an agent with
// several (folders): an older server refuses the file instead of importing the agent without its folder. The
// file holds the definition only: no ID, owner or time, and the folder as a
// path, never as the server's own reference to it.
type ChatsAgentExport struct {
+10 -5
View File
@@ -424,18 +424,23 @@ func (s *Service) liveFolder(ctx context.Context, p string, ag agents.Agent) (*f
func isSharedPath(p string) bool { return workspace.Within(p, sharedfolders.Root) }
// capModes applies the rule of nested folders: where one folder holds
// another, the outer mode caps the inner one. A folder inside another one
// gets the lower of the two modes, and the fence then needs no more than the
// outer root, which reaches the inner folder at the outer mode.
// another, the mode of the outer folder is the mode of the inner one. The
// store's fence gives a node the highest mode among the roots above it, so a
// read only folder inside a write folder cannot be enforced; the inner folder
// therefore takes the outer mode, in both directions, and the editor shows it.
func capModes(fs []scopedFolder) {
eff := make([]workspace.Mode, len(fs))
for i, f := range fs {
eff[i] = f.mode
outer := -1
for j, o := range fs {
if i != j && f.path != o.path && workspace.Within(f.path, o.path) {
eff[i] = min(eff[i], o.mode)
if i != j && f.path != o.path && workspace.Within(f.path, o.path) && (outer < 0 || len(o.path) < len(fs[outer].path)) {
outer = j
}
}
if outer >= 0 {
eff[i] = fs[outer].mode
}
}
for i := range fs {
fs[i].mode = eff[i]
+19
View File
@@ -278,3 +278,22 @@ func (s *Service) AgentFolderRows(ctx context.Context, user string, list []agent
}
return out
}
// SharedNotMounted counts the shared folders that a chat of the agent leaves
// out because they do not fit the access limit. It is 0 for an agent without
// folders or whose folders cannot be found.
func (s *Service) SharedNotMounted(ctx context.Context, user string, a agents.Agent) int {
if !a.HasFolder() {
return 0
}
p := principal(user)
sc, err := s.liveFolder(ctx, p, a)
if err != nil {
return 0
}
_, dropped, err := s.scopedRoots(ctx, userChatActor(p, ""), []*folderScope{sc})
if err != nil {
return 0
}
return dropped
}
+12 -2
View File
@@ -18,7 +18,9 @@ func (r *accessRig) multi(name string, rows ...studio.AgentFolderWrite) (agents.
return r.svc.SaveAgentWithFolders(r.ctx, alice, agents.Agent{Name: name}, &rows)
}
func rw(path, mode string) studio.AgentFolderWrite { return studio.AgentFolderWrite{Path: path, Mode: mode} }
func rw(path, mode string) studio.AgentFolderWrite {
return studio.AgentFolderWrite{Path: path, Mode: mode}
}
var _ = Describe("an agent with several folders", func() {
var r *accessRig
@@ -102,7 +104,7 @@ var _ = Describe("an agent with several folders", func() {
rows := r.svc.AgentFolderRows(r.ctx, alice, []agents.Agent{a})[a.ID]
Expect(rows[0].Effective).To(Equal("read"))
Expect(rows[1].Mode).To(Equal("write"))
Expect(rows[1].Effective).To(Equal("read"), "the outer mode caps the inner one")
Expect(rows[1].Effective).To(Equal("read"), "the outer mode is the inner mode")
act := r.access(r.chat(a.ID).ID).Actor()
_, err = r.ws.Put(r.ctx, act, r.home+"/Projects/Acme/new.txt", strings.NewReader("x"), workspace.PutOptions{})
Expect(err).To(HaveOccurred(), "the inner write is capped")
@@ -110,6 +112,14 @@ var _ = Describe("an agent with several folders", func() {
Expect(err).NotTo(HaveOccurred(), "the outer folder reads its children")
})
It("gives a read folder inside a write folder the write mode, and says so", func() {
a, err := r.multi("Nested", rw("/Projects", "write"), rw("/Projects/Acme", "read"))
Expect(err).NotTo(HaveOccurred())
rows := r.svc.AgentFolderRows(r.ctx, alice, []agents.Agent{a})[a.ID]
Expect(rows[1].Mode).To(Equal("read"))
Expect(rows[1].Effective).To(Equal("write"), "the fence cannot hold a read only folder inside a write folder")
})
It("stops a chat that has a deleted or unchosen row and names the row", func() {
a, err := r.multi("Two", rw("/Notes", "read"), rw("/Projects/Other", "write"))
Expect(err).NotTo(HaveOccurred())