@canonmsg/agent-sdk 3.1.0 → 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.
@@ -1,4 +1,4 @@
1
- import { type AddMemberResult, type CanonContact, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ContactCardPayload, type ClearRuntimeActivityOptions, type CreateContactRequestResult } from '@canonmsg/core';
1
+ import { type AddMemberResult, type CanonContact, type CanonConversation, type CreateConversationResult, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ContactCardPayload, type ClearRuntimeActivityOptions, type CreateContactRequestResult } from '@canonmsg/core';
2
2
  import type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, MessageUpdatedHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler, RuntimeSignalHandler, RuntimePrimitiveHandler } from './types.js';
3
3
  /**
4
4
  * Contact-graph operations exposed under `agent.contacts`. Wraps the REST
@@ -18,6 +18,11 @@ export interface AgentUsersAPI {
18
18
  block(userId: string): Promise<void>;
19
19
  unblock(userId: string): Promise<void>;
20
20
  }
21
+ export interface AgentConversationsAPI {
22
+ list(options?: {
23
+ targetUserId?: string;
24
+ }): Promise<CanonConversation[]>;
25
+ }
21
26
  export declare class CanonAgent {
22
27
  private options;
23
28
  private apiClient;
@@ -40,6 +45,8 @@ export declare class CanonAgent {
40
45
  readonly contacts: AgentContactsAPI;
41
46
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
42
47
  readonly users: AgentUsersAPI;
48
+ /** Conversation discovery for choosing existing sessions intentionally. */
49
+ readonly conversations: AgentConversationsAPI;
43
50
  private readonly reachOutInFlight;
44
51
  private agentId;
45
52
  private agentContext;
