@inline-chat/hermes-agent-adapter 1.0.0-alpha.3 → 1.0.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.
@@ -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.
@@ -610,11 +620,14 @@ def _inline_menu_commands(max_commands: int = _INLINE_COMMAND_LIMIT) -> tuple[Li
610
620
 
611
621
  def _inline_skill_catalog() -> List[Dict[str, Any]]:
612
622
  """Return installed, enabled Hermes skills in the Bot API catalog shape."""
613
- from tools.skills_tool import _find_all_skills, _sort_skills
623
+ import tools.skills_tool as skills_tool
614
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")
615
628
  published: List[Dict[str, Any]] = []
616
629
  seen: set[str] = set()
617
- for skill in _sort_skills(_find_all_skills(skip_disabled=False)):
630
+ for skill in sort(find_all(skip_disabled=False)):
618
631
  if len(published) >= 250:
619
632
  break
620
633
  key = str(skill.get("name") or "").strip()
@@ -915,6 +928,14 @@ class InlineAdapter(BasePlatformAdapter):
915
928
  else inline_env("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
916
929
  True,
917
930
  )
931
+ self._drop_pending_on_cold_boot = _truthy(
932
+ extra.get("drop_pending_on_cold_boot")
933
+ if "drop_pending_on_cold_boot" in extra
934
+ else inline_env("INLINE_DROP_PENDING_ON_COLD_BOOT"),
935
+ True,
936
+ )
937
+ self._cold_boot_dropped = 0
938
+ self._cold_boot_drop_logged_at = 0.0
918
939
  self._reply_thread_mode = _reply_thread_mode(
919
940
  extra.get("reply_threads") if "reply_threads" in extra else inline_env("INLINE_REPLY_THREADS"),
920
941
  "auto",
@@ -1015,7 +1036,7 @@ class InlineAdapter(BasePlatformAdapter):
1015
1036
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
1016
1037
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
1017
1038
  # 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()
1039
+ self._request_quotes_sender: "OrderedDict[str, bool]" = OrderedDict()
1019
1040
  # Commands this bot advertises in Inline's command menu, as last synced.
1020
1041
  self._advertised_commands: set[str] = set()
1021
1042
  self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
@@ -1179,7 +1200,8 @@ class InlineAdapter(BasePlatformAdapter):
1179
1200
  self, *, chat_id: str, chat_type: str, parent_chat_id: str, actor_id: str, actor_is_bot: bool, mode: str,
1180
1201
  ) -> Dict[str, Any]:
1181
1202
  """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."""
1203
+ /threads needs. Inside a reply thread it changes the parent chat, as /threads does,
1204
+ and chat_type is the parent's."""
1183
1205
  if actor_is_bot or not actor_id:
1184
1206
  raise PermissionError("only a person can change reply threads")
1185
1207
  target_chat_id = parent_chat_id or chat_id
@@ -1190,6 +1212,9 @@ class InlineAdapter(BasePlatformAdapter):
1190
1212
  self._set_reply_threads_for_chat(target_chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
1191
1213
  return {"chat_id": self._chat_key(target_chat_id), "mode": self._reply_thread_mode_for_chat(target_chat_id)}
1192
1214
 
1215
+ def chat_type_from_info(self, info: Dict[str, Any]) -> str:
1216
+ return self._bot_settings_chat_type(info)
1217
+
1193
1218
  @staticmethod
1194
1219
  def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
1195
1220
  peer = info.get("peer")
@@ -1953,6 +1978,12 @@ class InlineAdapter(BasePlatformAdapter):
1953
1978
  self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
1954
1979
  self._mark_connected()
1955
1980
  logger.info("[inline] connected via sidecar on %s:%d", self._sidecar_bind, self._sidecar_port)
1981
+ if not is_reconnect:
1982
+ logger.info(
1983
+ "[inline] Cold boot: %s Inline updates recorded before this gateway started "
1984
+ "(drop_pending_on_cold_boot: %s)",
1985
+ "dropping" if self._drop_pending_on_cold_boot else "preserving",
1986
+ "true" if self._drop_pending_on_cold_boot else "false")
1956
1987
  self._schedule_bot_command_sync()
1957
1988
  return True
1958
1989
 
@@ -2393,6 +2424,8 @@ class InlineAdapter(BasePlatformAdapter):
2393
2424
  # owns a coherent client-state cache.
2394
2425
  self._invalidate_chat_info(event.get("chatId"))
2395
2426
  kind = event.get("kind")
2427
+ if kind in _COLD_BOOT_GATED_KINDS and self._recorded_before_cold_boot(event):
2428
+ return
2396
2429
  if kind == "bot.chatSettings.request":
2397
2430
  if event.get("_inlineDeliveryId"):
2398
2431
  await self._handle_bot_settings_request(event)
@@ -2437,6 +2470,29 @@ class InlineAdapter(BasePlatformAdapter):
2437
2470
  return
2438
2471
  await self._dispatch_message(event)
2439
2472
 
2473
+ def _recorded_before_cold_boot(self, event: Dict[str, Any]) -> bool:
2474
+ """Whether this update predates the gateway process and must not wake the agent.
2475
+
2476
+ The update date is when the server recorded the change, so a live edit or reaction
2477
+ on an old message still passes. Updates without a date pass rather than risk
2478
+ dropping live work."""
2479
+ if not self._drop_pending_on_cold_boot:
2480
+ return False
2481
+ date = _to_int(event.get("date"))
2482
+ if date is None and event.get("kind") == "message.new":
2483
+ date = _to_int((event.get("message") or {}).get("date"))
2484
+ if date is None or date <= 0 or date >= _PROCESS_STARTED_AT - _COLD_BOOT_CLOCK_SKEW_SECONDS:
2485
+ return False
2486
+ self._cold_boot_dropped += 1
2487
+ now = time.time()
2488
+ if now - self._cold_boot_drop_logged_at >= _COLD_BOOT_DROP_LOG_INTERVAL_SECONDS:
2489
+ self._cold_boot_drop_logged_at = now
2490
+ logger.info(
2491
+ "[inline] dropped %d update(s) recorded before this gateway started; latest %s in chat %s, %ds before startup",
2492
+ self._cold_boot_dropped, event.get("kind"), event.get("chatId") or "?",
2493
+ int(_PROCESS_STARTED_AT - date))
2494
+ return True
2495
+
2440
2496
  def _is_duplicate(self, key: str) -> bool:
2441
2497
  now = time.time()
2442
2498
  old = self._seen_messages.get(key)
@@ -2684,13 +2740,17 @@ class InlineAdapter(BasePlatformAdapter):
2684
2740
  if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
2685
2741
  self._remember_observed_context(chat_id, msg, text)
2686
2742
  return
2687
- self._remember_request_sender(chat_id, msg_id, sender_profile.get("bot") is True)
2743
+ sender_is_bot = sender_profile.get("bot") is True
2744
+ self._remember_request_sender(chat_id, msg_id, sender_is_bot and (explicitly_mentions_me or command_addressed_to_me))
2688
2745
  if edit:
2689
2746
  text = f"message:edited:{text}" if text else "message:edited"
2690
2747
  if (
2691
2748
  not edit
2692
2749
  and not agent_action
2693
2750
  and not thread_id
2751
+ # A bot's message is answered in place even in a chat set to On: it wakes on
2752
+ # replies in the chat it asked from, and a thread would never reach it.
2753
+ and sender_profile.get("bot") is not True
2694
2754
  and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
2695
2755
  ):
2696
2756
  created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
@@ -2770,26 +2830,6 @@ class InlineAdapter(BasePlatformAdapter):
2770
2830
  reply_context_messages=context_backfill["reply_context_messages"],
2771
2831
  recent_messages=context_backfill["recent_messages"],
2772
2832
  )
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
2833
  session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
2794
2834
  try:
2795
2835
  from . import tools as _inline_tools
@@ -2812,7 +2852,11 @@ class InlineAdapter(BasePlatformAdapter):
2812
2852
  parent_chat_id=parent_chat_id,
2813
2853
  message_id=msg_id,
2814
2854
  )
