feat: implement session context switching via /session command

- Add SessionCommand with subcommands: `new`, `switch`, `ls`, `remove`
- Implement persistent user-session mapping in `state.Manager`
- Update SessionManager to support listing and deleting session files
- Update AgentLoop to dynamically resolve session keys based on active user context
- Enable isolated conversation contexts per user across all channels
This commit is contained in:
Tzufucius 2026-02-17 10:10:51 +08:00
parent de0fcf2514
commit 634c02f040
4 changed files with 267 additions and 2 deletions

View file

@ -143,6 +143,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
cmdRegistry.Register(&command.ShowCommand{}) cmdRegistry.Register(&command.ShowCommand{})
cmdRegistry.Register(&command.ListCommand{}) cmdRegistry.Register(&command.ListCommand{})
cmdRegistry.Register(&command.SwitchCommand{}) cmdRegistry.Register(&command.SwitchCommand{})
cmdRegistry.Register(&command.SessionCommand{}) // Register new session command
cmdRegistry.Register(&command.StartCommand{}) cmdRegistry.Register(&command.StartCommand{})
cmdRegistry.Register(&command.HelpCommand{Registry: cmdRegistry}) cmdRegistry.Register(&command.HelpCommand{Registry: cmdRegistry})
@ -162,6 +163,16 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
} }
} }
// GetSessionManager exposes the session manager.
func (al *AgentLoop) GetSessionManager() interface{} {
return al.sessions
}
// GetStateManager exposes the state manager.
func (al *AgentLoop) GetStateManager() interface{} {
return al.state
}
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true) al.running.Store(true)
@ -175,6 +186,19 @@ func (al *AgentLoop) Run(ctx context.Context) error {
continue continue
} }
// Resolve effective session key based on user's active session
// Default key is channel:chatID
// If user has active session "xyz", key becomes channel:chatID:xyz
baseKey := fmt.Sprintf("%s:%s", msg.Channel, msg.ChatID)
activeSession := al.state.GetUserSession(baseKey)
effectiveSessionKey := msg.SessionKey
if activeSession != "" && activeSession != "default" {
effectiveSessionKey = fmt.Sprintf("%s:%s", baseKey, activeSession)
// Update msg.SessionKey so downstream logic uses the correct context
msg.SessionKey = effectiveSessionKey
}
response, err := al.processMessage(ctx, msg) response, err := al.processMessage(ctx, msg)
if err != nil { if err != nil {
response = fmt.Sprintf("Error processing message: %v", err) response = fmt.Sprintf("Error processing message: %v", err)
@ -246,12 +270,21 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
} }
func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) { func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
// Resolve effective session key based on user's active session
baseKey := fmt.Sprintf("%s:%s", channel, chatID)
activeSession := al.state.GetUserSession(baseKey)
effectiveSessionKey := sessionKey
if activeSession != "" && activeSession != "default" {
effectiveSessionKey = fmt.Sprintf("%s:%s", baseKey, activeSession)
}
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: channel, Channel: channel,
SenderID: "cron", SenderID: "cron",
ChatID: chatID, ChatID: chatID,
Content: content, Content: content,
SessionKey: sessionKey, SessionKey: effectiveSessionKey,
} }
return al.processMessage(ctx, msg) return al.processMessage(ctx, msg)

160
pkg/command/session.go Normal file
View file

