diff --git a/core/application/agent_jobs.go b/core/application/agent_jobs.go new file mode 100644 index 000000000000..b4e28aa5a1a1 --- /dev/null +++ b/core/application/agent_jobs.go @@ -0,0 +1,43 @@ +package application + +import ( + "time" + + "github.com/mudler/LocalAI/core/services" + "github.com/rs/zerolog/log" +) + +// RestartAgentJobService restarts the agent job service with current ApplicationConfig settings +func (a *Application) RestartAgentJobService() error { + a.agentJobMutex.Lock() + defer a.agentJobMutex.Unlock() + + // Stop existing service if running + if a.agentJobService != nil { + if err := a.agentJobService.Stop(); err != nil { + log.Warn().Err(err).Msg("Error stopping agent job service") + } + // Wait a bit for shutdown to complete + time.Sleep(200 * time.Millisecond) + } + + // Create new service instance + agentJobService := services.NewAgentJobService( + a.ApplicationConfig(), + a.ModelLoader(), + a.ModelConfigLoader(), + a.TemplatesEvaluator(), + ) + + // Start the service + err := agentJobService.Start(a.ApplicationConfig().Context) + if err != nil { + log.Error().Err(err).Msg("Failed to start agent job service") + return err + } + + a.agentJobService = agentJobService + log.Info().Msg("Agent job service restarted") + return nil +} + diff --git a/core/application/application.go b/core/application/application.go index 24c53fcbae65..3e241c698f55 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -17,11 +17,13 @@ type Application struct { startupConfig *config.ApplicationConfig // Stores original config from env vars (before file loading) templatesEvaluator *templates.Evaluator galleryService *services.GalleryService + agentJobService *services.AgentJobService watchdogMutex sync.Mutex watchdogStop chan bool p2pMutex sync.Mutex p2pCtx context.Context p2pCancel context.CancelFunc + agentJobMutex sync.Mutex } func newApplication(appConfig *config.ApplicationConfig) *Application { @@ -53,6 +55,10 @@ func (a *Application) GalleryService() *services.GalleryService { return a.galleryService } +func (a *Application) AgentJobService() *services.AgentJobService { + return a.agentJobService +} + // StartupConfig returns the original startup configuration (from env vars, before file loading) func (a *Application) StartupConfig() *config.ApplicationConfig { return a.startupConfig @@ -67,5 +73,20 @@ func (a *Application) start() error { a.galleryService = galleryService + // Initialize agent job service + agentJobService := services.NewAgentJobService( + a.ApplicationConfig(), + a.ModelLoader(), + a.ModelConfigLoader(), + a.TemplatesEvaluator(), + ) + + err = agentJobService.Start(a.ApplicationConfig().Context) + if err != nil { + return err + } + + a.agentJobService = agentJobService + return nil } diff --git a/core/application/config_file_watcher.go b/core/application/config_file_watcher.go index 0129828cac5f..999d29aec279 100644 --- a/core/application/config_file_watcher.go +++ b/core/application/config_file_watcher.go @@ -43,6 +43,8 @@ func newConfigFileHandler(appConfig *config.ApplicationConfig) configFileHandler if err != nil { log.Error().Err(err).Str("file", "runtime_settings.json").Msg("unable to register config file handler") } + // Note: agent_tasks.json and agent_jobs.json are handled by AgentJobService directly + // The service watches and reloads these files internally return c } @@ -206,6 +208,7 @@ type runtimeSettings struct { AutoloadGalleries *bool `json:"autoload_galleries,omitempty"` AutoloadBackendGalleries *bool `json:"autoload_backend_galleries,omitempty"` ApiKeys *[]string `json:"api_keys,omitempty"` + AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"` } func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHandler { @@ -234,6 +237,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand envFederated := appConfig.Federated == startupAppConfig.Federated envAutoloadGalleries := appConfig.AutoloadGalleries == startupAppConfig.AutoloadGalleries envAutoloadBackendGalleries := appConfig.AutoloadBackendGalleries == startupAppConfig.AutoloadBackendGalleries + envAgentJobRetentionDays := appConfig.AgentJobRetentionDays == startupAppConfig.AgentJobRetentionDays if len(fileContent) > 0 { var settings runtimeSettings @@ -328,6 +332,9 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand // Replace all runtime keys with what's in runtime_settings.json appConfig.ApiKeys = append(envKeys, runtimeKeys...) } + if settings.AgentJobRetentionDays != nil && !envAgentJobRetentionDays { + appConfig.AgentJobRetentionDays = *settings.AgentJobRetentionDays + } // If watchdog is enabled via file but not via env, ensure WatchDog flag is set if !envWatchdogIdle && !envWatchdogBusy { diff --git a/core/application/startup.go b/core/application/startup.go index 6186424e5c4f..2bbbdfac79cd 100644 --- a/core/application/startup.go +++ b/core/application/startup.go @@ -226,6 +226,7 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) { WatchdogBusyTimeout *string `json:"watchdog_busy_timeout,omitempty"` SingleBackend *bool `json:"single_backend,omitempty"` ParallelBackendRequests *bool `json:"parallel_backend_requests,omitempty"` + AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"` } if err := json.Unmarshal(fileContent, &settings); err != nil { @@ -289,6 +290,12 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) { options.ParallelBackendRequests = *settings.ParallelBackendRequests } } + if settings.AgentJobRetentionDays != nil { + // Only apply if current value is default (0), suggesting it wasn't set from env var + if options.AgentJobRetentionDays == 0 { + options.AgentJobRetentionDays = *settings.AgentJobRetentionDays + } + } if !options.WatchDogIdle && !options.WatchDogBusy { if settings.WatchdogEnabled != nil && *settings.WatchdogEnabled { options.WatchDog = true diff --git a/core/cli/run.go b/core/cli/run.go index a1dc0e1c175f..3cc77baf1469 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -75,6 +75,7 @@ type RunCMD struct { DisableGalleryEndpoint bool `env:"LOCALAI_DISABLE_GALLERY_ENDPOINT,DISABLE_GALLERY_ENDPOINT" help:"Disable the gallery endpoints" group:"api"` MachineTag string `env:"LOCALAI_MACHINE_TAG,MACHINE_TAG" help:"Add Machine-Tag header to each response which is useful to track the machine in the P2P network" group:"api"` LoadToMemory []string `env:"LOCALAI_LOAD_TO_MEMORY,LOAD_TO_MEMORY" help:"A list of models to load into memory at startup" group:"models"` + AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Number of days to keep agent job history (default: 30)" group:"api"` Version bool } @@ -129,6 +130,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { config.WithLoadToMemory(r.LoadToMemory), config.WithMachineTag(r.MachineTag), config.WithAPIAddress(r.Address), + config.WithAgentJobRetentionDays(r.AgentJobRetentionDays), config.WithTunnelCallback(func(tunnels []string) { tunnelEnvVar := strings.Join(tunnels, ",") // TODO: this is very specific to llama.cpp, we should have a more generic way to set the environment variable diff --git a/core/config/application_config.go b/core/config/application_config.go index 9a9a8171c1e8..6f94a04a496b 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -70,15 +70,18 @@ type ApplicationConfig struct { TunnelCallback func(tunnels []string) DisableRuntimeSettings bool + + AgentJobRetentionDays int // Default: 30 days } type AppOption func(*ApplicationConfig) func NewApplicationConfig(o ...AppOption) *ApplicationConfig { opt := &ApplicationConfig{ - Context: context.Background(), - UploadLimitMB: 15, - Debug: true, + Context: context.Background(), + UploadLimitMB: 15, + Debug: true, + AgentJobRetentionDays: 30, // Default: 30 days } for _, oo := range o { oo(opt) @@ -333,6 +336,12 @@ func WithApiKeys(apiKeys []string) AppOption { } } +func WithAgentJobRetentionDays(days int) AppOption { + return func(o *ApplicationConfig) { + o.AgentJobRetentionDays = days + } +} + func WithEnforcedPredownloadScans(enforced bool) AppOption { return func(o *ApplicationConfig) { o.EnforcePredownloadScans = enforced diff --git a/core/http/app.go b/core/http/app.go index a5ce91e42566..b12f66c315e1 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -205,7 +205,7 @@ func API(application *application.Application) (*echo.Echo, error) { opcache = services.NewOpCache(application.GalleryService()) } - routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator()) + routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application) routes.RegisterOpenAIRoutes(e, requestExtractor, application) if !application.ApplicationConfig().DisableWebUI { routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application) diff --git a/core/http/app_test.go b/core/http/app_test.go index 362a6bc69de9..a733fcdd645b 100644 --- a/core/http/app_test.go +++ b/core/http/app_test.go @@ -210,6 +210,41 @@ func postRequestResponseJSON[B1 any, B2 any](url string, reqJson *B1, respJson * return json.Unmarshal(body, respJson) } +func putRequestJSON[B any](url string, bodyJson *B) error { + payload, err := json.Marshal(bodyJson) + if err != nil { + return err + } + + GinkgoWriter.Printf("PUT %s: %s\n", url, string(payload)) + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", bearerKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + return nil +} + func postInvalidRequest(url string) (error, int) { req, err := http.NewRequest("POST", url, bytes.NewBufferString("invalid request")) @@ -1194,6 +1229,138 @@ parameters: Expect(findRespBody.Similarities[i]).To(BeNumerically("<=", 1)) } }) + + Context("Agent Jobs", Label("agent-jobs"), func() { + It("creates and manages tasks", func() { + // Create a task + taskBody := map[string]interface{}{ + "name": "Test Task", + "description": "Test Description", + "model": "testmodel.ggml", + "prompt": "Hello {{.name}}", + "enabled": true, + } + + var createResp map[string]interface{} + err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp) + Expect(err).ToNot(HaveOccurred()) + Expect(createResp["id"]).ToNot(BeEmpty()) + taskID := createResp["id"].(string) + + // Get the task + var task schema.Task + resp, err := http.Get("http://127.0.0.1:9090/api/agent/tasks/" + taskID) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + body, _ := io.ReadAll(resp.Body) + json.Unmarshal(body, &task) + Expect(task.Name).To(Equal("Test Task")) + + // List tasks + resp, err = http.Get("http://127.0.0.1:9090/api/agent/tasks") + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + var tasks []schema.Task + body, _ = io.ReadAll(resp.Body) + json.Unmarshal(body, &tasks) + Expect(len(tasks)).To(BeNumerically(">=", 1)) + + // Update task + taskBody["name"] = "Updated Task" + err = putRequestJSON("http://127.0.0.1:9090/api/agent/tasks/"+taskID, &taskBody) + Expect(err).ToNot(HaveOccurred()) + + // Verify update + resp, err = http.Get("http://127.0.0.1:9090/api/agent/tasks/" + taskID) + Expect(err).ToNot(HaveOccurred()) + body, _ = io.ReadAll(resp.Body) + json.Unmarshal(body, &task) + Expect(task.Name).To(Equal("Updated Task")) + + // Delete task + req, _ := http.NewRequest("DELETE", "http://127.0.0.1:9090/api/agent/tasks/"+taskID, nil) + req.Header.Set("Authorization", bearerKey) + resp, err = http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + }) + + It("executes and monitors jobs", func() { + // Create a task first + taskBody := map[string]interface{}{ + "name": "Job Test Task", + "model": "testmodel.ggml", + "prompt": "Say hello", + "enabled": true, + } + + var createResp map[string]interface{} + err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp) + Expect(err).ToNot(HaveOccurred()) + taskID := createResp["id"].(string) + + // Execute a job + jobBody := map[string]interface{}{ + "task_id": taskID, + "parameters": map[string]string{}, + } + + var jobResp schema.JobExecutionResponse + err = postRequestResponseJSON("http://127.0.0.1:9090/api/agent/jobs/execute", &jobBody, &jobResp) + Expect(err).ToNot(HaveOccurred()) + Expect(jobResp.JobID).ToNot(BeEmpty()) + jobID := jobResp.JobID + + // Get job status + var job schema.Job + resp, err := http.Get("http://127.0.0.1:9090/api/agent/jobs/" + jobID) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + body, _ := io.ReadAll(resp.Body) + json.Unmarshal(body, &job) + Expect(job.ID).To(Equal(jobID)) + Expect(job.TaskID).To(Equal(taskID)) + + // List jobs + resp, err = http.Get("http://127.0.0.1:9090/api/agent/jobs") + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + var jobs []schema.Job + body, _ = io.ReadAll(resp.Body) + json.Unmarshal(body, &jobs) + Expect(len(jobs)).To(BeNumerically(">=", 1)) + + // Cancel job (if still pending/running) + if job.Status == schema.JobStatusPending || job.Status == schema.JobStatusRunning { + req, _ := http.NewRequest("POST", "http://127.0.0.1:9090/api/agent/jobs/"+jobID+"/cancel", nil) + req.Header.Set("Authorization", bearerKey) + resp, err = http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(200)) + } + }) + + It("executes task by name", func() { + // Create a task with a specific name + taskBody := map[string]interface{}{ + "name": "Named Task", + "model": "testmodel.ggml", + "prompt": "Hello", + "enabled": true, + } + + var createResp map[string]interface{} + err := postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks", &taskBody, &createResp) + Expect(err).ToNot(HaveOccurred()) + + // Execute by name + paramsBody := map[string]string{"param1": "value1"} + var jobResp schema.JobExecutionResponse + err = postRequestResponseJSON("http://127.0.0.1:9090/api/agent/tasks/Named Task/execute", ¶msBody, &jobResp) + Expect(err).ToNot(HaveOccurred()) + Expect(jobResp.JobID).ToNot(BeEmpty()) + }) + }) }) }) diff --git a/core/http/endpoints/localai/agent_jobs.go b/core/http/endpoints/localai/agent_jobs.go new file mode 100644 index 000000000000..0e65a241df4b --- /dev/null +++ b/core/http/endpoints/localai/agent_jobs.go @@ -0,0 +1,339 @@ +package localai + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/schema" +) + +// CreateTaskEndpoint creates a new agent task +// @Summary Create a new agent task +// @Description Create a new reusable agent task with prompt template and configuration +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param task body schema.Task true "Task definition" +// @Success 201 {object} map[string]string "Task created" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /api/agent/tasks [post] +func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + var task schema.Task + if err := c.Bind(&task); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()}) + } + + id, err := app.AgentJobService().CreateTask(task) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]string{"id": id}) + } +} + +// UpdateTaskEndpoint updates an existing task +// @Summary Update an agent task +// @Description Update an existing agent task +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param id path string true "Task ID" +// @Param task body schema.Task true "Updated task definition" +// @Success 200 {object} map[string]string "Task updated" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 404 {object} map[string]string "Task not found" +// @Router /api/agent/tasks/{id} [put] +func UpdateTaskEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + var task schema.Task + if err := c.Bind(&task); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()}) + } + + if err := app.AgentJobService().UpdateTask(id, task); err != nil { + if err.Error() == "task not found: "+id { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Task updated"}) + } +} + +// DeleteTaskEndpoint deletes a task +// @Summary Delete an agent task +// @Description Delete an agent task by ID +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Task ID" +// @Success 200 {object} map[string]string "Task deleted" +// @Failure 404 {object} map[string]string "Task not found" +// @Router /api/agent/tasks/{id} [delete] +func DeleteTaskEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + if err := app.AgentJobService().DeleteTask(id); err != nil { + if err.Error() == "task not found: "+id { + 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{"message": "Task deleted"}) + } +} + +// ListTasksEndpoint lists all tasks +// @Summary List all agent tasks +// @Description Get a list of all agent tasks +// @Tags agent-jobs +// @Produce json +// @Success 200 {array} schema.Task "List of tasks" +// @Router /api/agent/tasks [get] +func ListTasksEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + tasks := app.AgentJobService().ListTasks() + return c.JSON(http.StatusOK, tasks) + } +} + +// GetTaskEndpoint gets a task by ID +// @Summary Get an agent task +// @Description Get an agent task by ID +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Task ID" +// @Success 200 {object} schema.Task "Task details" +// @Failure 404 {object} map[string]string "Task not found" +// @Router /api/agent/tasks/{id} [get] +func GetTaskEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + task, err := app.AgentJobService().GetTask(id) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, task) + } +} + +// ExecuteJobEndpoint executes a job +// @Summary Execute an agent job +// @Description Create and execute a new agent job +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param request body schema.JobExecutionRequest true "Job execution request" +// @Success 201 {object} schema.JobExecutionResponse "Job created" +// @Failure 400 {object} map[string]string "Invalid request" +// @Router /api/agent/jobs/execute [post] +func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + var req schema.JobExecutionRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()}) + } + + if req.Parameters == nil { + req.Parameters = make(map[string]string) + } + + jobID, err := app.AgentJobService().ExecuteJob(req.TaskID, req.Parameters, "api") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + baseURL := c.Scheme() + "://" + c.Request().Host + return c.JSON(http.StatusCreated, schema.JobExecutionResponse{ + JobID: jobID, + Status: "pending", + URL: baseURL + "/api/agent/jobs/" + jobID, + }) + } +} + +// GetJobEndpoint gets a job by ID +// @Summary Get an agent job +// @Description Get an agent job by ID +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} schema.Job "Job details" +// @Failure 404 {object} map[string]string "Job not found" +// @Router /api/agent/jobs/{id} [get] +func GetJobEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + job, err := app.AgentJobService().GetJob(id) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, job) + } +} + +// ListJobsEndpoint lists jobs with optional filtering +// @Summary List agent jobs +// @Description Get a list of agent jobs, optionally filtered by task_id and status +// @Tags agent-jobs +// @Produce json +// @Param task_id query string false "Filter by task ID" +// @Param status query string false "Filter by status (pending, running, completed, failed, cancelled)" +// @Param limit query int false "Limit number of results" +// @Success 200 {array} schema.Job "List of jobs" +// @Router /api/agent/jobs [get] +func ListJobsEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + var taskID *string + var status *schema.JobStatus + limit := 0 + + if taskIDParam := c.QueryParam("task_id"); taskIDParam != "" { + taskID = &taskIDParam + } + + if statusParam := c.QueryParam("status"); statusParam != "" { + s := schema.JobStatus(statusParam) + status = &s + } + + if limitParam := c.QueryParam("limit"); limitParam != "" { + if l, err := strconv.Atoi(limitParam); err == nil { + limit = l + } + } + + jobs := app.AgentJobService().ListJobs(taskID, status, limit) + return c.JSON(http.StatusOK, jobs) + } +} + +// CancelJobEndpoint cancels a running job +// @Summary Cancel an agent job +// @Description Cancel a running or pending agent job +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} map[string]string "Job cancelled" +// @Failure 400 {object} map[string]string "Job cannot be cancelled" +// @Failure 404 {object} map[string]string "Job not found" +// @Router /api/agent/jobs/{id}/cancel [post] +func CancelJobEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + if err := app.AgentJobService().CancelJob(id); err != nil { + if err.Error() == "job not found: "+id { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]string{"message": "Job cancelled"}) + } +} + +// DeleteJobEndpoint deletes a job +// @Summary Delete an agent job +// @Description Delete an agent job by ID +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} map[string]string "Job deleted" +// @Failure 404 {object} map[string]string "Job not found" +// @Router /api/agent/jobs/{id} [delete] +func DeleteJobEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Param("id") + if err := app.AgentJobService().DeleteJob(id); err != nil { + if err.Error() == "job not found: "+id { + 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{"message": "Job deleted"}) + } +} + +// ExecuteTaskByNameEndpoint executes a task by name +// @Summary Execute a task by name +// @Description Execute an agent task by its name (convenience endpoint). Parameters can be provided in the request body as a JSON object with string values. +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param name path string true "Task name" +// @Param request body map[string]string false "Template parameters (JSON object with string values)" +// @Success 201 {object} schema.JobExecutionResponse "Job created" +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 404 {object} map[string]string "Task not found" +// @Router /api/agent/tasks/{name}/execute [post] +func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc { + return func(c echo.Context) error { + name := c.Param("name") + var params map[string]string + + // Try to bind parameters from request body + // If body is empty or invalid, use empty params + if c.Request().ContentLength > 0 { + if err := c.Bind(¶ms); err != nil { + // If binding fails, try to read as raw JSON + body := make(map[string]interface{}) + if err := c.Bind(&body); err == nil { + // Convert interface{} values to strings + params = make(map[string]string) + for k, v := range body { + if str, ok := v.(string); ok { + params[k] = str + } else { + // Convert non-string values to string + params[k] = fmt.Sprintf("%v", v) + } + } + } else { + // If all binding fails, use empty params + params = make(map[string]string) + } + } + } else { + // No body provided, use empty params + params = make(map[string]string) + } + + // Find task by name + tasks := app.AgentJobService().ListTasks() + var task *schema.Task + for _, t := range tasks { + if t.Name == name { + task = &t + break + } + } + + if task == nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Task not found: " + name}) + } + + jobID, err := app.AgentJobService().ExecuteJob(task.ID, params, "api") + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + baseURL := c.Scheme() + "://" + c.Request().Host + return c.JSON(http.StatusCreated, schema.JobExecutionResponse{ + JobID: jobID, + Status: "pending", + URL: baseURL + "/api/agent/jobs/" + jobID, + }) + } +} + diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go index 62f198a9d049..d5c5cd7db293 100644 --- a/core/http/endpoints/localai/settings.go +++ b/core/http/endpoints/localai/settings.go @@ -44,6 +44,7 @@ type RuntimeSettings struct { AutoloadGalleries *bool `json:"autoload_galleries,omitempty"` AutoloadBackendGalleries *bool `json:"autoload_backend_galleries,omitempty"` ApiKeys *[]string `json:"api_keys"` // No omitempty - we need to save empty arrays to clear keys + AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"` } // GetSettingsEndpoint returns current settings with precedence (env > file > defaults) @@ -80,6 +81,7 @@ func GetSettingsEndpoint(app *application.Application) echo.HandlerFunc { autoloadGalleries := appConfig.AutoloadGalleries autoloadBackendGalleries := appConfig.AutoloadBackendGalleries apiKeys := appConfig.ApiKeys + agentJobRetentionDays := appConfig.AgentJobRetentionDays settings.WatchdogIdleEnabled = &watchdogIdle settings.WatchdogBusyEnabled = &watchdogBusy @@ -101,6 +103,7 @@ func GetSettingsEndpoint(app *application.Application) echo.HandlerFunc { settings.AutoloadGalleries = &autoloadGalleries settings.AutoloadBackendGalleries = &autoloadBackendGalleries settings.ApiKeys = &apiKeys + settings.AgentJobRetentionDays = &agentJobRetentionDays var idleTimeout, busyTimeout string if appConfig.WatchDogIdleTimeout > 0 { @@ -268,6 +271,11 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { if settings.AutoloadBackendGalleries != nil { appConfig.AutoloadBackendGalleries = *settings.AutoloadBackendGalleries } + agentJobChanged := false + if settings.AgentJobRetentionDays != nil { + appConfig.AgentJobRetentionDays = *settings.AgentJobRetentionDays + agentJobChanged = true + } if settings.ApiKeys != nil { // API keys from env vars (startup) should be kept, runtime settings keys are added // Combine startup keys (env vars) with runtime settings keys @@ -302,6 +310,17 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { } } + // Restart agent job service if retention days changed + if agentJobChanged { + if err := app.RestartAgentJobService(); err != nil { + log.Error().Err(err).Msg("Failed to restart agent job service") + return c.JSON(http.StatusInternalServerError, SettingsResponse{ + Success: false, + Error: "Settings saved but failed to restart agent job service: " + err.Error(), + }) + } + } + // Restart P2P if P2P settings changed p2pChanged := settings.P2PToken != nil || settings.P2PNetworkID != nil || settings.Federated != nil if p2pChanged { diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go index bf8a7bfb8f16..32e030bf34f0 100644 --- a/core/http/routes/localai.go +++ b/core/http/routes/localai.go @@ -2,6 +2,7 @@ package routes import ( "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/application" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/http/endpoints/localai" "github.com/mudler/LocalAI/core/http/middleware" @@ -20,7 +21,8 @@ func RegisterLocalAIRoutes(router *echo.Echo, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, - evaluator *templates.Evaluator) { + evaluator *templates.Evaluator, + app *application.Application) { router.GET("/swagger/*", echoswagger.WrapHandler) // default @@ -154,4 +156,21 @@ func RegisterLocalAIRoutes(router *echo.Echo, router.POST("/mcp/v1/chat/completions", mcpStreamHandler, mcpStreamMiddleware...) } + // Agent job routes + if app != nil && app.AgentJobService() != nil { + router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app)) + router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app)) + router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app)) + router.GET("/api/agent/tasks", localai.ListTasksEndpoint(app)) + router.GET("/api/agent/tasks/:id", localai.GetTaskEndpoint(app)) + + router.POST("/api/agent/jobs/execute", localai.ExecuteJobEndpoint(app)) + router.GET("/api/agent/jobs/:id", localai.GetJobEndpoint(app)) + router.GET("/api/agent/jobs", localai.ListJobsEndpoint(app)) + router.POST("/api/agent/jobs/:id/cancel", localai.CancelJobEndpoint(app)) + router.DELETE("/api/agent/jobs/:id", localai.DeleteJobEndpoint(app)) + + router.POST("/api/agent/tasks/:name/execute", localai.ExecuteTaskByNameEndpoint(app)) + } + } diff --git a/core/http/routes/ui.go b/core/http/routes/ui.go index 6ef56550564d..368aa47987fe 100644 --- a/core/http/routes/ui.go +++ b/core/http/routes/ui.go @@ -34,6 +34,60 @@ func RegisterUIRoutes(app *echo.Echo, }) } + // Agent Jobs pages + app.GET("/agent-jobs", func(c echo.Context) error { + modelConfigs := cl.GetAllModelsConfigs() + summary := map[string]interface{}{ + "Title": "LocalAI - Agent Jobs", + "BaseURL": middleware.BaseURL(c), + "Version": internal.PrintableVersion(), + "ModelsConfig": modelConfigs, + } + return c.Render(200, "views/agent-jobs", summary) + }) + + app.GET("/agent-jobs/tasks/new", func(c echo.Context) error { + modelConfigs := cl.GetAllModelsConfigs() + summary := map[string]interface{}{ + "Title": "LocalAI - Create Task", + "BaseURL": middleware.BaseURL(c), + "Version": internal.PrintableVersion(), + "ModelsConfig": modelConfigs, + } + return c.Render(200, "views/agent-task-details", summary) + }) + + // More specific route must come first + app.GET("/agent-jobs/tasks/:id/edit", func(c echo.Context) error { + modelConfigs := cl.GetAllModelsConfigs() + summary := map[string]interface{}{ + "Title": "LocalAI - Edit Task", + "BaseURL": middleware.BaseURL(c), + "Version": internal.PrintableVersion(), + "ModelsConfig": modelConfigs, + } + return c.Render(200, "views/agent-task-details", summary) + }) + + // Task details page (less specific, comes after edit route) + app.GET("/agent-jobs/tasks/:id", func(c echo.Context) error { + summary := map[string]interface{}{ + "Title": "LocalAI - Task Details", + "BaseURL": middleware.BaseURL(c), + "Version": internal.PrintableVersion(), + } + return c.Render(200, "views/agent-task-details", summary) + }) + + app.GET("/agent-jobs/jobs/:id", func(c echo.Context) error { + summary := map[string]interface{}{ + "Title": "LocalAI - Job Details", + "BaseURL": middleware.BaseURL(c), + "Version": internal.PrintableVersion(), + } + return c.Render(200, "views/agent-job-details", summary) + }) + // P2P app.GET("/p2p/", func(c echo.Context) error { summary := map[string]interface{}{ diff --git a/core/http/static/chat.js b/core/http/static/chat.js index 0759b90d7fd0..dc0aef87af40 100644 --- a/core/http/static/chat.js +++ b/core/http/static/chat.js @@ -2392,7 +2392,10 @@ document.addEventListener('DOMContentLoaded', function() { if (shouldCreateNewChat) { // Create a new chat with the model from URL (which matches the selected model from index) const currentModel = document.getElementById("chat-model")?.value || ""; - const newChat = chatStore.createChat(currentModel, "", indexChatData.mcpMode || false); + // Check URL parameter for MCP mode (takes precedence over localStorage) + const urlParams = new URLSearchParams(window.location.search); + const mcpFromUrl = urlParams.get('mcp') === 'true'; + const newChat = chatStore.createChat(currentModel, "", mcpFromUrl || indexChatData.mcpMode || false); // Update context size from template if available const contextSizeInput = document.getElementById("chat-model"); @@ -2442,8 +2445,16 @@ document.addEventListener('DOMContentLoaded', function() { } }, 500); } else { - // No message, but might have mcpMode - clear localStorage + // No message, but might have mcpMode from URL - clear localStorage localStorage.removeItem('localai_index_chat_data'); + + // If MCP mode was set from URL, ensure it's enabled + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('mcp') === 'true' && newChat) { + newChat.mcpMode = true; + saveChatsToStorage(); + updateUIForActiveChat(); + } saveChatsToStorage(); updateUIForActiveChat(); } @@ -2452,12 +2463,25 @@ document.addEventListener('DOMContentLoaded', function() { if (!storedData || !storedData.chats || storedData.chats.length === 0) { const currentModel = document.getElementById("chat-model")?.value || ""; const oldSystemPrompt = localStorage.getItem(SYSTEM_PROMPT_STORAGE_KEY); - chatStore.createChat(currentModel, oldSystemPrompt || "", false); + // Check URL parameter for MCP mode + const urlParams = new URLSearchParams(window.location.search); + const mcpFromUrl = urlParams.get('mcp') === 'true'; + chatStore.createChat(currentModel, oldSystemPrompt || "", mcpFromUrl); // Remove old system prompt key after migration if (oldSystemPrompt) { localStorage.removeItem(SYSTEM_PROMPT_STORAGE_KEY); } + } else { + // Existing chats loaded - check URL parameter for MCP mode + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('mcp') === 'true') { + const activeChat = chatStore.activeChat(); + if (activeChat) { + activeChat.mcpMode = true; + saveChatsToStorage(); + } + } } // Update context size from template if available diff --git a/core/http/views/agent-job-details.html b/core/http/views/agent-job-details.html new file mode 100644 index 000000000000..914da8019e60 --- /dev/null +++ b/core/http/views/agent-job-details.html @@ -0,0 +1,327 @@ + + +{{template "views/partials/head" .}} + +
+Live job status, reasoning traces, and execution details
+The original prompt template from the task definition.
+ +Parameters configured for cron-triggered executions of this task.
+ +Parameters used for this specific job execution.
+ +The prompt with parameters substituted, as it was sent to the agent.
+ +No execution traces available yet. Traces will appear here as the job executes.
+Manage agent tasks and monitor job execution
++ To use Agent Jobs, you need to install a model first. Agent Jobs require models with MCP (Model Context Protocol) configuration. +
+ + +Browse and install pre-configured models
+Upload your own model files
+Use the API to download models programmatically
+Browse the Model Gallery
+Explore our curated collection of pre-configured models. Find models for chat, image generation, audio processing, and more.
+Install a Model
+Click on a model from the gallery to install it, or use the import feature to upload your own model files.
+Configure MCP
+After installing a model, configure MCP (Model Context Protocol) to enable Agent Jobs functionality.
++ You have models installed, but none have MCP (Model Context Protocol) enabled. Agent Jobs require MCP to function. +
+ + +Edit a Model Configuration
+Click "Configure MCP" on any model above, or navigate to the model editor to add MCP configuration.
+Add MCP Configuration
+In the model YAML, add MCP server or stdio configuration. See the documentation for detailed examples.
+Save and Return
+After saving the MCP configuration, return to this page to create your first Agent Job task.
+| Name | +Model | +Cron | +Status | +Actions | +
|---|---|---|---|---|
| + + + | +
+
+
+
+
+
+
+ |
+ + + - + | ++ + | +
+
+
+
+
+
+
+
+ |
+
| + No tasks found. Create one + | +||||
+ Enter parameters as key-value pairs (one per line, format: key=value). + These will be used to template the prompt. +
+ +
+ Example: user_name=Alice
+
+ Use these curl commands to interact with this task programmatically. +
+ +curl -X POST {{ .BaseURL }}api/agent/jobs/execute \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -d '{
+ "task_id": "",
+ "parameters": {
+ "user_name": "Alice",
+ "job_title": "Software Engineer",
+ "task_description": "Review code changes"
+ }
+ }'
+ curl -X POST {{ .BaseURL }}api/agent/tasks//execute \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -d '{
+ "user_name": "Bob",
+ "job_title": "Data Scientist",
+ "task_description": "Analyze sales data"
+ }'
+ + + The request body should be a JSON object where keys are parameter names and values are strings. + If no body is provided, the task will execute with empty parameters. +
+curl -X GET {{ .BaseURL }}api/agent/jobs/JOB_ID \
+ -H "Authorization: Bearer YOUR_API_KEY"
+
+ After executing a task, you will receive a job_id in the response. Use it to query the job's status and results.
+
+ Enter parameters as key-value pairs (one per line, format: key=value). + These will be used to template the prompt. +
+ +
+ Example: user_name=Alice
+