@securecoms/agent-bridge 0.21.0 → 0.24.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,24 +1,131 @@
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
+ * Idempotent per message: delivery is at-least-once (a failed ack redelivers
78
+ * the row, and custody serves it again), so the same message can reach
79
+ * {@link noteInbound} more than once. Each (channel, message id) is counted
80
+ * once, and a repeat returns the hop count its first sighting got, so the
81
+ * repeat gets the same verdict and leaves the run untouched. Counting a repeat
82
+ * inflated the run until the NEXT agent message was suppressed and acked, a
83
+ * silent loss. The key is sound because a message id is globally unique at the
84
+ * backend (`mls_messages_pkey`): a second, different message under a reused id
85
+ * is refused (`message_id_conflict`) and never fanned out, so a peer cannot
86
+ * use it to slip past the cap.
87
+ */
88
+ export declare class AgentHopTracker {
89
+ private readonly maxChannels;
90
+ private readonly hops;
91
+ /** `${channelId}\u0000${messageId}` → the hop count its first sighting got. Map order = age. */
92
+ private readonly seen;
93
+ private readonly now;
94
+ private readonly quietMs;
95
+ private readonly maxSeenIds;
96
+ constructor(maxChannels?: number, opts?: {
97
+ now?: () => number;
98
+ quietMs?: number;
99
+ maxSeenIds?: number;
100
+ });
101
+ /**
102
+ * Record an inbound message; returns the consecutive agent hops including it.
103
+ * `messageId` makes the call idempotent (see the class comment). Without one
104
+ * the message is counted every time, the conservative direction for a loop
105
+ * guard.
106
+ */
107
+ noteInbound(channelId: string, senderIsAgent: boolean | undefined, messageId?: string): number;
108
+ /** Test seam: how many message ids are remembered. */
109
+ seenIdCount(): number;
110
+ /**
111
+ * Record one of this agent's own SUBSTANTIVE sends (an agent turn in the
112
+ * channel). Callers do not report status frames, daily-limit or dead-letter
113
+ * notices, or continuation parts of one multi-part reply.
114
+ */
115
+ noteOwnSend(channelId: string): void;
116
+ size(): number;
117
+ private bump;
118
+ private set;
119
+ }
120
+ /**
121
+ * Final-wave I2: whether a runtime `send` is a SUBSTANTIVE agent turn for the
122
+ * hop count. Not one: a notice (`notice: true` — e.g. the daily-limit reply)
123
+ * or a continuation part of a multi-part reply (its in_reply_to already has a
124
+ * durable reply receipt, i.e. an earlier part was published).
125
+ */
126
+ export declare function isAgentTurn(receipts: {
127
+ runtimeReplyReceipt(inReplyTo: string): unknown;
128
+ }, frame: {
129
+ notice?: unknown;
130
+ in_reply_to?: unknown;
24
131
  }): 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" | "group_superseded" | "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[];
@@ -34,32 +53,32 @@ export declare const memoryRecordSchema: z.ZodEffects<z.ZodObject<{
34
53
  message_id: z.ZodOptional<z.ZodString>;
35
54
  idempotency_key: z.ZodOptional<z.ZodString>;
36
55
  }, "strip", z.ZodTypeAny, {
37
- role: "agent" | "peer";
38
56
  text: string;
57
+ role: "agent" | "peer";
39
58
  conversation_id: string;
40
59
  modality: "voice" | "text";
41
60
  message_id?: string | undefined;
42
61
  idempotency_key?: string | undefined;
43
62
  peer_id?: string | undefined;
44
63
  }, {
45
- role: "agent" | "peer";
46
64
  text: string;
65
+ role: "agent" | "peer";
47
66
  conversation_id: string;
48
67
  modality: "voice" | "text";
49
68
  message_id?: string | undefined;
50
69
  idempotency_key?: string | undefined;
51
70
  peer_id?: string | undefined;
52
71
  }>, {
53
- role: "agent" | "peer";
54
72
  text: string;
73
+ role: "agent" | "peer";
55
74
  conversation_id: string;
56
75
  modality: "voice" | "text";
57
76
  message_id?: string | undefined;
58
77
  idempotency_key?: string | undefined;
59
78
  peer_id?: string | undefined;
60
79
  }, {
61
- role: "agent" | "peer";
62
80
  text: string;
81
+ role: "agent" | "peer";
63
82
  conversation_id: string;
64
83
  modality: "voice" | "text";
65
84
  message_id?: string | undefined;