mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level priority routing cascade (peer > parent_peer > guild > team > account > channel > default). Backward compatible - empty agents.list creates implicit "main" agent from defaults. Core components: - routing/agent_id.go: ID normalization with pre-compiled regex - routing/session_key.go: 4 DM scope modes with identity links - routing/route.go: RouteResolver with priority-based binding matcher - agent/instance.go: Per-agent state (workspace, sessions, tools, model) - agent/registry.go: Agent lifecycle, route resolution, subagent ACL Integration: - config.go: AgentModelConfig (flexible JSON), bindings, session config - loop.go: Complete rewrite for multi-agent dispatch - Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack) - spawn.go: Subagent allowlist enforcement per agent Validated end-to-end with Discord channel-based bindings, default fallback routing, and per-agent session persistence.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultAgentID = "main"
|
||||
DefaultMainKey = "main"
|
||||
DefaultAccountID = "default"
|
||||
MaxAgentIDLength = 64
|
||||
)
|
||||
|
||||
var (
|
||||
validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
|
||||
invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`)
|
||||
leadingDashRe = regexp.MustCompile(`^-+`)
|
||||
trailingDashRe = regexp.MustCompile(`-+$`)
|
||||
)
|
||||
|
||||
// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}.
|
||||
// Invalid characters are collapsed to "-". Leading/trailing dashes stripped.
|
||||
// Empty input returns DefaultAgentID ("main").
|
||||
func NormalizeAgentID(id string) string {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return DefaultAgentID
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if validIDRe.MatchString(lower) {
|
||||
return lower
|
||||
}
|
||||
result := invalidCharsRe.ReplaceAllString(lower, "-")
|
||||
result = leadingDashRe.ReplaceAllString(result, "")
|
||||
result = trailingDashRe.ReplaceAllString(result, "")
|
||||
if len(result) > MaxAgentIDLength {
|
||||
result = result[:MaxAgentIDLength]
|
||||
}
|
||||
if result == "" {
|
||||
return DefaultAgentID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID.
|
||||
func NormalizeAccountID(id string) string {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return DefaultAccountID
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
if validIDRe.MatchString(lower) {
|
||||
return lower
|
||||
}
|
||||
result := invalidCharsRe.ReplaceAllString(lower, "-")
|
||||
result = leadingDashRe.ReplaceAllString(result, "")
|
||||
result = trailingDashRe.ReplaceAllString(result, "")
|
||||
if len(result) > MaxAgentIDLength {
|
||||
result = result[:MaxAgentIDLength]
|
||||
}
|
||||
if result == "" {
|
||||
return DefaultAccountID
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package routing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeAgentID_Empty(t *testing.T) {
|
||||
if got := NormalizeAgentID(""); got != DefaultAgentID {
|
||||
t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentID_Whitespace(t *testing.T) {
|
||||
if got := NormalizeAgentID(" "); got != DefaultAgentID {
|
||||
t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentID_Valid(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"main", "main"},
|
||||
{"Main", "main"},
|
||||
{"SALES", "sales"},
|
||||
{"support-bot", "support-bot"},
|
||||
{"agent_1", "agent_1"},
|
||||
{"a", "a"},
|
||||
{"0test", "0test"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := NormalizeAgentID(tt.input); got != tt.want {
|
||||
t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentID_InvalidChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"Hello World", "hello-world"},
|
||||
{"agent@123", "agent-123"},
|
||||
{"foo.bar.baz", "foo-bar-baz"},
|
||||
{"--leading", "leading"},
|
||||
{"--both--", "both"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := NormalizeAgentID(tt.input); got != tt.want {
|
||||
t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentID_AllInvalid(t *testing.T) {
|
||||
if got := NormalizeAgentID("@@@"); got != DefaultAgentID {
|
||||
t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "a"
|
||||
}
|
||||
got := NormalizeAgentID(long)
|
||||
if len(got) > MaxAgentIDLength {
|
||||
t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAccountID_Empty(t *testing.T) {
|
||||
if got := NormalizeAccountID(""); got != DefaultAccountID {
|
||||
t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAccountID_Valid(t *testing.T) {
|
||||
if got := NormalizeAccountID("MyBot"); got != "mybot" {
|
||||
t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAccountID_InvalidChars(t *testing.T) {
|
||||
if got := NormalizeAccountID("bot@home"); got != "bot-home" {
|
||||
t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// RouteInput contains the routing context from an inbound message.
|
||||
type RouteInput struct {
|
||||
Channel string
|
||||
AccountID string
|
||||
Peer *RoutePeer
|
||||
ParentPeer *RoutePeer
|
||||
GuildID string
|
||||
TeamID string
|
||||
}
|
||||
|
||||
// ResolvedRoute is the result of agent routing.
|
||||
type ResolvedRoute struct {
|
||||
AgentID string
|
||||
Channel string
|
||||
AccountID string
|
||||
SessionKey string
|
||||
MainSessionKey string
|
||||
MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default"
|
||||
}
|
||||
|
||||
// RouteResolver determines which agent handles a message based on config bindings.
|
||||
type RouteResolver struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewRouteResolver creates a new route resolver.
|
||||
func NewRouteResolver(cfg *config.Config) *RouteResolver {
|
||||
return &RouteResolver{cfg: cfg}
|
||||
}
|
||||
|
||||
// ResolveRoute determines which agent handles the message and constructs session keys.
|
||||
// Implements the 7-level priority cascade:
|
||||
// peer > parent_peer > guild > team > account > channel_wildcard > default
|
||||
func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
|
||||
channel := strings.ToLower(strings.TrimSpace(input.Channel))
|
||||
accountID := NormalizeAccountID(input.AccountID)
|
||||
peer := input.Peer
|
||||
|
||||
dmScope := DMScope(r.cfg.Session.DMScope)
|
||||
if dmScope == "" {
|
||||
dmScope = DMScopeMain
|
||||
}
|
||||
identityLinks := r.cfg.Session.IdentityLinks
|
||||
|
||||
bindings := r.filterBindings(channel, accountID)
|
||||
|
||||
choose := func(agentID string, matchedBy string) ResolvedRoute {
|
||||
resolvedAgentID := r.pickAgentID(agentID)
|
||||
sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: resolvedAgentID,
|
||||
Channel: channel,
|
||||
AccountID: accountID,
|
||||
Peer: peer,
|
||||
DMScope: dmScope,
|
||||
IdentityLinks: identityLinks,
|
||||
}))
|
||||
mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID))
|
||||
return ResolvedRoute{
|
||||
AgentID: resolvedAgentID,
|
||||
Channel: channel,
|
||||
AccountID: accountID,
|
||||
SessionKey: sessionKey,
|
||||
MainSessionKey: mainSessionKey,
|
||||
MatchedBy: matchedBy,
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: Peer binding
|
||||
if peer != nil && strings.TrimSpace(peer.ID) != "" {
|
||||
if match := r.findPeerMatch(bindings, peer); match != nil {
|
||||
return choose(match.AgentID, "binding.peer")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Parent peer binding
|
||||
parentPeer := input.ParentPeer
|
||||
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
|
||||
if match := r.findPeerMatch(bindings, parentPeer); match != nil {
|
||||
return choose(match.AgentID, "binding.peer.parent")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Guild binding
|
||||
guildID := strings.TrimSpace(input.GuildID)
|
||||
if guildID != "" {
|
||||
if match := r.findGuildMatch(bindings, guildID); match != nil {
|
||||
return choose(match.AgentID, "binding.guild")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Team binding
|
||||
teamID := strings.TrimSpace(input.TeamID)
|
||||
if teamID != "" {
|
||||
if match := r.findTeamMatch(bindings, teamID); match != nil {
|
||||
return choose(match.AgentID, "binding.team")
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 5: Account binding
|
||||
if match := r.findAccountMatch(bindings); match != nil {
|
||||
return choose(match.AgentID, "binding.account")
|
||||
}
|
||||
|
||||
// Priority 6: Channel wildcard binding
|
||||
if match := r.findChannelWildcardMatch(bindings); match != nil {
|
||||
return choose(match.AgentID, "binding.channel")
|
||||
}
|
||||
|
||||
// Priority 7: Default agent
|
||||
return choose(r.resolveDefaultAgentID(), "default")
|
||||
}
|
||||
|
||||
func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {
|
||||
var filtered []config.AgentBinding
|
||||
for _, b := range r.cfg.Bindings {
|
||||
matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))
|
||||
if matchChannel == "" || matchChannel != channel {
|
||||
continue
|
||||
}
|
||||
if !matchesAccountID(b.Match.AccountID, accountID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func matchesAccountID(matchAccountID, actual string) bool {
|
||||
trimmed := strings.TrimSpace(matchAccountID)
|
||||
if trimmed == "" {
|
||||
return actual == DefaultAccountID
|
||||
}
|
||||
if trimmed == "*" {
|
||||
return true
|
||||
}
|
||||
return strings.ToLower(trimmed) == strings.ToLower(actual)
|
||||
}
|
||||
|
||||
func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {
|
||||
for i := range bindings {
|
||||
b := &bindings[i]
|
||||
if b.Match.Peer == nil {
|
||||
continue
|
||||
}
|
||||
peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))
|
||||
peerID := strings.TrimSpace(b.Match.Peer.ID)
|
||||
if peerKind == "" || peerID == "" {
|
||||
continue
|
||||
}
|
||||
if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {
|
||||
for i := range bindings {
|
||||
b := &bindings[i]
|
||||
matchGuild := strings.TrimSpace(b.Match.GuildID)
|
||||
if matchGuild != "" && matchGuild == guildID {
|
||||
return &bindings[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {
|
||||
for i := range bindings {
|
||||
b := &bindings[i]
|
||||
matchTeam := strings.TrimSpace(b.Match.TeamID)
|
||||
if matchTeam != "" && matchTeam == teamID {
|
||||
return &bindings[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
||||
for i := range bindings {
|
||||
b := &bindings[i]
|
||||
accountID := strings.TrimSpace(b.Match.AccountID)
|
||||
if accountID == "*" {
|
||||
continue
|
||||
}
|
||||
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
||||
continue
|
||||
}
|
||||
return &bindings[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {
|
||||
for i := range bindings {
|
||||
b := &bindings[i]
|
||||
accountID := strings.TrimSpace(b.Match.AccountID)
|
||||
if accountID != "*" {
|
||||
continue
|
||||
}
|
||||
if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
|
||||
continue
|
||||
}
|
||||
return &bindings[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RouteResolver) pickAgentID(agentID string) string {
|
||||
trimmed := strings.TrimSpace(agentID)
|
||||
if trimmed == "" {
|
||||
return NormalizeAgentID(r.resolveDefaultAgentID())
|
||||
}
|
||||
normalized := NormalizeAgentID(trimmed)
|
||||
agents := r.cfg.Agents.List
|
||||
if len(agents) == 0 {
|
||||
return normalized
|
||||
}
|
||||
for _, a := range agents {
|
||||
if NormalizeAgentID(a.ID) == normalized {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return NormalizeAgentID(r.resolveDefaultAgentID())
|
||||
}
|
||||
|
||||
func (r *RouteResolver) resolveDefaultAgentID() string {
|
||||
agents := r.cfg.Agents.List
|
||||
if len(agents) == 0 {
|
||||
return DefaultAgentID
|
||||
}
|
||||
for _, a := range agents {
|
||||
if a.Default {
|
||||
id := strings.TrimSpace(a.ID)
|
||||
if id != "" {
|
||||
return NormalizeAgentID(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(agents[0].ID); id != "" {
|
||||
return NormalizeAgentID(id)
|
||||
}
|
||||
return DefaultAgentID
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config {
|
||||
return &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: "/tmp/picoclaw-test",
|
||||
Model: "gpt-4",
|
||||
},
|
||||
List: agents,
|
||||
},
|
||||
Bindings: bindings,
|
||||
Session: config.SessionConfig{
|
||||
DMScope: "per-peer",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
|
||||
cfg := testConfig(nil, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
})
|
||||
|
||||
if route.AgentID != DefaultAgentID {
|
||||
t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID)
|
||||
}
|
||||
if route.MatchedBy != "default" {
|
||||
t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_PeerBinding(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "sales", Default: true},
|
||||
{ID: "support"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "support",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "telegram",
|
||||
AccountID: "*",
|
||||
Peer: &config.PeerMatch{Kind: "direct", ID: "user123"},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
})
|
||||
|
||||
if route.AgentID != "support" {
|
||||
t.Errorf("AgentID = %q, want 'support'", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.peer" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_GuildBinding(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "general", Default: true},
|
||||
{ID: "gaming"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "gaming",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "discord",
|
||||
AccountID: "*",
|
||||
GuildID: "guild-abc",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "discord",
|
||||
GuildID: "guild-abc",
|
||||
Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
|
||||
})
|
||||
|
||||
if route.AgentID != "gaming" {
|
||||
t.Errorf("AgentID = %q, want 'gaming'", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.guild" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_TeamBinding(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "general", Default: true},
|
||||
{ID: "work"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "work",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "slack",
|
||||
AccountID: "*",
|
||||
TeamID: "T12345",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "slack",
|
||||
TeamID: "T12345",
|
||||
Peer: &RoutePeer{Kind: "channel", ID: "C001"},
|
||||
})
|
||||
|
||||
if route.AgentID != "work" {
|
||||
t.Errorf("AgentID = %q, want 'work'", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.team" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_AccountBinding(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "default-agent", Default: true},
|
||||
{ID: "premium"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "premium",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "telegram",
|
||||
AccountID: "bot2",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
AccountID: "bot2",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
})
|
||||
|
||||
if route.AgentID != "premium" {
|
||||
t.Errorf("AgentID = %q, want 'premium'", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.account" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_ChannelWildcard(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "main", Default: true},
|
||||
{ID: "telegram-bot"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "telegram-bot",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "telegram",
|
||||
AccountID: "*",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
|
||||
})
|
||||
|
||||
if route.AgentID != "telegram-bot" {
|
||||
t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.channel" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "general", Default: true},
|
||||
{ID: "vip"},
|
||||
{ID: "gaming"},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "vip",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "discord",
|
||||
AccountID: "*",
|
||||
Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"},
|
||||
},
|
||||
},
|
||||
{
|
||||
AgentID: "gaming",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "discord",
|
||||
AccountID: "*",
|
||||
GuildID: "guild-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "discord",
|
||||
GuildID: "guild-1",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
|
||||
})
|
||||
|
||||
if route.AgentID != "vip" {
|
||||
t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID)
|
||||
}
|
||||
if route.MatchedBy != "binding.peer" {
|
||||
t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "main", Default: true},
|
||||
}
|
||||
bindings := []config.AgentBinding{
|
||||
{
|
||||
AgentID: "nonexistent",
|
||||
Match: config.BindingMatch{
|
||||
Channel: "telegram",
|
||||
AccountID: "*",
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := testConfig(agents, bindings)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "telegram",
|
||||
})
|
||||
|
||||
if route.AgentID != "main" {
|
||||
t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "alpha"},
|
||||
{ID: "beta", Default: true},
|
||||
{ID: "gamma"},
|
||||
}
|
||||
cfg := testConfig(agents, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "cli",
|
||||
})
|
||||
|
||||
if route.AgentID != "beta" {
|
||||
t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
|
||||
agents := []config.AgentConfig{
|
||||
{ID: "alpha"},
|
||||
{ID: "beta"},
|
||||
}
|
||||
cfg := testConfig(agents, nil)
|
||||
r := NewRouteResolver(cfg)
|
||||
|
||||
route := r.ResolveRoute(RouteInput{
|
||||
Channel: "cli",
|
||||
})
|
||||
|
||||
if route.AgentID != "alpha" {
|
||||
t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DMScope controls DM session isolation granularity.
|
||||
type DMScope string
|
||||
|
||||
const (
|
||||
DMScopeMain DMScope = "main"
|
||||
DMScopePerPeer DMScope = "per-peer"
|
||||
DMScopePerChannelPeer DMScope = "per-channel-peer"
|
||||
DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
|
||||
)
|
||||
|
||||
// RoutePeer represents a chat peer with kind and ID.
|
||||
type RoutePeer struct {
|
||||
Kind string // "direct", "group", "channel"
|
||||
ID string
|
||||
}
|
||||
|
||||
// SessionKeyParams holds all inputs for session key construction.
|
||||
type SessionKeyParams struct {
|
||||
AgentID string
|
||||
Channel string
|
||||
AccountID string
|
||||
Peer *RoutePeer
|
||||
DMScope DMScope
|
||||
IdentityLinks map[string][]string
|
||||
}
|
||||
|
||||
// ParsedSessionKey is the result of parsing an agent-scoped session key.
|
||||
type ParsedSessionKey struct {
|
||||
AgentID string
|
||||
Rest string
|
||||
}
|
||||
|
||||
// BuildAgentMainSessionKey returns "agent:<agentId>:main".
|
||||
func BuildAgentMainSessionKey(agentID string) string {
|
||||
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
|
||||
}
|
||||
|
||||
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
|
||||
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
|
||||
agentID := NormalizeAgentID(params.AgentID)
|
||||
|
||||
peer := params.Peer
|
||||
if peer == nil {
|
||||
peer = &RoutePeer{Kind: "direct"}
|
||||
}
|
||||
peerKind := strings.TrimSpace(peer.Kind)
|
||||
if peerKind == "" {
|
||||
peerKind = "direct"
|
||||
}
|
||||
|
||||
if peerKind == "direct" {
|
||||
dmScope := params.DMScope
|
||||
if dmScope == "" {
|
||||
dmScope = DMScopeMain
|
||||
}
|
||||
peerID := strings.TrimSpace(peer.ID)
|
||||
|
||||
// Resolve identity links (cross-platform collapse)
|
||||
if dmScope != DMScopeMain && peerID != "" {
|
||||
if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" {
|
||||
peerID = linked
|
||||
}
|
||||
}
|
||||
peerID = strings.ToLower(peerID)
|
||||
|
||||
switch dmScope {
|
||||
case DMScopePerAccountChannelPeer:
|
||||
if peerID != "" {
|
||||
channel := normalizeChannel(params.Channel)
|
||||
accountID := NormalizeAccountID(params.AccountID)
|
||||
return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
|
||||
}
|
||||
case DMScopePerChannelPeer:
|
||||
if peerID != "" {
|
||||
channel := normalizeChannel(params.Channel)
|
||||
return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
|
||||
}
|
||||
case DMScopePerPeer:
|
||||
if peerID != "" {
|
||||
return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
|
||||
}
|
||||
}
|
||||
return BuildAgentMainSessionKey(agentID)
|
||||
}
|
||||
|
||||
// Group/channel peers always get per-peer sessions
|
||||
channel := normalizeChannel(params.Channel)
|
||||
peerID := strings.ToLower(strings.TrimSpace(peer.ID))
|
||||
if peerID == "" {
|
||||
peerID = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
|
||||
}
|
||||
|
||||
// ParseAgentSessionKey extracts agentId and rest from "agent:<agentId>:<rest>".
|
||||
func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
|
||||
raw := strings.TrimSpace(sessionKey)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(raw, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
if parts[0] != "agent" {
|
||||
return nil
|
||||
}
|
||||
agentID := strings.TrimSpace(parts[1])
|
||||
rest := parts[2]
|
||||
if agentID == "" || rest == "" {
|
||||
return nil
|
||||
}
|
||||
return &ParsedSessionKey{AgentID: agentID, Rest: rest}
|
||||
}
|
||||
|
||||
// IsSubagentSessionKey returns true if the session key represents a subagent.
|
||||
func IsSubagentSessionKey(sessionKey string) bool {
|
||||
raw := strings.TrimSpace(sessionKey)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
|
||||
return true
|
||||
}
|
||||
parsed := ParseAgentSessionKey(raw)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
|
||||
}
|
||||
|
||||
func normalizeChannel(channel string) string {
|
||||
c := strings.TrimSpace(strings.ToLower(channel))
|
||||
if c == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
|
||||
if len(identityLinks) == 0 {
|
||||
return ""
|
||||
}
|
||||
peerID = strings.TrimSpace(peerID)
|
||||
if peerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
candidates := make(map[string]bool)
|
||||
rawCandidate := strings.ToLower(peerID)
|
||||
if rawCandidate != "" {
|
||||
candidates[rawCandidate] = true
|
||||
}
|
||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||
if channel != "" {
|
||||
scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
|
||||
candidates[scopedCandidate] = true
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
for canonical, ids := range identityLinks {
|
||||
canonicalName := strings.TrimSpace(canonical)
|
||||
if canonicalName == "" {
|
||||
continue
|
||||
}
|
||||
for _, id := range ids {
|
||||
normalized := strings.ToLower(strings.TrimSpace(id))
|
||||
if normalized != "" && candidates[normalized] {
|
||||
return canonicalName
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package routing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildAgentMainSessionKey(t *testing.T) {
|
||||
got := BuildAgentMainSessionKey("sales")
|
||||
want := "agent:sales:main"
|
||||
if got != want {
|
||||
t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
|
||||
got := BuildAgentMainSessionKey("Sales Bot")
|
||||
want := "agent:sales-bot:main"
|
||||
if got != want {
|
||||
t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopeMain,
|
||||
})
|
||||
want := "agent:main:main"
|
||||
if got != want {
|
||||
t.Errorf("DMScopeMain = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
want := "agent:main:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerChannelPeer,
|
||||
})
|
||||
want := "agent:main:telegram:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
AccountID: "bot1",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "User123"},
|
||||
DMScope: DMScopePerAccountChannelPeer,
|
||||
})
|
||||
want := "agent:main:telegram:bot1:direct:user123"
|
||||
if got != want {
|
||||
t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "group", ID: "chat456"},
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
want := "agent:main:telegram:group:chat456"
|
||||
if got != want {
|
||||
t.Errorf("GroupPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: nil,
|
||||
DMScope: DMScopePerPeer,
|
||||
})
|
||||
// nil peer defaults to direct with empty ID, falls to main
|
||||
want := "agent:main:main"
|
||||
if got != want {
|
||||
t.Errorf("NilPeer = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
|
||||
links := map[string][]string{
|
||||
"john": {"telegram:user123", "discord:john#1234"},
|
||||
}
|
||||
got := BuildAgentPeerSessionKey(SessionKeyParams{
|
||||
AgentID: "main",
|
||||
Channel: "telegram",
|
||||
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
|
||||
DMScope: DMScopePerPeer,
|
||||
IdentityLinks: links,
|
||||
})
|
||||
want := "agent:main:direct:john"
|
||||
if got != want {
|
||||
t.Errorf("IdentityLink = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentSessionKey_Valid(t *testing.T) {
|
||||
parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
|
||||
if parsed == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if parsed.AgentID != "sales" {
|
||||
t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
|
||||
}
|
||||
if parsed.Rest != "telegram:direct:user123" {
|
||||
t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentSessionKey_Invalid(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"foo:bar",
|
||||
"notprefix:sales:main",
|
||||
"agent::main",
|
||||
"agent:sales:",
|
||||
}
|
||||
for _, input := range tests {
|
||||
if got := ParseAgentSessionKey(input); got != nil {
|
||||
t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSubagentSessionKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"subagent:task-1", true},
|
||||
{"agent:main:subagent:task-1", true},
|
||||
{"agent:main:main", false},
|
||||
{"agent:main:telegram:direct:user123", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsSubagentSessionKey(tt.input); got != tt.want {
|
||||
t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user