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