@canonmsg/agent-sdk 3.1.1 → 3.2.3

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.
@@ -56,9 +56,8 @@ export declare class CanonAgent {
56
56
  private cachedConversationIds;
57
57
  private running;
58
58
  private runtimeHeartbeatTimer;
59
- private runtimeControlPollTimer;
60
- private readonly lastSeenSignal;
61
- private readonly primitiveRequestDedupe;
59
+ private rtdbHandle;
60
+ private controlPoller;
62
61
  private readonly activeAbortControllers;
63
62
  private readonly activeTurns;
64
63
  private readonly conversationMemberIds;
@@ -137,20 +136,33 @@ export declare class CanonAgent {
137
136
  private rememberConversationMembers;
138
137
  private handleConversationUpdated;
139
138
  private buildGroupContext;
139
+ /**
140
+ * Shared `/control` channel poller, configured to the agent-sdk host
141
+ * profile pinned by core's characterization tests: flat 2s single-flight
142
+ * cadence, parallel conversations, signal + primitive keys (no session),
143
+ * eager signal baseline, and TTL'd primitive dedupe released on successful
144
+ * consume. The poller talks only to the scoped RTDB handle captured in
145
+ * start() — never the module-global default client.
146
+ */
147
+ private ensureControlPoller;
140
148
  private baselineRuntimeControlSignals;
141
149
  private startRuntimeControlPolling;
142
150
  private stopRuntimeControlPolling;
143
- private pollRuntimeControls;
144
- private handleRuntimePrimitiveRequests;
145
- private clearRuntimePrimitiveRequest;
146
- private prunePrimitiveRequestDedupe;
147
- private handleRuntimeSignal;
151
+ private handleRuntimePrimitiveEvent;
152
+ private handleRuntimeSignalEvent;
148
153
  private firstActiveTurn;
149
154
  private publishAcceptedRuntimeSignal;
150
155
  private abortActiveTurns;
151
156
  private resolveBatchDeliveryIntent;
152
157
  private markQueuedMessagesAccepted;
153
158
  private notifyMessageInterrupt;
159
+ /**
160
+ * Builds a runtime-state publisher bound to this agent's scoped RTDB
161
+ * handle (captured in start()). Threading the handle keeps every
162
+ * publish on this agent's own credentials — without it the publisher
163
+ * would fall back to core's deprecated module-global RTDB client,
164
+ * where the last-started agent's token wins in multi-agent processes.
165
+ */
154
166
  private createRuntimeStatePublisher;
155
167
  private requireRuntimeStatePublisher;
156
168
  private handleMessages;
@@ -1,4 +1,4 @@
1
- import { ApprovalManager, CanonClient, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, } from '@canonmsg/core';
1
+ import { ApprovalManager, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, } from '@canonmsg/core';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
@@ -6,6 +6,7 @@ import { buildRuntimeCardCreateArgs } from './runtime-card.js';
6
6
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
7
7
  import { SessionManager } from './session-manager.js';
8
8
  const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
9
+ const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
9
10
  const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
10
11
  const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
11
12
  const DEFAULT_RUNTIME_INPUT_TIMEOUT_MS = 5 * 60_000;
@@ -256,9 +257,8 @@ export class CanonAgent {
256
257
  cachedConversationIds = [];
257
258
  running = false;
258
259
  runtimeHeartbeatTimer = null;
259
- runtimeControlPollTimer = null;
260
- lastSeenSignal = new Map();
261
- primitiveRequestDedupe = new Map();
260
+ rtdbHandle = null;
261
+ controlPoller = null;
262
262
  activeAbortControllers = new Map();
263
263
  activeTurns = new Map();
264
264
  conversationMemberIds = new Map();
@@ -518,7 +518,11 @@ export class CanonAgent {
518
518
  if (this.running)
519
519
  return;
520
520
  this.running = true;
521
- initRTDBAuth(this.apiClient);
521
+ // The single scoped RTDB client for this agent. Every RTDB consumer in
522
+ // the SDK (control poller, runtime-state publishers) threads this handle;
523
+ // the SDK never reads through core's deprecated module-global default,
524
+ // so multiple CanonAgents in one process cannot clobber each other.
525
+ this.rtdbHandle = initRTDBAuth(this.apiClient);
522
526
  // 1. Authenticate
523
527
  const { agentId } = await this.authManager.authenticate();
524
528
  this.agentId = agentId;
@@ -847,143 +851,106 @@ export class CanonAgent {
847
851
  membershipChange: input.membershipChange,
848
852
  });
849
853
  }
854
+ /**
855
+ * Shared `/control` channel poller, configured to the agent-sdk host
856
+ * profile pinned by core's characterization tests: flat 2s single-flight
857
+ * cadence, parallel conversations, signal + primitive keys (no session),
858
+ * eager signal baseline, and TTL'd primitive dedupe released on successful
859
+ * consume. The poller talks only to the scoped RTDB handle captured in
860
+ * start() — never the module-global default client.
861
+ */
862
+ ensureControlPoller() {
863
+ if (this.controlPoller)
864
+ return this.controlPoller;
865
+ if (!this.rtdbHandle)
866
+ return null;
867
+ this.controlPoller = new ControlChannelPoller({
868
+ rtdb: this.rtdbHandle,
869
+ agentId: () => this.agentId,
870
+ conversationIds: () => this.cachedConversationIds,
871
+ cadence: { kind: 'fixed', intervalMs: RUNTIME_CONTROL_POLL_INTERVAL_MS },
872
+ pollOnStart: false,
873
+ conversationConcurrency: 'parallel',
874
+ handlers: {
875
+ signal: {
876
+ handle: (event) => this.handleRuntimeSignalEvent(event),
877
+ consumeOnError: true,
878
+ },
879
+ primitive: {
880
+ handle: (event) => this.handleRuntimePrimitiveEvent(event),
881
+ consumeOnError: true,
882
+ ordering: 'sequential',
883
+ dedupeTtlMs: RUNTIME_PRIMITIVE_DEDUPE_TTL_MS,
884
+ dedupeMaxEntries: RUNTIME_PRIMITIVE_DEDUPE_MAX,
885
+ releaseDedupeOnConsume: true,
886
+ },
887
+ },
888
+ onError: (error) => {
889
+ // Read/consume failures stay silent (the legacy loop swallowed them);
890
+ // handler-scope errors are host dispatch bugs worth surfacing.
891
+ if (error.scope === 'handler') {
892
+ console.error(`[canon-sdk] Runtime control ${error.key ?? 'poll'} dispatch failed for ${error.conversationId}:`, error.error);
893
+ }
894
+ },
895
+ });
896
+ return this.controlPoller;
897
+ }
850
898
  async baselineRuntimeControlSignals(conversationIds) {
851
- if (!this.agentId || !this.hasRuntimeSignalSupport())
899
+ if (!this.hasRuntimeSignalSupport())
852
900
  return;
853
- await Promise.all(conversationIds.map(async (conversationId) => {
854
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
855
- if (!raw || typeof raw !== 'object')
856
- return;
857
- const timestamp = Number(raw.updatedAt ?? 0);
858
- if (timestamp > 0) {
859
- this.lastSeenSignal.set(conversationId, timestamp);
860
- }
861
- }));
901
+ await this.ensureControlPoller()?.baseline(conversationIds);
862
902
  }
863
903
  startRuntimeControlPolling() {
864
- if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeControlSupport())
904
+ if (!this.hasRuntimeControlSupport())
865
905
  return;
866
- this.runtimeControlPollTimer = setInterval(() => {
867
- void this.pollRuntimeControls();
868
- }, 2_000);
869
- this.runtimeControlPollTimer.unref?.();
906
+ this.ensureControlPoller()?.start();
870
907
  }
871
908
  stopRuntimeControlPolling() {
872
- if (!this.runtimeControlPollTimer)
873
- return;
874
- clearInterval(this.runtimeControlPollTimer);
875
- this.runtimeControlPollTimer = null;
909
+ this.controlPoller?.stop();
876
910
  }
877
- async pollRuntimeControls() {
878
- if (!this.agentId || !this.hasRuntimeControlSupport())
911
+ async handleRuntimePrimitiveEvent(event) {
912
+ // Requests only belong to this runtime once primitive handlers exist —
913
+ // defer otherwise: the request stays in RTDB AND unseen, so a handler
914
+ // registered via onPrimitive() after start still receives it.
915
+ if (!this.hasRuntimePrimitiveSupport())
916
+ return { defer: true };
917
+ const { conversationId, requestId, value } = event;
918
+ const primitive = value.id;
919
+ // Unknown primitives and unhandled ids fall through so the poller
920
+ // consumes the request without dispatching, matching the legacy loop.
921
+ if (!isRuntimePrimitiveId(primitive))
879
922
  return;
880
- await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
881
- if (this.hasRuntimeSignalSupport()) {
882
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
883
- if (raw && typeof raw === 'object') {
884
- await this.handleRuntimeSignal(conversationId, raw);
885
- }
886
- }
887
- if (this.hasRuntimePrimitiveSupport()) {
888
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/primitive`)).catch(() => null);
889
- if (raw && typeof raw === 'object') {
890
- await this.handleRuntimePrimitiveRequests(conversationId, raw);
891
- }
892
- }
893
- }));
894
- }
895
- async handleRuntimePrimitiveRequests(conversationId, raw) {
896
- if (!this.agentId)
923
+ const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
924
+ if (!handler)
897
925
  return;
898
- this.prunePrimitiveRequestDedupe();
899
- const requests = Object.entries(raw)
900
- .map(([requestId, value]) => ({ requestId, value }))
901
- .filter((entry) => (Boolean(entry.requestId)
902
- && Boolean(entry.value)
903
- && typeof entry.value === 'object'
904
- && !Array.isArray(entry.value)))
905
- .sort((a, b) => Number(a.value.updatedAt ?? 0) - Number(b.value.updatedAt ?? 0));
906
- for (const { requestId, value } of requests) {
907
- const requestKey = `${conversationId}:${requestId}`;
908
- if (this.primitiveRequestDedupe.has(requestKey))
909
- continue;
910
- this.primitiveRequestDedupe.set(requestKey, Date.now());
911
- let cleared = false;
912
- try {
913
- const primitive = value.id;
914
- if (!isRuntimePrimitiveId(primitive)) {
915
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
916
- continue;
917
- }
918
- const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
919
- if (!handler) {
920
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
921
- continue;
922
- }
923
- const args = normalizePrimitiveArgs(value.args);
924
- await Promise.resolve(handler({
925
- conversationId,
926
- primitive,
927
- args,
928
- requestId,
929
- updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
930
- rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
931
- alias: typeof value.alias === 'string' ? value.alias : undefined,
932
- })).catch((error) => {
933
- console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
934
- });
935
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
936
- }
937
- finally {
938
- if (cleared) {
939
- this.primitiveRequestDedupe.delete(requestKey);
940
- }
941
- }
942
- }
943
- }
944
- async clearRuntimePrimitiveRequest(conversationId, requestId) {
945
- if (!this.agentId)
946
- return false;
947
- try {
948
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/primitive/${requestId}`, null));
949
- return true;
950
- }
951
- catch {
952
- return false;
953
- }
954
- }
955
- prunePrimitiveRequestDedupe(now = Date.now()) {
956
- for (const [key, timestamp] of this.primitiveRequestDedupe) {
957
- if (now - timestamp >= RUNTIME_PRIMITIVE_DEDUPE_TTL_MS) {
958
- this.primitiveRequestDedupe.delete(key);
959
- }
960
- }
961
- while (this.primitiveRequestDedupe.size > RUNTIME_PRIMITIVE_DEDUPE_MAX) {
962
- const oldestKey = this.primitiveRequestDedupe.keys().next().value;
963
- if (!oldestKey)
964
- break;
965
- this.primitiveRequestDedupe.delete(oldestKey);
966
- }
926
+ await Promise.resolve(handler({
927
+ conversationId,
928
+ primitive,
929
+ args: normalizePrimitiveArgs(value.args),
930
+ requestId,
931
+ updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
932
+ rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
933
+ alias: typeof value.alias === 'string' ? value.alias : undefined,
934
+ })).catch((error) => {
935
+ console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
936
+ });
967
937
  }
