@securecoms/agent-bridge 0.20.0 → 0.23.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.
@@ -1,4 +1,5 @@
1
1
  import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
+ import type { MemoryContextResult } from "@agentvault/connect-mls-store";
2
3
  /**
3
4
  * M6 (ADR-0013 Decision H): what the sweep observed about one channel.
4
5
  *
@@ -35,12 +36,45 @@ import { type ChatMessage } from "./voice/fast-prompt.js";
35
36
  import { VOICE_CALL_START_MARKER } from "./voice/prompt-compiler.js";
36
37
  import { type VaultListNode } from "./vault.js";
37
38
  import type { AgentActionNodeType, AgentActionNodeStatus, AgentActionNodeDetail } from "@agentvault/connect-core";
39
+ import type { RuntimeReplyReceipt } from "./reply-receipts.js";
40
+ export type AgentAudience = "owner_dm" | "team_room" | "partner_room" | "other_human_dm" | "agent_to_agent" | "unknown";
41
+ export type BridgeToolGrant = {
42
+ readonly tool: string;
43
+ readonly mode: "read";
44
+ } | {
45
+ readonly tool: string;
46
+ readonly mode: "side_effect";
47
+ readonly operation_sha256: string;
48
+ readonly confirmation_id: string;
49
+ readonly idempotency_key: string;
50
+ readonly expires_at: string;
51
+ };
52
+ /**
53
+ * Immutable authorization context derived from authenticated backend roster
54
+ * data. The E2E plaintext never contributes to these claims. Tool grants are
55
+ * intentionally empty until the channel-grant persistence/API lands; this
56
+ * makes the new contract fail closed during staged deployment.
57
+ */
58
+ export interface BridgeAuthorization {
59
+ readonly version: 1;
60
+ readonly audience: AgentAudience;
61
+ readonly sender_membership: "active" | "unknown";
62
+ readonly tool_grants: readonly BridgeToolGrant[];
63
+ }
38
64
  export interface BridgeMessage {
39
65
  channel_id: string;
40
66
  message_id: string;
41
67
  sender_id: string;
42
68
  text: string;
43
69
  ts: number;
70
+ authorization: BridgeAuthorization;
71
+ /**
72
+ * Protected, channel-scoped memory captured before this inbound turn is
73
+ * recorded. The runtime can therefore prepend prior context without owning
74
+ * a second plaintext persistence path or duplicating the current message.
75
+ * Missing only for non-text/legacy buffered entries.
76
+ */
77
+ memory?: MemoryContextResult;
44
78
  /**
45
79
  * Phase 2 (sender attribution): the sender's display name, resolved from the
46
80
  * channel roster cache. Undefined when the roster hasn't resolved the
@@ -179,6 +213,19 @@ export interface BridgeAgentDeps {
179
213
  * recent (a real-time messaging consumer cares about the newest). Default 1000.
180
214
  */
181
215
  maxBuffer?: number;
216
+ /**
217
+ * Phase 3 F1: how many times one inbound message may be delivered to a
218
+ * runtime consumer over /v1/ws before it is dead-lettered (content-free log +
219
+ * ack) instead of redelivered forever. Every redelivery can re-run the
220
+ * owner's model on their own key. Default {@link DEFAULT_MAX_DELIVERY_ATTEMPTS}.
221
+ */
222
+ maxDeliveryAttempts?: number;
223
+ /**
224
+ * Phase 3 F3: consecutive agent-authored messages allowed in one channel
225
+ * before this agent stops answering other agents there (a human message
226
+ * resets the count). Default {@link DEFAULT_MAX_AGENT_HOPS}.
227
+ */
228
+ maxAgentHops?: number;
182
229
  /**
183
230
  * Review I3/M3: clock seam for the agent.action.* partial-throttle token
184
231
  * buckets AND the unsettled-run stale-TTL sweep. Defaults to Date.now.
@@ -211,10 +258,46 @@ export interface BridgeAgentDeps {
211
258
  }
212
259
  /** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
213
260
  export declare const DEFAULT_MAX_BUFFER = 1000;
261
+ /**
262
+ * Phase 3 F1: default per-message delivery cap for the /v1/ws runtime handoff.
263
+ * Deliberately ABOVE the OpenClaw connector's own MAX_INBOUND_ATTEMPTS (3,
264
+ * connectors/openclaw/openclaw-entry.js): that connector acks a poison
265
+ * message itself after three failed turns, so this daemon cap is only the
266
+ * backstop for consumers without one, and the two extra deliveries leave
267
+ * headroom for the connector's "runtime not ready" / receipt-lookup retries,
268
+ * which it deliberately does not count.
269
+ */
270
+ export declare const DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
271
+ /**
272
+ * Final-wave I1(a): a runtime delivery that failed (a `nack`, or the socket
273
+ * closing with the message in flight) is not redelivered before
274
+ * 2^attempts × this base. With the default cap of 5 the deliveries span
275
+ * minutes, not seconds, so a provider outage no longer dead-letters work
276
+ * within seconds. The attempt counter is durable, and the delay is derived
277
+ * from it — a restart re-arms the same backoff from the restore.
278
+ */
279
+ export declare const REDELIVERY_BACKOFF_BASE_MS = 5000;
280
+ export declare function redeliveryDelayMs(attempts: number): number;
281
+ /**
282
+ * Final-wave I1(c): the one content-free reply a channel gets when one of its
283
+ * messages is dead-lettered. Fixed text — it never echoes the message.
284
+ */
285
+ export declare const DEAD_LETTER_NOTICE = "I couldn't process a message. Please resend it.";
286
+ /** At most one {@link DEAD_LETTER_NOTICE} per channel per this window. */
287
+ export declare const DEAD_LETTER_NOTICE_INTERVAL_MS: number;
288
+ /**
289
+ * Phase 3 F1 (pause stops inference): after a send is refused with
290
+ * `agent_paused`, inbound traffic for that channel is not handed to the
291
+ * runtime (no model call). The pause is reversible, so once this long has
292
+ * passed since it was last confirmed, the next inbound message re-probes the
293
+ * agent's own membership row before deciding.
294
+ */
295
+ export declare const PAUSE_RECHECK_MS = 30000;
214
296
  /**
215
297
  * TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
216
- * so this exists only so a cached `name` (the room display name used by the
217
- * @mention filter) re-resolves after a channel rename. Lazy expiry on read.
298
+ * so this exists only so a cached channel `name` re-resolves after a channel
299
+ * rename. The channel name is NOT an @mention name for this agent (2026-09-25:
300
+ * the filter uses this agent's own roster display_name). Lazy expiry on read.
218
301
  */
219
302
  export declare const CHANNEL_META_TTL_MS: number;
220
303
  /**
@@ -289,6 +372,17 @@ export declare class AgentPausedSendError extends Error {
289
372
  readonly channelId: string;
290
373
  constructor(channelId: string);
291
374
  }
375
+ /**
376
+ * Phase 3 F1: the backend refused a publish with a bare 401/403 (not the
377
+ * typed `agent_paused` body) — the agent's credential or device has been
378
+ * revoked or it is no longer a member. Retrying cannot succeed, so this is
379
+ * TERMINAL for the reply: the runtime's inbound message is acknowledged and
380
+ * the model is not run again for it.
381
+ */
382
+ export declare class SendForbiddenError extends Error {
383
+ readonly channelId: string;
384
+ constructor(channelId: string);
385
+ }
292
386
  /** Thrown by `agent.action.*` when a runId (or nodeId within a run) is unknown
293
387
  * or the run has already settled — settle() is terminal, mirroring the client
294
388
  * ingest side's "later partials for a settled run are dropped" rule. */
@@ -372,6 +466,15 @@ export declare class BridgeAgent {
372
466
  * per channel on a miss.
373
467
  */
374
468
  private readonly channelRoster;
469
+ /**
470
+ * 2026-09-25: this agent's own display_name (`ConnectIdentity.display_name`,
471
+ * identity-level, so the same in every channel) from the MOST RECENT roster
472
+ * refresh that listed it. Read by the room @mention filter as its
473
+ * `roomDisplayName`. Kept across a later failed refresh, so a transient
474
+ * members-fetch error does not make the agent deaf to its own name; a
475
+ * successful refresh after a rename replaces it.
476
+ */
477
+ private lastOwnDisplayName;
375
478
  /**
376
479
  * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
377
480
  * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
@@ -382,7 +485,37 @@ export declare class BridgeAgent {
382
485
  */
383
486
  private readonly channelOwner;
384
487
  private readonly buffer;
488
+ /** Message ids durably staged for the attached runtime but not yet acked. */
489
+ private readonly runtimePendingIds;
385
490
  private readonly maxBuffer;
491
+ /** Phase 3 F1: deliveries so far per pending runtime message (seeded from the store). */
492
+ private readonly deliveryAttempts;
493
+ private readonly maxDeliveryAttempts;
494
+ /** Phase 3 F1: cumulative messages dead-lettered at the delivery cap. */
495
+ private deadLetteredMessages;
496
+ /** Final-wave I1(a): message id → earliest redelivery time (epoch ms). */
497
+ private readonly redeliverNotBefore;
498
+ /** Final-wave I1(a): one timer poking consumers when the earliest backoff ends. */
499
+ private redeliveryTimer;
500
+ private redeliveryTimerAt;
501
+ /** Final-wave I1(c): channel → when its last dead-letter notice was posted. */
502
+ private readonly deadLetterNoticeAt;
503
+ /** Phase 3 F1: channel → when a pause was last confirmed (send 403 or probe). */
504
+ private readonly pausedChannels;
505
+ /**
506
+ * Phase 3 F1: runtime messages retired by a TERMINAL reply failure reported
507
+ * outside the /v1/ws socket that holds them in flight (HTTP /v1/send). The
508
+ * socket consults this so it neither redelivers them nor stays busy on them.
509
+ * Bounded; oldest forgotten first.
510
+ */
511
+ private readonly retiredRuntimeIds;
512
+ /** Phase 3 F1: one pending membership recheck per paused channel holding work. */
513
+ private readonly pauseRecheckTimers;
514
+ /** Phase 3 F3: consecutive agent turns per channel. */
515
+ private readonly agentHops;
516
+ private readonly maxAgentHops;
517
+ /** Phase 3 F3: cumulative agent-authored messages not answered (no explicit mention / hop limit). */
518
+ private agentLoopSuppressed;
386
519
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
387
520
  private droppedMessages;
388
521
  /**
@@ -519,7 +652,7 @@ export declare class BridgeAgent {
519
652
  private static readonly REQUEST_WELCOME_COOLDOWN_MS;
520
653
  private static readonly GRACEFUL_DRAIN_MS;
521
654
  /**
522
- * P0b (daemon-stability): collapses the onDecryptError console.warn from
655
+ * P0b (daemon-stability): collapses the onDecryptError diagnostic from
523
656
  * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
524
657
  * most one line immediately + one summary line per 60s per channel. LOG
525
658
  * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
@@ -601,6 +734,7 @@ export declare class BridgeAgent {
601
734
  private readonly waiters;
602
735
  private pingHandle?;
603
736
  private safetyTimer?;
737
+ private runtimeConfigTimer?;
604
738
  private readonly listeners;
605
739
  private startedAt;
606
740
  private readonly store;
@@ -819,9 +953,16 @@ export declare class BridgeAgent {
819
953
  * reads `null` and therefore degraded.
820
954
  */
821
955
  private keypackageExpired;
956
+ /** #335: dedupes the expiry-measurement warning to once per failure STREAK —
957
+ * any non-throwing resolution (including the known-unknown `null` returns,
958
+ * which are answers, not failures) re-arms it. Mirrors `enclaveListWarned`. */
959
+ private keypackageExpiryUnknownWarned;
822
960
  /** Resolve the flag from the SAME two inputs the guard uses, with every unknown
823
961
  * mapped to `null`. Never throws: a failure to measure is itself unknown. */
824
962
  private resolveKeypackageExpired;
963
+ /** Every non-throwing resolution ends the failure streak (the next throw is
964
+ * a NEW streak and warns again) and passes the value through unchanged. */
965
+ private kpExpiryResolved;
825
966
  private rotateDeviceKeyPackage;
826
967
  private selfHealSweep;
827
968
  /**
@@ -853,7 +994,14 @@ export declare class BridgeAgent {
853
994
  * already resolves internally) propagates unchanged.
854
995
  */
855
996
  private sendWithSelfHealTerminal;
856
- send(channelId: string, text: string, messageId?: string): Promise<{
997
+ /**
998
+ * `opts.agentTurn` (default true): whether this send is a SUBSTANTIVE agent
999
+ * turn for the Phase 3 F3 hop count. Notices (daily limit, dead letter) and
1000
+ * continuation parts of one multi-part reply pass false (final-wave I2).
1001
+ */
1002
+ send(channelId: string, text: string, messageId?: string, opts?: {
1003
+ agentTurn?: boolean;
1004
+ }): Promise<{
857
1005
  message_id: string;
858
1006
  status: "sent";
859
1007
  }>;
@@ -861,7 +1009,9 @@ export declare class BridgeAgent {
861
1009
  a2a_version: string;
862
1010
  kind: "message" | "task" | "artifact";
863
1011
  object: Record<string, unknown>;
864
- }, messageId?: string): Promise<{
1012
+ }, messageId?: string, opts?: {
1013
+ agentTurn?: boolean;
1014
+ }): Promise<{
865
1015
  message_id: string;
866
1016
  status: "sent";
867
1017
  }>;
@@ -937,6 +1087,21 @@ export declare class BridgeAgent {
937
1087
  seq: number;
938
1088
  deduped: boolean;
939
1089
  }>;
1090
+ /**
1091
+ * OpenClaw collapses text history and workspace facts into the owner's
1092
+ * session. Resolve the channel from fresh, authenticated server metadata
1093
+ * before exposing that provider through the local bridge API. A cold or
1094
+ * stale cache is refreshed once; any failure remains a fail-closed denial.
1095
+ */
1096
+ private mayReadOpenClawMemory;
1097
+ /** An inbound turn may receive OpenClaw memory only from an active owner DM. */
1098
+ private mayReadOpenClawMemoryForTurn;
1099
+ /**
1100
+ * Resolve the prior-turn snapshot carried to the native runtime. OpenClaw
1101
+ * stays owner-DM-only; every provider failure degrades to empty context and
1102
+ * never falls back to the runtime's retired plaintext JSON files.
1103
+ */
1104
+ private memoryForTurn;
940
1105
  memoryContext(conversationId: string, limit?: number): Promise<import("@agentvault/connect-mls-store").MemoryContextResult>;
941
1106
  /**
942
1107
  * Task 4 (self-hosted fast voice): instant persona-voiced acknowledgement
@@ -1118,6 +1283,13 @@ export declare class BridgeAgent {
1118
1283
  * attribution must NEVER block or drop a message.
1119
1284
  */
1120
1285
  private refreshChannelRoster;
1286
+ /**
1287
+ * Classify the sender only from server-authenticated channel/roster data.
1288
+ * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
1289
+ * guess. The returned object and grant array are frozen before they cross the
1290
+ * daemon boundary so one consumer cannot widen a later execution check.
1291
+ */
1292
+ private authorizationFor;
1121
1293
  /**
1122
1294
  * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
1123
1295
  * NEVER read the server's `role` field to pick the owner. In a DM the owner
@@ -1174,9 +1346,6 @@ export declare class BridgeAgent {
1174
1346
  /** call_summary rides a system_event envelope with a body-level type, the
1175
1347
  * same shape sendActionFrame uses for agent_action. */
1176
1348
  private sendSummary;
1177
- /** Minimal stderr logger for non-fatal daemon events (mirrors the ad hoc
1178
- * `process.stderr.write` calls elsewhere in this file). */
1179
- private log;
1180
1349
  /**
1181
1350
  * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
1182
1351
  * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
@@ -1211,6 +1380,97 @@ export declare class BridgeAgent {
1211
1380
  subscribe(listener: () => void): () => void;
1212
1381
  /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
1213
1382
  takeBuffered(channelId?: string): BridgeMessage[];
1383
+ /**
1384
+ * Phase 3 F2: per-channel ordered delivery for the /v1/ws runtime consumer.
1385
+ * Splices out, oldest first, at most ONE buffered message per channel that
1386
+ * is not in `busyChannels` (the channels the consumer still holds a message
1387
+ * in flight for); everything else stays buffered in order. A runtime is
1388
+ * therefore never handed a second message for a channel it is still
1389
+ * working on — which it used to refuse with a "busy" reply and ack — and a
1390
+ * backlog replays one message per channel at a time after a restart.
1391
+ */
1392
+ takeDeliverable(busyChannels: ReadonlySet<string>): BridgeMessage[];
1393
+ /**
1394
+ * Phase 3 F1: gate ONE delivery of `message` to the runtime consumer. Returns
1395
+ * true when it may be sent (and counts the attempt durably), false when the
1396
+ * message already reached the delivery cap — then it is dead-lettered:
1397
+ * acknowledged, never redelivered, never reaching a model. (Paused channels
1398
+ * never get here: takeDeliverable holds them, so a pause spends no attempts.)
1399
+ * Logs are content-free (event + counts only).
1400
+ */
1401
+ admitRuntimeDelivery(message: BridgeMessage): boolean;
1402
+ /**
1403
+ * Final-wave I1: put back runtime deliveries that FAILED — the runtime sent a
1404
+ * `nack` for the message, or the socket closed while it was in flight (an
1405
+ * older runtime closes on any failed turn). Each one's attempt was already
1406
+ * counted durably at delivery; here it either dead-letters (cap reached) or
1407
+ * is requeued at the front behind a 2^attempts × 5 s redelivery backoff.
1408
+ * Messages that were never handed to the runtime go back via requeueFront
1409
+ * instead and are not affected.
1410
+ */
1411
+ requeueFailedRuntimeDeliveries(messages: BridgeMessage[]): void;
1412
+ /**
1413
+ * Phase 3 F1 dead letter: acknowledged, never redelivered, never reaching a
1414
+ * model — plus (final-wave I1(c)) one content-free notice to the channel,
1415
+ * at most once per channel per hour. Logs carry counts only.
1416
+ */
1417
+ private deadLetter;
1418
+ private noticeDeadLetter;
1419
+ /** Poke consumers once the earliest redelivery backoff ends. */
1420
+ private armRedeliveryWake;
1421
+ /**
1422
+ * 2026-09-25: this agent's OWN display_name for the room @mention filter
1423
+ * (`roomDisplayName`). Never the channel's name: a channel called
1424
+ * "Hermesav, Cortina011" must not make `@hermesav` address this agent. A pure
1425
+ * field read; the roster refresh that fills it runs once per ingest cycle,
1426
+ * TTL-gated, before the filter. Undefined until any roster has listed this
1427
+ * agent, in which case the filter still matches the credential agentName and
1428
+ * the identity id.
1429
+ */
1430
+ private ownRoomDisplayName;
1431
+ /** Phase 3 F3: the loop guard's view of the sender — server roster first. */
1432
+ private loopSenderIsAgent;
1433
+ private noteAgentTurnSuppressed;
1434
+ /**
1435
+ * Phase 3 F1: whether `channelId`'s buffered work must be HELD because the
1436
+ * agent is paused there. Held work stays durably pending (never acked, no
1437
+ * delivery attempt spent) and is released by {@link recheckPause} once the
1438
+ * owner resumes the agent. Only a channel whose send was refused with
1439
+ * `agent_paused` is ever held. Holding arms a recheck at the end of the
1440
+ * PAUSE_RECHECK_MS window, so release does not depend on new traffic.
1441
+ */
1442
+ private heldForPause;
1443
+ private schedulePauseRecheck;
1444
+ /**
1445
+ * Re-read this agent's own membership row. Still paused → keep holding and
1446
+ * re-arm while work is held. Resumed — or a failed/inconclusive probe, which
1447
+ * fails OPEN so a channel is never left deaf (the worst case is one model
1448
+ * call whose reply is refused again, re-arming the pause) — releases the
1449
+ * held work and pokes consumers to drain it now.
1450
+ */
1451
+ private recheckPause;
1452
+ private clearPause;
1453
+ /** Read this agent's own `paused_at` from the enclave roster; undefined = unknown. */
1454
+ private probeOwnPause;
1455
+ recordRuntimeReply(inReplyTo: string, channelId: string, replyMessageId: string): RuntimeReplyReceipt;
1456
+ runtimeReplyReceipt(inReplyTo: string): RuntimeReplyReceipt | null;
1457
+ /**
1458
+ * Phase 3 F1: retire a runtime message whose reply failed TERMINALLY
1459
+ * (agent_paused / send_forbidden): acknowledged durably, never redelivered,
1460
+ * so the model is not run again for it. Pokes consumers so a socket holding
1461
+ * it in flight frees the channel for its next message.
1462
+ */
1463
+ retireRuntimeMessage(messageId: string): void;
1464
+ isRuntimeMessageRetired(messageId: string): boolean;
1465
+ /** Complete the durable handoff only after the runtime explicitly acks. */
1466
+ ackRuntimeMessages(messageIds: string[]): void;
1467
+ private restoreRuntimeInbox;
1468
+ /**
1469
+ * Seal a runtime frame before the backend queue id may be acknowledged.
1470
+ * Returning false is backpressure: the caller leaves the backend row unacked
1471
+ * so it remains the durable queue rather than shedding accepted work.
1472
+ */
1473
+ private stageRuntimeMessage;
1214
1474
  /**
1215
1475
  * Put messages back at the front (SSE/disconnect write-failure recovery).
1216
1476
  *
@@ -1256,6 +1516,12 @@ export declare class BridgeAgent {
1256
1516
  dropped: number;
1257
1517
  /** Cumulative ROOM messages dropped by the @mention filter (correct filtering ≠ idle). */
1258
1518
  filtered_room: number;
1519
+ /** Phase 3 F1: inbound messages retired at the runtime delivery-attempt cap. */
1520
+ dead_lettered: number;
1521
+ /** Phase 3 F1: channels whose inbound is currently held because the agent is paused. */
1522
+ paused_channels: number;
1523
+ /** Phase 3 F3: agent-authored messages not answered (no explicit mention / hop limit). */
1524
+ agent_loop_suppressed: number;
1259
1525
  /** Cumulative sends shed by the per-channel rate-limit floor (correct backpressure ≠ idle). */
1260
1526
  rate_limited: number;
1261
1527
  /** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
@@ -1374,6 +1640,12 @@ export declare class BridgeAgent {
1374
1640
  errors: ReturnType<MlsGroupKeyProvider["errorStats"]>;
1375
1641
  dropped: number;
1376
1642
  filteredRoomMessages: number;
1643
+ /** Phase 3 F1: inbound messages retired at the runtime delivery cap. */
1644
+ deadLetteredMessages: number;
1645
+ /** Phase 3 F1: channels whose inbound is currently held (agent paused). */
1646
+ pausedChannels: number;
1647
+ /** Phase 3 F3: agent-authored messages not answered by the loop guard. */
1648
+ agentLoopSuppressed: number;
1377
1649
  rateLimitedSends: number;
1378
1650
  permanentlySkipped: number;
1379
1651
  /** SDK-6: cumulative rows ack-drained via QUARANTINE, distinct from the
@@ -1,24 +1,109 @@
1
- /** Extract @mention names from plaintext. Returns lowercased names. */
1
+ /**
2
+ * Extract @mention names from plaintext. Returns lowercased names.
3
+ *
4
+ * A token starts with a word character and may contain `.` and `-`, so an agent
5
+ * handle (`@sc-agent-01`) and a full identity UUID mention stay whole (the gen2
6
+ * `@(\w[\w]*)` cut both at the first hyphen, so neither could ever address its
7
+ * agent). Trailing `.`/`-` is sentence punctuation and is trimmed
8
+ * (`thanks @Stephen.` → "stephen").
9
+ */
2
10
  export declare function parseMentions(text: string): string[];
3
11
  /**
4
12
  * Lower-cased set of names that count as "this agent" in a room: framework
5
13
  * agentName + its first word, accountId + its first word, and (when known) the
6
14
  * AgentVault room display_name + its first word. First word = split on
7
- * whitespace or "(".
15
+ * whitespace or "("; it also counts with trailing punctuation removed
16
+ * ("Stephen, PhD" → "stephen,", "stephen"), matching {@link parseMentions}.
17
+ *
18
+ * `roomDisplayName` is THIS AGENT's own display_name as a member of the room
19
+ * (its roster row), never the channel's name: a channel called
20
+ * "Hermesav, Cortina011" must not make `@hermesav` address this agent.
8
21
  */
9
22
  export declare function ownRoomNames(agentName: string, accountId: string, roomDisplayName?: string): Set<string>;
23
+ /**
24
+ * Phase 3 F3 — default per-room agent-to-agent hop limit: at most this many
25
+ * CONSECUTIVE agent-authored messages (since the last human message) in one
26
+ * channel. Two agents that keep @mentioning each other stop after this many
27
+ * turns instead of spending the owners' BYOK budget forever.
28
+ */
29
+ export declare const DEFAULT_MAX_AGENT_HOPS = 3;
30
+ /**
31
+ * Whether an agent-authored message may be answered. `agentHops` counts the
32
+ * consecutive agent-authored messages in the channel INCLUDING this one; our
33
+ * reply would be one more agent turn, so it is allowed only while
34
+ * `agentHops < maxAgentHops`.
35
+ */
36
+ export declare function agentHopAllowed(agentHops: number, maxAgentHops?: number): boolean;
10
37
  /**
11
38
  * Whether this agent should process a room message based on its @mentions.
12
39
  *
13
- * Loop prevention is the rate-limiter's job (a later slice), so this filter does
14
- * NOT gate on senderIsAgent — every message is processed unless it @mentions a
15
- * *different* agent specifically:
40
+ * Human (or unknown) sender — the gen2 floor, unchanged:
16
41
  * - no mentions → true (broadcast)
17
42
  * - @all / @everyone → true
18
43
  * - this agent is mentioned → true
19
44
  * - else (a different agent is mentioned) → false
45
+ *
46
+ * Agent sender (Phase 3 F3 loop guard): processed ONLY when that agent
47
+ * @mentions THIS agent by name (a broadcast, @all or @everyone from an agent
48
+ * is not an address) AND the per-room hop limit is not exhausted
49
+ * (`agentHops`, see {@link agentHopAllowed}). An unknown sender type keeps the
50
+ * fail-open human semantics so an unclassifiable message is never dropped.
20
51
  */
21
52
  export declare function shouldProcessRoomMessage(plaintext: string, agentName: string, accountId: string, opts?: {
22
53
  senderIsAgent?: boolean;
23
54
  roomDisplayName?: string;
55
+ /** Consecutive agent-authored messages in this room, including this one. */
56
+ agentHops?: number;
57
+ maxAgentHops?: number;
58
+ }): boolean;
59
+ /**
60
+ * Final-wave I2: a channel's agent-hop count resets once this long has passed
61
+ * since the last agent-authored message in it. Without it, two agents in a DM
62
+ * (where no human ever speaks) went permanently deaf to each other after
63
+ * DEFAULT_MAX_AGENT_HOPS turns. A live loop is seconds apart, so the cap still
64
+ * stops it; a conversation resumed ten quiet minutes later starts a new run.
65
+ */
66
+ export declare const AGENT_HOP_QUIET_MS: number;
67
+ /**
68
+ * Per-channel count of consecutive agent-authored messages since the last
69
+ * human message (or the last quiet window, {@link AGENT_HOP_QUIET_MS}). Fed
70
+ * with EVERY inbound text message of a channel (whether or not this agent
71
+ * processes it) plus this agent's own SUBSTANTIVE sends, so two agents in one
72
+ * room keep the same count from their own vantage points.
73
+ *
74
+ * In memory and bounded (oldest channel evicted first): a restart resets the
75
+ * counts, which can at worst allow one more bounded run of hops.
76
+ */
77
+ export declare class AgentHopTracker {
78
+ private readonly maxChannels;
79
+ private readonly hops;
80
+ private readonly now;
81
+ private readonly quietMs;
82
+ constructor(maxChannels?: number, opts?: {
83
+ now?: () => number;
84
+ quietMs?: number;
85
+ });
86
+ /** Record an inbound message; returns the consecutive agent hops including it. */
87
+ noteInbound(channelId: string, senderIsAgent: boolean | undefined): number;
88
+ /**
89
+ * Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
90
+ * channel). Callers do not report status frames, daily-limit or dead-letter
91
+ * notices, or continuation parts of one multi-part reply.
92
+ */
93
+ noteOwnSend(channelId: string): void;
94
+ size(): number;
95
+ private bump;
96
+ private set;
97
+ }
98
+ /**
99
+ * Final-wave I2: whether a runtime `send` is a SUBSTANTIVE agent turn for the
100
+ * hop count. Not one: a notice (`notice: true` — e.g. the daily-limit reply)
101
+ * or a continuation part of a multi-part reply (its in_reply_to already has a
102
+ * durable reply receipt, i.e. an earlier part was published).
103
+ */
104
+ export declare function isAgentTurn(receipts: {
105
+ runtimeReplyReceipt(inReplyTo: string): unknown;
106
+ }, frame: {
107
+ notice?: unknown;
108
+ in_reply_to?: unknown;
24
109
  }): boolean;
@@ -1,25 +1,19 @@
1
+ import { type ServiceLog } from "./service-log.js";
1
2
  export interface DecryptErrorInfo {
2
- /** MLS epoch the failing row was at, if known. */
3
+ /** MLS epoch the failing row was at, when it is a finite number. */
3
4
  epoch?: number | string;
4
- /** Delivery queue id the failing row came from, if known. */
5
- queueId?: string;
6
- /** The decrypt error's message (already stringified by the caller). */
7
- message: string;
5
+ /** Original thrown value; reduced to a bounded class by the service logger. */
6
+ error: unknown;
8
7
  }
9
8
  export interface DecryptErrorLogLimiterOpts {
10
9
  /** Suppression window in ms. Default 60_000 (60s). */
11
10
  windowMs?: number;
12
11
  /** Clock seam for tests. Defaults to Date.now. */
13
12
  now?: () => number;
14
- /** Log sink. Defaults to console.warn. Injectable so tests can assert on
15
- * emitted lines without capturing the real console. */
16
- log?: (line: string) => void;
13
+ /** Structured service-log seam for tests. */
14
+ log?: ServiceLog;
17
15
  }
18
- /** Default suppression window: 60 seconds. */
19
16
  export declare const DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 60000;
20
- /** Hard cap on distinct error reasons surfaced per channel per window. A wedged
21
- * channel has a small handful of genuine reasons; beyond this the extra volume
22
- * is noise (or an adversarial/buggy server cycling messages) and is suppressed. */
23
17
  export declare const MAX_DISTINCT_PER_WINDOW = 4;
24
18
  export declare class DecryptErrorLogLimiter {
25
19
  private readonly windowMs;
@@ -27,25 +21,7 @@ export declare class DecryptErrorLogLimiter {
27
21
  private readonly log;
28
22
  private readonly windows;
29
23
  constructor(opts?: DecryptErrorLogLimiterOpts);
30
- /**
31
- * Record one decrypt error for `channelId`. Logs immediately if this is
32
- * the channel's first error since its window opened (or its prior window
33
- * had already elapsed, in which case a summary for that just-closed
34
- * window is flushed first); otherwise the error is counted silently
35
- * toward the window's eventual summary line.
36
- */
37
24
  record(channelId: string, info: DecryptErrorInfo): void;
38
- /**
39
- * Clear a channel's window (e.g. once the caller learns the channel
40
- * cleared/healed) so its NEXT error logs immediately as a fresh "first"
41
- * rather than being folded into a stale window's summary. There is
42
- * currently no onDecryptSuccess-style hook from the SDK to wire this to
43
- * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
44
- * exposed here so a future success signal (or a caller with its own
45
- * channel-cleared signal) can reset explicitly. In its absence, the
46
- * window simply rolls over on interval elapse, which is an accepted
47
- * simplification (see report).
48
- */
49
25
  reset(channelId: string): void;
50
26
  private logFirst;
51
27
  private flushSummary;
package/dist/errors.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * WebSocket returns `{type:"error", error, message, detail?}`. A client gets the
7
7
  * same `error` code regardless of which transport it used. (A2A §9.5/§10.6 pattern.)
8
8
  */
9
- export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "unknown_action_run" | "vault_integrity_failed" | "internal";
9
+ export type BridgeErrorCode = "unauthorized" | "forbidden_origin" | "not_found" | "invalid_request" | "invalid_json" | "body_too_large" | "channel_pending" | "rate_limited" | "agent_paused" | "send_forbidden" | "unknown_action_run" | "vault_integrity_failed" | "internal";
10
10
  export interface BridgeErrorSpec {
11
11
  /** HTTP status this code maps to (the WS binding ignores it). */
12
12
  http: number;
@@ -1,11 +1,30 @@
1
1
  import { type Server } from "node:http";
2
2
  import { z } from "zod";
3
3
  import type { BridgeAgent, BridgeMessage } from "./bridge-agent.js";
4
+ /**
5
+ * #335 handling decision: the throwing-write swallow stays (a dying socket
6
+ * must cost only that connection's remainder — the caller requeues it and
7
+ * only acks what was written — never crash the flush loop), but it is no
8
+ * longer invisible: the optional `onWriteError` hook reports the thrown
9
+ * value so the caller can service-log its sanitized class. The hook is
10
+ * telemetry-only by contract: a throwing hook is contained here, so it can
11
+ * never change the returned remainder or turn a skipped write into a crash.
12
+ * (Relay precedent: connect-room-relay src/http.ts `writeSse`, PR #654.)
13
+ */
4
14
  export declare function writeSse(res: {
5
15
  destroyed?: boolean;
6
16
  writableEnded?: boolean;
7
17
  write: (s: string) => boolean;
8
- }, msgs: BridgeMessage[]): BridgeMessage[];
18
+ }, msgs: BridgeMessage[], onWriteError?: (error: unknown) => void): BridgeMessage[];
19
+ /**
20
+ * Once-per-connection write-failure reporter (#335). A throwing `res.write`
21
+ * means the socket is dying — teardown follows via the request's `'close'` —
22
+ * so one WARN per connection is the whole story; repeats from a re-entered
23
+ * flush on the same corpse stay silent. Logs the sanitized `error_class`
24
+ * only (ServiceLogFields is number/boolean-typed by design — message text,
25
+ * channel ids, and exception prose cannot reach the log line).
26
+ */
27
+ export declare function sseWriteFailureReporter(): (error: unknown) => void;
9
28
  export interface HttpServerOptions {
10
29
  token: string;
11
30
  allowOrigins: string[];