2855
+ # A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
2856
+ # posting its "needed a reply" warning. Hosts before reply_expected ignore this.
2857
+ answer_from_bot = sender_is_bot and not explicitly_mentions_me and not command_addressed_to_me
2815
2858
  await self.handle_message(MessageEvent(
2859
+ **({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
2816
2860
  text=text,
2817
2861
  message_type=message_type,
2818
2862
  source=source,
@@ -2835,7 +2879,6 @@ class InlineAdapter(BasePlatformAdapter):
2835
2879
  auto_skill=auto_skill,
2836
2880
  channel_prompt=channel_prompt,
2837
2881
  channel_context=channel_context,
2838
- metadata=metadata,
2839
2882
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2840
2883
  allow_gateway_control=not bool(agent_action),
2841
2884
  ))
@@ -3819,33 +3862,6 @@ class InlineAdapter(BasePlatformAdapter):
3819
3862
  prefix += " from an unknown sender"
3820
3863
  return f"{prefix}: {text}"
3821
3864
 
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
3865
  @staticmethod
3850
3866
  def _reply_thread_title(text: str) -> str:
3851
3867
  title = strip_markdown(str(text or "")).strip()
@@ -3939,8 +3955,10 @@ class InlineAdapter(BasePlatformAdapter):
3939
3955
  async def _reply_to_for_target(
3940
3956
  self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
3941
3957
  ) -> 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."""
3958
+ """Answers quote the message only when a bot asked by addressing this one: a bot wakes on
3959
+ replies to its own messages, so that is how the answer reaches it. A bot's reply to this
3960
+ bot is that answer coming back and gets a plain response, otherwise two bots would quote
3961
+ each other forever; another round takes a new mention. People get plain answers."""
3944
3962
  if not reply_to:
3945
3963
  return None
3946
3964
  target_chat_id = target.get("chatId") or ""
@@ -3951,8 +3969,8 @@ class InlineAdapter(BasePlatformAdapter):
3951
3969
  if anchored:
3952
3970
  return str(reply_to)
3953
3971
  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):
3972
+ quotes_sender = self._request_quotes_sender.get(key)
3973
+ if quotes_sender is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
3956
3974
  info = await self._get_chat_info(target_chat_id)
3957
3975
  if not info:
3958
3976
  return None
@@ -3964,16 +3982,16 @@ class InlineAdapter(BasePlatformAdapter):
3964
3982
  # Message ids are per chat, so only look it up where the request was sent; an answer
3965
3983
  # moved into a thread would otherwise read the thread's message with the same id.
3966
3984
  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
3985
+ quotes_sender = bool(fetched) and _inline_sender_profile({}, fetched).get("bot") is True and self._message_entity_mentions_me(fetched)
3986
+ self._remember_request_sender(target_chat_id, str(reply_to), quotes_sender)
3987
+ return str(reply_to) if quotes_sender else None
3970
3988
 
3971
- def _remember_request_sender(self, chat_id: str, msg_id: str, sender_is_bot: bool) -> None:
3989
+ def _remember_request_sender(self, chat_id: str, msg_id: str, quotes_sender: bool) -> None:
3972
3990
  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)
3991
+ self._request_quotes_sender[key] = quotes_sender
3992
+ self._request_quotes_sender.move_to_end(key)
3993
+ while len(self._request_quotes_sender) > _CHAT_INFO_CACHE_MAX_SIZE:
3994
+ self._request_quotes_sender.popitem(last=False)
3977
3995
 
3978
3996
  async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3979
3997
  target = _target_from_chat_id(chat_id)
@@ -5557,6 +5575,12 @@ class InlineAdapter(BasePlatformAdapter):
5557
5575
  if len(mapping) > limit:
5558
5576
  mapping.popitem(last=False)
5559
5577
 
5578
+ def _send_retry_is_final(self, result: SendResult) -> bool:
5579
+ # The request reached Inline but its result was lost. A retry or plain-text
5580
+ # fallback would post the message twice; Hermes treats timeouts the same way.
5581
+ raw = getattr(result, "raw_response", None)
5582
+ return isinstance(raw, dict) and raw.get("outcomeUnknown") is True
5583
+
5560
5584
  async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
5561
5585
  try:
5562
5586
  data = await self._sidecar_call(path, body)
@@ -5572,12 +5596,15 @@ class InlineAdapter(BasePlatformAdapter):
5572
5596
  except InlineSidecarError as exc:
5573
5597
  if exc.error_kind == "unknown":
5574
5598
  self._report_error("sidecar.send", exc)
5599
+ raw = exc.raw if isinstance(exc.raw, dict) else {}
5600
+ retry_after = _to_int(raw.get("retryAfterSeconds"))
5575
5601
  return _send_result(
5576
5602
  success=False,
5577
5603
  error=str(exc),
5578
5604
  raw_response=exc.raw,
5579
5605
  retryable=exc.retryable,
5580
5606
  error_kind=exc.error_kind,
5607
+ retry_after=float(retry_after) if retry_after and retry_after > 0 else None,
5581
5608
  )
5582
5609
  except Exception as exc:
5583
5610
  self._report_error("sidecar.send", exc)
@@ -5605,8 +5632,9 @@ class InlineAdapter(BasePlatformAdapter):
5605
5632
 
5606
5633
  def _send_result(**kwargs: Any) -> SendResult:
5607
5634
  fields = getattr(SendResult, "__dataclass_fields__", {})
5608
- if "error_kind" in kwargs and "error_kind" not in fields:
5609
- kwargs.pop("error_kind")
5635
+ for optional in ("error_kind", "retry_after"):
5636
+ if optional in kwargs and optional not in fields:
5637
+ kwargs.pop(optional)
5610
5638
  return SendResult(**kwargs)
5611
5639
 
5612
5640
 
@@ -27,7 +27,10 @@ _CLI_INSTALL_URL = "https://inline.chat/cli/install.sh"
27
27
  _MAX_TOKEN_BYTES = 16 * 1024
28
28
  _MAX_PROBE_RESPONSE_BYTES = 64 * 1024
29
29
  _MACHINE_SETUP_PROTOCOL_VERSION = 1
30
- _PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.0-alpha.3"
30
+ # Hermes' own tolerance: macOS readings of one process's start time drift about a second
31
+ # after boot-clock adjustments, and 200 is 2 s on every platform's scale.
32
+ _START_TIME_DRIFT_TOLERANCE = 200
33
+ _PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0.0"
31
34
  _ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
32
35
 
33
36
 
@@ -581,7 +584,11 @@ def _gateway_status() -> dict:
581
584
  return {"supported": False, "ready": False, "reason": "runtime_writer_identity_unavailable"}
582
585
  # Runtime status can retain platform entries from the prior process.
583
586
  # Only the current process's own connected projection is readiness proof.
584
- writer_matches = isinstance(inline, dict) and inline.get("writer_pid") == pid and inline.get("writer_start_time") == start_time
587
+ writer_start_time = inline.get("writer_start_time") if isinstance(inline, dict) else None
588
+ writer_matches = (
589
+ isinstance(inline, dict) and inline.get("writer_pid") == pid and type(writer_start_time) is int
590
+ and abs(writer_start_time - start_time) <= _START_TIME_DRIFT_TOLERANCE
591
+ )
585
592
  ready = generation is not None and writer_matches and state == "running" and platform_state == "connected"
586
593
  return {
587
594
  "supported": True,
@@ -3,7 +3,7 @@ label: Inline
3
3
  kind: platform
4
4
  provides_tools:
5
5
  - inline
6
- version: 1.0.0-alpha.3
6
+ version: 1.0.0
7
7
  requires_hermes: ">=0.21.3"
8
8
  description: >
9
9
  Inline platform adapter for Hermes Agent. The adapter runs as a native