@canonmsg/agent-sdk 10.3.0 → 10.4.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.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { buildChunkedMessagePartMetadata, CanonClient, CanonStream, createAttachedNativeSession, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
+ import { buildChunkedMessagePartMetadata, buildCanonGroupContext, buildCompactGroupContextLines, CanonClient, CanonStream, createAttachedNativeSession, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
3
3
  /**
4
4
  * Connect one existing native session to one existing Canon conversation.
5
5
  * This is account-level participation, not a second group member. The caller
@@ -47,6 +47,8 @@ export function createCanonAttachedSession(options) {
47
47
  const reportedHistory = new Set();
48
48
  let publicationFailed = false;
49
49
  let historyIncomplete = false;
50
+ let currentConversation = null;
51
+ let currentOwnerId;
50
52
  let inputError = null;
51
53
  let observedTurnId;
52
54
  let turnUpdatedAt = null;
@@ -183,6 +185,7 @@ export function createCanonAttachedSession(options) {
183
185
  if (stopped)
184
186
  return false;
185
187
  const conversation = conversations.find((entry) => entry.id === binding.conversationId);
188
+ currentConversation = conversation ?? null;
186
189
  if (!conversation?.memberIds.includes(binding.agentId)) {
187
190
  detach('conversation-membership-lost');
188
191
  return false;
@@ -226,9 +229,11 @@ export function createCanonAttachedSession(options) {
226
229
  await notice(payload, inputError);
227
230
  return;
228
231
  }
232
+ const roster = buildCanonGroupContext({ conversation: currentConversation, messages: [message], agentId: binding.agentId, ownerId: currentOwnerId });
233
+ const rosterLines = roster ? buildCompactGroupContextLines(roster, 'initial') : [];
229
234
  const result = await core.submit({
230
235
  messageId: message.id,
231
- text: `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`,
236
+ text: [...rosterLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
232
237
  ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
233
238
  });
234
239
  if (stopped)
@@ -375,6 +380,7 @@ export function createCanonAttachedSession(options) {
375
380
  if (!conversation || !conversation.memberIds.includes(binding.agentId)) {
376
381
  throw new Error('The agent is not a member of the specified Canon conversation');
377
382
  }
383
+ currentConversation = conversation;
378
384
  const initial = await client.getMessagesPage(binding.conversationId, 100);
379
385
  if (stopped)
380
386
  return;
@@ -414,6 +420,7 @@ export function createCanonAttachedSession(options) {
414
420
  const handler = {
415
421
  onMessage: (payload) => track(receive(payload), 'receive'),
416
422
  onAgentContext: (context) => {
423
+ currentOwnerId = context.ownerId;
417
424
  if (context.agentId !== binding.agentId) {
418
425
  report(new Error('Canon stream context belongs to a different agent'), 'stream-identity');
419
426
  detach('stream-identity-mismatch');
@@ -422,6 +429,8 @@ export function createCanonAttachedSession(options) {
422
429
  onConversationUpdated: (payload) => {
423
430
  if (stopped || payload.conversationId !== binding.conversationId)
424
431
  return;
432
+ if (currentConversation)
433
+ currentConversation = { ...currentConversation, ...payload.changes };
425
434
  const members = payload.changes.memberIds;
426
435
  if ((Array.isArray(members) && !members.includes(binding.agentId))
427
436
  || payload.membershipChange?.removedMemberIds.includes(binding.agentId)
@@ -90,6 +90,7 @@ export declare class CanonAgent {
90
90
  private readonly activeAbortControllers;
91
91
  private readonly activeTurns;
92
92
  private readonly conversationMemberIds;
93
+ private readonly conversationMembershipRevisions;
93
94
  private readonly pendingMembershipChanges;
94
95
  private readonly typingSignals;
95
96
  private sseConnectedLogged;
@@ -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, 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, 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';
@@ -143,6 +143,9 @@ const STANDARD_PRIMITIVE_COMMANDS = {
143
143
  function sleep(ms) {
144
144
  return new Promise((resolve) => setTimeout(resolve, ms));
145
145
  }
146
+ function knownMembershipRevision(value) {
147
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
148
+ }
146
149
  function sleepWithAbort(ms, signal) {
147
150
  if (signal.aborted)
148
151
  return Promise.reject(createTurnAbortError());
@@ -325,6 +328,7 @@ export class CanonAgent {
325
328
  activeAbortControllers = new Map();
326
329
  activeTurns = new Map();
327
330
  conversationMemberIds = new Map();
331
+ conversationMembershipRevisions = new Map();
328
332
  pendingMembershipChanges = new Map();
329
333
  typingSignals;
330
334
  sseConnectedLogged = false;
@@ -964,24 +968,43 @@ export class CanonAgent {
964
968
  }
965
969
  rememberConversationMembers(conversations) {
966
970
  for (const conversation of conversations) {
971
+ const revision = knownMembershipRevision(conversation.membershipRevision);
972
+ const currentRevision = this.conversationMembershipRevisions.get(conversation.id);
973
+ if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
974
+ continue;
967
975
  this.conversationMemberIds.set(conversation.id, [...(conversation.memberIds ?? [])]);
976
+ if (revision !== undefined)
977
+ this.conversationMembershipRevisions.set(conversation.id, revision);
978
+ else
979
+ this.conversationMembershipRevisions.delete(conversation.id);
968
980
  }
969
981
  }
970
982
  handleConversationUpdated(payload) {
971
983
  const rawMemberIds = payload.changes.memberIds;
972
984
  if (!Array.isArray(rawMemberIds))
973
985
  return;
986
+ const revision = knownMembershipRevision(payload.changes.membershipRevision);
987
+ const currentRevision = this.conversationMembershipRevisions.get(payload.conversationId);
988
+ if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
989
+ return;
974
990
  const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
975
991
  const hadPreviousMemberIds = this.conversationMemberIds.has(payload.conversationId);
976
992
  const previousMemberIds = this.conversationMemberIds.get(payload.conversationId) ?? [];
977
993
  const membershipChange = payload.membershipChange
978
994
  ?? (hadPreviousMemberIds ? diffCanonMemberIds(previousMemberIds, memberIds) : null);
979
995
  this.conversationMemberIds.set(payload.conversationId, memberIds);
996
+ if (revision !== undefined)
997
+ this.conversationMembershipRevisions.set(payload.conversationId, revision);
998
+ else
999
+ this.conversationMembershipRevisions.delete(payload.conversationId);
980
1000
  if (membershipChange) {
981
1001
  this.pendingMembershipChanges.set(payload.conversationId, membershipChange);
982
1002
  }
983
1003
  if (this.agentId && !memberIds.includes(this.agentId)) {
984
1004
  this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
1005
+ this.abortActiveTurns(payload.conversationId);
1006
+ this.sessionManager?.dropQueued(payload.conversationId);
1007
+ this.pendingMembershipChanges.delete(payload.conversationId);
985
1008
  }
986
1009
  else {
987
1010
  this.rememberConversationId(payload.conversationId);
@@ -1254,7 +1277,53 @@ export class CanonAgent {
1254
1277
  async executeHandler(conversationId, messages, session, provenanceByMessageId) {
1255
1278
  if (!this.handler)
1256
1279
  return;
1280
+ // Message rediscovery can precede the SSE roster snapshot after admission.
1281
+ // Refresh before trusting cached exclusion, accepting queued input, or
1282
+ // 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;
1299
+ }
1300
+ }
1301
+ let conversation = conversations.find((c) => c.id === conversationId);
1302
+ if (!conversation)
1303
+ 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
+ else {
1317
+ this.rememberConversationMembers([conversation]);
1318
+ }
1319
+ const currentMembers = this.conversationMemberIds.get(conversationId);
1320
+ if (currentMembers && this.agentId && !currentMembers.includes(this.agentId))
1321
+ return;
1322
+ this.rememberConversationId(conversationId);
1257
1323
  await this.markQueuedMessagesAccepted(conversationId, messages);
1324
+ const acceptedMembers = this.conversationMemberIds.get(conversationId);
1325
+ if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId))
1326
+ return;
1258
1327
  const turnId = randomUUID();
1259
1328
  const turnOpenedAt = Date.now();
1260
1329
  let turnState = 'thinking';
@@ -1316,19 +1385,14 @@ export class CanonAgent {
1316
1385
  : {}),
1317
1386
  })).catch(() => { });
1318
1387
  };
1319
- // Quiet mode is resolved HERE, once, and carried for the whole turn.
1320
- //
1321
- // It has to be here because everything downstream is already too late: the
1322
- // controller's mode is fixed at construction two lines below, the
1323
- // `'Thinking...'` seed lands before that, and the conversation fetch that
1324
- // would otherwise name the conversation type is ~30 lines further down,
1325
- // inside the try. Provenance already carries the type — the stream service
1326
- // stamps it on every `message.created` frame — so the answer is available
1327
- // before the first publish of any kind.
1328
- //
1329
- // Re-deriving it mid-turn is what must never happen: a turn that started
1330
- // quiet and finished verbose would emit a trail nothing narrated.
1331
- const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation?.type);
1388
+ // Resolve once from the refreshed roster before constructing the output
1389
+ // controller or publishing the first thinking seed. Keep it fixed for the
1390
+ // turn so the final trail agrees with what was actually narrated.
1391
+ const inboundProvenance = provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation;
1392
+ const inboundRoster = this.conversationMemberIds.get(conversationId) ?? inboundProvenance?.memberCount;
1393
+ const inboundConversationType = inboundRoster != null
1394
+ ? resolveConversationPolicyScope(inboundRoster)
1395
+ : normalizeTurnVerbosityConversationType(inboundProvenance?.type);
1332
1396
  const turnVerbosity = resolveTurnVerbosity({
1333
1397
  configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
1334
1398
  conversationType: inboundConversationType,
@@ -1412,12 +1476,6 @@ export class CanonAgent {
1412
1476
  if (this.sessionManager && session) {
1413
1477
  this.sessionManager.seedHistory(conversationId, history);
1414
1478
  }
1415
- // Get conversation info
1416
- const conversations = await this.apiClient.getConversations();
1417
- this.rememberConversationMembers(conversations);
1418
- const conversation = conversations.find((c) => c.id === conversationId);
1419
- if (!conversation)
1420
- return;
1421
1479
  // Build reply functions
1422
1480
  const replyFinal = async (text, options) => {
1423
1481
  throwIfAborted();
@@ -1615,7 +1673,7 @@ export class CanonAgent {
1615
1673
  ? resolveRuntimeProvenance({
1616
1674
  provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
1617
1675
  conversationId,
1618
- conversationType: conversation.type,
1676
+ conversationType: resolveConversationPolicyScope(conversation.memberIds),
1619
1677
  memberCount: conversation.memberIds.length,
1620
1678
  senderId: latestMessage.senderId,
1621
1679
  senderName: latestMessage.senderName ?? latestMessage.senderId,
@@ -1628,7 +1686,7 @@ export class CanonAgent {
1628
1686
  })
1629
1687
  : resolveRuntimeProvenance({
1630
1688
  conversationId,
1631
- conversationType: conversation.type,
1689
+ conversationType: resolveConversationPolicyScope(conversation.memberIds),
1632
1690
  memberCount: conversation.memberIds.length,
1633
1691
  senderId: '',
1634
1692
  senderType: 'human',
@@ -1663,7 +1721,7 @@ export class CanonAgent {
1663
1721
  content: latestMessage ? renderCanonHostInboundContent(latestMessage) : '[Empty message]',
1664
1722
  conversationId,
1665
1723
  participantContext: {
1666
- conversationType: conversation.type,
1724
+ conversationType: resolveConversationPolicyScope(conversation.memberIds),
1667
1725
  memberCount: conversation.memberIds.length,
1668
1726
  senderType: latestMessage?.senderType ?? provenance.sender.type,
1669
1727
  senderName: latestMessage?.senderName
package/dist/media.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface UploadMediaFileOptions {
22
22
  signal?: AbortSignal;
23
23
  }
24
24
  export interface ReplyWithFileOptions extends Omit<SendMessageOptions, 'attachments' | 'contentType'>, UploadMediaFileOptions {
25
+ /**
26
+ * Optional local policy check after upload/finalization, immediately before
27
+ * publishing the attachment. Returning false withholds the message. Automatic
28
+ * artifact routing uses this to recheck its current audience.
29
+ */
30
+ canPublish?: () => boolean;
25
31
  }
26
32
  export interface MaterializedCanonAttachment extends MediaAttachment {
27
33
  index: number;
package/dist/media.js CHANGED
@@ -396,7 +396,7 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
396
396
  return uploaded;
397
397
  }
398
398
  export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
399
- const { fileName, mimeType, durationMs, fetchImpl, signal, ...sendOptions } = options ?? {};
399
+ const { fileName, mimeType, durationMs, fetchImpl, signal, canPublish, ...sendOptions } = options ?? {};
400
400
  const uploaded = await uploadMediaFile(client, conversationId, filePath, {
401
401
  ...(fileName ? { fileName } : {}),
402
402
  ...(mimeType ? { mimeType } : {}),
@@ -405,6 +405,9 @@ export async function sendMediaFileMessage(client, conversationId, filePath, tex
405
405
  ...(signal ? { signal } : {}),
406
406
  });
407
407
  signal?.throwIfAborted();
408
+ if (canPublish && !canPublish()) {
409
+ throw new Error('Canon media publication withheld by current routing policy');
410
+ }
408
411
  return client.sendMessage(conversationId, text, {
409
412
  ...sendOptions,
410
413
  contentType: uploaded.attachment.kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "10.3.0",
3
+ "version": "10.4.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",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^12.4.0"
31
+ "@canonmsg/core": "^12.5.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"