@securecoms/agent-bridge 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { MlsGroupKeyProvider } from "@agentvault/connect-sdk-ts";
2
+ import type { BridgeConfig } from "./config.js";
3
+ import { LatencyRecorder } from "./latency.js";
4
+ export interface BridgeMessage {
5
+ channel_id: string;
6
+ message_id: string;
7
+ sender_id: string;
8
+ text: string;
9
+ ts: number;
10
+ /**
11
+ * Phase 2 (sender attribution): the sender's display name, resolved from the
12
+ * channel roster cache. Undefined when the roster hasn't resolved the
13
+ * sender_id (fetch failure, or a member not in the listing) — the consumer
14
+ * then falls back to sender_id. Never blocks delivery on resolution.
15
+ */
16
+ sender_name?: string;
17
+ /**
18
+ * Phase 2: true if the envelope's sender_type is "agent", false if "human",
19
+ * undefined if the envelope carried neither. Derived from the SAME field the
20
+ * @mention filter reads (not recomputed differently).
21
+ */
22
+ sender_is_agent?: boolean;
23
+ }
24
+ export interface BridgeAgentDeps {
25
+ transport?: any;
26
+ backend?: any;
27
+ fetchImpl?: typeof fetch;
28
+ /** Injectable so tests can assert recorded stages; defaults to a fresh one. */
29
+ latency?: LatencyRecorder;
30
+ /**
31
+ * Phase 1 item 4 (ADR-0002): hard cap on the in-memory inbound buffer. A
32
+ * consumer that never drains (agent offline, no /v1/events poller) must not
33
+ * let a sustained inbound backlog grow unbounded and OOM the daemon. When the
34
+ * buffer would exceed this many messages we shed the OLDEST and keep the most
35
+ * recent (a real-time messaging consumer cares about the newest). Default 1000.
36
+ */
37
+ maxBuffer?: number;
38
+ }
39
+ /** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
40
+ export declare const DEFAULT_MAX_BUFFER = 1000;
41
+ /**
42
+ * TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
43
+ * so this exists only so a cached `name` (the room display name used by the
44
+ * @mention filter) re-resolves after a channel rename. Lazy expiry on read.
45
+ */
46
+ export declare const CHANNEL_META_TTL_MS: number;
47
+ /**
48
+ * TTL for the Phase 2 channel ROSTER cache (channel_id → identity_id → name /
49
+ * is_agent), used to attribute decrypted messages by sender. Parallels
50
+ * CHANNEL_META_TTL_MS: a member's display_name can change (rename) and the
51
+ * member set can change, so a stale roster re-resolves on a later cycle. Lazy
52
+ * expiry on read. Resolution is best-effort and fail-soft — a miss leaves
53
+ * sender_name undefined and NEVER blocks or drops a message.
54
+ */
55
+ export declare const CHANNEL_ROSTER_TTL_MS: number;
56
+ export declare class ChannelPendingError extends Error {
57
+ readonly channelId: string;
58
+ constructor(channelId: string);
59
+ }
60
+ export declare class RateLimitedError extends Error {
61
+ readonly channelId: string;
62
+ readonly retryAfterMs: number;
63
+ constructor(channelId: string, retryAfterMs: number);
64
+ }
65
+ export declare const BRIDGE_VERSION = "0.0.0";
66
+ export declare class BridgeAgent {
67
+ private readonly cfg;
68
+ private readonly transport;
69
+ private readonly provider;
70
+ private readonly backend;
71
+ private readonly custody;
72
+ private readonly fetchImpl;
73
+ private readonly joinState;
74
+ /**
75
+ * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
76
+ * by the multi-agent room @mention filter at ingest. Populated/refreshed from a
77
+ * SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
78
+ * be resolved (network error, or not present in the listing) stays UNKNOWN → the
79
+ * filter fails OPEN.
80
+ *
81
+ * `is_dm` is immutable for a given channel_id (no misclassification risk), so the
82
+ * TTL exists only to keep `name` fresh after a channel rename — an entry older
83
+ * than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
84
+ */
85
+ private readonly channelMeta;
86
+ /**
87
+ * Phase 2 (sender attribution): per-channel roster cache
88
+ * (channel_id → identity_id → { name, is_agent }) resolved from the GET
89
+ * members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
90
+ * unresolved channel (network error, or no entry) leaves sender_name
91
+ * undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
92
+ * per channel on a miss.
93
+ */
94
+ private readonly channelRoster;
95
+ private readonly buffer;
96
+ private readonly maxBuffer;
97
+ /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
98
+ private droppedMessages;
99
+ /**
100
+ * Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
101
+ * ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
102
+ * most room traffic is distinguishable from an idle/wedged one.
103
+ */
104
+ private filteredRoomMessages;
105
+ /** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
106
+ private rateLimitedSends;
107
+ /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
108
+ private welcomeRequestsSent;
109
+ /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
110
+ private readonly lastWelcomeRequest;
111
+ /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
112
+ private static readonly REQUEST_WELCOME_COOLDOWN_MS;
113
+ private readonly rateFloor;
114
+ private readonly waiters;
115
+ private pingHandle?;
116
+ private safetyTimer?;
117
+ private readonly listeners;
118
+ private startedAt;
119
+ private readonly store;
120
+ private readonly latency;
121
+ constructor(cfg: BridgeConfig, deps?: BridgeAgentDeps);
122
+ /**
123
+ * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
124
+ *
125
+ * The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
126
+ * decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
127
+ * messages at the FRONT — those re-buffered messages were consumed earlier, so
128
+ * they are by definition OLDER than whatever already sits in the buffer. In
129
+ * every push path the newest messages therefore live at the back. To honour
130
+ * "the most-recent survive" we always shed from the FRONT, then record the
131
+ * shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
132
+ */
133
+ private capBuffer;
134
+ start(): Promise<void>;
135
+ private reconcileJoin;
136
+ /**
137
+ * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
138
+ * trip (incrementing the observable counter). `kind` namespaces the bucket so
139
+ * room and a2a traffic on the same channel are tracked separately.
140
+ */
141
+ private enforceRateFloor;
142
+ send(channelId: string, text: string, messageId?: string): Promise<{
143
+ message_id: string;
144
+ status: "sent";
145
+ }>;
146
+ sendA2A(channelId: string, a2a: {
147
+ a2a_version: string;
148
+ kind: "message" | "task" | "artifact";
149
+ object: Record<string, unknown>;
150
+ }, messageId?: string): Promise<{
151
+ message_id: string;
152
+ status: "sent";
153
+ }>;
154
+ listChannels(): Promise<Array<{
155
+ channel_id: string;
156
+ name: string | null;
157
+ is_dm: boolean;
158
+ joined: boolean;
159
+ }>>;
160
+ /** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
161
+ private freshMeta;
162
+ /**
163
+ * One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
164
+ * ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
165
+ * burst of K unknown channels fires a single listChannels() instead of K. Any
166
+ * listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
167
+ * blocks on the network.
168
+ */
169
+ private refreshChannelMeta;
170
+ /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
171
+ private freshRoster;
172
+ /**
173
+ * Resolve one channel's roster (members + display names + is-agent) from the
174
+ * GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
175
+ * cycle per channel on a cache miss. Fail-soft: any error leaves the cache
176
+ * as-is so the affected senders stay unresolved (sender_name undefined) —
177
+ * attribution must NEVER block or drop a message.
178
+ */
179
+ private refreshChannelRoster;
180
+ reportStatus(channelId: string, state: "idle" | "working" | "stalled" | "error", detail?: string): Promise<void>;
181
+ protected startReceivePump(): void;
182
+ private ingestOnce;
183
+ drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
184
+ /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
185
+ subscribe(listener: () => void): () => void;
186
+ /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
187
+ takeBuffered(channelId?: string): BridgeMessage[];
188
+ /**
189
+ * Put messages back at the front (SSE/disconnect write-failure recovery).
190
+ *
191
+ * The re-buffered messages are older un-acked deliveries, so they go at the
192
+ * FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
193
+ * the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
194
+ * sheds from the front, preserving the newest maxBuffer messages across the
195
+ * combined [requeued-older..., existing-newer...] buffer.
196
+ */
197
+ requeueFront(msgs: BridgeMessage[]): void;
198
+ /**
199
+ * Poke subscribers + waiters to drain the buffer now. Used after a
200
+ * disconnect re-buffers un-acked messages so a still-connected consumer
201
+ * (e.g. a replacement WS) redelivers them without waiting for new inbound.
202
+ * Mirrors the notify tail of ingestOnce.
203
+ */
204
+ wakeListeners(): void;
205
+ health(): {
206
+ status: "ok";
207
+ identity_id: string;
208
+ joined_channels: number;
209
+ buffered: number;
210
+ /** Cumulative inbound messages shed by the buffer cap (0 unless a flood hit the bound). */
211
+ dropped: number;
212
+ /** Cumulative ROOM messages dropped by the @mention filter (correct filtering ≠ idle). */
213
+ filtered_room: number;
214
+ /** Cumulative sends shed by the per-channel rate-limit floor (correct backpressure ≠ idle). */
215
+ rate_limited: number;
216
+ /** Cumulative inbound rows acked-and-dropped because they were provably past-epoch undecryptable (forward-secrecy secret gone). */
217
+ permanently_skipped: number;
218
+ uptime_s: number;
219
+ version: string;
220
+ };
221
+ /**
222
+ * Loopback observability snapshot served at GET /v1/metrics.
223
+ *
224
+ * `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
225
+ * locally and monotonically — send.encrypt / send.publish / send.total (the
226
+ * sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
227
+ * recipient half). Excludes the agent's LLM time entirely.
228
+ *
229
+ * `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
230
+ * provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
231
+ * channel appears only once it has had a decrypt error; `stuck:true` means
232
+ * repeated failures with no intervening successful decrypt.
233
+ *
234
+ * `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
235
+ * bounded-buffer cap. A daemon silently dropping messages under backpressure is
236
+ * exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
237
+ * on health()) rather than swallowed.
238
+ *
239
+ * `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
240
+ * dropped by the @mention filter. A silently-filtered room message produces no
241
+ * other signal, so an agent correctly filtering 95% of room traffic would look
242
+ * identical to an idle/wedged one without this counter.
243
+ *
244
+ * `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
245
+ * acked-and-dropped because they were provably past-epoch undecryptable
246
+ * (forward-secrecy secret gone), so a self-healed poison row is observable
247
+ * here instead of silently dropped.
248
+ */
249
+ metrics(): {
250
+ stages: ReturnType<LatencyRecorder["snapshot"]>;
251
+ errors: ReturnType<MlsGroupKeyProvider["errorStats"]>;
252
+ dropped: number;
253
+ filteredRoomMessages: number;
254
+ rateLimitedSends: number;
255
+ permanentlySkipped: number;
256
+ welcomeRequestsSent: number;
257
+ };
258
+ close(): Promise<void>;
259
+ }