Files
LocalAI/core/http/endpoints/studio/agents.go
T
Ettore Di Giacinto db596e1696 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
2026-11-03 14:02:02 +00:00

208 lines
8.2 KiB
Go

package studio
import (
"errors"
"net/http"
"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"
)
// folderOut is the folder of an agent as the API shows it, or nil for an
// agent that works on all the user's files.
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, 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.
func folderIn(f *schema.ChatsAgentFolderWrite) *studiosvc.AgentFolderWrite {
if f == nil {
return nil
}
return &studiosvc.AgentFolderWrite{Path: f.Path, Mode: f.Mode}
}
// 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)
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 {
return []string{}
}
return l
}
return schema.ChatsAgent{ID: a.ID, Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model,
MCPServers: nz(a.MCPServers), Addons: nz(a.Addons), Collections: nz(a.Collections), Skills: nz(a.Skills),
Network: networkOf(a.Network), Created: a.Created, Updated: a.Updated}
}
// agentIn reads the body. A model the user may not use is refused here, so a
// 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, 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")
}
if req.Model != "" && h.models != nil {
switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), req.Model); {
case errors.Is(err, ErrUnknownModel):
return a, nil, false, fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model")
case errors.Is(err, ErrModelNotAllowed):
return a, nil, false, fail(c, http.StatusForbidden, "authorization_error", "you may not use this model")
case err != nil:
return a, nil, false, failFor(c, err)
}
}
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},
foldersIn(req), true, nil
}
// networkOf is the network preset of an agent as the API shows it: off when
// none was set.
func networkOf(n string) string {
if n == "" {
return "off"
}
return n
}
// ListAgents lists the saved agents.
// @Summary List your saved agents.
// @Tags chats
// @Success 200 {object} schema.ChatsAgentList "Your agents, sorted by name"
// @Router /api/chats/agents [get]
func (h *Handlers) ListAgents(c echo.Context) error {
list, err := h.svc.Agents(c.Request().Context(), userID(c))
if err != nil {
return failFor(c, err)
}
out := schema.ChatsAgentList{Agents: make([]schema.ChatsAgent, 0, len(list))}
rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), list)
for _, a := range list {
o := agentOut(a)
h.addFolders(c, &o, a, rows[a.ID])
out.Agents = append(out.Agents, o)
}
return c.JSON(http.StatusOK, out)
}
// 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. 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, folders, ok, err := h.agentIn(c)
if !ok {
return err
}
saved, err := h.svc.SaveAgentWithFolders(c.Request().Context(), userID(c), a, folders)
if err != nil {
return failFor(c, err)
}
return c.JSON(http.StatusCreated, h.agentFull(c, saved))
}
// GetAgent reads a saved agent.
// @Summary Read a saved agent.
// @Tags chats
// @Param id path string true "the agent's id"
// @Success 200 {object} schema.ChatsAgent "The agent"
// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's"
// @Router /api/chats/agents/{id} [get]
func (h *Handlers) GetAgent(c echo.Context) error {
a, err := h.svc.Agent(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failFor(c, err)
}
return c.JSON(http.StatusOK, h.agentFull(c, a))
}
// UpdateAgent replaces a saved agent.
// @Summary Replace a saved agent.
// @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"
// @Success 200 {object} schema.ChatsAgent "The saved agent"
// @Failure 400 {object} schema.ErrorResponse "The definition or the folder is not valid"
// @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, folders, ok, err := h.agentIn(c)
if !ok {
return err
}
a.ID = c.Param("id")
saved, err := h.svc.SaveAgentWithFolders(c.Request().Context(), userID(c), a, folders)
if err != nil {
return failFor(c, err)
}
return c.JSON(http.StatusOK, h.agentFull(c, saved))
}
// DeleteAgent deletes a saved agent.
// @Summary Delete a saved agent.
// @Description Chats that ran with it keep their history; their next turn runs without the agent's tools.
// @Tags chats
// @Param id path string true "the agent's id"
// @Success 204 "Deleted"
// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's"
// @Router /api/chats/agents/{id} [delete]
func (h *Handlers) DeleteAgent(c echo.Context) error {
if err := h.svc.DeleteAgent(c.Request().Context(), userID(c), c.Param("id")); err != nil {
return failFor(c, err)
}
return c.NoContent(http.StatusNoContent)
}