Persist channel history, add fetch_channel_history tool, fix Qwen tool-use reliability

- Persist per-channel rolling history to disk (channel_history.json) so
  context survives bot restarts
- Add fetch_channel_history tool so the bot can pull Discord channel history
  on demand when it needs more context (e.g. first mention in a channel)
- Add _ensure_user_tail() called proactively before every LLM call to
  prevent Qwen "No user query found" template errors after tool results
- Set tool_choice="required" on the first agent call to guarantee the bot
  always uses a tool before replying; subsequent calls use "auto"

Co-Authored-By: DadBot <dadbot@localhost>
This commit is contained in:
Suppai_NekoandDadBot committed 2026-03-25 16:53:32 -07:00
1 parent 02267f0059
commit 495b7aaaba
2 files changed
+92 -27

No files matched your search

+85 -26
View File
@@ -34,6 +34,7 @@ MAX_CONTEXT = int(os.getenv("MAX_CONTEXT", "20"))
MCP_URL = os.getenv("MCP_URL", "http://mcp-searxng:4001/mcp")
DOSSIER_FILE = Path(os.getenv("DOSSIER_FILE", "dossiers.json"))
PERSONALITY_FILE = Path(os.getenv("PERSONALITY_FILE", "personality.txt"))
HISTORY_FILE = Path(os.getenv("HISTORY_FILE", "channel_history.json"))
llm = AsyncOpenAI(base_url=LITELLM_BASE_URL, api_key=LITELLM_API_KEY)
@@ -108,6 +109,29 @@ READ_LIFE_HISTORY_TOOL = {
},
}
FETCH_CHANNEL_HISTORY_TOOL = {
"type": "function",
"function": {
"name": "fetch_channel_history",
"description": (
"Fetch recent chat history from the current Discord channel directly from Discord. "
"Use this when you need more context than what's already in your conversation window — "
"for example, when you've just been mentioned for the first time, when someone refers to "
"something said earlier, or when you need to catch up on what's been discussed."
),
"parameters": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Number of recent messages to fetch (default 50, max 100).",
}
},
"required": [],
},
},
}
def load_personality():
global personality_notes
@@ -142,6 +166,22 @@ def save_dossiers():
DOSSIER_FILE.write_text(json.dumps(dossiers, indent=2))
def load_channel_history():
if HISTORY_FILE.exists():
text = HISTORY_FILE.read_text().strip()
if text:
data = json.loads(text)
for channel_id_str, messages in data.items():
ch = int(channel_id_str)
channel_history[ch] = deque(messages, maxlen=MAX_CONTEXT)
log.info(f"Loaded history for {len(data)} channel(s)")
def save_channel_history():
data = {str(ch_id): list(msgs) for ch_id, msgs in channel_history.items()}
HISTORY_FILE.write_text(json.dumps(data))
def load_reflection_notes() -> str:
if REFLECTION_NOTES_FILE.exists():
return REFLECTION_NOTES_FILE.read_text().strip()
@@ -517,14 +557,42 @@ async def call_mcp_tool(name: str, args: dict) -> str:
return "\n".join(parts)
async def run_agent(messages: list) -> str:
def _ensure_user_tail(messages: list) -> None:
"""Qwen's chat template requires the last message to be from the user. Inject a continuation prompt if needed."""
if messages and messages[-1]["role"] not in ("user", "system"):
messages.append({"role": "user", "content": "Please continue."})
async def fetch_discord_history(channel: discord.abc.Messageable, limit: int = 50) -> str:
limit = min(limit, 100)
msgs = []
async for msg in channel.history(limit=limit, oldest_first=False):
if not msg.content:
continue
if msg.author.id == bot.user.id:
name = "You"
elif msg.author.bot:
continue
else:
name = msg.author.display_name
msgs.append(f"{name}: {msg.content}")
msgs.reverse()
return "\n".join(msgs) if msgs else "No messages found."
async def run_agent(messages: list, channel: discord.abc.Messageable | None = None) -> 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, READ_LIFE_HISTORY_TOOL]
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools}
if channel is not None:
all_tools = all_tools + [FETCH_CHANNEL_HISTORY_TOOL]
kwargs = {"model": MODEL, "messages": list(messages), "tools": all_tools, "tool_choice": "required"}
iteration = 0
while True:
iteration += 1
if iteration > 1:
kwargs["tool_choice"] = "auto"
_ensure_user_tail(kwargs["messages"])
log.info(f"[agent] LLM call #{iteration} starting")
t0 = time.monotonic()
for attempt in range(3):
@@ -532,12 +600,7 @@ async def run_agent(messages: list) -> str:
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:
if _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)
@@ -564,6 +627,12 @@ async def run_agent(messages: list) -> str:
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."
elif tc.function.name == "fetch_channel_history":
if channel is not None:
limit = args.get("limit", 50)
result = await fetch_discord_history(channel, limit)
else:
result = "Channel history is not available in this context."
else:
result = await call_mcp_tool(tc.function.name, args)
@@ -583,6 +652,7 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str
final_content = ""
for turn in range(1, max_turns + 1):
_ensure_user_tail(kwargs["messages"])
log.info(f"[reflection] Turn {turn}/{max_turns}")
t0 = time.monotonic()
try:
@@ -591,15 +661,7 @@ async def run_reflection_agent(messages: list, max_turns: int) -> tuple[str, str
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
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)}")
@@ -646,6 +708,7 @@ async def run_life_event_reflection(messages: list):
last_assistant_text = ""
for turn in range(1, 4):
_ensure_user_tail(kwargs["messages"])
log.info(f"[life-sim] Reflection turn {turn}/3")
t0 = time.monotonic()
try:
@@ -654,14 +717,7 @@ async def run_life_event_reflection(messages: list):
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
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)}")
@@ -915,6 +971,7 @@ async def on_ready():
log.info(f"Logged in as {bot.user} (id: {bot.user.id})")
load_dossiers()
load_personality()
load_channel_history()
openai_tools = await fetch_mcp_tools()
# Compact any dossiers that are already over the threshold (run in background)
for key, entry in list(dossiers.items()):
@@ -978,6 +1035,7 @@ async def on_message(message: discord.Message):
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\nYou have a fetch_channel_history tool. Use it when you need more context than what's already in your conversation window — for example when you've just been mentioned for the first time, when someone references something said earlier, or when you're unsure what's been going on in the channel."
system_content = f"{system_content}\n\n{date_context}"
if dossier_context:
system_content = f"{system_content}\n\n{dossier_context}"
@@ -999,13 +1057,14 @@ async def on_message(message: discord.Message):
async with message.channel.typing():
try:
reply = await run_agent(messages)
reply = await run_agent(messages, channel=message.channel)
except Exception as exc:
reply = f"Error: {exc}"
history.pop()
if reply:
history.append({"role": "assistant", "content": reply})
save_channel_history()
asyncio.create_task(extract_and_update_dossier(message.author, list(history)))
asyncio.create_task(extract_and_update_self_dossier(list(history)))
+7 -1
View File
@@ -1,7 +1,13 @@
services:
discordbot:
build: .
restart: unless-stopped
restart: on-failure
deploy:
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 5
window: 120s
env_file: .env
volumes:
- ./dossiers.json:/app/dossiers.json