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