@inline-chat/hermes-agent-adapter 1.0.0 → 1.1.0

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.
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
  import asyncio
10
10
  import base64
11
11
  import hashlib
12
+ import inspect
12
13
  import json
13
14
  import logging
14
15
  import math
@@ -22,6 +23,7 @@ import signal
22
23
  import socket
23
24
  import subprocess
24
25
  import sys
26
+ import threading
25
27
  import time
26
28
  from collections import OrderedDict
27
29
  from datetime import datetime, timezone
@@ -52,11 +54,14 @@ from .message_actions import (
52
54
  build_inline_agent_action_input,
53
55
  build_inline_agent_action_turn_id,
54
56
  build_inline_system_action_id,
57
+ inline_message_widget_ids,
58
+ inline_widget_call,
55
59
  parse_inline_agent_action_reply_target,
56
60
  resolve_inline_message_action_ownership,
57
61
  )
58
62
  from .profile import bot_state_key, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
59
63
  from .telemetry import capture_plugin_error
64
+ from .widgets import has_widget_markup, scan_widgets, split_widget_aware, streaming_preview, without_widgets
60
65
 
61
66
  logger = logging.getLogger(__name__)
62
67
 
@@ -64,6 +69,7 @@ _DEFAULT_SIDECAR_PORT = 8794
64
69
  _ACTION_EDIT_RETRY_DELAY_SECONDS = 0.5
65
70
  _DEFAULT_SIDECAR_BIND = "127.0.0.1"
66
71
  _MAX_MESSAGE_LENGTH = 4000
72
+ _APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
67
73
  _MODEL_PAGE_SIZE = 8
68
74
  _CHOICE_PICKER_MAX_CHOICES = 16
69
75
  _CHOICE_PICKER_TTL_SECONDS = 2 * 60
@@ -168,6 +174,29 @@ _INLINE_ENTITY_TYPE_NAMES = {
168
174
  14: "group_mention",
169
175
  }
170
176
 
177
+ _APPROVAL_COMMAND_LIMIT = 2000
178
+ _APPROVAL_FENCE_PATTERN = re.compile(r"(`{3,})[^\n]*\n(.*?)\n\1", re.DOTALL)
179
+ # Same headings as OpenClaw's Inline, Slack and Discord approval cards.
180
+ _APPROVAL_DECISION_HEADINGS = {
181
+ "once": "✅ Allowed once",
182
+ "session": "✅ Allowed for this session",
183
+ "always": "✅ Always allowed",
184
+ "deny": "❌ Denied",
185
+ }
186
+ _APPROVAL_EXPIRED_HEADING = "⌛ Approval expired"
187
+
188
+
189
+ def _approval_command_block(command: str) -> str:
190
+ body = command if len(command) <= _APPROVAL_COMMAND_LIMIT else f"{command[:_APPROVAL_COMMAND_LIMIT]}…"
191
+ fence = "````" if "```" in body else "```"
192
+ return f"{fence}sh\n{body}\n{fence}"
193
+
194
+
195
+ def _approval_card_text(heading: str, command: str) -> str:
196
+ """An answered or expired card: the outcome above the command it was for."""
197
+ return f"{heading}\n\n{_approval_command_block(command)}" if command else heading
198
+
199
+
171
200
  _ENV_REF_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
172
201
  _SIDECAR_RETRYABLE_KINDS = {"transient"}