@@ -49,9 +56,8 @@ export declare class CanonAgent {
49
56
  private cachedConversationIds;
50
57
  private running;
51
58
  private runtimeHeartbeatTimer;
52
- private runtimeControlPollTimer;
53
- private readonly lastSeenSignal;
54
- private readonly primitiveRequestDedupe;
59
+ private rtdbHandle;
60
+ private controlPoller;
55
61
  private readonly activeAbortControllers;
56
62
  private readonly activeTurns;
57
63
  private readonly conversationMemberIds;
@@ -85,9 +91,7 @@ export declare class CanonAgent {
85
91
  reachOut(card: ContactCardPayload, options?: ReachOutOptions): Promise<ReachOutResult>;
86
92
  private executeReachOut;
87
93
  start(): Promise<void>;
88
- createConversation(options: CreateConversationOptions): Promise<{
89
- conversationId: string;
90
- }>;
94
+ createConversation(options: CreateConversationOptions): Promise<CreateConversationResult>;
91
95
  updateTopic(conversationId: string, topic: string): Promise<void>;
92
96
  leaveConversation(conversationId: string): Promise<void>;
93
97
  updateConversationName(conversationId: string, name: string): Promise<void>;
@@ -132,20 +136,33 @@ export declare class CanonAgent {
132
136
  private rememberConversationMembers;
133
137
  private handleConversationUpdated;
134
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;
135
148
  private baselineRuntimeControlSignals;
136
149
  private startRuntimeControlPolling;
137
150
  private stopRuntimeControlPolling;
138
- private pollRuntimeControls;
139
- private handleRuntimePrimitiveRequests;
140
- private clearRuntimePrimitiveRequest;
141
- private prunePrimitiveRequestDedupe;
142
- private handleRuntimeSignal;
151
+ private handleRuntimePrimitiveEvent;
152
+ private handleRuntimeSignalEvent;
143
153
  private firstActiveTurn;
144
154
  private publishAcceptedRuntimeSignal;
145
155
  private abortActiveTurns;
146
156
  private resolveBatchDeliveryIntent;
147
157
  private markQueuedMessagesAccepted;
148
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
+ */
149
166
  private createRuntimeStatePublisher;
150
167
  private requireRuntimeStatePublisher;
151
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;
@@ -245,6 +246,8 @@ export class CanonAgent {
245
246
  contacts;
246
247
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
247
248
  users;
249
+ /** Conversation discovery for choosing existing sessions intentionally. */
250
+ conversations;
248
251
  reachOutInFlight = new Map();
249
252
  agentId = null;
250
253
  agentContext = null;
@@ -254,9 +257,8 @@ export class CanonAgent {
254
257
  cachedConversationIds = [];
255
258
  running = false;
256
259
  runtimeHeartbeatTimer = null;
257
- runtimeControlPollTimer = null;
258
- lastSeenSignal = new Map();
259
- primitiveRequestDedupe = new Map();
260
+ rtdbHandle = null;
261
+ controlPoller = null;
260
262
  activeAbortControllers = new Map();
261
263
  activeTurns = new Map();
262
264
  conversationMemberIds = new Map();
@@ -292,6 +294,14 @@ export class CanonAgent {
292
294
  block: (userId) => apiClient.blockUser(userId),
293
295
  unblock: (userId) => apiClient.unblockUser(userId),
294
296
  };
297
+ this.conversations = {
298
+ list: async (options = {}) => {
299
+ const conversations = await apiClient.getConversations();
300
+ if (!options.targetUserId)
301
+ return conversations;
302
+ return conversations.filter((conversation) => conversation.memberIds.includes(options.targetUserId));
303
+ },
304
+ };
295
305
  if (options.sessions?.enabled) {
296
306
  this.sessionManager = new SessionManager({
297
307
  contextLimit: options.sessions.contextLimit,
@@ -457,7 +467,7 @@ export class CanonAgent {
457
467
  const contextualKey = options?.selfContext
458
468
  ? `${options.sourceConversationId ?? ''}\u0000${options.selfContext.type}\u0000${options.selfContext.context}`
459
469
  : '';
460
- const inFlightKey = `${targetUserId}\u0000${options?.text ?? ''}\u0000${options?.requestMessage ?? ''}\u0000${JSON.stringify(options?.sessionConfig ?? null)}\u0000${contextualKey}`;
470
+ const inFlightKey = `${targetUserId}\u0000${options?.text ?? ''}\u0000${options?.requestMessage ?? ''}\u0000${JSON.stringify(options?.sessionConfig ?? null)}\u0000${JSON.stringify(options?.sessionSelection ?? null)}\u0000${contextualKey}`;
461
471
  const inFlight = this.reachOutInFlight.get(inFlightKey);
462
472
  if (inFlight)
463
473
  return inFlight;
@@ -482,6 +492,7 @@ export class CanonAgent {
482
492
  selfContext: options.selfContext,
483
493
  requestMessage: options.requestMessage ?? null,
484
494
  sessionConfig: options.sessionConfig ?? null,
495
+ sessionSelection: options.sessionSelection,
485
496
  });
486
497
  return result.status === 'messaged'
487
498
  ? {
@@ -489,6 +500,9 @@ export class CanonAgent {
489
500
  conversationId: result.conversationId,
490
501
  messageId: result.messageId,
491
502
  selfContextId: result.selfContextId,
503
+ created: result.created,
504
+ reused: result.reused,
505
+ sessionSelection: result.sessionSelection,
492
506
  }
493
507
  : result;
494
508
  }
@@ -497,13 +511,18 @@ export class CanonAgent {
497
511
  text: options?.text ?? null,
498
512
  requestMessage: options?.requestMessage ?? null,
499
513
  sessionConfig: options?.sessionConfig ?? null,
514
+ sessionSelection: options?.sessionSelection,
500
515
  });
501
516
  }
502
517
  async start() {
503
518
  if (this.running)
504
519
  return;
505
520
  this.running = true;
506
- 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);
507
526
  // 1. Authenticate
508
527
  const { agentId } = await this.authManager.authenticate();
509
528
  this.agentId = agentId;
@@ -832,143 +851,104 @@ export class CanonAgent {
832
851
  membershipChange: input.membershipChange,
833
852
  });
834
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
+ }
835
898
  async baselineRuntimeControlSignals(conversationIds) {
836
- if (!this.agentId || !this.hasRuntimeSignalSupport())
899
+ if (!this.hasRuntimeSignalSupport())
837
900
  return;
838
- await Promise.all(conversationIds.map(async (conversationId) => {
839
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
840
- if (!raw || typeof raw !== 'object')
841
- return;
842
- const timestamp = Number(raw.updatedAt ?? 0);
843
- if (timestamp > 0) {
844
- this.lastSeenSignal.set(conversationId, timestamp);
845
- }
846
- }));
901
+ await this.ensureControlPoller()?.baseline(conversationIds);
847
902
  }
848
903
  startRuntimeControlPolling() {
849
- if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeControlSupport())
904
+ if (!this.hasRuntimeControlSupport())
850
905
  return;
851
- this.runtimeControlPollTimer = setInterval(() => {
852
- void this.pollRuntimeControls();
853
- }, 2_000);
854
- this.runtimeControlPollTimer.unref?.();
906
+ this.ensureControlPoller()?.start();
855
907
  }
856
908
  stopRuntimeControlPolling() {
857
- if (!this.runtimeControlPollTimer)
858
- return;
859
- clearInterval(this.runtimeControlPollTimer);
860
- this.runtimeControlPollTimer = null;
909
+ this.controlPoller?.stop();
861
910
  }
862
- async pollRuntimeControls() {
863
- if (!this.agentId || !this.hasRuntimeControlSupport())
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))
864
921
  return;
865
- await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
866
- if (this.hasRuntimeSignalSupport()) {
867
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
868
- if (raw && typeof raw === 'object') {
869
- await this.handleRuntimeSignal(conversationId, raw);
870
- }
871
- }
872
- if (this.hasRuntimePrimitiveSupport()) {
873
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/primitive`)).catch(() => null);
874
- if (raw && typeof raw === 'object') {
875
- await this.handleRuntimePrimitiveRequests(conversationId, raw);
876
- }
877
- }
878
- }));
879
- }
880
- async handleRuntimePrimitiveRequests(conversationId, raw) {
881
- if (!this.agentId)
922
+ const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
923
+ if (!handler)
882
924
  return;
883
- this.prunePrimitiveRequestDedupe();
884
- const requests = Object.entries(raw)
885
- .map(([requestId, value]) => ({ requestId, value }))
886
- .filter((entry) => (Boolean(entry.requestId)
887
- && Boolean(entry.value)
888
- && typeof entry.value === 'object'
889
- && !Array.isArray(entry.value)))
890
- .sort((a, b) => Number(a.value.updatedAt ?? 0) - Number(b.value.updatedAt ?? 0));
891
- for (const { requestId, value } of requests) {
892
- const requestKey = `${conversationId}:${requestId}`;
893
- if (this.primitiveRequestDedupe.has(requestKey))
894
- continue;
895
- this.primitiveRequestDedupe.set(requestKey, Date.now());
896
- let cleared = false;
897
- try {
898
- const primitive = value.id;
899
- if (!isRuntimePrimitiveId(primitive)) {
900
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
901
- continue;
902
- }
903
- const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
904
- if (!handler) {
905
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
906
- continue;
907
- }
908
- const args = normalizePrimitiveArgs(value.args);
909
- await Promise.resolve(handler({
910
- conversationId,
911
- primitive,
912
- args,
913
- requestId,
914
- updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
915
- rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
916
- alias: typeof value.alias === 'string' ? value.alias : undefined,
917
- })).catch((error) => {
918
- console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
919
- });
920
- cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
921
- }
922
- finally {
923
- if (cleared) {
924
- this.primitiveRequestDedupe.delete(requestKey);
925
- }
926
- }
927
- }
928
- }
929
- async clearRuntimePrimitiveRequest(conversationId, requestId) {
930
- if (!this.agentId)
931
- return false;
932
- try {
933
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/primitive/${requestId}`, null));
934
- return true;
935
- }
936
- catch {
937
- return false;
938
- }
939
- }
940
- prunePrimitiveRequestDedupe(now = Date.now()) {
941
- for (const [key, timestamp] of this.primitiveRequestDedupe) {
942
- if (now - timestamp >= RUNTIME_PRIMITIVE_DEDUPE_TTL_MS) {
943
- this.primitiveRequestDedupe.delete(key);
944
- }
945
- }
946
- while (this.primitiveRequestDedupe.size > RUNTIME_PRIMITIVE_DEDUPE_MAX) {
947
- const oldestKey = this.primitiveRequestDedupe.keys().next().value;
948
- if (!oldestKey)
949
- break;
950
- this.primitiveRequestDedupe.delete(oldestKey);
951
- }
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
+ });
952
936
  }
