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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ from collections import OrderedDict
5
6
  import os
6
7
  import re
7
8
  import urllib.error
@@ -9,6 +10,7 @@ import urllib.request
9
10
  from typing import Any, Dict, Iterable, Optional
10
11
 
11
12
  from .message_actions import build_inline_agent_action_id
13
+ from .profile import hermes_home, inline_env, multiplex_active
12
14
 
13
15
  try:
14
16
  from tools.registry import tool_error, tool_result
@@ -76,11 +78,57 @@ INLINE_PLATFORM_GUIDANCE = (
76
78
  "Use <footer>Attribution or brief metadata</footer> for a short footer."
77
79
  )
78
80
 
79
- _sidecar: Dict[str, Any] = {}
81
+ # Where each turn's triggering message physically lives, and who sent it. Hermes tells tools
82
+ # the session's chat and message id, but a thread's opener is keyed on the thread while its
83
+ # message lives in the parent chat, so "this message" must be paired with its own chat.
84
+ _turn_messages: "OrderedDict[tuple[str, str, str, str], Dict[str, Any]]" = OrderedDict()
85
+ _TURN_MESSAGES_MAX = 1000
86
+
87
+
88
+ def remember_turn_message(
89
+ *, session_chat_id: str, thread_id: str, message_id: str, chat_id: str, sender_id: str, sender_is_bot: bool,
90
+ chat_type: str = "", in_reply_thread: bool = False,
91
+ ) -> None:
92
+ key = (str(hermes_home()), str(session_chat_id or ""), str(thread_id or ""), str(message_id or ""))
93
+ _turn_messages[key] = {
94
+ "chatId": str(chat_id), "senderId": str(sender_id or ""), "senderIsBot": bool(sender_is_bot),
95
+ "chatType": str(chat_type or ""), "inReplyThread": bool(in_reply_thread),
96
+ }
97
+ _turn_messages.move_to_end(key)
98
+ while len(_turn_messages) > _TURN_MESSAGES_MAX:
99
+ _turn_messages.popitem(last=False)
100
+
101
+
102
+ def _current_turn() -> Dict[str, Any]:
103
+ """This turn's message: its id, the chat it lives in, and its sender when known."""
104
+ message_id = _session_message_id()
105
+ session_chat = _session_chat_id(prefer_thread=False)
106
+ thread_id = _inline_id(_session_env("HERMES_SESSION_THREAD_ID", ""))
107
+ known = _turn_messages.get((str(hermes_home()), session_chat, thread_id, message_id)) or {}
108
+ # The turn already runs in a thread when reply threads moved it there (its message lives
109
+ # in the parent chat) or when its message was sent inside the thread.
110
+ in_thread = bool(thread_id) and (bool(known.get("inReplyThread")) or (known.get("chatId") or session_chat) != thread_id)
111
+ return {
112
+ "messageId": message_id,
113
+ "chatId": known.get("chatId") or session_chat,
114
+ "senderId": known.get("senderId") or "",
115
+ "senderIsBot": known.get("senderIsBot"),
116
+ "chatType": known.get("chatType") or "",
117
+ "inReplyThread": bool(known.get("inReplyThread")),
118
+ "threadId": thread_id if in_thread else "",
119
+ }
120
+
121
+
122
+ # Live sidecars by profile home: one gateway process can serve several profiles,
123
+ # and a tool call must reach the sidecar of the bot it runs for.
124
+ _sidecars: Dict[str, Dict[str, Any]] = {}
125
+ # The adapter that owns each profile's Inline settings (reply threads), for the same reason.
126
+ _settings_owners: Dict[str, Any] = {}
80
127
 
