@canonmsg/agent-sdk 10.4.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,13 @@ await agent.start();
34
34
  npm install @canonmsg/agent-sdk
35
35
  ```
36
36
 
37
- The only runtime dependency is `@canonmsg/core`, which npm installs for you. Everything else is native `fetch` and `ReadableStream` (Node.js 18+).
37
+ The SDK uses `@canonmsg/core` and the shared `@canonmsg/endpoint` engine. Node.js 22.22.3 or later is required.
38
+
39
+ The endpoint owns stable installation and operation IDs, SQLite outbox/inbox state, live transport, receipt recovery, conversation synchronization, and upload dependencies. `CanonAgent` adds handler and native-session behavior. Set `CANON_ENDPOINT_STATE_DIR` to a persistent private directory; the default is `~/.canon/endpoint`. Keep this directory when upgrading or restarting a runtime.
40
+
41
+ Replicated or ephemeral cloud deployments must inject an operator-owned endpoint store through `endpoint: { endpoint: sharedEndpoint }`. File delivery also needs a durable `mediaBytes` adapter or persistent `mediaDirectory`; a temporary container filesystem is not a delivery journal. All replicas for one installation share the same store namespace. A process must not replay an uncertain provider call automatically.
42
+
43
+ Existing conversations require the rollout tool's reconciled history baseline before agent execution. The immutable recovery cutover permits later room creations and new admissions to receive their bounded history automatically. Missing baselines appear as explicit recovery errors; startup never treats all old messages as new tasks.
38
44
 
39
45
  Runtime heartbeats and Firebase token refresh come from Core, using the same machinery as the integrated plugins. The SDK adds handler dispatch and lifecycle wiring. Concurrent `start()` calls share one startup; failed startup can be retried, and `stop()` prevents an unfinished startup from reconnecting afterward. Heartbeat failures are reported and later heartbeats retry. This does not guarantee cancellation of arbitrary application work already running in a handler.
40
46
 
@@ -26,7 +26,7 @@ export function createCanonAttachedSession(options) {
26
26
  rtdbUrl: connection.rtdbUrl,
27
27
  firebaseWebApiKey: connection.firebaseApiKey,
28
28
  });
29
- const client = options.transport?.client ?? new CanonClient(connection.apiKey, runtimeConnection.apiBaseUrl);
29
+ const client = options.transport?.client ?? new CanonClient(connection.apiKey, runtimeConnection.apiBaseUrl, { environmentId: runtimeConnection.environmentId, streamUrl: runtimeConnection.streamUrl });
30
30
  let stopped = false;
31
31
  let running = false;
32
32
  let connected = false;
@@ -36,6 +36,8 @@ export function createCanonAttachedSession(options) {
36
36
  let coreStopPromise;
37
37
  let timer;
38
38
  let stream;
39
+ let endpoint;
40
+ let inbound;
39
41
  let unsubscribeStream;
40
42
  let publisher;
41
43
  let heartbeat;
@@ -82,6 +84,7 @@ export function createCanonAttachedSession(options) {
82
84
  const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
83
85
  const core = createAttachedNativeSession({
84
86
  binding, store: options.store, adapter: options.native,
87
+ beforeNativeSubmit: (input) => endpoint.claimInbound(`message:${binding.conversationId}:${input.messageId}`),
85
88
  isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
86
89
  publisher: {
87
90
  prepare(input) {
@@ -181,10 +184,9 @@ export function createCanonAttachedSession(options) {
181
184
  void stop().catch((error) => report(error, 'detach'));
182
185
  }
183
186
  async function verifyMembership() {
184
- const conversations = await client.getConversations();
187
+ const conversation = await client.getConversation(binding.conversationId);
185
188
  if (stopped)
186
189
  return false;
187
- const conversation = conversations.find((entry) => entry.id === binding.conversationId);
188
190
  currentConversation = conversation ?? null;
189
191
  if (!conversation?.memberIds.includes(binding.agentId)) {
190
192
  detach('conversation-membership-lost');
@@ -236,6 +238,15 @@ export function createCanonAttachedSession(options) {
236
238
  text: [...rosterLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
237
239
  ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
238
240
  });
241
+ // A conclusive provider acknowledgement settles command delivery. Native
242
+ // turn progress and transcript correlation remain in the provider journal.
243
+ const inputId = `message:${binding.conversationId}:${message.id}`;
244
+ if (result.status === 'uncertain' && result.messageId === message.id) {
245
+ await endpoint.setInboundState(inputId, 'uncertain', 'native-reconciliation-required');
246
+ }
247
+ else if (!(result.status === 'not_submitted' && result.reason === 'This input already has another execution owner.')) {
248
+ await endpoint.setInboundState(inputId, 'settled', `native-${result.status}`);
249
+ }
239
250
  if (stopped)
240
251
  return;
241
252
  reportedHistory.delete(message.id);
@@ -375,8 +386,8 @@ export function createCanonAttachedSession(options) {
375
386
  }
376
387
  if (stopped)
377
388
  return;
378
- const conversations = await client.getConversations();
379
- const conversation = conversations.find((entry) => entry.id === binding.conversationId);
389
+ endpoint = await client.getEndpoint();
390
+ const conversation = await client.getConversation(binding.conversationId);
380
391
  if (!conversation || !conversation.memberIds.includes(binding.agentId)) {
381
392
  throw new Error('The agent is not a member of the specified Canon conversation');
382
393
  }
@@ -417,8 +428,31 @@ export function createCanonAttachedSession(options) {
417
428
  });
418
429
  }
419
430
  running = true;
431
+ inbound = endpoint.acceptInbound({ kind: 'message.created', conversationId: binding.conversationId,
432
+ offer: async (event) => {
433
+ if (!running || stopped || options.transport?.isRouteActive?.(binding.conversationId) === false) {
434
+ await endpoint.setInboundState(event.id, 'deferred', 'attachment-not-ready');
435
+ return;
436
+ }
437
+ const payload = event.data;
438
+ if (core.getState().pendingSourceMessageId === payload.message.id && core.getState().status === 'uncertain') {
439
+ await endpoint.setInboundState(event.id, 'uncertain', 'native-reconciliation-required');
440
+ return;
441
+ }
442
+ await receive(payload);
443
+ const record = await endpoint.getInbound(event.id);
444
+ if (record && record.state !== 'uncertain' && record.state !== 'settled') {
445
+ await endpoint.setInboundState(event.id, 'settled', 'native-input-reviewed');
446
+ }
447
+ }, onError: (error) => report(error, 'inbound-recovery') });
448
+ inbound.start();
420
449
  const handler = {
421
- onMessage: (payload) => track(receive(payload), 'receive'),
450
+ onMessage: (payload) => {
451
+ if (payload.conversationId !== binding.conversationId)
452
+ return;
453
+ track(inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`, kind: 'message.created',
454
+ conversationId: payload.conversationId, durable: true, data: payload }), 'receive');
455
+ },
422
456
  onAgentContext: (context) => {
423
457
  currentOwnerId = context.ownerId;
424
458
  if (context.agentId !== binding.agentId) {
@@ -429,8 +463,10 @@ export function createCanonAttachedSession(options) {
429
463
  onConversationUpdated: (payload) => {
430
464
  if (stopped || payload.conversationId !== binding.conversationId)
431
465
  return;
432
- if (currentConversation)
466
+ if (currentConversation) {
433
467
  currentConversation = { ...currentConversation, ...payload.changes };
468
+ track(client.rememberConversation(currentConversation), 'conversation-state');
469
+ }
434
470
  const members = payload.changes.memberIds;
435
471
  if ((Array.isArray(members) && !members.includes(binding.agentId))
436
472
  || payload.membershipChange?.removedMemberIds.includes(binding.agentId)
@@ -464,7 +500,7 @@ export function createCanonAttachedSession(options) {
464
500
  unsubscribeStream = options.transport.subscribe(handler);
465
501
  else
466
502
  stream = new CanonStream({
467
- apiKey: connection.apiKey, agentId: binding.agentId, streamUrl: runtimeConnection.streamUrl, handler,
503
+ endpoint: await client.getEndpoint(), agentId: binding.agentId, handler,
468
504
  });
469
505
  publishState(core.getState());
470
506
  // CanonStream.start runs for the lifetime of a fetch stream. Do not
@@ -498,6 +534,7 @@ export function createCanonAttachedSession(options) {
498
534
  stopCore();
499
535
  stopPromise = (async () => {
500
536
  await startPromise?.catch(() => { });
537
+ await inbound?.close();
501
538
  await cleanup();
502
539
  emit({ status: 'stopped' });
503
540
  })();
@@ -1,4 +1,4 @@
1
- import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
1
+ import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { Debouncer } from './debouncer.js';
4
4
  import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
@@ -346,6 +346,7 @@ export class CanonAgent {
346
346
  historyLimit: 50,
347
347
  autoMarkRead: true,
348
348
  runtimeControlSurface: 'agent',
349
+ endpoint: {},
349
350
  ...options,
350
351
  environmentId: this.runtimeConnection.environmentId,
351
352
  baseUrl: this.runtimeConnection.apiBaseUrl,
@@ -353,7 +354,9 @@ export class CanonAgent {
353
354
  rtdbUrl: this.runtimeConnection.rtdbUrl,
354
355
  firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
355
356
  };
356
- this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl);
357
+ this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl, {
358
+ environmentId: this.runtimeConnection.environmentId, streamUrl: this.runtimeConnection.streamUrl, ...options.endpoint,
359
+ });
357
360
  this.typingSignals = createTypingStatusPublisher({
358
361
  setTyping: (conversationId, typing, status) => status
359
362
  ? this.apiClient.setTyping(conversationId, typing, status)
@@ -682,7 +685,7 @@ export class CanonAgent {
682
685
  if (generation !== this.lifecycleGeneration)
683
686
  return;
684
687
  this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
685
- const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled });
688
+ const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled, endpoint: await this.apiClient.getEndpoint() });
686
689
  if (this.voiceEventsEnabled) {
687
690
  rtm.setCallHandlers({
688
691
  onCallStarted: (payload) => {
@@ -1253,12 +1256,26 @@ export class CanonAgent {
1253
1256
  }
1254
1257
  async handleMessages(conversationId, messages, provenanceByMessageId) {
1255
1258
  const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
1256
- if (actionableMessages.length === 0) {
1257
- return;
1259
+ const consumed = messages.filter((message) => !actionableMessages.includes(message));
1260
+ if (consumed.length) {
1261
+ const endpoint = await this.apiClient.getEndpoint();
1262
+ for (const message of consumed) {
1263
+ const id = `message:${conversationId}:${message.id}`;
1264
+ if (await endpoint.getInbound(id))
1265
+ await endpoint.setInboundState(id, 'settled', 'interaction-consumed');
1266
+ }
1258
1267
  }
1268
+ if (actionableMessages.length === 0)
1269
+ return;
1259
1270
  messages = actionableMessages;
1260
1271
  if (!this.handler) {
1261
- console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
1272
+ const endpoint = await this.apiClient.getEndpoint();
1273
+ for (const message of messages) {
1274
+ const id = `message:${conversationId}:${message.id}`;
1275
+ if (await endpoint.getInbound(id))
1276
+ await endpoint.setInboundState(id, 'deferred', 'no-handler');
1277
+ }
1278
+ console.warn(`[canon-sdk] No message handler registered — input for ${conversationId} is deferred. Call agent.on('message', handler) before starting.`);
1262
1279
  return;
1263
1280
  }
1264
1281
  const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
@@ -1280,50 +1297,40 @@ export class CanonAgent {
1280
1297
  // Message rediscovery can precede the SSE roster snapshot after admission.
1281
1298
  // Refresh before trusting cached exclusion, accepting queued input, or
1282
1299
  // freezing output policy. Reuse this same snapshot for handler context.
1283
- let membersBeforeRefresh = this.conversationMemberIds.get(conversationId);
1284
- let conversations;
1285
- try {
1286
- conversations = await this.apiClient.getConversations();
1287
- }
1288
- catch {
1289
- // Realtime delivery has already deduplicated these IDs. Retry this read
1290
- // here once; throwing does not arrange replay or requeue the input.
1291
- await sleep(100);
1292
- membersBeforeRefresh = this.conversationMemberIds.get(conversationId);
1293
- try {
1294
- conversations = await this.apiClient.getConversations();
1295
- }
1296
- catch (error) {
1297
- console.error(`[canon-sdk] Pre-turn conversation refresh failed for ${conversationId}:`, error);
1298
- return;
1300
+ const endpoint = await this.apiClient.getEndpoint();
1301
+ const setInputState = async (state, reason) => {
1302
+ for (const message of messages) {
1303
+ const id = `message:${conversationId}:${message.id}`;
1304
+ if (await endpoint.getInbound(id))
1305
+ await endpoint.setInboundState(id, state, reason);
1299
1306
  }
1307
+ };
1308
+ let conversation;
1309
+ try {
1310
+ conversation = await this.apiClient.getConversation(conversationId);
1300
1311
  }
1301
- let conversation = conversations.find((c) => c.id === conversationId);
1302
- if (!conversation)
1312
+ catch (error) {
1313
+ await setInputState('deferred', 'membership-unavailable');
1314
+ console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
1303
1315
  return;
1304
- const membersDuringRefresh = this.conversationMemberIds.get(conversationId);
1305
- const currentRevision = this.conversationMembershipRevisions.get(conversationId);
1306
- const refreshedRevision = knownMembershipRevision(conversation.membershipRevision);
1307
- const comparableRevisions = currentRevision !== undefined && refreshedRevision !== undefined;
1308
- const keepCachedRoster = comparableRevisions
1309
- ? currentRevision > refreshedRevision
1310
- : membersDuringRefresh !== membersBeforeRefresh;
1311
- if (membersDuringRefresh && keepCachedRoster) {
1312
- // Prefer the higher known revision. For legacy unversioned snapshots,
1313
- // retain an event received while the request was pending.
1314
- conversation = { ...conversation, memberIds: membersDuringRefresh, membershipRevision: currentRevision };
1315
1316
  }
1316
- else {
1317
- this.rememberConversationMembers([conversation]);
1317
+ if (!conversation) {
1318
+ await setInputState('settled', 'conversation-unavailable');
1319
+ return;
1318
1320
  }
1321
+ this.rememberConversationMembers([conversation]);
1319
1322
  const currentMembers = this.conversationMemberIds.get(conversationId);
1320
- if (currentMembers && this.agentId && !currentMembers.includes(this.agentId))
1323
+ if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
1324
+ await setInputState('settled', 'membership-lost');
1321
1325
  return;
1326
+ }
1322
1327
  this.rememberConversationId(conversationId);
1323
1328
  await this.markQueuedMessagesAccepted(conversationId, messages);
1324
1329
  const acceptedMembers = this.conversationMemberIds.get(conversationId);
1325
- if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId))
1330
+ if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
1331
+ await setInputState('settled', 'membership-lost');
1326
1332
  return;
1333
+ }
1327
1334
  const turnId = randomUUID();
1328
1335
  const turnOpenedAt = Date.now();
1329
1336
  let turnState = 'thinking';
@@ -1477,7 +1484,7 @@ export class CanonAgent {
1477
1484
  this.sessionManager.seedHistory(conversationId, history);
1478
1485
  }
1479
1486
  // Build reply functions
1480
- const replyFinal = async (text, options) => {
1487
+ const replyFinal = async (text, options, delivery) => {
1481
1488
  throwIfAborted();
1482
1489
  try {
1483
1490
  await this.typingSignals.start(conversationId, 'typing');
@@ -1501,11 +1508,11 @@ export class CanonAgent {
1501
1508
  };
1502
1509
  let result;
1503
1510
  try {
1504
- result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId]);
1511
+ result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId], delivery);
1505
1512
  }
1506
1513
  catch (error) {
1507
1514
  const chunked = isChunkedSendMessageError(error) ? error : null;
1508
- if (!chunked || chunked.deliveredMessageIds.length === 0 || isAbortLikeError(error)) {
1515
+ if (!chunked || chunked.deliveredMessageIds.length === 0 || sendOptions.messageId || isAbortLikeError(error) || isPendingCanonOperation(error)) {
1509
1516
  throw error;
1510
1517
  }
1511
1518
  const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
@@ -1623,9 +1630,11 @@ export class CanonAgent {
1623
1630
  // override below only covers a retry backoff.
1624
1631
  const abortAwareClient = Object.create(this.apiClient, {
1625
1632
  sendMessage: {
1626
- value: (targetConversationId, targetText, targetOptions) => {
1633
+ value: (targetConversationId, targetText, targetOptions, delivery) => {
1627
1634
  throwIfAborted();
1628
- return this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
1635
+ return delivery?.operationId
1636
+ ? this.apiClient.sendMessage(targetConversationId, targetText, targetOptions, delivery)
1637
+ : this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
1629
1638
  },
1630
1639
  },
1631
1640
  });
@@ -1637,7 +1646,7 @@ export class CanonAgent {
1637
1646
  // that FITS still goes out as a single message under the plain id with
1638
1647
  // untouched metadata, so the common case (and the interim→final handoff
1639
1648
  // that keys on that id) is unchanged.
1640
- const sendDurableMessage = async (text, options, fallbackMessageIdParts) => {
1649
+ const sendDurableMessage = async (text, options, fallbackMessageIdParts, delivery) => {
1641
1650
  // Counts every durable send the turn attempts, not just the ones that
1642
1651
  // needed a generated id: teardown reads `durableMessageSequence` to
1643
1652
  // decide whether the turn genuinely ended silent, and a reply sent
@@ -1649,7 +1658,9 @@ export class CanonAgent {
1649
1658
  ...(options ?? {}),
1650
1659
  messageId,
1651
1660
  }, {
1652
- sleep: (ms) => sleepWithAbort(ms, abortController.signal),
1661
+ signal: abortController.signal,
1662
+ ...(delivery?.operationId ? { operationId: delivery.operationId } : {}),
1663
+ ...(delivery?.replacesOperationId ? { replacesOperationId: delivery.replacesOperationId } : {}),
1653
1664
  }, {
1654
1665
  resumable: true,
1655
1666
  });
@@ -1659,16 +1670,9 @@ export class CanonAgent {
1659
1670
  // the end of the answer, not its head. `messageIds` has the full set.
1660
1671
  return { messageId: messageIds[messageIds.length - 1], messageIds };
1661
1672
  };
1662
- // Build agent context (fallback to minimal if not yet received)
1663
- const agent = this.agentContext ?? {
1664
- agentId: this.agentId,
1665
- ownerId: '',
1666
- ownerName: '',
1667
- discoverable: false,
1668
- inboundPolicy: 'approval-required',
1669
- outboundPolicy: 'approval-required',
1670
- groupJoinPolicy: 'approval-required',
1671
- };
1673
+ // Runtime context must come from authenticated agent metadata. A synthetic
1674
+ // generation cannot authorize an endpoint or a new owner's native work.
1675
+ const agent = this.agentContext ?? await this.apiClient.getAgentMe();
1672
1676
  const provenance = latestMessage
1673
1677
  ? resolveRuntimeProvenance({
1674
1678
  provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
@@ -2187,8 +2191,15 @@ export class CanonAgent {
2187
2191
  catch { }
2188
2192
  }
2189
2193
  };
2190
- // Invoke handler
2194
+ // Fence uncertain native/business effects before handing execution out.
2195
+ // A crash after this write requires provider/operator reconciliation.
2191
2196
  throwIfAborted();
2197
+ // Every SDK message handler invocation originates in Canon admission. A
2198
+ // restored native queue without its journal must await migration instead
2199
+ // of treating a missing record as permission to execute.
2200
+ const journaled = messages.map((message) => `message:${conversationId}:${message.id}`);
2201
+ if (!journaled.length || !await endpoint.claimInbound(journaled))
2202
+ return;
2192
2203
  await this.handler({
2193
2204
  messages: hydratedMessages,
2194
2205
  history,
@@ -2197,6 +2208,19 @@ export class CanonAgent {
2197
2208
  conversation,
2198
2209
  ...(groupContext ? { groupContext } : {}),
2199
2210
  replyFinal,
2211
+ getReplyOperation: (messageId, delivery) => this.apiClient.getMessageOperation(conversationId, messageId, delivery),
2212
+ waitForReply: async (operationId) => {
2213
+ throwIfAborted();
2214
+ durableMessageSequence += 1;
2215
+ const result = await this.apiClient.waitForOperation(operationId, { signal: abortController.signal });
2216
+ if (typeof result?.messageId !== 'string' || !result.messageId)
2217
+ throw new Error('Canon returned a malformed message receipt');
2218
+ try {
2219
+ await this.typingSignals.clear(conversationId);
2220
+ }
2221
+ catch { }
2222
+ return { messageId: result.messageId, messageIds: result.messageIds ?? [result.messageId] };
2223
+ },
2200
2224
  replyProgress,
2201
2225
  deleteMessage,
2202
2226
  markAsRead,
@@ -2349,6 +2373,7 @@ export class CanonAgent {
2349
2373
  },
2350
2374
  },
2351
2375
  });
2376
+ await setInputState('settled', 'handler-completed');
2352
2377
  // Auto-mark conversation as read after successful processing
2353
2378
  if (this.options.autoMarkRead) {
2354
2379
  try {
package/dist/media.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface MaterializeMediaOptions {
15
15
  onError?: (error: unknown, attachment: MediaAttachment, index: number) => void;
16
16
  }
17
17
  export interface UploadMediaFileOptions {
18
+ /** Stable native/product job key for upload recovery across restarts. */
19
+ attachmentId?: string;
18
20
  fileName?: string;
19
21
  mimeType?: string;
20
22
  durationMs?: number;
package/dist/media.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createReadStream, createWriteStream } from 'node:fs';
1
+ import { createWriteStream } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join } from 'node:path';
@@ -321,98 +321,47 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
321
321
  }
322
322
  const mimeType = inferUploadMimeType(filePath, options?.mimeType);
323
323
  const fileName = options?.fileName ?? basename(filePath);
324
- const session = await client.createResumableMediaUpload(conversationId, fileStat.size, mimeType, fileName);
325
- options?.signal?.throwIfAborted();
326
- if (typeof session.uploadId !== 'string' || session.uploadId.length === 0) {
327
- throw new Error('Canon returned an invalid resumable upload ID');
328
- }
329
- if (session.sizeBytes !== fileStat.size) {
330
- throw new Error('Canon resumable upload size does not match the local file');
331
- }
332
- if (typeof session.mimeType !== 'string' || session.mimeType.length === 0) {
333
- throw new Error('Canon returned an invalid resumable upload MIME type');
334
- }
335
- let uploadUrl;
336
- try {
337
- uploadUrl = new URL(session.uploadUrl);
338
- }
339
- catch {
340
- throw new Error('Canon returned an invalid resumable upload URL');
341
- }
342
- if (uploadUrl.protocol !== 'https:') {
343
- throw new Error('Canon returned a non-HTTPS resumable upload URL');
344
- }
345
- if (uploadUrl.hostname !== 'storage.googleapis.com'
346
- || uploadUrl.username.length > 0
347
- || uploadUrl.password.length > 0) {
348
- throw new Error('Canon returned an untrusted resumable upload URL');
349
- }
350
- const fetchImpl = ensureFetch(options?.fetchImpl);
351
- const uploadResponse = await fetchImpl(uploadUrl, {
352
- method: 'PUT',
353
- headers: {
354
- 'Content-Type': session.mimeType,
355
- 'Content-Length': String(fileStat.size),
356
- 'Content-Range': `bytes 0-${fileStat.size - 1}/${fileStat.size}`,
357
- },
358
- body: createReadStream(filePath),
359
- signal: options?.signal,
360
- // Node requires this for streaming request bodies. It is intentionally
361
- // outside the DOM RequestInit type but supported by the built-in fetch.
362
- duplex: 'half',
363
- });
364
- if (!uploadResponse.ok) {
365
- throw new Error(`Failed to upload Canon media (${uploadResponse.status} ${uploadResponse.statusText})`);
366
- }
367
- options?.signal?.throwIfAborted();
368
- const finalized = await client.finalizeResumableMediaUpload(session.uploadId);
369
- options?.signal?.throwIfAborted();
370
- if (finalized.uploadId !== session.uploadId) {
371
- throw new Error('Canon resumable upload result does not match its session');
372
- }
373
- if (finalized.attachment.uploadId !== undefined
374
- && finalized.attachment.uploadId !== session.uploadId) {
375
- throw new Error('Canon resumable upload attachment does not match its session');
376
- }
377
- const uploaded = {
378
- ...finalized,
379
- attachment: {
380
- ...finalized.attachment,
381
- uploadId: session.uploadId,
382
- },
383
- };
384
- if (uploaded.attachment.kind === 'audio'
385
- && typeof options?.durationMs === 'number'
386
- && Number.isFinite(options.durationMs)
387
- && options.durationMs > 0) {
388
- return {
389
- ...uploaded,
390
- attachment: {
391
- ...uploaded.attachment,
392
- durationMs: Math.round(options.durationMs),
393
- },
394
- };
395
- }
396
- return uploaded;
324
+ const media = await client.getFileMediaPipeline(options?.fetchImpl);
325
+ const [attachment] = await media.prepare({ conversationId, attachmentId: options?.attachmentId ?? randomUUID(),
326
+ files: [{ source: filePath, descriptor: { mimeType, sizeBytes: fileStat.size, fileName,
327
+ ...(options?.durationMs !== undefined ? { durationMs: options.durationMs } : {}) } }],
328
+ }, { signal: options?.signal });
329
+ if (typeof attachment?.uploadId !== 'string' || typeof attachment.url !== 'string') {
330
+ throw new Error('Canon returned an invalid prepared attachment');
331
+ }
332
+ return { uploadId: attachment.uploadId, url: attachment.url, attachment: attachment };
397
333
  }
398
334
  export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
399
- const { fileName, mimeType, durationMs, fetchImpl, signal, canPublish, ...sendOptions } = options ?? {};
400
- const uploaded = await uploadMediaFile(client, conversationId, filePath, {
401
- ...(fileName ? { fileName } : {}),
402
- ...(mimeType ? { mimeType } : {}),
403
- ...(durationMs != null ? { durationMs } : {}),
404
- ...(fetchImpl ? { fetchImpl } : {}),
405
- ...(signal ? { signal } : {}),
406
- });
335
+ const { fileName, mimeType, durationMs, fetchImpl, signal, canPublish, attachmentId: _attachmentId, ...sendOptions } = options ?? {};
407
336
  signal?.throwIfAborted();
408
- if (canPublish && !canPublish()) {
409
- throw new Error('Canon media publication withheld by current routing policy');
410
- }
411
- return client.sendMessage(conversationId, text, {
412
- ...sendOptions,
413
- contentType: uploaded.attachment.kind,
414
- attachments: [uploaded.attachment],
415
- });
337
+ const info = await stat(filePath);
338
+ if (!info.isFile() || info.size <= 0 || info.size > MAX_CANON_MEDIA_BYTES) {
339
+ throw new Error('Canon media must be a regular file from 1 byte to 100 MiB');
340
+ }
341
+ const effectiveMimeType = inferUploadMimeType(filePath, mimeType);
342
+ const contentType = effectiveMimeType.startsWith('image/') ? 'image'
343
+ : effectiveMimeType.startsWith('video/') ? 'video'
344
+ : effectiveMimeType.startsWith('audio/') ? 'audio' : 'file';
345
+ // Automatic artifacts keep their audience across upload and every later
346
+ // receipt retry. A membership change may reduce, but never expand, that audience.
347
+ const initialRoom = canPublish ? await client.getConversation(conversationId) : null;
348
+ const originalMembers = new Set(initialRoom?.memberIds ?? []);
349
+ const beforePublish = canPublish ? async () => {
350
+ const current = await client.getConversation(conversationId);
351
+ return !!current && originalMembers.size > 0
352
+ && current.memberIds.every((id) => originalMembers.has(id)) && canPublish();
353
+ } : undefined;
354
+ const messageId = sendOptions.messageId ?? randomUUID();
355
+ const pipeline = await client.getFileMediaPipeline(fetchImpl);
356
+ const delivered = await pipeline.send({
357
+ conversationId, messageId,
358
+ message: { ...sendOptions, text, contentType },
359
+ files: [{ source: filePath, descriptor: {
360
+ mimeType: effectiveMimeType, sizeBytes: info.size, fileName: fileName ?? basename(filePath),
361
+ ...(durationMs != null ? { durationMs } : {}),
362
+ } }],
363
+ }, { ...(signal ? { signal } : {}), ...(beforePublish ? { beforePublish } : {}) });
364
+ return { messageId: delivered };
416
365
  }
417
366
  /**
418
367
  * Resolve the effective MIME type of a materialized attachment, falling back
@@ -1,4 +1,4 @@
1
- import { type AgentContext, type ContactAddedPayload, type ContactRemovedPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type ParticipationSuppressedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
1
+ import { type CanonEndpoint, type AgentContext, type ContactAddedPayload, type ContactRemovedPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type ParticipationSuppressedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
2
2
  import { Debouncer } from './debouncer.js';
3
3
  /**
4
4
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
@@ -9,8 +9,8 @@ import { Debouncer } from './debouncer.js';
9
9
  export declare class RealtimeManager {
10
10
  private debouncer;
11
11
  private stream;
12
- /** Recent inbound message IDs (`conversationId:messageId`) for cross-flush dedupe. */
13
- private readonly recentInboundMessageIds;
12
+ private readonly endpoint;
13
+ private readonly inbound;
14
14
  private lastSseErrorKey;
15
15
  private lastSseErrorAt;
16
16
  private suppressedSseErrorCount;
@@ -25,12 +25,12 @@ export declare class RealtimeManager {
25
25
  private onDisconnected;
26
26
  private onCallStarted;
27
27
  private onCallEnded;
28
- constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl?: string, options?: {
28
+ constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl: string | undefined, options: {
29
29
  enableVoiceEvents?: boolean;
30
+ endpoint: CanonEndpoint;
30
31
  });
31
- private hasSeenInboundMessage;
32
- private recordSeenInboundMessage;
33
- private pruneRecentInboundMessageIds;
32
+ private receive;
33
+ private offer;
34
34
  private logSseError;
35
35
  setOnAgentContext(cb: (ctx: AgentContext) => void): void;
36
36
  setContactGraphHandlers(handlers: {
package/dist/realtime.js CHANGED
@@ -1,6 +1,4 @@
1
1
  import { CanonStream, } from '@canonmsg/core';
2
- const RECENT_INBOUND_TTL_MS = 30 * 60 * 1000;
3
- const MAX_RECENT_INBOUND_MESSAGE_IDS = 5000;
4
2
  /**
5
3
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
6
4
  * - Debouncer integration (message batching)
@@ -10,8 +8,8 @@ const MAX_RECENT_INBOUND_MESSAGE_IDS = 5000;
10
8
  export class RealtimeManager {
11
9
  debouncer;
12
10
  stream;
13
- /** Recent inbound message IDs (`conversationId:messageId`) for cross-flush dedupe. */
14
- recentInboundMessageIds = new Map();
11
+ endpoint;
12
+ inbound;
15
13
  lastSseErrorKey = null;
16
14
  lastSseErrorAt = 0;
17
15
  suppressedSseErrorCount = 0;
@@ -28,10 +26,13 @@ export class RealtimeManager {
28
26
  onCallEnded = null;
29
27
  constructor(apiKey, debouncer, agentId, streamUrl, options) {
30
28
  this.debouncer = debouncer;
29
+ this.endpoint = options.endpoint;
30
+ this.inbound = this.endpoint.acceptInbound({ kind: 'message.created',
31
+ offer: (event) => this.offer(event.data),
32
+ onError: (error) => this.logSseError(error instanceof Error ? error : new Error(String(error))), });
31
33
  this.stream = new CanonStream({
32
- apiKey,
34
+ endpoint: this.endpoint,
33
35
  agentId,
34
- streamUrl,
35
36
  handler: {
36
37
  // The voice family is requested from handler PRESENCE at stream
37
38
  // construction, so these delegating closures exist only when the
@@ -49,44 +50,7 @@ export class RealtimeManager {
49
50
  }
50
51
  : {}),
51
52
  onMessage: (payload) => {
52
- // Cross-flush id dedupe: SSE replay overlap must never double-fire
53
- // a turn for the same message.
54
- if (this.hasSeenInboundMessage(payload.conversationId, payload.message.id)) {
55
- return;
56
- }
57
- this.recordSeenInboundMessage(payload.conversationId, payload.message.id);
58
- if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
59
- console.error(`[canon-sdk] Ignoring server-dispatched observe-only message in ${payload.conversationId}: ${payload.turnDispatch.reason}`);
60
- return;
61
- }
62
- const m = payload.message;
63
- const message = {
64
- id: m.id,
65
- senderId: m.senderId,
66
- ...(m.senderName ? { senderName: m.senderName } : {}),
67
- senderType: m.senderType ?? 'human',
68
- isOwner: m.isOwner ?? false,
69
- contentType: m.contentType ?? 'text',
70
- text: m.text ?? null,
71
- attachments: m.attachments ?? [],
72
- mentions: m.mentions ?? [],
73
- ...(m.reactions ? { reactions: m.reactions } : {}),
74
- replyTo: m.replyTo ?? null,
75
- replyToPosition: m.replyToPosition ?? null,
76
- ...(m.forwarded === true || m.forwardedFrom
77
- ? { forwarded: true }
78
- : {}),
79
- ...(m.forwardedFrom
80
- ? { forwardedFrom: m.forwardedFrom }
81
- : {}),
82
- status: 'sent',
83
- deleted: false,
84
- createdAt: m.createdAt ?? new Date().toISOString(),
85
- ...(m.contactCard ? { contactCard: m.contactCard } : {}),
86
- ...(m.metadata ? { metadata: m.metadata } : {}),
87
- ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
88
- };
89
- this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
53
+ void this.receive(payload).catch((error) => this.logSseError(error instanceof Error ? error : new Error(String(error))));
90
54
  },
91
55
  onMessageDeleted: (payload) => {
92
56
  this.debouncer.removeMessage(payload.conversationId, payload.messageId);
@@ -105,7 +69,9 @@ export class RealtimeManager {
105
69
  this.onContactRemoved?.(payload);
106
70
  },
107
71
  onConversationUpdated: (payload) => {
108
- this.onConversationUpdated?.(payload);
72
+ void this.endpoint.applyConversationSnapshot({ id: payload.conversationId, ...payload.changes })
73
+ .then(({ id: _id, ...changes }) => this.onConversationUpdated?.({ ...payload, changes }))
74
+ .catch((error) => this.logSseError(error instanceof Error ? error : new Error(String(error))));
109
75
  },
110
76
  onParticipationSuppressed: (payload) => {
111
77
  this.onParticipationSuppressed?.(payload);
@@ -117,7 +83,7 @@ export class RealtimeManager {
117
83
  this.onDisconnected?.();
118
84
  },
119
85
  onReplayExpired: (payload) => {
120
- console.error(`[canon-sdk] SSE replay window expired${payload.lastAvailableId ? ` (oldest available event: ${payload.lastAvailableId})` : ''}; missed history is available via explicit REST fetch`);
86
+ console.error(`[canon-sdk] SSE replay window expired${payload.lastAvailableId ? ` (oldest available event: ${payload.lastAvailableId})` : ''}; the endpoint is recovering the durable gap`);
121
87
  },
122
88
  onError: (err) => {
123
89
  this.logSseError(err);
@@ -125,27 +91,45 @@ export class RealtimeManager {
125
91
  },
126
92
  });
127
93
  }
128
- hasSeenInboundMessage(conversationId, messageId) {
129
- return this.recentInboundMessageIds.has(`${conversationId}:${messageId}`);
130
- }
131
- recordSeenInboundMessage(conversationId, messageId) {
132
- const now = Date.now();
133
- this.recentInboundMessageIds.set(`${conversationId}:${messageId}`, now);
134
- this.pruneRecentInboundMessageIds(now);
94
+ async receive(payload) {
95
+ await this.inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
96
+ kind: 'message.created', conversationId: payload.conversationId, durable: true,
97
+ data: payload });
135
98
  }
136
- pruneRecentInboundMessageIds(now = Date.now()) {
137
- const cutoff = now - RECENT_INBOUND_TTL_MS;
138
- for (const [key, seenAt] of this.recentInboundMessageIds) {
139
- if (seenAt < cutoff) {
140
- this.recentInboundMessageIds.delete(key);
141
- }
142
- }
143
- while (this.recentInboundMessageIds.size > MAX_RECENT_INBOUND_MESSAGE_IDS) {
144
- const oldestKey = this.recentInboundMessageIds.keys().next().value;
145
- if (!oldestKey)
146
- break;
147
- this.recentInboundMessageIds.delete(oldestKey);
99
+ async offer(payload) {
100
+ const id = `message:${payload.conversationId}:${payload.message.id}`;
101
+ if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
102
+ await this.endpoint.setInboundState(id, 'settled', 'observe-only');
103
+ return;
148
104
  }
105
+ const m = payload.message;
106
+ const message = {
107
+ id: m.id,
108
+ senderId: m.senderId,
109
+ ...(m.senderName ? { senderName: m.senderName } : {}),
110
+ senderType: m.senderType ?? 'human',
111
+ isOwner: m.isOwner ?? false,
112
+ contentType: m.contentType ?? 'text',
113
+ text: m.text ?? null,
114
+ attachments: m.attachments ?? [],
115
+ mentions: m.mentions ?? [],
116
+ ...(m.reactions ? { reactions: m.reactions } : {}),
117
+ replyTo: m.replyTo ?? null,
118
+ replyToPosition: m.replyToPosition ?? null,
119
+ ...(m.forwarded === true || m.forwardedFrom
120
+ ? { forwarded: true }
121
+ : {}),
122
+ ...(m.forwardedFrom
123
+ ? { forwardedFrom: m.forwardedFrom }
124
+ : {}),
125
+ status: 'sent',
126
+ deleted: false,
127
+ createdAt: m.createdAt ?? new Date().toISOString(),
128
+ ...(m.contactCard ? { contactCard: m.contactCard } : {}),
129
+ ...(m.metadata ? { metadata: m.metadata } : {}),
130
+ ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
131
+ };
132
+ this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
149
133
  }
150
134
  logSseError(err) {
151
135
  const code = err.code;
@@ -196,9 +180,11 @@ export class RealtimeManager {
196
180
  this.onCallEnded = handlers.onCallEnded ?? null;
197
181
  }
198
182
  async start() {
183
+ this.inbound.start();
199
184
  await this.stream.start();
200
185
  }
201
186
  stop() {
187
+ void this.inbound.close();
202
188
  this.stream.stop();
203
189
  }
204
190
  }
package/dist/types.d.ts CHANGED
@@ -166,7 +166,16 @@ export interface MessageHandlerContext {
166
166
  conversation: CanonConversation;
167
167
  /** Lightweight group awareness, present for group conversations. */
168
168
  groupContext?: CanonGroupContext;
169
- replyFinal: (text: string, options?: SendMessageOptions) => Promise<FinalMessageResult>;
169
+ replyFinal: (text: string, options?: SendMessageOptions, delivery?: {
170
+ operationId?: string;
171
+ replacesOperationId?: string;
172
+ }) => Promise<FinalMessageResult>;
173
+ /** Inspect a prior reply before renewing media or recording product completion. */
174
+ getReplyOperation: (messageId: string, delivery?: {
175
+ operationId?: string;
176
+ }) => Promise<import('@canonmsg/core').CanonMessageOperation | undefined>;
177
+ /** Reconcile every part of a journaled reply without rebuilding its content. */
178
+ waitForReply: (operationId: string) => Promise<FinalMessageResult>;
170
179
  replyProgress: (text: string, options?: ProgressMessageOptions) => Promise<ProgressMessageResult>;
171
180
  /** Soft-delete a message (agent must be the sender) */
172
181
  deleteMessage: (messageId: string) => Promise<void>;
@@ -307,6 +316,8 @@ export interface CanonAgentConnectionOptions {
307
316
  firebaseApiKey?: string;
308
317
  }
309
318
  export interface CanonAgentOptions extends CanonAgentConnectionOptions {
319
+ /** Shared durable engine or persistent installation profile for this runtime. */
320
+ endpoint?: import('@canonmsg/core').CanonClientEndpointOptions;
310
321
  apiKey: string;
311
322
  /** `auto` resolves to SSE. */
312
323
  deliveryMode?: DeliveryMode;
@@ -9,7 +9,7 @@ export function createCanonWorkSessionHost(options) {
9
9
  const runtime = resolveCanonRuntimeConnection({ environmentId: connection.environmentId,
10
10
  apiBaseUrl: connection.baseUrl, streamUrl: connection.streamUrl,
11
11
  rtdbUrl: connection.rtdbUrl, firebaseWebApiKey: connection.firebaseApiKey });
12
- const client = new CanonClient(connection.apiKey, runtime.apiBaseUrl);
12
+ const client = new CanonClient(connection.apiKey, runtime.apiBaseUrl, { environmentId: runtime.environmentId, streamUrl: runtime.streamUrl });
13
13
  const journal = createWorkSessionJournal(options.store, {
14
14
  environmentId: connection.environmentId, agentId: connection.agentId, hostId: options.hostId,
15
15
  });
@@ -30,6 +30,7 @@ export function createCanonWorkSessionHost(options) {
30
30
  let transport;
31
31
  let stream;
32
32
  let connected = false;
33
+ let agentContext;
33
34
  let stopped = false;
34
35
  let startPromise;
35
36
  let stopPromise;
@@ -134,7 +135,7 @@ export function createCanonWorkSessionHost(options) {
134
135
  throw new Error('This conversation already has a work session.');
135
136
  if ([...rooms.values()].some((room) => room.binding.nativeSessionId === binding.nativeSessionId))
136
137
  throw new Error('This native session already has a different Canon audience.');
137
- const conversation = (await client.getConversations()).find((entry) => entry.id === binding.conversationId);
138
+ const conversation = await client.getConversation(binding.conversationId);
138
139
  if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
139
140
  throw new Error('The owner and agent must both be members of this conversation.');
140
141
  fence();
@@ -446,11 +447,19 @@ export function createCanonWorkSessionHost(options) {
446
447
  transport = { agentId: connection.agentId, environmentId: connection.environmentId, client, publisher,
447
448
  hasPendingInteraction: (id) => pending.has(id),
448
449
  isRouteActive: (id) => !stopped && rooms.get(id)?.acknowledged === true,
449
- subscribe: (handler) => { handlers.add(handler); if (connected && !stopped)
450
- handler.onConnected?.(); return () => { handlers.delete(handler); }; } };
450
+ subscribe: (handler) => {
451
+ handlers.add(handler);
452
+ // Context is account state, not a one-shot notification. An attachment
453
+ // created after stream startup needs the same trusted owner identity.
454
+ if (!stopped && agentContext)
455
+ handler.onAgentContext?.(agentContext);
456
+ if (connected && !stopped)
457
+ handler.onConnected?.();
458
+ return () => { handlers.delete(handler); };
459
+ } };
451
460
  const connectionReady = new Promise((resolve, reject) => { resolveConnection = resolve; rejectConnection = reject; });
452
461
  const connectionTimer = setTimeout(() => rejectConnection?.(new Error('Canon stream did not connect; work-session setup was not started.')), connectTimeoutMs);
453
- stream = new CanonStream({ apiKey: connection.apiKey, agentId: connection.agentId, streamUrl: runtime.streamUrl, handler: {
462
+ stream = new CanonStream({ endpoint: await client.getEndpoint(), agentId: connection.agentId, handler: {
454
463
  onMessage: (payload) => {
455
464
  if (stopped || !connected)
456
465
  return;
@@ -462,6 +471,7 @@ export function createCanonWorkSessionHost(options) {
462
471
  fail(new Error('Canon stream identity changed.'), 'stream-identity');
463
472
  return;
464
473
  }
474
+ agentContext = context;
465
475
  fanout('onAgentContext', context);
466
476
  },
467
477
  onConversationUpdated: (payload) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "10.4.0",
3
+ "version": "11.0.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,10 +25,10 @@
25
25
  "prepack": "npm run build"
26
26
  },
27
27
  "engines": {
28
- "node": ">=18.0.0"
28
+ "node": ">=22.22.3"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^12.5.0"
31
+ "@canonmsg/core": "^13.0.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"