@securecoms/agent-bridge 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,6 +80,27 @@ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
80
80
  _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
81
81
  _CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
82
82
 
83
+ # Voice calls (owner report 2026-10-02): a turn the voice front desk handed to
84
+ # Hermes carries the daemon's compiled-in spoken-reply guidance. It rides as
85
+ # the event's per-message ephemeral system prompt (MessageEvent.channel_prompt)
86
+ # so the answer is fit to be read aloud. Byte-equal to
87
+ # @securecoms/agent-runtime's VOICE_TURN_PREFIX.
88
+ VOICE_TURN_PREFIX = (
89
+ "This message was spoken on a live voice call and your reply will be read aloud: "
90
+ "plain spoken sentences, no markdown, lists, links or code. "
91
+ "Use your tools as usual when the question needs them. "
92
+ )
93
+ _VOICE_GUIDANCE_MAX_CHARS = 1000
94
+
95
+
96
+ def voice_prompt_of(frame: Dict[str, Any]) -> Optional[str]:
97
+ """The voice system prompt for a `voice: true` frame, else None."""
98
+ guidance = frame.get("guidance")
99
+ if frame.get("voice") is not True or not isinstance(guidance, str):
100
+ return None
101
+ g = guidance.strip()[:_VOICE_GUIDANCE_MAX_CHARS]
102
+ return VOICE_TURN_PREFIX + g if g else None
103
+
83
104
 
84
105
  def _error_class(error: object) -> str:
85
106
  """Return a bounded class name without reading exception prose."""
@@ -417,6 +438,9 @@ class SecureComsAdapter(BasePlatformAdapter):
417
438
  message_id=str(frame.get("message_id") or int(time.time() * 1000)),
418
439
  timestamp=datetime.now(),
419
440
  )
441
+ voice_prompt = voice_prompt_of(frame)
442
+ if voice_prompt is not None:
443
+ event.channel_prompt = voice_prompt
420
444
  await self.handle_message(event)
421
445
 
422
446
  # ── Outbound ──────────────────────────────────────────────────────────
@@ -54,6 +54,7 @@ def _install_gateway_stub():
54
54
  source: Any = None
55
55
  message_id: Optional[str] = None
56
56
  timestamp: Any = field(default_factory=datetime.now)
57
+ channel_prompt: Optional[str] = None
57
58
 
58
59
  @dataclass
59
60
  class SendResult:
@@ -324,6 +325,34 @@ class TestEchoGuardWiring:
324
325
  assert ws.sent == [{"type": "ack", "message_id": "m2"}]
325
326
 
326
327
 
328
+ class TestVoiceGuidance:
329
+ @pytest.mark.asyncio
330
+ async def test_voice_turn_carries_guidance_as_channel_prompt_text_turn_none(self, monkeypatch):
331
+ a, _ = make_adapter(monkeypatch)
332
+ a._bound_identity = "A"
333
+ handled = []
334
+
335
+ async def handler(event):
336
+ handled.append(event)
337
+
338
+ a.set_message_handler(handler)
339
+ frames = [
340
+ {"type": "message", "sender_id": "B", "channel_id": "c", "text": "what's the weather?",
341
+ "message_id": "v1", "voice": True, "guidance": "One short spoken sentence."},
342
+ {"type": "message", "sender_id": "B", "channel_id": "c", "text": "hi",
343
+ "message_id": "t1", "guidance": "ignored without voice"},
344
+ ]
345
+ ws = FakeWS([_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(f)) for f in frames]
346
+ + [_FakeWSMsg(aiohttp.WSMsgType.CLOSED)])
347
+ a._ws = ws
348
+
349
+ await a._pump()
350
+
351
+ assert [e.text for e in handled] == ["what's the weather?", "hi"]
352
+ assert handled[0].channel_prompt == adapter.VOICE_TURN_PREFIX + "One short spoken sentence."
353
+ assert handled[1].channel_prompt is None
354
+
355
+
327
356
  # ---------------------------------------------------------------------------
328
357
  # (2) send() success fires the status manager's on_reply_posted
329
358
  # ---------------------------------------------------------------------------
