@inline-chat/hermes-agent-adapter 0.0.5 → 0.0.6

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.
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import hashlib
11
12
  import json
12
13
  import logging
13
14
  import math
@@ -15,6 +16,7 @@ import mimetypes
15
16
  import os
16
17
  import re
17
18
  import secrets
19
+ import shlex
18
20
  import shutil
19
21
  import signal
20
22
  import socket
@@ -56,6 +58,7 @@ _DEDUP_MAX_SIZE = 5000
56
58
  _DEDUP_WINDOW_SECONDS = 48 * 3600
57
59
  _CHAT_INFO_CACHE_SECONDS = 10 * 60
58
60
  _CHAT_INFO_CACHE_MAX_SIZE = 512
61
+ _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS = 60
59
62
  _DEFAULT_CONTEXT_BACKFILL = "selective"
60
63
  _CONTEXT_BACKFILL_MODES = {"off", "selective", "always"}
61
64
  _DEFAULT_THREAD_CONTEXT_LIMIT = 30
@@ -115,6 +118,13 @@ _INLINE_REPLY_THREAD_INTENT_RE = re.compile(
115
118
  re.IGNORECASE,
116
119
  )
117
120
  _INLINE_SETTINGS_VERSION = 1
121
+ _INLINE_BOT_SETTINGS_VERSION = 1
122
+ _INLINE_BOT_SETTINGS_UNAVAILABLE = 1
123
+ _INLINE_BOT_SETTINGS_INVALID_VALUE = 2
124
+ _INLINE_BOT_SETTINGS_STALE = 3
125
+ _INLINE_BOT_SETTINGS_FAILED = 4
126
+ _INLINE_BOT_SETTINGS_TONE_NEUTRAL = 1
127
+ _INLINE_BOT_SETTINGS_TONE_WARNING = 3
118
128
  _INLINE_ENTITY_LIMIT = 12
119
129
  _INLINE_ENTITY_TEXT_LIMIT = 120
120
130
 
@@ -424,6 +434,26 @@ def _normalize_inline_command_name(raw: str) -> str:
424
434
  return name.strip("_")
425
435
 
426
436
 
437
+ def _normalize_inline_username(raw: Any) -> Optional[str]:
438
+ username = str(raw or "").strip().lstrip("@").lower()
439
+ return username or None
440
+
441
+
442
+ def _resolve_inline_targeted_command(text: str, bot_username: Optional[str]) -> tuple[str, bool, bool]:
443
+ match = re.match(
444
+ r"^/([a-z0-9_][a-z0-9_-]*)@([a-z0-9_]+)(\s.*)?$",
445
+ str(text or ""),
446
+ re.IGNORECASE | re.DOTALL,
447
+ )
448
+ if not match:
449
+ return text, False, False
450
+ target_username = _normalize_inline_username(match.group(2))
451
+ own_username = _normalize_inline_username(bot_username)
452
+ if not own_username or target_username != own_username:
453
+ return text, True, False
454
+ return f"/{match.group(1)}{match.group(3) or ''}", True, True
455
+
456
+
427
457
  def _normalize_inline_plugin_command_text(text: str) -> str:
428
458
  match = re.match(r"^/([a-z0-9_]+)(@[A-Za-z0-9_]+)?(\s.*)?$", str(text or ""), re.IGNORECASE | re.DOTALL)
429
459
  if not match or "_" not in match.group(1):
@@ -816,9 +846,14 @@ class InlineAdapter(BasePlatformAdapter):
816
846
  self._sidecar_supervisor_task: Optional[asyncio.Task] = None
817
847
  self._command_sync_task: Optional[asyncio.Task] = None
818
848
  self._inbound_task: Optional[asyncio.Task] = None
849
+ self._bot_settings_tasks: set[asyncio.Task] = set()
850
+ self._bot_settings_locks: Dict[str, asyncio.Lock] = {}
851
+ self._bot_settings_lock_users: Dict[str, int] = {}
852
+ self._bot_settings_model_catalog_cache: Optional[tuple[float, List[Dict[str, Any]]]] = None
819
853
  self._inbound_running = False
820
854
  self._http_client: Optional[httpx.AsyncClient] = None
821
855
  self._me_id: Optional[str] = None
856
+ self._me_username: Optional[str] = None
822
857
  self._seen_messages: Dict[str, float] = {}
823
858
  self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
824
859
  self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
@@ -931,6 +966,479 @@ class InlineAdapter(BasePlatformAdapter):
931
966
  self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
932
967
  self._save_reply_thread_overrides()
933
968
 
