From db596e1696b1fe17d13fc028cd4919447efefb84 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:01:51 +0000 Subject: [PATCH] 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 --- core/http/endpoints/studio/agents.go | 57 ++++++++++++++---- core/http/endpoints/studio/agentsfile.go | 60 +++++++++++++------ core/http/endpoints/studio/agentsfile_test.go | 19 ++++-- core/http/endpoints/studio/capacity_test.go | 13 +--- core/http/routes/studio_agents_test.go | 6 +- core/http/routes/studio_agentsfile_test.go | 44 +++++++++++++- core/schema/chats.go | 27 ++++++++- core/schema/chats_features.go | 4 +- core/services/studio/agentaccess.go | 15 +++-- core/services/studio/agentfolder.go | 19 ++++++ core/services/studio/agentmultifolder_test.go | 14 ++++- 11 files changed, 216 insertions(+), 62 deletions(-) diff --git a/core/http/endpoints/studio/agents.go b/core/http/endpoints/studio/agents.go index 035b28777e..7d5bec5b7f 100644 --- a/core/http/endpoints/studio/agents.go +++ b/core/http/endpoints/studio/agents.go @@ -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) } diff --git a/core/http/endpoints/studio/agentsfile.go b/core/http/endpoints/studio/agentsfile.go index 0d73b49edf..71a495634c 100644 --- a/core/http/endpoints/studio/agentsfile.go +++ b/core/http/endpoints/studio/agentsfile.go @@ -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.") } diff --git a/core/http/endpoints/studio/agentsfile_test.go b/core/http/endpoints/studio/agentsfile_test.go index aa0fe53830..7c0ef661f1 100644 --- a/core/http/endpoints/studio/agentsfile_test.go +++ b/core/http/endpoints/studio/agentsfile_test.go @@ -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"))) }) }) diff --git a/core/http/endpoints/studio/capacity_test.go b/core/http/endpoints/studio/capacity_test.go index 35a0bc5fc8..e634a55931 100644 --- a/core/http/endpoints/studio/capacity_test.go +++ b/core/http/endpoints/studio/capacity_test.go @@ -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") }) }) diff --git a/core/http/routes/studio_agents_test.go b/core/http/routes/studio_agents_test.go index 63769f3f8c..58612402d0 100644 --- a/core/http/routes/studio_agents_test.go +++ b/core/http/routes/studio_agents_test.go @@ -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() { diff --git a/core/http/routes/studio_agentsfile_test.go b/core/http/routes/studio_agentsfile_test.go index c9a767112f..17ba6b3d34 100644 --- a/core/http/routes/studio_agentsfile_test.go +++ b/core/http/routes/studio_agentsfile_test.go @@ -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() { diff --git a/core/schema/chats.go b/core/schema/chats.go index ff8ac1846d..3e4ba07ca9 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -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. diff --git a/core/schema/chats_features.go b/core/schema/chats_features.go index 61dff350a9..682f698326 100644 --- a/core/schema/chats_features.go +++ b/core/schema/chats_features.go @@ -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 { diff --git a/core/services/studio/agentaccess.go b/core/services/studio/agentaccess.go index 8da792fa1f..8a07b7cb20 100644 --- a/core/services/studio/agentaccess.go +++ b/core/services/studio/agentaccess.go @@ -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] diff --git a/core/services/studio/agentfolder.go b/core/services/studio/agentfolder.go index db2a76f952..85780efe86 100644 --- a/core/services/studio/agentfolder.go +++ b/core/services/studio/agentfolder.go @@ -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 +} diff --git a/core/services/studio/agentmultifolder_test.go b/core/services/studio/agentmultifolder_test.go index e6e223b06f..8b1207752b 100644 --- a/core/services/studio/agentmultifolder_test.go +++ b/core/services/studio/agentmultifolder_test.go @@ -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())