@inline-chat/hermes-agent-adapter 1.0.0-alpha.2 → 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()
@@ -1176,18 +1197,23 @@ class InlineAdapter(BasePlatformAdapter):
1176
1197
  return {**(metadata or {}), "thread_id": thread_id} if thread_id else metadata
1177
1198
 
1178
1199
  def set_reply_threads_from_tool(
1179
- self, *, chat_id: str, chat_type: str, in_reply_thread: bool, actor_id: str, actor_is_bot: bool, mode: str,
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
- """Change this chat's reply threads because the person talking to the agent asked,
1182
- with the access /threads needs."""
1202
+ """Change reply threads because the person talking to the agent asked, with the access
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
- if in_reply_thread:
1186
- raise PermissionError("change reply threads from the parent chat, not inside a thread")
1207
+ target_chat_id = parent_chat_id or chat_id
1208
+ if parent_chat_id and self._actor_authorization(chat_type, actor_id, parent_chat_id) is not True:
1209
+ raise PermissionError("parent chat access could not be confirmed")
1187
1210
  if not self._may_change_reply_threads(actor_id, chat_type):
1188
1211
  raise PermissionError("changing reply threads needs access to the threads command")
1189
- self._set_reply_threads_for_chat(chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
1190
- return {"chat_id": self._chat_key(chat_id), "mode": self._reply_thread_mode_for_chat(chat_id)}
1212
+ self._set_reply_threads_for_chat(target_chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
1213
+ return {"chat_id": self._chat_key(target_chat_id), "mode": self._reply_thread_mode_for_chat(target_chat_id)}
1214
+
1215
+ def chat_type_from_info(self, info: Dict[str, Any]) -> str:
1216
+ return self._bot_settings_chat_type(info)
1191
1217
 
1192
1218
  @staticmethod
1193
1219
  def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
@@ -1952,6 +1978,12 @@ class InlineAdapter(BasePlatformAdapter):
1952
1978
  self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
1953
1979
  self._mark_connected()
1954
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")
1955
1987
  self._schedule_bot_command_sync()
1956
1988
  return True
1957
1989
 
@@ -2392,6 +2424,8 @@ class InlineAdapter(BasePlatformAdapter):
2392
2424
  # owns a coherent client-state cache.
2393
2425
  self._invalidate_chat_info(event.get("chatId"))
2394
2426
  kind = event.get("kind")
2427
+ if kind in _COLD_BOOT_GATED_KINDS and self._recorded_before_cold_boot(event):
2428
+ return
2395
2429
  if kind == "bot.chatSettings.request":
2396
2430
  if event.get("_inlineDeliveryId"):
2397
2431
  await self._handle_bot_settings_request(event)
@@ -2436,6 +2470,29 @@ class InlineAdapter(BasePlatformAdapter):
2436
2470
  return
2437
2471
  await self._dispatch_message(event)
2438
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
+
2439
2496
  def _is_duplicate(self, key: str) -> bool:
2440
2497
  now = time.time()
2441
2498
  old = self._seen_messages.get(key)
@@ -2683,13 +2740,17 @@ class InlineAdapter(BasePlatformAdapter):
2683
2740
  if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
2684
2741
  self._remember_observed_context(chat_id, msg, text)
2685
2742
  return
2686
- 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))
2687
2745
  if edit:
2688
2746
  text = f"message:edited:{text}" if text else "message:edited"
2689
2747
  if (
2690
2748
  not edit
2691
2749
  and not agent_action
2692
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
2693
2754
  and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
2694
2755
  ):
2695
2756
  created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
@@ -2769,33 +2830,14 @@ class InlineAdapter(BasePlatformAdapter):
2769
2830
  reply_context_messages=context_backfill["reply_context_messages"],
2770
2831
  recent_messages=context_backfill["recent_messages"],
2771
2832
  )
2772
- metadata = self._inline_event_metadata(
2773
- chat_id=chat_id,
2774
- msg_id=msg_id,
2775
- from_id=from_id,
2776
- thread_id=thread_id,
2777
- parent_chat_id=parent_chat_id,
2778
- parent_message_id=parent_message_id,
2779
- entity_text=entity_text,
2780
- )
2781
- if agent_action:
2782
- metadata["inline"]["action"] = {
2783
- "event_kind": "message.action.invoke",
2784
- "actor_user_id": str(agent_action.get("actorUserId") or ""),
2785
- "chat_id": str(agent_action.get("chatId") or ""),
2786
- "target_message_id": str(agent_action.get("messageId") or ""),
2787
- "interaction_id": str(agent_action.get("interactionId") or ""),
2788
- "action_id": str(agent_action.get("actionId") or ""),
2789
- "callback_data_base64": str(agent_action.get("dataBase64") or ""),
2790
- }
2791
-
2792
2833
  session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
2793
2834
  try:
2794
2835
  from . import tools as _inline_tools
2795
2836
  _inline_tools.remember_turn_message(
2796
2837
  session_chat_id=session_chat_id, thread_id=str(thread_id or ""), message_id=msg_id,
2797
2838
  chat_id=chat_id, sender_id=from_id, sender_is_bot=sender_profile.get("bot") is True,
2798
- chat_type=chat_type, in_reply_thread=bool(parent_chat_id) and parent_chat_id != chat_id,
2839
+ chat_type=chat_type,
2840
+ parent_chat_id=parent_chat_id if parent_chat_id and parent_chat_id != chat_id else "",
2799
2841
  )
2800
2842
  except Exception:
2801
2843
  logger.debug("[inline] failed to record the turn's message for tools", exc_info=True)
@@ -2810,7 +2852,11 @@ class InlineAdapter(BasePlatformAdapter):
2810
2852
  parent_chat_id=parent_chat_id,
2811
2853
  message_id=msg_id,
2812
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
2813
2858
  await self.handle_message(MessageEvent(
2859
+ **({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
2814
2860
  text=text,
2815
2861
  message_type=message_type,
2816
2862
  source=source,
@@ -2833,7 +2879,6 @@ class InlineAdapter(BasePlatformAdapter):
2833
2879
  auto_skill=auto_skill,
2834
2880
  channel_prompt=channel_prompt,
2835
2881
  channel_context=channel_context,
2836
- metadata=metadata,
2837
2882
  timestamp=self._timestamp(event.get("date") or msg.get("date")),
2838
2883
  allow_gateway_control=not bool(agent_action),
2839
2884
  ))
@@ -3817,33 +3862,6 @@ class InlineAdapter(BasePlatformAdapter):
3817
3862
  prefix += " from an unknown sender"
3818
3863
  return f"{prefix}: {text}"
3819
3864
 
3820
- def _inline_event_metadata(
3821
- self,
3822
- *,
3823
- chat_id: str,
3824
- msg_id: str,
3825
- from_id: str,
3826
- thread_id: Optional[str],
3827
- parent_chat_id: Optional[str],
3828
- parent_message_id: Optional[str],
3829
- entity_text: Optional[str],
3830
- ) -> Dict[str, Any]:
3831
- inline: Dict[str, Any] = {
3832
- "chat_id": self._chat_key(chat_id),
3833
- "message_id": str(msg_id),
3834
- }
3835
- if from_id:
3836
- inline["sender_user_id"] = self._chat_key(from_id)
3837
- if thread_id:
3838
- inline["thread_id"] = self._chat_key(thread_id)
3839
- if parent_chat_id:
3840
- inline["parent_chat_id"] = self._chat_key(parent_chat_id)
3841
- if parent_message_id:
3842
- inline["parent_message_id"] = str(parent_message_id)
3843
- if entity_text:
3844
- inline["message_entities"] = entity_text
3845
- return {"inline": inline}
3846
-
3847
3865
  @staticmethod
3848
3866
  def _reply_thread_title(text: str) -> str:
3849
3867
  title = strip_markdown(str(text or "")).strip()
@@ -3937,8 +3955,10 @@ class InlineAdapter(BasePlatformAdapter):
3937
3955
  async def _reply_to_for_target(
3938
3956
  self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
3939
3957
  ) -> Optional[str]:
3940
- """Answers quote the message only when it came from a bot: a bot wakes on replies to its
3941
- 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."""
3942
3962
  if not reply_to:
3943
3963
  return None
3944
3964
  target_chat_id = target.get("chatId") or ""
@@ -3949,8 +3969,8 @@ class InlineAdapter(BasePlatformAdapter):
3949
3969
  if anchored:
3950
3970
  return str(reply_to)
3951
3971
  key = f"{self._chat_key(target_chat_id)}:{reply_to}"
3952
- sender_is_bot = self._request_sender_is_bot.get(key)
3953
- 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):
3954
3974
  info = await self._get_chat_info(target_chat_id)
3955
3975
  if not info:
3956
3976
  return None
@@ -3962,16 +3982,16 @@ class InlineAdapter(BasePlatformAdapter):
3962
3982
  # Message ids are per chat, so only look it up where the request was sent; an answer
3963
3983
  # moved into a thread would otherwise read the thread's message with the same id.
3964
3984
  fetched = await self._fetch_message(target_chat_id, str(reply_to))
3965
- sender_is_bot = _inline_sender_profile({}, fetched).get("bot") is True if fetched else False
3966
- self._remember_request_sender(target_chat_id, str(reply_to), sender_is_bot)
3967
- 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
3968
3988
 
3969
- 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:
3970
3990
  key = f"{self._chat_key(chat_id)}:{msg_id}"
3971
- self._request_sender_is_bot[key] = sender_is_bot
3972
- self._request_sender_is_bot.move_to_end(key)
3973
- while len(self._request_sender_is_bot) > _CHAT_INFO_CACHE_MAX_SIZE:
3974
- 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)
3975
3995
 
3976
3996
  async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3977
3997
  target = _target_from_chat_id(chat_id)
@@ -5555,6 +5575,12 @@ class InlineAdapter(BasePlatformAdapter):
5555
5575
  if len(mapping) > limit:
5556
5576
  mapping.popitem(last=False)
5557
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
+
5558
5584
  async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
5559
5585
  try:
5560
5586
  data = await self._sidecar_call(path, body)
@@ -5570,12 +5596,15 @@ class InlineAdapter(BasePlatformAdapter):
5570
5596
  except InlineSidecarError as exc:
5571
5597
  if exc.error_kind == "unknown":
5572
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"))
5573
5601
  return _send_result(
5574
5602
  success=False,
5575
5603
  error=str(exc),
5576
5604
  raw_response=exc.raw,
5577
5605
  retryable=exc.retryable,
5578
5606
  error_kind=exc.error_kind,
5607
+ retry_after=float(retry_after) if retry_after and retry_after > 0 else None,
5579
5608
  )
5580
5609
  except Exception as exc:
5581
5610
  self._report_error("sidecar.send", exc)
@@ -5603,8 +5632,9 @@ class InlineAdapter(BasePlatformAdapter):
5603
5632
 
5604
5633
  def _send_result(**kwargs: Any) -> SendResult:
5605
5634
  fields = getattr(SendResult, "__dataclass_fields__", {})
5606
- if "error_kind" in kwargs and "error_kind" not in fields:
5607
- 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)
5608
5638
  return SendResult(**kwargs)
5609
5639
 
5610
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.2"
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.2
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