Merge dolphin-mistral: background model split, logging, semaphore, cooldowns, prompt updates

- Structured logging with timestamps and per-task prefixes ([agent], [dossier], etc.)
- Background LLM semaphore to prevent concurrent background calls
- 120s cooldown after background task failures
- 90s timeout on background LLM calls to prevent deadlocks
- BACKGROUND_MODEL env var to use a separate model for dossier/compaction tasks
- Strip <think> tags from model responses (Qwen compat)
- Prompt: read the room for dynamic mood adaptation
- Prompt: blunt one-line self-correction, no apologies
- Prompt: more aggressive stance, never fold under pressure
- Updated personality.txt: less people-pleasing, more authentic

Co-Authored-By: DadBot <dadbot@localhost>
This commit is contained in:
Suppai_NekoandDadBot committed 2026-03-18 15:01:30 -07:00
commit 2001c25bb6
1 file changed
+150 -72
+150 -72
View File
@@ -1,10 +1,19 @@
import asyncio import asyncio
import logging
import os import os
import json import json
import time
from collections import defaultdict, deque from collections import defaultdict, deque
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("dadbot")
import discord import discord
from openai import AsyncOpenAI from openai import AsyncOpenAI
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -17,6 +26,7 @@ DISCORD_TOKEN = os.environ["DISCORD_TOKEN"]
LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"] LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"]
LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "anything") LITELLM_API_KEY = os.getenv("LITELLM_API_KEY", "anything")
MODEL = os.getenv("MODEL", "gpt-4") MODEL = os.getenv("MODEL", "gpt-4")
BACKGROUND_MODEL = os.getenv("BACKGROUND_MODEL", MODEL)
SYSTEM_PROMPT = os.getenv("SYSTEM_PROMPT", "You are a helpful assistant.") SYSTEM_PROMPT = os.getenv("SYSTEM_PROMPT", "You are a helpful assistant.")
MAX_CONTEXT = int(os.getenv("MAX_CONTEXT", "20")) MAX_CONTEXT = int(os.getenv("MAX_CONTEXT", "20"))
MCP_URL = os.getenv("MCP_URL", "http://mcp-searxng:4001/mcp") MCP_URL = os.getenv("MCP_URL", "http://mcp-searxng:4001/mcp")
@@ -68,7 +78,7 @@ def load_personality():
global personality_notes global personality_notes
if PERSONALITY_FILE.exists(): if PERSONALITY_FILE.exists():
personality_notes = PERSONALITY_FILE.read_text().strip() personality_notes = PERSONALITY_FILE.read_text().strip()
print(f"Loaded personality ({len(personality_notes)} chars)") log.info(f"Loaded personality ({len(personality_notes)} chars)")
else: else:
personality_notes = "" personality_notes = ""
@@ -77,7 +87,7 @@ def save_personality(text: str):
global personality_notes global personality_notes
personality_notes = text.strip() personality_notes = text.strip()
PERSONALITY_FILE.write_text(personality_notes) PERSONALITY_FILE.write_text(personality_notes)
print(f"Personality updated ({len(personality_notes)} chars)") log.info(f"Personality updated ({len(personality_notes)} chars)")
def load_dossiers(): def load_dossiers():
@@ -85,7 +95,7 @@ def load_dossiers():
if DOSSIER_FILE.exists(): if DOSSIER_FILE.exists():
text = DOSSIER_FILE.read_text().strip() text = DOSSIER_FILE.read_text().strip()
dossiers = json.loads(text) if text else {} dossiers = json.loads(text) if text else {}
print(f"Loaded dossiers for {len(dossiers)} users") log.info(f"Loaded dossiers for {len(dossiers)} users")
else: else:
dossiers = {} dossiers = {}
@@ -97,6 +107,23 @@ def save_dossiers():
SELF_DOSSIER_KEY = "self" SELF_DOSSIER_KEY = "self"
COMPACTION_THRESHOLD = 15 COMPACTION_THRESHOLD = 15
# Semaphore to prevent background LLM tasks from running concurrently
background_llm_sem = asyncio.Semaphore(1)
# Cooldown tracking: skip background LLM calls for a period after a model error
BACKGROUND_COOLDOWN = 120 # seconds
BACKGROUND_LLM_TIMEOUT = 90 # seconds
_last_background_error: float = 0.0
def background_on_cooldown() -> bool:
return (time.monotonic() - _last_background_error) < BACKGROUND_COOLDOWN
def record_background_error():
global _last_background_error
_last_background_error = time.monotonic()
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."""
@@ -111,26 +138,44 @@ async def maybe_compact_dossier(key: str, name: str):
return return
facts_str = "\n".join(f"- {f}" for f in entry["facts"]) facts_str = "\n".join(f"- {f}" for f in entry["facts"])
try: if background_on_cooldown():
response = await llm.chat.completions.create( log.info(f"[compaction] Skipping '{name}' — model on cooldown")
model=MODEL, return
messages=[ log.info(f"[compaction] Waiting for background slot for '{name}'")
{ async with background_llm_sem:
"role": "system", log.info(f"[compaction] Starting LLM call for '{name}' ({len(entry['facts'])} facts)")
"content": ( t0 = time.monotonic()
"Distill the following facts about a person into a single compact summary paragraph " try:
"of 2-3 sentences. Be dense and preserve all meaningful details. No bullet points." response = await asyncio.wait_for(
), llm.chat.completions.create(
}, model=BACKGROUND_MODEL,
{"role": "user", "content": f"Person: {name}\n\nFacts:\n{facts_str}"}, messages=[
], {
) "role": "system",
summary = strip_thinking_tags(response.choices[0].message.content.strip()) "content": (
entry["facts"] = [summary] "Distill the following facts about a person into a single compact summary paragraph "
save_dossiers() "of 2-3 sentences. Be dense and preserve all meaningful details. No bullet points."
print(f"Compacted dossier for {name} into summary paragraph") ),
except Exception as exc: },
print(f"Dossier compaction failed for {name}: {exc}") {"role": "user", "content": f"Person: {name}\n\nFacts:\n{facts_str}"},
],
),
timeout=BACKGROUND_LLM_TIMEOUT,
)
elapsed = time.monotonic() - t0
log.info(f"[compaction] LLM call completed in {elapsed:.1f}s for '{name}'")
summary = strip_thinking_tags(response.choices[0].message.content.strip())
entry["facts"] = [summary]
save_dossiers()
log.info(f"[compaction] Saved compacted dossier for '{name}'")
except asyncio.TimeoutError:
elapsed = time.monotonic() - t0
log.warning(f"[compaction] Timed out after {elapsed:.1f}s for '{name}', will retry later")
record_background_error()
except Exception as exc:
elapsed = time.monotonic() - t0
log.error(f"[compaction] Failed for '{name}' after {elapsed:.1f}s: {exc}")
record_background_error()
def get_dossier_context(user_ids: list[int]) -> str: def get_dossier_context(user_ids: list[int]) -> str:
@@ -183,30 +228,43 @@ async def extract_and_update_dossier(user: discord.Member | discord.User, conver
}, },
] ]
try: if background_on_cooldown():
response = await llm.chat.completions.create( log.info(f"[dossier] Skipping {user.display_name} — model on cooldown")
model=MODEL, return
messages=extraction_messages, log.info(f"[dossier] Waiting for background slot for {user.display_name}")
) async with background_llm_sem:
raw = strip_thinking_tags(response.choices[0].message.content.strip()) log.info(f"[dossier] Starting extraction for {user.display_name}")
print(f"Dossier extraction raw response for {user.display_name}: {repr(raw[:200])}") t0 = time.monotonic()
if raw.startswith("```"): try:
raw = raw.split("\n", 1)[-1] response = await asyncio.wait_for(
raw = raw.rsplit("```", 1)[0].strip() llm.chat.completions.create(model=BACKGROUND_MODEL, messages=extraction_messages),
if not raw: timeout=BACKGROUND_LLM_TIMEOUT,
return )
updated_facts = json.loads(raw) elapsed = time.monotonic() - t0
if not isinstance(updated_facts, list) or not updated_facts: raw = strip_thinking_tags(response.choices[0].message.content.strip())
return log.info(f"[dossier] Extraction for {user.display_name} completed in {elapsed:.1f}s: {repr(raw[:200])}")
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1]
raw = raw.rsplit("```", 1)[0].strip()
if not raw:
return
updated_facts = json.loads(raw)
if not isinstance(updated_facts, list) or not updated_facts:
return
entry = dossiers.setdefault(uid, {"name": user.display_name, "facts": []}) entry = dossiers.setdefault(uid, {"name": user.display_name, "facts": []})
entry["name"] = user.display_name entry["name"] = user.display_name
entry["facts"] = updated_facts[:50] entry["facts"] = updated_facts[:50]
save_dossiers() save_dossiers()
print(f"Updated dossier for {user.display_name}: {len(updated_facts)} facts total") log.info(f"[dossier] Saved {len(updated_facts)} facts for {user.display_name}")
await maybe_compact_dossier(uid, user.display_name) except asyncio.TimeoutError:
except Exception as exc: elapsed = time.monotonic() - t0
print(f"Dossier extraction failed for {user.display_name}: {exc}") log.warning(f"[dossier] Timed out after {elapsed:.1f}s for {user.display_name}, will retry later")
record_background_error()
except Exception as exc:
log.error(f"[dossier] Extraction failed for {user.display_name}: {exc}")
record_background_error()
await maybe_compact_dossier(uid, user.display_name)
async def extract_and_update_self_dossier(conversation: list[dict]): async def extract_and_update_self_dossier(conversation: list[dict]):
@@ -235,28 +293,42 @@ async def extract_and_update_self_dossier(conversation: list[dict]):
}, },
] ]
try: if background_on_cooldown():
response = await llm.chat.completions.create( log.info("[self-dossier] Skipping — model on cooldown")
model=MODEL, return
messages=extraction_messages, log.info("[self-dossier] Waiting for background slot")
) async with background_llm_sem:
raw = strip_thinking_tags(response.choices[0].message.content.strip()) log.info("[self-dossier] Starting extraction")
if raw.startswith("```"): t0 = time.monotonic()
raw = raw.split("\n", 1)[-1] try:
raw = raw.rsplit("```", 1)[0].strip() response = await asyncio.wait_for(
if not raw: llm.chat.completions.create(model=BACKGROUND_MODEL, messages=extraction_messages),
return timeout=BACKGROUND_LLM_TIMEOUT,
updated_facts = json.loads(raw) )
if not isinstance(updated_facts, list) or not updated_facts: elapsed = time.monotonic() - t0
return log.info(f"[self-dossier] Extraction completed in {elapsed:.1f}s")
raw = strip_thinking_tags(response.choices[0].message.content.strip())
if raw.startswith("```"):
raw = raw.split("\n", 1)[-1]
raw = raw.rsplit("```", 1)[0].strip()
if not raw:
return
updated_facts = json.loads(raw)
if not isinstance(updated_facts, list) or not updated_facts:
return
entry = dossiers.setdefault(SELF_DOSSIER_KEY, {"facts": []}) entry = dossiers.setdefault(SELF_DOSSIER_KEY, {"facts": []})
entry["facts"] = updated_facts[:50] entry["facts"] = updated_facts[:50]
save_dossiers() save_dossiers()
print(f"Updated self-dossier: {len(updated_facts)} facts total") log.info(f"[self-dossier] Saved {len(updated_facts)} facts")
await maybe_compact_dossier(SELF_DOSSIER_KEY, "DadBot") except asyncio.TimeoutError:
except Exception as exc: elapsed = time.monotonic() - t0
print(f"Self-dossier extraction failed: {exc}") log.warning(f"[self-dossier] Timed out after {elapsed:.1f}s, will retry later")
record_background_error()
except Exception as exc:
log.error(f"[self-dossier] Extraction failed: {exc}")
record_background_error()
await maybe_compact_dossier(SELF_DOSSIER_KEY, "DadBot")
async def fetch_mcp_tools() -> list[dict]: async def fetch_mcp_tools() -> list[dict]:
@@ -276,10 +348,10 @@ async def fetch_mcp_tools() -> list[dict]:
} }
for t in result.tools for t in result.tools
] ]
print(f"Loaded {len(tools)} MCP tools: {[t['function']['name'] for t in tools]}") log.info(f"Loaded {len(tools)} MCP tools: {[t['function']['name'] for t in tools]}")
return tools return tools
except Exception as exc: except Exception as exc:
print(f"Warning: could not load MCP tools: {exc}") log.info(f"Warning: could not load MCP tools: {exc}")
return [] return []
@@ -302,9 +374,15 @@ async def run_agent(messages: list) -> str:
all_tools = openai_tools + [PERSONALITY_TOOL] all_tools = openai_tools + [PERSONALITY_TOOL]
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools} kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools}
iteration = 0
while True: while True:
iteration += 1
log.info(f"[agent] LLM call #{iteration} starting")
t0 = time.monotonic()
response = await llm.chat.completions.create(**kwargs) response = await llm.chat.completions.create(**kwargs)
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)}")
if not msg.tool_calls: if not msg.tool_calls:
return msg.content return msg.content
@@ -313,7 +391,7 @@ async def run_agent(messages: list) -> str:
for tc in msg.tool_calls: for tc in msg.tool_calls:
args = json.loads(tc.function.arguments) args = json.loads(tc.function.arguments)
print(f"Tool call: {tc.function.name}({args})") log.info(f"Tool call: {tc.function.name}({args})")
if tc.function.name == "update_personality": if tc.function.name == "update_personality":
save_personality(args["personality"]) save_personality(args["personality"])
@@ -331,7 +409,7 @@ async def run_agent(messages: list) -> str:
@bot.event @bot.event
async def on_ready(): async def on_ready():
global openai_tools global openai_tools
print(f"Logged in as {bot.user} (id: {bot.user.id})") log.info(f"Logged in as {bot.user} (id: {bot.user.id})")
load_dossiers() load_dossiers()
load_personality() load_personality()
openai_tools = await fetch_mcp_tools() openai_tools = await fetch_mcp_tools()
@@ -339,7 +417,7 @@ async def on_ready():
for key, entry in list(dossiers.items()): for key, entry in list(dossiers.items()):
if len(entry.get("facts", [])) >= COMPACTION_THRESHOLD: if len(entry.get("facts", [])) >= COMPACTION_THRESHOLD:
name = entry.get("name", key) name = entry.get("name", key)
print(f"Scheduling startup compaction for '{key}'") log.info(f"Scheduling startup compaction for '{key}'")
asyncio.create_task(maybe_compact_dossier(key, name)) asyncio.create_task(maybe_compact_dossier(key, name))