feat: agent jobs panel (#7390)

* feat(agent): agent jobs

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

* Multiple webhooks, simplify

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

* Do not use cron with seconds

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

* Create separate pages for details

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

* Detect if no models have MCP configuration, show wizard

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

* Make services test to run

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-11-28 23:05:39 +01:00
committed by GitHub
parent 4b5977f535
commit 53e5b2d6be
25 changed files with 4308 additions and 19 deletions

1180
core/services/agent_jobs.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,332 @@
package services_test
import (
"context"
"os"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("AgentJobService", func() {
var (
service *services.AgentJobService
tempDir string
appConfig *config.ApplicationConfig
modelLoader *model.ModelLoader
configLoader *config.ModelConfigLoader
evaluator *templates.Evaluator
)
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "agent_jobs_test")
Expect(err).NotTo(HaveOccurred())
systemState := &system.SystemState{}
systemState.Model.ModelsPath = tempDir
appConfig = config.NewApplicationConfig(
config.WithDynamicConfigDir(tempDir),
config.WithContext(context.Background()),
)
appConfig.SystemState = systemState
appConfig.APIAddress = "127.0.0.1:8080"
appConfig.AgentJobRetentionDays = 30
modelLoader = model.NewModelLoader(systemState, false)
configLoader = config.NewModelConfigLoader(tempDir)
evaluator = templates.NewEvaluator(tempDir)
service = services.NewAgentJobService(
appConfig,
modelLoader,
configLoader,
evaluator,
)
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
Describe("Task CRUD operations", func() {
It("should create a task", func() {
task := schema.Task{
Name: "Test Task",
Description: "Test Description",
Model: "test-model",
Prompt: "Hello {{.name}}",
Enabled: true,
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
Expect(id).NotTo(BeEmpty())
retrieved, err := service.GetTask(id)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.Name).To(Equal("Test Task"))
Expect(retrieved.Description).To(Equal("Test Description"))
Expect(retrieved.Model).To(Equal("test-model"))
Expect(retrieved.Prompt).To(Equal("Hello {{.name}}"))
})
It("should update a task", func() {
task := schema.Task{
Name: "Original Task",
Model: "test-model",
Prompt: "Original prompt",
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
updatedTask := schema.Task{
Name: "Updated Task",
Model: "test-model",
Prompt: "Updated prompt",
}
err = service.UpdateTask(id, updatedTask)
Expect(err).NotTo(HaveOccurred())
retrieved, err := service.GetTask(id)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.Name).To(Equal("Updated Task"))
Expect(retrieved.Prompt).To(Equal("Updated prompt"))
})
It("should delete a task", func() {
task := schema.Task{
Name: "Task to Delete",
Model: "test-model",
Prompt: "Prompt",
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
err = service.DeleteTask(id)
Expect(err).NotTo(HaveOccurred())
_, err = service.GetTask(id)
Expect(err).To(HaveOccurred())
})
It("should list all tasks", func() {
task1 := schema.Task{Name: "Task 1", Model: "test-model", Prompt: "Prompt 1"}
task2 := schema.Task{Name: "Task 2", Model: "test-model", Prompt: "Prompt 2"}
_, err := service.CreateTask(task1)
Expect(err).NotTo(HaveOccurred())
_, err = service.CreateTask(task2)
Expect(err).NotTo(HaveOccurred())
tasks := service.ListTasks()
Expect(len(tasks)).To(BeNumerically(">=", 2))
})
})
Describe("Job operations", func() {
var taskID string
BeforeEach(func() {
task := schema.Task{
Name: "Test Task",
Model: "test-model",
Prompt: "Hello {{.name}}",
Enabled: true,
}
var err error
taskID, err = service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
})
It("should create and queue a job", func() {
params := map[string]string{"name": "World"}
jobID, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
Expect(jobID).NotTo(BeEmpty())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.TaskID).To(Equal(taskID))
Expect(job.Status).To(Equal(schema.JobStatusPending))
Expect(job.Parameters).To(Equal(params))
})
It("should list jobs with filters", func() {
params := map[string]string{}
jobID1, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
time.Sleep(10 * time.Millisecond) // Ensure different timestamps
jobID2, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
allJobs := service.ListJobs(nil, nil, 0)
Expect(len(allJobs)).To(BeNumerically(">=", 2))
filteredJobs := service.ListJobs(&taskID, nil, 0)
Expect(len(filteredJobs)).To(BeNumerically(">=", 2))
status := schema.JobStatusPending
pendingJobs := service.ListJobs(nil, &status, 0)
Expect(len(pendingJobs)).To(BeNumerically(">=", 2))
// Verify both jobs are in the list
jobIDs := make(map[string]bool)
for _, job := range pendingJobs {
jobIDs[job.ID] = true
}
Expect(jobIDs[jobID1]).To(BeTrue())
Expect(jobIDs[jobID2]).To(BeTrue())
})
It("should cancel a pending job", func() {
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
err = service.CancelJob(jobID)
Expect(err).NotTo(HaveOccurred())
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(job.Status).To(Equal(schema.JobStatusCancelled))
})
It("should delete a job", func() {
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
err = service.DeleteJob(jobID)
Expect(err).NotTo(HaveOccurred())
_, err = service.GetJob(jobID)
Expect(err).To(HaveOccurred())
})
})
Describe("File operations", func() {
It("should save and load tasks from file", func() {
task := schema.Task{
Name: "Persistent Task",
Model: "test-model",
Prompt: "Test prompt",
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Create a new service instance to test loading
newService := services.NewAgentJobService(
appConfig,
modelLoader,
configLoader,
evaluator,
)
err = newService.LoadTasksFromFile()
Expect(err).NotTo(HaveOccurred())
retrieved, err := newService.GetTask(id)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.Name).To(Equal("Persistent Task"))
})
It("should save and load jobs from file", func() {
task := schema.Task{
Name: "Test Task",
Model: "test-model",
Prompt: "Test prompt",
Enabled: true,
}
taskID, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
service.SaveJobsToFile()
// Create a new service instance to test loading
newService := services.NewAgentJobService(
appConfig,
modelLoader,
configLoader,
evaluator,
)
err = newService.LoadJobsFromFile()
Expect(err).NotTo(HaveOccurred())
retrieved, err := newService.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.TaskID).To(Equal(taskID))
})
})
Describe("Prompt templating", func() {
It("should build prompt from template with parameters", func() {
task := schema.Task{
Name: "Template Task",
Model: "test-model",
Prompt: "Hello {{.name}}, you are {{.role}}",
}
id, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// We can't directly test buildPrompt as it's private, but we can test via ExecuteJob
// which uses it internally. However, without a real model, the job will fail.
// So we'll just verify the task was created correctly.
Expect(id).NotTo(BeEmpty())
})
})
Describe("Job cleanup", func() {
It("should cleanup old jobs", func() {
task := schema.Task{
Name: "Test Task",
Model: "test-model",
Prompt: "Test prompt",
Enabled: true,
}
taskID, err := service.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
params := map[string]string{}
jobID, err := service.ExecuteJob(taskID, params, "test")
Expect(err).NotTo(HaveOccurred())
// Manually set job creation time to be old
job, err := service.GetJob(jobID)
Expect(err).NotTo(HaveOccurred())
// Modify the job's CreatedAt to be 31 days ago
oldTime := time.Now().AddDate(0, 0, -31)
job.CreatedAt = oldTime
// We can't directly modify jobs in the service, so we'll test cleanup differently
// by setting retention to 0 and creating a new job
// Test that cleanup runs without error
err = service.CleanupOldJobs()
Expect(err).NotTo(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,13 @@
package services_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestServices(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "LocalAI services test")
}