mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
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
279 lines
13 KiB
Go
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)
|
|
}
|