173
202
  _INLINE_DISPLAY_DEFAULTS = {
@@ -206,11 +235,42 @@ def _truthy(value: Any, default: bool = False) -> bool:
206
235
  return str(value).strip().lower() in {"1", "true", "yes", "on"}
207
236
 
208
237
 
238
+ _UNAVAILABLE_HERMES_SEAMS: set[str] = set()
239
+
240
+
241
+ def _hermes_seam_unavailable(seam: str, effect: str, exc: Optional[BaseException] = None) -> None:
242
+ """Warn once per process that an undocumented Hermes internal is missing or changed.
243
+
244
+ Hermes documents no public API for these seams yet; the Inline repo tracks them in
245
+ plugins/hermes-agent/docs/hermes-upstream-seams.md.
246
+ Callers degrade the one feature that needs the seam; message delivery never depends on one.
247
+ """
248
+ if seam in _UNAVAILABLE_HERMES_SEAMS:
249
+ return
250
+ _UNAVAILABLE_HERMES_SEAMS.add(seam)
251
+ reason = f"{type(exc).__name__}: {exc}" if exc is not None else "missing"
252
+ logger.warning("[inline] Hermes internal %s is unavailable (%s); %s", seam, reason, effect)
253
+
254
+
255
+ def _display_registration_fields() -> Dict[str, Any]:
256
+ """Inline's display defaults through the public PlatformEntry fields, when the host has them."""
257
+ try:
258
+ from gateway.platform_registry import PlatformEntry
259
+
260
+ fields = getattr(PlatformEntry, "__dataclass_fields__", {})
261
+ if {"display_tier", "display_overrides"}.issubset(fields):
262
+ # The overrides cover every key a tier sets, so the tier only names the closest base.
263
+ return {"display_tier": "low", "display_overrides": dict(_INLINE_DISPLAY_DEFAULTS)}
264
+ except Exception:
265
+ logger.debug("[inline] Hermes display registration fields are unavailable", exc_info=True)
266
+ return {}
267
+
268
+
209
269
  def _install_inline_display_defaults() -> None:
210
- """Register Slack-like display defaults for Inline when Hermes exposes them.
270
+ """Seed Inline's display defaults on hosts that predate PlatformEntry.display_overrides.
211
271
 
212
- Hermes core owns display resolution. External plugins do not yet have a
213
- first-class display-default hook, so we seed the same private table core
272
+ Newer hosts take the same defaults from ``ctx.register_platform`` (see
273
+ ``_display_registration_fields``); only older ones need the private table core
214
274
  platforms use. User config still wins over these defaults.
215
275
  """
216
276
  try:
@@ -218,6 +278,10 @@ def _install_inline_display_defaults() -> None:
218
278
 
219
279
  defaults = getattr(_display_config, "_PLATFORM_DEFAULTS", None)
220
280
  if not isinstance(defaults, dict):
281
+ _hermes_seam_unavailable(
282
+ "gateway.display_config._PLATFORM_DEFAULTS",
283
+ "Inline uses Hermes' generic display defaults (tool progress may be shown)",
284
+ )
221
285
  return
222
286
  inline_defaults = dict(_INLINE_DISPLAY_DEFAULTS)
223
287
  current = defaults.get("inline")
@@ -225,8 +289,12 @@ def _install_inline_display_defaults() -> None:
225
289
  defaults["inline"] = {**inline_defaults, **current}
226
290
  else:
227
291
  defaults["inline"] = inline_defaults
228
- except Exception:
229
- logger.debug("[inline] failed to install display defaults", exc_info=True)
292
+ except Exception as exc:
293
+ _hermes_seam_unavailable(
294
+ "gateway.display_config._PLATFORM_DEFAULTS",
295
+ "Inline uses Hermes' generic display defaults (tool progress may be shown)",
296
+ exc,
297
+ )
230
298
 
231
299
 
232
300
  def _reply_thread_mode(value: Any, default: str = "auto") -> str:
@@ -570,7 +638,12 @@ def _normalize_inline_plugin_command_text(text: str) -> str:
570
638
  from hermes_cli.plugins import get_plugin_commands
571
639
  if command not in (get_plugin_commands() or {}):
572
640
  return text
573
- except Exception:
641
+ except Exception as exc:
642
+ _hermes_seam_unavailable(
643
+ "hermes_cli.plugins.get_plugin_commands",
644
+ "hyphenated plugin commands must be typed with hyphens in Inline",
645
+ exc,
646
+ )
574
647
  return text
575
648
  # Inline menus require underscores, while Hermes registers plugin commands
576
649
  # with hyphens. Normalize before gateway access checks as well as dispatch.
@@ -585,16 +658,30 @@ def _normalize_inline_command_description(raw: str) -> str:
585
658
  return description
586
659
 
587
660
 
588
- def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[List[Dict[str, Any]], int]:
589
- from hermes_cli.commands_platforms import telegram_menu_commands
661
+ def _hermes_menu_commands(max_commands: int) -> tuple[list, int]:
662
+ """Hermes' gateway command menu; empty when the host no longer exposes it."""
663
+ try:
664
+ from hermes_cli.commands_platforms import telegram_menu_commands
590
665
 
666
+ menu_commands, hidden_count = telegram_menu_commands(max_commands=max_commands)
667
+ return list(menu_commands or []), int(hidden_count or 0)
668
+ except Exception as exc:
669
+ _hermes_seam_unavailable(
670
+ "hermes_cli.commands_platforms.telegram_menu_commands",
671
+ "the Inline command menu lists only Inline's own commands",
672
+ exc,
673
+ )
674
+ return [], 0
675
+
676
+
677
+ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[List[Dict[str, Any]], int]:
591
678
  command_specs = _inline_command_specs()
592
679
  local_commands = [
593
680
  (spec.name, spec.description)
594
681
  for spec in command_specs[:max(0, max_commands)]
595
682
  ]
596
683
  remaining = max(0, max_commands - len(local_commands))
597
- menu_commands, hidden_count = telegram_menu_commands(max_commands=remaining)
684
+ menu_commands, hidden_count = _hermes_menu_commands(remaining)
598
685
  commands: List[Dict[str, Any]] = []
599
686
  seen: set[str] = set()
600
687
  skipped = 0
@@ -620,11 +707,15 @@ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[Li
620
707
 
621
708
  def _inline_skill_catalog() -> List[Dict[str, Any]]:
622
709
  """Return installed, enabled Hermes skills in the Bot API catalog shape."""
623
- import tools.skills_tool as skills_tool
624
-
625
- # Hermes 0.22.5 made these public; older supported hosts keep the private names.
626
- find_all = getattr(skills_tool, "find_all_skills", None) or getattr(skills_tool, "_find_all_skills")
627
- sort = getattr(skills_tool, "sort_skills", None) or getattr(skills_tool, "_sort_skills")
710
+ try:
711
+ # Public since Hermes 0.22.5 (plugins/index.md § Read the skill catalog).
712
+ from tools.skills_tool import find_all_skills as find_all, sort_skills as sort
713
+ except ImportError:
714
+ # Legacy hosts (< 0.22.5) only had the private names. Drop with that host floor.
715
+ import tools.skills_tool as legacy_skills_tool
716
+
717
+ find_all = getattr(legacy_skills_tool, "_find_all_skills")
718
+ sort = getattr(legacy_skills_tool, "_sort_skills")
628
719
  published: List[Dict[str, Any]] = []
629
720
  seen: set[str] = set()
630
721
  for skill in sort(find_all(skip_disabled=False)):
@@ -781,6 +872,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
781
872
  "state_path",
782
873
  "settings_path",
783
874
  "parse_markdown",
875
+ "cron_mention_prefix",
784
876
  "require_mention",
785
877
  "strict_mention",
786
878
  "allowed_chats",
@@ -816,10 +908,41 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
816
908
  return extra
817
909
 
818
910
 
911
+ async def _wait_for_exit(proc: subprocess.Popen, timeout: float) -> bool:
912
+ """Wait for ``proc`` without blocking the loop or taking an executor worker; False on timeout."""
913
+ deadline = time.monotonic() + timeout
914
+ while proc.poll() is None:
915
+ if time.monotonic() >= deadline:
916
+ return False
917
+ await asyncio.sleep(0.05)
918
+ return True
919
+
920
+
921
+ def _token_count(tokens: int) -> str:
922
+ """"850", "439k", "1.2M": the bridge's wording for context sizes."""
923
+ if tokens < 1_000:
924
+ return str(tokens)
925
+ if tokens < 1_000_000:
926
+ return f"{tokens // 1_000}k"
927
+ tenths = tokens // 100_000
928
+ return f"{tenths // 10}M" if tenths % 10 == 0 else f"{tenths // 10}.{tenths % 10}M"
929
+
930
+
931
+ def _accepts_keyword(fn: Callable[..., Any], name: str) -> bool:
932
+ """Whether a host callable takes keyword ``name`` (host APIs grow optional keywords)."""
933
+ try:
934
+ params = inspect.signature(fn).parameters
935
+ except (TypeError, ValueError):
936
+ return False
937
+ return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
938
+
939
+
819
940
  class InlineAdapter(BasePlatformAdapter):
820
941
  MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
821
942
  supports_code_blocks = True
822
943
  splits_long_messages = True
944
+ # Command preview on an approval card; Hermes sends a longer command whole before the card.
945
+ _EA_CMD_BUDGET = 2000
823
946
 
824
947
  def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
825
948
  super().__init__(config, Platform("inline"))
@@ -848,6 +971,10 @@ class InlineAdapter(BasePlatformAdapter):
848
971
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
849
972
  self._node_bin = _find_node_bin() or "node"
850
973
  self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
974
+ cron_mention_prefix = extra.get("cron_mention_prefix") if "cron_mention_prefix" in extra else inline_env("INLINE_CRON_MENTION_PREFIX")
975
+ self._cron_mention_prefix = cron_mention_prefix.strip() if isinstance(cron_mention_prefix, str) else ""
976
+ if self._cron_mention_prefix and not self._parse_markdown:
977
+ logger.warning("[inline] cron_mention_prefix needs parse_markdown=true; scheduled mentions are disabled")
851
978
  self._media_max_bytes = int(
852
979
  _normalize_positive_float(
853
980
  extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
@@ -1024,6 +1151,9 @@ class InlineAdapter(BasePlatformAdapter):
1024
1151
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
1025
1152
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
1026
1153
  self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
1154
+ self._partial_sends: "OrderedDict[tuple, bool]" = OrderedDict()
1155
+ self._approval_request_ids: "OrderedDict[str, str]" = OrderedDict()
1156
+ self._approval_commands: "OrderedDict[str, str]" = OrderedDict()
1027
1157
  self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
1028
1158
  self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
1029
1159
  self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
@@ -1163,12 +1293,14 @@ class InlineAdapter(BasePlatformAdapter):
1163
1293
  """Hermes' own slash-command access for this platform; None on hosts without it."""
1164
1294
  try:
1165
1295
  from gateway.slash_access import policy_from_extra
1166
- except Exception:
1167
- return None
1168
- try:
1296
+
1169
1297
  return policy_from_extra(dict(self.config.extra or {}), "dm" if chat_type == "dm" else "group")
1170
- except Exception:
1171
- logger.debug("[inline] slash access policy unavailable", exc_info=True)
1298
+ except Exception as exc:
1299
+ _hermes_seam_unavailable(
1300
+ "gateway.slash_access.policy_from_extra",
1301
+ "only a DM's owner can change reply threads; group settings are locked",
1302
+ exc,
1303
+ )
1172
1304
  return None
1173
1305
 
1174
1306
  def _may_change_reply_threads(self, actor_id: str, chat_type: str) -> bool:
@@ -1176,7 +1308,9 @@ class InlineAdapter(BasePlatformAdapter):
1176
1308
  who may run /threads. Without admin lists Hermes allows everyone, and so does this."""
1177
1309
  policy = self._slash_access_policy(chat_type)
1178
1310
  if policy is None:
1179
- return True
1311
+ # Every supported host has the policy, so its absence means the seam broke:
1312
+ # keep a DM's owner in control, but never open a group's setting to everyone.
1313
+ return chat_type == "dm"
1180
1314
  return policy.can_run(actor_id, "threads")
1181
1315
 
1182
1316
  def note_turn_thread(self, chat_id: str, message_id: str, thread_id: str) -> None:
@@ -1227,14 +1361,9 @@ class InlineAdapter(BasePlatformAdapter):
1227
1361
  return "group"
1228
1362
 
1229
1363
  def _bot_settings_runner(self) -> Any:
1230
- runner = getattr(self, "gateway_runner", None)
1231
- if runner is not None:
1232
- return runner
1233
- try:
1234
- from gateway.run import _gateway_runner_ref
1235
- return _gateway_runner_ref()
1236
- except Exception:
1237
- return None
1364
+ # BasePlatformAdapter.gateway_runner is the runner's documented back-reference;
1365
+ # every supported host sets it before the adapter connects.
1366
+ return getattr(self, "gateway_runner", None)
1238
1367
 
1239
1368
  async def _bot_settings_context(
1240
1369
  self,
@@ -1308,12 +1437,14 @@ class InlineAdapter(BasePlatformAdapter):
1308
1437
  }
1309
1438
  if runner is None:
1310
1439
  return context
1440
+ context["context_usage"] = self._session_context_usage(runner, source)
1311
1441
 
1312
1442
  try:
1313
- from gateway.run import _load_gateway_config
1443
+ from hermes_cli.config import load_config_readonly
1314
1444
  from hermes_cli.model_switch_providers import list_picker_providers
1315
1445
 
1316
- cfg = _load_gateway_config() or {}
1446
+ # Read-only: the returned dict is Hermes' shared config cache.
1447
+ cfg = load_config_readonly() or {}
1317
1448
  model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
1318
1449
  if not isinstance(model_cfg, dict):
1319
1450
  model_cfg = {"default": str(model_cfg or "")}
@@ -1415,10 +1546,34 @@ class InlineAdapter(BasePlatformAdapter):
1415
1546
  {"value": value, "label": value.capitalize()} for value in dict.fromkeys(reasoning_values)
1416
1547
  ],
1417
1548
  })
1549
+ except asyncio.TimeoutError:
1550
+ logger.warning("[inline] agent settings model catalog timed out")
1418
1551
  except Exception as exc:
1419
- logger.debug("[inline] Hermes runtime settings unavailable: %s", exc)
1552
+ # Model and reasoning rows lean on GatewayRunner internals and the model picker
1553
+ # catalog; without them the settings sheet still offers reply-thread controls.
1554
+ _hermes_seam_unavailable(
1555
+ "GatewayRunner session model/reasoning settings",
1556
+ "Inline agent settings hide the model and reasoning rows",
1557
+ exc,
1558
+ )
1420
1559
  return context
1421
1560
 
1561
+ @staticmethod
1562
+ def _session_context_usage(runner: Any, source: Any) -> Optional[List[int]]:
1563
+ """``[used, window]`` prompt tokens of the chat's resident Hermes agent, read like Hermes'
1564
+ own ``/usage``; None before its first turn or once the agent was evicted."""
1565
+ try:
1566
+ agent = runner._resident_agent_for(runner._session_key_for_source(source))
1567
+ compressor = getattr(agent, "context_compressor", None) if agent is not None else None
1568
+ used = int(getattr(compressor, "last_prompt_tokens", 0) or 0)
1569
+ window = int(getattr(compressor, "context_length", 0) or 0)
1570
+ except Exception as exc:
1571
+ _hermes_seam_unavailable(
1572
+ "GatewayRunner._resident_agent_for", "Inline agent settings omit the context window", exc,
1573
+ )
1574
+ return None
1575
+ return [used, window] if used > 0 and window > 0 else None
1576
+
1422
1577
  @staticmethod
1423
1578
  def _bot_settings_revision(context: Dict[str, Any]) -> str:
1424
1579
  visible = {
@@ -1480,6 +1635,21 @@ class InlineAdapter(BasePlatformAdapter):
1480
1635
  "options": [{**option, "disabled": False} for option in reasoning_options],
1481
1636
  }},
1482
1637
  })
1638
+ usage = context.get("context_usage")
1639
+ if usage:
1640
+ used, window = min(usage[0], usage[1]), usage[1]
1641
+ percent = used * 100 // window
1642
+ # The id carries the numbers: Inline's composer draws its context circle from
1643
+ # ``session.context:<used>:<window>``, as it does for coding-agent bridges.
1644
+ runtime_items.append({
1645
+ "id": f"session.context:{usage[0]}:{window}", "label": "Context window",
1646
+ "description": f"{_token_count(usage[0])} of {_token_count(window)} tokens used",
1647
+ "disabled": True,
1648
+ "control": {"oneofKind": "info", "info": {
1649
+ "text": f"{percent}% full",
1650
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING if percent >= 90 else _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
1651
+ }},
1652
+ })
1483
1653
  if not runtime_items:
1484
1654
  runtime_items.append({
1485
1655
  "id": "runtime-unavailable", "label": "Runtime", "disabled": False,
@@ -1632,7 +1802,11 @@ class InlineAdapter(BasePlatformAdapter):
1632
1802
  f"/model {shlex.quote(model)} --provider {shlex.quote(provider)} "
1633
1803
  f"{'--global' if item_id == 'model-default' else '--session'}"
1634
1804
  )
1635
- result = await runner._handle_model_command(MessageEvent(
1805
+ handle_model_command = getattr(runner, "_handle_model_command", None)
1806
+ if not callable(handle_model_command):
1807
+ _hermes_seam_unavailable("GatewayRunner._handle_model_command", "Inline settings cannot switch models")
1808
+ raise ValueError("model switching unavailable")
1809
+ result = await handle_model_command(MessageEvent(
1636
1810
  text=command,
1637
1811
  message_type=MessageType.TEXT,
1638
1812
  source=source,
@@ -1645,7 +1819,13 @@ class InlineAdapter(BasePlatformAdapter):
1645
1819
  selected = str(value_body.get("stringValue") or "")
1646
1820
  if selected not in {option.get("value") for option in context.get("reasoning_options") or []}:
1647
1821
  raise ValueError("reasoning unavailable")
1648
- runner._apply_reasoning_selection(context["session_key"], "inline", selected)
1822
+ apply_reasoning_selection = getattr(runner, "_apply_reasoning_selection", None)
1823
+ if not callable(apply_reasoning_selection):
1824
+ _hermes_seam_unavailable(
1825
+ "GatewayRunner._apply_reasoning_selection", "Inline settings cannot change reasoning effort",
1826
+ )
1827
+ raise ValueError("reasoning unavailable")
1828
+ apply_reasoning_selection(context["session_key"], "inline", selected)
1649
1829
  return
1650
1830
  raise ValueError("setting unavailable")
1651
1831
 
@@ -2090,14 +2270,34 @@ class InlineAdapter(BasePlatformAdapter):
2090
2270
  raise RuntimeError(f"Inline sidecar did not become ready within {timeout}: {last_error}")
2091
2271
 
2092
2272
  async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
2093
- if proc.stdout is None:
2273
+ """Log sidecar output. A quiet sidecar can go hours without a line, so its blocking
2274
+ read runs on a thread of its own: on the loop's shared executor every sidecar would pin
2275
+ a worker, and a gateway with more Inline bots than workers stalls every other
2276
+ ``to_thread`` job (startup included)."""
2277
+ stdout = proc.stdout
2278
+ if stdout is None:
2094
2279
  return
2095
- loop = asyncio.get_event_loop()
2096
- while True:
2280
+ loop = asyncio.get_running_loop()
2281
+ lines: "asyncio.Queue[bytes]" = asyncio.Queue()
2282
+
2283
+ def hand_off(raw: bytes) -> None:
2097
2284
  try:
2098
- line = await loop.run_in_executor(None, proc.stdout.readline)
2285
+ loop.call_soon_threadsafe(lines.put_nowait, raw)
2286
+ except RuntimeError:
2287
+ pass # the loop closed first
2288
+
2289
+ def read() -> None:
2290
+ try:
2291
+ for raw in iter(stdout.readline, b""):
2292
+ hand_off(raw)
2099
2293
  except Exception:
2100
- return
2294
+ pass
2295
+ finally:
2296
+ hand_off(b"")
2297
+
2298
+ threading.Thread(target=read, name="inline-sidecar-stdout", daemon=True).start()
2299
+ while True:
2300
+ line = await lines.get()
2101
2301
  if not line:
2102
2302
  return
2103
2303
  text = line.decode("utf-8", "replace").rstrip()
@@ -2126,9 +2326,7 @@ class InlineAdapter(BasePlatformAdapter):
2126
2326
  )
2127
2327
  except Exception:
2128
2328
  pass
2129
- try:
2130
- proc.wait(timeout=3.0)
2131
- except subprocess.TimeoutExpired:
2329
+ if not await _wait_for_exit(proc, 3.0):
2132
2330
  if sys.platform != "win32":
2133
2331
  try:
2134
2332
  os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
@@ -2136,9 +2334,7 @@ class InlineAdapter(BasePlatformAdapter):
2136
2334
  proc.terminate()
2137
2335
  else:
2138
2336
  proc.terminate()
2139
- try:
2140
- proc.wait(timeout=2.0)
2141
- except subprocess.TimeoutExpired:
2337
+ if not await _wait_for_exit(proc, 2.0):
2142
2338
  proc.kill()
2143
2339
  finally:
2144
2340
  self._sidecar_proc = None
@@ -2439,10 +2635,12 @@ class InlineAdapter(BasePlatformAdapter):
2439
2635
  self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2440
2636
  return
2441
2637
  if kind == "message.action.invoke":
2442
- if await self._handle_action(event):
2638
+ # A widget call names its widget, never a native control, so it skips system handlers.
2639
+ widget_call = inline_widget_call(event) is not None
2640
+ if not widget_call and await self._handle_action(event):
2443
2641
  return
2444
2642
  ownership = resolve_inline_message_action_ownership(event.get("actionId"))
2445
- if ownership.owner == "system" and ownership.explicit:
2643
+ if not widget_call and ownership.owner == "system" and ownership.explicit:
2446
2644
  await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
2447
2645
  logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
2448
2646
  return
@@ -2677,9 +2875,7 @@ class InlineAdapter(BasePlatformAdapter):
2677
2875
  await self.handle_message(MessageEvent(
2678
2876
  text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
2679
2877
  source=source, raw_message=event,
2680
- message_id=build_inline_agent_action_turn_id(
2681
- agent_action.get("messageId"), agent_action.get("interactionId"),
2682
- ) if agent_action else msg_id,
2878
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2683
2879
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2684
2880
  allow_gateway_control=not bool(agent_action),
2685
2881
  ))
@@ -2861,14 +3057,7 @@ class InlineAdapter(BasePlatformAdapter):
2861
3057
  message_type=message_type,
2862
3058
  source=source,
2863
3059
  raw_message=event,
2864
- message_id=(
2865
- build_inline_agent_action_turn_id(
2866
- agent_action.get("messageId"),
2867
- agent_action.get("interactionId"),
2868
- )
2869
- if agent_action
2870
- else msg_id
2871
- ),
3060
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2872
3061
  platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
2873
3062
  media_urls=media_urls,
2874
3063
  media_types=media_types,
@@ -2902,6 +3091,12 @@ class InlineAdapter(BasePlatformAdapter):
2902
3091
  if not target:
2903
3092
  await self._answer_action(interaction_id, "Action expired or no longer accessible.")
2904
3093
  return
3094
+ widget_call = inline_widget_call(event)
3095
+ if widget_call is None and str(event.get("actionId") or "") in inline_message_widget_ids(target):
3096
+ # Older realtime SDKs drop the call's name and payload but keep the widget ID.
3097
+ widget_call = {}
3098
+ if widget_call is not None:
3099
+ event = {**event, "widgetCall": widget_call}
2905
3100
 
2906
3101
  synthetic_message = dict(target)
2907
3102
  for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
@@ -4352,32 +4547,93 @@ class InlineAdapter(BasePlatformAdapter):
4352
4547
  _, approval_id, choice = parts
4353
4548
  choice = "once" if choice == "approve" else choice
4354
4549
  session_key = self._approval_sessions.get(approval_id)
4355
- if choice not in {"once", "session", "always", "deny"}:
4550
+ if choice not in _APPROVAL_CHOICES:
4356
4551
  return False
4357
4552
  if not session_key:
4358
- await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
4553
+ command = self._approval_commands.pop(approval_id, None) or await self._approval_command_from_message(event)
4554
+ await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
4359
4555
  return True
4360
4556
  try:
4361
- from tools.approval import resolve_gateway_approval
4362
- count = resolve_gateway_approval(session_key, choice)
4557
+ request_id = self._approval_request_ids.get(approval_id)
4558
+ actor_id = str(event.get("actorUserId") or "").strip()
4559
+ refusal = self._approval_refusal(session_key, actor_id, request_id)
4560
+ if refusal:
4561
+ # Another member's approval: leave the card live for the requester.
4562
+ await self._answer_action(str(event.get("interactionId") or ""), refusal)
4563
+ return True
4564
+ count = self._resolve_approval(session_key, choice, request_id, actor_id)
4363
4565
  self._approval_sessions.pop(approval_id, None)
4566
+ self._approval_request_ids.pop(approval_id, None)
4567
+ command = self._approval_commands.pop(approval_id, None) or ""
4364
4568
  if not count:
4365
- await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
4569
+ await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
4366
4570
  return True
4367
- labels = {
4368
- "once": ("Approved once", "Approved once."),
4369
- "session": ("Approved for session", "Approved for this session."),
4370
- "always": ("Always allowed", "Always allowed."),
4371
- "deny": ("Denied", "Denied."),
4571
+ toasts = {
4572
+ "once": "Approved once",
4573
+ "session": "Approved for session",
4574
+ "always": "Always allowed",
4575
+ "deny": "Denied",
4372
4576
  }
4373
- toast, text = labels[choice]
4374
- await self._finish_action(event, toast, text)
4577
+ # Slack, Discord and OpenClaw's Inline card say who answered; a bare id is noise.
4578
+ answered_by, _, _ = _inline_sender_identity(_inline_sender_profile(event))
4579
+ heading = _APPROVAL_DECISION_HEADINGS[choice] + (f" by {answered_by}" if answered_by else "")
4580
+ await self._finish_action(event, toasts[choice], _approval_card_text(heading, command))
4375
4581
  return True
4376
4582
  except Exception as exc:
4377
4583
  self._report_error("action.approval", exc)
4378
4584
  logger.exception("[inline] approval action failed")
4379
4585
  return True
4380
4586
 
4587
+ async def _approval_command_from_message(self, event: Dict[str, Any]) -> str:
4588
+ """Recover the command from the card itself when this process no longer has it (e.g. after a restart)."""
4589
+ chat_id = str(event.get("chatId") or "")
4590
+ message_id = str(event.get("messageId") or "")
4591
+ msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
4592
+ if not msg:
4593
+ return ""
4594
+ text = str(msg.get("message") or "")
4595
+ units = text.encode("utf-16-le")
4596
+ for entity in self._message_entities(msg):
4597
+ if self._entity_kind(entity) != "pre":
4598
+ continue
4599
+ offset = _to_int(entity.get("offset"))
4600
+ length = _to_int(entity.get("length"))
4601
+ if offset is None or length is None or offset < 0 or length <= 0:
4602
+ continue
4603
+ return units[offset * 2 : (offset + length) * 2].decode("utf-16-le", errors="ignore").strip()
4604
+ match = _APPROVAL_FENCE_PATTERN.search(text)
4605
+ return match.group(2).strip() if match else ""
4606
+
4607
+ def _approval_refusal(self, session_key: str, actor_id: str, request_id: Optional[str]) -> Optional[str]:
4608
+ """Hermes' approver rule (``approvals.approvers``) for a tap, or None when it may answer.
4609
+ Hosts without the rule leave it to the chat access check every action already passed."""
4610
+ try:
4611
+ from gateway.approval_actor import approval_refusal
4612
+ except ImportError:
4613
+ return None
4614
+ requester = approval_refusal(session_key, actor_id, extra=self.config.extra, request_id=request_id)
4615
+ if not requester:
4616
+ return None
4617
+ try:
4618
+ from gateway.approval_actor import refusal_text
4619
+ return str(refusal_text(requester))
4620
+ except ImportError:
4621
+ return f"Only {requester} can answer this approval."
4622
+
4623
+ def _resolve_approval(self, session_key: str, choice: str, request_id: Optional[str], actor_id: str) -> int:
4624
+ """Resolve only the request the card was sent for; once it settled (timeout, /approve)
4625
+ the tap resolves nothing rather than the next one queued. A card without a request id
4626
+ (hosts before 0.22.7) keeps the oldest-pending behaviour."""
4627
+ from tools import approval as approval_mod
4628
+ resolve_card = getattr(approval_mod, "resolve_gateway_approval_card", None)
4629
+ if resolve_card is not None:
4630
+ if _accepts_keyword(resolve_card, "actor_id"):
4631
+ return resolve_card(session_key, choice, request_id, actor_id=actor_id or None, extra=self.config.extra)
4632
+ return resolve_card(session_key, choice, request_id)
4633
+ if request_id:
4634
+ return approval_mod.resolve_gateway_approval(session_key, choice, request_id=request_id)
4635
+ return approval_mod.resolve_gateway_approval(session_key, choice)
4636
+
4381
4637
  async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
4382
4638
  action_id = str(event.get("actionId") or "")
4383
4639
  parts = action_id.split(":", 2)
@@ -4392,6 +4648,11 @@ class InlineAdapter(BasePlatformAdapter):
4392
4648
  return True
4393
4649
  try:
4394
4650
  from tools import slash_confirm as slash_confirm_mod
4651
+ except Exception as exc:
4652
+ _hermes_seam_unavailable("tools.slash_confirm.resolve", "Inline slash-confirm buttons cannot answer", exc)
4653
+ await self._finish_action(event, "Unavailable", "This Hermes version cannot record that choice.")
4654
+ return True
4655
+ try:
4395
4656
  result = await slash_confirm_mod.resolve(session_key, confirm_id, choice)
4396
4657
  self._slash_sessions.pop(confirm_id, None)
4397
4658
  text = str(result or ("Cancelled." if choice == "cancel" else "Recorded."))
@@ -4651,7 +4912,10 @@ class InlineAdapter(BasePlatformAdapter):
4651
4912
  from hermes_cli.models_catalog_static import PROVIDER_GROUPS
4652
4913
  label, _desc, members = PROVIDER_GROUPS.get(group_id, (group_id, "", []))
4653
4914
  member_slugs = [str(member) for member in members]
4654
- except Exception:
4915
+ except Exception as exc:
4916
+ _hermes_seam_unavailable(
4917
+ "hermes_cli.models_catalog_static.PROVIDER_GROUPS", "the model picker cannot open provider groups", exc,
4918
+ )
4655
4919
  member_slugs = []
4656
4920
  by_slug = {
4657
4921
  str(provider.get("slug") or "").strip().lower(): provider
@@ -4750,8 +5014,17 @@ class InlineAdapter(BasePlatformAdapter):
4750
5014
  async def _expensive_model_warning(self, model_id: str, provider_slug: str) -> Optional[Any]:
4751
5015
  try:
4752
5016
  from hermes_cli.model_cost_guard import expensive_model_warning
5017
+ except Exception as exc:
5018
+ _hermes_seam_unavailable(
5019
+ "hermes_cli.model_cost_guard.expensive_model_warning",
5020
+ "the model picker switches without the expensive-model confirmation",
5021
+ exc,
5022
+ )
5023
+ return None
5024
+ try:
4753
5025
  return await asyncio.to_thread(expensive_model_warning, model_id, provider=provider_slug)
4754
5026
  except Exception:
5027
+ logger.warning("[inline] expensive model check failed", exc_info=True)
4755
5028
  return None
4756
5029
 
4757
5030
  async def _edit_action_message(self, event: Dict[str, Any], text: str, actions: Optional[Dict[str, Any]] = None) -> SendResult:
@@ -4793,8 +5066,11 @@ class InlineAdapter(BasePlatformAdapter):
4793
5066
  metadata: Optional[Dict[str, Any]] = None,
4794
5067
  actions: Optional[Dict[str, Any]] = None,
4795
5068
  ) -> SendResult:
5069
+ requested = content
5070
+ content = self._with_cron_mention(content, metadata)
4796
5071
  metadata = self._with_turn_thread(chat_id, reply_to, metadata)
4797
5072
  target = self._target_for(chat_id, metadata)
5073
+ send_options = {"sendMode": "silent"} if (metadata or {}).get("notify") is False else {}
4798
5074
  agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4799
5075
  if agent_action_target:
4800
5076
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
@@ -4819,10 +5095,13 @@ class InlineAdapter(BasePlatformAdapter):
4819
5095
  "text": chunk,
4820
5096
  "parseMarkdown": self._parse_markdown,
4821
5097
  "replyToMsgId": previous_id,
5098
+ **send_options,
4822
5099
  })
4823
5100
  raw_responses.append(continuation.raw_response)
4824
5101
  if not continuation.success:
4825
- return continuation
5102
+ return self._partial_send_failure(
5103
+ chat_id, requested, continuation, previous_id, continuation_ids, len(chunks), raw_responses,
5104
+ )
4826
5105
  if continuation.message_id:
4827
5106
  previous_id = str(continuation.message_id)
4828
5107
  continuation_ids.append(previous_id)
@@ -4848,6 +5127,7 @@ class InlineAdapter(BasePlatformAdapter):
4848
5127
  "target": target,
4849
5128
  "text": chunk,
4850
5129
  "parseMarkdown": self._parse_markdown,
5130
+ **send_options,
4851
5131
  }
4852
5132
  if reply_to and index == 0:
4853
5133
  body["replyToMsgId"] = str(reply_to)
@@ -4855,7 +5135,12 @@ class InlineAdapter(BasePlatformAdapter):
4855
5135
  body["actions"] = actions
4856
5136
  last_result = await self._send_sidecar("/send", body)
4857
5137
  if not last_result.success:
4858
- return last_result
5138
+ if not message_ids:
5139
+ return last_result
5140
+ return self._partial_send_failure(
5141
+ chat_id, requested, last_result, message_ids[-1], message_ids[:-1], len(chunks),
5142
+ [*raw_responses, getattr(last_result, "raw_response", None)],
5143
+ )
4859
5144
  if not marked_visible:
4860
5145
  self._mark_reply_thread_visible(target)
4861
5146
  marked_visible = True
@@ -4917,7 +5202,13 @@ class InlineAdapter(BasePlatformAdapter):
4917
5202
  metadata: Optional[Dict[str, Any]] = None,
4918
5203
  ) -> SendResult:
4919
5204
  text = self.format_message(content)
4920
- if len(text) > self.MAX_MESSAGE_LENGTH:
5205
+ several_widgets = False
5206
+ if self._parse_markdown and has_widget_markup(text):
5207
+ if not finalize:
5208
+ # A streaming preview must not send a widget whose closing tag hasn't arrived.
5209
+ text = streaming_preview(text)
5210
+ several_widgets = len(scan_widgets(text).widgets) > 1
5211
+ if len(text) > self.MAX_MESSAGE_LENGTH or several_widgets:
4921
5212
  if finalize:
4922
5213
  return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
4923
5214
  text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
@@ -5201,35 +5492,31 @@ class InlineAdapter(BasePlatformAdapter):
5201
5492
  "actions": {"rows": self._action_rows(actions)},
5202
5493
  })
5203
5494
 
5204
- async def send_exec_approval(
5205
- self,
5206
- chat_id: str,
5207
- command: str,
5208
- session_key: str,
5209
- description: str = "dangerous command",
5210
- metadata: Optional[Dict[str, Any]] = None,
5211
- allow_permanent: bool = True,
5212
- allow_session: bool = True,
5213
- smart_denied: bool = False,
5214
- ) -> SendResult:
5495
+ async def _send_exec_approval_prompt(self, prompt: Any) -> SendResult:
5496
+ """Draw Hermes' exec-approval prompt as an Inline button card.
5497
+
5498
+ Hermes' ``send_exec_approval`` owns the wording, a truncated command's full send and
5499
+ the Deny-only gate of newer hosts; overriding this hook is also how Hermes knows the
5500
+ adapter has native buttons. Inline only draws the offered choices and remembers which
5501
+ request the card answers, so a tap never resolves a different one."""
5215
5502
  approval_id = secrets.token_hex(6)
5216
- text = f"Command approval required\n\n```\n{command[:2000]}\n```\n\nReason: {description}"
5217
- if smart_denied:
5218
- text += "\n\nSmart DENY: owner override applies to this one operation only."
5219
- actions = [self._action(f"appr:{approval_id}:once", "Allow Once")]
5220
- if not smart_denied and allow_session:
5221
- actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
5222
- if allow_permanent:
5223
- actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
5224
- actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
5503
+ actions = [
5504
+ self._action(f"appr:{approval_id}:{choice}", str(label))
5505
+ for label, choice, _style in prompt.actions
5506
+ if choice in _APPROVAL_CHOICES
5507
+ ]
5225
5508
  result = await self._send_sidecar("/send", {
5226
- "target": self._target_for(chat_id, metadata),
5227
- "text": text,
5509
+ "target": self._target_for(prompt.chat_id, prompt.metadata),
5510
+ "text": prompt.text,
5228
5511
  "parseMarkdown": self._parse_markdown,
5229
5512
  "actions": {"rows": self._action_rows(actions)},
5230
5513
  })
5231
5514
  if result.success:
5232
- self._remember(self._approval_sessions, approval_id, session_key)
5515
+ self._remember(self._approval_sessions, approval_id, prompt.session_key)
5516
+ self._remember(self._approval_commands, approval_id, str(getattr(prompt, "command", "") or ""))
5517
+ request_id = getattr(prompt, "request_id", None)
5518
+ if request_id:
5519
+ self._remember(self._approval_request_ids, approval_id, str(request_id))
5233
5520
  return result
5234
5521
 
5235
5522
  async def send_slash_confirm(self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
@@ -5396,7 +5683,8 @@ class InlineAdapter(BasePlatformAdapter):
5396
5683
  try:
5397
5684
  from hermes_cli.providers import get_label
5398
5685
  return str(get_label(slug))
5399
- except Exception:
5686
+ except Exception as exc:
5687
+ _hermes_seam_unavailable("hermes_cli.providers.get_label", "the model picker shows provider slugs", exc)
5400
5688
  return str(slug or "")
5401
5689
 
5402
5690
  def _build_provider_actions(self, picker_id: str, providers: list, *, include_back: bool = False) -> Dict[str, Any]:
@@ -5409,7 +5697,10 @@ class InlineAdapter(BasePlatformAdapter):
5409
5697
  try:
5410
5698
  from hermes_cli.models_catalog_static import group_providers
5411
5699
  grouped = group_providers(list(by_slug.keys()))
5412
- except Exception:
5700
+ except Exception as exc:
5701
+ _hermes_seam_unavailable(
5702
+ "hermes_cli.models_catalog_static.group_providers", "the model picker lists providers ungrouped", exc,
5703
+ )
5413
5704
  grouped = [{"kind": "single", "slug": slug} for slug in by_slug.keys()]
5414
5705
  for row in grouped:
5415
5706
  if row.get("kind") == "group":
@@ -5541,6 +5832,81 @@ class InlineAdapter(BasePlatformAdapter):
5541
5832
  out["parent_message_id"] = parent_message_id
5542
5833
  return out
5543
5834
 
5835
+ def _with_cron_mention(self, content: str, metadata: Optional[Dict[str, Any]]) -> str:
5836
+ # Hermes identifies scheduled text sends with job_id. notify also appears
5837
+ # on ordinary replies, so it cannot identify a cron on its own. Decorate
5838
+ # before chunking to mention once, while preserving the supplied body.
5839
+ meta = metadata or {}
5840
+ prefix = self._cron_mention_prefix
5841
+ if not prefix or not self._parse_markdown or not meta.get("job_id") or meta.get("notify") is False:
5842
+ return content
5843
+ if not content or not content.strip() or content.startswith(prefix):
5844
+ return content
5845
+ return f"{prefix}\n\n{content}"
5846
+
5847
+ def truncate_message(
5848
+ self,
5849
+ content: str,
5850
+ max_length: int = _MAX_MESSAGE_LENGTH,
5851
+ len_fn: Optional[Callable[[str], int]] = None,
5852
+ ) -> List[str]:
5853
+ """Hermes splitting, except a widget block is never cut or shares a message with another."""
5854
+ if getattr(self, "_parse_markdown", True):
5855
+ def split_plain(text: str, limit: int) -> List[str]:
5856
+ pieces = self._base_truncate(text, limit, len_fn)
5857
+ # Upstream numbers multi-part output; the parts here are pieces of one reply.
5858
+ return [piece.removesuffix(f" ({i + 1}/{len(pieces)})") for i, piece in enumerate(pieces)]
5859
+
5860
+ chunks = split_widget_aware(content, max_length, split_plain, len_fn or len)
5861
+ if chunks is not None:
5862
+ return chunks
5863
+ return self._base_truncate(content, max_length, len_fn)
5864
+
5865
+ def _base_truncate(self, content: str, max_length: int, len_fn: Optional[Callable[[str], int]]) -> List[str]:
5866
+ base = super().truncate_message
5867
+ if len_fn is not None and _accepts_keyword(base, "len_fn"):
5868
+ return list(base(content, max_length, len_fn=len_fn))
5869
+ return list(base(content, max_length))
5870
+
5871
+ def _partial_send_failure(
5872
+ self, chat_id: str, requested: str, failed: SendResult, last_delivered_id: str,
5873
+ delivered_continuations: List[str], total_chunks: int, raw_responses: List[Any],
5874
+ ) -> SendResult:
5875
+ """A later chunk failed after earlier ones were delivered. ``partial_overflow`` tells Hermes
5876
+ (0.22.7+) not to resend the visible head as plain text; older hosts are stopped in
5877
+ ``_send_plain_fallback``. Either way the reply is not posted twice."""
5878
+ if getattr(BasePlatformAdapter, "_is_partial_delivery", None) is None:
5879
+ self._remember(self._partial_sends, (self._chat_key(chat_id), requested), True, limit=64)
5880
+ return _send_result(
5881
+ success=False,
5882
+ message_id=last_delivered_id,
5883
+ error=failed.error or "continuation failed",
5884
+ error_kind=getattr(failed, "error_kind", None),
5885
+ retryable=bool(getattr(failed, "retryable", False)),
5886
+ raw_response={
5887
+ "partial_overflow": True,
5888
+ "delivered_chunks": 1 + len(delivered_continuations),
5889
+ "total_chunks": total_chunks,
5890
+ "last_message_id": last_delivered_id,
5891
+ "continuation_message_ids": tuple(delivered_continuations),
5892
+ "responses": raw_responses,
5893
+ },
5894
+ continuation_message_ids=tuple(delivered_continuations),
5895
+ )
5896
+
5897
+ async def _send_plain_fallback(self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> SendResult:
5898
+ if self._partial_sends.pop((self._chat_key(chat_id), content), None):
5899
+ # Part of this reply is already in the chat: resending it would post that part twice.
5900
+ logger.warning("[inline] send failed after partial delivery; not resending it as plain text")
5901
+ return _send_result(success=False, error="send failed after partial delivery", error_kind="bad_format")
5902
+ # A widget the server rejected would fail the fallback too; keep the prose and say so.
5903
+ if getattr(self, "_parse_markdown", True) and has_widget_markup(content):
5904
+ content = without_widgets(content)
5905
+ base = getattr(super(), "_send_plain_fallback", None)
5906
+ if base is None:
5907
+ return await self.send(chat_id, content, reply_to=reply_to, metadata=metadata)
5908
+ return await base(chat_id, content, reply_to=reply_to, metadata=metadata)
5909
+
5544
5910
  def format_message(self, content: str) -> str:
5545
5911
  # parseMarkdown=false means literal input at every Inline API boundary.
5546
5912
  # Preserve that contract instead of silently rewriting the caller's text.
@@ -5630,6 +5996,13 @@ class InlineAdapter(BasePlatformAdapter):
5630
5996
  return _sidecar_base_url(self._sidecar_bind, self._sidecar_port)
5631
5997
 
5632
5998
 
5999
+ def _agent_action_turn_message_id(agent_action: Optional[Dict[str, Any]], msg_id: str) -> str:
6000
+ """A button turn's answer replaces its card; a widget call's answer replies below the widget."""
6001
+ if not agent_action or inline_widget_call(agent_action) is not None:
6002
+ return msg_id
6003
+ return build_inline_agent_action_turn_id(agent_action.get("messageId"), agent_action.get("interactionId"))
6004
+
6005
+
5633
6006
  def _send_result(**kwargs: Any) -> SendResult:
5634
6007
  fields = getattr(SendResult, "__dataclass_fields__", {})
5635
6008
  for optional in ("error_kind", "retry_after"):
@@ -5784,7 +6157,9 @@ def _inline_catalog_part_summary(part: Dict[str, Any]) -> str:
5784
6157
  def _inline_version_text(last_sync: Optional[Dict[str, Any]] = None) -> str:
5785
6158
  version = _installed_inline_plugin_version()
5786
6159
  try:
5787
- from hermes_cli import __version__ as hermes_version
6160
+ from importlib.metadata import version as _distribution_version
6161
+
6162
+ hermes_version = _distribution_version("hermes-agent")
5788
6163
  except Exception:
5789
6164
  hermes_version = None
5790
6165
 
@@ -5854,7 +6229,9 @@ def register(ctx) -> None:
5854
6229
  from . import cli as _cli
5855
6230
  from . import tools as _tools
5856
6231
 
5857
- _install_inline_display_defaults()
6232
+ display_fields = _display_registration_fields()
6233
+ if not display_fields:
6234
+ _install_inline_display_defaults()
5858
6235
  ctx.register_platform(
5859
6236
  name="inline",
5860
6237
  label="Inline",
@@ -5877,6 +6254,7 @@ def register(ctx) -> None:
5877
6254
  allow_update_command=True,
5878
6255
  platform_hint=_tools.INLINE_PLATFORM_GUIDANCE,
5879
6256
  **_target_registration_hooks(),
6257
+ **display_fields,
5880
6258
  )
5881
6259
  register_command = getattr(ctx, "register_command", None)
5882
6260
  if callable(register_command):