feat(multiagent): add TPCP adapter for cross-device agent communication
Adds pkg/multiagent/tpcp — a thin wrapper around the TPCP Go SDK that gives PicoClaw agents network-level messaging without replacing the existing in-process blackboard. Key capabilities: - Ed25519 identity (generated or deterministic from 32-byte seed) - Direct P2P WebSocket listener (ListenAsync) + outbound Connect - Relay support for NAT traversal (wss://relay.agent-telepathy.io) - BROADCAST / TASK_REQUEST / CRITIQUE intent routing to OnMessage handlers - Dead-letter queue: messages to offline peers are delivered on reconnect - Thread-safe handler registration; chainable OnMessage API Closes #294
This commit is contained in:
parent
7bf12c3d5f
commit
eb42eb31ca
3 changed files with 416 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -5,6 +5,7 @@ go 1.25.7
|
|||
require (
|
||||
fyne.io/systray v1.12.0
|
||||
github.com/adhocore/gronx v1.19.6
|
||||
github.com/Etriti00/agent-telepathy/tpcp-go v0.4.1
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
|
|
|
|||
258
pkg/multiagent/tpcp/adapter.go
Normal file
258
pkg/multiagent/tpcp/adapter.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Package tpcp provides TPCP (Telepathy Communication Protocol) integration
|
||||
// for PicoClaw multi-agent coordination across multiple devices.
|
||||
//
|
||||
// TPCP solves the network transport problem that the in-process blackboard
|
||||
// cannot: agents running on different machines (Raspberry Pi, cloud VMs,
|
||||
// laptops) can send signed messages, synchronize shared state via CRDT, and
|
||||
// guarantee message delivery via a dead-letter queue.
|
||||
//
|
||||
// Usage — two PicoClaw instances communicating:
|
||||
//
|
||||
// // Device A (192.168.1.10)
|
||||
// nodeA, _ := tpcp.NewAdapter("picoclaw-agent-a", nil)
|
||||
// nodeA.OnMessage(func(from, content string) {
|
||||
// fmt.Printf("Received from %s: %s\n", from, content)
|
||||
// })
|
||||
// nodeA.ListenAsync(":8765")
|
||||
//
|
||||
// // Device B (192.168.1.20) — connects to Device A
|
||||
// nodeB, _ := tpcp.NewAdapter("picoclaw-agent-b", nil)
|
||||
// nodeB.Connect("ws://192.168.1.10:8765")
|
||||
// nodeB.Send(context.Background(), "picoclaw-agent-a", "hello from device B")
|
||||
//
|
||||
// For relay-based routing (internet/NAT traversal), pass a relay URL as a peer:
|
||||
//
|
||||
// node.Connect("wss://relay.agent-telepathy.io")
|
||||
// node.Send(ctx, "picoclaw-remote-agent", "hello over relay")
|
||||
package tpcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
tpcpgo "github.com/Etriti00/agent-telepathy/tpcp-go/tpcp"
|
||||
)
|
||||
|
||||
// Config holds optional configuration for an Adapter. All fields are optional;
|
||||
// zero values select safe defaults.
|
||||
type Config struct {
|
||||
// PrivateKeySeed is a 32-byte Ed25519 seed. If nil, a new key is generated.
|
||||
PrivateKeySeed []byte
|
||||
|
||||
// Framework identifies the agent runtime in identity announcements.
|
||||
// Defaults to "picoclaw".
|
||||
Framework string
|
||||
|
||||
// Capabilities advertises what this agent can do.
|
||||
// Defaults to ["chat", "task"].
|
||||
Capabilities []string
|
||||
|
||||
// AllowedOrigins restricts WebSocket browser origins for the listener.
|
||||
// If empty, all non-browser connections are accepted.
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// MessageHandler is called for every inbound TPCP message.
|
||||
type MessageHandler func(from, content string)
|
||||
|
||||
// Adapter wraps a TPCP node with a PicoClaw-friendly interface.
|
||||
// It is safe for concurrent use.
|
||||
type Adapter struct {
|
||||
node *tpcpgo.TPCPNode
|
||||
agentID string
|
||||
handlers []MessageHandler
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAdapter creates a TPCP adapter for a PicoClaw agent.
|
||||
//
|
||||
// agentID should be the same stable identifier used by the PicoClaw instance
|
||||
// (e.g. its configured agent name or a UUID). cfg may be nil to use defaults.
|
||||
func NewAdapter(agentID string, cfg *Config) (*Adapter, error) {
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("tpcp: agentID must not be empty")
|
||||
}
|
||||
|
||||
c := resolveConfig(cfg)
|
||||
|
||||
// Build Ed25519 keypair from seed or generate a fresh one.
|
||||
var privKey ed25519.PrivateKey
|
||||
var pubKey ed25519.PublicKey
|
||||
|
||||
if len(c.PrivateKeySeed) > 0 {
|
||||
if len(c.PrivateKeySeed) != 32 {
|
||||
return nil, fmt.Errorf("tpcp: PrivateKeySeed must be exactly 32 bytes (got %d)", len(c.PrivateKeySeed))
|
||||
}
|
||||
privKey = ed25519.NewKeyFromSeed(c.PrivateKeySeed)
|
||||
pubKey = privKey.Public().(ed25519.PublicKey)
|
||||
} else {
|
||||
var err error
|
||||
pubKey, privKey, err = ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tpcp: key generation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
identity := &tpcpgo.AgentIdentity{
|
||||
AgentID: agentID,
|
||||
Framework: c.Framework,
|
||||
PublicKey: base64.StdEncoding.EncodeToString(pubKey),
|
||||
Capabilities: c.Capabilities,
|
||||
Modality: []string{"text"},
|
||||
}
|
||||
|
||||
node := tpcpgo.NewTPCPNode(identity, privKey)
|
||||
node.AllowedOrigins = c.AllowedOrigins
|
||||
|
||||
a := &Adapter{node: node, agentID: agentID}
|
||||
|
||||
// Route all inbound BROADCAST and TASK_REQUEST messages to registered handlers.
|
||||
for _, intent := range []tpcpgo.Intent{tpcpgo.IntentBroadcast, tpcpgo.IntentTaskRequest, tpcpgo.IntentCritique} {
|
||||
intent := intent // capture
|
||||
node.RegisterHandler(intent, func(env *tpcpgo.TPCPEnvelope) {
|
||||
content := extractContent(env)
|
||||
a.mu.RLock()
|
||||
handlers := a.handlers
|
||||
a.mu.RUnlock()
|
||||
for _, h := range handlers {
|
||||
h(env.Header.SenderID, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// OnMessage registers a handler for inbound messages. Multiple handlers can be
|
||||
// registered; they are called in registration order for each message.
|
||||
func (a *Adapter) OnMessage(h MessageHandler) *Adapter {
|
||||
a.mu.Lock()
|
||||
a.handlers = append(a.handlers, h)
|
||||
a.mu.Unlock()
|
||||
return a
|
||||
}
|
||||
|
||||
// ListenAsync starts the TPCP WebSocket server in a background goroutine and
|
||||
// returns immediately once the port is bound. addr follows net.Listen syntax
|
||||
// (e.g. ":8765" or "0.0.0.0:8765").
|
||||
//
|
||||
// Errors after startup (e.g. accept errors) are silently logged by the
|
||||
// underlying TPCP node. Use Listen() if you need the goroutine under your
|
||||
// own supervision.
|
||||
func (a *Adapter) ListenAsync(addr string) error {
|
||||
ready := make(chan error, 1)
|
||||
go func() {
|
||||
if err := a.node.Listen(addr); err != nil {
|
||||
ready <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for the node's Ready channel (closed once the server is bound).
|
||||
select {
|
||||
case err := <-ready:
|
||||
return fmt.Errorf("tpcp: listen on %s failed: %w", addr, err)
|
||||
case <-a.node.Ready:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Connect opens a WebSocket connection to a peer or relay at url.
|
||||
// url should be a WebSocket URL, e.g.:
|
||||
// - "ws://192.168.1.10:8765" — direct P2P to another PicoClaw device
|
||||
// - "wss://relay.example.com" — relay for NAT traversal
|
||||
//
|
||||
// Connect performs a TPCP HANDSHAKE so the remote node registers this agent.
|
||||
// It returns once the connection is established; message exchange happens
|
||||
// asynchronously.
|
||||
func (a *Adapter) Connect(url string) error {
|
||||
if err := a.node.Connect(url); err != nil {
|
||||
return fmt.Errorf("tpcp: connect to %s failed: %w", url, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends a text message to the agent identified by targetID.
|
||||
// The message is signed with this node's Ed25519 key before transmission.
|
||||
//
|
||||
// If the target is not currently connected, the message is queued in the
|
||||
// dead-letter queue (DLQ) and delivered when connectivity is restored.
|
||||
func (a *Adapter) Send(_ context.Context, targetID, message string) error {
|
||||
payload := &tpcpgo.TextPayload{
|
||||
PayloadType: "text",
|
||||
Content: message,
|
||||
Language: "en",
|
||||
}
|
||||
if err := a.node.SendMessage(a.agentID, targetID, tpcpgo.IntentBroadcast, payload); err != nil {
|
||||
return fmt.Errorf("tpcp: send to %s failed: %w", targetID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast sends a text message to all connected peers.
|
||||
func (a *Adapter) Broadcast(_ context.Context, message string) error {
|
||||
payload := &tpcpgo.TextPayload{
|
||||
PayloadType: "text",
|
||||
Content: message,
|
||||
Language: "en",
|
||||
}
|
||||
const broadcastID = "00000000-0000-0000-0000-000000000000"
|
||||
if err := a.node.SendMessage(a.agentID, broadcastID, tpcpgo.IntentBroadcast, payload); err != nil {
|
||||
return fmt.Errorf("tpcp: broadcast failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentID returns the stable agent identifier for this node.
|
||||
func (a *Adapter) AgentID() string { return a.agentID }
|
||||
|
||||
// Stop shuts down the TPCP node, closing all connections.
|
||||
func (a *Adapter) Stop() error {
|
||||
return a.node.Stop()
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func resolveConfig(cfg *Config) Config {
|
||||
c := Config{
|
||||
Framework: "picoclaw",
|
||||
Capabilities: []string{"chat", "task"},
|
||||
}
|
||||
if cfg == nil {
|
||||
return c
|
||||
}
|
||||
if cfg.Framework != "" {
|
||||
c.Framework = cfg.Framework
|
||||
}
|
||||
if len(cfg.Capabilities) > 0 {
|
||||
c.Capabilities = cfg.Capabilities
|
||||
}
|
||||
if len(cfg.PrivateKeySeed) > 0 {
|
||||
c.PrivateKeySeed = cfg.PrivateKeySeed
|
||||
}
|
||||
if len(cfg.AllowedOrigins) > 0 {
|
||||
c.AllowedOrigins = cfg.AllowedOrigins
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func extractContent(env *tpcpgo.TPCPEnvelope) string {
|
||||
if env.Payload == nil {
|
||||
return ""
|
||||
}
|
||||
// Payload is interface{}; try common shapes.
|
||||
switch p := env.Payload.(type) {
|
||||
case map[string]interface{}:
|
||||
if c, ok := p["content"].(string); ok {
|
||||
return c
|
||||
}
|
||||
// Fallback: JSON-encode the whole payload
|
||||
return fmt.Sprintf("%v", p)
|
||||
case string:
|
||||
return p
|
||||
default:
|
||||
return fmt.Sprintf("%v", p)
|
||||
}
|
||||
}
|
||||
157
pkg/multiagent/tpcp/adapter_test.go
Normal file
157
pkg/multiagent/tpcp/adapter_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package tpcp_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tpcpadapter "github.com/sipeed/picoclaw/pkg/multiagent/tpcp"
|
||||
)
|
||||
|
||||
func TestNewAdapter_EmptyAgentID(t *testing.T) {
|
||||
_, err := tpcpadapter.NewAdapter("", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty agentID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdapter_InvalidSeedLength(t *testing.T) {
|
||||
cfg := &tpcpadapter.Config{
|
||||
PrivateKeySeed: []byte("short"),
|
||||
}
|
||||
_, err := tpcpadapter.NewAdapter("test-agent", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for seed with wrong length, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdapter_ValidSeed(t *testing.T) {
|
||||
seed := make([]byte, 32)
|
||||
cfg := &tpcpadapter.Config{
|
||||
PrivateKeySeed: seed,
|
||||
}
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if a.AgentID() != "test-agent" {
|
||||
t.Errorf("expected agentID 'test-agent', got %q", a.AgentID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdapter_NilConfig(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error with nil config: %v", err)
|
||||
}
|
||||
if a.AgentID() != "test-agent" {
|
||||
t.Errorf("expected agentID 'test-agent', got %q", a.AgentID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdapter_GeneratesKey(t *testing.T) {
|
||||
a1, err1 := tpcpadapter.NewAdapter("agent-1", nil)
|
||||
a2, err2 := tpcpadapter.NewAdapter("agent-2", nil)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("unexpected errors: %v, %v", err1, err2)
|
||||
}
|
||||
_ = a1
|
||||
_ = a2
|
||||
}
|
||||
|
||||
func TestOnMessage_Chainable(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
returned := a.OnMessage(func(from, content string) {})
|
||||
if returned != a {
|
||||
t.Error("OnMessage should return the same adapter for chaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnMessage_MultipleHandlers(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count := 0
|
||||
a.OnMessage(func(from, content string) { count++ })
|
||||
a.OnMessage(func(from, content string) { count++ })
|
||||
_ = count
|
||||
}
|
||||
|
||||
func TestAgentID(t *testing.T) {
|
||||
id := "picoclaw-device-001"
|
||||
a, err := tpcpadapter.NewAdapter(id, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := a.AgentID(); got != id {
|
||||
t.Errorf("AgentID() = %q, want %q", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop_BeforeConnect(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = a.Stop()
|
||||
}
|
||||
|
||||
func TestConnect_InvalidURL(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = a.Connect("ws://127.0.0.1:19999")
|
||||
if err == nil {
|
||||
t.Error("expected connection error for unreachable address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_NotConnected(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
_ = a.Send(ctx, "other-agent", "hello")
|
||||
}
|
||||
|
||||
func TestListenAsync_ThenStop(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := a.ListenAsync(":0"); err != nil {
|
||||
t.Fatalf("ListenAsync failed: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := a.Stop(); err != nil {
|
||||
t.Errorf("Stop failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Defaults(t *testing.T) {
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
|
||||
func TestConfig_CustomFramework(t *testing.T) {
|
||||
cfg := &tpcpadapter.Config{
|
||||
Framework: "custom-framework",
|
||||
Capabilities: []string{"vision", "tool-use"},
|
||||
}
|
||||
a, err := tpcpadapter.NewAdapter("test-agent", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
_ = a
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue