feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)
Reemplaza el scaffold del echobot por la plataforma completa de bots traida desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out: los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms + E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client). - go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths relativos reajustados a la nueva ubicacion dentro de fn_registry). - app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales. - modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports). agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// Package bus provides in-process agent-to-agent message passing.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Well-known message kinds used by the orchestrator.
|
||||
const (
|
||||
KindTask = "task" // orchestrator → bot: handle this question
|
||||
KindTaskResult = "task_result" // bot → orchestrator: here is my answer
|
||||
)
|
||||
|
||||
// AgentID identifies an agent.
|
||||
type AgentID string
|
||||
|
||||
// AgentMessage is a message between agents.
|
||||
type AgentMessage struct {
|
||||
From AgentID
|
||||
To AgentID
|
||||
Kind string
|
||||
Payload map[string]string
|
||||
}
|
||||
|
||||
// Bus manages channels for inter-agent communication.
|
||||
type Bus struct {
|
||||
mu sync.RWMutex
|
||||
channels map[AgentID]chan AgentMessage
|
||||
|
||||
replyMu sync.Mutex
|
||||
replyChs map[string]chan AgentMessage // taskID → one-shot reply channel
|
||||
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new Bus.
|
||||
func New(logger *slog.Logger) *Bus {
|
||||
return &Bus{
|
||||
channels: make(map[AgentID]chan AgentMessage),
|
||||
replyChs: make(map[string]chan AgentMessage),
|
||||
logger: logger.With("component", "bus"),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registers an agent and returns its receive channel.
|
||||
func (b *Bus) Subscribe(id AgentID) <-chan AgentMessage {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
ch := make(chan AgentMessage, 64)
|
||||
b.channels[id] = ch
|
||||
b.logger.Info("bus_subscribe", "agent", id)
|
||||
return ch
|
||||
}
|
||||
|
||||
// Send delivers a message to an agent's channel.
|
||||
func (b *Bus) Send(msg AgentMessage) error {
|
||||
b.mu.RLock()
|
||||
ch, ok := b.channels[msg.To]
|
||||
b.mu.RUnlock()
|
||||
if !ok {
|
||||
b.logger.Warn("bus_not_found", "to", msg.To, "from", msg.From, "kind", msg.Kind)
|
||||
return fmt.Errorf("agent %q not registered on bus", msg.To)
|
||||
}
|
||||
select {
|
||||
case ch <- msg:
|
||||
b.logger.Debug("bus_send", "from", msg.From, "to", msg.To, "kind", msg.Kind)
|
||||
return nil
|
||||
default:
|
||||
b.logger.Warn("bus_queue_full", "to", msg.To, "from", msg.From, "kind", msg.Kind)
|
||||
return fmt.Errorf("agent %q message queue full", msg.To)
|
||||
}
|
||||
}
|
||||
|
||||
// SendAndWait sends a task message and blocks until a reply with the matching
|
||||
// taskID arrives or the context expires. The caller must ensure the reply is
|
||||
// routed via Reply().
|
||||
func (b *Bus) SendAndWait(ctx context.Context, msg AgentMessage, taskID string, timeout time.Duration) (AgentMessage, error) {
|
||||
ch := make(chan AgentMessage, 1)
|
||||
b.replyMu.Lock()
|
||||
b.replyChs[taskID] = ch
|
||||
b.replyMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
b.replyMu.Lock()
|
||||
delete(b.replyChs, taskID)
|
||||
b.replyMu.Unlock()
|
||||
}()
|
||||
|
||||
if err := b.Send(msg); err != nil {
|
||||
return AgentMessage{}, err
|
||||
}
|
||||
|
||||
b.logger.Debug("bus_send_and_wait", "task", taskID, "to", msg.To, "timeout", timeout)
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case reply := <-ch:
|
||||
return reply, nil
|
||||
case <-timer.C:
|
||||
b.logger.Warn("bus_timeout", "task", taskID, "to", msg.To, "timeout", timeout)
|
||||
return AgentMessage{}, fmt.Errorf("task %s: delegation timeout after %s", taskID, timeout)
|
||||
case <-ctx.Done():
|
||||
return AgentMessage{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Reply routes a task_result message to the waiting SendAndWait caller.
|
||||
// If no one is waiting for this taskID, it falls back to regular Send.
|
||||
func (b *Bus) Reply(taskID string, msg AgentMessage) error {
|
||||
b.replyMu.Lock()
|
||||
ch, ok := b.replyChs[taskID]
|
||||
b.replyMu.Unlock()
|
||||
|
||||
if ok {
|
||||
select {
|
||||
case ch <- msg:
|
||||
b.logger.Debug("bus_reply", "task", taskID, "from", msg.From)
|
||||
return nil
|
||||
default:
|
||||
b.logger.Warn("bus_reply_full", "task", taskID)
|
||||
return fmt.Errorf("reply channel full for task %s", taskID)
|
||||
}
|
||||
}
|
||||
// Fallback: deliver via regular channel
|
||||
return b.Send(msg)
|
||||
}
|
||||
|
||||
// Unsubscribe removes an agent from the bus.
|
||||
func (b *Bus) Unsubscribe(id AgentID) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if ch, ok := b.channels[id]; ok {
|
||||
close(ch)
|
||||
delete(b.channels, id)
|
||||
b.logger.Info("bus_unsubscribe", "agent", id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package bus_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/agents/shell/bus"
|
||||
)
|
||||
|
||||
func newBus() *bus.Bus {
|
||||
return bus.New(slog.Default())
|
||||
}
|
||||
|
||||
func TestSubscribeAndSend(t *testing.T) {
|
||||
b := newBus()
|
||||
ch := b.Subscribe("agent-a")
|
||||
|
||||
msg := bus.AgentMessage{From: "orch", To: "agent-a", Kind: bus.KindTask, Payload: map[string]string{"k": "v"}}
|
||||
if err := b.Send(msg); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
got := <-ch
|
||||
if got.Kind != bus.KindTask || got.Payload["k"] != "v" {
|
||||
t.Fatalf("unexpected message: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeClosesChannel(t *testing.T) {
|
||||
b := newBus()
|
||||
ch := b.Subscribe("agent-b")
|
||||
|
||||
b.Unsubscribe("agent-b")
|
||||
|
||||
// Channel must be closed — reading from a closed channel returns zero value + ok=false.
|
||||
_, ok := <-ch
|
||||
if ok {
|
||||
t.Fatal("expected channel to be closed after Unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeRemovesFromBus(t *testing.T) {
|
||||
b := newBus()
|
||||
b.Subscribe("agent-c")
|
||||
b.Unsubscribe("agent-c")
|
||||
|
||||
// Sending after unsubscribe must return an error, not panic.
|
||||
err := b.Send(bus.AgentMessage{To: "agent-c", Kind: "ping"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sending to unsubscribed agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeIdempotent(t *testing.T) {
|
||||
b := newBus()
|
||||
b.Subscribe("agent-d")
|
||||
// Double unsubscribe must not panic.
|
||||
b.Unsubscribe("agent-d")
|
||||
b.Unsubscribe("agent-d")
|
||||
}
|
||||
|
||||
func TestUnsubscribeNonExistent(t *testing.T) {
|
||||
b := newBus()
|
||||
// Unsubscribing an ID that was never subscribed must not panic.
|
||||
b.Unsubscribe("does-not-exist")
|
||||
}
|
||||
|
||||
func TestSendToUnknownAgent(t *testing.T) {
|
||||
b := newBus()
|
||||
err := b.Send(bus.AgentMessage{To: "ghost", Kind: "hello"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when sending to unknown agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResubscribeAfterUnsubscribe(t *testing.T) {
|
||||
b := newBus()
|
||||
b.Subscribe("agent-e")
|
||||
b.Unsubscribe("agent-e")
|
||||
|
||||
// Re-subscribe must work and deliver messages.
|
||||
ch2 := b.Subscribe("agent-e")
|
||||
msg := bus.AgentMessage{To: "agent-e", Kind: "ping"}
|
||||
if err := b.Send(msg); err != nil {
|
||||
t.Fatalf("Send after re-subscribe: %v", err)
|
||||
}
|
||||
got := <-ch2
|
||||
if got.Kind != "ping" {
|
||||
t.Fatalf("unexpected kind: %q", got.Kind)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user