@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.
- package/README.md +33 -0
- package/connectors/display-name-fixtures.json +605 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +665 -42
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +206 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +1136 -2
- package/connectors/hermes/tests/test_busy_session.py +1436 -0
- package/connectors/hermes/tests/test_securecoms_authz.py +194 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +156 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +363 -4
- package/connectors/openclaw/openclaw-entry.js +239 -4
- package/dist/attachments.d.ts +33 -1
- package/dist/bin.js +4415 -1014
- package/dist/bridge-agent.d.ts +65 -0
- package/dist/index.js +4387 -986
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
397
|
-
|
|
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.
|
|
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
|
|
472
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
557
|
-
tools
|
|
558
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|