@securecoms/agent-bridge 0.37.0 → 0.38.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 +16 -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 +424 -32
- package/connectors/hermes/securecoms_transport.py +97 -1
- package/connectors/hermes/tests/test_adapter_wiring.py +887 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +127 -0
- package/connectors/openclaw/openclaw-entry.contract.test.ts +96 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +228 -1
- package/connectors/openclaw/openclaw-entry.js +128 -3
- 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
package/README.md
CHANGED
|
@@ -973,6 +973,22 @@ copied after the owner's next typed message.
|
|
|
973
973
|
|
|
974
974
|
---
|
|
975
975
|
|
|
976
|
+
## Release notes: 0.38.0
|
|
977
|
+
|
|
978
|
+
<!-- [COPY, released on Eric's "complete all three items" 2026-11-11; notes not yet reviewed by Eric] -->
|
|
979
|
+
|
|
980
|
+
#### What's new
|
|
981
|
+
- **Your agent sees your pictures.** When you send a picture, your agent can now see it and describe it. This works for OpenClaw, Hermes and SecureComs agents. It works for PNG, JPEG, GIF and WebP pictures up to 5 MB.
|
|
982
|
+
- **A picture and its caption are one message.** When you send a picture with a caption, your agent gets them together and answers once.
|
|
983
|
+
- **Safer pictures.** Your agent reads a picture only when the file really is the picture type it claims to be. In a team room, a picture follows the same @mention rules as a text message. The pictures are kept in private folders that only your account can read.
|
|
984
|
+
- **Hermes keeps your messages.** If Hermes stops in the middle of a reply, it now picks up your message again when it restarts, in almost all cases. You can still send `/stop` while Hermes is working.
|
|
985
|
+
|
|
986
|
+
#### Upgrade
|
|
987
|
+
|
|
988
|
+
Upgrade the bridge and restart your agent. For a SecureComs agent, also upgrade to @securecoms/agent-runtime 0.18.0. For OpenClaw or Hermes, copy the new plugin files from the bridge's `connectors` folder.
|
|
989
|
+
|
|
990
|
+
If you go back to 0.37.0, your agent keeps working, but it sees each picture and its caption as two messages.
|
|
991
|
+
|
|
976
992
|
## Release notes: 0.37.0
|
|
977
993
|
|
|
978
994
|
<!-- [COPY, approved by Eric 2026-11-01 ("Notes ok"), connected apps removal confirmation] -->
|
|
Binary file
|
|
@@ -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,46 @@ _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
|
+
# Hermes's Signal adapter mapping (_MEDIA_TYPE_BY_MIME_PREFIX): first matching
|
|
144
|
+
# prefix wins; anything else is a DOCUMENT so Hermes surfaces the file path.
|
|
145
|
+
_MEDIA_TYPE_BY_MIME_PREFIX = (("audio/", "VOICE"), ("image/", "PHOTO"), ("video/", "VIDEO"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _message_type_for_mimes(mimes):
|
|
149
|
+
name = next(
|
|
150
|
+
(n for prefix, n in _MEDIA_TYPE_BY_MIME_PREFIX if any(m.startswith(prefix) for m in mimes)),
|
|
151
|
+
"DOCUMENT",
|
|
152
|
+
)
|
|
153
|
+
return getattr(MessageType, name, MessageType.TEXT)
|
|
154
|
+
|
|
111
155
|
|
|
112
156
|
_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
113
157
|
_BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
|
|
@@ -146,6 +190,69 @@ def _error_class(error: object) -> str:
|
|
|
146
190
|
return name
|
|
147
191
|
|
|
148
192
|
|
|
193
|
+
class _DeliveryLedger:
|
|
194
|
+
"""Bounded record of inbound message_ids that are in flight or done.
|
|
195
|
+
|
|
196
|
+
begin() answers "new" (record it in flight and dispatch), "in_flight" (a
|
|
197
|
+
redelivery of a turn still running: do not dispatch, the original's ack
|
|
198
|
+
covers it) or "done" (already finished: ack again, no turn). An in-flight
|
|
199
|
+
entry is never evicted or expired, since dropping it would let a
|
|
200
|
+
redelivery run the turn twice; the daemon keeps at most one message per
|
|
201
|
+
channel in flight, so those stay few. Done entries expire after ttl_s and
|
|
202
|
+
the oldest go first once the ledger holds more than max_entries, except
|
|
203
|
+
pinned ones (acked by us but never delivered to the daemon).
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(self, max_entries: int, ttl_s: float, now) -> None:
|
|
207
|
+
self._max = max_entries
|
|
208
|
+
self._ttl = ttl_s
|
|
209
|
+
self._now = now
|
|
210
|
+
# message_id -> monotonic finish time, or None while in flight.
|
|
211
|
+
self._entries: "OrderedDict[str, Optional[float]]" = OrderedDict()
|
|
212
|
+
# Done ids whose ack never reached the daemon: kept past TTL and LRU,
|
|
213
|
+
# so a redelivery after a long outage is acked, never re-run.
|
|
214
|
+
self._pinned: set = set()
|
|
215
|
+
|
|
216
|
+
def __len__(self) -> int:
|
|
217
|
+
return len(self._entries)
|
|
218
|
+
|
|
219
|
+
def begin(self, message_id: str) -> str:
|
|
220
|
+
self._prune()
|
|
221
|
+
if message_id in self._entries:
|
|
222
|
+
return "in_flight" if self._entries[message_id] is None else "done"
|
|
223
|
+
self._entries[message_id] = None
|
|
224
|
+
return "new"
|
|
225
|
+
|
|
226
|
+
def finish(self, message_id: str) -> None:
|
|
227
|
+
self._entries.pop(message_id, None)
|
|
228
|
+
self._entries[message_id] = self._now()
|
|
229
|
+
self._prune()
|
|
230
|
+
|
|
231
|
+
def forget(self, message_id: str) -> None:
|
|
232
|
+
self._entries.pop(message_id, None)
|
|
233
|
+
self._pinned.discard(message_id)
|
|
234
|
+
|
|
235
|
+
def pin(self, message_id: str) -> None:
|
|
236
|
+
if message_id in self._entries:
|
|
237
|
+
self._pinned.add(message_id)
|
|
238
|
+
|
|
239
|
+
def unpin(self, message_id: str) -> None:
|
|
240
|
+
self._pinned.discard(message_id)
|
|
241
|
+
|
|
242
|
+
def _prune(self) -> None:
|
|
243
|
+
now = self._now()
|
|
244
|
+
excess = len(self._entries) - self._max
|
|
245
|
+
for mid, done_at in list(self._entries.items()):
|
|
246
|
+
if done_at is None or mid in self._pinned:
|
|
247
|
+
continue
|
|
248
|
+
if now - done_at > self._ttl:
|
|
249
|
+
del self._entries[mid]
|
|
250
|
+
excess -= 1
|
|
251
|
+
elif excess > 0:
|
|
252
|
+
del self._entries[mid]
|
|
253
|
+
excess -= 1
|
|
254
|
+
|
|
255
|
+
|
|
149
256
|
def _read_file_text(path: str) -> str:
|
|
150
257
|
with open(path, "r") as f:
|
|
151
258
|
return f.read()
|
|
@@ -180,11 +287,20 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
180
287
|
# SEC-06 #18: reply_to -> {"seq": n, "pending": text|None}; see _block_key.
|
|
181
288
|
self._block_seq: Dict[str, Dict[str, Any]] = {}
|
|
182
289
|
self._session = None # aiohttp.ClientSession - the CURRENT connected link
|
|
290
|
+
# channel_id -> monotonic time of the last lookup for an unknown channel.
|
|
291
|
+
self._unknown_channel_lookups: Dict[str, float] = {}
|
|
183
292
|
self._ws = None
|
|
184
293
|
self._recv_task: Optional[asyncio.Task] = None
|
|
185
294
|
self._tick_task: Optional[asyncio.Task] = None
|
|
186
295
|
self._stopped = False # set by disconnect(); stops the reconnect supervisor
|
|
187
296
|
self._hold_reason: Optional[str] = None # None == not holding
|
|
297
|
+
# Inbound message_ids awaiting their ack or acked, and the tasks that
|
|
298
|
+
# each wait for Hermes to accept one turn and then send its ack.
|
|
299
|
+
self._deliveries = _DeliveryLedger(_DEDUP_MAX_ENTRIES, _DEDUP_TTL_S, time.monotonic)
|
|
300
|
+
self._ack_waiters: set = set()
|
|
301
|
+
# message_ids whose ack was dropped (no link, or the send raised), in
|
|
302
|
+
# order; flushed on the next successful connect.
|
|
303
|
+
self._unsent_acks: "OrderedDict[str, None]" = OrderedDict()
|
|
188
304
|
|
|
189
305
|
self._status = securecoms_transport.ChannelStatusManager(
|
|
190
306
|
self._send_status_frame, now=time.monotonic
|
|
@@ -291,6 +407,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
291
407
|
self._token = target.token
|
|
292
408
|
self._leave_hold()
|
|
293
409
|
await self._send_hello()
|
|
410
|
+
await self._flush_unsent_acks()
|
|
294
411
|
await self._refresh_channels()
|
|
295
412
|
return True
|
|
296
413
|
|
|
@@ -389,19 +506,31 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
389
506
|
await task
|
|
390
507
|
except asyncio.CancelledError:
|
|
391
508
|
pass
|
|
509
|
+
# Unsent acks are dropped on purpose: the daemon re-buffers whatever is
|
|
510
|
+
# unacked when the socket closes and redelivers it to the next consumer.
|
|
511
|
+
waiters = list(self._ack_waiters)
|
|
512
|
+
for task in waiters:
|
|
513
|
+
task.cancel()
|
|
514
|
+
if waiters:
|
|
515
|
+
await asyncio.gather(*waiters, return_exceptions=True)
|
|
516
|
+
self._ack_waiters.clear()
|
|
392
517
|
await self._teardown_link()
|
|
393
518
|
|
|
519
|
+
async def _fetch_channels(self) -> None:
|
|
520
|
+
async with self._session.get(
|
|
521
|
+
f"{self.bridge_url}/v1/channels", headers=self._auth()
|
|
522
|
+
) as r:
|
|
523
|
+
if r.status == 200:
|
|
524
|
+
for c in await r.json():
|
|
525
|
+
self._channels[c["channel_id"]] = {
|
|
526
|
+
"name": c.get("name") or c["channel_id"],
|
|
527
|
+
"is_dm": bool(c.get("is_dm")),
|
|
528
|
+
}
|
|
529
|
+
|
|
394
530
|
async def _refresh_channels(self) -> None:
|
|
395
531
|
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
|
-
}
|
|
532
|
+
# Bounded: an inbound turn may wait on this (review r2).
|
|
533
|
+
await asyncio.wait_for(self._fetch_channels(), timeout=_CHANNEL_REFRESH_TIMEOUT_S)
|
|
405
534
|
except Exception as e:
|
|
406
535
|
logger.debug(
|
|
407
536
|
"SecureComs: channel refresh failed (error_class=%s)",
|
|
@@ -452,34 +581,261 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
452
581
|
except (ValueError, TypeError):
|
|
453
582
|
continue
|
|
454
583
|
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
|
-
)
|
|
584
|
+
await self._receive_message(frame)
|
|
468
585
|
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
469
586
|
return # WS closed → supervisor reconnects
|
|
470
587
|
|
|
471
|
-
async def
|
|
472
|
-
|
|
588
|
+
async def _receive_message(self, frame: Dict[str, Any]) -> None:
|
|
589
|
+
"""At-least-once intake for one `message` frame.
|
|
590
|
+
|
|
591
|
+
Ack timing:
|
|
592
|
+
- dropped before Hermes (echo guard, refused ungranted turn, no
|
|
593
|
+
handler): at once, as before, so the daemon stops redelivering;
|
|
594
|
+
- handled inline by Hermes, queued behind a busy session, or not
|
|
595
|
+
queued for a turn (a /stop, an /approve, a clarify answer, a
|
|
596
|
+
follow-up, a steer, a queue-mode debounce): at once;
|
|
597
|
+
- starting its own Hermes turn: once Hermes has durably accepted
|
|
598
|
+
it (_await_acceptance), at most _ACCEPT_WAIT_S later.
|
|
599
|
+
The wait runs in its own task, so this pump keeps reading and other
|
|
600
|
+
channels keep flowing; handle_message is still called here, in frame
|
|
601
|
+
order. A redelivery of an id still waiting is not dispatched (the
|
|
602
|
+
original's ack covers it); one already acked is acked again with no
|
|
603
|
+
turn. A frame without a message_id is not deduped (unchanged).
|
|
604
|
+
"""
|
|
605
|
+
mid = frame.get("message_id")
|
|
606
|
+
key = mid if isinstance(mid, str) and mid else None
|
|
607
|
+
if key is not None:
|
|
608
|
+
state = self._deliveries.begin(key)
|
|
609
|
+
if state == "done":
|
|
610
|
+
await self._send_ack(mid)
|
|
611
|
+
return
|
|
612
|
+
if state == "in_flight":
|
|
613
|
+
return
|
|
614
|
+
try:
|
|
615
|
+
event = await self._dispatch(frame)
|
|
616
|
+
except BaseException:
|
|
617
|
+
# Unacked, exactly as before: the supervisor reconnects and the
|
|
618
|
+
# daemon redelivers, which must then run the turn again.
|
|
619
|
+
if key is not None:
|
|
620
|
+
self._deliveries.forget(key)
|
|
621
|
+
raise
|
|
622
|
+
if event is None or not self._awaits_acceptance(event):
|
|
623
|
+
if key is not None:
|
|
624
|
+
self._deliveries.finish(key)
|
|
625
|
+
await self._send_ack(mid)
|
|
473
626
|
return
|
|
627
|
+
task = asyncio.create_task(self._ack_on_acceptance(mid, key, event))
|
|
628
|
+
self._ack_waiters.add(task)
|
|
629
|
+
task.add_done_callback(self._ack_waiters.discard)
|
|
630
|
+
|
|
631
|
+
# Hermes internals this relies on (v2026.9.24), all read-only:
|
|
632
|
+
# - MessageEvent._gateway_accepted: reset to False on entry to
|
|
633
|
+
# handle_message (gateway/platforms/base.py:3953), True when the event
|
|
634
|
+
# starts a session task (:3990) or is queued for one (:4052, :4064,
|
|
635
|
+
# gateway/run_busy.py:88-97 _enqueue_fifo).
|
|
636
|
+
# - _event_session_key / _active_sessions / _session_tasks: read before
|
|
637
|
+
# handle_message to tell "starts its own turn" from "queued behind a
|
|
638
|
+
# busy session" (base.py:2488, 1924, 1928, 3983-3987).
|
|
639
|
+
# - MessageEvent._gateway_active_turn_token: set by the runner's
|
|
640
|
+
# _mark_durable_active_turn (gateway/run_inbound.py:1754-1767) only
|
|
641
|
+
# after SessionStore.mark_turn_active has persisted the marker
|
|
642
|
+
# (gateway/session_lifecycle.py:115-145, write before publish), at
|
|
643
|
+
# gateway/run_turn.py:2090. An unclean restart promotes the marker to
|
|
644
|
+
# resume_pending (session_lifecycle.py:156-185) and resumes the
|
|
645
|
+
# session (gateway/run_startup.py:575-620).
|
|
646
|
+
# - on_processing_complete(event, outcome): called on every exit of
|
|
647
|
+
# _process_message_background (base.py:4520-4540).
|
|
648
|
+
# Without _gateway_accepted this degrades to acking at dispatch, the old
|
|
649
|
+
# behaviour, with one warning.
|
|
650
|
+
#
|
|
651
|
+
# Accepted gaps (each no worse than acking at dispatch, as main does):
|
|
652
|
+
# - The marker is written at run_turn.py:2090, but the user's message
|
|
653
|
+
# reaches the transcript only in _persist_turn_start just before the
|
|
654
|
+
# first LLM call (agent/turn_context.py:963, called at :1152). In
|
|
655
|
+
# between: the transcript load, _hmwa_run_session_hygiene (which can
|
|
656
|
+
# make a compression LLM call, run_turn.py:1346) and the agent build.
|
|
657
|
+
# A crash there resumes the session with an empty-text event
|
|
658
|
+
# (run_startup.py:610) and the acked message is lost.
|
|
659
|
+
# - A clean shutdown while the session's agent is still
|
|
660
|
+
# _AGENT_PENDING_SENTINEL (before the agent build) does not mark it
|
|
661
|
+
# resume_pending (gateway/run_shutdown.py:858-871), and a clean start
|
|
662
|
+
# discards orphan markers (session_lifecycle.py:189): acked, then lost.
|
|
663
|
+
# - A follow-up queued behind a busy session, a queue-mode debounce
|
|
664
|
+
# (base.py:3766-3796) or a steer is acked at once: Hermes holds it
|
|
665
|
+
# only in memory until its own turn starts (its pending queue is saved
|
|
666
|
+
# on clean shutdown only).
|
|
667
|
+
|
|
668
|
+
def _awaits_acceptance(self, event: Any) -> bool:
|
|
669
|
+
accepted = getattr(event, "_gateway_accepted", None)
|
|
670
|
+
if not isinstance(accepted, bool):
|
|
671
|
+
self._warn_no_turn_tracking()
|
|
672
|
+
return False
|
|
673
|
+
# Queued behind a busy session: not durable until its own turn
|
|
674
|
+
# starts, which may be minutes away, so waiting buys nothing and
|
|
675
|
+
# would hold the channel (a /stop typed next would wait the bound).
|
|
676
|
+
return accepted and not getattr(event, "_securecoms_session_was_busy", False)
|
|
677
|
+
|
|
678
|
+
def _session_was_busy(self, event: Any) -> bool:
|
|
679
|
+
"""Read BEFORE handle_message: does this event's Hermes session
|
|
680
|
+
already have a live owner? Mirrors handle_message's own check
|
|
681
|
+
(base.py:3983-3987): a guard in _active_sessions whose owner task in
|
|
682
|
+
_session_tasks has not exited (a done owner is healed on entry)."""
|
|
683
|
+
key_fn = getattr(self, "_event_session_key", None)
|
|
684
|
+
active = getattr(self, "_active_sessions", None)
|
|
685
|
+
if not callable(key_fn) or not isinstance(active, dict):
|
|
686
|
+
return False
|
|
687
|
+
try:
|
|
688
|
+
key = key_fn(event)
|
|
689
|
+
except Exception:
|
|
690
|
+
return False
|
|
691
|
+
if key not in active:
|
|
692
|
+
return False
|
|
693
|
+
tasks = getattr(self, "_session_tasks", None)
|
|
694
|
+
task = tasks.get(key) if isinstance(tasks, dict) else None
|
|
695
|
+
done = getattr(task, "done", None)
|
|
696
|
+
return not (callable(done) and done())
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _warn_no_turn_tracking() -> None:
|
|
700
|
+
global _TURN_TRACKING_WARNED
|
|
701
|
+
if _TURN_TRACKING_WARNED:
|
|
702
|
+
return
|
|
703
|
+
_TURN_TRACKING_WARNED = True
|
|
704
|
+
logger.warning(
|
|
705
|
+
"SecureComs: Hermes turn acceptance signal unavailable; acking at dispatch "
|
|
706
|
+
"(a crash before the turn starts can lose that message)"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
async def on_processing_complete(self, event: Any, outcome: Any) -> None:
|
|
710
|
+
"""Hermes lifecycle hook (base.py:3448): record the outcome on the
|
|
711
|
+
event for _await_acceptance, then keep the base behaviour."""
|
|
712
|
+
try:
|
|
713
|
+
event._securecoms_outcome = getattr(outcome, "name", None) or "UNKNOWN"
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
base = getattr(super(), "on_processing_complete", None)
|
|
717
|
+
if callable(base):
|
|
718
|
+
await base(event, outcome)
|
|
719
|
+
|
|
720
|
+
async def _await_acceptance(self, event: Any) -> str:
|
|
721
|
+
""""accepted" once Hermes holds the turn durably (marker persisted) or
|
|
722
|
+
has finished it; "cancelled" when Hermes cancelled it before that
|
|
723
|
+
(gateway shutdown: cancel_background_tasks); "timeout" after
|
|
724
|
+
_ACCEPT_WAIT_S."""
|
|
725
|
+
loop = asyncio.get_running_loop()
|
|
726
|
+
deadline = loop.time() + _ACCEPT_WAIT_S
|
|
727
|
+
while True:
|
|
728
|
+
if getattr(event, "_gateway_active_turn_token", None):
|
|
729
|
+
return "accepted"
|
|
730
|
+
outcome = getattr(event, "_securecoms_outcome", None)
|
|
731
|
+
if outcome == "CANCELLED":
|
|
732
|
+
return "cancelled"
|
|
733
|
+
if outcome is not None:
|
|
734
|
+
return "accepted" # finished (success or failure): never redeliver a poison turn
|
|
735
|
+
remaining = deadline - loop.time()
|
|
736
|
+
if remaining <= 0:
|
|
737
|
+
return "timeout"
|
|
738
|
+
await asyncio.sleep(min(_ACCEPT_POLL_S, remaining))
|
|
739
|
+
|
|
740
|
+
async def _ack_on_acceptance(self, mid: Any, key: Optional[str], event: Any) -> None:
|
|
741
|
+
try:
|
|
742
|
+
result = await self._await_acceptance(event)
|
|
743
|
+
except BaseException:
|
|
744
|
+
# Cancelled (disconnect) or broken: unacked, and forgotten so a
|
|
745
|
+
# redelivery to this same adapter runs the turn.
|
|
746
|
+
if key is not None:
|
|
747
|
+
self._deliveries.forget(key)
|
|
748
|
+
raise
|
|
749
|
+
if result == "cancelled":
|
|
750
|
+
# Hermes dropped it before persisting a marker: no Hermes resume
|
|
751
|
+
# will cover it. Fail it back to the daemon, which requeues it
|
|
752
|
+
# behind its backoff (ws-server.ts nack) instead of holding the
|
|
753
|
+
# channel until this socket closes.
|
|
754
|
+
if key is not None:
|
|
755
|
+
self._deliveries.forget(key)
|
|
756
|
+
await self._send_nack(mid)
|
|
757
|
+
return
|
|
758
|
+
if result == "timeout":
|
|
759
|
+
logger.info("SecureComs: turn not yet accepted by Hermes at the ack bound; acking it")
|
|
760
|
+
# Done BEFORE the send: a redelivery racing in is acked, never re-run.
|
|
761
|
+
if key is not None:
|
|
762
|
+
self._deliveries.finish(key)
|
|
763
|
+
await self._send_ack(mid)
|
|
764
|
+
|
|
765
|
+
async def _send_ack(self, mid: Any) -> None:
|
|
766
|
+
"""Ack on the CURRENT socket. The daemon's ack handler retires the id
|
|
767
|
+
durably for the agent (ackRuntimeMessages), not per socket, and also
|
|
768
|
+
clears it from that socket's in-flight map, which after a reconnect
|
|
769
|
+
holds the redelivered copy. With no socket the ack is dropped: the
|
|
770
|
+
ledger already says done, so the redelivery is acked on arrival."""
|
|
771
|
+
key = mid if isinstance(mid, str) and mid else None
|
|
772
|
+
ws = self._ws
|
|
773
|
+
if ws is None:
|
|
774
|
+
logger.debug("SecureComs: no daemon link for an ack; it is sent on reconnect")
|
|
775
|
+
self._hold_unsent_ack(key)
|
|
776
|
+
return
|
|
777
|
+
try:
|
|
778
|
+
await ws.send_json({"type": "ack", "message_id": mid})
|
|
779
|
+
except Exception as e:
|
|
780
|
+
logger.warning(
|
|
781
|
+
"SecureComs: ack failed (error_class=%s)",
|
|
782
|
+
_error_class(e),
|
|
783
|
+
)
|
|
784
|
+
self._hold_unsent_ack(key)
|
|
785
|
+
return
|
|
786
|
+
if key is not None:
|
|
787
|
+
self._unsent_acks.pop(key, None)
|
|
788
|
+
self._deliveries.unpin(key)
|
|
789
|
+
|
|
790
|
+
def _hold_unsent_ack(self, key: Optional[str]) -> None:
|
|
791
|
+
if key is None:
|
|
792
|
+
return
|
|
793
|
+
self._unsent_acks.pop(key, None)
|
|
794
|
+
self._unsent_acks[key] = None
|
|
795
|
+
self._deliveries.pin(key)
|
|
796
|
+
|
|
797
|
+
async def _flush_unsent_acks(self) -> None:
|
|
798
|
+
"""Send acks dropped while the link was down, on the new socket."""
|
|
799
|
+
for key in list(self._unsent_acks):
|
|
800
|
+
if self._ws is None:
|
|
801
|
+
return
|
|
802
|
+
await self._send_ack(key)
|
|
803
|
+
if key in self._unsent_acks:
|
|
804
|
+
return # the send failed again; keep the rest for next time
|
|
805
|
+
|
|
806
|
+
async def _send_nack(self, mid: Any) -> None:
|
|
807
|
+
ws = self._ws
|
|
808
|
+
if ws is None or not isinstance(mid, str) or not mid:
|
|
809
|
+
return # the daemon re-buffers whatever is unacked when the socket closes
|
|
810
|
+
try:
|
|
811
|
+
await ws.send_json({"type": "nack", "message_id": mid})
|
|
812
|
+
except Exception as e:
|
|
813
|
+
logger.debug(
|
|
814
|
+
"SecureComs: nack failed (error_class=%s)",
|
|
815
|
+
_error_class(e),
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
async def _dispatch(self, frame: Dict[str, Any]) -> Optional[Any]:
|
|
819
|
+
"""Hand one frame to Hermes. Returns the MessageEvent passed to
|
|
820
|
+
handle_message, or None when the frame was dropped before Hermes."""
|
|
821
|
+
if not self._message_handler:
|
|
822
|
+
return None
|
|
474
823
|
if securecoms_transport.should_drop_inbound(frame, self._bound_identity):
|
|
475
|
-
return # echo guard (contract §4.4): never re-feed our own sends
|
|
824
|
+
return None # echo guard (contract §4.4): never re-feed our own sends
|
|
476
825
|
channel_id = frame.get("channel_id", "")
|
|
477
826
|
sender_id = frame.get("sender_id", "")
|
|
478
827
|
text = frame.get("text", "")
|
|
828
|
+
if channel_id not in self._channels and self._session is not None and self._may_look_up(channel_id):
|
|
829
|
+
# A channel created after connect: look it up before guessing, at
|
|
830
|
+
# most once per _UNKNOWN_CHANNEL_RETRY_S per channel (review r2).
|
|
831
|
+
await self._refresh_channels()
|
|
479
832
|
meta = self._channels.get(channel_id, {})
|
|
480
|
-
|
|
833
|
+
# Review r1 M3: a channel whose metadata is not cached yet is a GROUP
|
|
834
|
+
# (Hermes keys group sessions per user), never a DM: guessing DM would
|
|
835
|
+
# put a room's messages into a direct-chat session.
|
|
836
|
+
chat_type = "dm" if meta.get("is_dm") is True else "group"
|
|
481
837
|
|
|
482
|
-
global _FRAMES_WITHOUT_AUTHORIZATION
|
|
838
|
+
global _FRAMES_WITHOUT_AUTHORIZATION, _ATTACHMENTS_DROPPED
|
|
483
839
|
if not isinstance(frame.get("authorization"), dict):
|
|
484
840
|
_FRAMES_WITHOUT_AUTHORIZATION += 1
|
|
485
841
|
if _FRAMES_WITHOUT_AUTHORIZATION % 100 == 1:
|
|
@@ -492,10 +848,11 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
492
848
|
_TURN_AUTHZ.record(channel_id, sender_id, tools_allowed)
|
|
493
849
|
if not tools_allowed and not _TOOL_VETO_ACTIVE:
|
|
494
850
|
# This Hermes cannot be made to refuse tool calls, so a turn the
|
|
495
|
-
# daemon did not grant tools never reaches it. Acked by
|
|
851
|
+
# daemon did not grant tools never reaches it. Acked at once by
|
|
852
|
+
# _receive_message.
|
|
496
853
|
logger.warning("SecureComs: refused a turn without a tool grant (tool veto unavailable)")
|
|
497
854
|
await self._notify_refused(channel_id, frame)
|
|
498
|
-
return
|
|
855
|
+
return None
|
|
499
856
|
|
|
500
857
|
source = self.build_source(
|
|
501
858
|
chat_id=channel_id,
|
|
@@ -509,12 +866,32 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
509
866
|
# ungranted text is escaped: Hermes strips leading @url:/@file:
|
|
510
867
|
# refs before its "/" check, so "@url:x /reset" is a command too.
|
|
511
868
|
text = securecoms_authz.COMMAND_ESCAPE + text
|
|
869
|
+
# Image/file attachments the daemon decrypted to disk ride as Hermes
|
|
870
|
+
# media, the same shape Hermes's own Signal adapter emits
|
|
871
|
+
# (gateway/platforms/signal.py): media_urls = local file paths,
|
|
872
|
+
# media_types = mimes, message_type from the first matching MIME
|
|
873
|
+
# prefix, else DOCUMENT. An image with no caption still runs a turn.
|
|
874
|
+
attachments, dropped = securecoms_transport.scan_inbound_attachments(frame)
|
|
875
|
+
if dropped:
|
|
876
|
+
# Content-free: a fixed line plus a process counter, never a path,
|
|
877
|
+
# name or mime (tests/test_logging_privacy.py bars dynamic args).
|
|
878
|
+
_ATTACHMENTS_DROPPED += dropped
|
|
879
|
+
logger.info("SecureComs: dropped inbound attachment(s) that failed validation")
|
|
880
|
+
media_kw: Dict[str, Any] = {}
|
|
881
|
+
message_type = MessageType.TEXT
|
|
882
|
+
if attachments:
|
|
883
|
+
media_kw = {
|
|
884
|
+
"media_urls": [p for p, _ in attachments],
|
|
885
|
+
"media_types": [m for _, m in attachments],
|
|
886
|
+
}
|
|
887
|
+
message_type = _message_type_for_mimes(media_kw["media_types"])
|
|
512
888
|
event = MessageEvent(
|
|
513
|
-
text=text,
|
|
514
|
-
message_type=
|
|
889
|
+
text=text if isinstance(text, str) else "",
|
|
890
|
+
message_type=message_type,
|
|
515
891
|
source=source,
|
|
516
892
|
message_id=str(frame.get("message_id") or int(time.time() * 1000)),
|
|
517
893
|
timestamp=datetime.now(),
|
|
894
|
+
**media_kw,
|
|
518
895
|
)
|
|
519
896
|
# Sender role rides as event metadata, never in the user text.
|
|
520
897
|
metadata = getattr(event, "metadata", None)
|
|
@@ -532,7 +909,22 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
532
909
|
prompts = [p for p in (voice_prompt_of(frame), None if tools_allowed else securecoms_authz.NO_TOOLS_TURN_NOTE) if p]
|
|
533
910
|
if prompts:
|
|
534
911
|
event.channel_prompt = "\n\n".join(prompts)
|
|
912
|
+
event._securecoms_session_was_busy = self._session_was_busy(event)
|
|
535
913
|
await self.handle_message(event)
|
|
914
|
+
return event
|
|
915
|
+
|
|
916
|
+
def _may_look_up(self, channel_id: str) -> bool:
|
|
917
|
+
"""Negative cache for unknown channels: True at most once per
|
|
918
|
+
_UNKNOWN_CHANNEL_RETRY_S per channel (bounded map, oldest evicted)."""
|
|
919
|
+
now = time.monotonic()
|
|
920
|
+
last = self._unknown_channel_lookups.get(channel_id)
|
|
921
|
+
if last is not None and now - last < _UNKNOWN_CHANNEL_RETRY_S:
|
|
922
|
+
return False
|
|
923
|
+
self._unknown_channel_lookups.pop(channel_id, None)
|
|
924
|
+
self._unknown_channel_lookups[channel_id] = now
|
|
925
|
+
while len(self._unknown_channel_lookups) > _UNKNOWN_CHANNEL_LOOKUPS_MAX:
|
|
926
|
+
self._unknown_channel_lookups.pop(next(iter(self._unknown_channel_lookups)))
|
|
927
|
+
return True
|
|
536
928
|
|
|
537
929
|
async def _notify_refused(self, channel_id: str, frame: Dict[str, Any]) -> None:
|
|
538
930
|
"""One short notice per channel that this agent cannot respond there.
|
|
@@ -639,7 +1031,7 @@ class SecureComsAdapter(BasePlatformAdapter):
|
|
|
639
1031
|
meta = self._channels.get(chat_id, {})
|
|
640
1032
|
return {
|
|
641
1033
|
"name": meta.get("name", chat_id),
|
|
642
|
-
"type": "dm" if meta.get("is_dm"
|
|
1034
|
+
"type": "dm" if meta.get("is_dm") is True else "group",
|
|
643
1035
|
"chat_id": chat_id,
|
|
644
1036
|
}
|
|
645
1037
|
|