Files
LocalAI/core/http/endpoints/localai/agents.go
T
Stefan Walcz 5f5feea15a fix(agentpool): keep the conversation in the agent web chat (#12410)
* feat(agents): keep the web chat history between messages

The agent chat endpoint ran every message as a fresh job
(ag.Ask(WithText(message))), so a follow-up such as "add two days to
item 3 and recalculate" never saw the answer it referred to. Agents
then rebuilt their reply from scratch instead of changing it.

Use the agent's own conversation tracker, as the Telegram and Slack
connectors already do: send the earlier turns with the new message and
record successful answers. Failed, cancelled or empty runs are not
recorded, and the tracker drops a conversation after the agent's
last_message_duration of inactivity. The distributed (NATS) chat path is
unchanged.

Assisted-by: Claude:claude-opus-5-5 ginkgo
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(agents): keep each web chat conversation's history separate

Review on this PR: the first version kept the history in the agent's
conversation tracker under one fixed key per agent. The web UI keeps
several conversations per agent (New Chat, switching, Clear) and did not
send which one a message belongs to, so the hidden history mixed them:
a new chat received the previous chat's turns, and Clear only cleared
the screen.

The client now sends the earlier turns of the conversation it is
showing as `history` with POST /api/agents/:name/chat, and the server
keeps no web chat history of its own. Each conversation only ever sees
its own turns; New Chat and Clear start without history. The server
uses only user and assistant turns with text, bounded to the most
recent 40 turns and 64,000 characters. `history` is optional, so
existing callers keep the previous behaviour (no history); the
distributed (NATS) path does not forward it yet.

Tests: two conversations of one agent stay apart, an empty history
(New Chat or Clear) starts fresh, system/tool/empty turns are dropped,
the bounds keep the most recent turns; the UI helper that builds the
history from the visible messages has node --test coverage. Docs:
features/agents.md describes `history` and the web UI behaviour.

Assisted-by: Claude:claude-opus-5-5 ginkgo
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
2026-10-05 01:38:29 +02:00

494 lines
16 KiB
Go

package localai
import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"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
}
// decodedParam returns the named path parameter, URL-decoding it.
//
// Echo routes a request via URL.RawPath whenever the path contains
// percent-encoded characters (e.g. %3A for ':'), and in that case stores the
// matched path-param value raw/escaped. Agent and collection names carry a
// "legacy-api-key:" prefix, so the ':' arrives as %3A and the raw param no
// longer matches the stored name. Callers must unescape before lookups.
// Falls back to the raw value if it isn't valid percent-encoding.
func decodedParam(c echo.Context, name string) string {
raw := c.Param(name)
if decoded, err := url.PathUnescape(raw); err == nil {
return decoded
}
return raw
}
// 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 := decodedParam(c, "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 := decodedParam(c, "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 := decodedParam(c, "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 := decodedParam(c, "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, decodedParam(c, "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, decodedParam(c, "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 := decodedParam(c, "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 := decodedParam(c, "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 := decodedParam(c, "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 := decodedParam(c, "name")
var payload struct {
Message string `json:"message"`
// History is the earlier turns of the conversation the client is
// showing; omitted or empty starts a conversation without history.
History []agentpool.ChatHistoryMessage `json:"history"`
}
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, payload.History...)
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 := decodedParam(c, "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 := decodedParam(c, "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)
}
}