953
- async handleRuntimeSignal(conversationId, raw) {
954
- if (!this.agentId)
955
- return;
956
- const signal = raw.type;
957
- if (signal !== 'interrupt' && signal !== 'stop_and_drop' && signal !== 'new_session')
958
- return;
959
- const timestamp = Number(raw.updatedAt ?? 0);
960
- if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
961
- return;
962
- 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;
963
943
  const handler = signal === 'new_session'
964
944
  ? this.newSessionHandler
965
945
  : signal === 'stop_and_drop'
966
946
  ? this.stopAndDropHandler
967
947
  : this.interruptHandler;
968
- if (!handler) {
969
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
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)
970
951
  return;
971
- }
972
952
  const activeTurn = this.firstActiveTurn(conversationId);
973
953
  const abortSignal = this.abortActiveTurns(conversationId);
974
954
  const droppedMessages = signal === 'new_session'
@@ -986,16 +966,15 @@ export class CanonAgent {
986
966
  hasActiveTurn: Boolean(abortSignal),
987
967
  droppedCount: droppedMessages.length,
988
968
  });
989
- await Promise.resolve(handler?.({
969
+ await Promise.resolve(handler({
990
970
  conversationId,
991
- signal: signal,
992
- updatedAt: timestamp || undefined,
971
+ signal,
972
+ updatedAt: updatedAt || undefined,
993
973
  abortSignal,
994
974
  droppedMessageIds,
995
975
  })).catch((error) => {
996
976
  console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
997
977
  });
998
- await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
999
978
  }
1000
979
  firstActiveTurn(conversationId) {
1001
980
  const turns = this.activeTurns.get(conversationId);
@@ -1065,6 +1044,13 @@ export class CanonAgent {
1065
1044
  console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
1066
1045
  });
1067
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
+ */
1068
1054
  createRuntimeStatePublisher() {
1069
1055
  if (!this.agentId)
1070
1056
  return null;
@@ -1072,6 +1058,7 @@ export class CanonAgent {
1072
1058
  agentId: this.agentId,
1073
1059
  clientType: this.options.clientType ?? 'generic',
1074
1060
  hostMode: this.options.runtimeControlSurface === 'host',
1061
+ ...(this.rtdbHandle ? { rtdb: this.rtdbHandle } : {}),
1075
1062
  });
1076
1063
  }
1077
1064
  requireRuntimeStatePublisher() {
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
- export type { AgentContactsAPI, AgentUsersAPI } from './canon-agent.js';
2
+ export type { AgentContactsAPI, AgentConversationsAPI, AgentUsersAPI } from './canon-agent.js';
3
3
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
4
4
  export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, } from '@canonmsg/core';
5
5
  export { SessionManager } from './session-manager.js';
6
6
  export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
7
7
  export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
8
8
  export type { SessionConfig, Session } from './session-manager.js';
9
- export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
9
+ export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, } from '@canonmsg/core';
10
10
  export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
