refactor(runtime): drop non-session legacy context compatibility

This commit is contained in:
Hoshina
2026-04-01 20:56:48 +08:00
parent ca9652e120
commit 59dee895fc
45 changed files with 1083 additions and 1427 deletions
+60 -19
View File
@@ -3,25 +3,21 @@ package routing
import (
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"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
}
// SessionPolicy describes how a routed message should be mapped to a session.
type SessionPolicy struct {
Dimensions []string
IdentityLinks map[string][]string
}
type RoutePeer struct {
Kind string
ID string
}
// ResolvedRoute is the result of agent routing.
type ResolvedRoute struct {
AgentID string
@@ -41,14 +37,15 @@ func NewRouteResolver(cfg *config.Config) *RouteResolver {
return &RouteResolver{cfg: cfg}
}
// ResolveRoute determines which agent handles the message and returns the
// session policy that should be used to allocate session state.
// ResolveRoute determines which agent handles the message from a normalized
// inbound context and returns the session policy that should be used to
// allocate session state.
// 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
func (r *RouteResolver) ResolveRoute(inbound bus.InboundContext) ResolvedRoute {
channel := strings.ToLower(strings.TrimSpace(inbound.Channel))
accountID := NormalizeAccountID(inbound.Account)
peer := routePeerFromContext(inbound)
sessionPolicy := r.sessionPolicy()
@@ -73,7 +70,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
}
// Priority 2: Parent peer binding
parentPeer := input.ParentPeer
parentPeer := parentPeerFromContext(inbound)
if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
if match := r.findPeerMatch(bindings, parentPeer); match != nil {
return choose(match.AgentID, "binding.peer.parent")
@@ -81,7 +78,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
}
// Priority 3: Guild binding
guildID := strings.TrimSpace(input.GuildID)
guildID := routeGuildIDFromContext(inbound)
if guildID != "" {
if match := r.findGuildMatch(bindings, guildID); match != nil {
return choose(match.AgentID, "binding.guild")
@@ -89,7 +86,7 @@ func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
}
// Priority 4: Team binding
teamID := strings.TrimSpace(input.TeamID)
teamID := routeTeamIDFromContext(inbound)
if teamID != "" {
if match := r.findTeamMatch(bindings, teamID); match != nil {
return choose(match.AgentID, "binding.team")
@@ -276,6 +273,46 @@ func normalizeSessionDimensions(dimensions []string) []string {
return normalized
}
func routePeerFromContext(ctx bus.InboundContext) *RoutePeer {
peerKind := normalizeChannel(strings.TrimSpace(ctx.ChatType))
if peerKind == "" || peerKind == "unknown" {
return nil
}
peerID := strings.TrimSpace(ctx.ChatID)
if peerKind == "direct" && peerID == "" {
peerID = strings.TrimSpace(ctx.SenderID)
}
if peerID == "" {
return nil
}
return &RoutePeer{Kind: peerKind, ID: peerID}
}
func parentPeerFromContext(ctx bus.InboundContext) *RoutePeer {
if topicID := strings.TrimSpace(ctx.TopicID); topicID != "" {
return &RoutePeer{Kind: "topic", ID: topicID}
}
return nil
}
func routeGuildIDFromContext(ctx bus.InboundContext) string {
if strings.EqualFold(strings.TrimSpace(ctx.SpaceType), "guild") {
return strings.TrimSpace(ctx.SpaceID)
}
return ""
}
func routeTeamIDFromContext(ctx bus.InboundContext) string {
switch strings.ToLower(strings.TrimSpace(ctx.SpaceType)) {
case "team", "workspace":
return strings.TrimSpace(ctx.SpaceID)
default:
return ""
}
}
func cloneIdentityLinks(src map[string][]string) map[string][]string {
if len(src) == 0 {
return nil
@@ -288,3 +325,7 @@ func cloneIdentityLinks(src map[string][]string) map[string][]string {
}
return cloned
}
func normalizeChannel(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
+39 -34
View File
@@ -3,6 +3,7 @@ package routing
import (
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -26,9 +27,10 @@ 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"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
ChatType: "direct",
SenderID: "user1",
})
if route.AgentID != DefaultAgentID {
@@ -63,9 +65,10 @@ func TestResolveRoute_PeerBinding(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user123"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
ChatType: "direct",
SenderID: "user123",
})
if route.AgentID != "support" {
@@ -94,10 +97,12 @@ func TestResolveRoute_GuildBinding(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "discord",
GuildID: "guild-abc",
Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "discord",
ChatID: "ch1",
ChatType: "channel",
SpaceID: "guild-abc",
SpaceType: "guild",
})
if route.AgentID != "gaming" {
@@ -126,10 +131,12 @@ func TestResolveRoute_TeamBinding(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "slack",
TeamID: "T12345",
Peer: &RoutePeer{Kind: "channel", ID: "C001"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "slack",
ChatID: "C001",
ChatType: "channel",
SpaceID: "T12345",
SpaceType: "team",
})
if route.AgentID != "work" {
@@ -157,10 +164,11 @@ func TestResolveRoute_AccountBinding(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "telegram",
AccountID: "bot2",
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
Account: "bot2",
ChatType: "direct",
SenderID: "user1",
})
if route.AgentID != "premium" {
@@ -188,9 +196,10 @@ func TestResolveRoute_ChannelWildcard(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "telegram",
Peer: &RoutePeer{Kind: "direct", ID: "user1"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "telegram",
ChatType: "direct",
SenderID: "user1",
})
if route.AgentID != "telegram-bot" {
@@ -228,10 +237,12 @@ func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "discord",
GuildID: "guild-1",
Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
route := r.ResolveRoute(bus.InboundContext{
Channel: "discord",
ChatType: "direct",
SenderID: "user-vip",
SpaceID: "guild-1",
SpaceType: "guild",
})
if route.AgentID != "vip" {
@@ -258,9 +269,7 @@ func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
cfg := testConfig(agents, bindings)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "telegram",
})
route := r.ResolveRoute(bus.InboundContext{Channel: "telegram"})
if route.AgentID != "main" {
t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
@@ -276,9 +285,7 @@ func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
cfg := testConfig(agents, nil)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "cli",
})
route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
if route.AgentID != "beta" {
t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
@@ -293,9 +300,7 @@ func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
cfg := testConfig(agents, nil)
r := NewRouteResolver(cfg)
route := r.ResolveRoute(RouteInput{
Channel: "cli",
})
route := r.ResolveRoute(bus.InboundContext{Channel: "cli"})
if route.AgentID != "alpha" {
t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
-218
View File
@@ -1,218 +0,0 @@
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 := CanonicalSessionPeerID(params.Channel, peer.ID, dmScope, params.IdentityLinks)
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)
}
// CanonicalSessionPeerID applies the current DM session canonicalization rules,
// including identity-link collapse when enabled.
func CanonicalSessionPeerID(
channel, peerID string,
dmScope DMScope,
identityLinks map[string][]string,
) string {
normalizedPeerID := strings.TrimSpace(peerID)
if normalizedPeerID == "" {
return ""
}
if dmScope != DMScopeMain {
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedPeerID); linked != "" {
normalizedPeerID = linked
}
}
return strings.ToLower(normalizedPeerID)
}
// CanonicalSessionIdentityID collapses an identity using identity_links when
// possible, then returns a normalized lowercase identifier.
func CanonicalSessionIdentityID(channel, rawID string, identityLinks map[string][]string) string {
normalizedID := strings.TrimSpace(rawID)
if normalizedID == "" {
return ""
}
if linked := resolveLinkedPeerID(identityLinks, channel, normalizedID); linked != "" {
normalizedID = linked
}
return strings.ToLower(normalizedID)
}
// 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 peerID is already in canonical "platform:id" format, also add the
// bare ID part as a candidate for backward compatibility with identity_links
// that use raw IDs (e.g. "123" instead of "telegram:123").
if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 {
bareID := rawCandidate[idx+1:]
candidates[bareID] = 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 ""
}
-207
View File
@@ -1,207 +0,0 @@
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 TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) {
// When peerID is already in canonical "platform:id" format,
// it should match identity_links that use the bare ID.
links := map[string][]string{
"john": {"123"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) {
// When identity_links contain canonical IDs and peerID is canonical too
links := map[string][]string{
"john": {"telegram:123", "discord:456"},
}
got := resolveLinkedPeerID(links, "telegram", "telegram:123")
if got != "john" {
t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) {
// When peerID is bare "123" and links have "telegram:123",
// the scoped candidate "telegram:123" should match.
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "telegram", "123")
if got != "john" {
t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john")
}
}
func TestResolveLinkedPeerID_NoMatch(t *testing.T) {
links := map[string][]string{
"john": {"telegram:123"},
}
got := resolveLinkedPeerID(links, "discord", "999")
if got != "" {
t.Errorf("resolveLinkedPeerID no match = %q, want empty", got)
}
}
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)
}
}
}