@inline-chat/hermes-agent-adapter 1.0.1 → 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 = {
@@ -843,6 +872,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
843
872
  "state_path",
844
873
  "settings_path",
845
874
  "parse_markdown",
875
+ "cron_mention_prefix",
846
876
  "require_mention",
847
877
  "strict_mention",
848
878
  "allowed_chats",
@@ -878,10 +908,41 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
878
908
  return extra
879
909
 
880
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
+
881
940
  class InlineAdapter(BasePlatformAdapter):
882
941
  MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
883
942
  supports_code_blocks = True
884
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
885
946
 
886
947
  def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
887
948
  super().__init__(config, Platform("inline"))
@@ -910,6 +971,10 @@ class InlineAdapter(BasePlatformAdapter):
910
971
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
911
972
  self._node_bin = _find_node_bin() or "node"
912
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")
913
978
  self._media_max_bytes = int(
914
979
  _normalize_positive_float(
915
980
  extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
@@ -1086,6 +1151,9 @@ class InlineAdapter(BasePlatformAdapter):
1086
1151
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
1087
1152
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
1088
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()
1089
1157
  self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
1090
1158
  self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
1091
1159
  self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
@@ -1369,6 +1437,7 @@ class InlineAdapter(BasePlatformAdapter):
1369
1437
  }
1370
1438
  if runner is None:
1371
1439
  return context
1440
+ context["context_usage"] = self._session_context_usage(runner, source)
1372
1441
 
1373
1442
  try:
1374
1443
  from hermes_cli.config import load_config_readonly
@@ -1489,6 +1558,22 @@ class InlineAdapter(BasePlatformAdapter):
1489
1558
  )
1490
1559
  return context
1491
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
+
1492
1577
  @staticmethod
1493
1578
  def _bot_settings_revision(context: Dict[str, Any]) -> str:
1494
1579
  visible = {
@@ -1550,6 +1635,21 @@ class InlineAdapter(BasePlatformAdapter):
1550
1635
  "options": [{**option, "disabled": False} for option in reasoning_options],
1551
1636
  }},
1552
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
+ })
1553
1653
  if not runtime_items:
1554
1654
  runtime_items.append({
1555
1655
  "id": "runtime-unavailable", "label": "Runtime", "disabled": False,
@@ -2170,14 +2270,34 @@ class InlineAdapter(BasePlatformAdapter):
2170
2270
  raise RuntimeError(f"Inline sidecar did not become ready within {timeout}: {last_error}")
2171
2271
 
2172
2272
  async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
2173
- 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:
2174
2279
  return
2175
- loop = asyncio.get_event_loop()
2176
- while True:
2280
+ loop = asyncio.get_running_loop()
2281
+ lines: "asyncio.Queue[bytes]" = asyncio.Queue()
2282
+
2283
+ def hand_off(raw: bytes) -> None:
2177
2284
  try:
2178
- 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)
2179
2293
  except Exception:
2180
- 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()
2181
2301
  if not line:
2182
2302
  return
2183
2303
  text = line.decode("utf-8", "replace").rstrip()
@@ -2206,9 +2326,7 @@ class InlineAdapter(BasePlatformAdapter):
2206
2326
  )
2207
2327
  except Exception:
2208
2328
  pass
2209
- try:
2210
- proc.wait(timeout=3.0)
2211
- except subprocess.TimeoutExpired:
2329
+ if not await _wait_for_exit(proc, 3.0):
2212
2330
  if sys.platform != "win32":
2213
2331
  try:
2214
2332
  os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
@@ -2216,9 +2334,7 @@ class InlineAdapter(BasePlatformAdapter):
2216
2334
  proc.terminate()
2217
2335
  else:
2218
2336
  proc.terminate()
2219
- try:
2220
- proc.wait(timeout=2.0)
2221
- except subprocess.TimeoutExpired:
2337
+ if not await _wait_for_exit(proc, 2.0):
2222
2338
  proc.kill()
2223
2339
  finally:
2224
2340
  self._sidecar_proc = None
@@ -2519,10 +2635,12 @@ class InlineAdapter(BasePlatformAdapter):
2519
2635
  self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2520
2636
  return
2521
2637
  if kind == "message.action.invoke":
2522
- 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):
2523
2641
  return
2524
2642
  ownership = resolve_inline_message_action_ownership(event.get("actionId"))
2525
- if ownership.owner == "system" and ownership.explicit:
2643
+ if not widget_call and ownership.owner == "system" and ownership.explicit:
2526
2644
  await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