@@ -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/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SendMessageOptions, SendContextualSelfContextInput, SessionConfig, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
1
+ export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, CreateConversationResult, DirectSessionSelection, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SendMessageOptions, SendContextualSelfContextInput, SessionConfig, DirectSessionSelection, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -282,6 +282,9 @@ export type ReachOutResult = {
282
282
  conversationId: string;
283
283
  messageId?: string;
284
284
  selfContextId?: string;
285
+ created?: boolean;
286
+ reused?: boolean;
287
+ sessionSelection?: DirectSessionSelection['mode'];
285
288
  } | {
286
289
  status: 'requested';
287
290
  requestId: string | null;
@@ -293,6 +296,9 @@ export type ReachOutResult = {
293
296
  } | {
294
297
  status: 'setup_required';
295
298
  reason: string;
299
+ } | {
300
+ status: 'no_session';
301
+ reason: string;
296
302
  } | {
297
303
  status: 'blocked' | 'unavailable';
298
304
  reason: string;
@@ -304,6 +310,8 @@ export interface ReachOutOptions {
304
310
  requestMessage?: string;
305
311
  /** Explicit session setup to use when the contact-card target is an agent. */
306
312
  sessionConfig?: SessionConfig | null;
313
+ /** Whether to continue an existing direct agent session or start a fresh one. */
314
+ sessionSelection?: DirectSessionSelection;
307
315
  /** Source conversation for contextual cross-session reach-outs. */
308
316
  sourceConversationId?: string;
309
317
  /** Private context for the agent when this reach-out sends a cross-session message. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "3.1.0",
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.1.0"
31
+ "@canonmsg/core": "^2.4.1"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"