fix: 3 critical autonomous engine & mood system bugs

1. Momentum cliff at 10 messages (P0): The conversation momentum formula
   had a discontinuity where the 10th message caused momentum to DROP from
   0.9 to 0.5. Replaced with a smooth log1p curve that monotonically
   increases (0→0→0.20→0.32→...→0.70→0.89→1.0 at 30 msgs).

2. Neutral keywords overriding all moods (P0): detect_mood_shift() checked
   neutral early with generic keywords (okay, sure, hmm) that matched
   almost any response, constantly resetting mood to neutral. Now: all
   specific moods are scored by match count first (best-match wins),
   neutral is only checked as fallback and requires 2+ keyword matches.

3. Uncancellable delayed_wakeup tasks (P0): Fire-and-forget sleep tasks
   could stack and overwrite mood state after manual wake-up. Added a
   centralized wakeup task registry in ServerManager with automatic
   cancellation on manual wake or new sleep cycle.
This commit is contained in:
2026-02-20 15:37:57 +02:00
parent 2f0d430c35
commit 422366df4c
4 changed files with 97 additions and 46 deletions

View File

@@ -14,6 +14,7 @@ from api import app
from config import CONFIG, SECRETS, validate_config, print_config_summary from config import CONFIG, SECRETS, validate_config, print_config_summary
from server_manager import server_manager from server_manager import server_manager
from config_manager import config_manager
from utils.scheduled import ( from utils.scheduled import (
send_monday_video send_monday_video
) )
@@ -106,7 +107,6 @@ async def on_ready():
restore_bipolar_mode_on_startup() restore_bipolar_mode_on_startup()
# Restore runtime settings (language, debug flags, etc.) from config_runtime.yaml # Restore runtime settings (language, debug flags, etc.) from config_runtime.yaml
from config_manager import config_manager
config_manager.restore_runtime_settings() config_manager.restore_runtime_settings()
# Initialize DM interaction analyzer # Initialize DM interaction analyzer
@@ -709,15 +709,7 @@ async def on_message(message):
if detected == "asleep": if detected == "asleep":
server_manager.set_server_sleep_state(message.guild.id, True) server_manager.set_server_sleep_state(message.guild.id, True)
# Schedule wake-up after 1 hour server_manager.schedule_wakeup_task(message.guild.id, delay_seconds=3600)
async def delayed_wakeup():
await asyncio.sleep(3600) # 1 hour
server_manager.set_server_sleep_state(message.guild.id, False)
server_manager.set_server_mood(message.guild.id, "neutral")
await update_server_nickname(message.guild.id)
logger.info(f"🌅 Server {message.guild.name} woke up from auto-sleep")
globals.client.loop.create_task(delayed_wakeup())
else: else:
logger.error(f"No server config found for guild {message.guild.id}, skipping mood detection") logger.error(f"No server config found for guild {message.guild.id}, skipping mood detection")
except Exception as e: except Exception as e:

View File

