mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 16:15:24 +00:00
Session keys must use agentKey (human-readable, e.g. "default") not UUID. Cache invalidation via InvalidateAgent(agentKey) only matches agentKey suffix, so UUID-keyed entries were never cleared — causing stale provider/model after agent updates from the web UI. - heartbeat: use agentKey (already resolved) instead of UUID for session key and HasActiveSessionsForAgent check - cron: resolve agentKey from UUID via agentStore before building session key - agents_update: also invalidate by UUID as safety net for legacy entries - startup migration: auto-fix existing UUID-based session keys in DB - tests: 7 new tests covering session key format and cache invalidation
190 lines
6.7 KiB
Go
190 lines
6.7 KiB
Go
// Package sessions — session key builder and parser.
|
|
//
|
|
// Session keys follow the TS OpenClaw canonical format:
|
|
//
|
|
// agent:{agentKey}:{rest}
|
|
//
|
|
// IMPORTANT: {agentKey} is the human-readable agent key (e.g. "default", "my-agent"),
|
|
// NOT the agent's UUID. All Build*SessionKey functions expect agentKey, not UUID.
|
|
// This is critical for cache invalidation — the agent router cache uses agentKey
|
|
// as the lookup key, and InvalidateAgent() matches by agentKey suffix.
|
|
// Tenant isolation is handled separately via context (tenant_id in DB, prefix in cache).
|
|
//
|
|
// Where {rest} depends on the session type:
|
|
//
|
|
// DM: {channel}:direct:{peerId}
|
|
// Group: {channel}:group:{groupId}
|
|
// Forum topic: {channel}:group:{groupId}:topic:{topicId}
|
|
// Subagent: subagent:{label}
|
|
// Cron: cron:{jobId}
|
|
//
|
|
// Examples:
|
|
//
|
|
// agent:default:telegram:direct:386246614
|
|
// agent:default:telegram:group:-100123456
|
|
// agent:default:telegram:group:-100123456:topic:99
|
|
// agent:default:subagent:my-task
|
|
// agent:default:cron:reminder-job-id
|
|
package sessions
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// PeerKind distinguishes DM from group conversations.
|
|
type PeerKind string
|
|
|
|
const (
|
|
PeerDirect PeerKind = "direct"
|
|
PeerGroup PeerKind = "group"
|
|
)
|
|
|
|
// BuildSessionKey builds the canonical agent session key for a channel conversation.
|
|
//
|
|
// DM: agent:{agentId}:{channel}:direct:{peerID}
|
|
// Group: agent:{agentId}:{channel}:group:{chatID}
|
|
func BuildSessionKey(agentID, channel string, kind PeerKind, chatID string) string {
|
|
return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, kind, chatID)
|
|
}
|
|
|
|
// BuildGroupTopicSessionKey builds the session key for a forum group topic.
|
|
// TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts
|
|
//
|
|
// agent:{agentId}:{channel}:group:{chatID}:topic:{topicID}
|
|
func BuildGroupTopicSessionKey(agentID, channel, chatID string, topicID int) string {
|
|
return fmt.Sprintf("agent:%s:%s:group:%s:topic:%d", agentID, channel, chatID, topicID)
|
|
}
|
|
|
|
// BuildDMThreadSessionKey builds the session key for a DM thread (topic in private chat).
|
|
// Preserves message_thread_id for session isolation within the same DM.
|
|
//
|
|
// agent:{agentId}:{channel}:direct:{peerID}:thread:{threadID}
|
|
func BuildDMThreadSessionKey(agentID, channel, peerID string, threadID int) string {
|
|
return fmt.Sprintf("agent:%s:%s:direct:%s:thread:%d", agentID, channel, peerID, threadID)
|
|
}
|
|
|
|
// BuildScopedThreadSessionKey builds a session key that includes a thread/topic ID.
|
|
// Supports string-based thread IDs (e.g. Slack timestamps).
|
|
//
|
|
// agent:{agentId}:{channel}:{kind}:{chatID}:thread:{threadID}
|
|
func BuildScopedThreadSessionKey(agentID, channel string, kind PeerKind, chatID, threadID string) string {
|
|
return fmt.Sprintf("agent:%s:%s:%s:%s:thread:%s", agentID, channel, kind, chatID, threadID)
|
|
}
|
|
|
|
// BuildSubagentSessionKey builds the session key for a subagent.
|
|
//
|
|
// agent:{agentId}:subagent:{label}
|
|
func BuildSubagentSessionKey(agentID, label string) string {
|
|
return fmt.Sprintf("agent:%s:subagent:%s", agentID, label)
|
|
}
|
|
|
|
// BuildTeamSessionKey builds an isolated session key for team task execution.
|
|
// Scoped per agent + team + chatID (user), matching workspace isolation.
|
|
// All tasks from the same user within the same team share one session per member agent.
|
|
//
|
|
// agent:{agentId}:team:{teamId}:{chatId}
|
|
func BuildTeamSessionKey(agentID, teamID, chatID string) string {
|
|
return fmt.Sprintf("agent:%s:team:%s:%s", agentID, teamID, chatID)
|
|
}
|
|
|
|
// IsTeamSession checks if a session key indicates a team session.
|
|
func IsTeamSession(key string) bool {
|
|
_, rest := ParseSessionKey(key)
|
|
return strings.HasPrefix(rest, "team:")
|
|
}
|
|
|
|
// BuildCronSessionKey builds the session key for a cron job.
|
|
// Each cron job gets one persistent session (all runs share the same history).
|
|
//
|
|
// agent:{agentId}:cron:{jobID}
|
|
//
|
|
// Guards against double-prefixing: if jobID is already a canonical session key
|
|
// (e.g. "agent:X:..."), only the rest part is used.
|
|
func BuildCronSessionKey(agentID, jobID string) string {
|
|
if _, rest := ParseSessionKey(jobID); rest != "" {
|
|
jobID = rest
|
|
}
|
|
return fmt.Sprintf("agent:%s:cron:%s", agentID, jobID)
|
|
}
|
|
|
|
// BuildAgentMainSessionKey builds the shared "main" session key for an agent.
|
|
// Used when dm_scope="main" — all DMs share one session per agent.
|
|
// Matching TS buildAgentMainSessionKey().
|
|
//
|
|
// agent:{agentId}:{mainKey}
|
|
func BuildAgentMainSessionKey(agentID, mainKey string) string {
|
|
if mainKey == "" {
|
|
mainKey = "main"
|
|
}
|
|
return fmt.Sprintf("agent:%s:%s", agentID, mainKey)
|
|
}
|
|
|
|
// BuildScopedSessionKey builds a session key using fixed scoping:
|
|
// - Groups: per-sender (full key with channel + group ID)
|
|
// - DMs: per-channel-peer (channel + peer user ID)
|
|
func BuildScopedSessionKey(agentID, channel string, kind PeerKind, chatID string) string {
|
|
return BuildSessionKey(agentID, channel, kind, chatID)
|
|
}
|
|
|
|
// ParseSessionKey extracts the agentID and rest from a canonical session key.
|
|
// Returns ("", "") if the key is not in the expected format.
|
|
func ParseSessionKey(key string) (agentID, rest string) {
|
|
parts := strings.SplitN(key, ":", 3)
|
|
if len(parts) < 3 || parts[0] != "agent" {
|
|
return "", ""
|
|
}
|
|
return parts[1], parts[2]
|
|
}
|
|
|
|
// IsSubagentSession checks if a session key indicates a subagent session.
|
|
func IsSubagentSession(key string) bool {
|
|
_, rest := ParseSessionKey(key)
|
|
return strings.HasPrefix(strings.ToLower(rest), "subagent:")
|
|
}
|
|
|
|
// IsCronSession checks if a session key indicates a cron session.
|
|
func IsCronSession(key string) bool {
|
|
_, rest := ParseSessionKey(key)
|
|
return strings.HasPrefix(strings.ToLower(rest), "cron:")
|
|
}
|
|
|
|
// BuildHeartbeatSessionKey builds the session key for a heartbeat run.
|
|
//
|
|
// isolated=true: agent:{agentId}:heartbeat:{unix_ms}
|
|
// isolated=false: agent:{agentId}:heartbeat
|
|
func BuildHeartbeatSessionKey(agentID string, isolated bool) string {
|
|
if isolated {
|
|
return fmt.Sprintf("agent:%s:heartbeat:%d", agentID, time.Now().UnixMilli())
|
|
}
|
|
return fmt.Sprintf("agent:%s:heartbeat", agentID)
|
|
}
|
|
|
|
// IsHeartbeatSession checks if a session key indicates a heartbeat session.
|
|
func IsHeartbeatSession(key string) bool {
|
|
_, rest := ParseSessionKey(key)
|
|
return strings.HasPrefix(rest, "heartbeat")
|
|
}
|
|
|
|
// BuildWSSessionKey builds the canonical WS session key for a web conversation.
|
|
//
|
|
// agent:{agentId}:ws:direct:{conversationId}
|
|
func BuildWSSessionKey(agentID, conversationID string) string {
|
|
return BuildSessionKey(agentID, "ws", PeerDirect, conversationID)
|
|
}
|
|
|
|
// IsWSSession checks if a session key is a WS session (new or legacy format).
|
|
func IsWSSession(key string) bool {
|
|
_, rest := ParseSessionKey(key)
|
|
return strings.HasPrefix(rest, "ws:") || strings.HasPrefix(rest, "ws-")
|
|
}
|
|
|
|
// PeerKindFromGroup returns PeerGroup if isGroup is true, PeerDirect otherwise.
|
|
func PeerKindFromGroup(isGroup bool) PeerKind {
|
|
if isGroup {
|
|
return PeerGroup
|
|
}
|
|
return PeerDirect
|
|
}
|