2527
2645
  logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
2528
2646
  return
@@ -2757,9 +2875,7 @@ class InlineAdapter(BasePlatformAdapter):
2757
2875
  await self.handle_message(MessageEvent(
2758
2876
  text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
2759
2877
  source=source, raw_message=event,
2760
- message_id=build_inline_agent_action_turn_id(
2761
- agent_action.get("messageId"), agent_action.get("interactionId"),
2762
- ) if agent_action else msg_id,
2878
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2763
2879
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2764
2880
  allow_gateway_control=not bool(agent_action),
2765
2881
  ))
@@ -2941,14 +3057,7 @@ class InlineAdapter(BasePlatformAdapter):
2941
3057
  message_type=message_type,
2942
3058
  source=source,
2943
3059
  raw_message=event,
2944
- message_id=(
2945
- build_inline_agent_action_turn_id(
2946
- agent_action.get("messageId"),
2947
- agent_action.get("interactionId"),
2948
- )
2949
- if agent_action
2950
- else msg_id
2951
- ),
3060
+ message_id=_agent_action_turn_message_id(agent_action, msg_id),
2952
3061
  platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
2953
3062
  media_urls=media_urls,
2954
3063
  media_types=media_types,
@@ -2982,6 +3091,12 @@ class InlineAdapter(BasePlatformAdapter):
2982
3091
  if not target:
2983
3092
  await self._answer_action(interaction_id, "Action expired or no longer accessible.")
2984
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}
2985
3100
 
2986
3101
  synthetic_message = dict(target)
2987
3102
  for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
@@ -4432,32 +4547,93 @@ class InlineAdapter(BasePlatformAdapter):
4432
4547
  _, approval_id, choice = parts
4433
4548
  choice = "once" if choice == "approve" else choice
4434
4549
  session_key = self._approval_sessions.get(approval_id)
4435
- if choice not in {"once", "session", "always", "deny"}:
4550
+ if choice not in _APPROVAL_CHOICES:
4436
4551
  return False
4437
4552
  if not session_key:
4438
- 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))
4439
4555
  return True
4440
4556
  try:
4441
- from tools.approval import resolve_gateway_approval
4442
- 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)
4443
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 ""
4444
4568
  if not count:
4445
- 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))
4446
4570
  return True
4447
- labels = {
4448
- "once": ("Approved once", "Approved once."),
4449
- "session": ("Approved for session", "Approved for this session."),
4450
- "always": ("Always allowed", "Always allowed."),
4451
- "deny": ("Denied", "Denied."),
4571
+ toasts = {
4572
+ "once": "Approved once",
4573
+ "session": "Approved for session",
4574
+ "always": "Always allowed",
4575
+ "deny": "Denied",
4452
4576
  }
4453
- toast, text = labels[choice]
4454
- 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))
4455
4581
  return True
4456
4582
  except Exception as exc:
4457
4583
  self._report_error("action.approval", exc)
4458
4584
  logger.exception("[inline] approval action failed")
4459
4585
  return True
4460
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
+
4461
4637
  async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
4462
4638
  action_id = str(event.get("actionId") or "")
4463
4639
  parts = action_id.split(":", 2)
@@ -4890,8 +5066,11 @@ class InlineAdapter(BasePlatformAdapter):
4890
5066
  metadata: Optional[Dict[str, Any]] = None,
4891
5067
  actions: Optional[Dict[str, Any]] = None,
4892
5068
  ) -> SendResult:
5069
+ requested = content
5070
+ content = self._with_cron_mention(content, metadata)
4893
5071
  metadata = self._with_turn_thread(chat_id, reply_to, metadata)
4894
5072
  target = self._target_for(chat_id, metadata)
5073
+ send_options = {"sendMode": "silent"} if (metadata or {}).get("notify") is False else {}
4895
5074
  agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4896
5075
  if agent_action_target:
4897
5076
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
@@ -4916,10 +5095,13 @@ class InlineAdapter(BasePlatformAdapter):
4916
5095
  "text": chunk,
4917
5096
  "parseMarkdown": self._parse_markdown,
4918
5097
  "replyToMsgId": previous_id,
5098
+ **send_options,
4919
5099
  })
4920
5100
  raw_responses.append(continuation.raw_response)
4921
5101
  if not continuation.success:
