Files
LocalAI/core/http/endpoints/studio/agentsfile.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

279 lines
13 KiB
Go

package studio
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"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"
)
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, 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.
// Version 4 is an agent with switches of built-in tool groups, for the
// same reason: an older server would drop them and give the agent every
// tool.
maxAgentFileVersion = 4
)
// agentFileVersion is the version an export writes: 1 for an agent without a
// folder or tool switches, so older servers keep importing it, 2 for one with
// one folder, 3 for one with several, and 4 for one with tool switches.
func agentFileVersion(folders int, hasSwitches bool) int {
switch {
case hasSwitches:
return 4
case folders > 1:
return 3
case folders == 1:
return 2
}
return 1
}
// 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 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 with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. 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"
// @Router /api/chats/agents/{id}/export [get]
func (h *Handlers) ExportAgent(c echo.Context) error {
a, err := h.svc.Agent(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failFor(c, err)
}
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,
BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn, Builtins: a.Builtins}
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)), f.Node); live {
exportPath = h.svc.Files().Rel(h.svc.Principal(userID(c)), abs)
}
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(len(a.FolderList()), len(a.BuiltinsOff)+len(a.BuiltinsOn) > 0), Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w})
}
// fileSafe keeps a name usable as a file name in a header.
func fileSafe(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
if b.Len() == 0 {
return "agent"
}
return b.String()
}
// ImportAgent saves an agent from a file.
// @Summary Import a saved agent.
// @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"
// @Param file formData file false "the agent file (multipart)"
// @Success 201 {object} schema.ChatsAgentImportResult "The new agent and what the import changed"
// @Router /api/chats/agents/import [post]
func (h *Handlers) ImportAgent(c echo.Context) error {
var raw []byte
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") {
fh, err := c.FormFile("file")
if err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "send the agent file as the file field")
}
f, err := fh.Open()
if err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the file could not be read")
}
defer func() { _ = f.Close() }()
if raw, err = io.ReadAll(io.LimitReader(f, maxAgentImportBody+1)); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the file could not be read")
}
} else {
var err error
if raw, err = io.ReadAll(io.LimitReader(c.Request().Body, maxAgentImportBody+1)); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the body could not be read")
}
}
if len(raw) > maxAgentImportBody {
return fail(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "the agent file is larger than 1 MiB")
}
def, err := readAgentFile(raw)
if err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
var warnings []string
if def.Model != "" && h.models != nil {
switch merr := h.models.Check(c.Request().Context(), auth.GetUser(c), def.Model); {
case errors.Is(merr, ErrUnknownModel), errors.Is(merr, ErrModelNotAllowed):
warnings = append(warnings, fmt.Sprintf("the model %q is not available to you here, so the agent has no model", def.Model))
def.Model = ""
case merr != nil:
return failFor(c, merr)
}
}
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,
BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn, Builtins: def.Builtins}
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))
}
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.")
}
}
return c.JSON(http.StatusCreated, schema.ChatsAgentImportResult{Agent: h.agentFull(c, saved), Warnings: warnings})
}
// readAgentFile reads an export file, or a bare agent definition.
func readAgentFile(raw []byte) (schema.ChatsAgentWrite, error) {
return readAgentFileUpTo(raw, maxAgentFileVersion)
}
// readAgentFileUpTo reads an export file of version 1 to newest, or a bare
// definition. With newest 1 it is the check of a server from before folders.
func readAgentFileUpTo(raw []byte, newest int) (schema.ChatsAgentWrite, error) {
var doc struct {
Version int `json:"version"`
Kind string `json:"kind"`
Agent *schema.ChatsAgentWrite `json:"agent"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return schema.ChatsAgentWrite{}, errors.New("the body is not a valid agent file")
}
if doc.Agent != nil {
if doc.Version < 1 || doc.Version > newest || (doc.Kind != "" && doc.Kind != agentExportKind) {
return schema.ChatsAgentWrite{}, errors.New("this agent file has a version or kind this server does not know")
}
return *doc.Agent, nil
}
var bare schema.ChatsAgentWrite
if err := json.Unmarshal(raw, &bare); err != nil || strings.TrimSpace(bare.Name) == "" {
return schema.ChatsAgentWrite{}, errors.New("the body is not a valid agent file")
}
return bare, nil
}
// AgentTemplates lists the starter agents.
// @Summary List the starter agents.
// @Description Ready-made definitions to start from. They grant nothing; save one, then grant it tools. POST /api/chats/agents/templates/{id}/create saves one for you.
// @Tags chats
// @Success 200 {object} schema.ChatsAgentTemplateList "The starter agents"
// @Router /api/chats/agents/templates [get]
func (h *Handlers) AgentTemplates(c echo.Context) error {
out := schema.ChatsAgentTemplateList{Templates: []schema.ChatsAgentTemplate{}}
for _, t := range agents.Templates() {
out.Templates = append(out.Templates, schema.ChatsAgentTemplate{ID: t.ID, Name: t.Name, Description: t.Description, Instructions: t.Instructions})
}
return c.JSON(http.StatusOK, out)
}
// BuiltinGrants lists the builtin grants an agent can be given.
// @Summary List the builtin grants of a saved agent.
// @Description Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.
// @Tags chats
// @Success 200 {object} schema.ChatsBuiltinGrantList "The grants, in display order"
// @Router /api/chats/agents/builtin-grants [get]
func (h *Handlers) BuiltinGrants(c echo.Context) error {
out := schema.ChatsBuiltinGrantList{Grants: []schema.ChatsBuiltinGrant{}}
for _, g := range h.svc.BuiltinGrants() {
out.Grants = append(out.Grants, schema.ChatsBuiltinGrant{Name: g.Name, Title: g.Title, Description: g.Description, Sensitive: g.Sensitive})
}
return c.JSON(http.StatusOK, out)
}
// CreateAgentFromTemplate saves a starter agent.
// @Summary Save a starter agent as your own.
// @Tags chats
// @Param id path string true "the template's id"
// @Param request body schema.ChatsAgentTemplateUse false "a name to use instead of the template's"
// @Success 201 {object} schema.ChatsAgent "The saved agent"
// @Router /api/chats/agents/templates/{id}/create [post]
func (h *Handlers) CreateAgentFromTemplate(c echo.Context) error {
var req schema.ChatsAgentTemplateUse
if c.Request().ContentLength != 0 {
if err := c.Bind(&req); err != nil {
return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON")
}
}
a, err := h.svc.CreateAgentFromTemplate(c.Request().Context(), userID(c), c.Param("id"), req.Name)
if err != nil {
return failFor(c, err)
}
return c.JSON(http.StatusCreated, agentOut(a))
}
// AgentRuns returns the run history of a saved agent.
// @Summary Show what an agent has run.
// @Description Lists your chats that ran with the agent, newest first: the ones you had, the ones its triggers started and the ones its connectors started, with how each ended. Open one with GET /api/chats/{chat_id}.
// @Tags chats
// @Param id path string true "the agent's id"
// @Param limit query int false "most runs to list (default 50, at most 200)"
// @Success 200 {object} schema.ChatsAgentHistory "The runs and the counts"
// @Router /api/chats/agents/{id}/runs [get]
func (h *Handlers) AgentRuns(c echo.Context) error {
hist, err := h.svc.AgentRuns(c.Request().Context(), userID(c), c.Param("id"), intParam(c, "limit", defaultListLimit, maxListLimit))
if err != nil {
return failFor(c, err)
}
out := schema.ChatsAgentHistory{Runs: make([]schema.ChatsAgentRun, 0, len(hist.Runs)), Total: hist.Total, Failed: hist.Failed, Running: hist.Running}
for _, r := range hist.Runs {
out.Runs = append(out.Runs, schema.ChatsAgentRun{ChatID: r.Chat.ID, Title: r.Chat.Title, Model: r.Chat.Model, Origin: r.Chat.Origin,
OriginID: r.Chat.OriginID, Created: r.Chat.Created, Updated: r.Chat.Updated, Running: r.Turn.Running, LastTurn: string(r.Turn.Last)})
}
return c.JSON(http.StatusOK, out)
}