@inline-chat/hermes-agent-adapter 1.0.0-alpha.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -76,6 +76,16 @@ _CHAT_ACCESS_LOOKUP_TIMEOUT_SECONDS = 2.0
76
76
  _INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS = 30.0
77
77
  _INBOUND_RETRY_INITIAL_SECONDS = 1.0
78
78
  _MAX_INBOUND_DELIVERIES = 16
79
+ # Telegram's drop_pending_on_cold_boot, for Inline: updates the server recorded
80
+ # before this gateway process started are acknowledged without waking the agent.
81
+ # A lost or fresh SDK cursor otherwise replays chat history as new messages.
82
+ _PROCESS_STARTED_AT = time.time()
83
+ _COLD_BOOT_CLOCK_SKEW_SECONDS = 60
84
+ _COLD_BOOT_DROP_LOG_INTERVAL_SECONDS = 60.0
85
+ _COLD_BOOT_GATED_KINDS = frozenset({
86
+ "message.new", "message.edit", "message.delete", "message.history.clear",
87
+ "reaction.add", "reaction.delete", "chat.participant.add", "chat.participant.delete",
88
+ })
79
89
  # Provider discovery may invoke host CLIs. Keep repeated panel opens and
80
90
  # unrelated mutations off that slow path while always injecting the live
81
91
  # current/default model into the document below.
@@ -196,11 +206,42 @@ def _truthy(value: Any, default: bool = False) -> bool:
196
206
  return str(value).strip().lower() in {"1", "true", "yes", "on"}
197
207
 
198
208
 
209
+ _UNAVAILABLE_HERMES_SEAMS: set[str] = set()
210
+
211
+
212
+ def _hermes_seam_unavailable(seam: str, effect: str, exc: Optional[BaseException] = None) -> None:
213
+ """Warn once per process that an undocumented Hermes internal is missing or changed.
214
+
215
+ Hermes documents no public API for these seams yet; the Inline repo tracks them in
216
+ plugins/hermes-agent/docs/hermes-upstream-seams.md.
217
+ Callers degrade the one feature that needs the seam; message delivery never depends on one.
218
+ """
219
+ if seam in _UNAVAILABLE_HERMES_SEAMS:
220
+ return
221
+ _UNAVAILABLE_HERMES_SEAMS.add(seam)
222
+ reason = f"{type(exc).__name__}: {exc}" if exc is not None else "missing"
223
+ logger.warning("[inline] Hermes internal %s is unavailable (%s); %s", seam, reason, effect)
224
+
225
+
226
+ def _display_registration_fields() -> Dict[str, Any]:
227
+ """Inline's display defaults through the public PlatformEntry fields, when the host has them."""
228
+ try:
229
+ from gateway.platform_registry import PlatformEntry
230
+
231
+ fields = getattr(PlatformEntry, "__dataclass_fields__", {})
232
+ if {"display_tier", "display_overrides"}.issubset(fields):
233
+ # The overrides cover every key a tier sets, so the tier only names the closest base.
234
+ return {"display_tier": "low", "display_overrides": dict(_INLINE_DISPLAY_DEFAULTS)}
235
+ except Exception:
236
+ logger.debug("[inline] Hermes display registration fields are unavailable", exc_info=True)
237
+ return {}
238
+
239
+
199
240
  def _install_inline_display_defaults() -> None:
200
- """Register Slack-like display defaults for Inline when Hermes exposes them.
241
+ """Seed Inline's display defaults on hosts that predate PlatformEntry.display_overrides.
201
242
 
202
- Hermes core owns display resolution. External plugins do not yet have a
203
- first-class display-default hook, so we seed the same private table core
243
+ Newer hosts take the same defaults from ``ctx.register_platform`` (see
244
+ ``_display_registration_fields``); only older ones need the private table core
204
245
  platforms use. User config still wins over these defaults.
