Files
LocalAI/core/http/endpoints/studio/agents.go
T
Ettore Di Giacinto 24c837e453 feat(studio): grant built-in capabilities to a saved agent
A saved agent now has a Builtins list: the capabilities its owner gave
it (memory.write, profile.write, skills.write, pages.write,
dashboard.write, triggers.propose, triggers.run, talkback.channels,
talkback.new_channel, files.upload, act.files, act.addons, act.send,
act.spend and the opt-in group saved_agents). The list is empty by
default, and nothing turns a grant on for a template, an import or a
write that omits the field.

A chat of the agent holds the grants the user may use at the turn
(agent scope). A tool exists when the grant is held, the chat did not
switch its group off and the run can hold rights: a sub-agent or a run
that started by itself never does. propose_change and revert_change
now exist only for a chat that holds the grant of at least one kind,
and the schema lists only those kinds.

propose_change gains the kinds skill, page and dashboard. A skill is a
SKILL.md for the personal or the agent layer, and a skill with
locked: true is refused, also as a shadow of a locked personal skill.
A page is a set of files checked against the Pages rules and written
as one change set. The dashboard is a home.json checked against its
schema. All three are re-checked when the owner confirms or edits.

GET /api/chats/agents/builtin-grants lists each grant with its title,
description and sensitive flag. The agent record, the write body and
the export file carry builtins; an old file imports as before and an
unknown name is dropped in an import and refused in a write.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
2026-11-03 14:56:42 +00:00

209 lines
8.4 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), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Builtins: nz(a.Builtins), Created: a.Created, Updated: a.Updated}
}
// agentIn reads the body. A model the user may not use is refused here, so a
// 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,
BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn, Builtins: req.Builtins},
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)
}