@@ -74,6 +74,7 @@ class ServerManager:
self.servers: Dict[int, ServerConfig] = {} self.servers: Dict[int, ServerConfig] = {}
self.schedulers: Dict[int, AsyncIOScheduler] = {} self.schedulers: Dict[int, AsyncIOScheduler] = {}
self.server_memories: Dict[int, Dict] = {} self.server_memories: Dict[int, Dict] = {}
self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task
self.load_config() self.load_config()
def load_config(self): def load_config(self):
@@ -291,9 +292,70 @@ class ServerManager:
server = self.servers[guild_id] server = self.servers[guild_id]
server.is_sleeping = sleeping server.is_sleeping = sleeping
# If waking up, cancel any pending delayed wakeup task
if not sleeping:
self.cancel_wakeup_task(guild_id)
self.save_config() self.save_config()
return True return True
def schedule_wakeup_task(self, guild_id: int, delay_seconds: int = 3600):
"""Schedule a delayed wakeup task for a server, cancelling any existing one first.
Args:
guild_id: The server to schedule wakeup for
delay_seconds: How long to sleep before waking (default 1 hour)
"""
# Cancel any existing wakeup task for this server
self.cancel_wakeup_task(guild_id)
import globals as _globals
async def _delayed_wakeup():
try:
await asyncio.sleep(delay_seconds)
# Check if we're still asleep (might have been woken manually)
server = self.servers.get(guild_id)
if server and server.is_sleeping:
self.set_server_sleep_state(guild_id, False)
self.set_server_mood(guild_id, "neutral")
# Notify autonomous engine
try:
from utils.autonomous import on_mood_change
on_mood_change(guild_id, "neutral")
except Exception as e:
logger.error(f"Failed to notify autonomous engine of wake-up: {e}")
# Update nickname
try:
from utils.moods import update_server_nickname
await update_server_nickname(guild_id)
except Exception as e:
logger.error(f"Failed to update nickname on wake-up: {e}")
logger.info(f"Server {guild_id} woke up from auto-sleep after {delay_seconds}s")
else:
logger.debug(f"Wakeup task for {guild_id} completed but server already awake, skipping")
except asyncio.CancelledError:
logger.debug(f"Wakeup task for server {guild_id} was cancelled")
finally:
# Clean up our reference
self._wakeup_tasks.pop(guild_id, None)
task = _globals.client.loop.create_task(_delayed_wakeup())
self._wakeup_tasks[guild_id] = task
logger.info(f"Scheduled auto-wake for server {guild_id} in {delay_seconds}s")
return task
def cancel_wakeup_task(self, guild_id: int):
"""Cancel a pending wakeup task for a server, if any."""
task = self._wakeup_tasks.pop(guild_id, None)
if task and not task.done():
task.cancel()
logger.info(f"Cancelled pending wakeup task for server {guild_id}")
def get_server_mood_state(self, guild_id: int) -> dict: def get_server_mood_state(self, guild_id: int) -> dict:
"""Get complete mood state for a specific server""" """Get complete mood state for a specific server"""
if guild_id not in self.servers: if guild_id not in self.servers:

View File

@@ -4,6 +4,7 @@ Truly autonomous decision-making engine for Miku.
Makes decisions based on context signals without constant LLM polling. Makes decisions based on context signals without constant LLM polling.
""" """
import math
import time import time
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -203,11 +204,12 @@ class AutonomousEngine:
ctx.messages_last_hour = sum(1 for t in times if now - t < 3600) ctx.messages_last_hour = sum(1 for t in times if now - t < 3600)
# Calculate conversation momentum (0-1 scale) # Calculate conversation momentum (0-1 scale)
# High momentum = consistent messages in last 5 minutes # Smooth curve: grows quickly at first, then tapers off toward 1.0
if ctx.messages_last_5min >= 10: # 1 msg → 0.10, 5 msgs → 0.41, 10 msgs → 0.63, 20 msgs → 0.82, 40 msgs → 0.95
ctx.conversation_momentum = min(1.0, ctx.messages_last_5min / 20) if ctx.messages_last_5min == 0:
ctx.conversation_momentum = 0.0
else: else:
ctx.conversation_momentum = ctx.messages_last_5min / 10 ctx.conversation_momentum = min(1.0, math.log1p(ctx.messages_last_5min) / math.log1p(30))
# Time since last action # Time since last action
if guild_id in self.server_last_action: if guild_id in self.server_last_action:

View File

