Files
LocalAI/core/http/endpoints/localai/agents.go
Ettore Di Giacinto 59108fbe32 feat: add distributed mode (#9124)
* feat: add distributed mode (experimental)

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix data races, mutexes, transactions

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix events and tool stream in agent chat

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* use ginkgo

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(cron): compute correctly time boundaries avoiding re-triggering

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not flood of healthy checks

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* do not list obvious backends as text backends

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactoring and consolidation

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop redundant healthcheck

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* enhancements, refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-30 00:47:27 +02:00

474 lines
15 KiB
Go

package localai
import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAGI/core/state"
coreTypes "github.com/mudler/LocalAGI/core/types"
agiServices "github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/agentpool"
"github.com/mudler/LocalAI/core/services/agents"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/xlog"
)
// getUserID extracts the scoped user ID from the request context.
// Returns empty string when auth is not active (backward compat).
func getUserID(c echo.Context) string {
user := auth.GetUser(c)
if user == nil {
return ""
}
return user.ID
}
// isAdminUser returns true if the authenticated user has admin role.
func isAdminUser(c echo.Context) bool {
user := auth.GetUser(c)
return user != nil && user.Role == auth.RoleAdmin
}
// wantsAllUsers returns true if the request has ?all_users=true and the user is admin.
func wantsAllUsers(c echo.Context) bool {
return c.QueryParam("all_users") == "true" && isAdminUser(c)
}
// effectiveUserID returns the user ID to scope operations to.
// SECURITY: Only admins and agent-worker service accounts may supply
// ?user_id=<id> to operate on another user's resources. Agent-worker users are
// created exclusively server-side during node registration and need to access
// collections on behalf of the user whose agent they are executing.
// Regular callers always get their own ID regardless of query params.
func effectiveUserID(c echo.Context) string {
if targetUID := c.QueryParam("user_id"); targetUID != "" && canImpersonateUser(c) {
if callerID := getUserID(c); callerID != targetUID {
xlog.Info("User impersonation", "caller", callerID, "target", targetUID, "path", c.Path())
}
return targetUID
}
return getUserID(c)
}
// canImpersonateUser returns true if the caller is allowed to use ?user_id= to
// scope operations to another user. Allowed for admins and agent-worker service
// accounts (ProviderAgentWorker is set server-side during node registration and
// cannot be self-assigned).
func canImpersonateUser(c echo.Context) bool {
user := auth.GetUser(c)
if user == nil {
return false
}
return user.Role == auth.RoleAdmin || user.Provider == auth.ProviderAgentWorker
}
func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
statuses := svc.ListAgentsForUser(userID)
agents := slices.Sorted(maps.Keys(statuses))
resp := map[string]any{
"agents": agents,
"agentCount": len(agents),
"actions": len(agiServices.AvailableActions),
"connectors": len(agiServices.AvailableConnectors),
"statuses": statuses,
}
if hubURL := svc.AgentHubURL(); hubURL != "" {
resp["agent_hub_url"] = hubURL
}
// Admin cross-user aggregation
if wantsAllUsers(c) {
grouped := svc.ListAllAgentsGrouped()
userGroups := map[string]any{}
for uid, agentList := range grouped {
if uid == userID || uid == "" {
continue
}
userGroups[uid] = map[string]any{"agents": agentList}
}
if len(userGroups) > 0 {
resp["user_groups"] = userGroups
}
}
return c.JSON(http.StatusOK, resp)
}
}
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var cfg state.AgentConfig
if err := c.Bind(&cfg); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.CreateAgentForUser(userID, &cfg); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
}
}
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
statuses := svc.ListAgentsForUser(userID)
active, exists := statuses[name]
if !exists {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
}
return c.JSON(http.StatusOK, map[string]any{"active": active})
}
}
func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
var cfg state.AgentConfig
if err := c.Bind(&cfg); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.UpdateAgentForUser(userID, name, &cfg); err != nil {
if strings.Contains(err.Error(), "not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
if err := svc.DeleteAgentForUser(userID, name); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
cfg := svc.GetAgentConfigForUser(userID, name)
if cfg == nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
}
return c.JSON(http.StatusOK, cfg)
}
}
func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
if err := svc.PauseAgentForUser(userID, c.Param("name")); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
if err := svc.ResumeAgentForUser(userID, c.Param("name")); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
history := svc.GetAgentStatusForUser(userID, name)
if history == nil {
return c.JSON(http.StatusOK, map[string]any{
"Name": name,
"History": []string{},
})
}
entries := []string{}
for i := len(history.Results()) - 1; i >= 0; i-- {
h := history.Results()[i]
actionName := ""
if h.ActionCurrentState.Action != nil {
actionName = h.ActionCurrentState.Action.Definition().Name.String()
}
entries = append(entries, fmt.Sprintf("Reasoning: %s\nAction taken: %s\nParameters: %+v\nResult: %s",
h.Reasoning,
actionName,
h.ActionCurrentState.Params,
h.Result))
}
return c.JSON(http.StatusOK, map[string]any{
"Name": name,
"History": entries,
})
}
}
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
history, err := svc.GetAgentObservablesForUser(userID, name)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if history == nil {
history = []json.RawMessage{}
}
return c.JSON(http.StatusOK, map[string]any{
"Name": name,
"History": history,
})
}
}
func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
if err := svc.ClearAgentObservablesForUser(userID, name); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true})
}
}
func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
var payload struct {
Message string `json:"message"`
}
if err := c.Bind(&payload); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request format"})
}
message := strings.TrimSpace(payload.Message)
if message == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"})
}
messageID, err := svc.ChatForUser(userID, name, message)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusAccepted, map[string]any{
"status": "message_received",
"message_id": messageID,
})
}
}
func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
// Try local SSE manager first
manager := svc.GetSSEManagerForUser(userID, name)
if manager != nil {
return agentpool.HandleSSE(c, manager)
}
// Fall back to distributed EventBridge SSE
var bridge *agents.EventBridge
if d := app.Distributed(); d != nil {
bridge = d.AgentBridge
}
if bridge != nil {
return bridge.HandleSSE(c, name, userID)
}
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
}
}
func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
return c.JSON(http.StatusOK, svc.GetConfigMetaResult())
}
}
func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
data, err := svc.ExportAgentForUser(userID, name)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.json", name))
return c.JSONBlob(http.StatusOK, data)
}
}
func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
// Try multipart form file first
file, err := c.FormFile("file")
if err == nil {
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to open file"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"})
}
if err := svc.ImportAgentForUser(userID, data); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
}
// Try JSON body
var cfg state.AgentConfig
if err := json.NewDecoder(c.Request().Body).Decode(&cfg); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request: provide a file or JSON body"})
}
data, err := json.Marshal(&cfg)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.ImportAgentForUser(userID, data); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
}
}
// --- Actions ---
func ListActionsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
return c.JSON(http.StatusOK, map[string]any{
"actions": svc.ListAvailableActions(),
})
}
}
func GetActionDefinitionEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
actionName := c.Param("name")
var payload struct {
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
payload.Config = map[string]string{}
}
def, err := svc.GetActionDefinition(actionName, payload.Config)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, def)
}
}
func ExecuteActionEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
actionName := c.Param("name")
var payload struct {
Config map[string]string `json:"config"`
Params coreTypes.ActionParams `json:"params"`
}
if err := json.NewDecoder(c.Request().Body).Decode(&payload); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
result, err := svc.ExecuteAction(c.Request().Context(), actionName, payload.Config, payload.Params)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result)
}
}
func AgentFileEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
requestedPath := c.QueryParam("path")
if requestedPath == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "no file path specified"})
}
// Resolve the real path (follows symlinks, eliminates ..)
resolved, err := filepath.EvalSymlinks(filepath.Clean(requestedPath))
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Determine the allowed outputs directory — scoped to the user when auth is active
allowedDir := svc.OutputsDir()
user := auth.GetUser(c)
if user != nil {
allowedDir = filepath.Join(allowedDir, user.ID)
}
allowedDirResolved, _ := filepath.EvalSymlinks(filepath.Clean(allowedDir))
if utils.InTrustedRoot(resolved, allowedDirResolved) != nil {
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
}
info, err := os.Stat(resolved)
if err != nil || info.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
return c.File(resolved)
}
}