mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
272536a11a
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.
253 lines
6.7 KiB
Go
253 lines
6.7 KiB
Go
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
|
|
}
|