81
128
  _ACTION_MANIFEST = [
82
129
  ("send_message", "(chat_id?|user_id?, text, buttons?)", "Send a message, including optional agent-owned buttons."),
83
- ("get_chat", "(chat_id?)", "Get metadata for a chat or reply thread."),
130
+ ("get_chat", "(chat_id?)", "Get metadata for a chat or reply thread, including whether you follow it."),
131
+ ("set_follow_mode", "(chat_id?, mode)", "Follow a thread to join its conversation, or unfollow it to answer only mentions and replies. Applies to you only; mode is following or unfollowed."),
84
132
  ("get_messages", "(chat_id?, message_ids)", "Fetch specific Inline messages by ID."),
85
133
  ("get_history", "(chat_id?, limit?, anchor_id?)", "Fetch recent or anchored Inline history."),
86
134
  ("search_messages", "(chat_id?|user_id?, query, limit?, offset_id?)", "Search Inline message text in a chat, thread, or DM."),
@@ -92,8 +140,12 @@ _ACTION_MANIFEST = [
92
140
  ("pin_message", "(chat_id?|user_id?, message_id?)", "Pin an Inline message for the conversation."),
93
141
  ("unpin_message", "(chat_id?|user_id?, message_id?)", "Unpin an Inline message for the conversation."),
94
142
  ("list_pins", "(chat_id?)", "List pinned Inline message IDs for a chat or reply thread."),
95
- ("create_thread", "(parent_chat_id?, parent_message_id?, title?)", "Create an Inline reply thread."),
96
- ("create_chat", "(title, space_id?, participant_user_ids?, is_public?)", "Create a top-level Inline thread/chat."),
143
+ ("create_thread", "(parent_chat_id?, parent_message_id?, title?)", "Create an Inline reply thread on a message. For the message you're answering, your answer goes into the thread; if this conversation already has its thread, you get that one back."),
144
+ ("set_reply_threads", "(mode)", "Change where your answers go in this chat when someone asks you to: auto answers in the chat and starts a thread only when asked; on answers each message in a reply thread; default clears this chat's setting."),
145
+ ("create_chat", "(title, space_id?, participant_user_ids?, is_public?, include_requester?)", "Create a top-level Inline thread/chat. A private chat includes the person who asked unless include_requester is false."),
146
+ ("add_participants", "(chat_id?, user_ids)", "Add people or agents to a chat or thread you manage."),
147
+ ("update_chat", "(chat_id?, title?, emoji?)", "Rename a chat or thread, or change its emoji."),
148
+ ("delete_chat", "(chat_id)", "Delete a chat or thread you created. This can't be undone."),
97
149
  ("create_agent", "(name, skill_key?, instructions?)", "Create a named Inline Agent backed by this bot."),
98
150
  ("get_agent", "(agent_id)", "Inspect one Agent backed by this bot."),
99
151
  ("list_agents", "()", "List Agents backed by this bot."),
@@ -106,16 +158,43 @@ _PRESENCE_KINDS = ["idle", "happy", "waving", "jumping", "failed", "waiting", "r
106
158
 
107
159
 
108
160
  def configure_sidecar(*, bind: str, port: int, token: str) -> None:
109
- """Store live adapter sidecar details for model tools in this process."""
161
+ """Store live adapter sidecar details for model tools in this profile."""
110
162
  if not token:
111
163
  return
112
- _sidecar.update({"bind": bind, "port": int(port), "token": token})
164
+ _sidecars[str(hermes_home())] = {"bind": bind, "port": int(port), "token": token}
165
+
166
+
167
+ def _current_sidecar() -> Dict[str, Any]:
168
+ current = _sidecars.get(str(hermes_home()))
169
+ if current is not None:
170
+ return current
171
+ # Only a gateway serving one profile may fall back to its one sidecar; with several
172
+ # profiles, another profile's sidecar would act as the wrong bot.
173
+ if len(_sidecars) == 1 and not multiplex_active():
174
+ return next(iter(_sidecars.values()))
175
+ if _sidecars:
176
+ raise InlineToolError("Inline is not connected for this profile", "forbidden")
177
+ return {}
178
+
179
+
180
+ def configure_settings(owner: Any) -> None:
181
+ """Register the adapter that keeps this profile's Inline settings."""
182
+ _settings_owners[str(hermes_home())] = owner
183
+
184
+
185
+ def _current_settings_owner() -> Any:
186
+ current = _settings_owners.get(str(hermes_home()))
187
+ if current is not None or multiplex_active():
188
+ return current
189
+ return next(iter(_settings_owners.values())) if len(_settings_owners) == 1 else None
113
190
 
114
191
 
115
192
  def check_inline_tool_requirements() -> bool:
116
- if _sidecar.get("token") or os.getenv("INLINE_SIDECAR_TOKEN"):
193
+ if multiplex_active():
194
+ return str(hermes_home()) in _sidecars
195
+ if _sidecars or inline_env("INLINE_SIDECAR_TOKEN"):
117
196
  return True
118
- return bool(os.getenv("INLINE_TOKEN") or os.getenv("INLINE_BOT_TOKEN"))
197
+ return bool(inline_env("INLINE_TOKEN") or inline_env("INLINE_BOT_TOKEN"))
119
198
 
120
199
 
121
200
  def inline_sender_guidance(
@@ -186,7 +265,8 @@ INLINE_TOOL_SCHEMA = {
186
265
  "When chat_id is omitted, the tool uses the current Inline reply thread if present, otherwise the current chat. "
187
266
  "Return normal assistant replies as text. Use send_message only for a button card or a different destination. "
188
267
  "Agent-owned button presses return as ordinary turns, and the normal response replaces the source card while clearing omitted buttons. "
189
- "Use create_thread with the current triggering message as parent_message_id to move large top-level discussions into a reply thread. "
268
+ "Use create_thread on the current triggering message when someone asks to continue in a thread; your answer then goes into it, so don't also post it in the chat. "
269
+ "Use set_reply_threads when someone asks you to always or never answer in threads here. "
190
270
  "Use create_chat to create a new top-level destination outside the current conversation; it defaults to private, and public creation must explicitly set is_public with a space_id. "
191
271
  "Use search_messages for exact catch-up across older chat history. "
192
272
  "Use pin_message/unpin_message only when the user explicitly asks because pins are durable shared-chat state. "
@@ -213,14 +293,14 @@ INLINE_TOOL_SCHEMA = {
213
293
  "type": "string",
214
294
  "description": "Parent message ID for create_thread. Defaults to the triggering message when available.",
215
295
  },
216
- "title": {"type": "string", "description": "Thread title. Required for create_chat and optional for create_thread."},
296
+ "title": {"type": "string", "description": "Thread title. Required for create_chat, optional for create_thread, and the new name for update_chat."},
217
297
  "name": {"type": "string", "description": "Agent name for create_agent."},
218
298
  "agent_id": {"type": "string", "description": "Globally unique Inline Agent ID for get_agent, update_agent, or delete_agent."},
219
299
  "handle": {"type": "string", "description": "Optional Agent handle."},
220
300
  "skill_key": {"type": "string", "description": "Optional harness skill key for create_agent or update_agent."},
221
301
  "instructions": {"type": "string", "description": "Optional specialized Agent instructions for create_agent or update_agent."},
222
302
  "description": {"type": "string", "description": "Optional thread or Agent description."},
223
- "emoji": {"type": "string", "description": "Optional thread or Agent emoji, or reaction emoji for reaction actions."},
303
+ "emoji": {"type": "string", "description": "Reaction emoji for reaction actions. For create_chat, create_thread and update_chat, one fitting emoji helps people spot the chat in their list; pass one unless the topic is sensitive or serious. Also an Agent's emoji."},
224
304
  "space_id": {"type": "string", "description": "Optional parent space ID for create_chat."},
225
305
  "participant_user_ids": {
226
306
  "type": "array",
@@ -263,6 +343,9 @@ INLINE_TOOL_SCHEMA = {
263
343
  "offset_id": {"type": "string", "description": "Offset message ID for search_messages pagination."},
264
344
  "kind": {"type": "string", "enum": _PRESENCE_KINDS, "description": "Bot avatar presence/status kind."},
265
345
  "comment": {"type": "string", "description": "Optional bot avatar presence/status message."},
346
+ "user_ids": {"type": "array", "items": {"type": "string"}, "description": "add_participants: Inline user IDs of the people or agents to add."},
347
+ "include_requester": {"type": "boolean", "description": "create_chat: include the person who asked (default true for private chats). Set false only for a deliberately bot-only chat."},
348
+ "mode": {"type": "string", "enum": ["following", "unfollowed", "auto", "on", "default"], "description": "set_follow_mode: following joins the thread's conversation; unfollowed limits you to mentions and replies. set_reply_threads: auto, on, or default to clear this chat's setting."},
266
349
  },
267
350
  "required": ["action"],
268
351
  },
@@ -290,6 +373,12 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str:
290
373
  return tool_error(f"inline: unknown action {action or '(empty)'}", allowed_actions=_ACTIONS)
291
374
 
292
375
  try:
376
+ if action == "set_follow_mode":
377
+ return tool_result({"success": True, "action": action, "result": _set_follow_mode(args)})
378
+ if action == "set_reply_threads":
379
+ return tool_result({"success": True, "action": action, "result": _set_reply_threads(args)})
380
+ if action == "create_thread":
381
+ return tool_result({"success": True, "action": action, "result": _create_thread(args)})
293
382
  path, body = _request_for_action(action, args)
294
383
  response = _sidecar_call(path, body)
295
384
  result = _compact_result(action, response.get("result") or {})
@@ -359,9 +448,10 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
359
448
  }
360
449
 
361
450
  if action in {"add_reaction", "remove_reaction"}:
451
+ target, message_id = _message_target(args)
362
452
  body = {
363
- "target": _target(args),
364
- "messageId": _message_id_or_current(args),
453
+ "target": target,
454
+ "messageId": message_id,
365
455
  "emoji": _required_str(args, "emoji", max_chars=64),
366
456
  }
367
457
  if action == "remove_reaction":
@@ -369,16 +459,12 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
369
459
  return "/reaction", body
370
460
 
371
461
  if action == "get_reactions":
372
- return "/reactions", {
373
- "target": _target(args),
374
- "messageId": _message_id_or_current(args),
375
- }
462
+ target, message_id = _message_target(args)
463
+ return "/reactions", {"target": target, "messageId": message_id}
376
464
 
377
465
  if action in {"pin_message", "unpin_message"}:
378
- body = {
379
- "target": _target(args),
380
- "messageId": _message_id_or_current(args),
381
- }
466
+ target, message_id = _message_target(args)
467
+ body = {"target": target, "messageId": message_id}
382
468
  if action == "unpin_message":
383
469
  body["unpin"] = True
384
470
  return "/pin", body
@@ -387,11 +473,15 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
387
473
  return "/pins", {"target": _target(args)}
388
474
 
389
475
  if action == "create_thread":
390
- parent_chat_id = _inline_id(args.get("parent_chat_id")) or _session_chat_id(prefer_thread=False)
476
+ turn = _current_turn()
477
+ parent_chat_id = _inline_id(args.get("parent_chat_id")) or turn["chatId"]
391
478
  if not parent_chat_id:
392
479
  raise InlineToolError("create_thread requires parent_chat_id or current Inline chat context", "bad_format")
393
480
  body = {"parentChatId": parent_chat_id}
394
- parent_message_id = _inline_id(args.get("parent_message_id")) or _session_message_id()
481
+ # Message ids are per chat: anchor on this turn's message only inside its own chat.
482
+ parent_message_id = _inline_id(args.get("parent_message_id")) or (
483
+ turn["messageId"] if parent_chat_id == turn["chatId"] else ""
484
+ )
395
485
  if parent_message_id:
396
486
  body["parentMessageId"] = parent_message_id
397
487
  for key in ("title", "description", "emoji"):
@@ -414,6 +504,12 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
414
504
  raise InlineToolError("public create_chat requires space_id", "bad_format")
415
505
  if participant_user_ids:
416
506
  raise InlineToolError("public create_chat cannot include participant_user_ids", "bad_format")
507
+ if not body["isPublic"] and _bool(args.get("include_requester"), True):
508
+ # The person who asked can open the chat the agent made for them (Jack, J13).
509
+ turn = _current_turn()
510
+ requester = _inline_id(turn["senderId"])
511
+ if requester and turn["senderIsBot"] is False and requester not in participant_user_ids:
512
+ participant_user_ids = [requester, *participant_user_ids]
417
513
  body["participantUserIds"] = participant_user_ids
418
514
  for key, limit in (("description", 1000), ("emoji", 16)):
419
515
  value = _str(args.get(key))
@@ -421,6 +517,28 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
421
517
  body[key] = _truncate(value, limit)
422
518
  return "/create-chat", body
423
519
 
520
+ if action == "add_participants":
521
+ user_ids = _id_list(args.get("user_ids"), max_items=50)
522
+ if not user_ids:
523
+ raise InlineToolError("add_participants requires user_ids", "bad_format")
524
+ return "/add-participants", {"chatId": _chat_id_or_current(args), "userIds": user_ids}
525
+
526
+ if action == "update_chat":
527
+ body = {"chatId": _chat_id_or_current(args)}
528
+ title = _str(args.get("title"))
529
+ emoji = _str(args.get("emoji"))
530
+ if title:
531
+ body["title"] = _truncate(title, 200)
532
+ if emoji:
533
+ body["emoji"] = _truncate(emoji, 16)
534
+ if len(body) == 1:
535
+ raise InlineToolError("update_chat requires title or emoji", "bad_format")
536
+ return "/update-chat", body
537
+
538
+ if action == "delete_chat":
539
+ # Deleting is never implied by the current chat: name it.
540
+ return "/delete-chat", {"chatId": _required_id(args, "chat_id")}
541
+
424
542
  if action == "create_agent":
425
543
  body = {"name": _required_str(args, "name", max_chars=256)}
426
544
  for key in ("handle", "emoji", "description", "skill_key", "instructions"):
@@ -462,6 +580,94 @@ def _request_for_action(action: str, args: Dict[str, Any]) -> tuple[str, Dict[st
462
580
  raise InlineToolError(f"inline: unimplemented action {action}", "bad_format")
463
581
 
464
582
 
583
+ def _set_follow_mode(args: Dict[str, Any]) -> Dict[str, Any]:
584
+ mode = _str(args.get("mode")).lower()
585
+ if mode not in {"following", "unfollowed"}:
586
+ raise InlineToolError("set_follow_mode requires mode: following or unfollowed", "bad_format")
587
+ target = _target(args)
588
+ if "chatId" not in target:
589
+ raise InlineToolError("Follow state applies to threads, not direct messages", "unsupported")
590
+ write_error: Optional[InlineToolError] = None
591
+ try:
592
+ _sidecar_call("/follow-mode", {"target": target, "mode": mode})
593
+ except InlineToolError as exc:
594
+ if exc.error_kind != "transient":
595
+ raise
596
+ write_error = exc
597
+ # Report the state the server holds, not the one requested: a lost response can
598
+ # still have applied the change.
599
+ chat = _sidecar_call("/chat", {"target": target}).get("result") or {}
600
+ actual = _follow_mode_from_chat(chat)
601
+ if actual == mode:
602
+ return {"chatId": target["chatId"], "mode": actual, "message": _FOLLOW_CONFIRMATIONS[mode]}
603
+ if write_error:
604
+ raise write_error
605
+ raise InlineToolError(f"Follow state is {actual or 'default'} after the change", "unknown")
606
+
607
+
608
+ def _set_reply_threads(args: Dict[str, Any]) -> Dict[str, Any]:
609
+ mode = _str(args.get("mode")).lower()
610
+ if mode not in {"auto", "on", "default"}:
611
+ raise InlineToolError("set_reply_threads requires mode auto, on or default", "bad_format")
612
+ owner = _current_settings_owner()
613
+ if owner is None:
614
+ raise InlineToolError("Inline settings aren't available for this profile", "unsupported")
615
+ turn = _current_turn()
616
+ if not turn.get("senderId"):
617
+ raise InlineToolError("Only the person who asked can change reply threads, and this turn has no sender", "forbidden")
618
+ try:
619
+ return owner.set_reply_threads_from_tool(
620
+ chat_id=turn["chatId"],
621
+ chat_type=turn.get("chatType") or "group",
622
+ in_reply_thread=bool(turn.get("inReplyThread")),
623
+ actor_id=turn["senderId"],
624
+ actor_is_bot=bool(turn.get("senderIsBot")),
625
+ mode=mode,
626
+ )
627
+ except PermissionError as exc:
628
+ raise InlineToolError(str(exc), "forbidden") from exc
629
+
630
+
631
+ def _create_thread(args: Dict[str, Any]) -> Dict[str, Any]:
632
+ """One conversation, one thread. A thread for the message being answered is this turn's
633
+ thread: if the turn already runs in one (reply threads on, or a message inside a thread),
634
+ that thread comes back instead of a second one, and a new one receives the turn's answer."""
635
+ path, body = _request_for_action("create_thread", args)
636
+ turn = _current_turn()
637
+ owner = _current_settings_owner()
638
+ for_this_turn = bool(turn["messageId"]) and body["parentChatId"] == turn["chatId"] and (
639
+ body.get("parentMessageId") in {None, turn["messageId"]}
640
+ )
641
+ if for_this_turn:
642
+ existing = turn.get("threadId") or (owner.turn_thread(turn["chatId"], turn["messageId"]) if owner else "")
643
+ if existing:
644
+ return {"chatId": existing, "existing": True, "message": "This conversation already has its thread; your answer goes there."}
645
+ result = _compact_result("create_thread", _sidecar_call(path, body).get("result") or {})
646
+ thread_id = _inline_id(result.get("chatId"))
647
+ if for_this_turn and owner is not None and thread_id:
648
+ owner.note_turn_thread(turn["chatId"], turn["messageId"], thread_id)
649
+ return result
650
+
651
+
652
+ _FOLLOW_CONFIRMATIONS = {
653
+ "following": "Following this chat.",
654
+ "unfollowed": "Unfollowed; mentions and replies still reach me.",
655
+ }
656
+
657
+
658
+ def _follow_mode_from_chat(chat: Dict[str, Any]) -> Optional[str]:
659
+ value = chat.get("dialogFollowMode") if isinstance(chat, dict) else None
660
+ dialog = chat.get("dialog") if isinstance(chat, dict) else None
661
+ if value is None and isinstance(dialog, dict):
662
+ value = dialog.get("followMode")
663
+ text = _str(value).lower()
664
+ if text in {"1", "following"}:
665
+ return "following"
666
+ if text in {"2", "unfollowed"}:
667
+ return "unfollowed"
668
+ return None
669
+
670
+
465
671
  def _sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
466
672
  base_url, token = _sidecar_config()
467
673
  request = urllib.request.Request(
@@ -496,11 +702,12 @@ def _sidecar_call(path: str, body: Dict[str, Any]) -> Dict[str, Any]:
496
702
 
497
703
 
498
704
  def _sidecar_config() -> tuple[str, str]:
499
- token = _str(_sidecar.get("token")) or _str(os.getenv("INLINE_SIDECAR_TOKEN"))
705
+ _sidecar = _current_sidecar()
706
+ token = _str(_sidecar.get("token")) or _str(inline_env("INLINE_SIDECAR_TOKEN"))
500
707
  if not token:
501
708
  raise InlineToolError("Inline sidecar token is not configured; start the Inline gateway adapter first", "forbidden")
502
- bind = _normalize_bind(_sidecar.get("bind") or os.getenv("INLINE_SIDECAR_BIND"))
503
- port = _normalize_port(_sidecar.get("port") or os.getenv("INLINE_SIDECAR_PORT"))
709
+ bind = _normalize_bind(_sidecar.get("bind") or inline_env("INLINE_SIDECAR_BIND"))
710
+ port = _normalize_port(_sidecar.get("port") or inline_env("INLINE_SIDECAR_PORT"))
504
711
  host = f"[{bind}]" if ":" in bind and not bind.startswith("[") else bind
505
712
  return f"http://{host}:{port}", token
506
713
 
@@ -570,11 +777,27 @@ def _message_ids(args: Dict[str, Any]) -> list[str]:
570
777
  return ids
571
778
 
572
779
 
573
- def _message_id_or_current(args: Dict[str, Any]) -> str:
574
- message_id = _inline_id(args.get("message_id")) or _session_message_id()
575
- if not message_id:
780
+ def _message_target(args: Dict[str, Any]) -> tuple[Dict[str, str], str]:
781
+ """Target and message id; "this message" always goes with the chat it lives in."""
782
+ explicit = _inline_id(args.get("message_id"))
783
+ if explicit:
784
+ return _target(args), explicit
785
+ turn = _current_turn()
786
+ if not turn["messageId"]:
787
+ raise InlineToolError("message_id is required outside an Inline message context", "bad_format")
788
+ own_chat = {"chatId": turn["chatId"]} if turn["chatId"] else None
789
+ if (_inline_id(args.get("chat_id")) or _inline_id(args.get("user_id"))) and _target(args) != own_chat:
790
+ raise InlineToolError("message_id is required when targeting a different chat", "bad_format")
791
+ if own_chat is None:
576
792
  raise InlineToolError("message_id is required outside an Inline message context", "bad_format")
577
- return message_id
793
+ return own_chat, turn["messageId"]
794
+
795
+
796
+ def _chat_id_or_current(args: Dict[str, Any]) -> str:
797
+ chat_id = _inline_id(args.get("chat_id")) or _session_chat_id(prefer_thread=True)
798
+ if not chat_id:
799
+ raise InlineToolError("chat_id is required outside an Inline chat", "bad_format")
800
+ return chat_id
578
801
 
579
802
 
580
803
  def _message_actions(args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@@ -723,6 +946,8 @@ def _compact_chat(chat: Dict[str, Any]) -> Dict[str, Any]:
723
946
  ]:
724
947
  if source in chat and chat[source] is not None and dest not in out:
725
948
  out[dest] = chat[source]
949
+ if "chatId" in out:
950
+ out["followMode"] = _follow_mode_from_chat(chat) or "default"
726
951
  return out
727
952
 
728
953