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