@inline-chat/hermes-agent-adapter 0.0.22 → 1.0.0-alpha.2

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.
@@ -55,11 +55,13 @@ from .message_actions import (
55
55
  parse_inline_agent_action_reply_target,
56
56
  resolve_inline_message_action_ownership,
57
57
  )
58
+ from .profile import bot_state_key, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
58
59
  from .telemetry import capture_plugin_error
59
60
 
60
61
  logger = logging.getLogger(__name__)
61
62
 
62
63
  _DEFAULT_SIDECAR_PORT = 8794
64
+ _ACTION_EDIT_RETRY_DELAY_SECONDS = 0.5
63
65
  _DEFAULT_SIDECAR_BIND = "127.0.0.1"
64
66
  _MAX_MESSAGE_LENGTH = 4000
65
67
  _MODEL_PAGE_SIZE = 8
@@ -106,7 +108,7 @@ _INLINE_COMMAND_DESCRIPTION_LIMIT = 256
106
108
  _INLINE_COMMAND_RETRY_RATIO = 0.8
107
109
  _INLINE_COMMAND_RE = re.compile(r"^[a-z0-9_]{1,32}$")
108
110
  _INLINE_THREADS_COMMAND_DESCRIPTION = "Configure Inline reply-thread routing"
109
- _INLINE_THREADS_COMMAND_ARGS = "[status|on|off|auto|reset]"
111
+ _INLINE_THREADS_COMMAND_ARGS = "[status|on|auto|reset]"
110
112
  _INLINE_FOLLOW_COMMAND_DESCRIPTION = "Explicitly follow this Inline chat or thread"
111
113
  _INLINE_UNFOLLOW_COMMAND_DESCRIPTION = "Explicitly unfollow this Inline chat or thread"
112
114
  _INLINE_SYNC_COMMAND_DESCRIPTION = "Resync Inline commands and skills"
@@ -119,26 +121,8 @@ _INLINE_MAINTENANCE_COMMAND_RE = re.compile(
119
121
  r"^/(inline[_-](?:sync|version))(?:@[A-Za-z0-9_]+)?(?:\s+(.*))?$",
120
122
  re.IGNORECASE,
121
123
  )
122
- _INLINE_REPLY_THREAD_NEGATION_RE = re.compile(
123
- r"\b(?:do\s+not|don't|dont|please\s+don't|please\s+dont|no\s+need\s+to)\s+"
124
- r"(?:create|start|open|make|use|move|take|reply|respond|answer|send|thread)\b[^.!?\n]*\bthread\b|"
125
- r"\b(?:reply|respond|answer|keep)\s+(?:here|in\s+the\s+main\s+chat|in\s+main\s+chat|"
126
- r"in\s+the\s+parent\s+chat|in\s+parent\s+chat)\b",
127
- re.IGNORECASE,
128
- )
129
- _INLINE_REPLY_THREAD_INTENT_RE = re.compile(
130
- r"\b(?:reply|respond|answer|send)\s+(?:in|inside|into|to)\s+(?:a\s+)?"
131
- r"(?:(?:new|child|reply)\s+)?thread\b|"
132
- r"\b(?:create|start|open|make|use)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
133
- r"\bkeep\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
134
- r"(?:in|inside|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
135
- r"\b(?:move|take)\s+(?:(?:this|it|the\s+answer|the\s+reply|the\s+response)\s+)?"
136
- r"(?:to|into)\s+(?:a\s+)?(?:(?:new|child|reply)\s+)?thread\b|"
137
- r"\bthread\s+(?:this|the\s+answer|the\s+reply|the\s+response)\b|"
138
- r"\b(?:threaded\s+(?:reply|response)|(?:reply|respond|answer)\s+threaded)\b",
139
- re.IGNORECASE,
140
- )
141
124
  _INLINE_SETTINGS_VERSION = 1
125
+ _TURN_THREADS_MAX = 512
142
126
  _INLINE_BOT_SETTINGS_VERSION = 1
143
127
  _INLINE_BOT_SETTINGS_UNAVAILABLE = 1
144
128
  _INLINE_BOT_SETTINGS_INVALID_VALUE = 2
@@ -174,11 +158,6 @@ _INLINE_ENTITY_TYPE_NAMES = {
174
158
  14: "group_mention",
175
159
  }
176
160
 
177
- _DEFAULT_MENTION_PATTERNS = [
178
- r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
179
- r"(?<![\w@])@?hermes\b[,:\-]?",
180
- r"(?<![\w@])@?inline\s+agent\b[,:\-]?",
181
- ]
182
161
  _ENV_REF_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
183
162
  _SIDECAR_RETRYABLE_KINDS = {"transient"}
