Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
+7
-1
@@ -830,6 +830,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["require_mention"] = platform_cfg["require_mention"]
|
||||
if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg:
|
||||
bridged["allowed_chats"] = platform_cfg["allowed_chats"]
|
||||
if plat == Platform.TELEGRAM and "group_allowed_chats" in platform_cfg:
|
||||
bridged["group_allowed_chats"] = platform_cfg["group_allowed_chats"]
|
||||
if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg:
|
||||
bridged["allowed_topics"] = platform_cfg["allowed_topics"]
|
||||
if "free_response_channels" in platform_cfg:
|
||||
@@ -838,6 +840,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
|
||||
if "exclusive_bot_mentions" in platform_cfg:
|
||||
bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"]
|
||||
if plat == Platform.TELEGRAM and "observe_unmentioned_group_messages" in platform_cfg:
|
||||
bridged["observe_unmentioned_group_messages"] = platform_cfg["observe_unmentioned_group_messages"]
|
||||
if "dm_policy" in platform_cfg:
|
||||
bridged["dm_policy"] = platform_cfg["dm_policy"]
|
||||
if "allow_from" in platform_cfg:
|
||||
@@ -1024,6 +1028,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
|
||||
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
|
||||
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
|
||||
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
|
||||
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
|
||||
frc = telegram_cfg.get("free_response_chats")
|
||||
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
|
||||
if isinstance(frc, list):
|
||||
@@ -1074,7 +1080,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(group_allowed_chats, list):
|
||||
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
|
||||
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
|
||||
for _telegram_extra_key in ("guest_mode", "disable_link_previews"):
|
||||
for _telegram_extra_key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"):
|
||||
if _telegram_extra_key in telegram_cfg:
|
||||
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
|
||||
@@ -64,7 +64,6 @@ def mirror_to_session(
|
||||
"mirror_source": source_label,
|
||||
}
|
||||
|
||||
_append_to_jsonl(session_id, mirror_msg)
|
||||
_append_to_sqlite(session_id, mirror_msg)
|
||||
|
||||
logger.debug("Mirror: wrote to session %s (from %s)", session_id, source_label)
|
||||
@@ -150,15 +149,6 @@ def _find_session_id(
|
||||
return best_entry.get("session_id")
|
||||
|
||||
|
||||
def _append_to_jsonl(session_id: str, message: dict) -> None:
|
||||
"""Append a message to the JSONL transcript file."""
|
||||
transcript_path = _SESSIONS_DIR / f"{session_id}.jsonl"
|
||||
try:
|
||||
with open(transcript_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(message, ensure_ascii=False) + "\n")
|
||||
except Exception as e:
|
||||
logger.debug("Mirror JSONL write failed: %s", e)
|
||||
|
||||
|
||||
def _append_to_sqlite(session_id: str, message: dict) -> None:
|
||||
"""Append a message to the SQLite session database."""
|
||||
|
||||
@@ -2706,8 +2706,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
Discord's TYPING_START gateway event is unreliable in DMs for bots.
|
||||
Instead, start a background loop that hits the typing endpoint every
|
||||
8 seconds (typing indicator lasts ~10s). The loop is cancelled when
|
||||
12 seconds (typing indicator lasts ~10s). The loop is cancelled when
|
||||
stop_typing() is called (after the response is sent).
|
||||
|
||||
Rate-limit handling: if a 429 is encountered, the loop logs a
|
||||
warning, sleeps for the ``retry_after`` duration (or a sensible
|
||||
default), and continues — it does NOT die on a single rate-limit
|
||||
hit. Only CancelledError (from stop_typing) stops the loop.
|
||||
"""
|
||||
if not self._client:
|
||||
return
|
||||
@@ -2727,9 +2732,22 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug("Discord typing indicator failed for %s: %s", chat_id, e)
|
||||
return
|
||||
await asyncio.sleep(8)
|
||||
# Don't die on 429 — backoff and continue
|
||||
retry_after = self._extract_discord_retry_after(e)
|
||||
if retry_after is not None:
|
||||
logger.warning(
|
||||
"Typing indicator rate-limited for %s; retrying in %.1fs",
|
||||
chat_id, retry_after,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Discord typing indicator failed for %s: %s",
|
||||
chat_id, e,
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(retry_after)
|
||||
continue
|
||||
await asyncio.sleep(12)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
|
||||
@@ -8,12 +8,14 @@ Uses python-telegram-bot library for:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import html as _html
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -4178,6 +4180,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return bool(configured)
|
||||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
def _telegram_observe_unmentioned_group_messages(self) -> bool:
|
||||
"""Return whether skipped unmentioned group messages are stored as context.
|
||||
|
||||
When enabled with ``require_mention``, Telegram matches the Yuanbao /
|
||||
OpenClaw-style group UX: observe ordinary group chatter in the session
|
||||
transcript, but only dispatch the agent when the bot is explicitly
|
||||
addressed.
|
||||
"""
|
||||
configured = self.config.extra.get("observe_unmentioned_group_messages")
|
||||
if configured is None:
|
||||
configured = self.config.extra.get("ingest_unmentioned_group_messages")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() in {"true", "1", "yes", "on"}
|
||||
return bool(configured)
|
||||
return os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES", "false").lower() in {"true", "1", "yes", "on"}
|
||||
|
||||
def _telegram_guest_mode(self) -> bool:
|
||||
"""Return whether non-allowlisted groups may trigger via direct @mention."""
|
||||
configured = self.config.extra.get("guest_mode")
|
||||
@@ -4219,6 +4238,30 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _telegram_group_allowed_chats(self) -> set[str]:
|
||||
"""Return Telegram chats authorized at group scope."""
|
||||
raw = self.config.extra.get("group_allowed_chats")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _telegram_observe_allowed_chats(self) -> set[str]:
|
||||
"""Chats where observed group context may use a shared source.
|
||||
|
||||
``group_allowed_chats`` is the gateway authorization allowlist for
|
||||
user-less group sources. ``allowed_chats`` remains an optional response
|
||||
gate; when set, observed context must satisfy both lists.
|
||||
"""
|
||||
group_allowed = self._telegram_group_allowed_chats()
|
||||
if not group_allowed:
|
||||
return set()
|
||||
response_allowed = self._telegram_allowed_chats()
|
||||
if response_allowed:
|
||||
return group_allowed & response_allowed
|
||||
return group_allowed
|
||||
|
||||
def _telegram_allowed_topics(self) -> set[str]:
|
||||
"""Return the whitelist of Telegram forum topic IDs this bot handles.
|
||||
|
||||
@@ -4466,6 +4509,126 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
||||
return cleaned or text
|
||||
|
||||
def _should_observe_unmentioned_group_message(self, message: Message) -> bool:
|
||||
"""Return True when a group message should be stored but not dispatched."""
|
||||
if not self._telegram_observe_unmentioned_group_messages():
|
||||
return False
|
||||
if not self._is_group_chat(message):
|
||||
return False
|
||||
|
||||
thread_id = getattr(message, "message_thread_id", None)
|
||||
allowed_topics = self._telegram_allowed_topics()
|
||||
if allowed_topics:
|
||||
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
||||
if topic_id not in allowed_topics:
|
||||
return False
|
||||
|
||||
if thread_id is not None:
|
||||
try:
|
||||
if int(thread_id) in self._telegram_ignored_threads():
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
||||
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
||||
return False
|
||||
|
||||
allowed = self._telegram_observe_allowed_chats()
|
||||
# Observed context is shared at chat/topic scope so a later trigger from
|
||||
# another user can see it. Require an explicit chat allowlist; that
|
||||
# keeps shared observed history limited to operator-approved groups and
|
||||
# lets gateway authorization pass even after the shared session source
|
||||
# drops the per-sender user_id.
|
||||
if not allowed or chat_id_str not in allowed:
|
||||
return False
|
||||
|
||||
# Only observe messages skipped by the require_mention gate. If the
|
||||
# message would be processed normally, let the dispatcher handle it;
|
||||
# if require_mention is disabled, every group message is a request.
|
||||
if chat_id_str in self._telegram_free_response_chats():
|
||||
return False
|
||||
if not self._telegram_require_mention():
|
||||
return False
|
||||
if self._is_reply_to_bot(message):
|
||||
return False
|
||||
if self._message_mentions_bot(message):
|
||||
return False
|
||||
if self._message_matches_mention_patterns(message):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _telegram_group_observe_shared_source(self, source):
|
||||
"""Return a chat/topic-scoped source for observed Telegram group context."""
|
||||
return dataclasses.replace(source, user_id=None, user_name=None, user_id_alt=None)
|
||||
|
||||
def _telegram_group_observe_attributed_text(self, event: MessageEvent) -> str:
|
||||
user_id = event.source.user_id or "unknown"
|
||||
sender = event.source.user_name or user_id
|
||||
return f"[{sender}|{user_id}]\n{event.text or ''}"
|
||||
|
||||
def _telegram_group_observe_channel_prompt(self) -> str:
|
||||
username = getattr(getattr(self, "_bot", None), "username", None) or "unknown"
|
||||
bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown"
|
||||
return (
|
||||
"You are handling a Telegram group chat message.\n"
|
||||
f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n"
|
||||
"- Lines in history prefixed with `[nickname|user_id]` are observed Telegram group context "
|
||||
"and are not necessarily addressed to you.\n"
|
||||
"- Treat only the current new message as a request explicitly directed at you, "
|
||||
"and answer it directly."
|
||||
)
|
||||
|
||||
def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
|
||||
"""Align triggered group turns with observed-history attribution."""
|
||||
if not self._telegram_observe_unmentioned_group_messages():
|
||||
return event
|
||||
raw_message = getattr(event, "raw_message", None)
|
||||
if not raw_message or not self._is_group_chat(raw_message):
|
||||
return event
|
||||
chat_id_str = str(getattr(getattr(raw_message, "chat", None), "id", ""))
|
||||
allowed = self._telegram_observe_allowed_chats()
|
||||
if not allowed or chat_id_str not in allowed:
|
||||
return event
|
||||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||||
observe_prompt = self._telegram_group_observe_channel_prompt()
|
||||
channel_prompt = f"{event.channel_prompt}\n\n{observe_prompt}" if event.channel_prompt else observe_prompt
|
||||
return dataclasses.replace(
|
||||
event,
|
||||
text=self._telegram_group_observe_attributed_text(event),
|
||||
source=shared_source,
|
||||
channel_prompt=channel_prompt,
|
||||
)
|
||||
|
||||
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
|
||||
"""Append skipped group chatter to the target session without dispatching."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
if not store:
|
||||
return
|
||||
try:
|
||||
event = self._build_message_event(message, msg_type, update_id=update_id)
|
||||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||||
session_entry = store.get_or_create_session(shared_source)
|
||||
entry = {
|
||||
"role": "user",
|
||||
"content": self._telegram_group_observe_attributed_text(event),
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"observed": True,
|
||||
}
|
||||
if event.message_id:
|
||||
entry["message_id"] = str(event.message_id)
|
||||
store.append_to_transcript(session_entry.session_id, entry)
|
||||
adapter_name = getattr(self, "name", "telegram")
|
||||
logger.info(
|
||||
"[%s] Telegram group message observed (no bot trigger): chat=%s from=%s",
|
||||
adapter_name,
|
||||
getattr(getattr(message, "chat", None), "id", "unknown"),
|
||||
event.source.user_id or "unknown",
|
||||
)
|
||||
except Exception as exc:
|
||||
adapter_name = getattr(self, "name", "telegram")
|
||||
logger.warning("[%s] Failed to observe Telegram group message: %s", adapter_name, exc)
|
||||
|
||||
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
||||
"""Apply Telegram group trigger rules.
|
||||
|
||||
@@ -4590,11 +4753,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not msg or not msg.text:
|
||||
return
|
||||
if not self._should_process_message(msg):
|
||||
if self._should_observe_unmentioned_group_message(msg):
|
||||
self._observe_unmentioned_group_message(msg, MessageType.TEXT, update_id=update.update_id)
|
||||
return
|
||||
await self._ensure_forum_commands(update.message)
|
||||
|
||||
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
@@ -4607,6 +4773,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
await self._ensure_forum_commands(msg)
|
||||
|
||||
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
@@ -4615,6 +4783,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not msg:
|
||||
return
|
||||
if not self._should_process_message(msg):
|
||||
if self._should_observe_unmentioned_group_message(msg):
|
||||
self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id)
|
||||
return
|
||||
|
||||
venue = getattr(msg, "venue", None)
|
||||
@@ -4644,6 +4814,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
|
||||
event.text = "\n".join(parts)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -4788,8 +4959,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
if self._should_observe_unmentioned_group_message(update.message):
|
||||
_m = update.message
|
||||
if _m.sticker:
|
||||
_observe_type = MessageType.STICKER
|
||||
elif _m.photo:
|
||||
_observe_type = MessageType.PHOTO
|
||||
elif _m.video:
|
||||
_observe_type = MessageType.VIDEO
|
||||
elif _m.audio:
|
||||
_observe_type = MessageType.AUDIO
|
||||
elif _m.voice:
|
||||
_observe_type = MessageType.VOICE
|
||||
else:
|
||||
_observe_type = MessageType.DOCUMENT
|
||||
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
|
||||
return
|
||||
|
||||
|
||||
msg = update.message
|
||||
|
||||
# Determine media type
|
||||
@@ -4817,9 +5003,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Handle stickers: describe via vision tool with caching
|
||||
if msg.sticker:
|
||||
await self._handle_sticker(msg, event)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
await self.handle_message(event)
|
||||
return
|
||||
|
||||
|
||||
# Apply observe attribution after caption is set; sticker is handled above
|
||||
# because _handle_sticker overwrites event.text with its vision description.
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
|
||||
# Download photo to local image cache so the vision tool can access it
|
||||
# even after Telegram's ephemeral file URLs expire (~1 hour).
|
||||
if msg.photo:
|
||||
|
||||
@@ -1410,41 +1410,43 @@ class RecallGuardMiddleware(InboundMiddleware):
|
||||
logger.warning("[%s] Recall: failed to resolve session: %s", adapter.name, exc)
|
||||
return
|
||||
|
||||
# Read JSONL directly — SQLite doesn't preserve message_id field.
|
||||
transcript: list = []
|
||||
# Load transcript from canonical store (state.db). Since PR #29278
|
||||
# added a ``platform_message_id`` column to the messages table and
|
||||
# ``append_to_transcript`` wires the incoming dict's ``message_id``
|
||||
# into it, ``load_transcript`` returns rows with ``message_id`` set
|
||||
# for any message that was observed with one — Branch A1 (exact id
|
||||
# match) is the canonical path again.
|
||||
try:
|
||||
path = store.get_transcript_path(sid)
|
||||
if path.exists():
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
transcript.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
transcript = store.load_transcript(sid)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Recall: failed to load transcript: %s", adapter.name, exc)
|
||||
return
|
||||
|
||||
# Branch A: redact — try message_id first, then content fallback.
|
||||
# Observed messages have message_id; agent-processed @bot messages
|
||||
# only have content (run.py doesn't write message_id to transcript).
|
||||
# Branch A1: exact platform message_id match. Authoritative when the
|
||||
# row was persisted with a platform_message_id (observed group
|
||||
# messages and any inbound message whose adapter carried a msg_id).
|
||||
target = None
|
||||
branch_label = ""
|
||||
for entry in transcript:
|
||||
if entry.get("message_id") == recalled_id:
|
||||
target = entry
|
||||
branch_label = "branch A1: id match"
|
||||
break
|
||||
# Branch A2: content-match fallback for messages that lack an exact
|
||||
# platform id on the row — e.g. agent-processed @bot messages
|
||||
# (run.py doesn't carry msg_id through) or older rows persisted
|
||||
# before the platform_message_id column existed.
|
||||
if target is None and recalled_content:
|
||||
for entry in transcript:
|
||||
if entry.get("role") == "user" and entry.get("content") == recalled_content:
|
||||
target = entry
|
||||
branch_label = "branch A2: content match"
|
||||
break
|
||||
if target is not None:
|
||||
target["content"] = cls._REDACTED
|
||||
try:
|
||||
store.rewrite_transcript(sid, transcript)
|
||||
logger.info("[%s] Recall: redacted msg_id=%s (branch A)", adapter.name, recalled_id)
|
||||
logger.info("[%s] Recall: redacted msg_id=%s (%s)", adapter.name, recalled_id, branch_label)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] Recall: rewrite_transcript failed: %s", adapter.name, exc)
|
||||
return
|
||||
|
||||
+26
-83
@@ -1248,20 +1248,15 @@ class SessionStore:
|
||||
|
||||
return entries
|
||||
|
||||
def get_transcript_path(self, session_id: str) -> Path:
|
||||
"""Get the path to a session's legacy transcript file."""
|
||||
return self.sessions_dir / f"{session_id}.jsonl"
|
||||
|
||||
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
|
||||
"""Append a message to a session's transcript (SQLite + legacy JSONL).
|
||||
"""Append a message to a session's transcript (SQLite).
|
||||
|
||||
Args:
|
||||
skip_db: When True, only write to JSONL and skip the SQLite write.
|
||||
Used when the agent already persisted messages to SQLite
|
||||
via its own _flush_messages_to_session_db(), preventing
|
||||
the duplicate-write bug (#860).
|
||||
skip_db: When True, skip the SQLite write. Used when the agent
|
||||
already persisted messages to SQLite via its own
|
||||
_flush_messages_to_session_db(), preventing the
|
||||
duplicate-write bug (#860).
|
||||
"""
|
||||
# Write to SQLite (unless the agent already handled it)
|
||||
if self._db and not skip_db:
|
||||
try:
|
||||
self._db.append_message(
|
||||
@@ -1276,94 +1271,42 @@ class SessionStore:
|
||||
reasoning_details=message.get("reasoning_details") if message.get("role") == "assistant" else None,
|
||||
codex_reasoning_items=message.get("codex_reasoning_items") if message.get("role") == "assistant" else None,
|
||||
codex_message_items=message.get("codex_message_items") if message.get("role") == "assistant" else None,
|
||||
# Platform-side message id (yuanbao msg_id, telegram update_id, …).
|
||||
# Accept either explicit ``platform_message_id`` or the legacy
|
||||
# ``message_id`` key the JSONL transcript used.
|
||||
platform_message_id=(
|
||||
message.get("platform_message_id") or message.get("message_id")
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
# Also write legacy JSONL (keeps existing tooling working during transition)
|
||||
transcript_path = self.get_transcript_path(session_id)
|
||||
try:
|
||||
with self._lock:
|
||||
with open(transcript_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(message, ensure_ascii=False) + "\n")
|
||||
except OSError as e:
|
||||
# Disk full / read-only fs / permission errors must not crash the
|
||||
# message handler — the SQLite write above is the primary store.
|
||||
logger.debug("Failed to write JSONL transcript for %s: %s", session_id, e)
|
||||
|
||||
def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
|
||||
"""Replace the entire transcript for a session with new messages.
|
||||
|
||||
Used by /retry, /undo, and /compress to persist modified conversation history.
|
||||
Rewrites both SQLite and legacy JSONL storage.
|
||||
|
||||
Used by /retry, /undo, and /compress to persist modified conversation
|
||||
history. state.db is the canonical store.
|
||||
"""
|
||||
# SQLite: replace atomically so a mid-rewrite failure doesn't leave
|
||||
# the session half-empty in the DB while JSONL still has history.
|
||||
if self._db:
|
||||
try:
|
||||
self._db.replace_messages(session_id, messages)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to rewrite transcript in DB: %s", e)
|
||||
|
||||
# JSONL: overwrite the file
|
||||
transcript_path = self.get_transcript_path(session_id)
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
for msg in messages:
|
||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||
|
||||
def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Load all messages from a session's transcript."""
|
||||
db_messages = []
|
||||
# Try SQLite first
|
||||
if self._db:
|
||||
try:
|
||||
db_messages = self._db.get_messages_as_conversation(session_id)
|
||||
except Exception as e:
|
||||
logger.debug("Could not load messages from DB: %s", e)
|
||||
"""Load all messages from a session's transcript.
|
||||
|
||||
# Load legacy JSONL transcript (may contain more history than SQLite
|
||||
# for sessions created before the DB layer was introduced).
|
||||
transcript_path = self.get_transcript_path(session_id)
|
||||
jsonl_messages = []
|
||||
if transcript_path.exists():
|
||||
try:
|
||||
with open(transcript_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
jsonl_messages.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Skipping corrupt line in transcript %s: %s",
|
||||
session_id, line[:120],
|
||||
)
|
||||
except OSError as e:
|
||||
# JSONL is the legacy compatibility store. If it becomes
|
||||
# unreadable, keep gateway recovery working by falling back to
|
||||
# SQLite rows loaded above (or [] when no DB exists).
|
||||
logger.debug("Failed to read JSONL transcript for %s: %s", session_id, e)
|
||||
|
||||
# Prefer whichever source has more messages.
|
||||
#
|
||||
# Background: when a session pre-dates SQLite storage (or when the DB
|
||||
# layer was added while a long-lived session was already active), the
|
||||
# first post-migration turn writes only the *new* messages to SQLite
|
||||
# (because _flush_messages_to_session_db skips messages already in
|
||||
# conversation_history, assuming they're persisted). On the *next*
|
||||
# turn load_transcript returns those few SQLite rows and ignores the
|
||||
# full JSONL history — the model sees a context of 1-4 messages instead
|
||||
# of hundreds. Using the longer source prevents this silent truncation.
|
||||
if len(jsonl_messages) > len(db_messages):
|
||||
if db_messages:
|
||||
logger.debug(
|
||||
"Session %s: JSONL has %d messages vs SQLite %d — "
|
||||
"using JSONL (legacy session not yet fully migrated)",
|
||||
session_id, len(jsonl_messages), len(db_messages),
|
||||
)
|
||||
return jsonl_messages
|
||||
|
||||
return db_messages
|
||||
state.db is the canonical store. The legacy JSONL fallback was removed
|
||||
in spec 002 — pre-DB sessions on existing disks have already been
|
||||
migrated (their DB row holds the full message history).
|
||||
"""
|
||||
if not self._db:
|
||||
return []
|
||||
try:
|
||||
return self._db.get_messages_as_conversation(session_id)
|
||||
except Exception as e:
|
||||
logger.debug("Could not load messages from DB: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def build_session_context(
|
||||
|
||||
Reference in New Issue
Block a user