mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 08:11:09 +00:00
08a2d95c0c
Phase 1 (Core):
- Migration 000022: agent_heartbeats, heartbeat_run_logs, agent_config_permissions tables
- HeartbeatStore + ConfigPermissionStore interfaces with PG implementations
- HeartbeatTicker: background poll → active hours filter → queue-aware skip → run → smart suppression → deliver/log
- Heartbeat tool: status/get/set/toggle/set_checklist/get_checklist/test/logs actions
- Permission check with wildcard scope matching + TTL cache (60s)
- RPC methods: heartbeat.get/set/toggle/test/logs/checklist.get/checklist.set
- HEARTBEAT.md routed via context file interceptor (read/write for both open + predefined agents)
- Session keys: agent:{id}:heartbeat or agent:{id}💓{ts} (isolated)
- PromptMinimal for heartbeat sessions (like cron/subagent)
- Event broadcasting + cache invalidation via bus (heartbeat + config_perms)
- Gateway wiring: ticker init, event wiring, graceful shutdown
Phase 2 (Integration):
- wakeMode: CronPayload.WakeHeartbeat triggers heartbeat after cron job completes
- Queue-aware: Scheduler.HasActiveSessionsForAgent() skips busy agents
- Stagger: deterministic FNV offset spreads heartbeats across interval
- lightContext: RunRequest.LightContext skips context files, only injects checklist
- System prompt distinguishes cron (user-scheduled tasks) vs heartbeat (autonomous monitoring)
193 lines
6.5 KiB
Go
193 lines
6.5 KiB
Go
// Package sessions — session key builder and parser.
|
|
//
|
|
// Session keys follow the TS OpenClaw canonical format:
|
|
//
|
|
// agent:{agentId}:{rest}
|
|
//
|
|
// 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)
|
|
}
|
|
|
|
// 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 session key based on scope config.
|
|
// Matching TS src/routing/session-key.ts buildAgentPeerSessionKey().
|
|
//
|
|
// scope:
|
|
// - "global" → "global"
|
|
// - "per-sender" → depends on dmScope (default)
|
|
//
|
|
// dmScope (for DMs only — groups always use full key):
|
|
// - "main" → agent:{agentId}:{mainKey}
|
|
// - "per-peer" → agent:{agentId}:direct:{peerId}
|
|
// - "per-channel-peer" → agent:{agentId}:{channel}:direct:{peerId} (default)
|
|
// - "per-account-channel-peer" → agent:{agentId}:{channel}:{accountId}:direct:{peerId}
|
|
func BuildScopedSessionKey(agentID, channel string, kind PeerKind, chatID, scope, dmScope, mainKey string) string {
|
|
// Global scope: one session for everything
|
|
if scope == "global" {
|
|
return "global"
|
|
}
|
|
|
|
// Groups always use full key (matching TS)
|
|
if kind == PeerGroup {
|
|
return BuildSessionKey(agentID, channel, kind, chatID)
|
|
}
|
|
|
|
// DM scope modes
|
|
switch dmScope {
|
|
case "main":
|
|
return BuildAgentMainSessionKey(agentID, mainKey)
|
|
case "per-peer":
|
|
return fmt.Sprintf("agent:%s:direct:%s", agentID, chatID)
|
|
case "per-account-channel-peer":
|
|
// accountId not yet wired — falls through to per-channel-peer behavior
|
|
return BuildSessionKey(agentID, channel, kind, chatID)
|
|
default: // "per-channel-peer" or empty
|
|
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")
|
|
}
|
|
|
|
// PeerKindFromGroup returns PeerGroup if isGroup is true, PeerDirect otherwise.
|
|
func PeerKindFromGroup(isGroup bool) PeerKind {
|
|
if isGroup {
|
|
return PeerGroup
|
|
}
|
|
return PeerDirect
|
|
}
|