4922
- return continuation
5102
+ return self._partial_send_failure(
5103
+ chat_id, requested, continuation, previous_id, continuation_ids, len(chunks), raw_responses,
5104
+ )
4923
5105
  if continuation.message_id:
4924
5106
  previous_id = str(continuation.message_id)
4925
5107
  continuation_ids.append(previous_id)
@@ -4945,6 +5127,7 @@ class InlineAdapter(BasePlatformAdapter):
4945
5127
  "target": target,
4946
5128
  "text": chunk,
4947
5129
  "parseMarkdown": self._parse_markdown,
5130
+ **send_options,
4948
5131
  }
4949
5132
  if reply_to and index == 0:
4950
5133
  body["replyToMsgId"] = str(reply_to)
@@ -4952,7 +5135,12 @@ class InlineAdapter(BasePlatformAdapter):
4952
5135
  body["actions"] = actions
4953
5136
  last_result = await self._send_sidecar("/send", body)
4954
5137
  if not last_result.success:
4955
- 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
+ )
4956
5144
  if not marked_visible:
4957
5145
  self._mark_reply_thread_visible(target)
4958
5146
  marked_visible = True
@@ -5014,7 +5202,13 @@ class InlineAdapter(BasePlatformAdapter):
5014
5202
  metadata: Optional[Dict[str, Any]] = None,
5015
5203
  ) -> SendResult:
5016
5204
  text = self.format_message(content)
5017
- 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:
5018
5212
  if finalize:
5019
5213
  return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
5020
5214
  text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
@@ -5298,35 +5492,31 @@ class InlineAdapter(BasePlatformAdapter):
5298
5492
  "actions": {"rows": self._action_rows(actions)},
5299
5493
  })
5300
5494
 
5301
- async def send_exec_approval(
5302
- self,
5303
- chat_id: str,
5304
- command: str,
5305
- session_key: str,
5306
- description: str = "dangerous command",
5307
- metadata: Optional[Dict[str, Any]] = None,
5308
- allow_permanent: bool = True,
5309
- allow_session: bool = True,
5310
- smart_denied: bool = False,
5311
- ) -> 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."""
5312
5502
  approval_id = secrets.token_hex(6)
5313
- text = f"Command approval required\n\n```\n{command[:2000]}\n```\n\nReason: {description}"
5314
- if smart_denied:
5315
- text += "\n\nSmart DENY: owner override applies to this one operation only."
5316
- actions = [self._action(f"appr:{approval_id}:once", "Allow Once")]
5317
- if not smart_denied and allow_session:
5318
- actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
5319
- if allow_permanent:
5320
- actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
5321
- 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
+ ]
5322
5508
  result = await self._send_sidecar("/send", {
5323
- "target": self._target_for(chat_id, metadata),
5324
- "text": text,
5509
+ "target": self._target_for(prompt.chat_id, prompt.metadata),
5510
+ "text": prompt.text,
5325
5511
  "parseMarkdown": self._parse_markdown,
5326
5512
  "actions": {"rows": self._action_rows(actions)},
5327
5513
  })
5328
5514
  if result.success:
5329
- 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))
5330
5520
  return result
5331
5521
 
5332
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:
@@ -5642,6 +5832,81 @@ class InlineAdapter(BasePlatformAdapter):
5642
5832
  out["parent_message_id"] = parent_message_id
5643
5833
  return out
5644
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
+
5645
5910
  def format_message(self, content: str) -> str:
5646
5911
  # parseMarkdown=false means literal input at every Inline API boundary.
5647
5912
  # Preserve that contract instead of silently rewriting the caller's text.
@@ -5731,6 +5996,13 @@ class InlineAdapter(BasePlatformAdapter):
5731
5996
  return _sidecar_base_url(self._sidecar_bind, self._sidecar_port)
5732
5997
 
5733
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
+
5734
6006
  def _send_result(**kwargs: Any) -> SendResult:
5735
6007
  fields = getattr(SendResult, "__dataclass_fields__", {})
5736
6008
  for optional in ("error_kind", "retry_after"):
@@ -30,7 +30,7 @@ _MACHINE_SETUP_PROTOCOL_VERSION = 1
30
30
  # Hermes' own tolerance: macOS readings of one process's start time drift about a second
31
31
  # after boot-clock adjustments, and 200 is 2 s on every platform's scale.
32
32
  _START_TIME_DRIFT_TOLERANCE = 200
33
- _PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.1"
33
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.1.0"
34
34
  _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
35
35
 
36
36