205
246
  """
206
247
  try:
@@ -208,6 +249,10 @@ def _install_inline_display_defaults() -> None:
208
249
 
209
250
  defaults = getattr(_display_config, "_PLATFORM_DEFAULTS", None)
210
251
  if not isinstance(defaults, dict):
252
+ _hermes_seam_unavailable(
253
+ "gateway.display_config._PLATFORM_DEFAULTS",
254
+ "Inline uses Hermes' generic display defaults (tool progress may be shown)",
255
+ )
211
256
  return
212
257
  inline_defaults = dict(_INLINE_DISPLAY_DEFAULTS)
213
258
  current = defaults.get("inline")
@@ -215,8 +260,12 @@ def _install_inline_display_defaults() -> None:
215
260
  defaults["inline"] = {**inline_defaults, **current}
216
261
  else:
217
262
  defaults["inline"] = inline_defaults
218
- except Exception:
219
- logger.debug("[inline] failed to install display defaults", exc_info=True)
263
+ except Exception as exc:
264
+ _hermes_seam_unavailable(
265
+ "gateway.display_config._PLATFORM_DEFAULTS",
266
+ "Inline uses Hermes' generic display defaults (tool progress may be shown)",
267
+ exc,
268
+ )
220
269
 
221
270
 
222
271
  def _reply_thread_mode(value: Any, default: str = "auto") -> str:
@@ -560,7 +609,12 @@ def _normalize_inline_plugin_command_text(text: str) -> str:
560
609
  from hermes_cli.plugins import get_plugin_commands
561
610
  if command not in (get_plugin_commands() or {}):
562
611
  return text
563
- except Exception:
612
+ except Exception as exc:
613
+ _hermes_seam_unavailable(
614
+ "hermes_cli.plugins.get_plugin_commands",
615
+ "hyphenated plugin commands must be typed with hyphens in Inline",
616
+ exc,
617
+ )
564
618
  return text
565
619
  # Inline menus require underscores, while Hermes registers plugin commands
566
620
  # with hyphens. Normalize before gateway access checks as well as dispatch.
@@ -575,16 +629,30 @@ def _normalize_inline_command_description(raw: str) -> str:
575
629
  return description
576
630
 
577
631
 
578
- def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[List[Dict[str, Any]], int]:
579
- from hermes_cli.commands_platforms import telegram_menu_commands
632
+ def _hermes_menu_commands(max_commands: int) -> tuple[list, int]:
633
+ """Hermes' gateway command menu; empty when the host no longer exposes it."""
634
+ try:
635
+ from hermes_cli.commands_platforms import telegram_menu_commands
636
+
637
+ menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
638
+ return list(menu_commands or []), int(hidden_count or 0)
639
+ except Exception as exc:
640
+ _hermes_seam_unavailable(
641
+ "hermes_cli.commands_platforms.telegram_menu_commands",
642
+ "the Inline command menu lists only Inline's own commands",
643
+ exc,
644
+ )
645
+ return [], 0
646
+
580
647
 
648
+ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[List[Dict[str, Any]], int]:
581
649
  command_specs = _inline_command_specs()
582
650
  local_commands = [
583
651
  (spec.name, spec.description)
584
652
  for spec in command_specs[:max(0, max_commands)]
585
653
  ]
586
654
  remaining = max(0, max_commands - len(local_commands))
587
- menu_commands, hidden_count = telegram_menu_commands(max_commands=remaining)
655
+ menu_commands, hidden_count = _hermes_menu_commands(remaining)
588
656
  commands: List[Dict[str, Any]] = []
589
657
  seen: set[str] = set()
590
658
  skipped = 0
