feat(session): implement session reset policy for messaging platforms
- Added configuration options for automatic session resets based on inactivity or daily boundaries in cli-config.yaml. - Enhanced SessionResetPolicy class to support a "none" mode for no auto-resets. - Implemented memory flushing before session resets in SessionStore to preserve important information. - Updated setup wizard to guide users in configuring session reset preferences.
This commit is contained in:
@@ -186,6 +186,33 @@ memory:
|
||||
# For exit/reset, only fires if the session had at least this many user turns.
|
||||
flush_min_turns: 6 # Min user turns to trigger flush on exit/reset (0 = disabled)
|
||||
|
||||
# =============================================================================
|
||||
# Session Reset Policy (Messaging Platforms)
|
||||
# =============================================================================
|
||||
# Controls when messaging sessions (Telegram, Discord, WhatsApp, Slack) are
|
||||
# automatically cleared. Without resets, conversation context grows indefinitely
|
||||
# which increases API costs with every message.
|
||||
#
|
||||
# When a reset triggers, the agent first saves important information to its
|
||||
# persistent memory — but the conversation context is wiped. The agent starts
|
||||
# fresh but retains learned facts via its memory system.
|
||||
#
|
||||
# Users can always manually reset with /reset or /new in chat.
|
||||
#
|
||||
# Modes:
|
||||
# "both" - Reset on EITHER inactivity timeout or daily boundary (recommended)
|
||||
# "idle" - Reset only after N minutes of inactivity
|
||||
# "daily" - Reset only at a fixed hour each day
|
||||
# "none" - Never auto-reset; context lives until /reset or compression kicks in
|
||||
#
|
||||
# When a reset triggers, the agent gets one turn to save important memories and
|
||||
# skills before the context is wiped. Persistent memory carries across sessions.
|
||||
#
|
||||
session_reset:
|
||||
mode: both # "both", "idle", "daily", or "none"
|
||||
idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
|
||||
at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
|
||||
|
||||
# =============================================================================
|
||||
# Skills Configuration
|
||||
# =============================================================================
|
||||
|
||||
@@ -65,8 +65,9 @@ class SessionResetPolicy:
|
||||
- "daily": Reset at a specific hour each day
|
||||
- "idle": Reset after N minutes of inactivity
|
||||
- "both": Whichever triggers first (daily boundary OR idle timeout)
|
||||
- "none": Never auto-reset (context managed only by compression)
|
||||
"""
|
||||
mode: str = "both" # "daily", "idle", or "both"
|
||||
mode: str = "both" # "daily", "idle", "both", or "none"
|
||||
at_hour: int = 4 # Hour for daily reset (0-23, local time)
|
||||
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
|
||||
|
||||
@@ -264,6 +265,21 @@ def load_gateway_config() -> GatewayConfig:
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to load {gateway_config_path}: {e}")
|
||||
|
||||
# Bridge session_reset from config.yaml (the user-facing config file)
|
||||
# into the gateway config. config.yaml takes precedence over gateway.json
|
||||
# for session reset policy since that's where hermes setup writes it.
|
||||
try:
|
||||
import yaml
|
||||
config_yaml_path = Path.home() / ".hermes" / "config.yaml"
|
||||
if config_yaml_path.exists():
|
||||
with open(config_yaml_path) as f:
|
||||
yaml_cfg = yaml.safe_load(f) or {}
|
||||
sr = yaml_cfg.get("session_reset")
|
||||
if sr and isinstance(sr, dict):
|
||||
config.default_reset_policy = SessionResetPolicy.from_dict(sr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Override with environment variables
|
||||
_apply_env_overrides(config)
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ class GatewayRunner:
|
||||
self.session_store = SessionStore(
|
||||
self.config.sessions_dir, self.config,
|
||||
has_active_processes_fn=lambda key: process_registry.has_active_for_session(key),
|
||||
on_auto_reset=self._flush_memories_before_reset,
|
||||
)
|
||||
self.delivery_router = DeliveryRouter(self.config)
|
||||
self._running = False
|
||||
@@ -148,6 +149,66 @@ class GatewayRunner:
|
||||
# Key: session_key, Value: {"command": str, "pattern_key": str}
|
||||
self._pending_approvals: Dict[str, Dict[str, str]] = {}
|
||||
|
||||
def _flush_memories_before_reset(self, old_entry):
|
||||
"""Prompt the agent to save memories/skills before an auto-reset.
|
||||
|
||||
Called synchronously by SessionStore before destroying an expired session.
|
||||
Loads the transcript, gives the agent a real turn with memory + skills
|
||||
tools, and explicitly asks it to preserve anything worth keeping.
|
||||
"""
|
||||
try:
|
||||
history = self.session_store.load_transcript(old_entry.session_id)
|
||||
if not history or len(history) < 4:
|
||||
return
|
||||
|
||||
from run_agent import AIAgent
|
||||
_flush_api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
|
||||
_flush_base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
||||
_flush_model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL", "anthropic/claude-opus-4.6")
|
||||
|
||||
if not _flush_api_key:
|
||||
return
|
||||
|
||||
tmp_agent = AIAgent(
|
||||
model=_flush_model,
|
||||
api_key=_flush_api_key,
|
||||
base_url=_flush_base_url,
|
||||
max_iterations=8,
|
||||
quiet_mode=True,
|
||||
enabled_toolsets=["memory", "skills"],
|
||||
session_id=old_entry.session_id,
|
||||
)
|
||||
|
||||
# Build conversation history from transcript
|
||||
msgs = [
|
||||
{"role": m.get("role"), "content": m.get("content")}
|
||||
for m in history
|
||||
if m.get("role") in ("user", "assistant") and m.get("content")
|
||||
]
|
||||
|
||||
# Give the agent a real turn to think about what to save
|
||||
flush_prompt = (
|
||||
"[System: This session is about to be automatically reset due to "
|
||||
"inactivity or a scheduled daily reset. The conversation context "
|
||||
"will be cleared after this turn.\n\n"
|
||||
"Review the conversation above and:\n"
|
||||
"1. Save any important facts, preferences, or decisions to memory "
|
||||
"(user profile or your notes) that would be useful in future sessions.\n"
|
||||
"2. If you discovered a reusable workflow or solved a non-trivial "
|
||||
"problem, consider saving it as a skill.\n"
|
||||
"3. If nothing is worth saving, that's fine — just skip.\n\n"
|
||||
"Do NOT respond to the user. Just use the memory and skill_manage "
|
||||
"tools if needed, then stop.]"
|
||||
)
|
||||
|
||||
tmp_agent.run_conversation(
|
||||
user_message=flush_prompt,
|
||||
conversation_history=msgs,
|
||||
)
|
||||
logger.info("Pre-reset save completed for session %s", old_entry.session_id)
|
||||
except Exception as e:
|
||||
logger.debug("Pre-reset save failed for session %s: %s", old_entry.session_id, e)
|
||||
|
||||
# DM pairing store for code-based user authorization
|
||||
from gateway.pairing import PairingStore
|
||||
self.pairing_store = PairingStore()
|
||||
|
||||
@@ -277,12 +277,14 @@ class SessionStore:
|
||||
"""
|
||||
|
||||
def __init__(self, sessions_dir: Path, config: GatewayConfig,
|
||||
has_active_processes_fn=None):
|
||||
has_active_processes_fn=None,
|
||||
on_auto_reset=None):
|
||||
self.sessions_dir = sessions_dir
|
||||
self.config = config
|
||||
self._entries: Dict[str, SessionEntry] = {}
|
||||
self._loaded = False
|
||||
self._has_active_processes_fn = has_active_processes_fn
|
||||
self._on_auto_reset = on_auto_reset # callback(old_entry) before auto-reset
|
||||
|
||||
# Initialize SQLite session database
|
||||
self._db = None
|
||||
@@ -345,6 +347,9 @@ class SessionStore:
|
||||
session_type=source.chat_type
|
||||
)
|
||||
|
||||
if policy.mode == "none":
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
if policy.mode in ("idle", "both"):
|
||||
@@ -396,8 +401,13 @@ class SessionStore:
|
||||
self._save()
|
||||
return entry
|
||||
else:
|
||||
# Session is being reset -- end the old one in SQLite
|
||||
# Session is being auto-reset — flush memories before destroying
|
||||
was_auto_reset = True
|
||||
if self._on_auto_reset:
|
||||
try:
|
||||
self._on_auto_reset(entry)
|
||||
except Exception as e:
|
||||
logger.debug("Auto-reset callback failed: %s", e)
|
||||
if self._db:
|
||||
try:
|
||||
self._db.end_session(entry.session_id, "session_reset")
|
||||
|
||||
@@ -1086,6 +1086,82 @@ def run_setup_wizard(args):
|
||||
|
||||
print_success(f"Context compression threshold set to {config['compression'].get('threshold', 0.85)}")
|
||||
|
||||
# =========================================================================
|
||||
# Step 6b: Session Reset Policy (Messaging)
|
||||
# =========================================================================
|
||||
print_header("Session Reset Policy")
|
||||
print_info("Messaging sessions (Telegram, Discord, etc.) accumulate context over time.")
|
||||
print_info("Each message adds to the conversation history, which means growing API costs.")
|
||||
print_info("")
|
||||
print_info("To manage this, sessions can automatically reset after a period of inactivity")
|
||||
print_info("or at a fixed time each day. When a reset happens, the agent saves important")
|
||||
print_info("things to its persistent memory first — but the conversation context is cleared.")
|
||||
print_info("")
|
||||
print_info("You can also manually reset anytime by typing /reset in chat.")
|
||||
print_info("")
|
||||
|
||||
reset_choices = [
|
||||
"Inactivity + daily reset (recommended — reset whichever comes first)",
|
||||
"Inactivity only (reset after N minutes of no messages)",
|
||||
"Daily only (reset at a fixed hour each day)",
|
||||
"Never auto-reset (context lives until /reset or context compression)",
|
||||
"Keep current settings",
|
||||
]
|
||||
|
||||
current_policy = config.get('session_reset', {})
|
||||
current_mode = current_policy.get('mode', 'both')
|
||||
current_idle = current_policy.get('idle_minutes', 1440)
|
||||
current_hour = current_policy.get('at_hour', 4)
|
||||
|
||||
default_reset = {"both": 0, "idle": 1, "daily": 2, "none": 3}.get(current_mode, 0)
|
||||
|
||||
reset_idx = prompt_choice("Session reset mode:", reset_choices, default_reset)
|
||||
|
||||
config.setdefault('session_reset', {})
|
||||
|
||||
if reset_idx == 0: # Both
|
||||
config['session_reset']['mode'] = 'both'
|
||||
idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle))
|
||||
try:
|
||||
idle_val = int(idle_str)
|
||||
if idle_val > 0:
|
||||
config['session_reset']['idle_minutes'] = idle_val
|
||||
except ValueError:
|
||||
pass
|
||||
hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour))
|
||||
try:
|
||||
hour_val = int(hour_str)
|
||||
if 0 <= hour_val <= 23:
|
||||
config['session_reset']['at_hour'] = hour_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min idle or daily at {config['session_reset'].get('at_hour', 4)}:00")
|
||||
elif reset_idx == 1: # Idle only
|
||||
config['session_reset']['mode'] = 'idle'
|
||||
idle_str = prompt(" Inactivity timeout (minutes)", str(current_idle))
|
||||
try:
|
||||
idle_val = int(idle_str)
|
||||
if idle_val > 0:
|
||||
config['session_reset']['idle_minutes'] = idle_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset after {config['session_reset'].get('idle_minutes', 1440)} min of inactivity")
|
||||
elif reset_idx == 2: # Daily only
|
||||
config['session_reset']['mode'] = 'daily'
|
||||
hour_str = prompt(" Daily reset hour (0-23, local time)", str(current_hour))
|
||||
try:
|
||||
hour_val = int(hour_str)
|
||||
if 0 <= hour_val <= 23:
|
||||
config['session_reset']['at_hour'] = hour_val
|
||||
except ValueError:
|
||||
pass
|
||||
print_success(f"Sessions reset daily at {config['session_reset'].get('at_hour', 4)}:00")
|
||||
elif reset_idx == 3: # None
|
||||
config['session_reset']['mode'] = 'none'
|
||||
print_info("Sessions will never auto-reset. Context is managed only by compression.")
|
||||
print_warning("Long conversations will grow in cost. Use /reset manually when needed.")
|
||||
# else: keep current (idx == 4)
|
||||
|
||||
# =========================================================================
|
||||
# Step 7: Messaging Platforms (Optional)
|
||||
# =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user