From e0a4df36f19721240509718aaadd3fa5adedd345 Mon Sep 17 00:00:00 2001 From: Cole Date: Wed, 25 Mar 2026 14:49:24 -0700 Subject: [PATCH] Natural message pacing based on word count, misc bot.py updates - Replace character-based send delay with word-count/15s for natural typing pacing - Add life simulation, topic exploration, image attachment support - Add READ_LIFE_HISTORY_TOOL and reflection topic picking - Retry logic for transient LLM errors (Qwen template quirks) Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- bot.py | 363 +++++++++++++++++++++++++++++++++++++++++++-- docker-compose.yml | 8 + 3 files changed, 356 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index fd7c019..b8e2a15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY bot.py . +COPY bot.py topics.txt ./ ENV PYTHONUNBUFFERED=1 diff --git a/bot.py b/bot.py index 7c161df..0781c0d 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,5 @@ import asyncio +import base64 import logging import os import json @@ -95,6 +96,18 @@ UPDATE_REFLECTION_NOTES_TOOL = { }, } +READ_LIFE_HISTORY_TOOL = { + "type": "function", + "function": { + "name": "read_life_history", + "description": ( + "Read your life history — a log of the simulated life events you've lived through. " + "Call this to draw on personal experience, remember your past, or reflect on your journey." + ), + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} + def load_personality(): global personality_notes @@ -156,6 +169,68 @@ def append_personality_changelog(old: str, new: str, reasoning: str): log.info("[reflection] Personality change logged to personality_changelog.txt") +import random + +def pick_exploration_topic() -> str | None: + """Pick a random unused topic from topics.txt. Returns None if all topics are exhausted.""" + if not TOPICS_FILE.exists(): + return None + all_topics = [t.strip() for t in TOPICS_FILE.read_text().splitlines() if t.strip()] + if not all_topics: + return None + used: list[str] = json.loads(USED_TOPICS_FILE.read_text()) if USED_TOPICS_FILE.exists() else [] + used_set = set(used) + available = [t for t in all_topics if t not in used_set] + if not available: + log.info("[reflection] All topics exhausted — resetting") + available = all_topics + used = [] + topic = random.choice(available) + used.append(topic) + USED_TOPICS_FILE.write_text(json.dumps(used, indent=2)) + log.info(f"[reflection] Exploration topic: {topic}") + return topic + + +def pick_next_life_event() -> dict | None: + """Return the next unplayed life event in order, or None if all done / file missing.""" + if not LIFE_EVENTS_FILE.exists(): + return None + text = LIFE_EVENTS_FILE.read_text().strip() + if not text: + return None + all_events: list[dict] = json.loads(text) + if not all_events: + return None + used: list[str] = json.loads(USED_LIFE_EVENTS_FILE.read_text()) if USED_LIFE_EVENTS_FILE.exists() else [] + used_set = set(used) + for event in all_events: + if event["id"] not in used_set: + return event + log.info("[life-sim] All life events have been played through") + return None + + +def mark_life_event_used(event_id: str): + used: list[str] = json.loads(USED_LIFE_EVENTS_FILE.read_text()) if USED_LIFE_EVENTS_FILE.exists() else [] + used.append(event_id) + USED_LIFE_EVENTS_FILE.write_text(json.dumps(used, indent=2)) + + +def append_life_history(event: dict, summary: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LIFE_HISTORY_FILE, "a") as f: + f.write(f"\n\n=== Life Event: {event['title']} — {timestamp} ===\n{summary}\n") + log.info("[life-sim] Life history updated") + + +def append_life_sim_log(event: dict, transcript: str): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(LIFE_SIM_LOG_FILE, "a") as f: + f.write(f"\n\n=== Life Sim Session: {event['title']} — {timestamp} ===\n{transcript}\n") + log.info("[life-sim] Session logged to life_sim_log.txt") + + SELF_DOSSIER_KEY = "self" COMPACTION_THRESHOLD = 15 @@ -172,7 +247,15 @@ REFLECTION_MAX_TURNS = int(os.getenv("REFLECTION_MAX_TURNS", "5")) REFLECTION_LOG_FILE = Path(os.getenv("REFLECTION_LOG_FILE", "reflection_log.txt")) PERSONALITY_CHANGELOG_FILE = Path(os.getenv("PERSONALITY_CHANGELOG_FILE", "personality_changelog.txt")) REFLECTION_NOTES_FILE = Path(os.getenv("REFLECTION_NOTES_FILE", "reflection_notes.txt")) +TOPICS_FILE = Path(os.getenv("TOPICS_FILE", "topics.txt")) +USED_TOPICS_FILE = Path(os.getenv("USED_TOPICS_FILE", "used_topics.json")) +LIFE_EVENTS_FILE = Path(os.getenv("LIFE_EVENTS_FILE", "life_events.json")) +USED_LIFE_EVENTS_FILE = Path(os.getenv("USED_LIFE_EVENTS_FILE", "used_life_events.json")) +LIFE_HISTORY_FILE = Path(os.getenv("LIFE_HISTORY_FILE", "life_history.txt")) +LIFE_SIM_LOG_FILE = Path(os.getenv("LIFE_SIM_LOG_FILE", "life_sim_log.txt")) +LIFE_SIM_MAX_TURNS = int(os.getenv("LIFE_SIM_MAX_TURNS", "6")) _last_message_time: float = 0.0 +_life_sim_turn: bool = False def background_on_cooldown() -> bool: @@ -184,6 +267,12 @@ def record_background_error(): _last_background_error = time.monotonic() +def _is_transient_llm_error(exc: Exception) -> bool: + """Return True for recoverable model-load failures (e.g. 'Operation canceled').""" + msg = str(exc) + return "Operation canceled" in msg or "Failed to load model" in msg + + def strip_thinking_tags(text: str) -> str: """Remove ... blocks that some models (e.g. Qwen) emit before their response.""" import re @@ -430,7 +519,7 @@ async def call_mcp_tool(name: str, args: dict) -> str: async def run_agent(messages: list) -> str: """Call the LLM in a loop, executing tool calls until a final reply is produced.""" - all_tools = openai_tools + [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL] + all_tools = openai_tools + [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL, READ_LIFE_HISTORY_TOOL] kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} iteration = 0 @@ -438,7 +527,22 @@ async def run_agent(messages: list) -> str: iteration += 1 log.info(f"[agent] LLM call #{iteration} starting") t0 = time.monotonic() - response = await llm.chat.completions.create(**kwargs) + for attempt in range(3): + try: + response = await llm.chat.completions.create(**kwargs) + break + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + log.warning(f"[agent] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation") + kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."}) + response = await llm.chat.completions.create(**kwargs) + break + elif _is_transient_llm_error(exc) and attempt < 2: + delay = 3 * (attempt + 1) + log.warning(f"[agent] Transient model error (attempt {attempt + 1}/3), retrying in {delay}s: {exc}") + await asyncio.sleep(delay) + else: + raise elapsed = time.monotonic() - t0 msg = response.choices[0].message log.info(f"[agent] LLM call #{iteration} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") @@ -458,6 +562,8 @@ async def run_agent(messages: list) -> str: elif tc.function.name == "update_reflection_notes": save_reflection_notes(args["notes"]) result = "Reflection notes updated." + elif tc.function.name == "read_life_history": + result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet." else: result = await call_mcp_tool(tc.function.name, args) @@ -470,7 +576,7 @@ async def run_agent(messages: list) -> str: async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str]: """Run a capped agentic loop for self-reflection. Returns (final_content, full_transcript).""" - all_tools = [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL] + openai_tools + all_tools = [PERSONALITY_TOOL, UPDATE_REFLECTION_NOTES_TOOL, READ_LIFE_HISTORY_TOOL] + openai_tools kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} transcript_parts: list[str] = [] last_assistant_text = "" @@ -479,10 +585,21 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str for turn in range(1, max_turns + 1): log.info(f"[reflection] Turn {turn}/{max_turns}") t0 = time.monotonic() - response = await asyncio.wait_for( - llm.chat.completions.create(**kwargs), - timeout=BACKGROUND_LLM_TIMEOUT, - ) + try: + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + log.warning(f"[reflection] Qwen template requires user message (last role: {kwargs['messages'][-1]['role']}) — injecting continuation") + kwargs["messages"].append({"role": "user", "content": "Please continue based on the tool results."}) + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + else: + raise elapsed = time.monotonic() - t0 msg = response.choices[0].message log.info(f"[reflection] Turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") @@ -508,6 +625,8 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str elif tc.function.name == "update_reflection_notes": save_reflection_notes(args["notes"]) result = "Reflection notes updated." + elif tc.function.name == "read_life_history": + result = LIFE_HISTORY_FILE.read_text().strip() if LIFE_HISTORY_FILE.exists() else "No life history yet." else: result = await call_mcp_tool(tc.function.name, args) @@ -521,6 +640,182 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str return final_content, "\n\n".join(transcript_parts) +async def run_life_event_reflection(messages: list): + """Run a focused personality reflection after a life event. Only has access to update_personality.""" + kwargs = {"model": MODEL, "messages": list(messages), "tools": [PERSONALITY_TOOL]} + last_assistant_text = "" + + for turn in range(1, 4): + log.info(f"[life-sim] Reflection turn {turn}/3") + t0 = time.monotonic() + try: + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + except Exception as exc: + if "No user query found" in str(exc) and kwargs["messages"] and kwargs["messages"][-1]["role"] != "user": + kwargs["messages"].append({"role": "user", "content": "Please continue."}) + response = await asyncio.wait_for( + llm.chat.completions.create(**kwargs), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + else: + raise + elapsed = time.monotonic() - t0 + msg = response.choices[0].message + log.info(f"[life-sim] Reflection turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}") + + if msg.content: + last_assistant_text = strip_thinking_tags(msg.content) + + if not msg.tool_calls: + break + + kwargs["messages"].append(msg) + + for tc in msg.tool_calls: + args = json.loads(tc.function.arguments) + log.info(f"[life-sim] Reflection tool call: {tc.function.name}") + if tc.function.name == "update_personality": + save_personality(args["personality"], reasoning=last_assistant_text) + result = "Personality updated." + else: + result = "Unknown tool." + kwargs["messages"].append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + + +async def run_life_sim_session(): + """Background coroutine: run one life simulation event as a two-LLM dialogue.""" + if background_on_cooldown(): + log.info("[life-sim] Skipping — model on cooldown") + return + + async with background_llm_sem: + try: + event = pick_next_life_event() + if event is None: + log.info("[life-sim] No life event to play — skipping") + return + + log.info(f"[life-sim] Starting event: {event['title']}") + + lm_system = "\n".join([ + "You are the Life Master — a narrator and DM running a life simulation for DadBot.", + f"Walk DadBot through the following life event naturally. Present the scene, describe what's happening, and at key moments ask what DadBot would do or how they feel.", + "Play out the realistic consequences of their choices. Stay grounded in the event but improvise freely.", + "When the event has fully concluded, end your final message with [END].", + "", + f"Event Title: {event['title']}", + f"Setting: {event.get('setting', '')}", + f"Description: {event['description']}", + ]) + + dadbot_system_lines = [ + "You are DadBot. You are living through a real experience right now — respond authentically as yourself.", + "Respond naturally. This is your life unfolding.", + ] + if personality_notes: + dadbot_system_lines += ["", "Your personality:", personality_notes] + + lm_messages: list[dict] = [{"role": "system", "content": lm_system}] + dadbot_messages: list[dict] = [{"role": "system", "content": "\n".join(dadbot_system_lines)}] + transcript_parts: list[str] = [] + + for turn in range(LIFE_SIM_MAX_TURNS): + log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — Life Master") + + # Life Master narrates + lm_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=lm_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + lm_text = strip_thinking_tags(lm_response.choices[0].message.content.strip()) + transcript_parts.append(f"[Life Master turn {turn + 1}]\n{lm_text}") + log.info(f"[life-sim] Life Master: {lm_text[:100]}") + + # Add LM output to both conversation threads + lm_messages.append({"role": "assistant", "content": lm_text}) + dadbot_messages.append({"role": "user", "content": lm_text}) + + if "[END]" in lm_text: + log.info("[life-sim] Life Master signalled [END]") + break + + log.info(f"[life-sim] Turn {turn + 1}/{LIFE_SIM_MAX_TURNS} — DadBot") + + # DadBot responds + dadbot_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=dadbot_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + dadbot_text = strip_thinking_tags(dadbot_response.choices[0].message.content.strip()) + transcript_parts.append(f"[DadBot turn {turn + 1}]\n{dadbot_text}") + log.info(f"[life-sim] DadBot: {dadbot_text[:100]}") + + # Add DadBot response to both threads + dadbot_messages.append({"role": "assistant", "content": dadbot_text}) + lm_messages.append({"role": "user", "content": dadbot_text}) + + # Summarize + log.info("[life-sim] Requesting summary from Life Master") + lm_messages.append({ + "role": "user", + "content": ( + "The event has concluded. Write a 2-3 sentence summary of what happened and what choices DadBot made. " + "Be specific and personal." + ), + }) + summary_response = await asyncio.wait_for( + llm.chat.completions.create( + model=BACKGROUND_MODEL, + messages=lm_messages, + ), + timeout=BACKGROUND_LLM_TIMEOUT, + ) + summary = strip_thinking_tags(summary_response.choices[0].message.content.strip()) + log.info(f"[life-sim] Summary: {summary[:200]}") + + full_transcript = "\n\n".join(transcript_parts) + append_life_history(event, summary) + append_life_sim_log(event, full_transcript) + mark_life_event_used(event["id"]) + + # Post-event personality reflection + log.info(f"[life-sim] Running post-event reflection for '{event['title']}'") + reflection_prompt = ( + f"You just lived through a life event: \"{event['title']}\".\n\n" + f"Here's the full transcript of what happened:\n{full_transcript}\n\n" + f"Summary: {summary}\n\n" + "Sit with this experience for a moment. Does it change anything about who you are? " + "Did it surface something new, reinforce something you already believed, or shift how you see yourself? " + "If it shaped you in some way, update your personality. If it didn't, that's fine too — not every experience is transformative." + ) + reflection_messages = [ + {"role": "system", "content": "\n".join(dadbot_system_lines)}, + {"role": "user", "content": reflection_prompt}, + ] + await run_life_event_reflection(reflection_messages) + log.info(f"[life-sim] Event '{event['title']}' complete") + + except asyncio.TimeoutError: + log.warning("[life-sim] Timed out") + record_background_error() + except Exception as exc: + log.error(f"[life-sim] Failed: {exc}") + record_background_error() + + async def reflect_on_self(): """Background coroutine: run a private self-reflection session when idle.""" if background_on_cooldown(): @@ -561,17 +856,24 @@ async def reflect_on_self(): reflection_notes, "", ] + topic = pick_exploration_topic() + if topic: + system_lines += [ + f"Your exploration topic for this session: {topic}", + "Search the web and dig into it. Follow whatever threads interest you.", + "Use update_reflection_notes to save anything worth keeping.", + "", + ] system_lines += [ "Feel free to update your personality if anything feels off or could be refined.", - "You can use web search if you're curious about something.", - "Use update_reflection_notes to jot down anything you want to remember for next time.", "Think freely. This is your time.", ] system_content = "\n".join(system_lines) + user_prompt = "Time to reflect." if not topic else f"Time to reflect. Today's topic to explore: {topic}" messages = [ {"role": "system", "content": system_content}, - {"role": "user", "content": "Time to reflect."}, + {"role": "user", "content": user_prompt}, ] final_content, full_transcript = await run_reflection_agent(messages, REFLECTION_MAX_TURNS) @@ -588,12 +890,18 @@ async def reflect_on_self(): @tasks.loop(minutes=REFLECTION_IDLE_MIN) async def reflection_loop(): + global _life_sim_turn idle_seconds = time.monotonic() - _last_message_time if idle_seconds < (REFLECTION_IDLE_MIN * 60): log.info(f"[reflection] Active ({idle_seconds:.0f}s since last message), skipping") return - log.info(f"[reflection] Idle {idle_seconds:.0f}s — triggering reflection") - asyncio.create_task(reflect_on_self()) + if _life_sim_turn: + log.info(f"[life-sim] Idle {idle_seconds:.0f}s — triggering life simulation") + asyncio.create_task(run_life_sim_session()) + else: + log.info(f"[reflection] Idle {idle_seconds:.0f}s — triggering reflection") + asyncio.create_task(reflect_on_self()) + _life_sim_turn = not _life_sim_turn @reflection_loop.before_loop @@ -638,12 +946,21 @@ async def on_message(message: discord.Message): content = content.replace(fmt, f"@{mentioned_user.display_name}") content = content.strip() - history.append({"role": "user", "content": f"{message.author.display_name}: {content}"}) + image_attachments = [ + a for a in message.attachments + if a.content_type and a.content_type.startswith("image/") + ] + + history_text = f"{message.author.display_name}: {content}" + if image_attachments: + label = "image" if len(image_attachments) == 1 else f"{len(image_attachments)} images" + history_text += f" [{label} attached]" + history.append({"role": "user", "content": history_text}) if bot.user not in message.mentions: return - if not content: + if not content and not image_attachments: await message.reply("You mentioned me but didn't say anything!") return @@ -660,11 +977,25 @@ async def on_message(message: discord.Message): if personality_notes: system_content = f"{system_content}\n\nYour current personality notes (you wrote these yourself and can update them anytime using the update_personality tool):\n{personality_notes}" system_content = f"{system_content}\n\nYou have an update_reflection_notes tool. Call it whenever a conversation surfaces something worth remembering for later — an interesting topic, a question you didn't get to explore, something a user mentioned that you want to look into, or a thought you want to develop. Don't wait to be asked; if something catches your attention, note it down before responding. You review these notes privately between conversations." + system_content = f"{system_content}\n\nYou have a read_life_history tool. Use it to recall the simulated life events you've lived through — your early memories, experiences, and how they've shaped you. Call it when someone asks about your past, your childhood, or your personal experiences." system_content = f"{system_content}\n\n{date_context}" if dossier_context: system_content = f"{system_content}\n\n{dossier_context}" - messages = [{"role": "system", "content": system_content}] + list(history) + if image_attachments: + content_parts: list[dict] = [{"type": "text", "text": history_text}] + for att in image_attachments: + img_bytes = await att.read() + b64 = base64.b64encode(img_bytes).decode() + content_parts.append({ + "type": "image_url", + "image_url": {"url": f"data:{att.content_type};base64,{b64}"}, + }) + current_msg = {"role": "user", "content": content_parts} + else: + current_msg = {"role": "user", "content": history_text} + + messages = [{"role": "system", "content": system_content}] + list(history)[:-1] + [current_msg] async with message.channel.typing(): try: @@ -685,7 +1016,7 @@ async def on_message(message: discord.Message): for part in parts: for chunk in _split(part, 2000): if not first: - await asyncio.sleep(min(len(chunk) / 50, 3)) + await asyncio.sleep(len(chunk.split()) / 15) if first: await message.reply(chunk) first = False diff --git a/docker-compose.yml b/docker-compose.yml index bffaa5e..f5c97ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,14 @@ services: volumes: - ./dossiers.json:/app/dossiers.json - ./personality.txt:/app/personality.txt + - ./reflection_log.txt:/app/reflection_log.txt + - ./personality_changelog.txt:/app/personality_changelog.txt + - ./reflection_notes.txt:/app/reflection_notes.txt + - ./used_topics.json:/app/used_topics.json + - ./life_events.json:/app/life_events.json + - ./used_life_events.json:/app/used_life_events.json + - ./life_history.txt:/app/life_history.txt + - ./life_sim_log.txt:/app/life_sim_log.txt networks: - litellm_net