@inline-chat/hermes-agent-adapter 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -5
- package/package.json +8 -7
- package/plugin/inline/README.md +23 -0
- package/plugin/inline/adapter.py +334 -62
- package/plugin/inline/cli.py +1 -1
- 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 = {
|
|
@@ -843,6 +872,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
843
872
|
"state_path",
|
|
844
873
|
"settings_path",
|
|
845
874
|
"parse_markdown",
|
|
875
|
+
"cron_mention_prefix",
|
|
846
876
|
"require_mention",
|
|
847
877
|
"strict_mention",
|
|
848
878
|
"allowed_chats",
|
|
@@ -878,10 +908,41 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
878
908
|
return extra
|
|
879
909
|
|
|
880
910
|
|
|
911
|
+
async def _wait_for_exit(proc: subprocess.Popen, timeout: float) -> bool:
|
|
912
|
+
"""Wait for ``proc`` without blocking the loop or taking an executor worker; False on timeout."""
|
|
913
|
+
deadline = time.monotonic() + timeout
|
|
914
|
+
while proc.poll() is None:
|
|
915
|
+
if time.monotonic() >= deadline:
|
|
916
|
+
return False
|
|
917
|
+
await asyncio.sleep(0.05)
|
|
918
|
+
return True
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _token_count(tokens: int) -> str:
|
|
922
|
+
""""850", "439k", "1.2M": the bridge's wording for context sizes."""
|
|
923
|
+
if tokens < 1_000:
|
|
924
|
+
return str(tokens)
|
|
925
|
+
if tokens < 1_000_000:
|
|
926
|
+
return f"{tokens // 1_000}k"
|
|
927
|
+
tenths = tokens // 100_000
|
|
928
|
+
return f"{tenths // 10}M" if tenths % 10 == 0 else f"{tenths // 10}.{tenths % 10}M"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _accepts_keyword(fn: Callable[..., Any], name: str) -> bool:
|
|
932
|
+
"""Whether a host callable takes keyword ``name`` (host APIs grow optional keywords)."""
|
|
933
|
+
try:
|
|
934
|
+
params = inspect.signature(fn).parameters
|
|
935
|
+
except (TypeError, ValueError):
|
|
936
|
+
return False
|
|
937
|
+
return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
938
|
+
|
|
939
|
+
|
|
881
940
|
class InlineAdapter(BasePlatformAdapter):
|
|
882
941
|
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
|
|
883
942
|
supports_code_blocks = True
|
|
884
943
|
splits_long_messages = True
|
|
944
|
+
# Command preview on an approval card; Hermes sends a longer command whole before the card.
|
|
945
|
+
_EA_CMD_BUDGET = 2000
|
|
885
946
|
|
|
886
947
|
def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
|
|
887
948
|
super().__init__(config, Platform("inline"))
|
|
@@ -910,6 +971,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
910
971
|
_configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
|
|
911
972
|
self._node_bin = _find_node_bin() or "node"
|
|
912
973
|
self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
|
|
974
|
+
cron_mention_prefix = extra.get("cron_mention_prefix") if "cron_mention_prefix" in extra else inline_env("INLINE_CRON_MENTION_PREFIX")
|
|
975
|
+
self._cron_mention_prefix = cron_mention_prefix.strip() if isinstance(cron_mention_prefix, str) else ""
|
|
976
|
+
if self._cron_mention_prefix and not self._parse_markdown:
|
|
977
|
+
logger.warning("[inline] cron_mention_prefix needs parse_markdown=true; scheduled mentions are disabled")
|
|
913
978
|
self._media_max_bytes = int(
|
|
914
979
|
_normalize_positive_float(
|
|
915
980
|
extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
|
|
@@ -1086,6 +1151,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1086
1151
|
self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
|
|
1087
1152
|
self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1088
1153
|
self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1154
|
+
self._partial_sends: "OrderedDict[tuple, bool]" = OrderedDict()
|
|
1155
|
+
self._approval_request_ids: "OrderedDict[str, str]" = OrderedDict()
|
|
1156
|
+
self._approval_commands: "OrderedDict[str, str]" = OrderedDict()
|
|
1089
1157
|
self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1090
1158
|
self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
1091
1159
|
self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
@@ -1369,6 +1437,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1369
1437
|
}
|
|
1370
1438
|
if runner is None:
|
|
1371
1439
|
return context
|
|
1440
|
+
context["context_usage"] = self._session_context_usage(runner, source)
|
|
1372
1441
|
|
|
1373
1442
|
try:
|
|
1374
1443
|
from hermes_cli.config import load_config_readonly
|
|
@@ -1489,6 +1558,22 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1489
1558
|
)
|
|
1490
1559
|
return context
|
|
1491
1560
|
|
|
1561
|
+
@staticmethod
|
|
1562
|
+
def _session_context_usage(runner: Any, source: Any) -> Optional[List[int]]:
|
|
1563
|
+
"""``[used, window]`` prompt tokens of the chat's resident Hermes agent, read like Hermes'
|
|
1564
|
+
own ``/usage``; None before its first turn or once the agent was evicted."""
|
|
1565
|
+
try:
|
|
1566
|
+
agent = runner._resident_agent_for(runner._session_key_for_source(source))
|
|
1567
|
+
compressor = getattr(agent, "context_compressor", None) if agent is not None else None
|
|
1568
|
+
used = int(getattr(compressor, "last_prompt_tokens", 0) or 0)
|
|
1569
|
+
window = int(getattr(compressor, "context_length", 0) or 0)
|
|
1570
|
+
except Exception as exc:
|
|
1571
|
+
_hermes_seam_unavailable(
|
|
1572
|
+
"GatewayRunner._resident_agent_for", "Inline agent settings omit the context window", exc,
|
|
1573
|
+
)
|
|
1574
|
+
return None
|
|
1575
|
+
return [used, window] if used > 0 and window > 0 else None
|
|
1576
|
+
|
|
1492
1577
|
@staticmethod
|
|
1493
1578
|
def _bot_settings_revision(context: Dict[str, Any]) -> str:
|
|
1494
1579
|
visible = {
|
|
@@ -1550,6 +1635,21 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1550
1635
|
"options": [{**option, "disabled": False} for option in reasoning_options],
|
|
1551
1636
|
}},
|
|
1552
1637
|
})
|
|
1638
|
+
usage = context.get("context_usage")
|
|
1639
|
+
if usage:
|
|
1640
|
+
used, window = min(usage[0], usage[1]), usage[1]
|
|
1641
|
+
percent = used * 100 // window
|
|
1642
|
+
# The id carries the numbers: Inline's composer draws its context circle from
|
|
1643
|
+
# ``session.context:<used>:<window>``, as it does for coding-agent bridges.
|
|
1644
|
+
runtime_items.append({
|
|
1645
|
+
"id": f"session.context:{usage[0]}:{window}", "label": "Context window",
|
|
1646
|
+
"description": f"{_token_count(usage[0])} of {_token_count(window)} tokens used",
|
|
1647
|
+
"disabled": True,
|
|
1648
|
+
"control": {"oneofKind": "info", "info": {
|
|
1649
|
+
"text": f"{percent}% full",
|
|
1650
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_WARNING if percent >= 90 else _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
|
|
1651
|
+
}},
|
|
1652
|
+
})
|
|
1553
1653
|
if not runtime_items:
|
|
1554
1654
|
runtime_items.append({
|
|
1555
1655
|
"id": "runtime-unavailable", "label": "Runtime", "disabled": False,
|
|
@@ -2170,14 +2270,34 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2170
2270
|
raise RuntimeError(f"Inline sidecar did not become ready within {timeout}: {last_error}")
|
|
2171
2271
|
|
|
2172
2272
|
async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
|
|
2173
|
-
|
|
2273
|
+
"""Log sidecar output. A quiet sidecar can go hours without a line, so its blocking
|
|
2274
|
+
read runs on a thread of its own: on the loop's shared executor every sidecar would pin
|
|
2275
|
+
a worker, and a gateway with more Inline bots than workers stalls every other
|
|
2276
|
+
``to_thread`` job (startup included)."""
|
|
2277
|
+
stdout = proc.stdout
|
|
2278
|
+
if stdout is None:
|
|
2174
2279
|
return
|
|
2175
|
-
loop = asyncio.
|
|
2176
|
-
|
|
2280
|
+
loop = asyncio.get_running_loop()
|
|
2281
|
+
lines: "asyncio.Queue[bytes]" = asyncio.Queue()
|
|
2282
|
+
|
|
2283
|
+
def hand_off(raw: bytes) -> None:
|
|
2177
2284
|
try:
|
|
2178
|
-
|
|
2285
|
+
loop.call_soon_threadsafe(lines.put_nowait, raw)
|
|
2286
|
+
except RuntimeError:
|
|
2287
|
+
pass # the loop closed first
|
|
2288
|
+
|
|
2289
|
+
def read() -> None:
|
|
2290
|
+
try:
|
|
2291
|
+
for raw in iter(stdout.readline, b""):
|
|
2292
|
+
hand_off(raw)
|
|
2179
2293
|
except Exception:
|
|
2180
|
-
|
|
2294
|
+
pass
|
|
2295
|
+
finally:
|
|
2296
|
+
hand_off(b"")
|
|
2297
|
+
|
|
2298
|
+
threading.Thread(target=read, name="inline-sidecar-stdout", daemon=True).start()
|
|
2299
|
+
while True:
|
|
2300
|
+
line = await lines.get()
|
|
2181
2301
|
if not line:
|
|
2182
2302
|
return
|
|
2183
2303
|
text = line.decode("utf-8", "replace").rstrip()
|
|
@@ -2206,9 +2326,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2206
2326
|
)
|
|
2207
2327
|
except Exception:
|
|
2208
2328
|
pass
|
|
2209
|
-
|
|
2210
|
-
proc.wait(timeout=3.0)
|
|
2211
|
-
except subprocess.TimeoutExpired:
|
|
2329
|
+
if not await _wait_for_exit(proc, 3.0):
|
|
2212
2330
|
if sys.platform != "win32":
|
|
2213
2331
|
try:
|
|
2214
2332
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
@@ -2216,9 +2334,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2216
2334
|
proc.terminate()
|
|
2217
2335
|
else:
|
|
2218
2336
|
proc.terminate()
|
|
2219
|
-
|
|
2220
|
-
proc.wait(timeout=2.0)
|
|
2221
|
-
except subprocess.TimeoutExpired:
|
|
2337
|
+
if not await _wait_for_exit(proc, 2.0):
|
|
2222
2338
|
proc.kill()
|
|
2223
2339
|
finally:
|
|
2224
2340
|
self._sidecar_proc = None
|
|
@@ -2519,10 +2635,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2519
2635
|
self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
|
|
2520
2636
|
return
|
|
2521
2637
|
if kind == "message.action.invoke":
|
|
2522
|
-
|
|
2638
|
+
# A widget call names its widget, never a native control, so it skips system handlers.
|
|
2639
|
+
widget_call = inline_widget_call(event) is not None
|
|
2640
|
+
if not widget_call and await self._handle_action(event):
|
|
2523
2641
|
return
|
|
2524
2642
|
ownership = resolve_inline_message_action_ownership(event.get("actionId"))
|
|
2525
|
-
if ownership.owner == "system" and ownership.explicit:
|
|
2643
|
+
if not widget_call and ownership.owner == "system" and ownership.explicit:
|
|
2526
2644
|
await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
|
|
2527
2645
|
logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
|
|
2528
2646
|
return
|
|
@@ -2757,9 +2875,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2757
2875
|
await self.handle_message(MessageEvent(
|
|
2758
2876
|
text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
|
|
2759
2877
|
source=source, raw_message=event,
|
|
2760
|
-
message_id=
|
|
2761
|
-
agent_action.get("messageId"), agent_action.get("interactionId"),
|
|
2762
|
-
) if agent_action else msg_id,
|
|
2878
|
+
message_id=_agent_action_turn_message_id(agent_action, msg_id),
|
|
2763
2879
|
timestamp=self._timestamp(event.get("date") or msg.get("date")),
|
|
2764
2880
|
allow_gateway_control=not bool(agent_action),
|
|
2765
2881
|
))
|
|
@@ -2941,14 +3057,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2941
3057
|
message_type=message_type,
|
|
2942
3058
|
source=source,
|
|
2943
3059
|
raw_message=event,
|
|
2944
|
-
message_id=(
|
|
2945
|
-
build_inline_agent_action_turn_id(
|
|
2946
|
-
agent_action.get("messageId"),
|
|
2947
|
-
agent_action.get("interactionId"),
|
|
2948
|
-
)
|
|
2949
|
-
if agent_action
|
|
2950
|
-
else msg_id
|
|
2951
|
-
),
|
|
3060
|
+
message_id=_agent_action_turn_message_id(agent_action, msg_id),
|
|
2952
3061
|
platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
|
|
2953
3062
|
media_urls=media_urls,
|
|
2954
3063
|
media_types=media_types,
|
|
@@ -2982,6 +3091,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2982
3091
|
if not target:
|
|
2983
3092
|
await self._answer_action(interaction_id, "Action expired or no longer accessible.")
|
|
2984
3093
|
return
|
|
3094
|
+
widget_call = inline_widget_call(event)
|
|
3095
|
+
if widget_call is None and str(event.get("actionId") or "") in inline_message_widget_ids(target):
|
|
3096
|
+
# Older realtime SDKs drop the call's name and payload but keep the widget ID.
|
|
3097
|
+
widget_call = {}
|
|
3098
|
+
if widget_call is not None:
|
|
3099
|
+
event = {**event, "widgetCall": widget_call}
|
|
2985
3100
|
|
|
2986
3101
|
synthetic_message = dict(target)
|
|
2987
3102
|
for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
|
|
@@ -4432,32 +4547,93 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4432
4547
|
_, approval_id, choice = parts
|
|
4433
4548
|
choice = "once" if choice == "approve" else choice
|
|
4434
4549
|
session_key = self._approval_sessions.get(approval_id)
|
|
4435
|
-
if choice not in
|
|
4550
|
+
if choice not in _APPROVAL_CHOICES:
|
|
4436
4551
|
return False
|
|
4437
4552
|
if not session_key:
|
|
4438
|
-
|
|
4553
|
+
command = self._approval_commands.pop(approval_id, None) or await self._approval_command_from_message(event)
|
|
4554
|
+
await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
|
|
4439
4555
|
return True
|
|
4440
4556
|
try:
|
|
4441
|
-
|
|
4442
|
-
|
|
4557
|
+
request_id = self._approval_request_ids.get(approval_id)
|
|
4558
|
+
actor_id = str(event.get("actorUserId") or "").strip()
|
|
4559
|
+
refusal = self._approval_refusal(session_key, actor_id, request_id)
|
|
4560
|
+
if refusal:
|
|
4561
|
+
# Another member's approval: leave the card live for the requester.
|
|
4562
|
+
await self._answer_action(str(event.get("interactionId") or ""), refusal)
|
|
4563
|
+
return True
|
|
4564
|
+
count = self._resolve_approval(session_key, choice, request_id, actor_id)
|
|
4443
4565
|
self._approval_sessions.pop(approval_id, None)
|
|
4566
|
+
self._approval_request_ids.pop(approval_id, None)
|
|
4567
|
+
command = self._approval_commands.pop(approval_id, None) or ""
|
|
4444
4568
|
if not count:
|
|
4445
|
-
await self._finish_action(event, "Approval expired",
|
|
4569
|
+
await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
|
|
4446
4570
|
return True
|
|
4447
|
-
|
|
4448
|
-
"once":
|
|
4449
|
-
"session":
|
|
4450
|
-
"always":
|
|
4451
|
-
"deny":
|
|
4571
|
+
toasts = {
|
|
4572
|
+
"once": "Approved once",
|
|
4573
|
+
"session": "Approved for session",
|
|
4574
|
+
"always": "Always allowed",
|
|
4575
|
+
"deny": "Denied",
|
|
4452
4576
|
}
|
|
4453
|
-
|
|
4454
|
-
|
|
4577
|
+
# Slack, Discord and OpenClaw's Inline card say who answered; a bare id is noise.
|
|
4578
|
+
answered_by, _, _ = _inline_sender_identity(_inline_sender_profile(event))
|
|
4579
|
+
heading = _APPROVAL_DECISION_HEADINGS[choice] + (f" by {answered_by}" if answered_by else "")
|
|
4580
|
+
await self._finish_action(event, toasts[choice], _approval_card_text(heading, command))
|
|
4455
4581
|
return True
|
|
4456
4582
|
except Exception as exc:
|
|
4457
4583
|
self._report_error("action.approval", exc)
|
|
4458
4584
|
logger.exception("[inline] approval action failed")
|
|
4459
4585
|
return True
|
|
4460
4586
|
|
|
4587
|
+
async def _approval_command_from_message(self, event: Dict[str, Any]) -> str:
|
|
4588
|
+
"""Recover the command from the card itself when this process no longer has it (e.g. after a restart)."""
|
|
4589
|
+
chat_id = str(event.get("chatId") or "")
|
|
4590
|
+
message_id = str(event.get("messageId") or "")
|
|
4591
|
+
msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
|
|
4592
|
+
if not msg:
|
|
4593
|
+
return ""
|
|
4594
|
+
text = str(msg.get("message") or "")
|
|
4595
|
+
units = text.encode("utf-16-le")
|
|
4596
|
+
for entity in self._message_entities(msg):
|
|
4597
|
+
if self._entity_kind(entity) != "pre":
|
|
4598
|
+
continue
|
|
4599
|
+
offset = _to_int(entity.get("offset"))
|
|
4600
|
+
length = _to_int(entity.get("length"))
|
|
4601
|
+
if offset is None or length is None or offset < 0 or length <= 0:
|
|
4602
|
+
continue
|
|
4603
|
+
return units[offset * 2 : (offset + length) * 2].decode("utf-16-le", errors="ignore").strip()
|
|
4604
|
+
match = _APPROVAL_FENCE_PATTERN.search(text)
|
|
4605
|
+
return match.group(2).strip() if match else ""
|
|
4606
|
+
|
|
4607
|
+
def _approval_refusal(self, session_key: str, actor_id: str, request_id: Optional[str]) -> Optional[str]:
|
|
4608
|
+
"""Hermes' approver rule (``approvals.approvers``) for a tap, or None when it may answer.
|
|
4609
|
+
Hosts without the rule leave it to the chat access check every action already passed."""
|
|
4610
|
+
try:
|
|
4611
|
+
from gateway.approval_actor import approval_refusal
|
|
4612
|
+
except ImportError:
|
|
4613
|
+
return None
|
|
4614
|
+
requester = approval_refusal(session_key, actor_id, extra=self.config.extra, request_id=request_id)
|
|
4615
|
+
if not requester:
|
|
4616
|
+
return None
|
|
4617
|
+
try:
|
|
4618
|
+
from gateway.approval_actor import refusal_text
|
|
4619
|
+
return str(refusal_text(requester))
|
|
4620
|
+
except ImportError:
|
|
4621
|
+
return f"Only {requester} can answer this approval."
|
|
4622
|
+
|
|
4623
|
+
def _resolve_approval(self, session_key: str, choice: str, request_id: Optional[str], actor_id: str) -> int:
|
|
4624
|
+
"""Resolve only the request the card was sent for; once it settled (timeout, /approve)
|
|
4625
|
+
the tap resolves nothing rather than the next one queued. A card without a request id
|
|
4626
|
+
(hosts before 0.22.7) keeps the oldest-pending behaviour."""
|
|
4627
|
+
from tools import approval as approval_mod
|
|
4628
|
+
resolve_card = getattr(approval_mod, "resolve_gateway_approval_card", None)
|
|
4629
|
+
if resolve_card is not None:
|
|
4630
|
+
if _accepts_keyword(resolve_card, "actor_id"):
|
|
4631
|
+
return resolve_card(session_key, choice, request_id, actor_id=actor_id or None, extra=self.config.extra)
|
|
4632
|
+
return resolve_card(session_key, choice, request_id)
|
|
4633
|
+
if request_id:
|
|
4634
|
+
return approval_mod.resolve_gateway_approval(session_key, choice, request_id=request_id)
|
|
4635
|
+
return approval_mod.resolve_gateway_approval(session_key, choice)
|
|
4636
|
+
|
|
4461
4637
|
async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
|
|
4462
4638
|
action_id = str(event.get("actionId") or "")
|
|
4463
4639
|
parts = action_id.split(":", 2)
|
|
@@ -4890,8 +5066,11 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4890
5066
|
metadata: Optional[Dict[str, Any]] = None,
|
|
4891
5067
|
actions: Optional[Dict[str, Any]] = None,
|
|
4892
5068
|
) -> SendResult:
|
|
5069
|
+
requested = content
|
|
5070
|
+
content = self._with_cron_mention(content, metadata)
|
|
4893
5071
|
metadata = self._with_turn_thread(chat_id, reply_to, metadata)
|
|
4894
5072
|
target = self._target_for(chat_id, metadata)
|
|
5073
|
+
send_options = {"sendMode": "silent"} if (metadata or {}).get("notify") is False else {}
|
|
4895
5074
|
agent_action_target = parse_inline_agent_action_reply_target(reply_to)
|
|
4896
5075
|
if agent_action_target:
|
|
4897
5076
|
chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
|
|
@@ -4916,10 +5095,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4916
5095
|
"text": chunk,
|
|
4917
5096
|
"parseMarkdown": self._parse_markdown,
|
|
4918
5097
|
"replyToMsgId": previous_id,
|
|
5098
|
+
**send_options,
|
|
4919
5099
|
})
|
|
4920
5100
|
raw_responses.append(continuation.raw_response)
|
|
4921
5101
|
if not continuation.success:
|
|
4922
|
-
return
|
|
5102
|
+
return self._partial_send_failure(
|
|
5103
|
+
chat_id, requested, continuation, previous_id, continuation_ids, len(chunks), raw_responses,
|
|
5104
|
+
)
|
|
4923
5105
|
if continuation.message_id:
|
|
4924
5106
|
previous_id = str(continuation.message_id)
|
|
4925
5107
|
continuation_ids.append(previous_id)
|
|
@@ -4945,6 +5127,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4945
5127
|
"target": target,
|
|
4946
5128
|
"text": chunk,
|
|
4947
5129
|
"parseMarkdown": self._parse_markdown,
|
|
5130
|
+
**send_options,
|
|
4948
5131
|
}
|
|
4949
5132
|
if reply_to and index == 0:
|
|
4950
5133
|
body["replyToMsgId"] = str(reply_to)
|
|
@@ -4952,7 +5135,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4952
5135
|
body["actions"] = actions
|
|
4953
5136
|
last_result = await self._send_sidecar("/send", body)
|
|
4954
5137
|
if not last_result.success:
|
|
4955
|
-
|
|
5138
|
+
if not message_ids:
|
|
5139
|
+
return last_result
|
|
5140
|
+
return self._partial_send_failure(
|
|
5141
|
+
chat_id, requested, last_result, message_ids[-1], message_ids[:-1], len(chunks),
|
|
5142
|
+
[*raw_responses, getattr(last_result, "raw_response", None)],
|
|
5143
|
+
)
|
|
4956
5144
|
if not marked_visible:
|
|
4957
5145
|
self._mark_reply_thread_visible(target)
|
|
4958
5146
|
marked_visible = True
|
|
@@ -5014,7 +5202,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5014
5202
|
metadata: Optional[Dict[str, Any]] = None,
|
|
5015
5203
|
) -> SendResult:
|
|
5016
5204
|
text = self.format_message(content)
|
|
5017
|
-
|
|
5205
|
+
several_widgets = False
|
|
5206
|
+
if self._parse_markdown and has_widget_markup(text):
|
|
5207
|
+
if not finalize:
|
|
5208
|
+
# A streaming preview must not send a widget whose closing tag hasn't arrived.
|
|
5209
|
+
text = streaming_preview(text)
|
|
5210
|
+
several_widgets = len(scan_widgets(text).widgets) > 1
|
|
5211
|
+
if len(text) > self.MAX_MESSAGE_LENGTH or several_widgets:
|
|
5018
5212
|
if finalize:
|
|
5019
5213
|
return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
|
|
5020
5214
|
text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
|
|
@@ -5298,35 +5492,31 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5298
5492
|
"actions": {"rows": self._action_rows(actions)},
|
|
5299
5493
|
})
|
|
5300
5494
|
|
|
5301
|
-
async def
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
command
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
allow_permanent: bool = True,
|
|
5309
|
-
allow_session: bool = True,
|
|
5310
|
-
smart_denied: bool = False,
|
|
5311
|
-
) -> SendResult:
|
|
5495
|
+
async def _send_exec_approval_prompt(self, prompt: Any) -> SendResult:
|
|
5496
|
+
"""Draw Hermes' exec-approval prompt as an Inline button card.
|
|
5497
|
+
|
|
5498
|
+
Hermes' ``send_exec_approval`` owns the wording, a truncated command's full send and
|
|
5499
|
+
the Deny-only gate of newer hosts; overriding this hook is also how Hermes knows the
|
|
5500
|
+
adapter has native buttons. Inline only draws the offered choices and remembers which
|
|
5501
|
+
request the card answers, so a tap never resolves a different one."""
|
|
5312
5502
|
approval_id = secrets.token_hex(6)
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
|
|
5319
|
-
if allow_permanent:
|
|
5320
|
-
actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
|
|
5321
|
-
actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
|
|
5503
|
+
actions = [
|
|
5504
|
+
self._action(f"appr:{approval_id}:{choice}", str(label))
|
|
5505
|
+
for label, choice, _style in prompt.actions
|
|
5506
|
+
if choice in _APPROVAL_CHOICES
|
|
5507
|
+
]
|
|
5322
5508
|
result = await self._send_sidecar("/send", {
|
|
5323
|
-
"target": self._target_for(chat_id, metadata),
|
|
5324
|
-
"text": text,
|
|
5509
|
+
"target": self._target_for(prompt.chat_id, prompt.metadata),
|
|
5510
|
+
"text": prompt.text,
|
|
5325
5511
|
"parseMarkdown": self._parse_markdown,
|
|
5326
5512
|
"actions": {"rows": self._action_rows(actions)},
|
|
5327
5513
|
})
|
|
5328
5514
|
if result.success:
|
|
5329
|
-
self._remember(self._approval_sessions, approval_id, session_key)
|
|
5515
|
+
self._remember(self._approval_sessions, approval_id, prompt.session_key)
|
|
5516
|
+
self._remember(self._approval_commands, approval_id, str(getattr(prompt, "command", "") or ""))
|
|
5517
|
+
request_id = getattr(prompt, "request_id", None)
|
|
5518
|
+
if request_id:
|
|
5519
|
+
self._remember(self._approval_request_ids, approval_id, str(request_id))
|
|
5330
5520
|
return result
|
|
5331
5521
|
|
|
5332
5522
|
async def send_slash_confirm(self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
@@ -5642,6 +5832,81 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5642
5832
|
out["parent_message_id"] = parent_message_id
|
|
5643
5833
|
return out
|
|
5644
5834
|
|
|
5835
|
+
def _with_cron_mention(self, content: str, metadata: Optional[Dict[str, Any]]) -> str:
|
|
5836
|
+
# Hermes identifies scheduled text sends with job_id. notify also appears
|
|
5837
|
+
# on ordinary replies, so it cannot identify a cron on its own. Decorate
|
|
5838
|
+
# before chunking to mention once, while preserving the supplied body.
|
|
5839
|
+
meta = metadata or {}
|
|
5840
|
+
prefix = self._cron_mention_prefix
|
|
5841
|
+
if not prefix or not self._parse_markdown or not meta.get("job_id") or meta.get("notify") is False:
|
|
5842
|
+
return content
|
|
5843
|
+
if not content or not content.strip() or content.startswith(prefix):
|
|
5844
|
+
return content
|
|
5845
|
+
return f"{prefix}\n\n{content}"
|
|
5846
|
+
|
|
5847
|
+
def truncate_message(
|
|
5848
|
+
self,
|
|
5849
|
+
content: str,
|
|
5850
|
+
max_length: int = _MAX_MESSAGE_LENGTH,
|
|
5851
|
+
len_fn: Optional[Callable[[str], int]] = None,
|
|
5852
|
+
) -> List[str]:
|
|
5853
|
+
"""Hermes splitting, except a widget block is never cut or shares a message with another."""
|
|
5854
|
+
if getattr(self, "_parse_markdown", True):
|
|
5855
|
+
def split_plain(text: str, limit: int) -> List[str]:
|
|
5856
|
+
pieces = self._base_truncate(text, limit, len_fn)
|
|
5857
|
+
# Upstream numbers multi-part output; the parts here are pieces of one reply.
|
|
5858
|
+
return [piece.removesuffix(f" ({i + 1}/{len(pieces)})") for i, piece in enumerate(pieces)]
|
|
5859
|
+
|
|
5860
|
+
chunks = split_widget_aware(content, max_length, split_plain, len_fn or len)
|
|
5861
|
+
if chunks is not None:
|
|
5862
|
+
return chunks
|
|
5863
|
+
return self._base_truncate(content, max_length, len_fn)
|
|
5864
|
+
|
|
5865
|
+
def _base_truncate(self, content: str, max_length: int, len_fn: Optional[Callable[[str], int]]) -> List[str]:
|
|
5866
|
+
base = super().truncate_message
|
|
5867
|
+
if len_fn is not None and _accepts_keyword(base, "len_fn"):
|
|
5868
|
+
return list(base(content, max_length, len_fn=len_fn))
|
|
5869
|
+
return list(base(content, max_length))
|
|
5870
|
+
|
|
5871
|
+
def _partial_send_failure(
|
|
5872
|
+
self, chat_id: str, requested: str, failed: SendResult, last_delivered_id: str,
|
|
5873
|
+
delivered_continuations: List[str], total_chunks: int, raw_responses: List[Any],
|
|
5874
|
+
) -> SendResult:
|
|
5875
|
+
"""A later chunk failed after earlier ones were delivered. ``partial_overflow`` tells Hermes
|
|
5876
|
+
(0.22.7+) not to resend the visible head as plain text; older hosts are stopped in
|
|
5877
|
+
``_send_plain_fallback``. Either way the reply is not posted twice."""
|
|
5878
|
+
if getattr(BasePlatformAdapter, "_is_partial_delivery", None) is None:
|
|
5879
|
+
self._remember(self._partial_sends, (self._chat_key(chat_id), requested), True, limit=64)
|
|
5880
|
+
return _send_result(
|
|
5881
|
+
success=False,
|
|
5882
|
+
message_id=last_delivered_id,
|
|
5883
|
+
error=failed.error or "continuation failed",
|
|
5884
|
+
error_kind=getattr(failed, "error_kind", None),
|
|
5885
|
+
retryable=bool(getattr(failed, "retryable", False)),
|
|
5886
|
+
raw_response={
|
|
5887
|
+
"partial_overflow": True,
|
|
5888
|
+
"delivered_chunks": 1 + len(delivered_continuations),
|
|
5889
|
+
"total_chunks": total_chunks,
|
|
5890
|
+
"last_message_id": last_delivered_id,
|
|
5891
|
+
"continuation_message_ids": tuple(delivered_continuations),
|
|
5892
|
+
"responses": raw_responses,
|
|
5893
|
+
},
|
|
5894
|
+
continuation_message_ids=tuple(delivered_continuations),
|
|
5895
|
+
)
|
|
5896
|
+
|
|
5897
|
+
async def _send_plain_fallback(self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> SendResult:
|
|
5898
|
+
if self._partial_sends.pop((self._chat_key(chat_id), content), None):
|
|
5899
|
+
# Part of this reply is already in the chat: resending it would post that part twice.
|
|
5900
|
+
logger.warning("[inline] send failed after partial delivery; not resending it as plain text")
|
|
5901
|
+
return _send_result(success=False, error="send failed after partial delivery", error_kind="bad_format")
|
|
5902
|
+
# A widget the server rejected would fail the fallback too; keep the prose and say so.
|
|
5903
|
+
if getattr(self, "_parse_markdown", True) and has_widget_markup(content):
|
|
5904
|
+
content = without_widgets(content)
|
|
5905
|
+
base = getattr(super(), "_send_plain_fallback", None)
|
|
5906
|
+
if base is None:
|
|
5907
|
+
return await self.send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
5908
|
+
return await base(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
5909
|
+
|
|
5645
5910
|
def format_message(self, content: str) -> str:
|
|
5646
5911
|
# parseMarkdown=false means literal input at every Inline API boundary.
|
|
5647
5912
|
# Preserve that contract instead of silently rewriting the caller's text.
|
|
@@ -5731,6 +5996,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5731
5996
|
return _sidecar_base_url(self._sidecar_bind, self._sidecar_port)
|
|
5732
5997
|
|
|
5733
5998
|
|
|
5999
|
+
def _agent_action_turn_message_id(agent_action: Optional[Dict[str, Any]], msg_id: str) -> str:
|
|
6000
|
+
"""A button turn's answer replaces its card; a widget call's answer replies below the widget."""
|
|
6001
|
+
if not agent_action or inline_widget_call(agent_action) is not None:
|
|
6002
|
+
return msg_id
|
|
6003
|
+
return build_inline_agent_action_turn_id(agent_action.get("messageId"), agent_action.get("interactionId"))
|
|
6004
|
+
|
|
6005
|
+
|
|
5734
6006
|
def _send_result(**kwargs: Any) -> SendResult:
|
|
5735
6007
|
fields = getattr(SendResult, "__dataclass_fields__", {})
|
|
5736
6008
|
for optional in ("error_kind", "retry_after"):
|
package/plugin/inline/cli.py
CHANGED
|
@@ -30,7 +30,7 @@ _MACHINE_SETUP_PROTOCOL_VERSION = 1
|
|
|
30
30
|
# Hermes' own tolerance: macOS readings of one process's start time drift about a second
|
|
31
31
|
# after boot-clock adjustments, and 200 is 2 s on every platform's scale.
|
|
32
32
|
_START_TIME_DRIFT_TOLERANCE = 200
|
|
33
|
-
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.0
|
|
33
|
+
_PROBE_USER_AGENT = "inline-hermes-agent-adapter/1.1.0"
|
|
34
34
|
_ENV_REFERENCE_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
35
35
|
|
|
36
36
|
|