Files
LocalAI/core/http/endpoints/studio/agentsfile.go
T
Ettore Di Giacinto adbb71cfdf feat(memory): add Always confirm, an agent save rule and a messaging row
A person can now choose four save levels on the Memory tab: Save it and
tell me, Ask me first (the default), Always confirm and Never. Always
confirm is like ask, and nothing can loosen it: no agent or messaging
setting overrides it, and the model cannot undo or forget a note by
itself (revert_change is refused, and the person uses the Undo).

One function, effectiveSaveLevel, combines the rules. The strictest wins,
in the order auto, ask, always, never. The inputs are the level of the
person, the rule of the saved agent, the messaging row and the chat.
A table test covers every combination.

A saved agent has an optional memory_save field: always or never. It can
only be stricter than the person, so auto is not offered and the API
refuses it. A write without the field keeps the stored rule, and use_mine
clears it. It is stored by both stores and is part of an agent export and
import. The agent form shows three cards under When it wants to save a
note.

The new row In messaging apps (messaging_save on GET and PUT
/api/chats/memory) takes ask (the default), paired_private or never. With
paired_private a note is saved at once only when the sender is paired and
the chat is one-to-one, and it goes to the notes of that conversation,
with an Undo. The limits stay: five notes a reply, the size limit, and a
note that looks like a secret still waits. The row cannot loosen the level
of the person when that is always or never. A setting that cannot be read
reads as ask.

The connector turn now carries whether the chat is one-to-one and whether
the sender is paired, in the turn grant and in the worker JSON. Both read
as false when unknown. The Telegram, Slack and Discord transports already
set Direct; new specs pin it for private, group and supergroup chats,
direct messages, channels, group conversations and threads.

A note from a messaging chat goes to the conversation first. It reaches the
notes of the agent or of the person only from a paired sender, only when
the memory choice of the agent allows that scope, and only after that
sender confirms it. Under Always confirm only the paired sender who asked
can confirm. An agent whose memory is None still saves nothing.

The script of the confirm-in-chat specs now proposes a note for the
conversation, because a sender who is only listed cannot write to the
notes of the person.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-04 19:45:08 +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, Memory: a.Memory, MemorySave: a.MemorySave,
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, Memory: def.Memory, MemorySave: def.MemorySave,
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)
}