feat(routing): add ordered dispatch rules

This commit is contained in:
Hoshina
2026-04-01 22:13:04 +08:00
parent 82bfe0d9a0
commit bef17d6453
6 changed files with 524 additions and 31 deletions
+185 -4
View File
@@ -1,6 +1,7 @@
package routing
import (
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -19,7 +20,7 @@ type ResolvedRoute struct {
Channel string
AccountID string
SessionPolicy SessionPolicy
MatchedBy string // currently always "default" until the new binding system lands
MatchedBy string
}
// RouteResolver determines which agent handles a message.
@@ -38,12 +39,24 @@ func NewRouteResolver(cfg *config.Config) *RouteResolver {
func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute {
channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
accountID := NormalizeAccountID(inbound.Account)
identityLinks := cloneIdentityLinks(r.cfg.Session.IdentityLinks)
view := buildDispatchView(inbound, identityLinks)
if rule := r.matchDispatchRule(view); rule != nil {
return ResolvedRoute{
AgentID: r.pickAgentID(rule.Agent),
Channel: channel,
AccountID: accountID,
SessionPolicy: r.sessionPolicy(rule),
MatchedBy: matchedByForRule(rule),
}
}
return ResolvedRoute{
AgentID: r.pickAgentID(r.resolveDefaultAgentID()),
Channel: channel,
AccountID: accountID,
SessionPolicy: r.sessionPolicy(),
SessionPolicy: r.sessionPolicy(nil),
MatchedBy: "default",
}
}
@@ -85,9 +98,13 @@ func (r *RouteResolver) resolveDefaultAgentID() string {
return DefaultAgentID
}
func (r *RouteResolver) sessionPolicy() SessionPolicy {
func (r *RouteResolver) sessionPolicy(rule *config.DispatchRule) SessionPolicy {
dimensions := r.cfg.Session.Dimensions
if rule != nil && len(rule.SessionDimensions) > 0 {
dimensions = rule.SessionDimensions
}
return SessionPolicy{
Dimensions: normalizeSessionDimensions(r.cfg.Session.Dimensions),
Dimensions: normalizeSessionDimensions(dimensions),
IdentityLinks: cloneIdentityLinks(r.cfg.Session.IdentityLinks),
}
}
@@ -130,3 +147,167 @@ func cloneIdentityLinks(src map[string][]string) map[string][]string {
}
return cloned
}
type dispatchView struct {
Channel string
Account string
Space string
Chat string
Topic string
Sender string
Mentioned bool
}
func (r *RouteResolver) matchDispatchRule(view dispatchView) *config.DispatchRule {
if r.cfg == nil || r.cfg.Agents.Dispatch == nil || len(r.cfg.Agents.Dispatch.Rules) == 0 {
return nil
}
for i := range r.cfg.Agents.Dispatch.Rules {
rule := &r.cfg.Agents.Dispatch.Rules[i]
if !selectorHasAnyConstraint(rule.When) {
continue
}
if ruleMatchesView(*rule, view) {
return rule
}
}
return nil
}
func ruleMatchesView(rule config.DispatchRule, view dispatchView) bool {
when := normalizeDispatchSelector(rule.When)
if when.Channel != "" && when.Channel != view.Channel {
return false
}
if when.Account != "" && when.Account != view.Account {
return false
}
if when.Space != "" && when.Space != view.Space {
return false
}
if when.Chat != "" && when.Chat != view.Chat {
return false
}
if when.Topic != "" && when.Topic != view.Topic {
return false
}
if when.Sender != "" && when.Sender != view.Sender {
return false
}
if when.Mentioned != nil && *when.Mentioned != view.Mentioned {
return false
}
return true
}
func matchedByForRule(rule *config.DispatchRule) string {
if rule == nil {
return "default"
}
name := strings.TrimSpace(rule.Name)
if name == "" {
return "dispatch.rule"
}
return "dispatch.rule:" + strings.ToLower(name)
}
func buildDispatchView(inbound bus.InboundContext, identityLinks map[string][]string) dispatchView {
view := dispatchView{
Channel: strings.ToLower(strings.TrimSpace(inbound.Channel)),
Account: NormalizeAccountID(inbound.Account),
Mentioned: inbound.Mentioned,
}
if spaceID := strings.TrimSpace(inbound.SpaceID); spaceID != "" {
spaceType := strings.ToLower(strings.TrimSpace(inbound.SpaceType))
if spaceType == "" {
spaceType = "space"
}
view.Space = fmt.Sprintf("%s:%s", spaceType, strings.ToLower(spaceID))
}
if chatID := strings.TrimSpace(inbound.ChatID); chatID != "" {
chatType := strings.ToLower(strings.TrimSpace(inbound.ChatType))
if chatType == "" {
chatType = "direct"
}
view.Chat = fmt.Sprintf("%s:%s", chatType, strings.ToLower(chatID))
}
if topicID := strings.TrimSpace(inbound.TopicID); topicID != "" {
view.Topic = "topic:" + strings.ToLower(topicID)
}
view.Sender = canonicalDispatchSenderID(inbound.Channel, inbound.SenderID, identityLinks)
return view
}
func normalizeDispatchSelector(selector config.DispatchSelector) config.DispatchSelector {
selector.Channel = strings.ToLower(strings.TrimSpace(selector.Channel))
selector.Account = NormalizeAccountID(selector.Account)
selector.Space = strings.ToLower(strings.TrimSpace(selector.Space))
selector.Chat = strings.ToLower(strings.TrimSpace(selector.Chat))
selector.Topic = strings.ToLower(strings.TrimSpace(selector.Topic))
selector.Sender = strings.ToLower(strings.TrimSpace(selector.Sender))
return selector
}
func selectorHasAnyConstraint(selector config.DispatchSelector) bool {
return strings.TrimSpace(selector.Channel) != "" ||
strings.TrimSpace(selector.Account) != "" ||
strings.TrimSpace(selector.Space) != "" ||
strings.TrimSpace(selector.Chat) != "" ||
strings.TrimSpace(selector.Topic) != "" ||
strings.TrimSpace(selector.Sender) != "" ||
selector.Mentioned != nil
}
func canonicalDispatchSenderID(channel, rawID string, identityLinks map[string][]string) string {
normalizedID := strings.TrimSpace(rawID)
if normalizedID == "" {
return ""
}
if linked := resolveLinkedDispatchID(identityLinks, channel, normalizedID); linked != "" {
normalizedID = linked
}
return strings.ToLower(normalizedID)
}
func resolveLinkedDispatchID(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 != "" {
candidates[fmt.Sprintf("%s:%s", channel, rawCandidate)] = true
}
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
candidates[rawCandidate[idx+1:]] = true
}
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 ""
}
+116
View File
@@ -71,6 +71,122 @@ func TestResolveRoute_UsesNormalizedInboundContextFields(t *testing.T) {
}
}
func TestResolveRoute_DispatchFirstMatchWins(t *testing.T) {
cfg := testConfig([]config.AgentConfig{
{ID: "main", Default: true},
{ID: "support"},
{ID: "sales"},
})
cfg.Agents.Dispatch = &config.DispatchConfig{
Rules: []config.DispatchRule{
{
Name: "support-group",
Agent: "support",
When: config.DispatchSelector{
Channel: "telegram",
Chat: "group:-100123",
},
},
{
Name: "vip-in-group",
Agent: "sales",
When: config.DispatchSelector{
Channel: "telegram",
Chat: "group:-100123",
Sender: "12345",
},
},
},
}
r := NewRouteResolver(cfg)
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
ChatID: "-100123",
ChatType: "group",
SenderID: "12345",
})
if route.AgentID != "support" {
t.Fatalf("AgentID = %q, want support", route.AgentID)
}
if route.MatchedBy != "dispatch.rule:support-group" {
t.Fatalf("MatchedBy = %q, want dispatch.rule:support-group", route.MatchedBy)
}
}
func TestResolveRoute_DispatchOverridesSessionDimensions(t *testing.T) {
cfg := testConfig([]config.AgentConfig{
{ID: "main", Default: true},
{ID: "support"},
})
cfg.Session.Dimensions = []string{"chat"}
cfg.Agents.Dispatch = &config.DispatchConfig{
Rules: []config.DispatchRule{
{
Name: "support-dm",
Agent: "support",
When: config.DispatchSelector{
Channel: "telegram",
Chat: "direct:user-1",
},
SessionDimensions: []string{"chat", "sender"},
},
},
}
r := NewRouteResolver(cfg)
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
ChatID: "user-1",
ChatType: "direct",
SenderID: "user-1",
})
if route.AgentID != "support" {
t.Fatalf("AgentID = %q, want support", route.AgentID)
}
if got := route.SessionPolicy.Dimensions; len(got) != 2 || got[0] != "chat" || got[1] != "sender" {
t.Fatalf("SessionPolicy.Dimensions = %v, want [chat sender]", got)
}
}
func TestResolveRoute_DispatchMentionedRule(t *testing.T) {
cfg := testConfig([]config.AgentConfig{
{ID: "main", Default: true},
{ID: "support"},
})
mentioned := true
cfg.Agents.Dispatch = &config.DispatchConfig{
Rules: []config.DispatchRule{
{
Name: "slack-mentions",
Agent: "support",
When: config.DispatchSelector{
Channel: "slack",
Space: "workspace:t001",
Mentioned: &mentioned,
},
},
},
}
r := NewRouteResolver(cfg)
route := r.ResolveRoute(bus.InboundContext{
Channel: "slack",
ChatID: "C123",
ChatType: "channel",
SpaceID: "T001",
SpaceType: "workspace",
SenderID: "U123",
Mentioned: true,
})
if route.AgentID != "support" {
t.Fatalf("AgentID = %q, want support", route.AgentID)
}
}
func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
agents := []config.AgentConfig{
{ID: "main", Default: true},