@securecoms/agent-bridge 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,9 @@ Flow:
13
13
  --> Hermes gateway --> LLM --> send() --> daemon --MLS--> owner
14
14
  outbound: the agent's reply is delivered to send(), which POSTs it to the
15
15
  daemon's /v1/send; the daemon encrypts and relays it.
16
+ ack: an inbound frame is acked to the daemon once Hermes has durably
17
+ accepted its turn (see _receive_message); after that, crash
18
+ recovery is Hermes's own (its persisted active-turn marker).
16
19
 
17
20
  This is the universal daemon-client pattern wearing a Hermes BasePlatformAdapter
18
21
  shim. The same daemon serves any framework; only this thin shim is
@@ -60,6 +63,7 @@ import json
60
63
  import logging
61
64
  import os
62
65
  import time
66
+ from collections import OrderedDict
63
67
  from datetime import datetime
64
68
  from typing import Any, Dict, Optional
65
69
 
@@ -108,6 +112,57 @@ _REFUSAL_NOTICE_SENT: set = set()
108
112
  _REFUSAL_NOTICE_MAX = 4096
109
113
  # Inbound frames seen without an authorization block (daemon too old).
110
114
  _FRAMES_WITHOUT_AUTHORIZATION = 0
115
+ # Inbound attachments dropped by securecoms_transport.scan_inbound_attachments.
116
+ _ATTACHMENTS_DROPPED = 0
117
+ # Unknown-channel lookups (review r2): one /v1/channels refresh per unknown
118
+ # channel per minute, each bounded so a slow daemon cannot stall a turn.
119
+ _UNKNOWN_CHANNEL_RETRY_S = 60.0
120
+ _UNKNOWN_CHANNEL_LOOKUPS_MAX = 4096
121
+ _CHANNEL_REFRESH_TIMEOUT_S = 5.0
122
+
123
+ # Ack on durable acceptance (see _receive_message). Hermes's handle_message
124
+ # returns as soon as it has spawned the turn as a background task, so an ack
125
+ # sent when it returned could reach the daemon before Hermes held the message
126
+ # anywhere durable: a crash then lost it (the daemon never redelivers an acked
127
+ # row). Acking after the WHOLE turn is wrong too: the daemon delivers one
128
+ # message per channel and the next only after the ack (ws-server.ts F2), which
129
+ # would hold back /stop, /approve and clarify answers until the turn ended, and
130
+ # Hermes already resumes a crashed turn from its own durable marker, so a
131
+ # daemon redelivery on top would run it twice. So the ack goes out once Hermes
132
+ # has persisted its active-turn marker for this event, or finished with it.
133
+ # Redeliveries are deduped by message_id in a bounded ledger.
134
+ _DEDUP_MAX_ENTRIES = 2048
135
+ _DEDUP_TTL_S = 600.0
136
+ # How long to wait for Hermes to accept a turn that starts its own session
137
+ # task before acking anyway (holding the ack holds the channel, F2).
138
+ _ACCEPT_WAIT_S = 10.0
139
+ _ACCEPT_POLL_S = 0.02
140
+ # One warning per process when this Hermes lacks the acceptance signal.
141
+ _TURN_TRACKING_WARNED = False
142
+
143
+ # H0: Hermes session key -> [(ledger token, runner key)] for every ungranted
144
+ # event dispatched into that session and not yet released. Module-level, like
145
+ # _TURN_AUTHZ, so a replaced adapter instance or a task cancelled before its
146
+ # first step cannot strand a count: the next completion that finds the session
147
+ # idle releases it, whichever adapter instance observes it.
148
+ _ATTACHED: Dict[Any, list] = {}
149
+ # One warning per process each: an idle input Hermes does not expose (so no
150
+ # count can be released), and H0.2b's shared room sessions.
151
+ _IDLE_INPUTS_WARNED = False
152
+ _SHARED_ROOMS_WARNED = False
153
+
154
+ # Hermes's Signal adapter mapping (_MEDIA_TYPE_BY_MIME_PREFIX): first matching
155
+ # prefix wins; anything else is a DOCUMENT so Hermes surfaces the file path.
156
+ _MEDIA_TYPE_BY_MIME_PREFIX = (("audio/", "VOICE"), ("image/", "PHOTO"), ("video/", "VIDEO"))
157
+
158
+
159
+ def _message_type_for_mimes(mimes):
160
+ name = next(
161
+ (n for prefix, n in _MEDIA_TYPE_BY_MIME_PREFIX if any(m.startswith(prefix) for m in mimes)),
162
+ "DOCUMENT",
163
+ )
164
+ return getattr(MessageType, name, MessageType.TEXT)
165
+
111
166
 
112
167
  _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
113
168
  _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
@@ -146,6 +201,69 @@ def _error_class(error: object) -> str:
146
201
  return name
147
202
 
148
203
 