@@ -63,10 +63,6 @@ def detect_mood_shift(response_text, server_context=None):
"asleep": [ "asleep": [
"good night", "goodnight", "sweet dreams", "going to bed", "I will go to bed", "zzz~", "sleep tight" "good night", "goodnight", "sweet dreams", "going to bed", "I will go to bed", "zzz~", "sleep tight"
], ],
"neutral": [
"okay", "sure", "alright", "i see", "understood", "hmm",
"sounds good", "makes sense", "alrighty", "fine", "got it"
],
"bubbly": [ "bubbly": [
"so excited", "feeling bubbly", "super cheerful", "yay!", "", "nya~", "so excited", "feeling bubbly", "super cheerful", "yay!", "", "nya~",
"kyaa~", "heehee", "bouncy", "so much fun", "i'm glowing!", "nee~", "teehee", "I'm so happy" "kyaa~", "heehee", "bouncy", "so much fun", "i'm glowing!", "nee~", "teehee", "I'm so happy"
@@ -116,25 +112,41 @@ def detect_mood_shift(response_text, server_context=None):
] ]
} }
# First pass: find ALL matching moods with their match counts (excluding neutral)
response_lower = response_text.lower()
mood_matches = {}
for mood, phrases in mood_keywords.items(): for mood, phrases in mood_keywords.items():
# Check against server mood if provided, otherwise skip
if mood == "asleep": if mood == "asleep":
# asleep requires sleepy prerequisite
if server_context: if server_context:
# For server context, check against server's current mood
current_mood = server_context.get('current_mood_name', 'neutral') current_mood = server_context.get('current_mood_name', 'neutral')
if current_mood != "sleepy": if current_mood != "sleepy":
logger.debug(f"Mood 'asleep' skipped - server mood isn't 'sleepy', it's '{current_mood}'")
continue continue
else: else:
# For DM context, check against DM mood
if globals.DM_MOOD != "sleepy": if globals.DM_MOOD != "sleepy":
logger.debug(f"Mood 'asleep' skipped - DM mood isn't 'sleepy', it's '{globals.DM_MOOD}'")
continue continue
for phrase in phrases: match_count = sum(1 for phrase in phrases if phrase.lower() in response_lower)
if phrase.lower() in response_text.lower(): if match_count > 0:
logger.info(f"Mood keyword triggered: {phrase}") mood_matches[mood] = match_count
return mood
if mood_matches:
# Return the mood with the most keyword matches (strongest signal)
best_mood = max(mood_matches, key=mood_matches.get)
logger.info(f"Mood shift detected: {best_mood} ({mood_matches[best_mood]} keyword matches, all matches: {mood_matches})")
return best_mood
# Neutral is checked separately and only triggers if NOTHING else matched
# Requires 2+ neutral keywords to avoid false positives from casual "okay" / "sure"
neutral_phrases = [
"okay", "sure", "alright", "i see", "understood", "hmm",
"sounds good", "makes sense", "alrighty", "fine", "got it"
]
neutral_count = sum(1 for phrase in neutral_phrases if phrase.lower() in response_lower)
if neutral_count >= 2:
logger.info(f"Mood shift detected: neutral ({neutral_count} neutral keywords)")
return "neutral"
return None return None
async def rotate_dm_mood(): async def rotate_dm_mood():
@@ -287,27 +299,10 @@ async def rotate_server_mood(guild_id: int):
except Exception as mood_notify_error: except Exception as mood_notify_error:
logger.error(f"Failed to notify autonomous engine of mood change: {mood_notify_error}") logger.error(f"Failed to notify autonomous engine of mood change: {mood_notify_error}")
# If transitioning to asleep, set up auto-wake # If transitioning to asleep, set up auto-wake via centralized registry
if new_mood_name == "asleep": if new_mood_name == "asleep":
server_manager.set_server_sleep_state(guild_id, True) server_manager.set_server_sleep_state(guild_id, True)
# Schedule wake-up after 1 hour server_manager.schedule_wakeup_task(guild_id, delay_seconds=3600)
async def delayed_wakeup():
await asyncio.sleep(3600) # 1 hour
server_manager.set_server_sleep_state(guild_id, False)
server_manager.set_server_mood(guild_id, "neutral")
# V2: Notify autonomous engine of mood change
try:
from utils.autonomous import on_mood_change
on_mood_change(guild_id, "neutral")
except Exception as mood_notify_error:
logger.error(f"Failed to notify autonomous engine of wake-up mood change: {mood_notify_error}")
await update_server_nickname(guild_id)
logger.info(f"Server {guild_id} woke up from auto-sleep (mood rotation)")
globals.client.loop.create_task(delayed_wakeup())
logger.info(f"Scheduled auto-wake for server {guild_id} in 1 hour")
# Update nickname for this specific server # Update nickname for this specific server
await update_server_nickname(guild_id) await update_server_nickname(guild_id)