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