204
+ class _DeliveryLedger:
205
+ """Bounded record of inbound message_ids that are in flight or done.
206
+
207
+ begin() answers "new" (record it in flight and dispatch), "in_flight" (a
208
+ redelivery of a turn still running: do not dispatch, the original's ack
209
+ covers it) or "done" (already finished: ack again, no turn). An in-flight
210
+ entry is never evicted or expired, since dropping it would let a
211
+ redelivery run the turn twice; the daemon keeps at most one message per
212
+ channel in flight, so those stay few. Done entries expire after ttl_s and
213
+ the oldest go first once the ledger holds more than max_entries, except
214
+ pinned ones (acked by us but never delivered to the daemon).
215
+ """
216
+
217
+ def __init__(self, max_entries: int, ttl_s: float, now) -> None:
218
+ self._max = max_entries
219
+ self._ttl = ttl_s
220
+ self._now = now
221
+ # message_id -> monotonic finish time, or None while in flight.
222
+ self._entries: "OrderedDict[str, Optional[float]]" = OrderedDict()
223
+ # Done ids whose ack never reached the daemon: kept past TTL and LRU,
224
+ # so a redelivery after a long outage is acked, never re-run.
225
+ self._pinned: set = set()
226
+
227
+ def __len__(self) -> int:
228
+ return len(self._entries)
229
+
230
+ def begin(self, message_id: str) -> str:
231
+ self._prune()
232
+ if message_id in self._entries:
233
+ return "in_flight" if self._entries[message_id] is None else "done"
234
+ self._entries[message_id] = None
235
+ return "new"
236
+
237
+ def finish(self, message_id: str) -> None:
238
+ self._entries.pop(message_id, None)
239
+ self._entries[message_id] = self._now()
240
+ self._prune()
241
+
242
+ def forget(self, message_id: str) -> None:
243
+ self._entries.pop(message_id, None)
244
+ self._pinned.discard(message_id)
245
+
246
+ def pin(self, message_id: str) -> None:
247
+ if message_id in self._entries:
248
+ self._pinned.add(message_id)
249
+
250
+ def unpin(self, message_id: str) -> None:
251
+ self._pinned.discard(message_id)
252
+
253
+ def _prune(self) -> None:
254
+ now = self._now()
255
+ excess = len(self._entries) - self._max
256
+ for mid, done_at in list(self._entries.items()):
257
+ if done_at is None or mid in self._pinned:
258
+ continue
259
+ if now - done_at > self._ttl:
260
+ del self._entries[mid]
261
+ excess -= 1
262
+ elif excess > 0:
263
+ del self._entries[mid]
264
+ excess -= 1
265
+
266
+
149
267
  def _read_file_text(path: str) -> str:
150
268
  with open(path, "r") as f:
151
269
  return f.read()
@@ -180,16 +298,35 @@ class SecureComsAdapter(BasePlatformAdapter):
180
298
  # SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
181
299
  self._block_seq: Dict[str, Dict[str, Any]] = {}
182
300
  self._session = None # aiohttp.ClientSession - the CURRENT connected link
301
+ # channel_id -> monotonic time of the last lookup for an unknown channel.
302
+ self._unknown_channel_lookups: Dict[str, float] = {}
183
303
  self._ws = None
184
304
  self._recv_task: Optional[asyncio.Task] = None
185
305
  self._tick_task: Optional[asyncio.Task] = None
186
306
  self._stopped = False # set by disconnect(); stops the reconnect supervisor
187
307
  self._hold_reason: Optional[str] = None # None == not holding
308
+ # Inbound message_ids awaiting their ack or acked, and the tasks that
309
+ # each wait for Hermes to accept one turn and then send its ack.
310
+ self._deliveries = _DeliveryLedger(_DEDUP_MAX_ENTRIES, _DEDUP_TTL_S, time.monotonic)
311
+ self._ack_waiters: set = set()
312
+ # message_ids whose ack was dropped (no link, or the send raised), in
313
+ # order; flushed on the next successful connect.
314
+ self._unsent_acks: "OrderedDict[str, None]" = OrderedDict()
188
315
 
189
316
  self._status = securecoms_transport.ChannelStatusManager(
190
317
  self._send_status_frame, now=time.monotonic
191
318
  )
192
319
 
320
+ # H0.2b: Hermes shares ONE room session across every sender when
321
+ # group_sessions_per_user is off; every room turn is then ungranted.
322
+ self._room_sessions_shared = self._group_sessions_shared()
323
+
324
+ def _group_sessions_shared(self) -> bool:
325
+ extra = getattr(self.config, "extra", None)
326
+ if not isinstance(extra, dict):
327
+ return True
328
+ return extra.get("group_sessions_per_user", True) is not True
329
+
193
330
  @property
194
331
  def name(self) -> str:
195
332
  return "SecureComs"
