Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -1191,10 +1191,12 @@ _MEDIA_EXT_ALTERNATION = "|".join(
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),
|
||||
# ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@@ -2665,9 +2667,10 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
# and relative paths (./foo.png)
|
||||
# (?:~/|/) anchors to absolute or home-relative paths
|
||||
# (?:~/|/) anchors to absolute or home-relative Unix paths
|
||||
# (?:[A-Za-z]:[/\\]) anchors to Windows drive-letter paths (#34632)
|
||||
path_re = re.compile(
|
||||
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@@ -2804,21 +2804,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return slug
|
||||
|
||||
try:
|
||||
# Build provider buttons — 2 per row
|
||||
buttons: list = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
# Compact callback data: mp:<slug> (max 64 bytes)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
# Build provider buttons — folds provider groups (display only).
|
||||
keyboard = self._build_provider_keyboard(providers)
|
||||
|
||||
provider_label = get_label(current_provider)
|
||||
text = self.format_message(
|
||||
@@ -2865,6 +2852,56 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list):
|
||||
"""Build the top-level provider keyboard, folding provider groups.
|
||||
|
||||
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
|
||||
a single ``mpg:<gid>`` button; tapping it drills into a member
|
||||
sub-keyboard. Single providers (and groups with only one authenticated
|
||||
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
|
||||
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
|
||||
so all surfaces stay consistent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.models import group_providers
|
||||
except Exception:
|
||||
group_providers = None
|
||||
|
||||
by_slug = {p.get("slug"): p for p in providers}
|
||||
|
||||
def _provider_button(p):
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
|
||||
buttons: list = []
|
||||
if group_providers is not None:
|
||||
for row in group_providers([p.get("slug") for p in providers]):
|
||||
if row["kind"] == "group":
|
||||
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
||||
count = sum(
|
||||
m.get("total_models", len(m.get("models", []))) for m in members
|
||||
)
|
||||
label = f"{row['label']} ▸ ({count})"
|
||||
if any(m.get("is_current") for m in members):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
|
||||
)
|
||||
else:
|
||||
p = by_slug.get(row["slug"])
|
||||
if p is not None:
|
||||
buttons.append(_provider_button(p))
|
||||
else:
|
||||
for p in providers:
|
||||
buttons.append(_provider_button(p))
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
||||
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
||||
page_size = self._MODEL_PAGE_SIZE
|
||||
@@ -3043,10 +3080,23 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list ---
|
||||
elif data.startswith("mpg:"):
|
||||
# --- Provider group selected: show member providers ---
|
||||
group_id = data[4:]
|
||||
try:
|
||||
from hermes_cli.models import PROVIDER_GROUPS
|
||||
_label, member_slugs = PROVIDER_GROUPS.get(group_id, ("", []))
|
||||
except Exception:
|
||||
_label, member_slugs = "", []
|
||||
|
||||
by_slug = {p["slug"]: p for p in state["providers"]}
|
||||
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
||||
if not members:
|
||||
await query.answer(text="Group not found.")
|
||||
return
|
||||
|
||||
buttons = []
|
||||
for p in state["providers"]:
|
||||
for p in members:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
@@ -3054,11 +3104,30 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
rows.append([
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider family: *{_label or group_id}*\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list (folds groups) ---
|
||||
keyboard = self._build_provider_keyboard(state["providers"])
|
||||
|
||||
try:
|
||||
provider_label = get_label(state["current_provider"])
|
||||
except Exception:
|
||||
@@ -3107,7 +3176,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
|
||||
+115
-6
@@ -1180,12 +1180,48 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# iLink delivers messages individually, so rapid multi-message
|
||||
# bursts (forwarded batches, paste-splits) each trigger a
|
||||
# separate agent invocation. Default 3s delay / 5s split delay
|
||||
# are tuned for iLink's typical delivery cadence. Tunable via
|
||||
# config.yaml under
|
||||
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 3.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 5.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
if self._account_id and not self._token:
|
||||
persisted = load_weixin_account(hermes_home, self._account_id)
|
||||
if persisted:
|
||||
self._token = str(persisted.get("token") or "").strip()
|
||||
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _coerce_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
@@ -1247,6 +1283,11 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def disconnect(self) -> None:
|
||||
_LIVE_ADAPTERS.pop(self._token, None)
|
||||
self._running = False
|
||||
for task in self._pending_text_batch_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self._pending_text_batches.clear()
|
||||
self._pending_text_batch_tasks.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
@@ -1395,12 +1436,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
await self.handle_message(event)
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -1409,6 +1448,76 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
return sender_id in self._allow_from
|
||||
return True
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text debounce batching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When users forward multiple messages or send rapid-fire texts
|
||||
via WeChat, each arrives as a separate iLink message. This
|
||||
concatenates them and waits for a short quiet period before
|
||||
dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
if self._pending_text_batch_tasks.get(key) is not current_task:
|
||||
return
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
|
||||
item_type = item.get("type")
|
||||
if item_type == ITEM_IMAGE:
|
||||
|
||||
@@ -278,6 +278,43 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# notification before the normal "✓ whatsapp disconnected" fires.
|
||||
self._shutting_down: bool = False
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# WhatsApp often delivers multiple messages in rapid succession
|
||||
# (e.g. forwarded batches, paste-splits) — without debounce each
|
||||
# message triggers a separate agent invocation, wasting tokens and
|
||||
# flooding the user with reply fragments. Default 5s delay /
|
||||
# 10s split delay are conservative for WhatsApp's delivery cadence.
|
||||
# Tunable via config.yaml under
|
||||
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 5.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 10.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
def _effective_reply_prefix(self) -> str:
|
||||
"""Return the prefix the Node bridge will add in self-chat mode."""
|
||||
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
|
||||
@@ -1139,7 +1176,10 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
await self.handle_message(event)
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -1151,7 +1191,67 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await asyncio.sleep(1) # Poll interval
|
||||
|
||||
|
||||
# ── Text debounce batching ──────────────────────────────────────
|
||||
|
||||
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When WhatsApp delivers rapid-fire messages (e.g. forwarded
|
||||
batches), this concatenates them and waits for a short quiet
|
||||
period before dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
|
||||
"""Build a MessageEvent from bridge message data, downloading images to cache."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user