@@ -610,11 +678,18 @@ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[Li
610
678
 
611
679
  def _inline_skill_catalog() -> List[Dict[str, Any]]:
612
680
  """Return installed, enabled Hermes skills in the Bot API catalog shape."""
613
- from tools.skills_tool import _find_all_skills, _sort_skills
614
-
681
+ try:
682
+ # Public since Hermes 0.22.5 (plugins/index.md § Read the skill catalog).
683
+ from tools.skills_tool import find_all_skills as find_all, sort_skills as sort
684
+ except ImportError:
685
+ # Legacy hosts (< 0.22.5) only had the private names. Drop with that host floor.
686
+ import tools.skills_tool as legacy_skills_tool
687
+
688
+ find_all = getattr(legacy_skills_tool, "_find_all_skills")
689
+ sort = getattr(legacy_skills_tool, "_sort_skills")
615
690
  published: List[Dict[str, Any]] = []
616
691
  seen: set[str] = set()
617
- for skill in _sort_skills(_find_all_skills(skip_disabled=False)):
692
+ for skill in sort(find_all(skip_disabled=False)):
618
693
  if len(published) >= 250:
619
694
  break
620
695
  key = str(skill.get("name") or "").strip()
@@ -915,6 +990,14 @@ class InlineAdapter(BasePlatformAdapter):
915
990
  else inline_env("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
916
991
  True,
917
992
  )
993
+ self._drop_pending_on_cold_boot = _truthy(
994
+ extra.get("drop_pending_on_cold_boot")
995
+ if "drop_pending_on_cold_boot" in extra
996
+ else inline_env("INLINE_DROP_PENDING_ON_COLD_BOOT"),
997
+ True,
998
+ )
999
+ self._cold_boot_dropped = 0
1000
+ self._cold_boot_drop_logged_at = 0.0
918
1001
  self._reply_thread_mode = _reply_thread_mode(
919
1002
  extra.get("reply_threads") if "reply_threads" in extra else inline_env("INLINE_REPLY_THREADS"),
920
1003
  "auto",
@@ -1015,7 +1098,7 @@ class InlineAdapter(BasePlatformAdapter):
1015
1098
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
1016
1099
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
1017
1100
  # Whether each request came from a bot, so answers to bots reply to them and reach them.
1018
- self._request_sender_is_bot: "OrderedDict[str, bool]" = OrderedDict()
1101
+ self._request_quotes_sender: "OrderedDict[str, bool]" = OrderedDict()
1019
1102
  # Commands this bot advertises in Inline's command menu, as last synced.
1020
1103
  self._advertised_commands: set[str] = set()
1021
1104
  self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
@@ -1142,12 +1225,14 @@ class InlineAdapter(BasePlatformAdapter):
1142
1225
  """Hermes' own slash-command access for this platform; None on hosts without it."""
1143
1226
  try:
1144
1227
  from gateway.slash_access import policy_from_extra
1145
- except Exception:
1146
- return None
1147
- try:
1228
+
1148
1229
  return policy_from_extra(dict(self.config.extra or {}), "dm" if chat_type == "dm" else "group")
1149
- except Exception:
1150
- logger.debug("[inline] slash access policy unavailable", exc_info=True)
1230
+ except Exception as exc:
1231
+ _hermes_seam_unavailable(
1232
+ "gateway.slash_access.policy_from_extra",
1233
+ "only a DM's owner can change reply threads; group settings are locked",
1234
+ exc,
1235
+ )
1151
1236
  return None
1152
1237
 
1153
1238
  def _may_change_reply_threads(self, actor_id: str, chat_type: str) -> bool:
@@ -1155,7 +1240,9 @@ class InlineAdapter(BasePlatformAdapter):
1155
1240
  who may run /threads. Without admin lists Hermes allows everyone, and so does this."""
1156
1241
  policy = self._slash_access_policy(chat_type)
1157
1242
  if policy is None:
1158
- return True
1243
+ # Every supported host has the policy, so its absence means the seam broke:
1244
+ # keep a DM's owner in control, but never open a group's setting to everyone.
1245
+ return chat_type == "dm"
1159
1246
  return policy.can_run(actor_id, "threads")
1160
1247
 
1161
1248
  def note_turn_thread(self, chat_id: str, message_id: str, thread_id: str) -> None:
@@ -1179,7 +1266,8 @@ class InlineAdapter(BasePlatformAdapter):
1179
1266
  self, *, chat_id: str, chat_type: str, parent_chat_id: str, actor_id: str, actor_is_bot: bool, mode: str,
1180
1267
  ) -> Dict[str, Any]:
1181
1268
  """Change reply threads because the person talking to the agent asked, with the access
1182
- /threads needs. Inside a reply thread it changes the parent chat, as /threads does."""
1269
+ /threads needs. Inside a reply thread it changes the parent chat, as /threads does,
1270
+ and chat_type is the parent's."""
1183
1271
  if actor_is_bot or not actor_id:
1184
1272
  raise PermissionError("only a person can change reply threads")
1185
1273
  target_chat_id = parent_chat_id or chat_id
@@ -1190,6 +1278,9 @@ class InlineAdapter(BasePlatformAdapter):
1190
1278
  self._set_reply_threads_for_chat(target_chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
1191
1279
  return {"chat_id": self._chat_key(target_chat_id), "mode": self._reply_thread_mode_for_chat(target_chat_id)}
1192
1280
 
1281
+ def chat_type_from_info(self, info: Dict[str, Any]) -> str:
1282
+ return self._bot_settings_chat_type(info)
1283
+
1193
1284
  @staticmethod
1194
1285
  def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
1195
1286
  peer = info.get("peer")
@@ -1202,14 +1293,9 @@ class InlineAdapter(BasePlatformAdapter):
1202
1293
  return "group"
1203
1294
 
1204
1295
  def _bot_settings_runner(self) -> Any:
1205
- runner = getattr(self, "gateway_runner", None)
1206
- if runner is not None:
1207
- return runner
1208
- try:
1209
- from gateway.run import _gateway_runner_ref
1210
- return _gateway_runner_ref()
1211
- except Exception:
1212
- return None
1296
+ # BasePlatformAdapter.gateway_runner is the runner's documented back-reference;
1297
+ # every supported host sets it before the adapter connects.
1298
+ return getattr(self, "gateway_runner", None)
1213
1299
 
1214
1300
  async def _bot_settings_context(
1215
1301
  self,
@@ -1285,10 +1371,11 @@ class InlineAdapter(BasePlatformAdapter):
1285
1371
  return context
1286
1372
 
1287
1373
  try:
1288
- from gateway.run import _load_gateway_config
1374
+ from hermes_cli.config import load_config_readonly
1289
1375
  from hermes_cli.model_switch_providers import list_picker_providers
1290
1376
 
1291
- cfg = _load_gateway_config() or {}
1377
+ # Read-only: the returned dict is Hermes' shared config cache.
1378
+ cfg = load_config_readonly() or {}
1292
1379
  model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
1293
1380
  if not isinstance(model_cfg, dict):
1294
1381
  model_cfg = {"default": str(model_cfg or "")}
@@ -1390,8 +1477,16 @@ class InlineAdapter(BasePlatformAdapter):
1390
1477
  {"value": value, "label": value.capitalize()} for value in dict.fromkeys(reasoning_values)
1391
1478
  ],
1392
1479
  })
1480
+ except asyncio.TimeoutError:
1481
+ logger.warning("[inline] agent settings model catalog timed out")
1393
1482
  except Exception as exc:
1394
- logger.debug("[inline] Hermes runtime settings unavailable: %s", exc)
1483
+ # Model and reasoning rows lean on GatewayRunner internals and the model picker
1484
+ # catalog; without them the settings sheet still offers reply-thread controls.
1485
+ _hermes_seam_unavailable(
1486
+ "GatewayRunner session model/reasoning settings",
1487
+ "Inline agent settings hide the model and reasoning rows",
1488
+ exc,
1489
+ )
1395
1490
  return context
1396
1491
 
1397
1492
  @staticmethod
@@ -1607,7 +1702,11 @@ class InlineAdapter(BasePlatformAdapter):
1607
1702
  f"/model {shlex.quote(model)} --provider {shlex.quote(provider)} "
1608
1703
  f"{'--global' if item_id == 'model-default' else '--session'}"
1609
1704
  )