@@ -211,6 +348,14 @@ class SecureComsAdapter(BasePlatformAdapter):
211
348
  """
212
349
  del is_reconnect # accepted for contract parity; nothing to preserve
213
350
  self._stopped = False
351
+ self._room_sessions_shared = self._group_sessions_shared()
352
+ global _SHARED_ROOMS_WARNED
353
+ if self._room_sessions_shared and not _SHARED_ROOMS_WARNED:
354
+ _SHARED_ROOMS_WARNED = True
355
+ logger.warning(
356
+ "SecureComs: group_sessions_per_user is off, so a room shares one Hermes session "
357
+ "across senders; every room turn runs without tools"
358
+ )
214
359
 
215
360
  # BINDING-STABILITY fold: resolve bound_identity here, ONCE, for the
216
361
  # entire process life of this adapter instance. Every subsequent
@@ -291,6 +436,7 @@ class SecureComsAdapter(BasePlatformAdapter):
291
436
  self._token = target.token
292
437
  self._leave_hold()
293
438
  await self._send_hello()
439
+ await self._flush_unsent_acks()
294
440
  await self._refresh_channels()
295
441
  return True
296
442
 
@@ -389,19 +535,33 @@ class SecureComsAdapter(BasePlatformAdapter):
389
535
  await task
390
536
  except asyncio.CancelledError:
391
537
  pass
538
+ # Unsent acks are dropped on purpose: the daemon re-buffers whatever is
539
+ # unacked when the socket closes and redelivers it to the next consumer.
540
+ waiters = list(self._ack_waiters)
541
+ for task in waiters:
542
+ task.cancel()
543
+ if waiters:
544
+ await asyncio.gather(*waiters, return_exceptions=True)
545
+ self._ack_waiters.clear()
392
546
  await self._teardown_link()
393
547
 
548
+ async def _fetch_channels(self) -> None:
549
+ async with self._session.get(
550
+ f"{self.bridge_url}/v1/channels", headers=self._auth()
551
+ ) as r:
552
+ if r.status == 200:
553
+ for c in await r.json():
554
+ self._channels[c["channel_id"]] = {
555
+ # Member-chosen and reaches the prompt (chat_name,
556
+ # get_chat_info): sanitized once, here (R3).
557
+ "name": securecoms_transport.display_name(c.get("name")) or c["channel_id"],
558
+ "is_dm": bool(c.get("is_dm")),
559
+ }
560
+
394
561
  async def _refresh_channels(self) -> None:
395
562
  try:
396
- async with self._session.get(
397
- f"{self.bridge_url}/v1/channels", headers=self._auth()
398
- ) as r:
399
- if r.status == 200:
400
- for c in await r.json():
401
- self._channels[c["channel_id"]] = {
402
- "name": c.get("name") or c["channel_id"],
403
- "is_dm": bool(c.get("is_dm")),
404
- }
563
+ # Bounded: an inbound turn may wait on this (review r2).
564
+ await asyncio.wait_for(self._fetch_channels(), timeout=_CHANNEL_REFRESH_TIMEOUT_S)
405
565
  except Exception as e:
406
566
  logger.debug(
407
567
  "SecureComs: channel refresh failed (error_class=%s)",
@@ -452,34 +612,377 @@ class SecureComsAdapter(BasePlatformAdapter):
452
612
  except (ValueError, TypeError):
453
613
  continue
454
614
  if frame.get("type") == "message":
455
- await self._dispatch(frame)
456
- # at-least-once: ack every accepted frame, dropped or not
457
- # (echo-guard drops still need the daemon to stop
458
- # redelivering them).
459
- try:
460
- await self._ws.send_json(
461
- {"type": "ack", "message_id": frame.get("message_id")}
462
- )
463
- except Exception as e:
464
- logger.warning(
465
- "SecureComs: ack failed (error_class=%s)",
466
- _error_class(e),
467
- )
615
+ await self._receive_message(frame)
468
616
  elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
469
617
  return # WS closed → supervisor reconnects
470
618
 
471
- async def _dispatch(self, frame: Dict[str, Any]) -> None:
472
- if not self._message_handler:
619
+ async def _receive_message(self, frame: Dict[str, Any]) -> None:
620
+ """At-least-once intake for one `message` frame.
621
+
622
+ Ack timing:
623
+ - dropped before Hermes (echo guard, refused ungranted turn, no
624
+ handler): at once, as before, so the daemon stops redelivering;
625
+ - handled inline by Hermes, queued behind a busy session, or not
626
+ queued for a turn (a /stop, an /approve, a clarify answer, a
627
+ follow-up, a steer, a queue-mode debounce): at once;
628
+ - starting its own Hermes turn: once Hermes has durably accepted
629
+ it (_await_acceptance), at most _ACCEPT_WAIT_S later.
630
+ The wait runs in its own task, so this pump keeps reading and other
631
+ channels keep flowing; handle_message is still called here, in frame
632
+ order. A redelivery of an id still waiting is not dispatched (the
633
+ original's ack covers it); one already acked is acked again with no
634
+ turn. A frame without a message_id is not deduped (unchanged).
635
+ """
636
+ mid = frame.get("message_id")
637
+ key = mid if isinstance(mid, str) and mid else None
638
+ if key is not None:
639
+ state = self._deliveries.begin(key)
640
+ if state == "done":
641
+ await self._send_ack(mid)
642
+ return
643
+ if state == "in_flight":
644
+ return
645
+ try:
646
+ event = await self._dispatch(frame)
647
+ except BaseException:
648
+ # Unacked, exactly as before: the supervisor reconnects and the
649
+ # daemon redelivers, which must then run the turn again.
650
+ if key is not None:
651
+ self._deliveries.forget(key)
652
+ raise
653
+ if event is None or not self._awaits_acceptance(event):
654
+ if key is not None:
655
+ self._deliveries.finish(key)
656
+ await self._send_ack(mid)
657
+ return
658
+ task = asyncio.create_task(self._ack_on_acceptance(mid, key, event))
659
+ self._ack_waiters.add(task)
660
+ task.add_done_callback(self._ack_waiters.discard)
661
+
662
+ # Hermes internals this relies on (v2026.9.24), all read-only:
663
+ # - MessageEvent._gateway_accepted: reset to False on entry to
664
+ # handle_message (gateway/platforms/base.py:3953), True when the event
665
+ # starts a session task (:3990) or is queued for one (:4052, :4064,
666
+ # gateway/run_busy.py:88-97 _enqueue_fifo).
667
+ # - _event_session_key / _active_sessions / _session_tasks: read before
668
+ # handle_message to tell "starts its own turn" from "queued behind a
669
+ # busy session" (base.py:2488, 1924, 1928, 3983-3987).
670
+ # - MessageEvent._gateway_active_turn_token: set by the runner's
671
+ # _mark_durable_active_turn (gateway/run_inbound.py:1754-1767) only
672
+ # after SessionStore.mark_turn_active has persisted the marker
673
+ # (gateway/session_lifecycle.py:115-145, write before publish), at
674
+ # gateway/run_turn.py:2090. An unclean restart promotes the marker to
675
+ # resume_pending (session_lifecycle.py:156-185) and resumes the
676
+ # session (gateway/run_startup.py:575-620).
677
+ # - on_processing_complete(event, outcome): called on every exit of
678
+ # _process_message_background (base.py:4520-4540).
679
+ # Without _gateway_accepted this degrades to acking at dispatch, the old
680
+ # behaviour, with one warning.
681
+ #
682
+ # Accepted gaps (each no worse than acking at dispatch, as main does):
683
+ # - The marker is written at run_turn.py:2090, but the user's message
684
+ # reaches the transcript only in _persist_turn_start just before the
685
+ # first LLM call (agent/turn_context.py:963, called at :1152). In
686
+ # between: the transcript load, _hmwa_run_session_hygiene (which can
687
+ # make a compression LLM call, run_turn.py:1346) and the agent build.
688
+ # A crash there resumes the session with an empty-text event
689
+ # (run_startup.py:610) and the acked message is lost.
690
+ # - A clean shutdown while the session's agent is still
691
+ # _AGENT_PENDING_SENTINEL (before the agent build) does not mark it
692
+ # resume_pending (gateway/run_shutdown.py:858-871), and a clean start
693
+ # discards orphan markers (session_lifecycle.py:189): acked, then lost.
694
+ # - A follow-up queued behind a busy session, a queue-mode debounce
695
+ # (base.py:3766-3796) or a steer is acked at once: Hermes holds it
696
+ # only in memory until its own turn starts (its pending queue is saved
697
+ # on clean shutdown only).
698
+
699
+ def _awaits_acceptance(self, event: Any) -> bool:
700
+ accepted = getattr(event, "_gateway_accepted", None)
701
+ if not isinstance(accepted, bool):
702
+ self._warn_no_turn_tracking()
703
+ return False
704
+ # Queued behind a busy session: not durable until its own turn
705
+ # starts, which may be minutes away, so waiting buys nothing and
706
+ # would hold the channel (a /stop typed next would wait the bound).
707
+ return accepted and not getattr(event, "_securecoms_session_was_busy", False)
708
+
709
+ def _session_was_busy(self, event: Any) -> bool:
710
+ """Read BEFORE handle_message: does this event's Hermes session
711
+ already have a live owner? Mirrors handle_message's own check
712
+ (base.py:3983-3987): a guard in _active_sessions whose owner task in
713
+ _session_tasks has not exited (a done owner is healed on entry)."""
714
+ key_fn = getattr(self, "_event_session_key", None)
715
+ active = getattr(self, "_active_sessions", None)
716
+ if not callable(key_fn) or not isinstance(active, dict):
717
+ return False
718
+ try:
719
+ key = key_fn(event)
720
+ except Exception:
721
+ return False
722
+ if key not in active:
723
+ return False
724
+ tasks = getattr(self, "_session_tasks", None)
725
+ task = tasks.get(key) if isinstance(tasks, dict) else None
726
+ done = getattr(task, "done", None)
727
+ return not (callable(done) and done())
728
+
729
+ @staticmethod
730
+ def _warn_no_turn_tracking() -> None:
731
+ global _TURN_TRACKING_WARNED
732
+ if _TURN_TRACKING_WARNED:
733
+ return
734
+ _TURN_TRACKING_WARNED = True
735
+ logger.warning(
736
+ "SecureComs: Hermes turn acceptance signal unavailable; acking at dispatch "
737
+ "(a crash before the turn starts can lose that message)"
738
+ )
739
+
740
+ async def on_processing_complete(self, event: Any, outcome: Any) -> None:
741
+ """Hermes lifecycle hook (base.py:3448): record the outcome on the
742
+ event for _await_acceptance, run the H0 release check, then keep the
743
+ base behaviour."""
744
+ try:
745
+ event._securecoms_outcome = getattr(outcome, "name", None) or "UNKNOWN"
746
+ except Exception:
747
+ pass
748
+ try:
749
+ self._settle_on_completion(event)
750
+ except Exception as e:
751
+ logger.warning(
752
+ "SecureComs: ungranted-turn release check failed; its sender stays without tools "
753
+ "(error_class=%s)",
754
+ _error_class(e),
755
+ )
756
+ base = getattr(super(), "on_processing_complete", None)
757
+ if callable(base):
758
+ await base(event, outcome)
759
+
760
+ # ── H0: ungranted turns in flight ─────────────────────────────────────
761
+ #
762
+ # Hermes v2026.9.24 (read on the Mini): on_processing_complete fires once
763
+ # per started task, for the event that started it only (base.py:4520-4540),
764
+ # before the debounce flush and the pending pop. An event can reach a turn
765
+ # without starting it (redirect, steer, base merge, runner FIFO, debounce),
766
+ # and an event that DID start its own base task can still be parked by the
767
+ # runner behind a pending sentinel (run_inbound.py:690-697) or swapped for
768
+ # an orphaned FIFO head, so its own completion does not prove its text has
769
+ # run. Every ungranted event's count is therefore attached to its Hermes
770
+ # session at dispatch and comes down only when the event never reached
771
+ # Hermes, or at a moment its session is idle: no other running task, no
772
+ # command guard, nothing pending or debounced, an empty runner FIFO and no
773
+ # runner turn. Never on a grant. Anything unreadable counts as busy.
774
+
775
+ def _session_key_of(self, event: Any) -> Optional[str]:
776
+ key_fn = getattr(self, "_event_session_key", None)
777
+ if not callable(key_fn):
778
+ return None
779
+ try:
780
+ key = key_fn(event)
781
+ except Exception:
782
+ return None
783
+ return key if isinstance(key, str) and key else None
784
+
785
+ def _runner_key_of(self, event: Any) -> Optional[str]:
786
+ runner = getattr(self, "gateway_runner", None)
787
+ key_fn = getattr(runner, "_session_key_for_source", None)
788
+ if not callable(key_fn):
789
+ return None
790
+ try:
791
+ key = key_fn(event.source)
792
+ except Exception:
793
+ return None
794
+ return key if isinstance(key, str) and key else None
795
+
796
+ @staticmethod
797
+ def _warn_idle_inputs_missing() -> None:
798
+ global _IDLE_INPUTS_WARNED
799
+ if _IDLE_INPUTS_WARNED:
473
800
  return
801
+ _IDLE_INPUTS_WARNED = True
802
+ logger.warning(
803
+ "SecureComs: this Hermes does not expose its session idle state; a sender whose "
804
+ "turn ran without tools stays without tools"
805
+ )
806
+
807
+ def _session_idle(self, session_key: str, completing: Any, runner_keys=()) -> bool:
808
+ """True only when nothing in this Hermes session can still be
809
+ processing an event's text. ``completing`` is the task whose
810
+ on_processing_complete is running (it is ending, so it is not busy)."""
811
+ try:
812
+ active = getattr(self, "_active_sessions", None)
813
+ tasks = getattr(self, "_session_tasks", None)
814
+ pending = getattr(self, "_pending_messages", None)
815
+ store_fn = getattr(self, "_text_debounce_store", None)
816
+ runner = getattr(self, "gateway_runner", None)
817
+ peek = getattr(runner, "_peek_session_state", None)
818
+ if (
819
+ not isinstance(active, dict) or not isinstance(tasks, dict) or not isinstance(pending, dict)
820
+ or not callable(store_fn) or not callable(peek)
821
+ ):
822
+ self._warn_idle_inputs_missing()
823
+ return False
824
+ if session_key in pending:
825
+ return False
826
+ store = store_fn()
827
+ if not isinstance(store, dict) or session_key in store:
828
+ return False
829
+ task = tasks.get(session_key)
830
+ if task is not None and task is not completing and not task.done():
831
+ return False
832
+ if session_key in active and task is None:
833
+ return False # a guard with no owner task: a command is running
834
+ for key in {session_key, *(k for k in runner_keys if k)}:
835
+ state = peek(key)
836
+ if state is None:
837
+ continue
838
+ if state.turn.agent is not None:
839
+ return False
840
+ if state.conversation.queued_events:
841
+ return False
842
+ return True
843
+ except Exception:
844
+ return False
845
+
846
+ def _release_if_idle(self, session_key: Optional[str], completing: Any, runner_key: Optional[str] = None) -> None:
847
+ """Release every count attached to this session once it is idle."""
848
+ if session_key is None:
849
+ return
850
+ waiting = _ATTACHED.get(session_key)
851
+ if not waiting:
852
+ return
853
+ runner_keys = tuple(rk for _, rk in waiting) + (runner_key,)
854
+ if self._session_idle(session_key, completing, runner_keys):
855
+ for token, _ in _ATTACHED.pop(session_key, []):
856
+ _TURN_AUTHZ.end_ungranted(*token)
857
+
858
+ def _settle_on_completion(self, event: Any) -> None:
859
+ """H0.2 (d)+(e): at every completion in a session, release what is
860
+ attached to it if the session is idle."""
861
+ self._release_if_idle(self._session_key_of(event), asyncio.current_task(), self._runner_key_of(event))
862
+
863
+ async def _await_acceptance(self, event: Any) -> str:
864
+ """"accepted" once Hermes holds the turn durably (marker persisted) or
865
+ has finished it; "cancelled" when Hermes cancelled it before that
866
+ (gateway shutdown: cancel_background_tasks); "timeout" after
867
+ _ACCEPT_WAIT_S."""
868
+ loop = asyncio.get_running_loop()
869
+ deadline = loop.time() + _ACCEPT_WAIT_S
870
+ while True:
871
+ if getattr(event, "_gateway_active_turn_token", None):
872
+ return "accepted"
873
+ outcome = getattr(event, "_securecoms_outcome", None)
874
+ if outcome == "CANCELLED":
875
+ return "cancelled"
876
+ if outcome is not None:
877
+ return "accepted" # finished (success or failure): never redeliver a poison turn
878
+ remaining = deadline - loop.time()
879
+ if remaining <= 0:
880
+ return "timeout"
881
+ await asyncio.sleep(min(_ACCEPT_POLL_S, remaining))
882
+
883
+ async def _ack_on_acceptance(self, mid: Any, key: Optional[str], event: Any) -> None:
884
+ try:
885
+ result = await self._await_acceptance(event)
886
+ except BaseException:
887
+ # Cancelled (disconnect) or broken: unacked, and forgotten so a
888
+ # redelivery to this same adapter runs the turn.
889
+ if key is not None:
890
+ self._deliveries.forget(key)
891
+ raise
892
+ if result == "cancelled":
893
+ # Hermes dropped it before persisting a marker: no Hermes resume
894
+ # will cover it. Fail it back to the daemon, which requeues it
895
+ # behind its backoff (ws-server.ts nack) instead of holding the
896
+ # channel until this socket closes.
897
+ if key is not None:
898
+ self._deliveries.forget(key)
899
+ await self._send_nack(mid)
900
+ return
901
+ if result == "timeout":
902
+ logger.info("SecureComs: turn not yet accepted by Hermes at the ack bound; acking it")
903
+ # Done BEFORE the send: a redelivery racing in is acked, never re-run.
904
+ if key is not None:
905
+ self._deliveries.finish(key)
906
+ await self._send_ack(mid)
907
+
908
+ async def _send_ack(self, mid: Any) -> None:
909
+ """Ack on the CURRENT socket. The daemon's ack handler retires the id
910
+ durably for the agent (ackRuntimeMessages), not per socket, and also
911
+ clears it from that socket's in-flight map, which after a reconnect
912
+ holds the redelivered copy. With no socket the ack is dropped: the
913
+ ledger already says done, so the redelivery is acked on arrival."""
914
+ key = mid if isinstance(mid, str) and mid else None
915
+ ws = self._ws
916
+ if ws is None:
917
+ logger.debug("SecureComs: no daemon link for an ack; it is sent on reconnect")
918
+ self._hold_unsent_ack(key)
919
+ return
920
+ try:
921
+ await ws.send_json({"type": "ack", "message_id": mid})
922
+ except Exception as e:
923
+ logger.warning(
924
+ "SecureComs: ack failed (error_class=%s)",
925
+ _error_class(e),
926
+ )
927
+ self._hold_unsent_ack(key)
928
+ return
929
+ if key is not None:
930
+ self._unsent_acks.pop(key, None)
931
+ self._deliveries.unpin(key)
932
+
933
+ def _hold_unsent_ack(self, key: Optional[str]) -> None:
934
+ if key is None:
935
+ return
936
+ self._unsent_acks.pop(key, None)
937
+ self._unsent_acks[key] = None
938
+ self._deliveries.pin(key)
939
+
940
+ async def _flush_unsent_acks(self) -> None:
941
+ """Send acks dropped while the link was down, on the new socket."""
942
+ for key in list(self._unsent_acks):
943
+ if self._ws is None:
944
+ return
945
+ await self._send_ack(key)
946
+ if key in self._unsent_acks:
947
+ return # the send failed again; keep the rest for next time
948
+
949
+ async def _send_nack(self, mid: Any) -> None:
950
+ ws = self._ws
951
+ if ws is None or not isinstance(mid, str) or not mid:
952
+ return # the daemon re-buffers whatever is unacked when the socket closes
953
+ try:
954
+ await ws.send_json({"type": "nack", "message_id": mid})
955
+ except Exception as e:
956
+ logger.debug(
957
+ "SecureComs: nack failed (error_class=%s)",
958
+ _error_class(e),
959
+ )
960
+
961
+ async def _dispatch(self, frame: Dict[str, Any]) -> Optional[Any]:
962
+ """Hand one frame to Hermes. Returns the MessageEvent passed to
963
+ handle_message, or None when the frame was dropped before Hermes."""
964
+ if not self._message_handler:
965
+ return None
474
966
  if securecoms_transport.should_drop_inbound(frame, self._bound_identity):
475
- return # echo guard (contract §4.4): never re-feed our own sends
967
+ return None # echo guard (contract §4.4): never re-feed our own sends
476
968
  channel_id = frame.get("channel_id", "")
477
969
  sender_id = frame.get("sender_id", "")
478
970
  text = frame.get("text", "")
971
+ if channel_id not in self._channels and self._session is not None and self._may_look_up(channel_id):
972
+ # A channel created after connect: look it up before guessing, at
973
+ # most once per _UNKNOWN_CHANNEL_RETRY_S per channel (review r2).
974
+ await self._refresh_channels()
479
975
  meta = self._channels.get(channel_id, {})
480
- chat_type = "dm" if meta.get("is_dm", True) else "group"
481
-
482
- global _FRAMES_WITHOUT_AUTHORIZATION
976
+ # Review r1 M3: a channel whose metadata is not cached yet is a GROUP
977
+ # (Hermes keys group sessions per user), never a DM: guessing DM would
978
+ # put a room's messages into a direct-chat session.
979
+ chat_type = "dm" if meta.get("is_dm") is True else "group"
980
+ if securecoms_authz.block_says_dm(frame):
981
+ # Audit r2: the daemon's block marks a DM whatever this cache says
982
+ # (a cache miss or a failed refresh must not skip the H0.2c split).
983
+ chat_type = "dm"
984
+
985
+ global _FRAMES_WITHOUT_AUTHORIZATION, _ATTACHMENTS_DROPPED
483
986
  if not isinstance(frame.get("authorization"), dict):
484
987
  _FRAMES_WITHOUT_AUTHORIZATION += 1
485
988
  if _FRAMES_WITHOUT_AUTHORIZATION % 100 == 1:
@@ -489,32 +992,80 @@ class SecureComsAdapter(BasePlatformAdapter):
489
992
  "Upgrade @securecoms/agent-bridge"
490
993
  )
491
994
  tools_allowed = securecoms_authz.tools_allowed(frame)
492
- _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
995
+ if tools_allowed and self._room_sessions_shared and chat_type != "dm":
996
+ tools_allowed = False # H0.2b: one room session for every sender
997
+ # H0.2c: Hermes keys a DM session by chat only. Every non-owner DM turn
998
+ # runs in its own session (thread NON_OWNER_THREAD), so its text never
999
+ # enters the transcript the owner's granted turns run in.
1000
+ thread_id = None
1001
+ if chat_type == "dm":
1002
+ thread_id = securecoms_authz.dm_thread(frame)
1003
+ if thread_id is not None:
1004
+ # Defence in depth only: no non-owner or unclassified frame passes
1005
+ # securecoms_authz.tools_allowed, and the ledger forces these
1006
+ # threads off. Removing this line is an equivalent mutant (N4).
1007
+ tools_allowed = False
1008
+ _TURN_AUTHZ.record(channel_id, sender_id, tools_allowed, thread_id)
493
1009
  if not tools_allowed and not _TOOL_VETO_ACTIVE:
494
1010
  # This Hermes cannot be made to refuse tool calls, so a turn the
495
- # daemon did not grant tools never reaches it. Acked by _pump.
1011
+ # daemon did not grant tools never reaches it. Acked at once by
1012
+ # _receive_message.
496
1013
  logger.warning("SecureComs: refused a turn without a tool grant (tool veto unavailable)")
497
1014
  await self._notify_refused(channel_id, frame)
498
- return
1015
+ return None
499
1016
 
500
1017
  source = self.build_source(
501
1018
  chat_id=channel_id,
502
- chat_name=meta.get("name", channel_id),
1019
+ # The room name is member-chosen and reaches the prompt: same
1020
+ # sanitizer as the sender label, falling back to the channel id.
1021
+ chat_name=securecoms_transport.display_name(meta.get("name")) or channel_id,
503
1022
  chat_type=chat_type,
1023
+ # user_id (Hermes's session and the tool ledger key) is the
1024
+ # relay-attributed sender_id; user_name is a sanitized label of
1025
+ # that same sender (R3). thread_id: H0.2c above.
504
1026
  user_id=sender_id,
505
- user_name=sender_id,
1027
+ thread_id=thread_id,
1028
+ user_name=securecoms_transport.own_label(frame),
506
1029
  )
1030
+ speaker = securecoms_transport.speaker_label(frame)
1031
+ if speaker and isinstance(text, str):
1032
+ # A relayed voice room: one Hermes session (the relay's) holds
1033
+ # several speakers, and Hermes pins user_name in the per-session
1034
+ # system prompt, so the real speaker rides each turn's text, the
1035
+ # way Hermes's own shared sessions prefix "[sender name]". The
1036
+ # label's name part cannot contain "[]()" (R3, M2).
1037
+ text = f"[{speaker}] {text}"
507
1038
  if not tools_allowed and isinstance(text, str):
508
1039
  # An ungranted sender must never drive gateway commands. EVERY
509
1040
  # ungranted text is escaped: Hermes strips leading @url:/@file:
510
1041
  # refs before its "/" check, so "@url:x /reset" is a command too.
511
1042
  text = securecoms_authz.COMMAND_ESCAPE + text
1043
+ # Image/file attachments the daemon decrypted to disk ride as Hermes
1044
+ # media, the same shape Hermes's own Signal adapter emits
1045
+ # (gateway/platforms/signal.py): media_urls = local file paths,
1046
+ # media_types = mimes, message_type from the first matching MIME
1047
+ # prefix, else DOCUMENT. An image with no caption still runs a turn.
1048
+ attachments, dropped = securecoms_transport.scan_inbound_attachments(frame)
1049
+ if dropped:
1050
+ # Content-free: a fixed line plus a process counter, never a path,
1051
+ # name or mime (tests/test_logging_privacy.py bars dynamic args).
1052
+ _ATTACHMENTS_DROPPED += dropped
1053
+ logger.info("SecureComs: dropped inbound attachment(s) that failed validation")
1054
+ media_kw: Dict[str, Any] = {}
1055
+ message_type = MessageType.TEXT
1056
+ if attachments:
1057
+ media_kw = {
1058
+ "media_urls": [p for p, _ in attachments],
1059
+ "media_types": [m for _, m in attachments],
1060
+ }
1061
+ message_type = _message_type_for_mimes(media_kw["media_types"])
512
1062
  event = MessageEvent(
513
- text=text,
514
- message_type=MessageType.TEXT,
1063
+ text=text if isinstance(text, str) else "",
1064
+ message_type=message_type,
515
1065
  source=source,
516
1066
  message_id=str(frame.get("message_id") or int(time.time() * 1000)),
517
1067
  timestamp=datetime.now(),
1068
+ **media_kw,
518
1069
  )
519
1070
  # Sender role rides as event metadata, never in the user text.
520
1071
  metadata = getattr(event, "metadata", None)
@@ -532,7 +1083,63 @@ class SecureComsAdapter(BasePlatformAdapter):
532
1083
  prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
533
1084
  if prompts:
534
1085
  event.channel_prompt = "\n\n".join(prompts)
535
- await self.handle_message(event)
1086
+ token = None
1087
+ session_key = None
1088
+ if not tools_allowed:
1089
+ # H0.2 (a): this key has no tools until no turn in its session can
1090
+ # still be processing this event's text. Attached to the session
1091
+ # now; released only when the session is idle.
1092
+ token = (channel_id, sender_id, thread_id, self._room_sessions_shared and chat_type != "dm")
1093
+ _TURN_AUTHZ.begin_ungranted(*token)
1094
+ session_key = self._session_key_of(event)
1095
+ if session_key is None:
1096
+ logger.warning(
1097
+ "SecureComs: an ungranted turn's Hermes session is unknown; its sender stays without tools"
1098
+ )
1099
+ else:
1100
+ _ATTACHED.setdefault(session_key, []).append((token, self._runner_key_of(event)))
1101
+ reached = False
1102
+ try:
1103
+ event._securecoms_session_was_busy = self._session_was_busy(event)
1104
+ reached = True
1105
+ await self.handle_message(event)
1106
+ except BaseException:
1107
+ if token is not None and not reached:
1108
+ self._detach(session_key, token) # never reached Hermes
1109
+ _TURN_AUTHZ.end_ungranted(*token)
1110
+ elif token is not None:
1111
+ self._release_if_idle(session_key, None)
1112
+ raise
1113
+ if token is not None:
1114
+ # (b)/(c): released now only if nothing in the session can still
1115
+ # be processing it; else at a later idle completion (d)/(e).
1116
+ self._release_if_idle(session_key, None)
1117
+ return event
1118
+
1119
+ @staticmethod
1120
+ def _detach(session_key: Optional[str], token: tuple) -> None:
1121
+ waiting = _ATTACHED.get(session_key) if session_key is not None else None
1122
+ if not waiting:
1123
+ return
1124
+ for i, (t, _) in enumerate(waiting):
1125
+ if t is token:
1126
+ del waiting[i]
1127
+ break
1128
+ if not waiting:
1129
+ _ATTACHED.pop(session_key, None)
1130
+
1131
+ def _may_look_up(self, channel_id: str) -> bool:
1132
+ """Negative cache for unknown channels: True at most once per
1133
+ _UNKNOWN_CHANNEL_RETRY_S per channel (bounded map, oldest evicted)."""
1134
+ now = time.monotonic()
1135
+ last = self._unknown_channel_lookups.get(channel_id)
1136
+ if last is not None and now - last < _UNKNOWN_CHANNEL_RETRY_S:
1137
+ return False
1138
+ self._unknown_channel_lookups.pop(channel_id, None)
1139
+ self._unknown_channel_lookups[channel_id] = now
1140
+ while len(self._unknown_channel_lookups) > _UNKNOWN_CHANNEL_LOOKUPS_MAX:
1141
+ self._unknown_channel_lookups.pop(next(iter(self._unknown_channel_lookups)))
1142
+ return True
536
1143
 
537
1144
  async def _notify_refused(self, channel_id: str, frame: Dict[str, Any]) -> None:
538
1145
  """One short notice per channel that this agent cannot respond there.
@@ -553,12 +1160,14 @@ class SecureComsAdapter(BasePlatformAdapter):
553
1160
  def toolsets_for_source(self, source) -> Optional[list]:
554
1161
  """Hermes per-turn toolset override (gateway run_turn
555
1162
  _resolve_enabled_toolsets_for_source). None keeps the configured
556
- toolsets: only for a (chat, sender) whose latest turn was granted
557
- tools. Anything else, including an unknown source, gets the no-tools
558
- override."""
1163
+ toolsets: only for a (chat, sender, thread) whose latest turn was
1164
+ granted tools and that has no ungranted turn still in flight (H0).
1165
+ Anything else, including an unknown source and every turn in the
1166
+ non-owner DM thread, gets the no-tools override."""
559
1167
  chat_id = getattr(source, "chat_id", None)
560
1168
  user_id = getattr(source, "user_id", None)
561
- if _TURN_AUTHZ.tools_allowed(chat_id, user_id):
1169
+ thread_id = getattr(source, "thread_id", None)
1170
+ if _TURN_AUTHZ.tools_allowed(chat_id, user_id, thread_id):
562
1171
  return None
563
1172
  return list(securecoms_authz.NO_TOOLS_TOOLSETS)
564
1173
 
@@ -639,7 +1248,7 @@ class SecureComsAdapter(BasePlatformAdapter):
639
1248
  meta = self._channels.get(chat_id, {})
640
1249
  return {
641
1250
  "name": meta.get("name", chat_id),
642
- "type": "dm" if meta.get("is_dm", True) else "group",
1251
+ "type": "dm" if meta.get("is_dm") is True else "group",
643
1252
  "chat_id": chat_id,
644
1253
  }
645
1254
 
@@ -705,7 +1314,21 @@ def _install_tool_veto(ctx) -> bool:
705
1314
  ):
706
1315
  return False
707
1316
  from gateway import session_context
708
- from gateway.session_context import get_session_env
1317
+
1318
+ # Read the turn's task-local ContextVars directly: Hermes's own
1319
+ # get_session_env falls back to os.environ, which a concurrent turn or
1320
+ # the process can set. Unset (_UNSET) reads as None, never as a value.
1321
+ var_map = getattr(session_context, "_VAR_MAP", None)
1322
+ unset = getattr(session_context, "_UNSET", None)
1323
+ names = ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_USER_ID", "HERMES_SESSION_THREAD_ID")
1324
+ if unset is None or not isinstance(var_map, dict) or not all(
1325
+ callable(getattr(var_map.get(n), "get", None)) for n in names
1326
+ ):
1327
+ return False
1328
+
1329
+ def get_session_env(name, default=""):
1330
+ value = var_map[name].get()
1331
+ return None if value is unset else value
709
1332
 
710
1333
  engaged = getattr(session_context, "session_context_engaged", None)
711
1334
  _SESSION_ENGAGED = engaged if callable(engaged) else None