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 <noreply@anthropic.com>
This commit is contained in:
1 parent
daecdbbbc8
commit
e0a4df36f1
3 files changed
+356
-17
No files matched your search
+1
-1
@@ -5,7 +5,7 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY bot.py .
|
COPY bot.py topics.txt ./
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import json
|
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():
|
def load_personality():
|
||||||
global personality_notes
|
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")
|
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"
|
SELF_DOSSIER_KEY = "self"
|
||||||
COMPACTION_THRESHOLD = 15
|
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"))
|
REFLECTION_LOG_FILE = Path(os.getenv("REFLECTION_LOG_FILE", "reflection_log.txt"))
|
||||||
PERSONALITY_CHANGELOG_FILE = Path(os.getenv("PERSONALITY_CHANGELOG_FILE", "personality_changelog.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"))
|
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
|
_last_message_time: float = 0.0
|
||||||
|
_life_sim_turn: bool = False
|
||||||
|
|
||||||
|
|
||||||
def background_on_cooldown() -> bool:
|
def background_on_cooldown() -> bool:
|
||||||
@@ -184,6 +267,12 @@ def record_background_error():
|
|||||||
_last_background_error = time.monotonic()
|
_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:
|
def strip_thinking_tags(text: str) -> str:
|
||||||
"""Remove <think>...</think> blocks that some models (e.g. Qwen) emit before their response."""
|
"""Remove <think>...</think> blocks that some models (e.g. Qwen) emit before their response."""
|
||||||
import re
|
import re
|
||||||
@@ -430,7 +519,7 @@ async def call_mcp_tool(name: str, args: dict) -> str:
|
|||||||
|
|
||||||
async def run_agent(messages: list) -> str:
|
async def run_agent(messages: list) -> str:
|
||||||
"""Call the LLM in a loop, executing tool calls until a final reply is produced."""
|
"""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}
|
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools}
|
||||||
|
|
||||||
iteration = 0
|
iteration = 0
|
||||||
@@ -438,7 +527,22 @@ async def run_agent(messages: list) -> str:
|
|||||||
iteration += 1
|
iteration += 1
|
||||||
log.info(f"[agent] LLM call #{iteration} starting")
|
log.info(f"[agent] LLM call #{iteration} starting")
|
||||||
t0 = time.monotonic()
|
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
|
elapsed = time.monotonic() - t0
|
||||||
msg = response.choices[0].message
|
msg = response.choices[0].message
|
||||||
log.info(f"[agent] LLM call #{iteration} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}")
|
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":
|
elif tc.function.name == "update_reflection_notes":
|
||||||
save_reflection_notes(args["notes"])
|
save_reflection_notes(args["notes"])
|
||||||
result = "Reflection notes updated."
|
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:
|
else:
|
||||||
result = await call_mcp_tool(tc.function.name, args)
|
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]:
|
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)."""
|
"""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}
|
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools}
|
||||||
transcript_parts: list[str] = []
|
transcript_parts: list[str] = []
|
||||||
last_assistant_text = ""
|
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):
|
for turn in range(1, max_turns + 1):
|
||||||
log.info(f"[reflection] Turn {turn}/{max_turns}")
|
log.info(f"[reflection] Turn {turn}/{max_turns}")
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
response = await asyncio.wait_for(
|
try:
|
||||||
llm.chat.completions.create(**kwargs),
|
response = await asyncio.wait_for(
|
||||||
timeout=BACKGROUND_LLM_TIMEOUT,
|
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
|
elapsed = time.monotonic() - t0
|
||||||
msg = response.choices[0].message
|
msg = response.choices[0].message
|
||||||
log.info(f"[reflection] Turn {turn} completed in {elapsed:.1f}s, tool_calls={bool(msg.tool_calls)}")
|
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":
|
elif tc.function.name == "update_reflection_notes":
|
||||||
save_reflection_notes(args["notes"])
|
save_reflection_notes(args["notes"])
|
||||||
result = "Reflection notes updated."
|
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:
|
else:
|
||||||
result = await call_mcp_tool(tc.function.name, args)
|
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)
|
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():
|
async def reflect_on_self():
|
||||||
"""Background coroutine: run a private self-reflection session when idle."""
|
"""Background coroutine: run a private self-reflection session when idle."""
|
||||||
if background_on_cooldown():
|
if background_on_cooldown():
|
||||||
@@ -561,17 +856,24 @@ async def reflect_on_self():
|
|||||||
reflection_notes,
|
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 += [
|
system_lines += [
|
||||||
"Feel free to update your personality if anything feels off or could be refined.",
|
"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.",
|
"Think freely. This is your time.",
|
||||||
]
|
]
|
||||||
|
|
||||||
system_content = "\n".join(system_lines)
|
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 = [
|
messages = [
|
||||||
{"role": "system", "content": system_content},
|
{"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)
|
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)
|
@tasks.loop(minutes=REFLECTION_IDLE_MIN)
|
||||||
async def reflection_loop():
|
async def reflection_loop():
|
||||||
|
global _life_sim_turn
|
||||||
idle_seconds = time.monotonic() - _last_message_time
|
idle_seconds = time.monotonic() - _last_message_time
|
||||||
if idle_seconds < (REFLECTION_IDLE_MIN * 60):
|
if idle_seconds < (REFLECTION_IDLE_MIN * 60):
|
||||||
log.info(f"[reflection] Active ({idle_seconds:.0f}s since last message), skipping")
|
log.info(f"[reflection] Active ({idle_seconds:.0f}s since last message), skipping")
|
||||||
return
|
return
|
||||||
log.info(f"[reflection] Idle {idle_seconds:.0f}s — triggering reflection")
|
if _life_sim_turn:
|
||||||
asyncio.create_task(reflect_on_self())
|
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
|
@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.replace(fmt, f"@{mentioned_user.display_name}")
|
||||||
content = content.strip()
|
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:
|
if bot.user not in message.mentions:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not content:
|
if not content and not image_attachments:
|
||||||
await message.reply("You mentioned me but didn't say anything!")
|
await message.reply("You mentioned me but didn't say anything!")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -660,11 +977,25 @@ async def on_message(message: discord.Message):
|
|||||||
if personality_notes:
|
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\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 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}"
|
system_content = f"{system_content}\n\n{date_context}"
|
||||||
if dossier_context:
|
if dossier_context:
|
||||||
system_content = f"{system_content}\n\n{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():
|
async with message.channel.typing():
|
||||||
try:
|
try:
|
||||||
@@ -685,7 +1016,7 @@ async def on_message(message: discord.Message):
|
|||||||
for part in parts:
|
for part in parts:
|
||||||
for chunk in _split(part, 2000):
|
for chunk in _split(part, 2000):
|
||||||
if not first:
|
if not first:
|
||||||
await asyncio.sleep(min(len(chunk) / 50, 3))
|
await asyncio.sleep(len(chunk.split()) / 15)
|
||||||
if first:
|
if first:
|
||||||
await message.reply(chunk)
|
await message.reply(chunk)
|
||||||
first = False
|
first = False
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./dossiers.json:/app/dossiers.json
|
- ./dossiers.json:/app/dossiers.json
|
||||||
- ./personality.txt:/app/personality.txt
|
- ./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:
|
networks:
|
||||||
- litellm_net
|
- litellm_net
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user