184
163
  _INLINE_DISPLAY_DEFAULTS = {
@@ -241,15 +220,15 @@ def _install_inline_display_defaults() -> None:
241
220
 
242
221
 
243
222
  def _reply_thread_mode(value: Any, default: str = "auto") -> str:
223
+ """Auto answers in the chat and threads only when asked; On answers in a reply thread.
224
+ The old Off reads as Auto, which no longer threads on its own."""
244
225
  if value is None or str(value).strip() == "":
245
226
  return default
246
227
  text = str(value).strip().lower()
247
- if text in {"auto", "default", "reset", "config"}:
228
+ if text in {"auto", "default", "reset", "config", "0", "false", "no", "off", "never", "flat", "channel", "main"}:
248
229
  return "auto"
249
230
  if text in {"1", "true", "yes", "on", "always", "thread", "threads"}:
250
231
  return "on"
251
- if text in {"0", "false", "no", "off", "never", "flat", "channel", "main"}:
252
- return "off"
253
232
  logger.warning("[inline] unknown reply_threads value %r; using %s", value, default)
254
233
  return default
255
234
 
@@ -551,6 +530,27 @@ def _resolve_inline_targeted_command(text: str, bot_username: Optional[str]) ->
551
530
  return f"/{match.group(1)}{match.group(3) or ''}", True, True
552
531
 
553
532
 
533
+ def _session_chat_id(chat_id: str, chat_type: str, thread_id: Optional[str], parent_chat_id: Optional[str]) -> str:
534
+ """The chat a turn's native session is keyed on.
535
+
536
+ Inline reply threads are chats of their own, and follow-ups inside one are keyed on
537
+ the thread (``group:C:C``), as native Hermes keys a Discord thread. A group message
538
+ that opens or continues a thread from its parent chat is keyed the same way, so the
539
+ opener and its follow-ups share one conversation. A thread under the bot's DM is keyed
540
+ like a Slack DM thread (``dm:D:C``) from its opener on, for the DM's person.
541
+ """
542
+ if chat_type == "group" and thread_id and parent_chat_id == chat_id and thread_id != chat_id:
543
+ return str(thread_id)
544
+ if chat_type == "dm" and thread_id and parent_chat_id and thread_id == chat_id:
545
+ return str(parent_chat_id)
546
+ return chat_id
547
+
548
+
549
+ def _inline_command_name(text: str) -> str:
550
+ match = re.match(r"^\s*/([a-z0-9_][a-z0-9_-]*)", str(text or ""), re.IGNORECASE)
551
+ return match.group(1).lower() if match else ""
552
+
553
+
554
554
  def _normalize_inline_plugin_command_text(text: str) -> str:
555
555
  match = re.match(r"^/([a-z0-9_]+)(@[A-Za-z0-9_]+)?(\s.*)?$", str(text or ""), re.IGNORECASE | re.DOTALL)
556
556
  if not match or "_" not in match.group(1):
@@ -651,8 +651,8 @@ def _token_value(raw: Any) -> str:
651
651
  def _config_token(cfg: Optional[PlatformConfig] = None) -> str:
652
652
  extra = (cfg.extra if cfg else None) or {}
653
653
  for raw in [
654
- os.getenv("INLINE_TOKEN"),
655
- os.getenv("INLINE_BOT_TOKEN"),
654
+ inline_env("INLINE_TOKEN"),
655
+ inline_env("INLINE_BOT_TOKEN"),
656
656
  getattr(cfg, "token", None),
657
657
  extra.get("token"),
658
658
  extra.get("bot_token"),
@@ -664,7 +664,7 @@ def _config_token(cfg: Optional[PlatformConfig] = None) -> str:
664
664
 
665
665
 
666
666
  def _find_node_bin() -> Optional[str]:
667
- configured = os.getenv("INLINE_NODE_BIN")
667
+ configured = inline_env("INLINE_NODE_BIN")
668
668
  if configured:
669
669
  return configured
670
670
  try:
@@ -746,13 +746,13 @@ def _env_enablement() -> Optional[dict]:
746
746
  return None
747
747
  seed: dict = {
748
748
  "token": _config_token(),
749
- "base_url": os.getenv("INLINE_BASE_URL", "https://api.inline.chat"),
749
+ "base_url": inline_env("INLINE_BASE_URL", "https://api.inline.chat"),
750
750
  }
751
- home = os.getenv("INLINE_HOME_CHANNEL", "").strip()
751
+ home = inline_env("INLINE_HOME_CHANNEL", "").strip()
752
752
  if home:
753
753
  seed["home_channel"] = {
754
754
  "chat_id": home,
755
- "name": os.getenv("INLINE_HOME_CHANNEL_NAME", "Inline home"),
755
+ "name": inline_env("INLINE_HOME_CHANNEL_NAME", "Inline home"),
756
756
  }
757
757
  return seed
758
758
 
@@ -770,7 +770,6 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
770
770
  "parse_markdown",
771
771
  "require_mention",
772
772
  "strict_mention",
773
- "mention_patterns",
774
773
  "allowed_chats",
775
774
  "free_response_chats",
776
775
  "group_policy",
@@ -813,62 +812,64 @@ class InlineAdapter(BasePlatformAdapter):
813
812
  super().__init__(config, Platform("inline"))
814
813
  extra = config.extra or {}
815
814
 
816
- if _truthy(os.getenv("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
815
+ if _truthy(inline_env("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
817
816
  logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing")
818
817
  self._token = _config_token(config)
819
- self._base_url = os.getenv("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
820
- self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or os.getenv("INLINE_SIDECAR_BIND"))
821
- sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else os.getenv("INLINE_SIDECAR_PORT")
818
+ self._base_url = inline_env("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
819
+ self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or inline_env("INLINE_SIDECAR_BIND"))
820
+ sidecar_port = extra.get("sidecar_port") if "sidecar_port" in extra else inline_env("INLINE_SIDECAR_PORT")
821
+ self._autostart_sidecar = _truthy(inline_env("INLINE_SIDECAR_AUTOSTART"), True)
822
+ # A gateway can run several profiles in one process, so a sidecar this
823
+ # adapter starts itself takes a free port unless one is configured.
822
824
  self._sidecar_port = (
823
825
  _available_loopback_port(self._sidecar_bind)
824
- if use_ephemeral_sidecar_port
826
+ if use_ephemeral_sidecar_port or (self._autostart_sidecar and not str(sidecar_port or "").strip())
825
827
  else _normalize_sidecar_port(sidecar_port)
826
828
  )
827
829
  self._connect_timeout_ms = _normalize_positive_float(
828
- extra.get("connect_timeout_ms") if "connect_timeout_ms" in extra else os.getenv("INLINE_CONNECT_TIMEOUT_MS"),
830
+ extra.get("connect_timeout_ms") if "connect_timeout_ms" in extra else inline_env("INLINE_CONNECT_TIMEOUT_MS"),
829
831
  _DEFAULT_CONNECT_TIMEOUT_MS,
830
832
  "INLINE_CONNECT_TIMEOUT_MS",
831
833
  )
832
- self._sidecar_token = os.getenv("INLINE_SIDECAR_TOKEN") or secrets.token_hex(16)
834
+ self._sidecar_token = inline_env("INLINE_SIDECAR_TOKEN") or secrets.token_hex(16)
833
835
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
834
836
  self._node_bin = _find_node_bin() or "node"
835
- self._autostart_sidecar = _truthy(os.getenv("INLINE_SIDECAR_AUTOSTART"), True)
836
- self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else os.getenv("INLINE_PARSE_MARKDOWN"), True)
837
+ self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
837
838
  self._media_max_bytes = int(
838
839
  _normalize_positive_float(
839
- extra.get("media_max_mb") if "media_max_mb" in extra else os.getenv("INLINE_MEDIA_MAX_MB"),
840
+ extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
840
841
  _DEFAULT_MEDIA_MAX_MB,
841
842
  "INLINE_MEDIA_MAX_MB",
842
843
  ) * 1024 * 1024
843
844
  )
844
845
  self._upload_max_mb = _normalize_positive_float(
845
- extra.get("upload_max_mb") if "upload_max_mb" in extra else os.getenv("INLINE_UPLOAD_MAX_MB"),
846
+ extra.get("upload_max_mb") if "upload_max_mb" in extra else inline_env("INLINE_UPLOAD_MAX_MB"),
846
847
  _DEFAULT_UPLOAD_MAX_MB,
847
848
  "INLINE_UPLOAD_MAX_MB",
848
849
  )
849
850
  self._upload_max_bytes = int(self._upload_max_mb * 1024 * 1024)
850
851
  self._system_events = _truthy(
851
- extra.get("system_events") if "system_events" in extra else os.getenv("INLINE_SYSTEM_EVENTS"),
852
+ extra.get("system_events") if "system_events" in extra else inline_env("INLINE_SYSTEM_EVENTS"),
852
853
  False,
853
854
  )
854
855
  self._processing_reactions = _truthy(
855
- extra.get("reactions") if "reactions" in extra else os.getenv("INLINE_REACTIONS"),
856
+ extra.get("reactions") if "reactions" in extra else inline_env("INLINE_REACTIONS"),
856
857
  False,
857
858
  )
858
859
  self._sync_commands = _truthy(
859
- extra.get("sync_commands") if "sync_commands" in extra else os.getenv("INLINE_SYNC_COMMANDS"),
860
+ extra.get("sync_commands") if "sync_commands" in extra else inline_env("INLINE_SYNC_COMMANDS"),
860
861
  True,
861
862
  )
862
863
  self._command_limit = _normalize_command_limit(
863
- extra.get("command_limit") if "command_limit" in extra else os.getenv("INLINE_COMMAND_LIMIT")
864
+ extra.get("command_limit") if "command_limit" in extra else inline_env("INLINE_COMMAND_LIMIT")
864
865
  )
865
866
  context_backfill_raw = (
866
- extra.get("context_backfill") if "context_backfill" in extra else os.getenv("INLINE_CONTEXT_BACKFILL")
867
+ extra.get("context_backfill") if "context_backfill" in extra else inline_env("INLINE_CONTEXT_BACKFILL")
867
868
  )
868
869
  history_limit_raw = (
869
870
  extra.get("context_history_limit")
870
871
  if "context_history_limit" in extra
871
- else os.getenv("INLINE_CONTEXT_HISTORY_LIMIT")
872
+ else inline_env("INLINE_CONTEXT_HISTORY_LIMIT")
872
873
  )
873
874
  history_limit_configured = history_limit_raw is not None and str(history_limit_raw).strip() != ""
874
875
  legacy_history_limit = _normalize_context_history_limit(history_limit_raw) if history_limit_configured else None
@@ -877,7 +878,7 @@ class InlineAdapter(BasePlatformAdapter):
877
878
  self._thread_context_limit = _normalize_context_limit(
878
879
  extra.get("thread_context_limit")
879
880
  if "thread_context_limit" in extra
880
- else os.getenv("INLINE_THREAD_CONTEXT_LIMIT"),
881
+ else inline_env("INLINE_THREAD_CONTEXT_LIMIT"),
881
882
  default=_DEFAULT_THREAD_CONTEXT_LIMIT,
882
883
  maximum=_MAX_THREAD_CONTEXT_LIMIT,
883
884
  name="INLINE_THREAD_CONTEXT_LIMIT",
@@ -885,7 +886,7 @@ class InlineAdapter(BasePlatformAdapter):
885
886
  self._reply_context_limit = _normalize_context_limit(
886
887
  extra.get("reply_context_limit")
887
888
  if "reply_context_limit" in extra
888
- else os.getenv("INLINE_REPLY_CONTEXT_LIMIT"),
889
+ else inline_env("INLINE_REPLY_CONTEXT_LIMIT"),
889
890
  default=_DEFAULT_REPLY_CONTEXT_LIMIT,
890
891
  maximum=_MAX_REPLY_CONTEXT_LIMIT,
891
892
  name="INLINE_REPLY_CONTEXT_LIMIT",
@@ -893,7 +894,7 @@ class InlineAdapter(BasePlatformAdapter):
893
894
  self._observed_context_limit = _normalize_context_limit(
894
895
  extra.get("observed_context_limit")
895
896
  if "observed_context_limit" in extra
896
- else os.getenv("INLINE_OBSERVED_CONTEXT_LIMIT"),
897
+ else inline_env("INLINE_OBSERVED_CONTEXT_LIMIT"),
897
898
  default=_DEFAULT_OBSERVED_CONTEXT_LIMIT,
898
899
  maximum=_MAX_OBSERVED_CONTEXT_LIMIT,
899
900
  name="INLINE_OBSERVED_CONTEXT_LIMIT",
@@ -905,74 +906,80 @@ class InlineAdapter(BasePlatformAdapter):
905
906
  self._context_backfill == "always"
906
907
  and legacy_history_limit is not None
907
908
  and "thread_context_limit" not in extra
908
- and not os.getenv("INLINE_THREAD_CONTEXT_LIMIT")
909
+ and not inline_env("INLINE_THREAD_CONTEXT_LIMIT")
909
910
  ):
910
911
  self._thread_context_limit = min(legacy_history_limit, _MAX_THREAD_CONTEXT_LIMIT)
911
912
  self._observe_unmentioned_messages = _truthy(
912
913
  extra.get("observe_unmentioned_messages")
913
914
  if "observe_unmentioned_messages" in extra
914
- else os.getenv("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
915
+ else inline_env("INLINE_OBSERVE_UNMENTIONED_MESSAGES"),
915
916
  True,
916
917
  )
917
918
  self._reply_thread_mode = _reply_thread_mode(
918
- extra.get("reply_threads") if "reply_threads" in extra else os.getenv("INLINE_REPLY_THREADS"),
919
+ extra.get("reply_threads") if "reply_threads" in extra else inline_env("INLINE_REPLY_THREADS"),
919
920
  "auto",
920
921
  )
922
+ if self._reply_thread_mode == "on":
923
+ logger.warning(
924
+ "[inline] reply_threads=on now applies per chat; using auto as the default. "
925
+ "Turn it on in a chat with /threads on, the agent settings, or by asking the agent."
926
+ )
927
+ self._reply_thread_mode = "auto"
921
928
 
922
- state_path = extra.get("state_path") or os.getenv("INLINE_STATE_PATH")
929
+ state_path = extra.get("state_path") or inline_env("INLINE_STATE_PATH")
923
930
  if state_path:
924
931
  self._state_path = Path(str(state_path)).expanduser()
925
932
  else:
926
- self._state_path = _STATE_DIR / "sdk-state.json"
927
- settings_path = extra.get("settings_path") or os.getenv("INLINE_SETTINGS_PATH")
933
+ # One state file per profile and bot: two writers on one file replay
934
+ # each other's answered messages.
935
+ self._state_path = state_dir() / f"sdk-state-{bot_state_key(self._base_url, self._token)}.json"
936
+ self._state_path_configured = bool(state_path)
937
+ settings_path = extra.get("settings_path") or inline_env("INLINE_SETTINGS_PATH")
928
938
  if settings_path:
929
939
  self._settings_path = Path(str(settings_path)).expanduser()
930
940
  else:
931
941
  self._settings_path = self._state_path.with_name("adapter-settings.json")
932
942
 
933
943
  self.require_mention = _truthy(
934
- extra.get("require_mention") if "require_mention" in extra else os.getenv("INLINE_REQUIRE_MENTION"),
944
+ extra.get("require_mention") if "require_mention" in extra else inline_env("INLINE_REQUIRE_MENTION"),
935
945
  True,
936
946
  )
937
947
  self._strict_mention = _truthy(
938
- extra.get("strict_mention") if "strict_mention" in extra else os.getenv("INLINE_STRICT_MENTION"),
948
+ extra.get("strict_mention") if "strict_mention" in extra else inline_env("INLINE_STRICT_MENTION"),
939
949
  False,
940
950
  )
941
- self._mention_patterns = self._compile_mention_patterns(
942
- extra["mention_patterns"] if "mention_patterns" in extra else os.getenv("INLINE_MENTION_PATTERNS")
943
- )
944
951
  self._allowed_chats = self._parse_chat_set(
945
952
  extra.get("allowed_chats")
946
953
  if "allowed_chats" in extra
947
- else extra.get("allowedChannels") if "allowedChannels" in extra else os.getenv("INLINE_ALLOWED_CHATS")
954
+ else extra.get("allowedChannels") if "allowedChannels" in extra else inline_env("INLINE_ALLOWED_CHATS")
948
955
  )
949
956
  self._free_response_chats = self._parse_chat_set(
950
957
  extra.get("free_response_chats")
951
958
  if "free_response_chats" in extra
952
- else extra.get("freeResponseChats") if "freeResponseChats" in extra else os.getenv("INLINE_FREE_RESPONSE_CHATS")
959
+ else extra.get("freeResponseChats") if "freeResponseChats" in extra else inline_env("INLINE_FREE_RESPONSE_CHATS")
953
960
  )
954
961
  allow_all_raw = (
955
962
  extra.get("allow_all")
956
963
  if "allow_all" in extra
957
- else extra.get("allow_all_users") if "allow_all_users" in extra else os.getenv("INLINE_ALLOW_ALL_USERS")
964
+ else extra.get("allow_all_users") if "allow_all_users" in extra else inline_env("INLINE_ALLOW_ALL_USERS")
958
965
  )
959
966
  self._allow_all = _truthy(allow_all_raw, False)
960
967
  self._allow_from = self._parse_id_set(
961
968
  extra.get("allow_from")
962
969
  if "allow_from" in extra
963
- else extra.get("allowed_users") if "allowed_users" in extra else os.getenv("INLINE_ALLOWED_USERS")
970
+ else extra.get("allowed_users") if "allowed_users" in extra else inline_env("INLINE_ALLOWED_USERS")
964
971
  )
965
972
  self._group_allow_from = self._parse_id_set(
966
973
  extra.get("group_allow_from")
967
974
  if "group_allow_from" in extra
968
- else extra.get("groupAllowFrom") if "groupAllowFrom" in extra else os.getenv("INLINE_GROUP_ALLOW_FROM")
975
+ else extra.get("groupAllowFrom") if "groupAllowFrom" in extra else inline_env("INLINE_GROUP_ALLOW_FROM")
969
976
  )
970
977
  self._dm_policy = _normalize_policy(
971
- extra.get("dm_policy") if "dm_policy" in extra else os.getenv("INLINE_DM_POLICY"),
978
+ extra.get("dm_policy") if "dm_policy" in extra else inline_env("INLINE_DM_POLICY"),
972
979
  "allowlist" if self._allow_from and not self._allow_all else "open",
973
980
  )
974
981
  self._group_policy = _normalize_policy(
975
- extra.get("group_policy") if "group_policy" in extra else os.getenv("INLINE_GROUP_POLICY"),
982
+ extra.get("group_policy") if "group_policy" in extra else inline_env("INLINE_GROUP_POLICY"),
976
983
  "allowlist" if self._group_allow_from and not self._allow_all else "open",
977
984
  )
978
985
 
@@ -1007,12 +1014,23 @@ class InlineAdapter(BasePlatformAdapter):
1007
1014
  self._bot_agent_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict()
1008
1015
  self._reply_thread_cache: "OrderedDict[str, str]" = OrderedDict()
1009
1016
  self._reply_thread_parent_reply_ids: "OrderedDict[str, set[str]]" = OrderedDict()
1017
+ # Whether each request came from a bot, so answers to bots reply to them and reach them.
1018
+ self._request_sender_is_bot: "OrderedDict[str, bool]" = OrderedDict()
1019
+ # Commands this bot advertises in Inline's command menu, as last synced.
1020
+ self._advertised_commands: set[str] = set()
1010
1021
  self._reply_thread_parent_typing_targets: "OrderedDict[str, str]" = OrderedDict()
1011
1022
  self._visible_reply_thread_targets: "OrderedDict[str, None]" = OrderedDict()
1012
1023
  self._active_typing_targets: Dict[str, Dict[str, str]] = {}
1013
1024
  self._observed_context: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
1014
1025
  self._context_backfill_seen: "OrderedDict[str, float]" = OrderedDict()
1015
1026
  self._reply_thread_overrides = self._load_reply_thread_overrides()
1027
+ # Threads the agent made for the message it is answering: (chat, message) -> thread.
1028
+ self._turn_threads: "OrderedDict[tuple[str, str], str]" = OrderedDict()
1029
+ try:
1030
+ from . import tools as _inline_tools
1031
+ _inline_tools.configure_settings(self)
1032
+ except Exception:
1033
+ logger.debug("[inline] failed to register Inline settings for tools", exc_info=True)
1016
1034
 
1017
1035
  def _report_error(self, operation: str, error: BaseException, *, handled: bool = True) -> None:
1018
1036
  try:
@@ -1081,7 +1099,7 @@ class InlineAdapter(BasePlatformAdapter):
1081
1099
  if not key:
1082
1100
  continue
1083
1101
  if isinstance(enabled, bool):
1084
- overrides[key] = "on" if enabled else "off"
1102
+ overrides[key] = "on" if enabled else "auto"
1085
1103
  continue
1086
1104
  mode = _reply_thread_mode(enabled, "")
1087
1105
  if mode:
@@ -1110,9 +1128,6 @@ class InlineAdapter(BasePlatformAdapter):
1110
1128
  return self._reply_thread_overrides[key]
1111
1129
  return self._reply_thread_mode
1112
1130
 
1113
- def _reply_threads_for_chat(self, chat_id: str, parent_chat_id: Optional[str] = None) -> bool:
1114
- return self._reply_thread_mode_for_chat(chat_id, parent_chat_id) != "off"
1115
-
1116
1131
  def _set_reply_threads_for_chat(self, chat_id: str, value: Optional[str]) -> None:
1117
1132
  key = self._chat_key(chat_id)
1118
1133
  if not key:
@@ -1123,6 +1138,57 @@ class InlineAdapter(BasePlatformAdapter):
1123
1138
  self._reply_thread_overrides[key] = _reply_thread_mode(value, "auto")
1124
1139
  self._save_reply_thread_overrides()
1125
1140
 
1141
+ def _slash_access_policy(self, chat_type: str) -> Any:
1142
+ """Hermes' own slash-command access for this platform; None on hosts without it."""
1143
+ try:
1144
+ from gateway.slash_access import policy_from_extra
1145
+ except Exception:
1146
+ return None
1147
+ try:
1148
+ return policy_from_extra(dict(self.config.extra or {}), "dm" if chat_type == "dm" else "group")
1149
+ except Exception:
1150
+ logger.debug("[inline] slash access policy unavailable", exc_info=True)
1151
+ return None
1152
+
1153
+ def _may_change_reply_threads(self, actor_id: str, chat_type: str) -> bool:
1154
+ """Who may change a chat's reply threads follows Hermes' admin lists: the same people
1155
+ who may run /threads. Without admin lists Hermes allows everyone, and so does this."""
1156
+ policy = self._slash_access_policy(chat_type)
1157
+ if policy is None:
1158
+ return True
1159
+ return policy.can_run(actor_id, "threads")
1160
+
1161
+ def note_turn_thread(self, chat_id: str, message_id: str, thread_id: str) -> None:
1162
+ """The agent opened a thread on the message it is answering; the answer goes there."""
1163
+ key = (self._chat_key(chat_id), str(message_id))
1164
+ self._turn_threads[key] = self._chat_key(thread_id)
1165
+ self._turn_threads.move_to_end(key)
1166
+ while len(self._turn_threads) > _TURN_THREADS_MAX:
1167
+ self._turn_threads.popitem(last=False)
1168
+
1169
+ def turn_thread(self, chat_id: str, message_id: str) -> str:
1170
+ return self._turn_threads.get((self._chat_key(chat_id), str(message_id)), "")
1171
+
1172
+ def _with_turn_thread(self, chat_id: str, reply_to: Optional[str], metadata: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
1173
+ if not reply_to or (metadata or {}).get("thread_id"):
1174
+ return metadata
1175
+ thread_id = self.turn_thread(chat_id, str(reply_to))
1176
+ return {**(metadata or {}), "thread_id": thread_id} if thread_id else metadata
1177
+
1178
+ def set_reply_threads_from_tool(
1179
+ self, *, chat_id: str, chat_type: str, in_reply_thread: bool, actor_id: str, actor_is_bot: bool, mode: str,
1180
+ ) -> Dict[str, Any]:
1181
+ """Change this chat's reply threads because the person talking to the agent asked,
1182
+ with the access /threads needs."""
1183
+ if actor_is_bot or not actor_id:
1184
+ raise PermissionError("only a person can change reply threads")
1185
+ if in_reply_thread:
1186
+ raise PermissionError("change reply threads from the parent chat, not inside a thread")
1187
+ if not self._may_change_reply_threads(actor_id, chat_type):
1188
+ raise PermissionError("changing reply threads needs access to the threads command")
1189
+ self._set_reply_threads_for_chat(chat_id, None if mode in {"default", "reset"} else _reply_thread_mode(mode))
1190
+ return {"chat_id": self._chat_key(chat_id), "mode": self._reply_thread_mode_for_chat(chat_id)}
1191
+
1126
1192
  @staticmethod
1127
1193
  def _bot_settings_chat_type(info: Dict[str, Any]) -> str:
1128
1194
  peer = info.get("peer")
@@ -1194,7 +1260,9 @@ class InlineAdapter(BasePlatformAdapter):
1194
1260
  )
1195
1261
  runner = self._bot_settings_runner()
1196
1262
  can_modify = can_inspect
1197
- can_set_reply_threads = not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
1263
+ can_set_reply_threads = (
1264
+ not is_reply_thread or await self._chat_actor_authorized(scope_chat_id, actor_id)
1265
+ ) and self._may_change_reply_threads(actor_id, chat_type)
1198
1266
  context: Dict[str, Any] = {
1199
1267
  "access": "full" if can_modify else "readOnly",
1200
1268
  "scope_id": scope_chat_id,
@@ -1407,9 +1475,8 @@ class InlineAdapter(BasePlatformAdapter):
1407
1475
  "control": {"oneofKind": "select", "select": {
1408
1476
  "value": context.get("reply_threads") or "auto",
1409
1477
  "options": [
1410
- {"value": "auto", "label": "Auto", "description": "Agent decides.", "disabled": False},
1411
- {"value": "on", "label": "On", "description": "Always use threads.", "disabled": False},
1412
- {"value": "off", "label": "Off", "description": "Stay in chat.", "disabled": False},
1478
+ {"value": "auto", "label": "Auto", "description": "Answer in the chat; a thread when asked.", "disabled": False},
1479
+ {"value": "on", "label": "On", "description": "Answer each message in a thread.", "disabled": False},
1413
1480
  ],
1414
1481
  }},
1415
1482
  }]})
@@ -1521,7 +1588,7 @@ class InlineAdapter(BasePlatformAdapter):
1521
1588
  if not context.get("can_set_reply_threads"):
1522
1589
  raise ValueError("parent chat thread settings are not authorized")
1523
1590
  mode = str(value_body.get("stringValue") or "")
1524
- if mode not in {"auto", "on", "off"}:
1591
+ if mode not in {"auto", "on"}:
1525
1592
  raise ValueError("invalid reply mode")
1526
1593
  self._set_reply_threads_for_chat(context["scope_id"], mode)
1527
1594
  return
@@ -1619,30 +1686,10 @@ class InlineAdapter(BasePlatformAdapter):
1619
1686
  int((time.monotonic() - started_at) * 1000),
1620
1687
  )
1621
1688
 
1622
- @staticmethod
1623
- def _has_reply_thread_intent(text: str) -> bool:
1624
- normalized = re.sub(r"\s+", " ", str(text or "").strip())
1625
- if not normalized:
1626
- return False
1627
- if _INLINE_REPLY_THREAD_NEGATION_RE.search(normalized):
1628
- return False
1629
- return bool(_INLINE_REPLY_THREAD_INTENT_RE.search(normalized))
1630
-
1631
- def _should_create_reply_thread_for_message(
1632
- self,
1633
- *,
1634
- chat_id: str,
1635
- parent_chat_id: Optional[str],
1636
- text: str,
1637
- ) -> bool:
1638
- mode = self._reply_thread_mode_for_chat(chat_id, parent_chat_id)
1639
- if mode == "off":
1640
- return False
1641
- if mode == "on":
1642
- return True
1643
- if self._has_reply_thread_intent(text):
1644
- return True
1645
- return False
1689
+ def _should_create_reply_thread_for_message(self, *, chat_id: str, parent_chat_id: Optional[str]) -> bool:
1690
+ # Answers stay in the chat unless someone turned reply threads on for it. Threads asked
1691
+ # for in conversation come from the agent's create_thread tool.
1692
+ return self._reply_thread_mode_for_chat(chat_id, parent_chat_id) == "on"
1646
1693
 
1647
1694
  @staticmethod
1648
1695
  def _id_allowed(entries: set[str], value: str) -> bool:
@@ -1653,49 +1700,6 @@ class InlineAdapter(BasePlatformAdapter):
1653
1700
  return True
1654
1701
  return False
1655
1702
 
1656
- @staticmethod
1657
- def _compile_mention_patterns(raw: Any) -> "list[re.Pattern]":
1658
- if raw is None:
1659
- patterns = list(_DEFAULT_MENTION_PATTERNS)
1660
- elif isinstance(raw, str):
1661
- text = raw.strip()
1662
- try:
1663
- loaded = json.loads(text) if text else []
1664
- except Exception:
1665
- loaded = None
1666
- patterns = loaded if isinstance(loaded, list) else [
1667
- part.strip()
1668
- for line in text.splitlines()
1669
- for part in line.split(",")
1670
- ]
1671
- elif isinstance(raw, list):
1672
- patterns = raw
1673
- else:
1674
- patterns = [raw]
1675
- compiled = []
1676
- for pattern in patterns:
1677
- text = str(pattern).strip()
1678
- if not text:
1679
- continue
1680
- try:
1681
- compiled.append(re.compile(text, re.IGNORECASE))
1682
- except re.error as exc:
1683
- logger.warning("[inline] invalid mention pattern %r: %s", text, exc)
1684
- return compiled
1685
-
1686
- def _matches_mention(self, text: str) -> bool:
1687
- return bool(text and any(pattern.search(text) for pattern in self._mention_patterns))
1688
-
1689
- def _clean_mention(self, text: str) -> str:
1690
- if not text:
1691
- return text
1692
- stripped = text.lstrip()
1693
- for pattern in self._mention_patterns:
1694
- match = pattern.match(stripped)
1695
- if match:
1696
- return stripped[match.end():].lstrip(" ,:-") or text
1697
- return text
1698
-
1699
1703
  @staticmethod
1700
1704
  def _thread_command_action(text: str) -> Optional[str]:
1701
1705
  match = _INLINE_THREAD_COMMAND_RE.match(str(text or "").strip())
@@ -1709,9 +1713,7 @@ class InlineAdapter(BasePlatformAdapter):
1709
1713
  return "status"
1710
1714
  if action in {"on", "enable", "enabled", "true", "thread", "threads"}:
1711
1715
  return "on"
1712
- if action in {"off", "disable", "disabled", "false", "flat", "channel"}:
1713
- return "off"
1714
- if action in {"auto", "config"}:
1716
+ if action in {"auto", "config", "off", "disable", "disabled", "false", "flat", "channel"}:
1715
1717
  return "auto"
1716
1718
  if action in {"reset", "default", "inherit", "clear"}:
1717
1719
  return "reset"
@@ -1723,17 +1725,15 @@ class InlineAdapter(BasePlatformAdapter):
1723
1725
  has_override = key in self._reply_thread_overrides
1724
1726
  scope = "chat override" if has_override else "global default"
1725
1727
  if mode == "on":
1726
- behavior = "Top-level replies will start or reuse Inline reply threads."
1727
- elif mode == "auto":
1728
- behavior = "Top-level replies stay in the parent chat unless a thread is explicitly requested."
1728
+ behavior = "Each top-level message gets its answer in a reply thread."
1729
1729
  else:
1730
- behavior = "Top-level replies stay in the parent chat."
1730
+ behavior = "Answers stay in the chat; a thread starts only when someone asks for one."
1731
1731
  first_line = f"Inline reply threads are {mode} for this chat ({scope})."
1732
1732
  if updated:
1733
1733
  first_line = f"Inline reply threads updated: {mode} for this chat ({scope})."
1734
1734
  footer = "Existing Inline reply threads are always preserved."
1735
1735
  if controls:
1736
- footer = f"{footer} Use Auto, On, Off, or Reset."
1736
+ footer = f"{footer} Use Auto, On, or Reset."
1737
1737
  return (
1738
1738
  f"{first_line}\n"
1739
1739
  f"{behavior}\n"
@@ -1769,7 +1769,6 @@ class InlineAdapter(BasePlatformAdapter):
1769
1769
  rows = [{"actions": [
1770
1770
  self._action(f"{prefix}auto", "Auto"),
1771
1771
  self._action(f"{prefix}on", "On"),
1772
- self._action(f"{prefix}off", "Off"),
1773
1772
  ]}]
1774
1773
  if self._chat_key(target_chat_id) in self._reply_thread_overrides:
1775
1774
  rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
@@ -1811,17 +1810,18 @@ class InlineAdapter(BasePlatformAdapter):
1811
1810
  if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, from_id):
1812
1811
  await self.send(chat_id, "Parent chat access could not be confirmed. Check access and try again.", reply_to=msg_id, metadata=metadata)
1813
1812
  return True
1813
+ if action in {"on", "auto", "reset"} and not self._may_change_reply_threads(from_id, chat_type):
1814
+ await self.send(chat_id, "Changing reply threads needs access to /threads in this bot's Hermes settings.", reply_to=msg_id, metadata=metadata)
1815
+ return True
1814
1816
  if action == "on":
1815
1817
  self._set_reply_threads_for_chat(target_chat_id, "on")
1816
- elif action == "off":
1817
- self._set_reply_threads_for_chat(target_chat_id, "off")
1818
1818
  elif action == "auto":
1819
1819
  self._set_reply_threads_for_chat(target_chat_id, "auto")
1820
1820
  elif action == "reset":
1821
1821
  self._set_reply_threads_for_chat(target_chat_id, None)
1822
1822
 
1823
1823
  if action == "help":
1824
- body = "Usage: /threads status, /threads auto, /threads on, /threads off, or /threads reset."
1824
+ body = "Usage: /threads status, /threads auto, /threads on, or /threads reset."
1825
1825
  await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
1826
1826
  else:
1827
1827
  await self._send_thread_status(
@@ -1925,9 +1925,16 @@ class InlineAdapter(BasePlatformAdapter):
1925
1925
  if not self._token:
1926
1926
  self._set_fatal_error("MISSING_TOKEN", "Inline token is required in INLINE_TOKEN, INLINE_BOT_TOKEN, or Hermes Inline config", retryable=False)
1927
1927
  return False
1928
+ # One gateway per bot, the native way: Hermes' machine-wide lock names the profile
1929
+ # already using this bot, across gateways and multiplexed profiles.
1930
+ if not self._acquire_bot_lock():
1931
+ return False
1932
+ # Re-register under the profile scope the gateway connects this adapter in.
1933
+ _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
1928
1934
  node_error = _node_version_error(self._node_bin)
1929
1935
  if node_error:
1930
1936
  self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
1937
+ self._release_bot_lock()
1931
1938
  return False
1932
1939
  self._http_client = httpx.AsyncClient(timeout=30.0, trust_env=False)
1933
1940
  if self._autostart_sidecar:
@@ -1939,6 +1946,7 @@ class InlineAdapter(BasePlatformAdapter):
1939
1946
  await self._stop_sidecar()
1940
1947
  await self._http_client.aclose()
1941
1948
  self._http_client = None
1949
+ self._release_bot_lock()
1942
1950
  return False
1943
1951
  self._inbound_running = True
1944
1952
  self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop())
@@ -1947,6 +1955,18 @@ class InlineAdapter(BasePlatformAdapter):
1947
1955
  self._schedule_bot_command_sync()
1948
1956
  return True
1949
1957
 
1958
+ def _acquire_bot_lock(self) -> bool:
1959
+ acquire = getattr(self, "_acquire_platform_lock", None)
1960
+ if acquire is None:
1961
+ return True
1962
+ identity = f"{self._base_url}\n{bot_state_key(self._base_url, self._token)}"
1963
+ return bool(acquire("inline-bot", identity, "Inline bot"))
1964
+
1965
+ def _release_bot_lock(self) -> None:
1966
+ release = getattr(self, "_release_platform_lock", None)
1967
+ if release is not None:
1968
+ release()
1969
+
1950
1970
  async def disconnect(self) -> None:
1951
1971
  self._inbound_running = False
1952
1972
  if self._command_sync_task is not None:
@@ -1982,11 +2002,14 @@ class InlineAdapter(BasePlatformAdapter):
1982
2002
  if self._http_client is not None:
1983
2003
  await self._http_client.aclose()
1984
2004
  self._http_client = None
2005
+ self._release_bot_lock()
1985
2006
  self._mark_disconnected()
1986
2007
 
1987
2008
  async def _start_sidecar(self) -> None:
1988
2009
  if not _SIDECAR_ENTRY.exists():
1989
2010
  raise RuntimeError(f"Inline sidecar not found at {_SIDECAR_ENTRY}; run inline-hermes install again")
2011
+ if not self._state_path_configured:
2012
+ self._adopt_legacy_state()
1990
2013
  self._state_path.parent.mkdir(parents=True, exist_ok=True)
1991
2014
  env = _with_hermes_node_path(os.environ.copy())
1992
2015
  env["INLINE_TOKEN"] = self._token
@@ -2137,6 +2160,9 @@ class InlineAdapter(BasePlatformAdapter):
2137
2160
  logger.warning("[inline] bot command sync skipped: no valid Hermes commands resolved")
2138
2161
  return {"state": "failed", "count": 0}
2139
2162
  synced = await self._set_bot_commands_with_retry(commands)
2163
+ self._advertised_commands = {
2164
+ str(command.get("command") or "").strip().lower() for command in synced if isinstance(command, dict)
2165
+ }
2140
2166
  hidden_suffix = f", {hidden_count} hidden" if hidden_count else ""
2141
2167
  logger.info("[inline] bot commands synced (%d command%s%s)", len(synced), "" if len(synced) == 1 else "s", hidden_suffix)
2142
2168
  return {"state": "synced", "count": len(synced), "hidden": hidden_count}
@@ -2480,7 +2506,11 @@ class InlineAdapter(BasePlatformAdapter):
2480
2506
 
2481
2507
  raw_message_text = str(msg.get("message") or "")
2482
2508
  text = raw_message_text.strip()
2483
- explicitly_mentions_me = self._message_entity_mentions_me(msg)
2509
+ # The run of mentions a message starts with names who it is for; a later
2510
+ # mention of this bot does not override it.
2511
+ leading_mention_ids = self._leading_mention_user_ids(msg, raw_message_text)
2512
+ addressed_elsewhere = bool(self._me_id and leading_mention_ids) and self._me_id not in leading_mention_ids
2513
+ explicitly_mentions_me = self._message_entity_mentions_me(msg) and not addressed_elsewhere
2484
2514
 
2485
2515
  chat_type = self._chat_type_from_message(msg)
2486
2516
  thread_id = self._thread_id_from_message(msg)
@@ -2513,6 +2543,11 @@ class InlineAdapter(BasePlatformAdapter):
2513
2543
  thread_id = chat_id
2514
2544
  parent_chat_id = info_parent_chat_id
2515
2545
  parent_message_id = parent_message_id or info_parent_message_id
2546
+ # A thread under this bot's DM stays that DM for its person, like a Slack DM
2547
+ # thread in native Hermes: DM access, every message answered, and one session
2548
+ # with the turn that opened it. Anyone else in the thread keeps group rules.
2549
+ if from_id and from_id == await self._dm_peer_of(info_parent_chat_id):
2550
+ chat_type = "dm"
2516
2551
  if not self._allowed(chat_type, from_id):
2517
2552
  return
2518
2553
  if chat_type == "group" and not self._chat_allowed(chat_id, thread_id, parent_chat_id):
@@ -2523,6 +2558,23 @@ class InlineAdapter(BasePlatformAdapter):
2523
2558
  )
2524
2559
  if has_command_target and not command_addressed_to_me:
2525
2560
  return
2561
+ # The app attaches the bot you picked a command for, and the server fills one in when it
2562
+ # can tell (only one bot offers it, you replied to one, or one follows the thread).
2563
+ starts_with_command, command_target = self._leading_command_target(msg, raw_message_text)
2564
+ if command_target and self._me_id:
2565
+ if command_target != self._me_id:
2566
+ return
2567
+ command_addressed_to_me = True
2568
+ elif (
2569
+ starts_with_command
2570
+ and not has_command_target
2571
+ and chat_type == "group"
2572
+ and _inline_command_name(text) in self._advertised_commands
2573
+ ):
2574
+ # Several bots offer this command and nothing says which one was meant, so the
2575
+ # server picked none; running it here could reset or steer the wrong conversation.
2576
+ self._remember_observed_context(chat_id, msg, text)
2577
+ return
2526
2578
  actor_authorized = self._actor_authorization(
2527
2579
  chat_type, from_id, chat_id, thread_id=thread_id,
2528
2580
  parent_chat_id=parent_chat_id, is_bot=sender_profile.get("bot") is True,
@@ -2533,8 +2585,26 @@ class InlineAdapter(BasePlatformAdapter):
2533
2585
  return
2534
2586
  if not edit and self._is_duplicate_message_instance(chat_id, msg, event):
2535
2587
  return
2588
+ reply_to_is_own = False
2589
+ reply_to_someone_else = False
2590
+ reply_to_text = None
2591
+ reply_to_author = None
2592
+ reply_to_id = str(msg.get("replyToMsgId") or "") or None
2593
+ if reply_to_id:
2594
+ reply = await self._fetch_message(chat_id, reply_to_id)
2595
+ if reply:
2596
+ reply_to_text = str(reply.get("message") or "") or None
2597
+ reply_to_author = str(reply.get("fromId") or "") or None
2598
+ reply_to_is_own = bool(self._me_id and reply_to_author == self._me_id)
2599
+ # Replying to your own earlier message continues your own point.
2600
+ reply_to_someone_else = bool(reply_to_author) and not reply_to_is_own and reply_to_author != from_id
2601
+ provenance_verified = event.get("_inlineSenderProvenanceVerified") is not False
2602
+ # Another bot reaches this one only by addressing it: a mention, or a reply to
2603
+ # this bot's message (how a worker hands its result back).
2604
+ bot_replies_to_me = provenance_verified and reply_to_is_own and not addressed_elsewhere
2536
2605
  context_only = not explicitly_mentions_me and (
2537
- event.get("_inlineSenderProvenanceVerified") is False or sender_profile.get("bot") is True
2606
+ not provenance_verified
2607
+ or (sender_profile.get("bot") is True and not bot_replies_to_me)
2538
2608
  )
2539
2609
  if not actor_authorized:
2540
2610
  if context_only:
@@ -2592,58 +2662,35 @@ class InlineAdapter(BasePlatformAdapter):
2592
2662
  ):
2593
2663
  return
2594
2664
  text = _normalize_inline_plugin_command_text(text)
2595
- reply_to_is_own = False
2596
- reply_to_text = None
2597
- reply_to_author = None
2598
- reply_to_id = str(msg.get("replyToMsgId") or "") or None
2599
- if reply_to_id:
2600
- reply = await self._fetch_message(chat_id, reply_to_id)
2601
- if reply:
2602
- reply_to_text = str(reply.get("message") or "") or None
2603
- reply_to_author = str(reply.get("fromId") or "") or None
2604
- reply_to_is_own = bool(self._me_id and reply_to_author == self._me_id)
2605
-
2606
2665
  mentioned = False
2607
2666
  mention_gate_active = (
2608
2667
  chat_type == "group"
2609
2668
  and self.require_mention
2610
2669
  and not self._free_response_chat(chat_id, thread_id, parent_chat_id)
2611
2670
  )
2612
- if mention_gate_active:
2613
- mentioned = command_addressed_to_me or bool(msg.get("mentioned")) or self._matches_mention(text)
2614
- reply_wakes_thread = reply_to_is_own and not self._strict_mention
2615
- # Freshness only controls whether the server auto-follows a thread.
2616
- # Once this dialog is FOLLOWING, it remains relevant until unfollowed.
2617
- follow_mode_wakes_thread = (
2618
- self._chat_follow_mode_following(chat_info)
2619
- and not self._strict_mention
2671
+ if chat_type == "group":
2672
+ follow_mode = self._chat_follow_mode(chat_info)
2673
+ mentioned = command_addressed_to_me or (bool(msg.get("mentioned")) and not addressed_elsewhere)
2674
+ reply_wakes_thread = reply_to_is_own and not addressed_elsewhere and not self._strict_mention
2675
+ # A reply is addressed to its author, like a leading mention: a following
2676
+ # bot stays out of replies to other people and bots unless it is mentioned.
2677
+ addressed_to_others = addressed_elsewhere or (reply_to_someone_else and not mentioned)
2678
+ # Ambient participation: a followed thread, or a free-response room. An
2679
+ # explicit unfollow always wins; mentions and replies still reach the bot.
2680
+ ambient = follow_mode != "unfollowed" and (
2681
+ not mention_gate_active or (follow_mode == "following" and not self._strict_mention)
2620
2682
  )
2621
- if not mentioned and not reply_wakes_thread and not follow_mode_wakes_thread:
2622
- self._remember_observed_context(chat_id, msg, text)
2623
- return
2624
- # A leading explicit address to another person overwhelmingly means
2625
- # the turn is for them. It overrides only inferred follow/reply
2626
- # attention; an explicit address to this bot still wins.
2627
- if (
2628
- not mentioned
2629
- and (reply_wakes_thread or follow_mode_wakes_thread)
2630
- and self._starts_with_other_user_mention(msg, raw_message_text)
2631
- ):
2683
+ if not mentioned and not reply_wakes_thread and (addressed_to_others or not ambient):
2632
2684
  self._remember_observed_context(chat_id, msg, text)
2633
2685
  return
2634
- if mentioned:
2635
- text = self._clean_mention(text)
2686
+ self._remember_request_sender(chat_id, msg_id, sender_profile.get("bot") is True)
2636
2687
  if edit:
2637
2688
  text = f"message:edited:{text}" if text else "message:edited"
2638
2689
  if (
2639
2690
  not edit
2640
2691
  and not agent_action
2641
2692
  and not thread_id
2642
- and self._should_create_reply_thread_for_message(
2643
- chat_id=chat_id,
2644
- parent_chat_id=parent_chat_id,
2645
- text=text,
2646
- )
2693
+ and self._should_create_reply_thread_for_message(chat_id=chat_id, parent_chat_id=parent_chat_id)
2647
2694
  ):
2648
2695
  created_thread_id = await self._create_reply_thread(chat_id, msg_id, text, reply_to_id)
2649
2696
  if created_thread_id:
@@ -2742,8 +2789,18 @@ class InlineAdapter(BasePlatformAdapter):
2742
2789
  "callback_data_base64": str(agent_action.get("dataBase64") or ""),
2743
2790
  }
2744
2791
 
2792
+ session_chat_id = _session_chat_id(chat_id, chat_type, thread_id, parent_chat_id)
2793
+ try:
2794
+ from . import tools as _inline_tools
2795
+ _inline_tools.remember_turn_message(
2796
+ session_chat_id=session_chat_id, thread_id=str(thread_id or ""), message_id=msg_id,
2797
+ chat_id=chat_id, sender_id=from_id, sender_is_bot=sender_profile.get("bot") is True,
2798
+ chat_type=chat_type, in_reply_thread=bool(parent_chat_id) and parent_chat_id != chat_id,
2799
+ )
2800
+ except Exception:
2801
+ logger.debug("[inline] failed to record the turn's message for tools", exc_info=True)
2745
2802
  source = self.build_source(
2746
- chat_id=chat_id,
2803
+ chat_id=session_chat_id,
2747
2804
  chat_name=chat_name,
2748
2805
  chat_type=chat_type,
2749
2806
  user_id=from_id,
@@ -3330,28 +3387,45 @@ class InlineAdapter(BasePlatformAdapter):
3330
3387
  text = str(value).strip()
3331
3388
  return text or None
3332
3389
 
3333
- def _starts_with_other_user_mention(self, msg: Dict[str, Any], message_text: str) -> bool:
3334
- if not self._me_id:
3335
- return False
3336
- candidates: List[tuple[int, int, Dict[str, Any]]] = []
3337
- for index, entity in enumerate(self._message_entities(msg)):
3390
+ def _leading_command_target(self, msg: Dict[str, Any], message_text: str) -> tuple[bool, Optional[str]]:
3391
+ """Whether the message starts with a bot command, and the bot it names if any."""
3392
+ units = message_text.encode("utf-16-le")
3393
+ for entity in self._message_entities(msg):
3394
+ if self._entity_kind(entity) not in {"bot_command", "botcommand"}:
3395
+ continue
3338
3396
  offset = _to_int(entity.get("offset"))
3339
- if offset is not None and offset >= 0:
3340
- candidates.append((offset, index, entity))
3341
- if not candidates:
3342
- return False
3343
- offset, _, first_entity = min(candidates, key=lambda item: (item[0], item[1]))
3344
- if self._entity_kind(first_entity) != "mention":
3345
- return False
3346
- payload = self._entity_payload(first_entity, "mention")
3347
- mentioned_user_id = self._entity_id(payload, "userId")
3348
- if not mentioned_user_id or mentioned_user_id == self._me_id:
3349
- return False
3350
- try:
3351
- utf16_prefix = message_text.encode("utf-16-le")[: offset * 2].decode("utf-16-le")
3352
- except UnicodeDecodeError:
3353
- return False
3354
- return not utf16_prefix.strip()
3397
+ if offset is None or offset < 0:
3398
+ continue
3399
+ if units[: offset * 2].decode("utf-16-le", errors="ignore").strip():
3400
+ continue
3401
+ return True, self._entity_id(self._entity_payload(entity, "botCommand"), "botUserId")
3402
+ return False, None
3403
+
3404
+ def _leading_mention_user_ids(self, msg: Dict[str, Any], message_text: str) -> List[str]:
3405
+ """User ids in the run of mentions a message starts with, in order."""
3406
+ mentions: List[tuple[int, int, str]] = []
3407
+ for entity in self._message_entities(msg):
3408
+ if self._entity_kind(entity) != "mention":
3409
+ continue
3410
+ offset = _to_int(entity.get("offset"))
3411
+ length = _to_int(entity.get("length"))
3412
+ user_id = self._entity_id(self._entity_payload(entity, "mention"), "userId")
3413
+ if offset is None or length is None or offset < 0 or length <= 0 or not user_id:
3414
+ continue
3415
+ mentions.append((offset, length, user_id))
3416
+ units = message_text.encode("utf-16-le")
3417
+ leading: List[str] = []
3418
+ cursor = 0
3419
+ for offset, length, user_id in sorted(mentions):
3420
+ try:
3421
+ between = units[cursor * 2 : offset * 2].decode("utf-16-le")
3422
+ except UnicodeDecodeError:
3423
+ break
3424
+ if offset < cursor or between.replace(",", "").strip():
3425
+ break
3426
+ leading.append(user_id)
3427
+ cursor = offset + length
3428
+ return leading
3355
3429
 
3356
3430
  def _format_entity_summary(self, entity: Dict[str, Any], message_text: str) -> Optional[str]:
3357
3431
  kind = self._entity_kind(entity)
@@ -3844,15 +3918,60 @@ class InlineAdapter(BasePlatformAdapter):
3844
3918
  if key:
3845
3919
  self._chat_info_cache.pop(key, None)
3846
3920
 
3847
- def _reply_to_for_target(self, reply_to: Optional[str], target: Dict[str, str]) -> Optional[str]:
3921
+ def _adopt_legacy_state(self) -> None:
3922
+ legacy = legacy_state_dir() / "sdk-state.json"
3923
+ if self._state_path.exists() or not legacy.exists():
3924
+ return
3925
+ if not may_adopt_legacy_state():
3926
+ logger.warning(
3927
+ "[inline] not reusing shared legacy state %s for this profile; catching up from now", legacy,
3928
+ )
3929
+ return
3930
+ try:
3931
+ self._state_path.parent.mkdir(parents=True, exist_ok=True)
3932
+ shutil.copy2(legacy, self._state_path)
3933
+ logger.info("[inline] adopted legacy state %s as %s", legacy, self._state_path)
3934
+ except OSError as exc:
3935
+ logger.warning("[inline] could not adopt legacy state %s: %s", legacy, exc)
3936
+
3937
+ async def _reply_to_for_target(
3938
+ self, reply_to: Optional[str], target: Dict[str, str], *, request_chat_id: str = "", anchored: bool = False,
3939
+ ) -> Optional[str]:
3940
+ """Answers quote the message only when it came from a bot: a bot wakes on replies to its
3941
+ own messages, so that is how an answer reaches it. People get plain answers."""
3848
3942
  if not reply_to:
3849
3943
  return None
3850
- target_chat_id = target.get("chatId")
3944
+ target_chat_id = target.get("chatId") or ""
3851
3945
  if target_chat_id:
3852
3946
  suppressed = self._reply_thread_parent_reply_ids.get(self._chat_key(target_chat_id), set())
3853
3947
  if str(reply_to) in suppressed:
3854
3948
  return None
3855
- return str(reply_to)
3949
+ if anchored:
3950
+ return str(reply_to)
3951
+ key = f"{self._chat_key(target_chat_id)}:{reply_to}"
3952
+ sender_is_bot = self._request_sender_is_bot.get(key)
3953
+ if sender_is_bot is None and target_chat_id and self._chat_key(request_chat_id) == self._chat_key(target_chat_id):
3954
+ info = await self._get_chat_info(target_chat_id)
3955
+ if not info:
3956
+ return None
3957
+ if self._chat_info_id(info, "parentChatId") and self._chat_info_id(info, "parentMessageId") == str(reply_to):
3958
+ # Hermes keys an opener on its child session chat. After a restart that is
3959
+ # indistinguishable from an equal message id in the child; do not guess.
3960
+ return None
3961
+ # Not seen since this process started, such as an answer finishing after a restart.
3962
+ # Message ids are per chat, so only look it up where the request was sent; an answer
3963
+ # moved into a thread would otherwise read the thread's message with the same id.
3964
+ fetched = await self._fetch_message(target_chat_id, str(reply_to))
3965
+ sender_is_bot = _inline_sender_profile({}, fetched).get("bot") is True if fetched else False
3966
+ self._remember_request_sender(target_chat_id, str(reply_to), sender_is_bot)
3967
+ return str(reply_to) if sender_is_bot else None
3968
+
3969
+ def _remember_request_sender(self, chat_id: str, msg_id: str, sender_is_bot: bool) -> None:
3970
+ key = f"{self._chat_key(chat_id)}:{msg_id}"
3971
+ self._request_sender_is_bot[key] = sender_is_bot
3972
+ self._request_sender_is_bot.move_to_end(key)
3973
+ while len(self._request_sender_is_bot) > _CHAT_INFO_CACHE_MAX_SIZE:
3974
+ self._request_sender_is_bot.popitem(last=False)
3856
3975
 
3857
3976
  async def _get_chat_info(self, chat_id: str, *, required: bool = False) -> Dict[str, Any]:
3858
3977
  target = _target_from_chat_id(chat_id)
@@ -3885,6 +4004,22 @@ class InlineAdapter(BasePlatformAdapter):
3885
4004
  self._chat_info_cache.popitem(last=False)
3886
4005
  return info
3887
4006
 
4007
+ async def _dm_peer_of(self, chat_id: str) -> Optional[str]:
4008
+ """The person in this bot's DM, when chat_id is one."""
4009
+ try:
4010
+ info = await asyncio.wait_for(self._get_chat_info(chat_id), timeout=_INBOUND_ACCESS_LOOKUP_TIMEOUT_SECONDS)
4011
+ except Exception:
4012
+ return None
4013
+ if not info or self._bot_settings_chat_type(info) != "dm":
4014
+ return None
4015
+ peer = info.get("peer")
4016
+ if peer is None and isinstance(info.get("chat"), dict):
4017
+ peer = info["chat"].get("peerId")
4018
+ peer_type = (peer.get("peer") or peer.get("type") or {}) if isinstance(peer, dict) else {}
4019
+ user = peer_type.get("user") if isinstance(peer_type, dict) else None
4020
+ user_id = str((user or {}).get("userId") or "").strip() if isinstance(user, dict) else ""
4021
+ return user_id or None
4022
+
3888
4023
  @staticmethod
3889
4024
  def _chat_info_id(info: Dict[str, Any], key: str) -> Optional[str]:
3890
4025
  value = info.get(key)
@@ -3896,21 +4031,28 @@ class InlineAdapter(BasePlatformAdapter):
3896
4031
  return text or None
3897
4032
 
3898
4033
  @staticmethod
3899
- def _chat_follow_mode_following(info: Dict[str, Any]) -> bool:
4034
+ def _chat_follow_mode(info: Dict[str, Any]) -> Optional[str]:
4035
+ """This bot's own follow state in the chat: "following", "unfollowed" or None."""
3900
4036
  value = info.get("dialogFollowMode")
3901
4037
  dialog = info.get("dialog")
3902
4038
  if value is None and isinstance(dialog, dict):
3903
4039
  value = dialog.get("followMode")
3904
4040
  if value is None and isinstance(dialog, dict):
3905
4041
  value = dialog.get("follow_mode")
3906
- if value is None:
3907
- return False
3908
- if isinstance(value, bool):
3909
- return False
4042
+ if value is None or isinstance(value, bool):
4043
+ return None
3910
4044
  if isinstance(value, int):
3911
- return value == 1
4045
+ return {1: "following", 2: "unfollowed"}.get(value)
3912
4046
  text = str(value).strip().lower()
3913
- return text in {"1", "following", "follow_mode_following", "dialog_following"}
4047
+ if text in {"1", "following", "follow_mode_following", "dialog_following"}:
4048
+ return "following"
4049
+ if text in {"2", "unfollowed", "follow_mode_unfollowed", "dialog_unfollowed"}:
4050
+ return "unfollowed"
4051
+ return None
4052
+
4053
+ @classmethod
4054
+ def _chat_follow_mode_following(cls, info: Dict[str, Any]) -> bool:
4055
+ return cls._chat_follow_mode(info) == "following"
3914
4056
 
3915
4057
  @staticmethod
3916
4058
  def _chat_title_from_info(info: Dict[str, Any]) -> Optional[str]:
@@ -4253,7 +4395,7 @@ class InlineAdapter(BasePlatformAdapter):
4253
4395
  await self._answer_action(interaction_id, "Thread controls expired")
4254
4396
  return True
4255
4397
  _, session_id, choice = parts
4256
- if choice not in {"auto", "on", "off", "reset"}:
4398
+ if choice not in {"auto", "on", "off", "reset"}: # off: buttons sent before 1.0
4257
4399
  await self._answer_action(interaction_id, "Thread controls expired")
4258
4400
  return True
4259
4401
  state = self._thread_action_state(session_id, chat_id)
@@ -4266,11 +4408,14 @@ class InlineAdapter(BasePlatformAdapter):
4266
4408
  return True
4267
4409
  if not await self._action_allowed(event):
4268
4410
  return True
4269
- if target_chat_id != chat_id and not await self._chat_actor_authorized(
4270
- target_chat_id, str(event.get("actorUserId") or ""),
4271
- ):
4411
+ actor_id = str(event.get("actorUserId") or "")
4412
+ if target_chat_id != chat_id and not await self._chat_actor_authorized(target_chat_id, actor_id):
4272
4413
  await self._answer_action(interaction_id, "Parent chat access could not be confirmed. Check access and try again.")
4273
4414
  return True
4415
+ scope = await self._message_scope(chat_id, await self._fetch_message(chat_id, str(event.get("messageId") or "")) or {})
4416
+ if not self._may_change_reply_threads(actor_id, scope[0] if scope else "group"):
4417
+ await self._answer_action(interaction_id, "Not authorized")
4418
+ return True
4274
4419
  try:
4275
4420
  if choice == "reset":
4276
4421
  self._set_reply_threads_for_chat(target_chat_id, None)
@@ -4603,9 +4748,13 @@ class InlineAdapter(BasePlatformAdapter):
4603
4748
  if actions is not None:
4604
4749
  body["actions"] = actions
4605
4750
  result = await self._send_sidecar("/edit", body)
4751
+ if not result.success and getattr(result, "retryable", False):
4752
+ # A transient failure would leave the answered card's buttons in place.
4753
+ await asyncio.sleep(_ACTION_EDIT_RETRY_DELAY_SECONDS)
4754
+ result = await self._send_sidecar("/edit", body)
4606
4755
  if result.success:
4607
4756
  return result
4608
- logger.debug("[inline] edit action message failed; sending fallback: %s", result.error)
4757
+ logger.warning("[inline] could not update the answered action card; posting the outcome instead: %s", result.error)
4609
4758
  return await self.send(chat_id, text)
4610
4759
 
4611
4760
  async def _answer_action(self, interaction_id: str, toast: str) -> None:
@@ -4624,6 +4773,7 @@ class InlineAdapter(BasePlatformAdapter):
4624
4773
  metadata: Optional[Dict[str, Any]] = None,
4625
4774
  actions: Optional[Dict[str, Any]] = None,
4626
4775
  ) -> SendResult:
4776
+ metadata = self._with_turn_thread(chat_id, reply_to, metadata)
4627
4777
  target = self._target_for(chat_id, metadata)
4628
4778
  agent_action_target = parse_inline_agent_action_reply_target(reply_to)
4629
4779
  if agent_action_target:
@@ -4667,7 +4817,7 @@ class InlineAdapter(BasePlatformAdapter):
4667
4817
  first_result.error,
4668
4818
  )
4669
4819
  reply_to = agent_action_target
4670
- reply_to = self._reply_to_for_target(reply_to, target)
4820
+ reply_to = await self._reply_to_for_target(reply_to, target, request_chat_id=chat_id, anchored=bool(agent_action_target))
4671
4821
  chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
4672
4822
  message_ids: List[str] = []
4673
4823
  raw_responses: List[Any] = []
@@ -4839,7 +4989,8 @@ class InlineAdapter(BasePlatformAdapter):
4839
4989
  async def send_typing(self, chat_id: str, metadata=None) -> None:
4840
4990
  try:
4841
4991
  target = self._typing_target_for(chat_id, metadata)
4842
- await self._sidecar_call("/typing", {"target": target, "state": "start"})
4992
+ # Agents show "working", not "typing"; clients without it see typing.
4993
+ await self._sidecar_call("/typing", {"target": target, "state": "working"})
4843
4994
  self._active_typing_targets[self._typing_target_key(chat_id, metadata)] = target
4844
4995
  except Exception as exc:
4845
4996
  logger.debug("[inline] typing failed: %s", exc)
@@ -4868,7 +5019,10 @@ class InlineAdapter(BasePlatformAdapter):
4868
5019
  if (kind and kind != "message.new") or raw_message.get("_inlineAgentAction"):
4869
5020
  return None
4870
5021
  source = getattr(event, "source", None)
4871
- chat_id = str(getattr(source, "chat_id", None) or "").strip()
5022
+ # React where the message physically is: an opener that starts a reply thread is keyed
5023
+ # to the thread's session but still lives in the parent chat.
5024
+ physical_chat_id = raw_message.get("chatId") if isinstance(raw_message, dict) else None
5025
+ chat_id = str(physical_chat_id or getattr(source, "chat_id", None) or "").strip()
4872
5026
  message_id = str(getattr(event, "message_id", None) or "").strip()
4873
5027
  if not chat_id or not message_id:
4874
5028
  return None
@@ -4979,7 +5133,7 @@ class InlineAdapter(BasePlatformAdapter):
4979
5133
  return _send_result(success=False, error=f"attachment exceeds Inline upload cap ({actual} > {limit})", error_kind="too_long")
4980
5134
  mime_type, _ = mimetypes.guess_type(safe_path)
4981
5135
  target = self._target_for(chat_id, metadata)
4982
- reply_to = self._reply_to_for_target(reply_to, target)
5136
+ reply_to = await self._reply_to_for_target(reply_to, target, request_chat_id=chat_id)
4983
5137
  body = {
4984
5138
  "target": target,
4985
5139
  "path": safe_path,
@@ -5540,7 +5694,7 @@ def _standalone_attachment_kind(path: str, is_voice: bool, force_document: bool)
5540
5694
  def _inline_threads_command_handler(raw_args: str) -> str:
5541
5695
  text = f"/threads {str(raw_args or '').strip()}".strip()
5542
5696
  action = InlineAdapter._thread_command_action(text) or "help"
5543
- usage = "Usage: /threads status, /threads auto, /threads on, /threads off, or /threads reset."
5697
+ usage = "Usage: /threads status, /threads auto, /threads on, or /threads reset."
5544
5698
  if action == "help":
5545
5699
  return usage
5546
5700
  return (