@canonmsg/agent-sdk 3.1.1 → 3.2.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.
- package/dist/canon-agent.d.ts +20 -8
- package/dist/canon-agent.js +101 -129
- package/dist/realtime.d.ts +26 -0
- package/dist/realtime.js +128 -1
- package/package.json +2 -2
package/dist/canon-agent.d.ts
CHANGED
|
@@ -56,9 +56,8 @@ export declare class CanonAgent {
|
|
|
56
56
|
private cachedConversationIds;
|
|
57
57
|
private running;
|
|
58
58
|
private runtimeHeartbeatTimer;
|
|
59
|
-
private
|
|
60
|
-
private
|
|
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
|
|
144
|
-
private
|
|
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;
|
package/dist/canon-agent.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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,104 @@ 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.
|
|
899
|
+
if (!this.hasRuntimeSignalSupport())
|
|
852
900
|
return;
|
|
853
|
-
await
|
|
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.
|
|
904
|
+
if (!this.hasRuntimeControlSupport())
|
|
865
905
|
return;
|
|
866
|
-
this.
|
|
867
|
-
void this.pollRuntimeControls();
|
|
868
|
-
}, 2_000);
|
|
869
|
-
this.runtimeControlPollTimer.unref?.();
|
|
906
|
+
this.ensureControlPoller()?.start();
|
|
870
907
|
}
|
|
871
908
|
stopRuntimeControlPolling() {
|
|
872
|
-
|
|
873
|
-
return;
|
|
874
|
-
clearInterval(this.runtimeControlPollTimer);
|
|
875
|
-
this.runtimeControlPollTimer = null;
|
|
909
|
+
this.controlPoller?.stop();
|
|
876
910
|
}
|
|
877
|
-
async
|
|
878
|
-
|
|
911
|
+
async handleRuntimePrimitiveEvent(event) {
|
|
912
|
+
// Requests only belong to this runtime once primitive handlers exist —
|
|
913
|
+
// leave them untouched otherwise (the legacy loop never read the key).
|
|
914
|
+
if (!this.hasRuntimePrimitiveSupport())
|
|
915
|
+
return { consume: false };
|
|
916
|
+
const { conversationId, requestId, value } = event;
|
|
917
|
+
const primitive = value.id;
|
|
918
|
+
// Unknown primitives and unhandled ids fall through so the poller
|
|
919
|
+
// consumes the request without dispatching, matching the legacy loop.
|
|
920
|
+
if (!isRuntimePrimitiveId(primitive))
|
|
879
921
|
return;
|
|
880
|
-
|
|
881
|
-
|
|
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)
|
|
922
|
+
const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
|
|
923
|
+
if (!handler)
|
|
897
924
|
return;
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
}
|
|
925
|
+
await Promise.resolve(handler({
|
|
926
|
+
conversationId,
|
|
927
|
+
primitive,
|
|
928
|
+
args: normalizePrimitiveArgs(value.args),
|
|
929
|
+
requestId,
|
|
930
|
+
updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
|
|
931
|
+
rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
|
|
932
|
+
alias: typeof value.alias === 'string' ? value.alias : undefined,
|
|
933
|
+
})).catch((error) => {
|
|
934
|
+
console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
|
|
935
|
+
});
|
|
967
936
|
}
|
|
968
|
-
async
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const timestamp = Number(raw.updatedAt ?? 0);
|
|
975
|
-
if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
|
|
976
|
-
return;
|
|
977
|
-
this.lastSeenSignal.set(conversationId, timestamp);
|
|
937
|
+
async handleRuntimeSignalEvent(event) {
|
|
938
|
+
// Signals only belong to this runtime once signal handlers exist — leave
|
|
939
|
+
// them untouched otherwise (the legacy loop never read the key).
|
|
940
|
+
if (!this.hasRuntimeSignalSupport())
|
|
941
|
+
return { consume: false };
|
|
942
|
+
const { conversationId, type: signal, updatedAt } = event;
|
|
978
943
|
const handler = signal === 'new_session'
|
|
979
944
|
? this.newSessionHandler
|
|
980
945
|
: signal === 'stop_and_drop'
|
|
981
946
|
? this.stopAndDropHandler
|
|
982
947
|
: this.interruptHandler;
|
|
983
|
-
|
|
984
|
-
|
|
948
|
+
// No handler for this specific signal: fall through so the poller
|
|
949
|
+
// consumes the node without dispatching, matching the legacy loop.
|
|
950
|
+
if (!handler)
|
|
985
951
|
return;
|
|
986
|
-
}
|
|
987
952
|
const activeTurn = this.firstActiveTurn(conversationId);
|
|
988
953
|
const abortSignal = this.abortActiveTurns(conversationId);
|
|
989
954
|
const droppedMessages = signal === 'new_session'
|
|
@@ -1001,16 +966,15 @@ export class CanonAgent {
|
|
|
1001
966
|
hasActiveTurn: Boolean(abortSignal),
|
|
1002
967
|
droppedCount: droppedMessages.length,
|
|
1003
968
|
});
|
|
1004
|
-
await Promise.resolve(handler
|
|
969
|
+
await Promise.resolve(handler({
|
|
1005
970
|
conversationId,
|
|
1006
|
-
signal
|
|
1007
|
-
updatedAt:
|
|
971
|
+
signal,
|
|
972
|
+
updatedAt: updatedAt || undefined,
|
|
1008
973
|
abortSignal,
|
|
1009
974
|
droppedMessageIds,
|
|
1010
975
|
})).catch((error) => {
|
|
1011
976
|
console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
|
|
1012
977
|
});
|
|
1013
|
-
await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
|
|
1014
978
|
}
|
|
1015
979
|
firstActiveTurn(conversationId) {
|
|
1016
980
|
const turns = this.activeTurns.get(conversationId);
|
|
@@ -1080,6 +1044,13 @@ export class CanonAgent {
|
|
|
1080
1044
|
console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
|
|
1081
1045
|
});
|
|
1082
1046
|
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Builds a runtime-state publisher bound to this agent's scoped RTDB
|
|
1049
|
+
* handle (captured in start()). Threading the handle keeps every
|
|
1050
|
+
* publish on this agent's own credentials — without it the publisher
|
|
1051
|
+
* would fall back to core's deprecated module-global RTDB client,
|
|
1052
|
+
* where the last-started agent's token wins in multi-agent processes.
|
|
1053
|
+
*/
|
|
1083
1054
|
createRuntimeStatePublisher() {
|
|
1084
1055
|
if (!this.agentId)
|
|
1085
1056
|
return null;
|
|
@@ -1087,6 +1058,7 @@ export class CanonAgent {
|
|
|
1087
1058
|
agentId: this.agentId,
|
|
1088
1059
|
clientType: this.options.clientType ?? 'generic',
|
|
1089
1060
|
hostMode: this.options.runtimeControlSurface === 'host',
|
|
1061
|
+
...(this.rtdbHandle ? { rtdb: this.rtdbHandle } : {}),
|
|
1090
1062
|
});
|
|
1091
1063
|
}
|
|
1092
1064
|
requireRuntimeStatePublisher() {
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "3.2.2",
|
|
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.
|
|
31
|
+
"@canonmsg/core": "^2.4.1"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|