@@ -255,6 +255,22 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
255
255
  expect(rt.recorded.map((r) => r.sessionKey)).toEqual([a.SessionKey, b.SessionKey]);
256
256
  });
257
257
 
258
+ it("a voice turn carries the daemon's spoken-reply guidance as GroupSystemPrompt; a text turn carries none", async () => {
259
+ const rt = makeRuntime();
260
+ const { instances } = await start(rt.runtime);
261
+ const ws = instances[0];
262
+
263
+ deliver(ws, { channel_id: "chan-V", message_id: "m-V", sender_id: OWNER_ID, text: "what's the weather?", voice: true, guidance: "One short spoken sentence.", authorization: auth("owner_dm") });
264
+ deliver(ws, { channel_id: "chan-T", message_id: "m-T", sender_id: OWNER_ID, text: "hi", guidance: "ignored without voice", authorization: auth("owner_dm") });
265
+ await waitFor(() => acksOf(ws).length === 2);
266
+
267
+ const { VOICE_TURN_PREFIX } = await import("./openclaw-entry.js");
268
+ const [v, t] = rt.contexts;
269
+ expect(v.GroupSystemPrompt).toBe(`${VOICE_TURN_PREFIX}One short spoken sentence.`);
270
+ expect(v.RawBody).toBe("what's the weather?");
271
+ expect(t.GroupSystemPrompt).toBeUndefined();
272
+ });
273
+
258
274
  it("two DMs from different humans do not share a session", async () => {
259
275
  const rt = makeRuntime();
260
276
  const { instances } = await start(rt.runtime);
@@ -85,6 +85,25 @@ const _channels = new Map();
85
85
  // ---------------------------------------------------------------------------
86
86
 
87
87
  export const MAX_INBOUND_ATTEMPTS = 3;
88
+
89
+ /**
90
+ * Voice calls (owner report 2026-10-02): prefixes the daemon's compiled-in
91
+ * spoken-reply guidance on a live voice turn, so a turn the voice front desk
92
+ * handed to OpenClaw is answered in a form fit to be read aloud. Byte-equal
93
+ * to @securecoms/agent-runtime's VOICE_TURN_PREFIX.
94
+ */
95
+ export const VOICE_TURN_PREFIX =
96
+ "This message was spoken on a live voice call and your reply will be read aloud: plain spoken sentences, no markdown, lists, links or code. Use your tools as usual when the question needs them. ";
97
+
98
+ /** Cap on the daemon-supplied voice guidance carried into the prompt. */
99
+ const VOICE_GUIDANCE_MAX_CHARS = 1000;
100
+
101
+ /** The guidance of a `voice: true` frame (trimmed, capped), else undefined. */
102
+ export function voiceGuidanceOf(frame) {
103
+ if (frame?.voice !== true || typeof frame.guidance !== "string") return undefined;
104
+ const g = frame.guidance.trim().slice(0, VOICE_GUIDANCE_MAX_CHARS);
105
+ return g || undefined;
106
+ }
88
107
  const SEEN_MAX = 5000;
89
108
 
90
109
  /** Thrown by the inbound handler when register() has not injected a runtime. */
@@ -519,6 +538,7 @@ export class LoopbackChannel {
519
538
  senderName: frame.sender_name,
520
539
  authorization: frame.authorization,
521
540
  timestamp: frame.ts,
541
+ voiceGuidance: voiceGuidanceOf(frame),
522
542
  });
523
543
  this._failures.delete(messageId);
524
544
  this._failStreak = 0;
@@ -750,6 +770,9 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
750
770
  // authorization block (isOwnerAuthorization). MLS membership proves a
751
771
  // sender is in the room, not that they own this agent.
752
772
  CommandAuthorized: commandAuthorized,
773
+ // Voice turn: OpenClaw folds GroupSystemPrompt into this turn's extra
774
+ // system prompt (get-reply's extraSystemPromptParts) for any chat type.
775
+ ...(metadata.voiceGuidance ? { GroupSystemPrompt: `${VOICE_TURN_PREFIX}${metadata.voiceGuidance}` } : {}),
753
776
  });
754
777
 
755
778
  await core.channel.session.recordInboundSession({