1610
- result = await runner._handle_model_command(MessageEvent(
1705
+ handle_model_command = getattr(runner, "_handle_model_command", None)
1706
+ if not callable(handle_model_command):
1707
+ _hermes_seam_unavailable("GatewayRunner._handle_model_command", "Inline settings cannot switch models")
1708
+ raise ValueError("model switching unavailable")
1709
+ result = await handle_model_command(MessageEvent(
1611
1710
  text=command,
1612
1711
  message_type=MessageType.TEXT,
1613
1712
  source=source,
@@ -1620,7 +1719,13 @@ class InlineAdapter(BasePlatformAdapter):
1620
1719
  selected = str(value_body.get("stringValue") or "")
1621
1720
  if selected not in {option.get("value") for option in context.get("reasoning_options") or []}:
1622
1721
  raise ValueError("reasoning unavailable")
1623
- runner._apply_reasoning_selection(context["session_key"], "inline", selected)
1722
+ apply_reasoning_selection = getattr(runner, "_apply_reasoning_selection", None)
1723
+ if not callable(apply_reasoning_selection):
1724
+ _hermes_seam_unavailable(
1725
+ "GatewayRunner._apply_reasoning_selection", "Inline settings cannot change reasoning effort",
1726
+ )
1727
+ raise ValueError("reasoning unavailable")
1728
+ apply_reasoning_selection(context["session_key"], "inline", selected)
1624
1729
  return
1625
1730
  raise ValueError("setting unavailable")
1626
1731
 
@@ -1953,6 +2058,12 @@ class InlineAdapter(BasePlatformAdapter):
1953
2058
  self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
1954
2059
  self._mark_connected()
1955
2060
  logger.info("[inline] connected via sidecar on %s:%d", self._sidecar_bind, self._sidecar_port)
2061
+ if not is_reconnect:
2062
+ logger.info(
2063
+ "[inline] Cold boot: %s Inline updates recorded before this gateway started "
2064
+ "(drop_pending_on_cold_boot: %s)",
2065
+ "dropping" if self._drop_pending_on_cold_boot else "preserving",
2066
+ "true" if self._drop_pending_on_cold_boot else "false")
1956
2067
  self._schedule_bot_command_sync()
1957
2068
  return True
1958
2069
 
@@ -2393,6 +2504,8 @@ class InlineAdapter(BasePlatformAdapter):
2393
2504
  # owns a coherent client-state cache.
2394
2505
  self._invalidate_chat_info(event.get("chatId"))
2395
2506
  kind = event.get("kind")
2507
+ if kind in _COLD_BOOT_GATED_KINDS and self._recorded_before_cold_boot(event):
2508
+ return
2396
2509
  if kind == "bot.chatSettings.request":
2397
2510
  if event.get("_inlineDeliveryId"):
2398
2511
  await self._handle_bot_settings_request(event)
@@ -2437,6 +2550,29 @@ class InlineAdapter(BasePlatformAdapter):
2437
2550
  return
2438
2551
  await self._dispatch_message(event)
2439
2552
 
2553
+ def _recorded_before_cold_boot(self, event: Dict[str, Any]) -> bool:
2554
+ """Whether this update predates the gateway process and must not wake the agent.
2555
+
2556
+ The update date is when the server recorded the change, so a live edit or reaction
2557
+ on an old message still passes. Updates without a date pass rather than risk
2558
+ dropping live work."""
2559
+ if not self._drop_pending_on_cold_boot:
2560
+ return False
2561
+ date = _to_int(event.get("date"))
2562
+ if date is None and event.get("kind") == "message.new":
2563
+ date = _to_int((event.get("message") or {}).get("date"))
2564
+ if date is None or date <= 0 or date >= _PROCESS_STARTED_AT - _COLD_BOOT_CLOCK_SKEW_SECONDS:
2565
+ return False
2566
+ self._cold_boot_dropped += 1
2567
+ now = time.time()
2568
+ if now - self._cold_boot_drop_logged_at >= _COLD_BOOT_DROP_LOG_INTERVAL_SECONDS:
2569
+ self._cold_boot_drop_logged_at = now
2570
+ logger.info(
2571
+ "[inline] dropped %d update(s) recorded before this gateway started; latest %s in chat %s, %ds before startup",
2572
+ self._cold_boot_dropped, event.get("kind"), event.get("chatId") or "?",
2573
+ int(_PROCESS_STARTED_AT - date))
2574
+ return True
2575
+
2440
2576
  def _is_duplicate(self, key: str) -> bool:
2441
2577
  now = time.time()
2442
2578
  old = self._seen_messages.get(key)
@@ -2684,13 +2820,17 @@ class InlineAdapter(BasePlatformAdapter):
2684
2820
  if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
2685
2821
  self._remember_observed_context(chat_id, msg, text)
2686
2822
  return
2687
- self._remember_request_sender(chat_id, msg_id, sender_profile.get("bot") is True)
2823
+ sender_is_bot = sender_profile.get("bot") is True
2824
+ self._remember_request_sender(chat_id, msg_id, sender_is_bot and (explicitly_mentions_me or command_addressed_to_me))
2688
2825
  if edit:
2689
2826
  text = f"message:edited:{text}" if text else "message:edited"
2690
2827
  if (
2691
2828
  not edit
2692
2829
  and not agent_action
2693
2830
  and not thread_id
2831
+ # A bot's message is answered in place even in a chat set to On: it wakes on
2832
+ # replies in the chat it asked from, and a thread would never reach it.
2833
+ and sender_profile.get("bot") is not True
2694
2834
  and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
2695
2835
  ):
2696
2836
  created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
@@ -2770,26 +2910,6 @@ class InlineAdapter(BasePlatformAdapter):
2770
2910
  reply_context_messages=context_backfill["reply_context_messages"],
2771
2911
  recent_messages=context_backfill["recent_messages"],
2772
2912
  )
2773
- metadata = self._inline_event_metadata(
2774
- chat_id=chat_id,
2775
- msg_id=msg_id,
2776
- from_id=from_id,
2777
- thread_id=thread_id,
2778
- parent_chat_id=parent_chat_id,
2779
- parent_message_id=parent_message_id,
2780
- entity_text=entity_text,
2781
- )
2782
- if agent_action:
2783
- metadata["inline"]["action"] = {
2784
- "event_kind": "message.action.invoke",
2785
- "actor_user_id": str(agent_action.get("actorUserId") or ""),
2786
- "chat_id": str(agent_action.get("chatId") or ""),
2787
- "target_message_id": str(agent_action.get("messageId") or ""),
2788
- "interaction_id": str(agent_action.get("interactionId") or ""),
2789
- "action_id": str(agent_action.get("actionId") or ""),
2790
- "callback_data_base64": str(agent_action.get("dataBase64") or ""),
2791
- }
2792
-
2793
2913
  session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
2794
2914
  try:
2795
2915
  from . import tools as _inline_tools
@@ -2812,7 +2932,11 @@ class InlineAdapter(BasePlatformAdapter):
2812
2932
  parent_chat_id=parent_chat_id,
2813
2933
  message_id=msg_id,
2814
2934
  )
2935
+ # A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
2936
+ # posting its "needed a reply" warning. Hosts before reply_expected ignore this.
2937
+ answer_from_bot = sender_is_bot and not explicitly_mentions_me and not command_addressed_to_me
2815
2938
  await self.handle_message(MessageEvent(
2939
+ **({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
2816
2940
  text=text,
2817
2941
  message_type=message_type,
2818
2942
  source=source,
@@ -2835,7 +2959,6 @@ class InlineAdapter(BasePlatformAdapter):
2835
2959
  auto_skill=auto_skill,
2836
2960
  channel_prompt=channel_prompt,
2837
2961
  channel_context=channel_context,
2838
- metadata=metadata,
2839
2962
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2840
2963
  allow_gateway_control=not bool(agent_action),
2841
2964
  ))
@@ -3819,33 +3942,6 @@ class InlineAdapter(BasePlatformAdapter):
3819
3942
  prefix += " from an unknown sender"
3820
3943
  return f"{prefix}: {text}"
3821
3944
 
3822
- def _inline_event_metadata(
3823
- self,
3824
- *,
3825
- chat_id: str,
3826
- msg_id: str,
3827
- from_id: str,
3828
- thread_id: Optional[str],
3829
- parent_chat_id: Optional[str],
3830
- parent_message_id: Optional[str],
3831
- entity_text: Optional[str],
3832
- ) -> Dict[str, Any]:
3833
- inline: Dict[str, Any] = {
3834
- "chat_id": self._chat_key(chat_id),
3835
- "message_id": str(msg_id),
3836
- }
3837
- if from_id:
3838
- inline["sender_user_id"] = self._chat_key(from_id)
3839
- if thread_id:
3840
- inline["thread_id"] = self._chat_key(thread_id)
3841
- if parent_chat_id:
3842
- inline["parent_chat_id"] = self._chat_key(parent_chat_id)
3843
- if parent_message_id:
3844
- inline["parent_message_id"] = str(parent_message_id)
3845
- if entity_text:
3846
- inline["message_entities"] = entity_text
3847
- return {"inline": inline}
3848
-
3849
3945
  @staticmethod
3850
3946
  def _reply_thread_title(text: str) -> str:
3851
3947
  title = strip_markdown(str(text or "")).strip()
@@ -3939,8 +4035,10 @@ class InlineAdapter(BasePlatformAdapter):
3939
4035
  async def _reply_to_for_target(
3940
4036
  self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
3941
4037
  ) -> Optional[str]:
3942
- """Answers quote the message only when it came from a bot: a bot wakes on replies to its
3943
- own messages, so that is how an answer reaches it. People get plain answers."""
4038
+ """Answers quote the message only when a bot asked by addressing this one: a bot wakes on
4039
+ replies to its own messages, so that is how the answer reaches it. A bot's reply to this
4040
+ bot is that answer coming back and gets a plain response, otherwise two bots would quote
4041
+ each other forever; another round takes a new mention. People get plain answers."""
3944
4042
  if not reply_to:
3945
4043
  return None
3946
4044
  target_chat_id = target.get("chatId") or ""
@@ -3951,8 +4049,8 @@ class InlineAdapter(BasePlatformAdapter):
3951
4049
  if anchored:
3952
4050
  return str(reply_to)
3953
4051
  key = f"{self._chat_key(target_chat_id)}:{reply_to}"
3954
- sender_is_bot = self._request_sender_is_bot.get(key)
3955
- if sender_is_bot is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
4052
+ quotes_sender = self._request_quotes_sender.get(key)
4053
+ if quotes_sender is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
3956
4054
  info = await self._get_chat_info(target_chat_id)
3957
4055
  if not info:
3958
4056
  return None
@@ -3964,16 +4062,16 @@ class InlineAdapter(BasePlatformAdapter):
3964
4062
  # Message ids are per chat, so only look it up where the request was sent; an answer
3965
4063
  # moved into a thread would otherwise read the thread's message with the same id.
3966
4064
  fetched = await self._fetch_message(target_chat_id, str(reply_to))
3967
- sender_is_bot = _inline_sender_profile({}, fetched).get("bot") is True if fetched else False
3968
- self._remember_request_sender(target_chat_id, str(reply_to), sender_is_bot)
3969
- return str(reply_to) if sender_is_bot else None
4065
+ quotes_sender = bool(fetched) and _inline_sender_profile({}, fetched).get("bot") is True and self._message_entity_mentions_me(fetched)
4066
+ self._remember_request_sender(target_chat_id, str(reply_to), quotes_sender)
4067
+ return str(reply_to) if quotes_sender else None
3970
4068
 
3971
- def _remember_request_sender(self, chat_id: str, msg_id: str, sender_is_bot: bool) -> None:
4069
+ def _remember_request_sender(self, chat_id: str, msg_id: str, quotes_sender: bool) -> None:
3972
4070
  key = f"{self._chat_key(chat_id)}:{msg_id}"
3973
- self._request_sender_is_bot[key] = sender_is_bot
3974
- self._request_sender_is_bot.move_to_end(key)
3975
- while len(self._request_sender_is_bot) > _CHAT_INFO_CACHE_MAX_SIZE:
3976
- self._request_sender_is_bot.popitem(last=False)
4071
+ self._request_quotes_sender[key] = quotes_sender
4072
+ self._request_quotes_sender.move_to_end(key)
4073
+ while len(self._request_quotes_sender) > _CHAT_INFO_CACHE_MAX_SIZE:
4074
+ self._request_quotes_sender.popitem(last=False)
3977
4075
 
3978
4076
  async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3979
4077
  target = _target_from_chat_id(chat_id)
@@ -4374,6 +4472,11 @@ class InlineAdapter(BasePlatformAdapter):
4374
4472
  return True
4375
4473
  try:
4376
4474
  from tools import slash_confirm as slash_confirm_mod
4475
+ except Exception as exc:
4476
+ _hermes_seam_unavailable("tools.slash_confirm.resolve", "Inline slash-confirm buttons cannot answer", exc)
4477
+ await self._finish_action(event, "Unavailable", "This Hermes version cannot record that choice.")
4478
+ return True
4479
+ try:
4377
4480
  result = await slash_confirm_mod.resolve(session_key, confirm_id, choice)
4378
4481
  self._slash_sessions.pop(confirm_id, None)
4379
4482
  text = str(result or ("Cancelled." if choice == "cancel" else "Recorded."))
@@ -4633,7 +4736,10 @@ class InlineAdapter(BasePlatformAdapter):
4633
4736
  from hermes_cli.models_catalog_static import PROVIDER_GROUPS
4634
4737
  label, _desc, members = PROVIDER_GROUPS.get(group_id, (group_id, "", []))
4635
4738
  member_slugs = [str(member) for member in members]
4636
- except Exception:
4739
+ except Exception as exc:
4740
+ _hermes_seam_unavailable(
4741
+ "hermes_cli.models_catalog_static.PROVIDER_GROUPS", "the model picker cannot open provider groups", exc,
4742
+ )
4637
4743
  member_slugs = []
4638
4744
  by_slug = {
4639
4745
  str(provider.get("slug") or "").strip().lower(): provider
@@ -4732,8 +4838,17 @@ class InlineAdapter(BasePlatformAdapter):
4732
4838
  async def _expensive_model_warning(self, model_id: str, provider_slug: str) -> Optional[Any]:
4733
4839
  try:
4734
4840
  from hermes_cli.model_cost_guard import expensive_model_warning
4841
+ except Exception as exc:
4842
+ _hermes_seam_unavailable(
4843
+ "hermes_cli.model_cost_guard.expensive_model_warning",
4844
+ "the model picker switches without the expensive-model confirmation",
4845
+ exc,
4846
+ )
4847
+ return None
4848
+ try:
4735
4849
  return await asyncio.to_thread(expensive_model_warning, model_id, provider=provider_slug)
4736
4850
  except Exception:
4851
+ logger.warning("[inline] expensive model check failed", exc_info=True)
4737
4852
  return None
4738
4853
 
4739
4854
  async def _edit_action_message(self, event: Dict[str, Any], text: str, actions: Optional[Dict[str, Any]] = None) -> SendResult:
@@ -5378,7 +5493,8 @@ class InlineAdapter(BasePlatformAdapter):
5378
5493
  try:
5379
5494
  from hermes_cli.providers import get_label
5380
5495
  return str(get_label(slug))
5381
- except Exception:
5496
+ except Exception as exc:
5497
+ _hermes_seam_unavailable("hermes_cli.providers.get_label", "the model picker shows provider slugs", exc)
5382
5498
  return str(slug or "")
5383
5499
 
5384
5500
  def _build_provider_actions(self, picker_id: str, providers: list, *, include_back: bool = False) -> Dict[str, Any]:
@@ -5391,7 +5507,10 @@ class InlineAdapter(BasePlatformAdapter):
5391
5507
  try:
5392
5508
  from hermes_cli.models_catalog_static import group_providers
5393
5509
  grouped = group_providers(list(by_slug.keys()))
5394
- except Exception:
5510
+ except Exception as exc:
5511
+ _hermes_seam_unavailable(
5512
+ "hermes_cli.models_catalog_static.group_providers", "the model picker lists providers ungrouped", exc,
5513
+ )
5395
5514
  grouped = [{"kind": "single", "slug": slug} for slug in by_slug.keys()]
5396
5515
  for row in grouped:
5397
5516
  if row.get("kind") == "group":
@@ -5557,6 +5676,12 @@ class InlineAdapter(BasePlatformAdapter):
5557
5676
  if len(mapping) > limit:
5558
5677
  mapping.popitem(last=False)
5559
5678
 
5679
+ def _send_retry_is_final(self, result: SendResult) -> bool:
5680
+ # The request reached Inline but its result was lost. A retry or plain-text
5681
+ # fallback would post the message twice; Hermes treats timeouts the same way.
5682
+ raw = getattr(result, "raw_response", None)
5683
+ return isinstance(raw, dict) and raw.get("outcomeUnknown") is True
5684
+
5560
5685
  async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
5561
5686
  try:
5562
5687
  data = await self._sidecar_call(path, body)
@@ -5572,12 +5697,15 @@ class InlineAdapter(BasePlatformAdapter):
5572
5697
  except InlineSidecarError as exc:
5573
5698
  if exc.error_kind == "unknown":
5574
5699
  self._report_error("sidecar.send", exc)
5700
+ raw = exc.raw if isinstance(exc.raw, dict) else {}
5701
+ retry_after = _to_int(raw.get("retryAfterSeconds"))
5575
5702
  return _send_result(
5576
5703
  success=False,
5577
5704
  error=str(exc),
5578
5705
  raw_response=exc.raw,
5579
5706
  retryable=exc.retryable,
5580
5707
  error_kind=exc.error_kind,
5708
+ retry_after=float(retry_after) if retry_after and retry_after > 0 else None,
5581
5709
  )
5582
5710
  except Exception as exc:
5583
5711
  self._report_error("sidecar.send", exc)
@@ -5605,8 +5733,9 @@ class InlineAdapter(BasePlatformAdapter):
5605
5733
 
5606
5734
  def _send_result(**kwargs: Any) -> SendResult:
5607
5735
  fields = getattr(SendResult, "__dataclass_fields__", {})
5608
- if "error_kind" in kwargs and "error_kind" not in fields:
5609
- kwargs.pop("error_kind")
5736
+ for optional in ("error_kind", "retry_after"):
5737
+ if optional in kwargs and optional not in fields:
5738
+ kwargs.pop(optional)
5610
5739
  return SendResult(**kwargs)
5611
5740
 
5612
5741
 
@@ -5756,7 +5885,9 @@ def _inline_catalog_part_summary(part: Dict[str, Any]) -> str:
5756
5885
  def _inline_version_text(last_sync: Optional[Dict[str, Any]] = None) -> str:
5757
5886
  version = _installed_inline_plugin_version()
5758
5887
  try:
5759
- from hermes_cli import __version__ as hermes_version
5888
+ from importlib.metadata import version as _distribution_version
5889
+
5890
+ hermes_version = _distribution_version("hermes-agent")
5760
5891
  except Exception:
5761
5892
  hermes_version = None
5762
5893
 
@@ -5826,7 +5957,9 @@ def register(ctx) -> None:
5826
5957
  from . import cli as _cli
5827
5958
  from . import tools as _tools
5828
5959
 
5829
- _install_inline_display_defaults()
5960
+ display_fields = _display_registration_fields()
5961
+ if not display_fields:
5962
+ _install_inline_display_defaults()
5830
5963
  ctx.register_platform(
5831
5964
  name="inline",
5832
5965
  label="Inline",
@@ -5849,6 +5982,7 @@ def register(ctx) -> None:
5849
5982
  allow_update_command=True,
5850
5983
  platform_hint=_tools.INLINE_PLATFORM_GUIDANCE,
5851
5984
  **_target_registration_hooks(),
5985
+ **display_fields,
5852
5986
  )
5853
5987
  register_command = getattr(ctx, "register_command", None)
5854
5988
  if callable(register_command):