@ -0,0 +1,160 @@
package command
import (
"context"
"fmt"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
)
type SessionCommand struct{}
func (c *SessionCommand) Name() string {
return "/session"
}
func (c *SessionCommand) Description() string {
return "Manage conversation sessions (new, switch, ls, remove)"
}
func (c *SessionCommand) Execute(ctx context.Context, agent AgentState, args []string, msg bus.InboundMessage) (string, error) {
if len(args) < 1 {
return "Usage: /session <new|switch|ls|remove> [name]", nil
}
subcmd := args[0]
// Use ChatID from message for unique session base key channel:chatID
baseKey := fmt.Sprintf("%s:%s", msg.Channel, msg.ChatID)
// Define interfaces for accessing SessionManager and StateManager
type SessionManager interface {
ListSessions(prefix string) []string
DeleteSession(key string) error
}
type StateManager interface {
SetUserSession(userID, sessionName string) error
GetUserSession(userID string) string
}
type ManagersProvider interface {
GetSessionManager() interface{}
GetStateManager() interface{}
}
provider, ok := agent.(ManagersProvider)
if !ok {
return "Error: Agent does not support session management", nil
}
smRaw := provider.GetSessionManager()
stRaw := provider.GetStateManager()
sm, okSm := smRaw.(SessionManager)
st, okSt := stRaw.(StateManager)
if !okSm || !okSt {
return "Error: Failed to access internal managers", nil
}
switch subcmd {
case "ls", "list":
return c.handleList(sm, st, baseKey)
case "new", "switch":
if len(args) < 2 {
return fmt.Sprintf("Usage: /session %s <name>", subcmd), nil
}
name := args[1]
return c.handleSwitch(st, baseKey, name)
case "remove", "rm", "delete":
if len(args) < 2 {
return "Usage: /session remove <name>", nil
}
name := args[1]
return c.handleRemove(sm, st, baseKey, name)
default:
return fmt.Sprintf("Unknown subcommand: %s", subcmd), nil
}
}
func (c *SessionCommand) handleList(sm interface {
ListSessions(prefix string) []string
}, st interface {
GetUserSession(userID string) string
}, baseKey string) (string, error) {
keys := sm.ListSessions(baseKey)
currentSessionName := st.GetUserSession(baseKey)
if currentSessionName == "" {
currentSessionName = "default"
}
sessionMap := make(map[string]bool)
sessionMap["default"] = true // Default always implicitly exists
for _, key := range keys {
if key == baseKey {
sessionMap["default"] = true
} else if strings.HasPrefix(key, baseKey+":") {
name := strings.TrimPrefix(key, baseKey+":")
sessionMap[name] = true
}
}
var names []string
for name := range sessionMap {
names = append(names, name)
}
sort.Strings(names)
var sb strings.Builder
sb.WriteString("Available sessions:\n")
for _, name := range names {
marker := " "
if name == currentSessionName {
marker = "*"
}
sb.WriteString(fmt.Sprintf("%s %s\n", marker, name))
}
return sb.String(), nil
}
func (c *SessionCommand) handleSwitch(st interface {
SetUserSession(userID, sessionName string) error
}, baseKey, name string) (string, error) {
target := name
if name == "default" {
target = "" // empty string denotes default session in state
}
if err := st.SetUserSession(baseKey, target); err != nil {
return "", fmt.Errorf("failed to switch session: %w", err)
}
return fmt.Sprintf("Switched to session: %s", name), nil
}
func (c *SessionCommand) handleRemove(sm interface {
DeleteSession(key string) error
}, st interface {
GetUserSession(userID string) string
}, baseKey, name string) (string, error) {
if name == "default" {
return "Error: Cannot remove default session", nil
}
current := st.GetUserSession(baseKey)
if current == name {
return "Error: Cannot remove active session. Switch to another session first.", nil
}
// Session key format: baseKey:name
sessionKey := fmt.Sprintf("%s:%s", baseKey, name)
if err := sm.DeleteSession(sessionKey); err != nil {
return "", fmt.Errorf("failed to remove session: %w", err)
}
return fmt.Sprintf("Session '%s' removed.", name), nil
}

View file

@ -280,3 +280,41 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session.Updated = time.Now() session.Updated = time.Now()
} }
} }
// ListSessions returns a list of sessions that match the given prefix.
// The prefix is typically the channel:chatID part.
func (sm *SessionManager) ListSessions(prefix string) []string {
sm.mu.RLock()
defer sm.mu.RUnlock()
var sessions []string
for key := range sm.sessions {
if strings.HasPrefix(key, prefix) {
sessions = append(sessions, key)
}
}
return sessions
}
// DeleteSession removes a session from memory and disk.
func (sm *SessionManager) DeleteSession(key string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
// Remove from memory
delete(sm.sessions, key)
if sm.storage == "" {
return nil
}
// Remove from disk
filename := sanitizeFilename(key) + ".json"
sessionPath := filepath.Join(sm.storage, filename)
if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View file

@ -21,6 +21,9 @@ type State struct {
// Timestamp is the last time this state was updated // Timestamp is the last time this state was updated
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
// UserSessions maps user IDs to their active session names
UserSessions map[string]string `json:"user_sessions,omitempty"`
} }
// Manager manages persistent state with atomic saves. // Manager manages persistent state with atomic saves.
@ -153,7 +156,7 @@ func (sm *Manager) saveAtomic() error {
return nil return nil
} }
// load loads the state from disk. // Load loads the state from disk.
func (sm *Manager) load() error { func (sm *Manager) load() error {
data, err := os.ReadFile(sm.stateFile) data, err := os.ReadFile(sm.stateFile)
if err != nil { if err != nil {
@ -170,3 +173,34 @@ func (sm *Manager) load() error {
return nil return nil
} }
// SetUserSession atomically updates the active session for a user.
func (sm *Manager) SetUserSession(userID, sessionName string) error {
sm.mu.Lock()
defer sm.mu.Unlock()
if sm.state.UserSessions == nil {
sm.state.UserSessions = make(map[string]string)
}
sm.state.UserSessions[userID] = sessionName
sm.state.Timestamp = time.Now()
// Atomic save using temp file + rename
if err := sm.saveAtomic(); err != nil {
return fmt.Errorf("failed to save state atomically: %w", err)
}
return nil
}
// GetUserSession returns the active session for a user.
func (sm *Manager) GetUserSession(userID string) string {
sm.mu.RLock()
defer sm.mu.RUnlock()
if sm.state.UserSessions == nil {
return ""
}
return sm.state.UserSessions[userID]
}