Merge main into bb/gui.
Resolve merge conflicts while preserving bb/gui dashboard paths and STT provider support.
This commit is contained in:
+80
-10
@@ -74,6 +74,24 @@ def _normalize_notice_delivery(value: Any, default: str = "public") -> str:
|
||||
return default
|
||||
|
||||
|
||||
def _ensure_platform_extra_dict(platforms_data: dict, name: str) -> tuple[dict, dict]:
|
||||
"""Get-or-create ``platforms_data[name]`` and its nested ``extra`` dict.
|
||||
|
||||
Both slots are coerced to ``{}`` if a non-dict value is encountered, so
|
||||
callers can safely write keys without type-checking. Returns
|
||||
``(plat_data, extra)`` for in-place mutation.
|
||||
"""
|
||||
plat_data = platforms_data.setdefault(name, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
plat_data = {}
|
||||
platforms_data[name] = plat_data
|
||||
extra = plat_data.setdefault("extra", {})
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
return plat_data, extra
|
||||
|
||||
|
||||
# Module-level cache for bundled platform plugin names (lives outside the
|
||||
# enum so it doesn't become an accidental enum member).
|
||||
_Platform__bundled_plugin_names: Optional[set] = None
|
||||
@@ -717,6 +735,10 @@ def load_gateway_config() -> GatewayConfig:
|
||||
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
|
||||
|
||||
streaming_cfg = yaml_cfg.get("streaming")
|
||||
if not isinstance(streaming_cfg, dict):
|
||||
# Fall back to nested gateway.streaming written by
|
||||
# ``hermes config set gateway.streaming.*``
|
||||
streaming_cfg = yaml_cfg.get("gateway", {}).get("streaming")
|
||||
if isinstance(streaming_cfg, dict):
|
||||
gw_data["streaming"] = streaming_cfg
|
||||
|
||||
@@ -755,7 +777,27 @@ def load_gateway_config() -> GatewayConfig:
|
||||
merged["extra"] = merged_extra
|
||||
platforms_data[plat_name] = merged
|
||||
gw_data["platforms"] = platforms_data
|
||||
for plat in Platform:
|
||||
# Iterate built-in platforms plus any registered plugin platforms
|
||||
# so plugin authors get the same shared-key bridging (#24836).
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
discover_plugins() # idempotent
|
||||
from gateway.platform_registry import platform_registry as _pr
|
||||
except Exception as e:
|
||||
logger.debug("plugin discovery skipped: %s", e)
|
||||
_pr = None
|
||||
|
||||
_shared_loop_targets: list = list(Platform)
|
||||
if _pr is not None:
|
||||
for _entry in _pr.plugin_entries():
|
||||
try:
|
||||
_plat = Platform(_entry.name)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if _plat not in _shared_loop_targets:
|
||||
_shared_loop_targets.append(_plat)
|
||||
|
||||
for plat in _shared_loop_targets:
|
||||
if plat == Platform.LOCAL:
|
||||
continue
|
||||
platform_cfg = yaml_cfg.get(plat.value)
|
||||
@@ -810,20 +852,38 @@ def load_gateway_config() -> GatewayConfig:
|
||||
enabled_was_explicit = "enabled" in platform_cfg
|
||||
if not bridged and not enabled_was_explicit:
|
||||
continue
|
||||
plat_data = platforms_data.setdefault(plat.value, {})
|
||||
if not isinstance(plat_data, dict):
|
||||
plat_data = {}
|
||||
platforms_data[plat.value] = plat_data
|
||||
plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value)
|
||||
if enabled_was_explicit:
|
||||
plat_data["enabled"] = platform_cfg["enabled"]
|
||||
extra = plat_data.setdefault("extra", {})
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
plat_data["extra"] = extra
|
||||
if enabled_was_explicit:
|
||||
if plat == Platform.SLACK and enabled_was_explicit:
|
||||
extra["_enabled_explicit"] = True
|
||||
extra.update(bridged)
|
||||
|
||||
# Plugin-owned YAML→env config bridges (#24836). See
|
||||
# ``PlatformEntry.apply_yaml_config_fn`` for the hook contract.
|
||||
# Order: shared-key loop (above) → this dispatch → legacy hardcoded
|
||||
# blocks (below; no-op when a hook already set their env var) →
|
||||
# ``_apply_env_overrides()`` after ``GatewayConfig.from_dict``.
|
||||
if _pr is not None:
|
||||
for entry in _pr.all_entries():
|
||||
if entry.apply_yaml_config_fn is None:
|
||||
continue
|
||||
platform_cfg = yaml_cfg.get(entry.name)
|
||||
if not isinstance(platform_cfg, dict):
|
||||
continue
|
||||
try:
|
||||
seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"apply_yaml_config_fn for %s raised: %s",
|
||||
entry.name, e,
|
||||
)
|
||||
continue
|
||||
if not isinstance(seeded, dict) or not seeded:
|
||||
continue
|
||||
_, extra = _ensure_platform_extra_dict(platforms_data, entry.name)
|
||||
extra.update(seeded)
|
||||
|
||||
# Slack settings → env vars (env vars take precedence)
|
||||
slack_cfg = yaml_cfg.get("slack", {})
|
||||
if isinstance(slack_cfg, dict):
|
||||
@@ -852,6 +912,8 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(discord_cfg, dict):
|
||||
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
@@ -879,6 +941,14 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(ntc, list):
|
||||
ntc = ",".join(str(v) for v in ntc)
|
||||
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||
# history_backfill: recover missed channel messages for shared sessions
|
||||
# when require_mention is active. Fetches messages between bot turns
|
||||
# and prepends them to the user message for context.
|
||||
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
|
||||
hbl = discord_cfg.get("history_backfill_limit")
|
||||
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
|
||||
# allow_mentions: granular control over what the bot can ping.
|
||||
# Safe defaults (no @everyone/roles) are applied in the adapter;
|
||||
# these YAML keys only override when set and let users opt back
|
||||
|
||||
@@ -119,6 +119,22 @@ class PlatformEntry:
|
||||
# Signature: () -> Optional[dict[str, Any]]
|
||||
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
|
||||
|
||||
# ── YAML→env config bridge ──
|
||||
# Optional: translate this platform's ``config.yaml`` keys into env vars
|
||||
# and/or seed ``PlatformConfig.extra`` directly. Lets a plugin own its
|
||||
# YAML config translation instead of forcing core ``gateway/config.py``
|
||||
# to know every platform's schema.
|
||||
#
|
||||
# Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]
|
||||
# Called from ``load_gateway_config()`` after the generic shared-key loop
|
||||
# and before ``_apply_env_overrides``. Mutating ``os.environ`` is allowed
|
||||
# (use ``not os.getenv(...)`` guards to preserve env > YAML precedence);
|
||||
# any returned dict is merged into ``PlatformConfig.extra``. Exceptions
|
||||
# are caught and logged at debug level.
|
||||
# See website/docs/developer-guide/adding-platform-adapters.md for the
|
||||
# full contract and a worked example.
|
||||
apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
|
||||
|
||||
# Optional: home-channel env var name for cron/notification delivery
|
||||
# (e.g. ``"IRC_HOME_CHANNEL"``). When set, ``cron.scheduler`` treats this
|
||||
# platform as a valid ``deliver=<name>`` target and reads the env var to
|
||||
|
||||
@@ -21,6 +21,14 @@ status display, gateway setup, and more.
|
||||
constructed. Without this, env-only setups don't surface in
|
||||
`hermes gateway status` or `get_connected_platforms()` until the SDK
|
||||
instantiates.
|
||||
- `apply_yaml_config_fn: (yaml_cfg, platform_cfg) -> Optional[dict]` —
|
||||
translate this platform's `config.yaml` keys into env vars and/or seed
|
||||
`PlatformConfig.extra` directly. Lets a plugin own its YAML schema
|
||||
instead of growing core `gateway/config.py` boilerplate per platform.
|
||||
Mutating `os.environ` is allowed (use `not os.getenv(...)` guards to
|
||||
preserve env > YAML precedence); the returned dict is merged into
|
||||
`PlatformConfig.extra`. Called during `load_gateway_config()` after
|
||||
the generic shared-key loop and before `_apply_env_overrides()`.
|
||||
- `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var. When
|
||||
set, `deliver=<name>` cron jobs route to this var without editing
|
||||
`cron/scheduler.py`'s hardcoded sets.
|
||||
|
||||
@@ -356,15 +356,34 @@ class ResponseStore:
|
||||
# Evict oldest entries beyond max_size
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM responses").fetchone()[0]
|
||||
if count > self._max_size:
|
||||
self._conn.execute(
|
||||
"DELETE FROM responses WHERE response_id IN "
|
||||
"(SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?)",
|
||||
(count - self._max_size,),
|
||||
)
|
||||
# Collect IDs that will be evicted
|
||||
evict_ids = [
|
||||
row[0]
|
||||
for row in self._conn.execute(
|
||||
"SELECT response_id FROM responses ORDER BY accessed_at ASC LIMIT ?",
|
||||
(count - self._max_size,),
|
||||
).fetchall()
|
||||
]
|
||||
if evict_ids:
|
||||
placeholders = ",".join("?" for _ in evict_ids)
|
||||
# Clear conversation mappings pointing to evicted responses
|
||||
self._conn.execute(
|
||||
f"DELETE FROM conversations WHERE response_id IN ({placeholders})",
|
||||
evict_ids,
|
||||
)
|
||||
# Delete evicted responses
|
||||
self._conn.execute(
|
||||
f"DELETE FROM responses WHERE response_id IN ({placeholders})",
|
||||
evict_ids,
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def delete(self, response_id: str) -> bool:
|
||||
"""Remove a response from the store. Returns True if found and deleted."""
|
||||
# Clear conversation mappings pointing to this response
|
||||
self._conn.execute(
|
||||
"DELETE FROM conversations WHERE response_id = ?", (response_id,)
|
||||
)
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM responses WHERE response_id = ?", (response_id,)
|
||||
)
|
||||
|
||||
@@ -955,6 +955,12 @@ class MessageEvent:
|
||||
# Per-channel ephemeral system prompt (e.g. Discord channel_prompts).
|
||||
# Applied at API call time and never persisted to transcript history.
|
||||
channel_prompt: Optional[str] = None
|
||||
|
||||
# Channel context recovered by history backfill (e.g. messages between
|
||||
# bot turns that were missed due to require_mention). Kept separate
|
||||
# from ``text`` so the sender-prefix logic in run.py can operate on the
|
||||
# trigger message alone, then prepend this context afterward.
|
||||
channel_context: Optional[str] = None
|
||||
|
||||
# Internal flag — set for synthetic events (e.g. background process
|
||||
# completion notifications) that must bypass user authorization checks.
|
||||
@@ -1774,8 +1780,12 @@ class BasePlatformAdapter(ABC):
|
||||
The default implementation falls back to a numbered text list,
|
||||
which works on every platform — the user replies with a number
|
||||
("2") or with the literal choice text, and the gateway intercepts
|
||||
and resolves. Adapters with native button UIs (Telegram, Discord)
|
||||
SHOULD override this for a richer UX.
|
||||
and resolves. For the text fallback path, the default calls
|
||||
``mark_awaiting_text()`` so that the gateway text-intercept
|
||||
(:meth:`GatewayRunner._maybe_intercept_clarify_text`) catches the
|
||||
user's reply instead of timing out.
|
||||
Adapters with native button UIs (Telegram, Discord) SHOULD
|
||||
override this for a richer UX.
|
||||
"""
|
||||
if choices:
|
||||
lines = [f"❓ {question}", ""]
|
||||
@@ -1784,6 +1794,10 @@ class BasePlatformAdapter(ABC):
|
||||
lines.append("")
|
||||
lines.append("Reply with the number, the option text, or your own answer.")
|
||||
text = "\n".join(lines)
|
||||
# Text fallback: enable text-capture so the gateway intercept
|
||||
# picks up the user's typed reply (e.g. "2" or choice text).
|
||||
from tools.clarify_gateway import mark_awaiting_text
|
||||
mark_awaiting_text(clarify_id)
|
||||
else:
|
||||
text = f"❓ {question}"
|
||||
return await self.send(
|
||||
|
||||
@@ -111,9 +111,33 @@ DINGTALK_TYPE_MAPPING = {
|
||||
|
||||
|
||||
def check_dingtalk_requirements() -> bool:
|
||||
"""Check if DingTalk dependencies are available and configured."""
|
||||
"""Check if DingTalk dependencies are available and configured.
|
||||
|
||||
Lazy-installs dingtalk-stream via ``tools.lazy_deps.ensure("platform.dingtalk")``
|
||||
on first call if not present.
|
||||
"""
|
||||
global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage
|
||||
global HTTPX_AVAILABLE, httpx
|
||||
if not DINGTALK_STREAM_AVAILABLE or not HTTPX_AVAILABLE:
|
||||
return False
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("platform.dingtalk", prompt=False)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
import dingtalk_stream as _ds
|
||||
from dingtalk_stream import ChatbotMessage as _CM
|
||||
from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM
|
||||
import httpx as _httpx
|
||||
except ImportError:
|
||||
return False
|
||||
dingtalk_stream = _ds
|
||||
ChatbotMessage = _CM
|
||||
CallbackMessage = _CBM
|
||||
AckMessage = _AM
|
||||
httpx = _httpx
|
||||
DINGTALK_STREAM_AVAILABLE = True
|
||||
HTTPX_AVAILABLE = True
|
||||
if not os.getenv("DINGTALK_CLIENT_ID") or not os.getenv("DINGTALK_CLIENT_SECRET"):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -589,6 +589,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# chunk only, default), "all" (reply-reference on every chunk).
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._slash_commands: bool = self.config.extra.get("slash_commands", True)
|
||||
# In-memory cache of the bot's last message ID per channel, used by
|
||||
# history backfill to skip the full scan on hot paths. Falls back to
|
||||
# scanning channel.history() on cache miss (cold start / restart).
|
||||
self._last_self_message_id: Dict[str, str] = {}
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
@@ -1459,6 +1463,12 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raise
|
||||
message_ids.append(str(msg.id))
|
||||
|
||||
# Track the last message we sent in this channel for history
|
||||
# backfill — avoids a full channel.history() scan on hot paths.
|
||||
if message_ids:
|
||||
_target_id = thread_id or chat_id
|
||||
self._last_self_message_id[_target_id] = message_ids[-1]
|
||||
|
||||
return SendResult(
|
||||
success=True,
|
||||
message_id=message_ids[0] if message_ids else None,
|
||||
@@ -3577,6 +3587,153 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
def _discord_thread_require_mention(self) -> bool:
|
||||
"""Return whether thread participation requires @mention to follow up.
|
||||
|
||||
When ``False`` (default), once the bot has participated in a thread it
|
||||
keeps responding to every message in that thread without needing to be
|
||||
mentioned again — useful for one-on-one conversations.
|
||||
|
||||
When ``True``, the @mention requirement is enforced inside threads as
|
||||
well. Set this when multiple bots share a thread and you want each
|
||||
one to only fire on explicit @mention, avoiding bot-to-bot loops or
|
||||
unwanted cross-replies.
|
||||
"""
|
||||
configured = self.config.extra.get("thread_require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in ("false", "0", "no", "off")
|
||||
return bool(configured)
|
||||
return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _discord_history_backfill(self) -> bool:
|
||||
"""Return whether history backfill is enabled for shared sessions."""
|
||||
configured = self.config.extra.get("history_backfill")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() not in ("false", "0", "no", "off")
|
||||
return bool(configured)
|
||||
return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
def _discord_history_backfill_limit(self) -> int:
|
||||
"""Return the max number of messages to scan backwards for context.
|
||||
|
||||
In practice the scan usually stops much earlier — at the bot's own
|
||||
last message in the channel (the natural partition point). This
|
||||
limit is a safety cap for cold starts and long gaps where no prior
|
||||
bot message exists in recent history.
|
||||
"""
|
||||
configured = self.config.extra.get("history_backfill_limit")
|
||||
if configured is not None:
|
||||
try:
|
||||
return int(configured)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
raw = os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT", "50")
|
||||
try:
|
||||
return int(raw)
|
||||
except (ValueError, TypeError):
|
||||
return 50
|
||||
|
||||
async def _fetch_channel_context(
|
||||
self,
|
||||
channel: Any,
|
||||
before: "DiscordMessage",
|
||||
) -> str:
|
||||
"""Fetch recent channel messages for conversational context.
|
||||
|
||||
Scans backwards from *before* and collects messages until it hits
|
||||
a message sent by this bot (the natural partition point between
|
||||
bot turns) or reaches ``history_backfill_limit``.
|
||||
|
||||
Returns a formatted block like::
|
||||
|
||||
[Recent channel messages]
|
||||
[Alice] some message
|
||||
[Bob [bot]] another message
|
||||
|
||||
Returns an empty string if no context is available.
|
||||
"""
|
||||
limit = self._discord_history_backfill_limit()
|
||||
if limit <= 0:
|
||||
return ""
|
||||
|
||||
# Determine which bot messages to include in context
|
||||
allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
|
||||
include_other_bots = allow_bots_raw != "none"
|
||||
|
||||
# Use the in-memory cache to narrow the fetch window on hot paths.
|
||||
# If we know our last message ID in this channel, pass it as `after`
|
||||
# to avoid scanning the full limit. Falls back to scanning on cache
|
||||
# miss (cold start / restart).
|
||||
# Guard: only use the cache when it's chronologically before the
|
||||
# trigger — Discord snowflake IDs are monotonically increasing, so
|
||||
# a simple int comparison suffices.
|
||||
channel_id = str(getattr(channel, "id", ""))
|
||||
_cached_id = self._last_self_message_id.get(channel_id)
|
||||
_after_obj = None
|
||||
try:
|
||||
if _cached_id and int(_cached_id) < int(before.id):
|
||||
_after_obj = discord.Object(id=int(_cached_id))
|
||||
except (ValueError, TypeError):
|
||||
pass # Malformed cache entry — fall back to cold-start scan
|
||||
|
||||
try:
|
||||
collected = []
|
||||
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
|
||||
# silently flips the default to True when `after=` is supplied,
|
||||
# which would select the *earliest* N messages after our last
|
||||
# response instead of the *latest* N before the trigger. In
|
||||
# high-traffic windows that returns stale tool traces and drops
|
||||
# the actual final answer. See the regression test
|
||||
# `test_fetch_channel_context_cache_uses_latest_window_when_after_set`.
|
||||
async for msg in channel.history(
|
||||
limit=limit,
|
||||
before=before,
|
||||
after=_after_obj,
|
||||
oldest_first=False,
|
||||
):
|
||||
# Stop at our own message — this is the partition point.
|
||||
# Everything before this is already in the session transcript.
|
||||
# (Redundant when _after_obj is set, but needed for cold start.)
|
||||
if msg.author == self._client.user:
|
||||
break
|
||||
|
||||
# Skip system messages (pins, joins, thread renames, etc.)
|
||||
if msg.type not in (discord.MessageType.default, discord.MessageType.reply):
|
||||
continue
|
||||
|
||||
# Respect DISCORD_ALLOW_BOTS for other bots.
|
||||
# For history context, "mentions" is treated as "all" — we are
|
||||
# deciding what context to show, not whether to respond.
|
||||
if getattr(msg.author, "bot", False) and not include_other_bots:
|
||||
continue
|
||||
|
||||
content = getattr(msg, "clean_content", msg.content) or ""
|
||||
if not content and msg.attachments:
|
||||
content = "(attachment)"
|
||||
if not content:
|
||||
continue
|
||||
|
||||
name = msg.author.display_name
|
||||
if getattr(msg.author, "bot", False):
|
||||
name = f"{name} [bot]"
|
||||
collected.append(f"[{name}] {content}")
|
||||
|
||||
if not collected:
|
||||
return ""
|
||||
|
||||
# channel.history returns newest-first (oldest_first=False); reverse for chronological order
|
||||
collected.reverse()
|
||||
return "[Recent channel messages]\n" + "\n".join(collected)
|
||||
|
||||
except discord.Forbidden:
|
||||
logger.debug("[%s] Missing permissions to fetch channel history", self.name)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning("[%s] Failed to fetch channel history: %s", self.name, e)
|
||||
return ""
|
||||
|
||||
def _thread_parent_channel(self, channel: Any) -> Any:
|
||||
"""Return the parent text channel when invoked from a thread."""
|
||||
return getattr(channel, "parent", None) or channel
|
||||
@@ -3877,6 +4034,84 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_clarify(
|
||||
self,
|
||||
chat_id: str,
|
||||
question: str,
|
||||
choices: Optional[list],
|
||||
clarify_id: str,
|
||||
session_key: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Render a clarify prompt with one Discord button per choice.
|
||||
|
||||
Multi-choice mode (``choices`` non-empty): renders a button per option
|
||||
plus a final "✏️ Other (type answer)" button. Picking "Other" flips
|
||||
the clarify entry into text-capture mode so the next user message in
|
||||
the session becomes the response. Numeric clicks resolve immediately
|
||||
via ``resolve_gateway_clarify(clarify_id, choice_text)``.
|
||||
|
||||
Open-ended mode (``choices`` empty/None): renders the question as
|
||||
plain embed text — no buttons. The gateway's text-intercept captures
|
||||
the next message in this session and resolves the clarify.
|
||||
"""
|
||||
if not self._client or not DISCORD_AVAILABLE:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
target_id = chat_id
|
||||
if metadata and metadata.get("thread_id"):
|
||||
target_id = metadata["thread_id"]
|
||||
|
||||
channel = self._client.get_channel(int(target_id))
|
||||
if not channel:
|
||||
channel = await self._client.fetch_channel(int(target_id))
|
||||
|
||||
# Discord embed description limit is 4096; trim conservatively.
|
||||
max_desc = 4088
|
||||
body = str(question or "").strip()
|
||||
if len(body) > max_desc:
|
||||
body = body[: max_desc - 3] + "..."
|
||||
|
||||
embed = discord.Embed(
|
||||
title="❓ Hermes needs your input",
|
||||
description=body,
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
|
||||
clean_choices = [
|
||||
str(c).strip() for c in (choices or []) if c is not None and str(c).strip()
|
||||
]
|
||||
# Discord allows up to 5 buttons per row, 5 rows per view = 25.
|
||||
# We reserve one slot for the "Other" button, so cap at 24 choices.
|
||||
clean_choices = clean_choices[:24]
|
||||
|
||||
if clean_choices:
|
||||
embed.add_field(
|
||||
name="Choices",
|
||||
value="Pick one below, or click ✏️ Other to type a custom answer.",
|
||||
inline=False,
|
||||
)
|
||||
view = ClarifyChoiceView(
|
||||
choices=clean_choices,
|
||||
clarify_id=clarify_id,
|
||||
allowed_user_ids=self._allowed_user_ids,
|
||||
allowed_role_ids=self._allowed_role_ids,
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Reply",
|
||||
value="Reply in this channel with your answer.",
|
||||
inline=False,
|
||||
)
|
||||
view = None
|
||||
|
||||
msg = await channel.send(embed=embed, view=view) if view else await channel.send(embed=embed)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
except Exception as e:
|
||||
logger.warning("[%s] send_clarify failed: %s", self.name, e)
|
||||
return SendResult(success=False, error=str(e))
|
||||
|
||||
async def send_update_prompt(
|
||||
self, chat_id: str, prompt: str, default: str = "",
|
||||
session_key: str = "",
|
||||
@@ -4167,6 +4402,17 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raw_content = message.content.strip()
|
||||
normalized_content = raw_content
|
||||
mention_prefix = False
|
||||
|
||||
snapshot_attachments = []
|
||||
if hasattr(message, "message_snapshots") and message.message_snapshots:
|
||||
snapshot_text_parts = []
|
||||
for snap in message.message_snapshots:
|
||||
if getattr(snap, "content", None):
|
||||
snapshot_text_parts.append(snap.content.strip())
|
||||
snapshot_attachments.extend(getattr(snap, "attachments", []) or [])
|
||||
if snapshot_text_parts and not raw_content:
|
||||
raw_content = "\n".join(snapshot_text_parts)
|
||||
normalized_content = raw_content
|
||||
if self._client.user and self._client.user in message.mentions:
|
||||
mention_prefix = True
|
||||
normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||
@@ -4209,8 +4455,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
# Skip the mention check if the message is in a thread where
|
||||
# the bot has previously participated (auto-created or replied in).
|
||||
in_bot_thread = is_thread and thread_id in self._threads
|
||||
# the bot has previously participated (auto-created or replied in)
|
||||
# — UNLESS thread_require_mention is enabled, in which case threads
|
||||
# are gated the same as channels. Useful when multiple bots share
|
||||
# a thread.
|
||||
in_bot_thread = (
|
||||
is_thread
|
||||
and thread_id in self._threads
|
||||
and not self._discord_thread_require_mention()
|
||||
)
|
||||
|
||||
if require_mention and not is_free_channel and not in_bot_thread:
|
||||
if self._client.user not in message.mentions and not mention_prefix:
|
||||
@@ -4223,7 +4476,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
|
||||
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
|
||||
skip_thread = bool(channel_ids & no_thread_channels)
|
||||
skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
|
||||
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"}
|
||||
is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
|
||||
if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:
|
||||
@@ -4235,13 +4488,15 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
auto_threaded_channel = thread
|
||||
self._threads.mark(thread_id)
|
||||
|
||||
all_attachments = list(message.attachments) + snapshot_attachments
|
||||
|
||||
# Determine message type
|
||||
msg_type = MessageType.TEXT
|
||||
if normalized_content.startswith("/"):
|
||||
msg_type = MessageType.COMMAND
|
||||
elif message.attachments:
|
||||
elif all_attachments:
|
||||
# Check attachment types
|
||||
for att in message.attachments:
|
||||
for att in all_attachments:
|
||||
if att.content_type:
|
||||
if att.content_type.startswith("image/"):
|
||||
msg_type = MessageType.PHOTO
|
||||
@@ -4300,7 +4555,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
media_urls = []
|
||||
media_types = []
|
||||
pending_text_injection: Optional[str] = None
|
||||
for att in message.attachments:
|
||||
for att in all_attachments:
|
||||
content_type = att.content_type or "unknown"
|
||||
if content_type.startswith("image/"):
|
||||
try:
|
||||
@@ -4387,9 +4642,50 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if pending_text_injection:
|
||||
event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection
|
||||
|
||||
# ── History backfill ─────────────────────────────────────────
|
||||
# When require_mention is active, the bot only processes messages
|
||||
# that @mention it. Messages in the channel between bot turns are
|
||||
# invisible to the session transcript. To recover that context,
|
||||
# fetch recent channel history and prepend it to the user message.
|
||||
#
|
||||
# The fetch window is: everything after the bot's last message in
|
||||
# the channel up to (but not including) the current trigger. On
|
||||
# cold start (no prior bot message found), fetch the last N messages
|
||||
# and stop at the first self-message encountered.
|
||||
#
|
||||
# Threads naturally scope to thread-only history (channel.history()
|
||||
# on a thread returns only that thread's messages). DMs are skipped
|
||||
# because every DM message triggers the bot — there's no mention gap
|
||||
# to fill; the session transcript already has everything.
|
||||
#
|
||||
# Per-user sessions also benefit: Alice's session is missing the
|
||||
# other-channel-participants' context, and her own messages from
|
||||
# before she mentioned the bot. Backfill fills that gap.
|
||||
#
|
||||
# Messages that arrive while the bot is processing (between trigger
|
||||
# and response) are not captured — this is an accepted simplification
|
||||
# to keep the partition rule clean.
|
||||
_channel_context = None
|
||||
_is_dm = isinstance(message.channel, discord.DMChannel)
|
||||
if not _is_dm:
|
||||
_needed_mention = (
|
||||
require_mention
|
||||
and not is_free_channel
|
||||
and not in_bot_thread
|
||||
)
|
||||
_backfill_enabled = self._discord_history_backfill()
|
||||
if _needed_mention and _backfill_enabled:
|
||||
_backfill_text = await self._fetch_channel_context(
|
||||
message.channel, before=message,
|
||||
)
|
||||
if _backfill_text:
|
||||
_channel_context = _backfill_text
|
||||
|
||||
# Defense-in-depth: prevent empty user messages from entering session
|
||||
# (can happen when user sends @mention-only with no other text)
|
||||
if not event_text or not event_text.strip():
|
||||
# (can happen when user sends @mention-only with no other text).
|
||||
# When channel_context is present, a bare mention means "catch me up"
|
||||
# — the context IS the message, so skip the placeholder.
|
||||
if (not event_text or not event_text.strip()) and not _channel_context:
|
||||
event_text = "(The user sent a message with no text content)"
|
||||
|
||||
_chan = message.channel
|
||||
@@ -4418,6 +4714,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
timestamp=message.created_at,
|
||||
auto_skill=_skills,
|
||||
channel_prompt=_channel_prompt,
|
||||
channel_context=_channel_context,
|
||||
)
|
||||
|
||||
# Track thread participation so the bot won't require @mention for
|
||||
@@ -5099,3 +5396,188 @@ if DISCORD_AVAILABLE:
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
self.clear_items()
|
||||
|
||||
|
||||
class ClarifyChoiceView(discord.ui.View):
|
||||
"""Interactive button view for the clarify tool's multiple-choice prompts.
|
||||
|
||||
Renders one button per choice (max 24) plus a final ``✏️ Other`` button.
|
||||
Picking a numeric choice resolves the gateway clarify entry immediately;
|
||||
picking ``Other`` flips the entry into text-capture mode so the next
|
||||
user message in the session becomes the response (the gateway's
|
||||
text-intercept handles the resolution).
|
||||
|
||||
Auth gating mirrors ``ExecApprovalView`` — only users/roles in the
|
||||
Discord adapter's allowlist may answer. Single-use: after the first
|
||||
valid click all buttons disable and the embed updates to show who
|
||||
answered and what they chose.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choices: List[str],
|
||||
clarify_id: str,
|
||||
allowed_user_ids: set,
|
||||
allowed_role_ids: Optional[set] = None,
|
||||
):
|
||||
super().__init__(timeout=300) # 5-minute timeout
|
||||
self.choices = list(choices)[:24]
|
||||
self.clarify_id = clarify_id
|
||||
self.allowed_user_ids = allowed_user_ids
|
||||
self.allowed_role_ids = allowed_role_ids or set()
|
||||
self.resolved = False
|
||||
|
||||
for index, choice in enumerate(self.choices):
|
||||
# Discord button labels are capped at 80 chars.
|
||||
label_body = choice if len(choice) <= 75 else choice[:72] + "..."
|
||||
button = discord.ui.Button(
|
||||
label=f"{index + 1}. {label_body}",
|
||||
style=discord.ButtonStyle.primary,
|
||||
custom_id=f"clarify:{clarify_id}:{index}",
|
||||
)
|
||||
button.callback = self._make_choice_callback(index, choice)
|
||||
self.add_item(button)
|
||||
|
||||
other_btn = discord.ui.Button(
|
||||
label="✏️ Other (type answer)",
|
||||
style=discord.ButtonStyle.secondary,
|
||||
custom_id=f"clarify:{clarify_id}:other",
|
||||
)
|
||||
other_btn.callback = self._on_other
|
||||
self.add_item(other_btn)
|
||||
|
||||
def _check_auth(self, interaction: "discord.Interaction") -> bool:
|
||||
return _component_check_auth(
|
||||
interaction, self.allowed_user_ids, self.allowed_role_ids,
|
||||
)
|
||||
|
||||
def _make_choice_callback(self, index: int, choice: str):
|
||||
async def _callback(interaction: "discord.Interaction"):
|
||||
await self._resolve_choice(interaction, index, choice)
|
||||
return _callback
|
||||
|
||||
async def _resolve_choice(
|
||||
self,
|
||||
interaction: "discord.Interaction",
|
||||
index: int,
|
||||
choice: str,
|
||||
) -> None:
|
||||
"""Resolve the clarify with a chosen option."""
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"This prompt has already been answered~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized to answer this prompt~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
embed = interaction.message.embeds[0] if (
|
||||
interaction.message and interaction.message.embeds
|
||||
) else None
|
||||
if embed:
|
||||
user = getattr(interaction, "user", None)
|
||||
display_name = getattr(user, "display_name", "user")
|
||||
embed.color = discord.Color.green()
|
||||
embed.set_footer(text=f"Answered by {display_name}: {choice}")
|
||||
|
||||
try:
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Discord clarify edit_message failed for %s",
|
||||
self.clarify_id,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await interaction.response.defer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve via the gateway clarify primitive — same mechanism as
|
||||
# Telegram. Look up the canonical choice text from the entry so
|
||||
# we round-trip the original value, not a button-label variant.
|
||||
resolved_text: Optional[str] = None
|
||||
try:
|
||||
from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
|
||||
entry = _clarify_entries.get(self.clarify_id)
|
||||
if entry and entry.choices and 0 <= index < len(entry.choices):
|
||||
resolved_text = entry.choices[index]
|
||||
except Exception:
|
||||
resolved_text = None
|
||||
if resolved_text is None:
|
||||
resolved_text = choice
|
||||
|
||||
try:
|
||||
from tools.clarify_gateway import resolve_gateway_clarify
|
||||
resolved = resolve_gateway_clarify(self.clarify_id, resolved_text)
|
||||
logger.info(
|
||||
"Discord clarify button resolved (id=%s, choice=%r, user=%s, ok=%s)",
|
||||
self.clarify_id, resolved_text,
|
||||
getattr(getattr(interaction, "user", None), "display_name", "?"),
|
||||
resolved,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Discord clarify resolve_gateway_clarify failed (id=%s): %s",
|
||||
self.clarify_id, exc,
|
||||
)
|
||||
|
||||
async def _on_other(self, interaction: "discord.Interaction") -> None:
|
||||
"""Flip the clarify entry into text-capture mode."""
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"This prompt has already been answered~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
if not self._check_auth(interaction):
|
||||
await interaction.response.send_message(
|
||||
"You're not authorized to answer this prompt~", ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Don't pop the entry — the gateway's text-intercept needs it
|
||||
# until the user actually types. Just mark it as awaiting text
|
||||
# and disable the buttons so the user can't double-click.
|
||||
try:
|
||||
from tools.clarify_gateway import mark_awaiting_text
|
||||
mark_awaiting_text(self.clarify_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Discord clarify mark_awaiting_text failed (id=%s): %s",
|
||||
self.clarify_id, exc,
|
||||
)
|
||||
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
embed = interaction.message.embeds[0] if (
|
||||
interaction.message and interaction.message.embeds
|
||||
) else None
|
||||
if embed:
|
||||
user = getattr(interaction, "user", None)
|
||||
display_name = getattr(user, "display_name", "user")
|
||||
embed.color = discord.Color.blue()
|
||||
embed.set_footer(
|
||||
text=f"Awaiting typed response from {display_name}…",
|
||||
)
|
||||
|
||||
try:
|
||||
await interaction.response.edit_message(embed=embed, view=self)
|
||||
except Exception:
|
||||
try:
|
||||
await interaction.response.defer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def on_timeout(self):
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
@@ -1300,12 +1300,12 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
||||
except Exception:
|
||||
logger.debug("[Feishu] Failed to apply websocket runtime overrides", exc_info=True)
|
||||
|
||||
async def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
|
||||
def _connect_with_overrides(*args: Any, **kwargs: Any) -> Any:
|
||||
if adapter._ws_ping_interval is not None and "ping_interval" not in kwargs:
|
||||
kwargs["ping_interval"] = adapter._ws_ping_interval
|
||||
if adapter._ws_ping_timeout is not None and "ping_timeout" not in kwargs:
|
||||
kwargs["ping_timeout"] = adapter._ws_ping_timeout
|
||||
return await original_connect(*args, **kwargs)
|
||||
return original_connect(*args, **kwargs)
|
||||
|
||||
def _configure_with_overrides(conf: Any) -> Any:
|
||||
if original_configure is None:
|
||||
@@ -1343,8 +1343,65 @@ def _run_official_feishu_ws_client(ws_client: Any, adapter: Any) -> None:
|
||||
|
||||
|
||||
def check_feishu_requirements() -> bool:
|
||||
"""Check if Feishu/Lark dependencies are available."""
|
||||
return FEISHU_AVAILABLE
|
||||
"""Check if Feishu/Lark dependencies are available.
|
||||
|
||||
Lazy-installs lark-oapi via ``tools.lazy_deps.ensure("platform.feishu")``
|
||||
on first call if not present. Rebinds all module-level globals on success.
|
||||
"""
|
||||
if FEISHU_AVAILABLE:
|
||||
return True
|
||||
|
||||
def _import():
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.application.v6 import GetApplicationRequest
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest, CreateFileRequestBody,
|
||||
CreateImageRequest, CreateImageRequestBody,
|
||||
CreateMessageRequest, CreateMessageRequestBody,
|
||||
GetChatRequest, GetMessageRequest, GetMessageResourceRequest,
|
||||
P2ImMessageMessageReadV1,
|
||||
ReplyMessageRequest, ReplyMessageRequestBody,
|
||||
UpdateMessageRequest, UpdateMessageRequestBody,
|
||||
)
|
||||
from lark_oapi.core import AccessTokenType, HttpMethod
|
||||
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
|
||||
from lark_oapi.core.model import BaseRequest
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
CallBackCard, P2CardActionTriggerResponse,
|
||||
)
|
||||
from lark_oapi.event.dispatcher_handler import EventDispatcherHandler
|
||||
from lark_oapi.ws import Client as FeishuWSClient
|
||||
return {
|
||||
"lark": lark,
|
||||
"GetApplicationRequest": GetApplicationRequest,
|
||||
"CreateFileRequest": CreateFileRequest,
|
||||
"CreateFileRequestBody": CreateFileRequestBody,
|
||||
"CreateImageRequest": CreateImageRequest,
|
||||
"CreateImageRequestBody": CreateImageRequestBody,
|
||||
"CreateMessageRequest": CreateMessageRequest,
|
||||
"CreateMessageRequestBody": CreateMessageRequestBody,
|
||||
"GetChatRequest": GetChatRequest,
|
||||
"GetMessageRequest": GetMessageRequest,
|
||||
"GetMessageResourceRequest": GetMessageResourceRequest,
|
||||
"P2ImMessageMessageReadV1": P2ImMessageMessageReadV1,
|
||||
"ReplyMessageRequest": ReplyMessageRequest,
|
||||
"ReplyMessageRequestBody": ReplyMessageRequestBody,
|
||||
"UpdateMessageRequest": UpdateMessageRequest,
|
||||
"UpdateMessageRequestBody": UpdateMessageRequestBody,
|
||||
"AccessTokenType": AccessTokenType,
|
||||
"HttpMethod": HttpMethod,
|
||||
"FEISHU_DOMAIN": FEISHU_DOMAIN,
|
||||
"LARK_DOMAIN": LARK_DOMAIN,
|
||||
"BaseRequest": BaseRequest,
|
||||
"CallBackCard": CallBackCard,
|
||||
"P2CardActionTriggerResponse": P2CardActionTriggerResponse,
|
||||
"EventDispatcherHandler": EventDispatcherHandler,
|
||||
"FeishuWSClient": FeishuWSClient,
|
||||
"FEISHU_AVAILABLE": True,
|
||||
}
|
||||
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
return ensure_and_bind("platform.feishu", _import, globals(), prompt=False)
|
||||
|
||||
|
||||
class FeishuAdapter(BasePlatformAdapter):
|
||||
|
||||
@@ -224,7 +224,11 @@ def _check_e2ee_deps() -> bool:
|
||||
|
||||
|
||||
def check_matrix_requirements() -> bool:
|
||||
"""Return True if the Matrix adapter can be used."""
|
||||
"""Return True if the Matrix adapter can be used.
|
||||
|
||||
Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")``
|
||||
on first call if not present. Rebinds all module-level type globals on success.
|
||||
"""
|
||||
token = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||
password = os.getenv("MATRIX_PASSWORD", "")
|
||||
homeserver = os.getenv("MATRIX_HOMESERVER", "")
|
||||
@@ -238,10 +242,31 @@ def check_matrix_requirements() -> bool:
|
||||
try:
|
||||
import mautrix # noqa: F401
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
|
||||
)
|
||||
return False
|
||||
def _import():
|
||||
from mautrix.types import (
|
||||
ContentURI, EventID, EventType, PaginationDirection,
|
||||
PresenceState, RoomCreatePreset, RoomID, SyncToken,
|
||||
TrustState, UserID,
|
||||
)
|
||||
return {
|
||||
"ContentURI": ContentURI,
|
||||
"EventID": EventID,
|
||||
"EventType": EventType,
|
||||
"PaginationDirection": PaginationDirection,
|
||||
"PresenceState": PresenceState,
|
||||
"RoomCreatePreset": RoomCreatePreset,
|
||||
"RoomID": RoomID,
|
||||
"SyncToken": SyncToken,
|
||||
"TrustState": TrustState,
|
||||
"UserID": UserID,
|
||||
}
|
||||
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
|
||||
logger.warning(
|
||||
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
|
||||
)
|
||||
return False
|
||||
|
||||
# If encryption is requested, verify E2EE deps are available at startup
|
||||
# rather than silently degrading to plaintext-only at connect time.
|
||||
|
||||
@@ -176,6 +176,28 @@ class QQAdapter(BasePlatformAdapter):
|
||||
fut.set_exception(RuntimeError(reason))
|
||||
self._pending_responses.clear()
|
||||
|
||||
def _mark_transport_disconnected(self) -> None:
|
||||
"""Mark QQ WS down without stopping the reconnect loop.
|
||||
|
||||
BasePlatformAdapter uses _running for both process lifecycle and
|
||||
connection status. QQBot needs to keep the listener task alive across
|
||||
transient transport drops so it can continue reconnect attempts after a
|
||||
short-lived gateway or network failure.
|
||||
"""
|
||||
if self.has_fatal_error:
|
||||
return
|
||||
self._write_runtime_status_safe(
|
||||
"disconnected",
|
||||
platform_state="disconnected",
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return True only when the QQ WebSocket transport is usable."""
|
||||
return bool(self._running and self._ws and not self._ws.closed)
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.QQBOT)
|
||||
|
||||
@@ -509,7 +531,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
else:
|
||||
quick_disconnect_count = 0
|
||||
|
||||
self._mark_disconnected()
|
||||
self._mark_transport_disconnected()
|
||||
self._fail_pending("Connection closed")
|
||||
|
||||
# Stop reconnecting for fatal codes
|
||||
@@ -531,6 +553,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
RATE_LIMIT_DELAY,
|
||||
)
|
||||
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
|
||||
self._mark_disconnected()
|
||||
return
|
||||
await asyncio.sleep(RATE_LIMIT_DELAY)
|
||||
if await self._reconnect(backoff_idx):
|
||||
@@ -584,17 +607,19 @@ class QQAdapter(BasePlatformAdapter):
|
||||
backoff_idx += 1
|
||||
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
|
||||
logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag)
|
||||
self._mark_disconnected()
|
||||
return
|
||||
|
||||
except Exception as exc:
|
||||
if not self._running:
|
||||
return
|
||||
logger.warning("[%s] WebSocket error: %s", self._log_tag, exc)
|
||||
self._mark_disconnected()
|
||||
self._mark_transport_disconnected()
|
||||
self._fail_pending("Connection interrupted")
|
||||
|
||||
if backoff_idx >= MAX_RECONNECT_ATTEMPTS:
|
||||
logger.error("[%s] Max reconnect attempts reached", self._log_tag)
|
||||
self._mark_disconnected()
|
||||
return
|
||||
|
||||
if await self._reconnect(backoff_idx):
|
||||
|
||||
@@ -73,8 +73,29 @@ class _ThreadContextCache:
|
||||
|
||||
|
||||
def check_slack_requirements() -> bool:
|
||||
"""Check if Slack dependencies are available."""
|
||||
return SLACK_AVAILABLE
|
||||
"""Check if Slack dependencies are available.
|
||||
|
||||
Lazy-installs slack-bolt/slack-sdk via ``tools.lazy_deps.ensure("platform.slack")``
|
||||
on first call if not present. Rebinds all module-level globals on success.
|
||||
"""
|
||||
if SLACK_AVAILABLE:
|
||||
return True
|
||||
|
||||
def _import():
|
||||
from slack_bolt.async_app import AsyncApp
|
||||
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
import aiohttp
|
||||
return {
|
||||
"AsyncApp": AsyncApp,
|
||||
"AsyncSocketModeHandler": AsyncSocketModeHandler,
|
||||
"AsyncWebClient": AsyncWebClient,
|
||||
"aiohttp": aiohttp,
|
||||
"SLACK_AVAILABLE": True,
|
||||
}
|
||||
|
||||
from tools.lazy_deps import ensure_and_bind
|
||||
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
|
||||
|
||||
|
||||
def _extract_text_from_slack_blocks(blocks: list) -> str:
|
||||
@@ -1777,6 +1798,26 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
return
|
||||
|
||||
original_text = event.get("text", "")
|
||||
|
||||
# Slack blocks native slash commands inside threads ("/queue is not
|
||||
# supported in threads. Sorry!"). As a workaround, recognise a
|
||||
# leading ``!`` as an alternate command prefix and rewrite it to
|
||||
# ``/`` so the rest of the pipeline (MessageType.COMMAND tagging,
|
||||
# gateway dispatcher) handles it like a normal slash command. Only
|
||||
# rewrite when the first token resolves to a known gateway command
|
||||
# so casual messages like "!nice work" pass through unchanged.
|
||||
if original_text.startswith("!"):
|
||||
try:
|
||||
from hermes_cli.commands import is_gateway_known_command
|
||||
first_token = original_text[1:].split(maxsplit=1)[0]
|
||||
# Strip "@suffix" the same way get_command() does, so
|
||||
# forms like ``!stop@hermes`` still resolve.
|
||||
cmd_name = first_token.split("@", 1)[0].lower()
|
||||
if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name):
|
||||
original_text = "/" + original_text[1:]
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
text = original_text
|
||||
|
||||
# Extract quoted/forwarded content from Slack blocks.
|
||||
@@ -2744,7 +2785,10 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
from hermes_cli.commands import slack_subcommand_map
|
||||
subcommand_map = slack_subcommand_map()
|
||||
subcommand_map["compact"] = "/compress"
|
||||
first_word = text.split()[0] if text else ""
|
||||
# Guard against whitespace-only text where ``text`` is truthy but
|
||||
# ``text.split()`` returns ``[]`` (e.g. user sends ``/hermes ``).
|
||||
parts = text.split() if text else []
|
||||
first_word = parts[0] if parts else ""
|
||||
if first_word in subcommand_map:
|
||||
rest = text[len(first_word):].strip()
|
||||
text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word]
|
||||
|
||||
@@ -332,6 +332,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
||||
_GENERAL_TOPIC_THREAD_ID = "1"
|
||||
|
||||
# Telegram's edit_message applies MarkdownV2 formatting only on the
|
||||
# finalize=True path. Without this flag, stream_consumer._send_or_edit
|
||||
# short-circuits when the raw text is unchanged between the last streamed
|
||||
# edit and the final edit, skipping the plain-text → MarkdownV2 conversion.
|
||||
# Fixes #25710.
|
||||
REQUIRES_EDIT_FINALIZE: bool = True
|
||||
|
||||
# Adaptive text-batch ingress: short messages need a tighter delay so the
|
||||
# first token reaches the agent fast. Numbers tuned for "feels instant":
|
||||
# ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024
|
||||
@@ -2070,7 +2077,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="Not connected")
|
||||
try:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
text = f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}"
|
||||
text = self.format_message(f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}")
|
||||
keyboard = InlineKeyboardMarkup([
|
||||
[
|
||||
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
|
||||
@@ -2082,7 +2089,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
msg = await self._send_message_with_thread_fallback(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_id,
|
||||
**self._thread_kwargs_for_send(
|
||||
@@ -2334,11 +2341,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
provider_label = get_label(current_provider)
|
||||
text = (
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Current model: `{current_model or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
text = self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Current model: `{current_model or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
)
|
||||
|
||||
thread_id = metadata.get("thread_id") if metadata else None
|
||||
@@ -2346,7 +2355,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
msg = await self._send_message_with_thread_fallback(
|
||||
chat_id=int(chat_id),
|
||||
text=text,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
reply_to_message_id=reply_to_id,
|
||||
**self._thread_kwargs_for_send(
|
||||
@@ -2456,12 +2465,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
|
||||
|
||||
await query.edit_message_text(
|
||||
text=(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider: *{pname}*{page_info}\n"
|
||||
f"Select a model:{extra}"
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider: *{pname}*{page_info}\n"
|
||||
f"Select a model:{extra}"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
@@ -2490,12 +2501,14 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
|
||||
|
||||
await query.edit_message_text(
|
||||
text=(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider: *{pname}*{page_info}\n"
|
||||
f"Select a model:{extra}"
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider: *{pname}*{page_info}\n"
|
||||
f"Select a model:{extra}"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
@@ -2530,8 +2543,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Edit message to show confirmation, remove buttons
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=result_text,
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
text=self.format_message(result_text),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2571,13 +2584,15 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
provider_label = state["current_provider"]
|
||||
|
||||
await query.edit_message_text(
|
||||
text=(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Current model: `{state['current_model'] or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Current model: `{state['current_model'] or 'unknown'}`\n"
|
||||
f"Provider: {provider_label}\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
@@ -2660,8 +2675,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Edit message to show decision, remove buttons
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"{label} by {user_display}",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
text=self.format_message(f"{label} by {user_display}"),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2714,8 +2729,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"{label} by {user_display}",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
text=self.format_message(f"{label} by {user_display}"),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
@@ -2740,8 +2755,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
prompt_message_id = getattr(query.message, "message_id", None)
|
||||
send_kwargs: Dict[str, Any] = {
|
||||
"chat_id": int(query.message.chat_id),
|
||||
"text": result_text,
|
||||
"parse_mode": ParseMode.MARKDOWN,
|
||||
"text": self.format_message(result_text),
|
||||
"parse_mode": ParseMode.MARKDOWN_V2,
|
||||
**self._link_preview_kwargs(),
|
||||
}
|
||||
chat_type_value = getattr(chat_type, "value", chat_type)
|
||||
@@ -2901,8 +2916,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
label = "Yes" if answer == "y" else "No"
|
||||
try:
|
||||
await query.edit_message_text(
|
||||
text=f"⚕ Update prompt answered: *{label}*",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
text=self.format_message(f"⚕ Update prompt answered: *{label}*"),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=None,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -322,6 +322,26 @@ class WhatsAppAdapter(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()}
|
||||
|
||||
@staticmethod
|
||||
def _is_broadcast_chat(chat_id: str) -> bool:
|
||||
"""True for WhatsApp pseudo-chats that aren't real conversations.
|
||||
|
||||
Covers Status updates (Stories) and Channel/Newsletter broadcasts.
|
||||
These show up as inbound messages on Baileys but the agent should
|
||||
never reply — answering a Story update spams the contact's status
|
||||
feed, and Channel posts aren't addressable in the first place.
|
||||
"""
|
||||
if not chat_id:
|
||||
return False
|
||||
cid = chat_id.strip().lower()
|
||||
if cid == "status@broadcast":
|
||||
return True
|
||||
# @broadcast suffix covers status@broadcast plus any future
|
||||
# broadcast-list variants. @newsletter is the Channel JID suffix.
|
||||
if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
"""Check whether a DM from the given sender should be processed."""
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -432,9 +452,16 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
return cleaned.strip() or text
|
||||
|
||||
def _should_process_message(self, data: Dict[str, Any]) -> bool:
|
||||
chat_id_raw = str(data.get("chatId") or "")
|
||||
# WhatsApp uses pseudo-chats for Status updates (Stories) and
|
||||
# Channel/Newsletter broadcasts. These are not real conversations
|
||||
# and the agent should never reply to them — even in self-chat mode
|
||||
# where the bridge may surface them as "fromMe" events.
|
||||
if self._is_broadcast_chat(chat_id_raw):
|
||||
return False
|
||||
is_group = data.get("isGroup", False)
|
||||
if is_group:
|
||||
chat_id = str(data.get("chatId") or "")
|
||||
chat_id = chat_id_raw
|
||||
if not self._is_group_allowed(chat_id):
|
||||
return False
|
||||
else:
|
||||
|
||||
+150
-34
@@ -147,6 +147,9 @@ _YB_RES_REF_RE = re.compile(
|
||||
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
|
||||
)
|
||||
|
||||
# Media kinds that can be resolved and injected into the model context
|
||||
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
|
||||
|
||||
# Strip page indicators like (1/3) appended by BasePlatformAdapter
|
||||
_INDICATOR_RE = re.compile(r'\s*\(\d+/\d+\)$')
|
||||
|
||||
@@ -925,6 +928,7 @@ class InboundContext:
|
||||
# Populated by QuoteContextMiddleware
|
||||
reply_to_message_id: Optional[str] = None
|
||||
reply_to_text: Optional[str] = None
|
||||
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
|
||||
|
||||
# Populated by MediaResolveMiddleware
|
||||
media_urls: list = dc_field(default_factory=list)
|
||||
@@ -1645,6 +1649,25 @@ class ExtractContentMiddleware(InboundMiddleware):
|
||||
return None
|
||||
return f"[link: {link} | visit link for full content]"
|
||||
|
||||
@staticmethod
|
||||
def _parse_resource_id(url: str) -> str:
|
||||
"""Extract resourceId from Yuanbao resource URL query parameters.
|
||||
|
||||
Args:
|
||||
url: Resource URL (e.g., https://...?resourceId=abc123)
|
||||
|
||||
Returns:
|
||||
Resource ID string, or empty string if not found
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
||||
ids = query.get("resourceId") or query.get("resourceid") or []
|
||||
return str(ids[0]).strip() if ids else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def _extract_text(cls, msg_body: list) -> str:
|
||||
"""Extract plain text content from MsgBody.
|
||||
@@ -1668,14 +1691,35 @@ class ExtractContentMiddleware(InboundMiddleware):
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif elem_type == "TIMImageElem":
|
||||
parts.append("[image]")
|
||||
# Extract resourceId from image_info_array URL
|
||||
image_info_array = content.get("image_info_array")
|
||||
if not isinstance(image_info_array, list):
|
||||
image_info_array = []
|
||||
image_info = None
|
||||
# Prefer medium image (index 1), fallback to index 0
|
||||
if len(image_info_array) > 1 and isinstance(image_info_array[1], dict):
|
||||
image_info = image_info_array[1]
|
||||
elif len(image_info_array) > 0 and isinstance(image_info_array[0], dict):
|
||||
image_info = image_info_array[0]
|
||||
image_url = str((image_info or {}).get("url") or "").strip()
|
||||
rid = cls._parse_resource_id(image_url)
|
||||
parts.append(f"[image|ybres:{rid}]" if rid else "[image]")
|
||||
elif elem_type == "TIMFileElem":
|
||||
filename = content.get("file_name", content.get("fileName", content.get("filename", "")))
|
||||
parts.append(f"[file: {filename}]" if filename else "[file]")
|
||||
file_url = str(content.get("url") or "").strip()
|
||||
rid = cls._parse_resource_id(file_url)
|
||||
if rid:
|
||||
parts.append(f"[file:{filename}|ybres:{rid}]" if filename else f"[file|ybres:{rid}]")
|
||||
else:
|
||||
parts.append(f"[file: {filename}]" if filename else "[file]")
|
||||
elif elem_type == "TIMSoundElem":
|
||||
parts.append("[voice]")
|
||||
sound_url = str(content.get("url") or "").strip()
|
||||
rid = cls._parse_resource_id(sound_url)
|
||||
parts.append(f"[voice|ybres:{rid}]" if rid else "[voice]")
|
||||
elif elem_type == "TIMVideoFileElem":
|
||||
parts.append("[video]")
|
||||
video_url = str(content.get("url") or "").strip()
|
||||
rid = cls._parse_resource_id(video_url)
|
||||
parts.append(f"[video|ybres:{rid}]" if rid else "[video]")
|
||||
elif elem_type == "TIMCustomElem":
|
||||
data_val = content.get("data", "")
|
||||
if data_val:
|
||||
@@ -2132,22 +2176,23 @@ class QuoteContextMiddleware(InboundMiddleware):
|
||||
name = "quote-context"
|
||||
|
||||
@staticmethod
|
||||
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
|
||||
"""Extract quote context, mapping to MessageEvent.reply_to_*.
|
||||
|
||||
Returns:
|
||||
(reply_to_message_id, reply_to_text)
|
||||
(reply_to_message_id, reply_to_text, quote_media_refs)
|
||||
where quote_media_refs is a list of (rid, kind, filename) tuples
|
||||
"""
|
||||
if not cloud_custom_data:
|
||||
return None, None
|
||||
return None, None, []
|
||||
try:
|
||||
parsed = json.loads(cloud_custom_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None
|
||||
return None, None, []
|
||||
|
||||
quote = parsed.get("quote") if isinstance(parsed, dict) else None
|
||||
if not isinstance(quote, dict):
|
||||
return None, None
|
||||
return None, None, []
|
||||
|
||||
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
|
||||
quote_type = int(quote.get("type") or 0)
|
||||
@@ -2155,15 +2200,26 @@ class QuoteContextMiddleware(InboundMiddleware):
|
||||
if quote_type == 2 and not desc:
|
||||
desc = "[image]"
|
||||
if not desc:
|
||||
return None, None
|
||||
return None, None, []
|
||||
|
||||
quote_id = str(quote.get("id") or "").strip() or None
|
||||
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
|
||||
quote_text = f"{sender}: {desc}" if sender else desc
|
||||
return quote_id, quote_text
|
||||
|
||||
# Extract media references from desc using _YB_RES_REF_RE regex
|
||||
media_refs: list = []
|
||||
for m in _YB_RES_REF_RE.finditer(desc):
|
||||
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
media_refs.append((rid, kind, filename.strip()))
|
||||
|
||||
return quote_id, quote_text, media_refs
|
||||
|
||||
async def handle(self, ctx: InboundContext, next_fn) -> None:
|
||||
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
|
||||
|
||||
await next_fn()
|
||||
|
||||
|
||||
@@ -2332,7 +2388,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
for ref in media_refs:
|
||||
kind = str(ref.get("kind") or "").strip().lower()
|
||||
url = str(ref.get("url") or "").strip()
|
||||
if kind not in {"image", "file"} or not url:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -2391,7 +2447,7 @@ class MediaResolveMiddleware(InboundMiddleware):
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind not in {"image", "file"}:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
if rid in seen:
|
||||
continue
|
||||
@@ -2458,26 +2514,82 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
media_urls = list(ctx.media_urls)
|
||||
media_types = list(ctx.media_types)
|
||||
|
||||
# Backfill observed media from recent transcript history
|
||||
extra_img_urls: List[str] = []
|
||||
extra_img_mimes: List[str] = []
|
||||
try:
|
||||
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
|
||||
adapter, ctx.source,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
if extra_img_urls:
|
||||
current = set(media_urls)
|
||||
for u, m in zip(extra_img_urls, extra_img_mimes):
|
||||
if u in current:
|
||||
# If user quoted a message (reply_to_message_id is set), resolve only
|
||||
# quote_media_refs to avoid injecting unrelated history media.
|
||||
# Otherwise, backfill observed media from recent transcript history.
|
||||
if ctx.reply_to_message_id is not None:
|
||||
# Fallback: if desc didn't contain ybres refs, look up transcript
|
||||
if not ctx.quote_media_refs:
|
||||
try:
|
||||
store = getattr(adapter, "_session_store", None)
|
||||
if store:
|
||||
session_entry = store.get_or_create_session(ctx.source)
|
||||
history = store.load_transcript(session_entry.session_id)
|
||||
for msg in reversed(history or []):
|
||||
mid = msg.get("message_id", "")
|
||||
if mid and mid == ctx.reply_to_message_id:
|
||||
_content = msg.get("content", "")
|
||||
if isinstance(_content, str) and "|ybres:" in _content:
|
||||
for m in _YB_RES_REF_RE.finditer(_content):
|
||||
head = m.group(1)
|
||||
rid = m.group(2)
|
||||
kind, _, filename = head.partition(":")
|
||||
kind = kind.strip()
|
||||
if kind in _RESOLVABLE_MEDIA_KINDS:
|
||||
ctx.quote_media_refs.append((rid, kind, filename.strip()))
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote transcript lookup failed: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
# User quoted a message — resolve only media from the quote
|
||||
for rid, kind, filename in ctx.quote_media_refs:
|
||||
if kind not in _RESOLVABLE_MEDIA_KINDS:
|
||||
continue
|
||||
media_urls.append(u)
|
||||
media_types.append(m)
|
||||
current.add(u)
|
||||
try:
|
||||
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
|
||||
adapter.name, rid, kind, exc,
|
||||
)
|
||||
continue
|
||||
cached = await MediaResolveMiddleware._download_and_cache(
|
||||
adapter,
|
||||
fetch_url=fresh_url,
|
||||
kind=kind,
|
||||
file_name=filename or None,
|
||||
log_tag=f"quote rid={rid}",
|
||||
)
|
||||
if cached is None:
|
||||
continue
|
||||
path, mime = cached
|
||||
# Avoid duplicates
|
||||
if path not in media_urls:
|
||||
media_urls.append(path)
|
||||
media_types.append(mime)
|
||||
else:
|
||||
# No quote — backfill observed media from recent transcript history
|
||||
extra_img_urls: List[str] = []
|
||||
extra_img_mimes: List[str] = []
|
||||
try:
|
||||
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
|
||||
adapter, ctx.source,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[%s] observed-image hydration raised, continuing anyway: %s",
|
||||
adapter.name, exc,
|
||||
)
|
||||
if extra_img_urls:
|
||||
current = set(media_urls)
|
||||
for u, m in zip(extra_img_urls, extra_img_mimes):
|
||||
if u in current:
|
||||
continue
|
||||
media_urls.append(u)
|
||||
media_types.append(m)
|
||||
current.add(u)
|
||||
|
||||
# Replace [kind|ybres:xxx] anchors with local cache paths so
|
||||
# the transcript records usable paths for the model.
|
||||
@@ -2506,7 +2618,11 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
|
||||
event = MessageEvent(
|
||||
text=_patched_event_text,
|
||||
message_type=ctx.msg_type,
|
||||
message_type=(
|
||||
MessageType.DOCUMENT
|
||||
if any(mt.startswith(("application/", "text/")) for mt in media_types)
|
||||
else ctx.msg_type
|
||||
),
|
||||
source=ctx.source,
|
||||
message_id=ctx.msg_id or None,
|
||||
raw_message=ctx.push,
|
||||
|
||||
+192
-4
@@ -1139,6 +1139,38 @@ def _should_clear_resume_pending_after_turn(agent_result: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _preserve_queued_followup_history_offset(
|
||||
current_result: dict,
|
||||
followup_result: dict,
|
||||
) -> dict:
|
||||
"""Carry the outer history offset through queued follow-up drains.
|
||||
|
||||
``_process_message_background()`` persists transcript rows only once, after the
|
||||
entire in-band queued-follow-up chain returns. Each recursive ``_run_agent()``
|
||||
call advances ``history_offset`` to the history it received, so without
|
||||
correction the outermost persistence step sees only the *last* queued turn as
|
||||
"new" and silently drops earlier turns from the same drain chain.
|
||||
|
||||
Preserve the earliest (outermost) history offset so the final transcript slice
|
||||
still includes every queued turn that ran during the chain.
|
||||
"""
|
||||
if not isinstance(followup_result, dict):
|
||||
return followup_result
|
||||
if not isinstance(current_result, dict):
|
||||
return followup_result
|
||||
|
||||
current_offset = current_result.get("history_offset")
|
||||
followup_offset = followup_result.get("history_offset")
|
||||
if not isinstance(current_offset, int):
|
||||
return followup_result
|
||||
if isinstance(followup_offset, int) and followup_offset <= current_offset:
|
||||
return followup_result
|
||||
|
||||
merged = dict(followup_result)
|
||||
merged["history_offset"] = current_offset
|
||||
return merged
|
||||
|
||||
|
||||
class GatewayRunner:
|
||||
"""
|
||||
Main gateway controller.
|
||||
@@ -6096,6 +6128,12 @@ class GatewayRunner:
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "model":
|
||||
return "Agent is running — wait or /stop first, then switch models."
|
||||
|
||||
# /codex-runtime must not be used while the agent is running.
|
||||
# Switching mid-turn would split a turn across two transports.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "codex-runtime":
|
||||
return ("Agent is running — wait or /stop first, then "
|
||||
"change runtime.")
|
||||
|
||||
# /approve and /deny must bypass the running-agent interrupt path.
|
||||
# The agent thread is blocked on a threading.Event inside
|
||||
# tools/approval.py — sending an interrupt won't unblock it.
|
||||
@@ -6135,6 +6173,12 @@ class GatewayRunner:
|
||||
return await self._handle_goal_command(event)
|
||||
return "Agent is running — use /goal status / pause / clear mid-run, or /stop before setting a new goal."
|
||||
|
||||
# /subgoal is safe mid-run — it only modifies the goal's
|
||||
# subgoals list, which the judge reads at the next turn
|
||||
# boundary. No race with the running turn.
|
||||
if _cmd_def_inner and _cmd_def_inner.name == "subgoal":
|
||||
return await self._handle_subgoal_command(event)
|
||||
|
||||
# Session-level toggles that are safe to run mid-agent —
|
||||
# /yolo can unblock a pending approval prompt, /verbose cycles
|
||||
# the tool-progress display mode for the ongoing stream.
|
||||
@@ -6430,6 +6474,9 @@ class GatewayRunner:
|
||||
if canonical == "model":
|
||||
return await self._handle_model_command(event)
|
||||
|
||||
if canonical == "codex-runtime":
|
||||
return await self._handle_codex_runtime_command(event)
|
||||
|
||||
if canonical == "personality":
|
||||
return await self._handle_personality_command(event)
|
||||
|
||||
@@ -6513,6 +6560,9 @@ class GatewayRunner:
|
||||
if canonical == "goal":
|
||||
return await self._handle_goal_command(event)
|
||||
|
||||
if canonical == "subgoal":
|
||||
return await self._handle_subgoal_command(event)
|
||||
|
||||
if canonical == "voice":
|
||||
return await self._handle_voice_command(event)
|
||||
|
||||
@@ -6759,6 +6809,12 @@ class GatewayRunner:
|
||||
if _is_shared_multi_user and source.user_name:
|
||||
message_text = f"[{source.user_name}] {message_text}"
|
||||
|
||||
# Prepend channel context from history backfill (if any). This
|
||||
# happens after sender-prefix so the prefix only applies to the
|
||||
# trigger message, not the backfill block.
|
||||
if getattr(event, "channel_context", None):
|
||||
message_text = f"{event.channel_context}\n\n[New message]\n{message_text}"
|
||||
|
||||
if event.media_urls:
|
||||
image_paths = []
|
||||
audio_paths = []
|
||||
@@ -7935,6 +7991,8 @@ class GatewayRunner:
|
||||
try:
|
||||
if _err_body is not None:
|
||||
_err_json = _err_body.json().get("error", {})
|
||||
if not isinstance(_err_json, dict):
|
||||
_err_json = {}
|
||||
except Exception:
|
||||
pass
|
||||
if _err_json.get("type") == "usage_limit_reached":
|
||||
@@ -9210,6 +9268,51 @@ class GatewayRunner:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /codex-runtime command in the gateway.
|
||||
|
||||
Same surface as the CLI handler in cli.py:
|
||||
/codex-runtime — show current state
|
||||
/codex-runtime auto — Hermes default runtime
|
||||
/codex-runtime codex_app_server — codex subprocess runtime
|
||||
/codex-runtime on / off — synonyms
|
||||
|
||||
On change, the cached agent for this session is evicted so the next
|
||||
message creates a fresh AIAgent with the new api_mode wired in
|
||||
(avoids prompt-cache invalidation mid-session)."""
|
||||
from hermes_cli import codex_runtime_switch as crs
|
||||
|
||||
raw_args = event.get_command_args().strip() if event else ""
|
||||
new_value, errors = crs.parse_args(raw_args)
|
||||
if errors:
|
||||
return "❌ " + "\n❌ ".join(errors)
|
||||
|
||||
# Load + persist via the same helpers used for /model and /yolo
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
except Exception as exc:
|
||||
return f"❌ Could not load config: {exc}"
|
||||
cfg = load_config()
|
||||
|
||||
result = crs.apply(
|
||||
cfg,
|
||||
new_value,
|
||||
persist_callback=(save_config if new_value is not None else None),
|
||||
)
|
||||
|
||||
# On a real change, evict the cached agent so the new runtime takes
|
||||
# effect on the next message rather than waiting for cache TTL.
|
||||
if result.success and new_value is not None and result.requires_new_session:
|
||||
try:
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
self._evict_cached_agent(session_key)
|
||||
except Exception:
|
||||
logger.debug("could not evict cached agent after codex-runtime change",
|
||||
exc_info=True)
|
||||
|
||||
prefix = "✓" if result.success else "✗"
|
||||
return f"{prefix} {result.message}"
|
||||
|
||||
async def _handle_personality_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /personality command - list or set a personality."""
|
||||
from hermes_constants import display_hermes_home
|
||||
@@ -9438,6 +9541,57 @@ class GatewayRunner:
|
||||
|
||||
return t("gateway.goal.set", budget=state.max_turns, goal=state.goal)
|
||||
|
||||
async def _handle_subgoal_command(self, event: "MessageEvent") -> str:
|
||||
"""Handle /subgoal for gateway platforms (mirror of CLI handler).
|
||||
|
||||
Subgoals are extra criteria appended to the active goal mid-loop.
|
||||
They modify state read at the next turn boundary, so this is safe
|
||||
to invoke while the agent is running.
|
||||
"""
|
||||
args = (event.get_command_args() or "").strip()
|
||||
mgr, _session_entry = self._get_goal_manager_for_event(event)
|
||||
if mgr is None:
|
||||
return t("gateway.goal.unavailable")
|
||||
if not mgr.has_goal():
|
||||
return "No active goal. Set one with /goal <text>."
|
||||
|
||||
# No args → list current subgoals.
|
||||
if not args:
|
||||
return f"{mgr.status_line()}\n{mgr.render_subgoals()}"
|
||||
|
||||
tokens = args.split(None, 1)
|
||||
verb = tokens[0].lower()
|
||||
rest = tokens[1].strip() if len(tokens) > 1 else ""
|
||||
|
||||
if verb == "remove":
|
||||
if not rest:
|
||||
return "Usage: /subgoal remove <n>"
|
||||
try:
|
||||
idx = int(rest.split()[0])
|
||||
except ValueError:
|
||||
return "/subgoal remove: <n> must be an integer (1-based index)."
|
||||
try:
|
||||
removed = mgr.remove_subgoal(idx)
|
||||
except (IndexError, RuntimeError) as exc:
|
||||
return f"/subgoal remove: {exc}"
|
||||
return f"✓ Removed subgoal {idx}: {removed}"
|
||||
|
||||
if verb == "clear":
|
||||
try:
|
||||
prev = mgr.clear_subgoals()
|
||||
except RuntimeError as exc:
|
||||
return f"/subgoal clear: {exc}"
|
||||
if prev:
|
||||
return f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}."
|
||||
return "No subgoals to clear."
|
||||
|
||||
try:
|
||||
text = mgr.add_subgoal(args)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return f"/subgoal: {exc}"
|
||||
idx = len(mgr.state.subgoals) if mgr.state else 0
|
||||
return f"✓ Added subgoal {idx}: {text}"
|
||||
|
||||
async def _send_goal_status_notice(self, source: Any, message: str) -> None:
|
||||
"""Send a /goal judge status line back to the originating chat/thread."""
|
||||
adapter = self.adapters.get(source.platform)
|
||||
@@ -10209,6 +10363,10 @@ class GatewayRunner:
|
||||
|
||||
event_message_id = self._reply_anchor_for_event(event)
|
||||
|
||||
# Forward image/audio attachments so the background agent can see them.
|
||||
media_urls = list(event.media_urls) if event.media_urls else []
|
||||
media_types = list(event.media_types) if event.media_types else []
|
||||
|
||||
# Fire-and-forget the background task
|
||||
_task = asyncio.create_task(
|
||||
self._run_background_task(
|
||||
@@ -10216,6 +10374,8 @@ class GatewayRunner:
|
||||
source,
|
||||
task_id,
|
||||
event_message_id=event_message_id,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
)
|
||||
)
|
||||
self._background_tasks.add(_task)
|
||||
@@ -10230,10 +10390,15 @@ class GatewayRunner:
|
||||
source: "SessionSource",
|
||||
task_id: str,
|
||||
event_message_id: Optional[str] = None,
|
||||
media_urls: Optional[List[str]] = None,
|
||||
media_types: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Execute a background agent task and deliver the result to the chat."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
media_urls = media_urls or []
|
||||
media_types = media_types or []
|
||||
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if not adapter:
|
||||
logger.warning("No adapter for platform %s in background task %s", source.platform, task_id)
|
||||
@@ -10269,6 +10434,23 @@ class GatewayRunner:
|
||||
self._service_tier = self._load_service_tier()
|
||||
turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs)
|
||||
|
||||
# Enrich the prompt with image descriptions so the background
|
||||
# agent can see user-attached images (same as the main flow).
|
||||
enriched_prompt = prompt
|
||||
if media_urls:
|
||||
image_paths = []
|
||||
for i, path in enumerate(media_urls):
|
||||
mtype = media_types[i] if i < len(media_types) else ""
|
||||
if mtype.startswith("image/"):
|
||||
image_paths.append(path)
|
||||
if image_paths:
|
||||
try:
|
||||
enriched_prompt = await self._enrich_message_with_vision(
|
||||
prompt, image_paths,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Background task vision enrichment failed: %s", e)
|
||||
|
||||
def run_sync():
|
||||
agent = AIAgent(
|
||||
model=turn_route["model"],
|
||||
@@ -10300,7 +10482,7 @@ class GatewayRunner:
|
||||
)
|
||||
try:
|
||||
return agent.run_conversation(
|
||||
user_message=prompt,
|
||||
user_message=enriched_prompt,
|
||||
task_id=task_id,
|
||||
)
|
||||
finally:
|
||||
@@ -15957,6 +16139,7 @@ class GatewayRunner:
|
||||
_already_streamed = bool(
|
||||
(_sc and getattr(_sc, "final_response_sent", False))
|
||||
or _previewed
|
||||
or (_sc and getattr(_sc, "final_content_delivered", False))
|
||||
)
|
||||
first_response = result.get("final_response", "")
|
||||
if first_response and not _already_streamed:
|
||||
@@ -16042,7 +16225,7 @@ class GatewayRunner:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return await self._run_agent(
|
||||
followup_result = await self._run_agent(
|
||||
message=next_message,
|
||||
context_prompt=context_prompt,
|
||||
history=updated_history,
|
||||
@@ -16054,6 +16237,7 @@ class GatewayRunner:
|
||||
event_message_id=next_message_id,
|
||||
channel_prompt=next_channel_prompt,
|
||||
)
|
||||
return _preserve_queued_followup_history_offset(result, followup_result)
|
||||
finally:
|
||||
# Stop progress sender, interrupt monitor, and notification task
|
||||
if progress_task:
|
||||
@@ -16117,12 +16301,16 @@ class GatewayRunner:
|
||||
# response_previewed means the interim_assistant_callback already
|
||||
# sent the final text via the adapter (non-streaming path).
|
||||
_previewed = bool(response.get("response_previewed"))
|
||||
if not _is_empty_sentinel and (_streamed or _previewed):
|
||||
_content_delivered = bool(
|
||||
_sc and getattr(_sc, "final_content_delivered", False)
|
||||
)
|
||||
if not _is_empty_sentinel and (_streamed or _previewed or _content_delivered):
|
||||
logger.info(
|
||||
"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s).",
|
||||
"Suppressing normal final send for session %s: final delivery already confirmed (streamed=%s previewed=%s content_delivered=%s).",
|
||||
session_key or "?",
|
||||
_streamed,
|
||||
_previewed,
|
||||
_content_delivered,
|
||||
)
|
||||
response["already_sent"] = True
|
||||
|
||||
|
||||
@@ -518,6 +518,9 @@ class SessionEntry:
|
||||
else None
|
||||
),
|
||||
"is_fresh_reset": self.is_fresh_reset,
|
||||
"was_auto_reset": self.was_auto_reset,
|
||||
"auto_reset_reason": self.auto_reset_reason,
|
||||
"reset_had_activity": self.reset_had_activity,
|
||||
}
|
||||
if self.origin:
|
||||
result["origin"] = self.origin.to_dict()
|
||||
@@ -567,6 +570,9 @@ class SessionEntry:
|
||||
resume_reason=data.get("resume_reason"),
|
||||
last_resume_marked_at=last_resume_marked_at,
|
||||
is_fresh_reset=data.get("is_fresh_reset", False),
|
||||
was_auto_reset=data.get("was_auto_reset", False),
|
||||
auto_reset_reason=data.get("auto_reset_reason"),
|
||||
reset_had_activity=data.get("reset_had_activity", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -128,6 +128,7 @@ def _read_process_cmdline(pid: int) -> Optional[str]:
|
||||
|
||||
On Linux, reads /proc/<pid>/cmdline directly. On macOS and other
|
||||
platforms without /proc, falls back to ``ps -p <pid> -o command=``.
|
||||
On Windows (no /proc, no ps), uses psutil.
|
||||
"""
|
||||
cmdline_path = Path(f"/proc/{pid}/cmdline")
|
||||
try:
|
||||
@@ -150,6 +151,16 @@ def _read_process_cmdline(pid: int) -> Optional[str]:
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Windows fallback: psutil (already used by _pid_exists)
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
proc = psutil.Process(pid)
|
||||
cmdline_parts = proc.cmdline()
|
||||
if cmdline_parts:
|
||||
return " ".join(cmdline_parts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -178,7 +189,8 @@ def _record_looks_like_gateway(record: dict[str, Any]) -> bool:
|
||||
if not isinstance(argv, list) or not argv:
|
||||
return False
|
||||
|
||||
cmdline = " ".join(str(part) for part in argv)
|
||||
# Normalize Windows backslashes so patterns match cross-platform.
|
||||
cmdline = " ".join(str(part) for part in argv).replace("\\", "/")
|
||||
patterns = (
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
|
||||
@@ -150,6 +150,10 @@ class GatewayStreamConsumer:
|
||||
self._flood_strikes = 0 # Consecutive flood-control edit failures
|
||||
self._current_edit_interval = self.cfg.edit_interval # Adaptive backoff
|
||||
self._final_response_sent = False
|
||||
# Set when the final response content was sent to the user via
|
||||
# streaming, even if the final edit (cursor removal etc.)
|
||||
# subsequently failed.
|
||||
self._final_content_delivered = False
|
||||
# Cache adapter lifecycle capability: only platforms that need an
|
||||
# explicit finalize call (e.g. DingTalk AI Cards) force us to make
|
||||
# a redundant final edit. Everyone else keeps the fast path.
|
||||
@@ -187,6 +191,12 @@ class GatewayStreamConsumer:
|
||||
"""True when the stream consumer delivered the final assistant reply."""
|
||||
return self._final_response_sent
|
||||
|
||||
@property
|
||||
def final_content_delivered(self) -> bool:
|
||||
"""True when the final response content reached the user, even if
|
||||
the subsequent cosmetic edit (cursor removal) failed."""
|
||||
return self._final_content_delivered
|
||||
|
||||
def on_segment_break(self) -> None:
|
||||
"""Finalize the current stream segment and start a fresh message."""
|
||||
self._queue.put(_NEW_SEGMENT)
|
||||
@@ -455,6 +465,8 @@ class GatewayStreamConsumer:
|
||||
# tool-progress edits or fallback-mode promotion (#10748)
|
||||
# — that doesn't mean the final answer reached the user.
|
||||
self._final_response_sent = chunks_delivered
|
||||
if chunks_delivered:
|
||||
self._final_content_delivered = True
|
||||
return
|
||||
if got_segment_break:
|
||||
self._message_id = None
|
||||
@@ -505,6 +517,11 @@ class GatewayStreamConsumer:
|
||||
self._last_edit_time = time.monotonic()
|
||||
|
||||
if got_done:
|
||||
# Record that the final content reached the user even
|
||||
# if the cosmetic final edit below fails.
|
||||
if current_update_visible and self._accumulated:
|
||||
self._final_content_delivered = True
|
||||
|
||||
# Final edit without cursor. If progressive editing failed
|
||||
# mid-stream, send a single continuation/fallback message
|
||||
# here instead of letting the base gateway path send the
|
||||
|
||||
Reference in New Issue
Block a user