968
- async handleRuntimeSignal(conversationId, raw) {
969
- if (!this.agentId)
970
- return;
971
- const signal = raw.type;
972
- if (signal !== 'interrupt' && signal !== 'stop_and_drop' && signal !== 'new_session')
973
- return;
974
- const timestamp = Number(raw.updatedAt ?? 0);
975
- if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
976
- return;
977
- this.lastSeenSignal.set(conversationId, timestamp);
938
+ async handleRuntimeSignalEvent(event) {
939
+ // Signals only belong to this runtime once signal handlers exist — defer
940
+ // otherwise: the signal stays in RTDB AND unseen, so a handler registered
941
+ // via on('interrupt' | ...) after start still receives it.
942
+ if (!this.hasRuntimeSignalSupport())
943
+ return { defer: true };
944
+ const { conversationId, type: signal, updatedAt } = event;
978
945
  const handler = signal === 'new_session'
979
946
  ? this.newSessionHandler
980
947
  : signal === 'stop_and_drop'
981
948
  ? this.stopAndDropHandler
982
949
  : this.interruptHandler;
983
- if (!handler) {
984
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
950
+ // No handler for this specific signal: fall through so the poller
951
+ // consumes the node without dispatching, matching the legacy loop.
952
+ if (!handler)
985
953
  return;
986
- }
987
954
  const activeTurn = this.firstActiveTurn(conversationId);
988
955
  const abortSignal = this.abortActiveTurns(conversationId);
989
956
  const droppedMessages = signal === 'new_session'
@@ -1001,16 +968,15 @@ export class CanonAgent {
1001
968
  hasActiveTurn: Boolean(abortSignal),
1002
969
  droppedCount: droppedMessages.length,
1003
970
  });
1004
- await Promise.resolve(handler?.({
971
+ await Promise.resolve(handler({
1005
972
  conversationId,
1006
- signal: signal,
1007
- updatedAt: timestamp || undefined,
973
+ signal,
974
+ updatedAt: updatedAt || undefined,
1008
975
  abortSignal,
1009
976
  droppedMessageIds,
1010
977
  })).catch((error) => {
1011
978
  console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
1012
979
  });
1013
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
1014
980
  }
1015
981
  firstActiveTurn(conversationId) {
1016
982
  const turns = this.activeTurns.get(conversationId);
@@ -1080,6 +1046,13 @@ export class CanonAgent {
1080
1046
  console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
1081
1047
  });
1082
1048
  }
1049
+ /**
1050
+ * Builds a runtime-state publisher bound to this agent's scoped RTDB
1051
+ * handle (captured in start()). Threading the handle keeps every
1052
+ * publish on this agent's own credentials — without it the publisher
1053
+ * would fall back to core's deprecated module-global RTDB client,
1054
+ * where the last-started agent's token wins in multi-agent processes.
1055
+ */
1083
1056
  createRuntimeStatePublisher() {
1084
1057
  if (!this.agentId)
1085
1058
  return null;
@@ -1087,6 +1060,7 @@ export class CanonAgent {
1087
1060
  agentId: this.agentId,
1088
1061
  clientType: this.options.clientType ?? 'generic',
1089
1062
  hostMode: this.options.runtimeControlSurface === 'host',
1063
+ ...(this.rtdbHandle ? { rtdb: this.rtdbHandle } : {}),
1090
1064
  });
1091
1065
  }
1092
1066
  requireRuntimeStatePublisher() {
@@ -4,12 +4,21 @@ import { Debouncer } from './debouncer.js';
4
4
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
5
5
  * - Debouncer integration (message batching)
6
6
  * - Agent context callback
7
+ * - REST catch-up when the SSE replay window expires (replay.expired)
7
8
  */
8
9
  export declare class RealtimeManager {
9
10
  private debouncer;
10
11
  private agentId;
12
+ private apiClient;
11
13
  private stream;
12
14
  private running;
15
+ /** Recent inbound message IDs (`conversationId:messageId`) for cross-flush dedupe. */
16
+ private readonly recentInboundMessageIds;
17
+ /** Latest handled inbound message timestamp per conversation. */
18
+ private readonly lastInboundMessageAtByConversation;
19
+ /** Lower bound for catch-up in conversations with no in-memory cursor. */
20
+ private readonly replaySyncStartedAt;
21
+ private replayCatchupInFlight;
13
22
  private lastSseErrorKey;
14
23
  private lastSseErrorAt;
15
24
  private suppressedSseErrorCount;
@@ -24,6 +33,23 @@ export declare class RealtimeManager {
24
33
  private onConnected;
25
34
  private onDisconnected;
26
35
  constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl?: string, apiClient?: CanonClient);
36
+ private hasSeenInboundMessage;
37
+ private recordSeenInboundMessage;
38
+ private pruneRecentInboundMessageIds;
39
+ /**
40
+ * REST catch-up after `replay.expired`: the stream service evicted our
41
+ * cursor, so messages in the gap were silently dropped. Fetch the newest
42
+ * page per conversation and feed unseen inbound messages through the normal
43
+ * debouncer path (same entry point as SSE delivery, same id dedupe).
44
+ *
45
+ * Lower bound per conversation: the in-memory last-seen inbound timestamp,
46
+ * falling back to this manager's construction time for conversations with
47
+ * no prior inbound traffic — anything older predates this process and may
48
+ * already have been handled by a previous run. For the same reason the
49
+ * catch-up is NOT wired on initial connect: with no durable cursor, a fresh
50
+ * process would re-fire turns for messages an earlier run already answered.
51
+ */
52
+ private runReplayCatchup;
27
53
  private logSseError;
28
54
  setOnAgentContext(cb: (ctx: AgentContext) => void): void;
29
55
  setContactRequestHandlers(handlers: {
package/dist/realtime.js CHANGED
@@ -1,14 +1,39 @@
1
1
  import { CanonStream, } from '@canonmsg/core';
2
+ import { shouldDispatchInboundMessage } from './turn-filter.js';
3
+ const RECENT_INBOUND_TTL_MS = 30 * 60 * 1000;
4
+ const MAX_RECENT_INBOUND_MESSAGE_IDS = 5000;
5
+ /**
6
+ * Newest-page bound for the replay-expiry REST catch-up. The SDK has no
7
+ * durable per-conversation cursor (everything here is in-memory), so the
8
+ * catch-up only inspects the newest page per conversation and relies on the
9
+ * id-based dedupe below for anything that overlaps live SSE delivery.
10
+ */
11
+ const REPLAY_CATCHUP_PAGE_LIMIT = 50;
12
+ function messageCreatedAtMs(createdAt) {
13
+ if (!createdAt)
14
+ return 0;
15
+ const parsed = new Date(createdAt).getTime();
16
+ return Number.isFinite(parsed) ? parsed : 0;
17
+ }
2
18
  /**
3
19
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
4
20
  * - Debouncer integration (message batching)
5
21
  * - Agent context callback
22
+ * - REST catch-up when the SSE replay window expires (replay.expired)
6
23
  */
7
24
  export class RealtimeManager {
8
25
  debouncer;
9
26
  agentId;
27
+ apiClient;
10
28
  stream;
11
29
  running = false;
30
+ /** Recent inbound message IDs (`conversationId:messageId`) for cross-flush dedupe. */
31
+ recentInboundMessageIds = new Map();
32
+ /** Latest handled inbound message timestamp per conversation. */
33
+ lastInboundMessageAtByConversation = new Map();
34
+ /** Lower bound for catch-up in conversations with no in-memory cursor. */
35
+ replaySyncStartedAt = Date.now();
36
+ replayCatchupInFlight = null;
12
37
  lastSseErrorKey = null;
13
38
  lastSseErrorAt = 0;
14
39
  suppressedSseErrorCount = 0;
@@ -25,13 +50,19 @@ export class RealtimeManager {
25
50
  constructor(apiKey, debouncer, agentId, streamUrl, apiClient) {
26
51
  this.debouncer = debouncer;
27
52
  this.agentId = agentId;
28
- void apiClient;
53
+ this.apiClient = apiClient ?? null;
29
54
  this.stream = new CanonStream({
30
55
  apiKey,
31
56
  agentId,
32
57
  streamUrl,
33
58
  handler: {
34
59
  onMessage: (payload) => {
60
+ // Cross-flush id dedupe: replay overlap or a concurrent REST
61
+ // catch-up must never double-fire a turn for the same message.
62
+ if (this.hasSeenInboundMessage(payload.conversationId, payload.message.id)) {
63
+ return;
64
+ }
65
+ this.recordSeenInboundMessage(payload.conversationId, payload.message.id, messageCreatedAtMs(payload.message.createdAt));
35
66
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
36
67
  console.error(`[canon-sdk] Ignoring server-dispatched observe-only message in ${payload.conversationId}: ${payload.turnDispatch.reason}`);
37
68
  return;
@@ -96,12 +127,108 @@ export class RealtimeManager {
96
127
  onDisconnected: () => {
97
128
  this.onDisconnected?.();
98
129
  },
130
+ onReplayExpired: (payload) => {
131
+ console.error(`[canon-sdk] SSE replay window expired${payload.lastAvailableId ? ` (oldest available event: ${payload.lastAvailableId})` : ''} — catching up over REST`);
132
+ this.replayCatchupInFlight ??= this.runReplayCatchup().finally(() => {
133
+ this.replayCatchupInFlight = null;
134
+ });
135
+ },
99
136
  onError: (err) => {
100
137
  this.logSseError(err);
101
138
  },
102
139
  },
103
140
  });
104
141
  }
142
+ hasSeenInboundMessage(conversationId, messageId) {
143
+ return this.recentInboundMessageIds.has(`${conversationId}:${messageId}`);
144
+ }
145
+ recordSeenInboundMessage(conversationId, messageId, createdAtMs) {
146
+ const now = Date.now();
147
+ this.recentInboundMessageIds.set(`${conversationId}:${messageId}`, now);
148
+ const effectiveTimestamp = createdAtMs > 0 ? createdAtMs : now;
149
+ const previous = this.lastInboundMessageAtByConversation.get(conversationId) ?? 0;
150
+ if (effectiveTimestamp > previous) {
151
+ this.lastInboundMessageAtByConversation.set(conversationId, effectiveTimestamp);
152
+ }
153
+ this.pruneRecentInboundMessageIds(now);
154
+ }
155
+ pruneRecentInboundMessageIds(now = Date.now()) {
156
+ const cutoff = now - RECENT_INBOUND_TTL_MS;
157
+ for (const [key, seenAt] of this.recentInboundMessageIds) {
158
+ if (seenAt < cutoff) {
159
+ this.recentInboundMessageIds.delete(key);
160
+ }
161
+ }
162
+ while (this.recentInboundMessageIds.size > MAX_RECENT_INBOUND_MESSAGE_IDS) {
163
+ const oldestKey = this.recentInboundMessageIds.keys().next().value;
164
+ if (!oldestKey)
165
+ break;
166
+ this.recentInboundMessageIds.delete(oldestKey);
167
+ }
168
+ }
169
+ /**
170
+ * REST catch-up after `replay.expired`: the stream service evicted our
171
+ * cursor, so messages in the gap were silently dropped. Fetch the newest
172
+ * page per conversation and feed unseen inbound messages through the normal
173
+ * debouncer path (same entry point as SSE delivery, same id dedupe).
174
+ *
175
+ * Lower bound per conversation: the in-memory last-seen inbound timestamp,
176
+ * falling back to this manager's construction time for conversations with
177
+ * no prior inbound traffic — anything older predates this process and may
178
+ * already have been handled by a previous run. For the same reason the
179
+ * catch-up is NOT wired on initial connect: with no durable cursor, a fresh
180
+ * process would re-fire turns for messages an earlier run already answered.
181
+ */
182
+ async runReplayCatchup() {
183
+ const apiClient = this.apiClient;
184
+ if (!apiClient) {
185
+ console.error('[canon-sdk] Replay catch-up skipped — no API client available');
186
+ return;
187
+ }
188
+ try {
189
+ const conversations = await apiClient.getConversations();
190
+ let recovered = 0;
191
+ await Promise.all(conversations.map(async (conversation) => {
192
+ try {
193
+ const page = await apiClient.getMessagesPage(conversation.id, REPLAY_CATCHUP_PAGE_LIMIT);
194
+ const lowerBoundMs = this.lastInboundMessageAtByConversation.get(conversation.id)
195
+ ?? this.replaySyncStartedAt;
196
+ const candidates = [...(page.messages ?? [])]
197
+ .filter((message) => !message.deleted)
198
+ .sort((a, b) => messageCreatedAtMs(a.createdAt) - messageCreatedAtMs(b.createdAt));
199
+ for (const message of candidates) {
200
+ if (!this.running)
201
+ return;
202
+ if (message.senderId === this.agentId)
203
+ continue;
204
+ const createdAtMs = messageCreatedAtMs(message.createdAt);
205
+ if (!createdAtMs || createdAtMs <= lowerBoundMs)
206
+ continue;
207
+ if (this.hasSeenInboundMessage(conversation.id, message.id))
208
+ continue;
209
+ this.recordSeenInboundMessage(conversation.id, message.id, createdAtMs);
210
+ const dispatch = await shouldDispatchInboundMessage(conversation.id, this.agentId, message, {
211
+ conversationType: conversation.type,
212
+ behavior: page.behavior ?? null,
213
+ });
214
+ if (!dispatch)
215
+ continue;
216
+ this.debouncer.add(conversation.id, message, null);
217
+ recovered += 1;
218
+ }
219
+ }
220
+ catch (err) {
221
+ console.error(`[canon-sdk] Replay catch-up failed for ${conversation.id}:`, err instanceof Error ? err.message : err);
222
+ }
223
+ }));
224
+ if (recovered > 0) {
225
+ console.error(`[canon-sdk] Replay catch-up recovered ${recovered} missed message(s)`);
226
+ }
227
+ }
228
+ catch (err) {
229
+ console.error('[canon-sdk] Replay catch-up failed:', err instanceof Error ? err.message : err);
230
+ }
231
+ }
105
232
  logSseError(err) {
106
233
  const code = err.code;
107
234
  const key = `${typeof code === 'string' ? code : 'generic'}:${err.message}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "3.1.1",
3
+ "version": "3.2.3",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^2.1.1"
31
+ "@canonmsg/core": "^2.6.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"