mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-22 06:04:55 -04:00
Five of the six families whose subscriber is an open HTTP response rather than a process-lifetime cache now travel on pgbus: jobs.<id>.progress, jobs.<id>.result, jobs.<id>.cancel, agent.<name>.events.<user> and responses.<id>.cancel. Both ends of each move together, so there is no state where a publisher is on one carrier and its subscriber on the other. agent.<name>.cancel does NOT move, and the plan was wrong about why. Its only subscriber in the tree is the agent worker, which has no database and so cannot join the PostgreSQL carrier at all. Publishing that cancel on pgbus would have lost every cancel of a worker-run agent while returning nil, which reports a cancel that reached nobody as a cancel that was sent. EventBridge now names its cancel carrier separately, a frontend replica sets it to the carrier the worker reads, and it stays there until a cancel rides the worker's tunnel like every other verb addressed to a worker. The carrier drops at 256 rather than blocking, which is not safe on its own for a result: a lost result has no successor message. It is not the only path. The claiming replica persists the terminal line before it releases the claim, and an open progress stream re-reads the job row once after subscribing and then periodically, so a dropped terminal broadcast costs promptness and never the answer. Both per-request subscriptions close in a defer instead of on one return path, and pgbus grows Subscribers() so the leak they would otherwise cause can be asserted. It has no other symptom: only the first subscriber of a channel issues a LISTEN, so a leaked filter just adds one closure per notification for every stream the replica has ever served. Subscribe now issues its LISTEN before it registers, which makes that count a readiness signal rather than a figure to compare against itself. Two rules that were stated at several sites and pinned at none are now one each. The re-broadcaster is built beside the dispatcher and the bridge and handed to the dispatch loop, so no line is left that can point it at a carrier nobody subscribes to while every spec stays green. The set of statuses a job never leaves is one exported set that the SSE bridge and the store both read. The last hand-written subject filter in production code became messaging.SubjectAgentEventsWildcard. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
209 lines
7.4 KiB
Go
209 lines
7.4 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
|
|
"github.com/mudler/LocalAI/core/services/agents"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Phase 3: Agent Conversations & SSE", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
store *agents.AgentStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_agents_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
store, err = agents.NewAgentStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Agent Config Store", func() {
|
|
It("should store agent config in PostgreSQL", func() {
|
|
cfg := &agents.AgentConfigRecord{
|
|
UserID: "user1",
|
|
Name: "my-agent",
|
|
ConfigJSON: `{"model": "llama3", "actions": ["web_search"]}`,
|
|
Status: "active",
|
|
}
|
|
Expect(store.SaveConfig(cfg)).To(Succeed())
|
|
Expect(cfg.ID).ToNot(BeEmpty())
|
|
|
|
retrieved, err := store.GetConfig("user1", "my-agent")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.Name).To(Equal("my-agent"))
|
|
Expect(retrieved.ConfigJSON).To(ContainSubstring("llama3"))
|
|
})
|
|
|
|
It("should list agent configs for a user", func() {
|
|
Expect(store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "agent-a", ConfigJSON: "{}", Status: "active"})).To(Succeed())
|
|
Expect(store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "agent-b", ConfigJSON: "{}", Status: "active"})).To(Succeed())
|
|
Expect(store.SaveConfig(&agents.AgentConfigRecord{UserID: "u2", Name: "agent-c", ConfigJSON: "{}", Status: "active"})).To(Succeed())
|
|
|
|
u1Agents, err := store.ListConfigs("u1")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(u1Agents).To(HaveLen(2))
|
|
|
|
allAgents, err := store.ListConfigs("")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(allAgents).To(HaveLen(3))
|
|
})
|
|
|
|
It("should soft-delete agent config", func() {
|
|
store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "deleteme", ConfigJSON: "{}", Status: "active"})
|
|
|
|
Expect(store.DeleteConfig("u1", "deleteme")).To(Succeed())
|
|
|
|
// Should not appear in list
|
|
configs, _ := store.ListConfigs("u1")
|
|
Expect(configs).To(BeEmpty())
|
|
|
|
// But can still be found directly
|
|
cfg, err := store.GetConfig("u1", "deleteme")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(cfg.Status).To(Equal("deleted"))
|
|
})
|
|
|
|
It("should update agent config on re-save", func() {
|
|
store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "update-me", ConfigJSON: `{"v":1}`, Status: "active"})
|
|
store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "update-me", ConfigJSON: `{"v":2}`, Status: "active"})
|
|
|
|
configs, _ := store.ListConfigs("u1")
|
|
Expect(configs).To(HaveLen(1))
|
|
Expect(configs[0].ConfigJSON).To(ContainSubstring(`"v":2`))
|
|
})
|
|
|
|
It("should update agent status (pause/resume)", func() {
|
|
store.SaveConfig(&agents.AgentConfigRecord{UserID: "u1", Name: "pausable", ConfigJSON: "{}", Status: "active"})
|
|
|
|
Expect(store.UpdateStatus("u1", "pausable", "paused")).To(Succeed())
|
|
|
|
cfg, _ := store.GetConfig("u1", "pausable")
|
|
Expect(cfg.Status).To(Equal("paused"))
|
|
|
|
Expect(store.UpdateStatus("u1", "pausable", "active")).To(Succeed())
|
|
cfg, _ = store.GetConfig("u1", "pausable")
|
|
Expect(cfg.Status).To(Equal("active"))
|
|
})
|
|
})
|
|
|
|
// Conversation history is managed client-side (browser localStorage).
|
|
// No server-side conversation storage tests needed.
|
|
|
|
Context("Agent SSE events on the broadcast carrier", func() {
|
|
It("bridges an agent's SSE events from a peer replica's carrier", func() {
|
|
// Two carriers, because the whole point of this bridge is that the
|
|
// user's SSE connection and the replica running the agent are not
|
|
// the same process.
|
|
watcher := agents.NewEventBridge(infra.Bus(), store, "instance-1")
|
|
runner := agents.NewEventBridge(infra.Bus(), store, "instance-2")
|
|
|
|
received := make(chan agents.AgentEvent, 16)
|
|
sub, err := watcher.SubscribeEvents("my-agent", "user1", func(evt agents.AgentEvent) {
|
|
received <- evt
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer sub.Unsubscribe()
|
|
|
|
// Published on the OTHER replica, as an agent execution there would.
|
|
Expect(runner.PublishMessage("my-agent", "user1", "user", "What's the weather?", "msg-1")).To(Succeed())
|
|
Expect(runner.PublishStatus("my-agent", "user1", "processing")).To(Succeed())
|
|
Expect(runner.PublishMessage("my-agent", "user1", "agent", "The weather is sunny.", "msg-2")).To(Succeed())
|
|
Expect(runner.PublishStatus("my-agent", "user1", "completed")).To(Succeed())
|
|
|
|
var evts []agents.AgentEvent
|
|
for i := 0; i < 4; i++ {
|
|
var evt agents.AgentEvent
|
|
Eventually(received, "20s").Should(Receive(&evt))
|
|
evts = append(evts, evt)
|
|
}
|
|
Expect(evts[0].EventType).To(Equal("json_message"))
|
|
Expect(evts[0].Sender).To(Equal("user"))
|
|
Expect(evts[1].EventType).To(Equal("json_message_status"))
|
|
Expect(evts[2].Sender).To(Equal("agent"))
|
|
})
|
|
|
|
// Conversation persistence removed — chat history is browser-only.
|
|
|
|
// Two FRONTEND replicas, which share a carrier. A cancel bound for an
|
|
// agent WORKER does not travel this way: a worker has no database, so
|
|
// it cannot listen on the PostgreSQL carrier and the frontend publishes
|
|
// its cancels where the worker is listening instead. That pairing is
|
|
// pinned in core/services/agents; this pins the replica-to-replica half.
|
|
It("cancels a running agent from another replica", func() {
|
|
bridge := agents.NewEventBridge(infra.Bus(), store, "instance-1")
|
|
canceller := agents.NewEventBridge(infra.Bus(), store, "instance-2")
|
|
|
|
// Start cancel listener
|
|
cancelSub, err := bridge.StartCancelListener()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer cancelSub.Unsubscribe()
|
|
|
|
// Register a cancellable context
|
|
_, cancel := context.WithCancel(infra.Ctx)
|
|
var cancelled atomic.Bool
|
|
wrappedCancel := context.CancelFunc(func() {
|
|
cancelled.Store(true)
|
|
cancel()
|
|
})
|
|
bridge.RegisterCancel("test-msg-id", wrappedCancel)
|
|
|
|
// Issued on the replica that does NOT hold the execution, which is
|
|
// the case the broadcast exists for. Its error says only that the
|
|
// request was published, so what is asserted is the effect.
|
|
Expect(canceller.CancelExecution("my-agent", "user1", "test-msg-id")).To(Succeed())
|
|
|
|
Eventually(func() bool { return cancelled.Load() }, "20s").Should(BeTrue())
|
|
})
|
|
|
|
// Agent execution is now dispatched via AgentPoolService.dispatchChat(),
|
|
// not via EventBridge.EnqueueExecution(). See agent_pool.go.
|
|
})
|
|
|
|
Context("Observables", func() {
|
|
It("should store and retrieve observables", func() {
|
|
store.AppendObservable(&agents.AgentObservableRecord{
|
|
AgentName: "u1:agent",
|
|
EventType: "action",
|
|
PayloadJSON: `{"tool": "web_search", "query": "weather"}`,
|
|
})
|
|
store.AppendObservable(&agents.AgentObservableRecord{
|
|
AgentName: "u1:agent",
|
|
EventType: "status",
|
|
PayloadJSON: `{"message": "completed"}`,
|
|
})
|
|
|
|
obs, err := store.GetObservables("u1:agent", 0)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(obs).To(HaveLen(2))
|
|
})
|
|
|
|
It("should clear observables", func() {
|
|
store.AppendObservable(&agents.AgentObservableRecord{
|
|
AgentName: "u1:agent", EventType: "action", PayloadJSON: "{}",
|
|
})
|
|
|
|
Expect(store.ClearObservables("u1:agent")).To(Succeed())
|
|
|
|
obs, _ := store.GetObservables("u1:agent", 0)
|
|
Expect(obs).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|