969
+ @staticmethod
970
+ def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
971
+ peer = info.get("peer")
972
+ if peer is None and isinstance(info.get("chat"), dict):
973
+ peer = info["chat"].get("peerId")
974
+ if isinstance(peer, dict):
975
+ peer_type = peer.get("peer") or peer.get("type") or {}
976
+ if isinstance(peer_type, dict) and peer_type.get("oneofKind") == "user":
977
+ return "dm"
978
+ return "group"
979
+
980
+ def _bot_settings_runner(self) -> Any:
981
+ runner = getattr(self, "gateway_runner", None)
982
+ if runner is not None:
983
+ return runner
984
+ try:
985
+ from gateway.run import _gateway_runner_ref
986
+ return _gateway_runner_ref()
987
+ except Exception:
988
+ return None
989
+
990
+ async def _bot_settings_context(
991
+ self,
992
+ event: Dict[str, Any],
993
+ *,
994
+ model_catalog: Optional[Dict[str, Any]] = None,
995
+ ) -> Dict[str, Any]:
996
+ chat_id = self._chat_key(event.get("chatId"))
997
+ actor_id = str(event.get("actorUserId") or "").strip()
998
+ if not chat_id or not actor_id:
999
+ return {"access": "guideOnly", "scope_id": chat_id or "unknown", "reply_threads": "auto"}
1000
+
1001
+ info = await self._get_chat_info(chat_id)
1002
+ if not info:
1003
+ return {
1004
+ "access": "guideOnly",
1005
+ "scope_id": chat_id,
1006
+ "reply_threads": "auto",
1007
+ "unavailable_reason": "chat_metadata",
1008
+ }
1009
+ parent_chat_id = self._chat_info_id(info, "parentChatId")
1010
+ scope_chat_id = parent_chat_id or chat_id
1011
+ is_reply_thread = bool(parent_chat_id)
1012
+ chat_type = self._bot_settings_chat_type(info)
1013
+ can_inspect = self._allowed(chat_type, actor_id) and self._chat_allowed(
1014
+ chat_id,
1015
+ chat_id if is_reply_thread else None,
1016
+ parent_chat_id,
1017
+ )
1018
+ if not can_inspect:
1019
+ return {"access": "guideOnly", "scope_id": scope_chat_id, "reply_threads": "auto"}
1020
+
1021
+ source = self.build_source(
1022
+ chat_id=chat_id,
1023
+ chat_name=self._chat_title_from_info(info) or chat_id,
1024
+ chat_type=chat_type,
1025
+ user_id=actor_id,
1026
+ thread_id=chat_id if is_reply_thread else None,
1027
+ parent_chat_id=parent_chat_id,
1028
+ )
1029
+ runner = self._bot_settings_runner()
1030
+ can_modify = self._actor_authorized(chat_type, actor_id)
1031
+ context: Dict[str, Any] = {
1032
+ "access": "full" if can_modify else "readOnly",
1033
+ "scope_id": scope_chat_id,
1034
+ "chat_id": chat_id,
1035
+ "actor_id": actor_id,
1036
+ "chat_type": chat_type,
1037
+ "is_reply_thread": is_reply_thread,
1038
+ "following": self._chat_follow_mode_following(info),
1039
+ "reply_threads": self._reply_thread_mode_for_chat(scope_chat_id),
1040
+ "can_set_default_model": can_modify,
1041
+ "source": source,
1042
+ "runner": runner,
1043
+ "model_options": [],
1044
+ "model_map": {},
1045
+ "reasoning_options": [],
1046
+ }
1047
+ if runner is None:
1048
+ return context
1049
+
1050
+ try:
1051
+ from gateway.run import _load_gateway_config
1052
+ from hermes_cli.model_switch import list_picker_providers
1053
+
1054
+ cfg = _load_gateway_config() or {}
1055
+ model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
1056
+ if not isinstance(model_cfg, dict):
1057
+ model_cfg = {"default": str(model_cfg or "")}
1058
+ default_model = str(model_cfg.get("default") or model_cfg.get("model") or "").strip()
1059
+ default_provider = str(model_cfg.get("provider") or "openrouter").strip()
1060
+ session_key = runner._session_key_for_source(source)
1061
+ override = (getattr(runner, "_session_model_overrides", {}) or {}).get(session_key) or {}
1062
+ current_model = str(override.get("model") or default_model).strip()
1063
+ current_provider = str(override.get("provider") or default_provider).strip()
1064
+ providers = None
1065
+ if model_catalog is None:
1066
+ cached_catalog = self._bot_settings_model_catalog_cache
1067
+ if cached_catalog and time.monotonic() - cached_catalog[0] < _BOT_SETTINGS_MODEL_CATALOG_CACHE_SECONDS:
1068
+ providers = cached_catalog[1]
1069
+ logger.info("[inline] AGENT_SETTINGS_TRACE phase=model_catalog cache=hit")
1070
+ else:
1071
+ catalog_started = time.monotonic()
1072
+ providers = await asyncio.wait_for(
1073
+ asyncio.to_thread(
1074
+ list_picker_providers,
1075
+ current_provider=current_provider,
1076
+ current_base_url=str(override.get("base_url") or model_cfg.get("base_url") or ""),
1077
+ current_model=current_model,
1078
+ user_providers=cfg.get("providers") if isinstance(cfg, dict) else None,
1079
+ custom_providers=cfg.get("custom_providers") if isinstance(cfg, dict) else None,
1080
+ max_models=100,
1081
+ include_moa=True,
1082
+ excluded_providers=(cfg.get("model_catalog") or {}).get("excluded_providers")
1083
+ if isinstance(cfg.get("model_catalog"), dict) else None,
1084
+ ),
1085
+ timeout=5.0,
1086
+ )
1087
+ self._bot_settings_model_catalog_cache = (time.monotonic(), providers)
1088
+ logger.info(
1089
+ "[inline] AGENT_SETTINGS_TRACE phase=model_catalog cache=miss elapsed_ms=%d",
1090
+ int((time.monotonic() - catalog_started) * 1000),
1091
+ )
1092
+ model_options: List[Dict[str, str]] = []
1093
+ model_map: Dict[str, tuple[str, str]] = {}
1094
+
1095
+ def add_model(provider: str, model: str) -> Optional[str]:
1096
+ provider = str(provider or "").strip()
1097
+ model = str(model or "").strip()
1098
+ if not provider or not model:
1099
+ return None
1100
+ value = f"{provider}\t{model}"
1101
+ if value not in model_map and len(model_options) < 100:
1102
+ label = model if model.startswith(f"{provider}/") else f"{provider}/{model}"
1103
+ model_map[value] = (provider, model)
1104
+ model_options.append({"value": value, "label": label})
1105
+ return value
1106
+
1107
+ current_value = add_model(current_provider, current_model)
1108
+ if providers is not None:
1109
+ for provider in providers:
1110
+ provider_slug = str(provider.get("slug") or "").strip()
1111
+ if not provider_slug:
1112
+ continue
1113
+ for model in provider.get("models") or []:
1114
+ add_model(provider_slug, str(model).strip())
1115
+ else:
1116
+ previous_map = model_catalog.get("model_map") if isinstance(model_catalog, dict) else None
1117
+ previous_options = model_catalog.get("model_options") if isinstance(model_catalog, dict) else None
1118
+ if isinstance(previous_map, dict) and isinstance(previous_options, list):
1119
+ for option in previous_options:
1120
+ if not isinstance(option, dict):
1121
+ continue
1122
+ selected = previous_map.get(option.get("value"))
1123
+ if isinstance(selected, (list, tuple)) and len(selected) == 2:
1124
+ add_model(str(selected[0]), str(selected[1]))
1125
+ default_value = add_model(default_provider, default_model)
1126
+ context.update({"session_key": session_key})
1127
+ if current_value:
1128
+ context.update({
1129
+ "current_model": current_value,
1130
+ "default_model": default_value,
1131
+ "model_options": model_options,
1132
+ "model_map": model_map,
1133
+ })
1134
+
1135
+ reasoning = runner._resolve_session_reasoning_config(
1136
+ source=source,
1137
+ session_key=session_key,
1138
+ model=current_model,
1139
+ )
1140
+ if reasoning is None:
1141
+ current_reasoning = "medium"
1142
+ elif reasoning.get("enabled") is False:
1143
+ current_reasoning = "none"
1144
+ else:
1145
+ current_reasoning = str(reasoning.get("effort") or "medium")
1146
+ from hermes_constants import VALID_REASONING_EFFORTS
1147
+ reasoning_values = ["none", *[str(value) for value in VALID_REASONING_EFFORTS]]
1148
+ if current_reasoning not in reasoning_values:
1149
+ reasoning_values.insert(0, current_reasoning)
1150
+ context.update({
1151
+ "reasoning": current_reasoning,
1152
+ "reasoning_options": [
1153
+ {"value": value, "label": value.capitalize()} for value in dict.fromkeys(reasoning_values)
1154
+ ],
1155
+ })
1156
+ except Exception as exc:
1157
+ logger.debug("[inline] Hermes runtime settings unavailable: %s", exc)
1158
+ return context
1159
+
1160
+ @staticmethod
1161
+ def _bot_settings_revision(context: Dict[str, Any]) -> str:
1162
+ visible = {
1163
+ key: value for key, value in context.items()
1164
+ if key not in {"runner", "source", "model_map"}
1165
+ }
1166
+ encoded = json.dumps(visible, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
1167
+ return f"hermes-v1-{hashlib.sha256(encoded).hexdigest()[:12]}"
1168
+
1169
+ def _bot_settings_document(self, context: Dict[str, Any]) -> Dict[str, Any]:
1170
+ if context.get("access") == "guideOnly":
1171
+ unavailable_text = (
1172
+ "Hermes could not verify this chat. Try again when the bot is reachable."
1173
+ if context.get("unavailable_reason") == "chat_metadata"
1174
+ else "This chat is not allowed by Hermes' access policy."
1175
+ )
1176
+ return {
1177
+ "version": _INLINE_BOT_SETTINGS_VERSION,
1178
+ "revision": self._bot_settings_revision(context),
1179
+ "sections": [{
1180
+ "id": "access",
1181
+ "items": [{
1182
+ "id": "access-guide",
1183
+ "label": "Hermes unavailable",
1184
+ "disabled": False,
1185
+ "control": {"oneofKind": "info", "info": {
1186
+ "text": unavailable_text,
1187
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
1188
+ }},
1189
+ }],
1190
+ }],
1191
+ }
1192
+
1193
+ disabled = context.get("access") != "full"
1194
+ disabled_reason = "Only an authorized Hermes controller can change this."
1195
+ common = {"disabled": disabled, **({"disabledReason": disabled_reason} if disabled else {})}
1196
+ runtime_items: List[Dict[str, Any]] = []
1197
+ model_options = context.get("model_options") or []
1198
+ current_model = context.get("current_model")
1199
+ if current_model and model_options:
1200
+ runtime_items.append({
1201
+ "id": "model", "label": "Model", "description": "This session.", **common,
1202
+ "control": {"oneofKind": "select", "select": {
1203
+ "value": current_model,
1204
+ "options": [{**option, "disabled": False} for option in model_options],
1205
+ }},
1206
+ })
1207
+ if context.get("can_set_default_model") and current_model != context.get("default_model"):
1208
+ runtime_items.append({
1209
+ "id": "model-default", "label": "Use as default", "description": "For new sessions.", **common,
1210
+ "control": {"oneofKind": "button", "button": {}},
1211
+ })
1212
+ reasoning_options = context.get("reasoning_options") or []
1213
+ if context.get("reasoning") and reasoning_options:
1214
+ runtime_items.append({
1215
+ "id": "reasoning", "label": "Reasoning", **common,
1216
+ "control": {"oneofKind": "select", "select": {
1217
+ "value": context["reasoning"],
1218
+ "options": [{**option, "disabled": False} for option in reasoning_options],
1219
+ }},
1220
+ })
1221
+ if not runtime_items:
1222
+ runtime_items.append({
1223
+ "id": "runtime-unavailable", "label": "Runtime", "disabled": False,
1224
+ "control": {"oneofKind": "info", "info": {
1225
+ "text": "Runtime options are not available for this session.",
1226
+ "tone": _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
1227
+ }},
1228
+ })
1229
+
1230
+ sections: List[Dict[str, Any]] = [
1231
+ {"id": "runtime", "items": runtime_items},
1232
+ {"id": "attention", "items": [{
1233
+ "id": "following", "label": "Following", "description": "Wake on eligible activity.", **common,
1234
+ "control": {"oneofKind": "toggle", "toggle": {"value": bool(context.get("following"))}},
1235
+ }]},
1236
+ {"id": "replies", "items": [{
1237
+ "id": "reply-threads", "label": "Reply in threads", **common,
1238
+ "control": {"oneofKind": "select", "select": {
1239
+ "value": context.get("reply_threads") or "auto",
1240
+ "options": [
1241
+ {"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
1242
+ {"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
1243
+ {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1244
+ ],
1245
+ }},
1246
+ }]},
1247
+ ]
1248
+ if disabled:
1249
+ sections.append({"id": "access", "items": [{
1250
+ "id": "read-only", "label": "Read-only", "disabled": False,
1251
+ "control": {"oneofKind": "info", "info": {
1252
+ "text": "Access and reply mode inherit from the parent chat. Model, reasoning, and following stay with this thread."
1253
+ if context.get("is_reply_thread") else "A Hermes owner controls who can make changes.",
1254
+ "tone": _INLINE_BOT_SETTINGS_TONE_WARNING,
1255
+ }},
1256
+ }]})
1257
+ return {
1258
+ "version": _INLINE_BOT_SETTINGS_VERSION,
1259
+ "revision": self._bot_settings_revision(context),
1260
+ "sections": sections,
1261
+ }
1262
+
1263
+ @staticmethod
1264
+ def _bot_settings_problem(code: int, message: str, document: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1265
+ problem = {"code": code, "message": message}
1266
+ if document is not None:
1267
+ problem["currentDocument"] = document
1268
+ return {"result": {"oneofKind": "problem", "problem": problem}}
1269
+
1270
+ @staticmethod
1271
+ def _bot_settings_result_kind(response: Dict[str, Any]) -> str:
1272
+ result = response.get("result") if isinstance(response, dict) else None
1273
+ if not isinstance(result, dict):
1274
+ return "invalid"
1275
+ kind = str(result.get("oneofKind") or "invalid")
1276
+ if kind == "problem" and isinstance(result.get("problem"), dict):
1277
+ return f"problem:{result['problem'].get('code', 'unknown')}"
1278
+ return kind
1279
+
1280
+ async def _answer_bot_settings(self, request_id: str, response: Dict[str, Any]) -> bool:
1281
+ try:
1282
+ await self._sidecar_call("/answer-bot-settings", {
1283
+ "requestId": request_id,
1284
+ "response": response,
1285
+ })
1286
+ return True
1287
+ except asyncio.CancelledError:
1288
+ raise
1289
+ except Exception as exc:
1290
+ logger.warning("[inline] agent settings answer expired: %s", exc)
1291
+ return False
1292
+
1293
+ async def _handle_bot_settings_request(self, event: Dict[str, Any]) -> None:
1294
+ request_id = str(event.get("requestId") or "").strip()
1295
+ if not request_id:
1296
+ return
1297
+ started_at = time.monotonic()
1298
+ logger.info(
1299
+ "[inline] AGENT_SETTINGS_TRACE request=%s phase=request_start chat=%s",
1300
+ request_id,
1301
+ self._chat_key(event.get("chatId")),
1302
+ )
1303
+ try:
1304
+ if int(event.get("version") or 0) != _INLINE_BOT_SETTINGS_VERSION:
1305
+ response = self._bot_settings_problem(
1306
+ _INLINE_BOT_SETTINGS_UNAVAILABLE,
1307
+ "This Hermes version supports agent settings V1 only.",
1308
+ )
1309
+ else:
1310
+ context = await self._bot_settings_context(event)
1311
+ response = {"result": {"oneofKind": "document", "document": self._bot_settings_document(context)}}
1312
+ except Exception as exc:
1313
+ logger.warning("[inline] failed to load Hermes agent settings: %s", exc)
1314
+ response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_FAILED, "Hermes could not load settings.")
1315
+ answered = await self._answer_bot_settings(request_id, response)
1316
+ logger.info(
1317
+ "[inline] AGENT_SETTINGS_TRACE request=%s phase=request_answer result=%s answered=%d elapsed_ms=%d",
1318
+ request_id,
1319
+ self._bot_settings_result_kind(response),
1320
+ 1 if answered else 0,
1321
+ int((time.monotonic() - started_at) * 1000),
1322
+ )
1323
+
1324
+ async def _apply_bot_setting(self, event: Dict[str, Any], context: Dict[str, Any]) -> None:
1325
+ item_id = str(event.get("itemId") or "")
1326
+ value = event.get("value") if isinstance(event.get("value"), dict) else {}
1327
+ value_body = value.get("value") if isinstance(value.get("value"), dict) else {}
1328
+ runner = context.get("runner")
1329
+ source = context.get("source")
1330
+ if item_id == "following" and value_body.get("oneofKind") == "boolValue":
1331
+ enabled = bool(value_body.get("boolValue"))
1332
+ target = (
1333
+ {"userId": context["actor_id"]}
1334
+ if context.get("chat_type") == "dm"
1335
+ else {"chatId": context["chat_id"]}
1336
+ )
1337
+ await self._sidecar_call("/follow-mode", {
1338
+ "target": target,
1339
+ "mode": "following" if enabled else "unfollowed",
1340
+ })
1341
+ self._invalidate_chat_info(context["chat_id"])
1342
+ return
1343
+ if item_id == "reply-threads" and value_body.get("oneofKind") == "stringValue":
1344
+ mode = str(value_body.get("stringValue") or "")
1345
+ if mode not in {"auto", "on", "off"}:
1346
+ raise ValueError("invalid reply mode")
1347
+ self._set_reply_threads_for_chat(context["scope_id"], mode)
1348
+ return
1349
+ if runner is None or source is None:
1350
+ raise ValueError("runtime unavailable")
1351
+ if item_id in {"model", "model-default"}:
1352
+ if item_id == "model-default" and not context.get("can_set_default_model"):
1353
+ raise ValueError("default model unavailable")
1354
+ selection = context.get("current_model") if item_id == "model-default" else value_body.get("stringValue")
1355
+ selected = (context.get("model_map") or {}).get(selection)
1356
+ if not selected:
1357
+ raise ValueError("model unavailable")
1358
+ provider, model = selected
1359
+ command = (
1360
+ f"/model {shlex.quote(model)} --provider {shlex.quote(provider)} "
1361
+ f"{'--global' if item_id == 'model-default' else '--session'}"
1362
+ )
1363
+ result = await runner._handle_model_command(MessageEvent(
1364
+ text=command,
1365
+ message_type=MessageType.TEXT,
1366
+ source=source,
1367
+ raw_message=event,
1368
+ ))
1369
+ if result and re.search(r"(?:error|confirm|failed|could not)", str(result), re.IGNORECASE):
1370
+ raise RuntimeError(str(result))
1371
+ return
1372
+ if item_id == "reasoning" and value_body.get("oneofKind") == "stringValue":
1373
+ selected = str(value_body.get("stringValue") or "")
1374
+ if selected not in {option.get("value") for option in context.get("reasoning_options") or []}:
1375
+ raise ValueError("reasoning unavailable")
1376
+ runner._apply_reasoning_selection(context["session_key"], "inline", selected)
1377
+ return
1378
+ raise ValueError("setting unavailable")
1379
+
1380
+ async def _handle_bot_settings_item(self, event: Dict[str, Any]) -> None:
1381
+ lock_key = self._chat_key(event.get("chatId")) or "unknown"
1382
+ lock = self._bot_settings_locks.setdefault(lock_key, asyncio.Lock())
1383
+ self._bot_settings_lock_users[lock_key] = self._bot_settings_lock_users.get(lock_key, 0) + 1
1384
+ try:
1385
+ async with lock:
1386
+ await self._handle_bot_settings_item_serialized(event)
1387
+ finally:
1388
+ remaining = self._bot_settings_lock_users.get(lock_key, 1) - 1
1389
+ if remaining <= 0:
1390
+ self._bot_settings_lock_users.pop(lock_key, None)
1391
+ if self._bot_settings_locks.get(lock_key) is lock:
1392
+ self._bot_settings_locks.pop(lock_key, None)
1393
+ else:
1394
+ self._bot_settings_lock_users[lock_key] = remaining
1395
+
1396
+ async def _handle_bot_settings_item_serialized(self, event: Dict[str, Any]) -> None:
1397
+ request_id = str(event.get("requestId") or "").strip()
1398
+ if not request_id:
1399
+ return
1400
+ started_at = time.monotonic()
1401
+ item_id = str(event.get("itemId") or "")
1402
+ logger.info(
1403
+ "[inline] AGENT_SETTINGS_TRACE request=%s phase=mutation_start chat=%s item=%s",
1404
+ request_id,
1405
+ self._chat_key(event.get("chatId")),
1406
+ item_id,
1407
+ )
1408
+ try:
1409
+ context = await self._bot_settings_context(event)
1410
+ document = self._bot_settings_document(context)
1411
+ if str(event.get("documentRevision") or "") != document["revision"]:
1412
+ response = self._bot_settings_problem(
1413
+ _INLINE_BOT_SETTINGS_STALE,
1414
+ "Settings changed. Try again.",
1415
+ document,
1416
+ )
1417
+ elif context.get("access") != "full":
1418
+ response = self._bot_settings_problem(
1419
+ _INLINE_BOT_SETTINGS_FAILED,
1420
+ "You do not have access to change this.",
1421
+ document,
1422
+ )
1423
+ else:
1424
+ await self._apply_bot_setting(event, context)
1425
+ next_context = await self._bot_settings_context(event, model_catalog=context)
1426
+ response = {"result": {"oneofKind": "document", "document": self._bot_settings_document(next_context)}}
1427
+ except ValueError as exc:
1428
+ response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_INVALID_VALUE, str(exc).capitalize())
1429
+ except Exception as exc:
1430
+ logger.warning("[inline] failed to update Hermes agent settings: %s", exc)
1431
+ response = self._bot_settings_problem(_INLINE_BOT_SETTINGS_FAILED, "Hermes could not update this setting.")
1432
+ answered = await self._answer_bot_settings(request_id, response)
1433
+ logger.info(
1434
+ "[inline] AGENT_SETTINGS_TRACE request=%s phase=mutation_answer item=%s result=%s answered=%d elapsed_ms=%d",
1435
+ request_id,
1436
+ item_id,
1437
+ self._bot_settings_result_kind(response),
1438
+ 1 if answered else 0,
1439
+ int((time.monotonic() - started_at) * 1000),
1440
+ )
1441
+
934
1442
  @staticmethod
935
1443
  def _has_reply_thread_intent(text: str) -> bool:
936
1444
  normalized = re.sub(r"\s+", " ", str(text or "").strip())
@@ -1176,15 +1684,9 @@ class InlineAdapter(BasePlatformAdapter):
1176
1684
 
1177
1685
  self._chat_info_cache.pop(self._chat_key(chat_id), None)
1178
1686
  if command == "follow":
1179
- body = (
1180
- "Now explicitly following this Inline chat or thread. "
1181
- "Eligible activity can wake Hermes without an @mention."
1182
- )
1687
+ body = "Following this chat—eligible messages can wake Hermes without an @mention."
1183
1688
  else:
1184
- body = (
1185
- "Explicitly unfollowed this Inline chat or thread. Automatic follow heuristics "
1186
- "will not turn following back on; mentions and replies can still wake Hermes."
1187
- )
1689
+ body = "Unfollowed this chat—mentions and replies can still wake Hermes."
1188
1690
  await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
1189
1691
  return True
1190
1692
 
@@ -1241,6 +1743,11 @@ class InlineAdapter(BasePlatformAdapter):
1241
1743
  except Exception:
1242
1744
  pass
1243
1745
  self._inbound_task = None
1746
+ for task in list(self._bot_settings_tasks):
1747
+ task.cancel()
1748
+ if self._bot_settings_tasks:
1749
+ await asyncio.gather(*self._bot_settings_tasks, return_exceptions=True)
1750
+ self._bot_settings_tasks.clear()
1244
1751
  await self._stop_sidecar()
1245
1752
  if self._http_client is not None:
1246
1753
  await self._http_client.aclose()
@@ -1287,6 +1794,7 @@ class InlineAdapter(BasePlatformAdapter):
1287
1794
  result = data.get("result") or {}
1288
1795
  if result.get("connected"):
1289
1796
  self._me_id = str(result.get("meId") or "") or None
1797
+ self._me_username = _normalize_inline_username(result.get("meUsername"))
1290
1798
  return
1291
1799
  if result.get("connectError"):
1292
1800
  last_error = RuntimeError(str(result.get("connectError")))
@@ -1465,6 +1973,21 @@ class InlineAdapter(BasePlatformAdapter):
1465
1973
  def _is_bot_commands_too_much(error: Exception) -> bool:
1466
1974
  return bool(re.search(r"\bBOT_COMMANDS_TOO_MUCH\b", str(error), re.IGNORECASE))
1467
1975
 
1976
+ def _schedule_bot_settings_task(self, operation: Any) -> None:
1977
+ task = asyncio.get_event_loop().create_task(operation)
1978
+ self._bot_settings_tasks.add(task)
1979
+
1980
+ def finished(completed: asyncio.Task) -> None:
1981
+ self._bot_settings_tasks.discard(completed)
1982
+ if completed.cancelled():
1983
+ return
1984
+ try:
1985
+ completed.result()
1986
+ except Exception as exc:
1987
+ logger.warning("[inline] agent settings task failed: %s", exc)
1988
+
1989
+ task.add_done_callback(finished)
1990
+
1468
1991
  async def _inbound_loop(self) -> None:
1469
1992
  if self._http_client is None:
1470
1993
  return
@@ -1497,6 +2020,8 @@ class InlineAdapter(BasePlatformAdapter):
1497
2020
  event = json.loads(line)
1498
2021
  except json.JSONDecodeError:
1499
2022
  return
2023
+ self._me_id = str(event.get("meId") or self._me_id or "") or None
2024
+ self._me_username = _normalize_inline_username(event.get("meUsername") or self._me_username)
1500
2025
  # Chat snapshots include mutable dialog and routing fields such as
1501
2026
  # followMode and lastMsgId. Invalidating here intentionally makes the
1502
2027
  # next group-message dispatch perform an extra GET_CHAT; correctness
@@ -1504,6 +2029,12 @@ class InlineAdapter(BasePlatformAdapter):
1504
2029
  # owns a coherent client-state cache.
1505
2030
  self._invalidate_chat_info(event.get("chatId"))
1506
2031
  kind = event.get("kind")
2032
+ if kind == "bot.chatSettings.request":
2033
+ self._schedule_bot_settings_task(self._handle_bot_settings_request(event))
2034
+ return
2035
+ if kind == "bot.chatSettings.item.invoke":
2036
+ self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
2037
+ return
1507
2038
  if kind == "message.action.invoke":
1508
2039
  if await self._handle_action(event):
1509
2040
  return
@@ -1543,7 +2074,19 @@ class InlineAdapter(BasePlatformAdapter):
1543
2074
  chat_id = str(event.get("chatId") or msg.get("chatId") or "")
1544
2075
  if not msg_id or not chat_id:
1545
2076
  return
1546
- dedup_key = f"edit:{chat_id}:{msg_id}:{msg.get('rev') or event.get('seq') or ''}" if edit else f"{chat_id}:{msg_id}"
2077
+ if edit:
2078
+ dedup_key = f"edit:{chat_id}:{msg_id}:{msg.get('rev') or event.get('seq') or ''}"
2079
+ else:
2080
+ event_seq = str(event.get("seq") or "").strip()
2081
+ message_date = str(msg.get("date") or event.get("date") or "").strip()
2082
+ # Update sequence identifies the delivery even when an older server
2083
+ # reuses a deleted message ID. Date keeps sequence-less events safer.
2084
+ if event_seq:
2085
+ dedup_key = f"new:{chat_id}:seq:{event_seq}"
2086
+ elif message_date:
2087
+ dedup_key = f"new:{chat_id}:msg:{msg_id}:date:{message_date}"
2088
+ else:
2089
+ dedup_key = f"new:{chat_id}:msg:{msg_id}"
1547
2090
  if self._is_duplicate(dedup_key):
1548
2091
  return
1549
2092
  from_id = str(msg.get("fromId") or "")
@@ -1552,7 +2095,8 @@ class InlineAdapter(BasePlatformAdapter):
1552
2095
  sender_profile = _inline_sender_profile(event, msg)
1553
2096
  sender_name, sender_first_name, sender_username = _inline_sender_identity(sender_profile)
1554
2097
 
1555
- text = str(msg.get("message") or "").strip()
2098
+ raw_message_text = str(msg.get("message") or "")
2099
+ text = raw_message_text.strip()
1556
2100
  media_text, media_urls, media_types, message_type = await self._normalize_media(msg)
1557
2101
  if media_text:
1558
2102
  text = f"{text}\n{media_text}".strip() if text else media_text
@@ -1581,6 +2125,12 @@ class InlineAdapter(BasePlatformAdapter):
1581
2125
  return
1582
2126
  if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
1583
2127
  return
2128
+ text, has_command_target, command_addressed_to_me = _resolve_inline_targeted_command(
2129
+ text,
2130
+ self._me_username,
2131
+ )
2132
+ if has_command_target and not command_addressed_to_me:
2133
+ return
1584
2134
  if await self._handle_thread_command(
1585
2135
  chat_id=chat_id,
1586
2136
  msg_id=msg_id,
@@ -1618,7 +2168,7 @@ class InlineAdapter(BasePlatformAdapter):
1618
2168
  and not self._free_response_chat(chat_id, thread_id, parent_chat_id)
1619
2169
  )
1620
2170
  if mention_gate_active:
1621
- mentioned = bool(msg.get("mentioned")) or self._matches_mention(text)
2171
+ mentioned = command_addressed_to_me or bool(msg.get("mentioned")) or self._matches_mention(text)
1622
2172
  reply_wakes_thread = reply_to_is_own and not self._strict_mention
1623
2173
  # Freshness only controls whether the server auto-follows a thread.
1624
2174
  # Once this dialog is FOLLOWING, it remains relevant until unfollowed.
@@ -1629,6 +2179,16 @@ class InlineAdapter(BasePlatformAdapter):
1629
2179
  if not mentioned and not reply_wakes_thread and not follow_mode_wakes_thread:
1630
2180
  self._remember_observed_context(chat_id, msg, text)
1631
2181
  return
2182
+ # A leading explicit address to another person overwhelmingly means
2183
+ # the turn is for them. It overrides only inferred follow/reply
2184
+ # attention; an explicit address to this bot still wins.
2185
+ if (
2186
+ not mentioned
2187
+ and (reply_wakes_thread or follow_mode_wakes_thread)
2188
+ and self._starts_with_other_user_mention(msg, raw_message_text)
2189
+ ):
2190
+ self._remember_observed_context(chat_id, msg, text)
2191
+ return
1632
2192
  if mentioned:
1633
2193
  text = self._clean_mention(text)
1634
2194
  if edit:
@@ -2133,6 +2693,29 @@ class InlineAdapter(BasePlatformAdapter):
2133
2693
  text = str(value).strip()
2134
2694
  return text or None
2135
2695
 
2696
+ def _starts_with_other_user_mention(self, msg: Dict[str, Any], message_text: str) -> bool:
2697
+ if not self._me_id:
2698
+ return False
2699
+ candidates: List[tuple[int, int, Dict[str, Any]]] = []
2700
+ for index, entity in enumerate(self._message_entities(msg)):
2701
+ offset = _to_int(entity.get("offset"))
2702
+ if offset is not None and offset >= 0:
2703
+ candidates.append((offset, index, entity))
2704
+ if not candidates:
2705
+ return False
2706
+ offset, _, first_entity = min(candidates, key=lambda item: (item[0], item[1]))
2707
+ if self._entity_kind(first_entity) != "mention":
2708
+ return False
2709
+ payload = self._entity_payload(first_entity, "mention")
2710
+ mentioned_user_id = self._entity_id(payload, "userId")
2711
+ if not mentioned_user_id or mentioned_user_id == self._me_id:
2712
+ return False
2713
+ try:
2714
+ utf16_prefix = message_text.encode("utf-16-le")[: offset * 2].decode("utf-16-le")
2715
+ except UnicodeDecodeError:
2716
+ return False
2717
+ return not utf16_prefix.strip()
2718
+
2136
2719
  def _format_entity_summary(self, entity: Dict[str, Any], message_text: str) -> Optional[str]:
2137
2720
  kind = self._entity_kind(entity)
2138
2721
  text = self._entity_slice(message_text, entity)
@@ -2715,8 +3298,6 @@ class InlineAdapter(BasePlatformAdapter):
2715
3298
 
2716
3299
  async def _handle_action(self, event: Dict[str, Any]) -> bool:
2717
3300
  action_id = str(event.get("actionId") or "")
2718
- chat_id = str(event.get("chatId") or "")
2719
- interaction_id = str(event.get("interactionId") or "")
2720
3301
  if self._is_model_picker_action(action_id):
2721
3302
  if not await self._action_allowed(event):
2722
3303
  return True
@@ -2724,15 +3305,15 @@ class InlineAdapter(BasePlatformAdapter):
2724
3305
  if action_id.startswith("cl:"):
2725
3306
  if not await self._action_allowed(event):
2726
3307
  return True
2727
- return await self._handle_clarify_action(action_id, chat_id, interaction_id)
3308
+ return await self._handle_clarify_action(event)
2728
3309
  if action_id.startswith("appr:"):
2729
3310
  if not await self._action_allowed(event):
2730
3311
  return True
2731
- return await self._handle_approval_action(action_id, chat_id, interaction_id)
3312
+ return await self._handle_approval_action(event)
2732
3313
  if action_id.startswith("sc:"):
2733
3314
  if not await self._action_allowed(event):
2734
3315
  return True
2735
- return await self._handle_slash_action(action_id, chat_id, interaction_id)
3316
+ return await self._handle_slash_action(event)
2736
3317
  if action_id.startswith(_INLINE_THREADS_ACTION_PREFIX):
2737
3318
  return await self._handle_thread_action(event)
2738
3319
  return False
@@ -2778,14 +3359,16 @@ class InlineAdapter(BasePlatformAdapter):
2778
3359
  return None
2779
3360
  return self._chat_type_from_message(msg)
2780
3361
 
2781
- async def _handle_clarify_action(self, action_id: str, chat_id: str, interaction_id: str) -> bool:
3362
+ async def _handle_clarify_action(self, event: Dict[str, Any]) -> bool:
3363
+ action_id = str(event.get("actionId") or "")
2782
3364
  parts = action_id.split(":", 2)
2783
3365
  if len(parts) != 3:
2784
3366
  return False
2785
3367
  _, clarify_id, choice = parts
2786
3368
  session_key = self._clarify_sessions.get(clarify_id)
2787
3369
  if not session_key:
2788
- return False
3370
+ await self._finish_action(event, "Prompt expired", "Clarification expired.")
3371
+ return True
2789
3372
  if choice == "other":
2790
3373
  try:
2791
3374
  from tools.clarify_gateway import mark_awaiting_text
@@ -2793,10 +3376,9 @@ class InlineAdapter(BasePlatformAdapter):
2793
3376
  if not marked:
2794
3377
  self._clarify_sessions.pop(clarify_id, None)
2795
3378
  self._clarify_choices.pop(clarify_id, None)
2796
- await self._answer_action(interaction_id, "Prompt expired")
3379
+ await self._finish_action(event, "Prompt expired", "Clarification expired.")
2797
3380
  return True
2798
- await self._answer_action(interaction_id, "Type your answer")
2799
- await self.send(chat_id, "Type your answer:")
3381
+ await self._finish_action(event, "Type your answer", "Type your answer:")
2800
3382
  return True
2801
3383
  except Exception:
2802
3384
  logger.exception("[inline] clarify other failed")
@@ -2810,59 +3392,76 @@ class InlineAdapter(BasePlatformAdapter):
2810
3392
  if resolved:
2811
3393
  self._clarify_sessions.pop(clarify_id, None)
2812
3394
  self._clarify_choices.pop(clarify_id, None)
2813
- await self._answer_action(interaction_id, "Answer recorded")
3395
+ await self._finish_action(event, "Answer recorded", "Answer recorded.")
2814
3396
  else:
2815
3397
  self._clarify_sessions.pop(clarify_id, None)
2816
3398
  self._clarify_choices.pop(clarify_id, None)
2817
- await self._answer_action(interaction_id, "Prompt expired")
3399
+ await self._finish_action(event, "Prompt expired", "Clarification expired.")
2818
3400
  return True
2819
3401
  except Exception:
2820
3402
  logger.exception("[inline] clarify action failed")
2821
3403
  return True
2822
3404
 
2823
- async def _handle_approval_action(self, action_id: str, chat_id: str, interaction_id: str) -> bool:
3405
+ async def _handle_approval_action(self, event: Dict[str, Any]) -> bool:
3406
+ action_id = str(event.get("actionId") or "")
2824
3407
  parts = action_id.split(":", 2)
2825
3408
  if len(parts) != 3:
2826
3409
  return False
2827
3410
  _, approval_id, choice = parts
3411
+ choice = "once" if choice == "approve" else choice
2828
3412
  session_key = self._approval_sessions.get(approval_id)
2829
- if not session_key or choice not in {"approve", "deny"}:
3413
+ if choice not in {"once", "session", "always", "deny"}:
2830
3414
  return False
3415
+ if not session_key:
3416
+ await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
3417
+ return True
2831
3418
  try:
2832
3419
  from tools.approval import resolve_gateway_approval
2833
3420
  count = resolve_gateway_approval(session_key, choice)
2834
3421
  self._approval_sessions.pop(approval_id, None)
2835
3422
  if not count:
2836
- await self._answer_action(interaction_id, "Approval expired")
3423
+ await self._finish_action(event, "Approval expired", "Approval expired; the command was not run.")
2837
3424
  return True
2838
- label = "Approved" if choice == "approve" else "Denied"
2839
- await self._answer_action(interaction_id, label)
2840
- await self.send(chat_id, f"{label}.")
3425
+ labels = {
3426
+ "once": ("Approved once", "Approved once."),
3427
+ "session": ("Approved for session", "Approved for this session."),
3428
+ "always": ("Always allowed", "Always allowed."),
3429
+ "deny": ("Denied", "Denied."),
3430
+ }
3431
+ toast, text = labels[choice]
3432
+ await self._finish_action(event, toast, text)
2841
3433
  return True
2842
3434
  except Exception:
2843
3435
  logger.exception("[inline] approval action failed")
2844
3436
  return True
2845
3437
 
2846
- async def _handle_slash_action(self, action_id: str, chat_id: str, interaction_id: str) -> bool:
3438
+ async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
3439
+ action_id = str(event.get("actionId") or "")
2847
3440
  parts = action_id.split(":", 2)
2848
3441
  if len(parts) != 3:
2849
3442
  return False
2850
3443
  _, choice, confirm_id = parts
2851
3444
  session_key = self._slash_sessions.get(confirm_id)
2852
- if not session_key or choice not in {"once", "always", "cancel"}:
3445
+ if choice not in {"once", "always", "cancel"}:
2853
3446
  return False
3447
+ if not session_key:
3448
+ await self._finish_action(event, "Prompt expired", "Confirmation expired.")
3449
+ return True
2854
3450
  try:
2855
3451
  from tools import slash_confirm as slash_confirm_mod
2856
3452
  result = await slash_confirm_mod.resolve(session_key, confirm_id, choice)
2857
3453
  self._slash_sessions.pop(confirm_id, None)
2858
- await self._answer_action(interaction_id, "Recorded")
2859
- if result:
2860
- await self.send(chat_id, result)
3454
+ text = str(result or ("Cancelled." if choice == "cancel" else "Recorded."))
3455
+ await self._finish_action(event, "Recorded", text)
2861
3456
  return True
2862
3457
  except Exception:
2863
3458
  logger.exception("[inline] slash confirm action failed")
2864
3459
  return True
2865
3460
 
3461
+ async def _finish_action(self, event: Dict[str, Any], toast: str, text: str) -> None:
3462
+ await self._answer_action(str(event.get("interactionId") or ""), toast)
3463
+ await self._edit_action_message(event, text, {"rows": []})
3464
+
2866
3465
  async def _handle_thread_action(self, event: Dict[str, Any]) -> bool:
2867
3466
  action_id = str(event.get("actionId") or "")
2868
3467
  chat_id = str(event.get("chatId") or "")
@@ -3381,19 +3980,36 @@ class InlineAdapter(BasePlatformAdapter):
3381
3980
  "actions": {"rows": [{"actions": actions}]},
3382
3981
  })
3383
3982
 
3384
- async def send_exec_approval(self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None) -> SendResult:
3983
+ async def send_exec_approval(
3984
+ self,
3985
+ chat_id: str,
3986
+ command: str,
3987
+ session_key: str,
3988
+ description: str = "dangerous command",
3989
+ metadata: Optional[Dict[str, Any]] = None,
3990
+ allow_permanent: bool = True,
3991
+ allow_session: bool = True,
3992
+ smart_denied: bool = False,
3993
+ ) -> SendResult:
3385
3994
  approval_id = secrets.token_hex(6)
3386
- self._remember(self._approval_sessions, approval_id, session_key)
3387
3995
  text = f"Command approval required\n\n```\n{command[:2000]}\n```\n\nReason: {description}"
3388
- return await self._send_sidecar("/send", {
3996
+ if smart_denied:
3997
+ text += "\n\nSmart DENY: owner override applies to this one operation only."
3998
+ actions = [self._action(f"appr:{approval_id}:once", "Allow Once")]
3999
+ if not smart_denied and allow_session:
4000
+ actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
4001
+ if allow_permanent:
4002
+ actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
4003
+ actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
4004
+ result = await self._send_sidecar("/send", {
3389
4005
  "target": self._target_for(chat_id, metadata),
3390
4006
  "text": text,
3391
4007
  "parseMarkdown": self._parse_markdown,
3392
- "actions": {"rows": [{"actions": [
3393
- {"id": f"appr:{approval_id}:approve", "text": "Approve", "callback": f"appr:{approval_id}:approve"},
3394
- {"id": f"appr:{approval_id}:deny", "text": "Deny", "callback": f"appr:{approval_id}:deny"},
3395
- ]}]},
4008
+ "actions": {"rows": self._action_rows(actions)},
3396
4009
  })
4010
+ if result.success:
4011
+ self._remember(self._approval_sessions, approval_id, session_key)
4012
+ return result
3397
4013
 
3398
4014
  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:
3399
4015
  self._remember(self._slash_sessions, confirm_id, session_key)