@canonmsg/agent-sdk 10.2.1 → 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,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/index.d.ts CHANGED
@@ -11,3 +11,11 @@ export type { SessionConfig, Session } from './session-manager.js';
11
11
  export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
12
12
  export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonMessage, CanonConversation, CommunicateInput, CommunicateResult, DirectConversationSelection, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendMessageOptions, CreateGroupOptions, CreateGroupResult, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
13
13
  export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, FinalMessageResult, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ParticipationSuppressedHandler, ProgressMessageOptions, ProgressMessageResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimePlanReviewRequest, RuntimePlanReviewResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
14
+ export { createCanonAttachedSession, createCanonAttachedSessionPublisher } from './attached-session.js';
15
+ export { createCanonWorkSessionHost } from './work-session-host.js';
16
+ export type { CanonWorkSessionHost, CanonWorkSessionHostOptions } from './work-session-host.js';
17
+ export { createFileWorkSessionHostStore } from './work-session-state.js';
18
+ export type { WorkSessionHostStore, WorkSessionHostJournal } from './work-session-state.js';
19
+ export { createFileAttachedSessionStore } from '@canonmsg/core';
20
+ export type { CanonAttachedSession, CanonAttachedSessionConnection, CanonAttachedSessionOptions, CanonAttachedSessionStatus, } from './attached-session.js';
21
+ export type { AttachedNativeSessionAdapter, AttachedSessionBinding, AttachedSessionJournal, AttachedSessionState, AttachedSessionStore, NativeSessionEvent, NativeSessionItem, NativeSessionSnapshot, NativeSubmissionResult, } from '@canonmsg/core';
package/dist/index.js CHANGED
@@ -3,3 +3,7 @@ export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from
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 { SessionManager } from './session-manager.js';
5
5
  export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
6
+ export { createCanonAttachedSession, createCanonAttachedSessionPublisher } from './attached-session.js';
7
+ export { createCanonWorkSessionHost } from './work-session-host.js';
8
+ export { createFileWorkSessionHostStore } from './work-session-state.js';
9
+ export { createFileAttachedSessionStore } from '@canonmsg/core';
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,
@@ -0,0 +1,31 @@
1
+ import { type AttachedSessionBinding, type AttachedSessionStore, type NativeWorkSessionProvider } from '@canonmsg/core';
2
+ import { type CanonAttachedSessionConnection, type CanonAttachedSessionStatus } from './attached-session.js';
3
+ import { type WorkSessionHostStore } from './work-session-state.js';
4
+ export interface CanonWorkSessionHostOptions {
5
+ connection: CanonAttachedSessionConnection;
6
+ /** Stable installation identity. A new random runtime epoch is used each start. */
7
+ hostId: string;
8
+ displayName: string;
9
+ provider: NativeWorkSessionProvider;
10
+ store: WorkSessionHostStore;
11
+ createSessionStore(binding: AttachedSessionBinding): AttachedSessionStore;
12
+ /** Process-wide native audience lock, shared with any standalone attachment CLI. */
13
+ acquireNativeSession(binding: AttachedSessionBinding): (() => void) | Promise<() => void>;
14
+ onStatus?: (event: {
15
+ status: 'available' | 'stopped' | 'session';
16
+ conversationId?: string;
17
+ session?: CanonAttachedSessionStatus;
18
+ }) => void;
19
+ onError?: (error: unknown, operation: string) => void;
20
+ pollIntervalMs?: number;
21
+ heartbeatIntervalMs?: number;
22
+ connectTimeoutMs?: number;
23
+ }
24
+ export interface CanonWorkSessionHost {
25
+ start(): Promise<void>;
26
+ stop(): Promise<void>;
27
+ /** Refresh discovery and process one durable request; serialized with the poll loop. */
28
+ reconcile(): Promise<void>;
29
+ }
30
+ /** One authenticated transport and liveness publisher, with durable per-room native routes. */
31
+ export declare function createCanonWorkSessionHost(options: CanonWorkSessionHostOptions): CanonWorkSessionHost;