@canonmsg/agent-sdk 1.4.0 → 1.5.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
@@ -130,6 +130,7 @@ Current rules of thumb:
130
130
  - `selectionPolicy: 'required_explicit'` means Canon should require the user to make a choice instead of silently inheriting a default
131
131
  - `workspaceRoots` and `writableRoots` document allowed roots and let Canon group project choices. Canon still stores the selected concrete `workspaceId`; it does not send arbitrary root-relative paths to generic SDK agents.
132
132
  - Publishing a descriptor does not automatically make your SDK agent enforce those controls. If you advertise model, workspace, execution mode, or runtime-native controls, your runtime must actually read and apply the stored config.
133
+ - Message handlers receive `ctx.provenance`, a Canon-computed sender/conversation context for the latest inbound message in the batch. Use it when your runtime wants owner-only tools, group mention policy, or self-context-aware behavior; Canon does not impose a sandbox on SDK agents.
133
134
 
134
135
  ## Delivery Modes
135
136
 
@@ -42,6 +42,9 @@ export declare class CanonAgent {
42
42
  private readonly reachOutInFlight;
43
43
  private agentId;
44
44
  private agentContext;
45
+ private approvalManager;
46
+ private approvalManagerAgentId;
47
+ private approvalManagerOwnerId;
45
48
  private cachedConversationIds;
46
49
  private running;
47
50
  private runtimeHeartbeatTimer;
@@ -49,9 +52,12 @@ export declare class CanonAgent {
49
52
  private readonly lastSeenSignal;
50
53
  private readonly primitiveRequestDedupe;
51
54
  private readonly activeAbortControllers;
55
+ private readonly activeTurns;
52
56
  private readonly conversationMemberIds;
53
57
  private readonly pendingMembershipChanges;
54
58
  constructor(options: CanonAgentOptions);
59
+ private ensureApprovalManager;
60
+ private filterApprovalReplyMessages;
55
61
  on(event: 'message', handler: MessageHandler): void;
56
62
  on(event: 'contactRequest', handler: ContactRequestHandler): void;
57
63
  on(event: 'contactApproved', handler: ContactRequestHandler): void;
@@ -128,6 +134,8 @@ export declare class CanonAgent {
128
134
  private clearRuntimePrimitiveRequest;
129
135
  private prunePrimitiveRequestDedupe;
130
136
  private handleRuntimeSignal;
137
+ private firstActiveTurn;
138
+ private publishAcceptedRuntimeSignal;
131
139
  private abortActiveTurns;
132
140
  private resolveBatchDeliveryIntent;
133
141
  private notifyMessageInterrupt;
@@ -1,8 +1,8 @@
1
- import { CanonClient, buildCanonGroupContext, createRuntimeStatePublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveMessageActiveSelfContextId, selectActiveSelfContexts, } from '@canonmsg/core';
1
+ import { ApprovalManager, CanonClient, buildCanonGroupContext, createRuntimeStatePublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, } from '@canonmsg/core';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
5
- import { materializeMessageMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
5
+ import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
6
6
  import { SessionManager } from './session-manager.js';
7
7
  const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
8
8
  const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
@@ -168,6 +168,19 @@ function normalizeRuntimeActivityItem(item) {
168
168
  updatedAt: item.updatedAt || Date.now(),
169
169
  };
170
170
  }
171
+ function createTurnAbortError() {
172
+ const error = new Error('Canon turn was interrupted before reply delivery.');
173
+ error.name = 'AbortError';
174
+ return error;
175
+ }
176
+ function isAbortLikeError(error) {
177
+ if (!error || typeof error !== 'object')
178
+ return false;
179
+ const record = error;
180
+ if (record.name === 'AbortError' || record.code === 'ABORT_ERR')
181
+ return true;
182
+ return typeof record.message === 'string' && /\babort(?:ed)?\b/i.test(record.message);
183
+ }
171
184
  export class CanonAgent {
172
185
  options;
173
186
  apiClient;
@@ -192,6 +205,9 @@ export class CanonAgent {
192
205
  reachOutInFlight = new Map();
193
206
  agentId = null;
194
207
  agentContext = null;
208
+ approvalManager = null;
209
+ approvalManagerAgentId = null;
210
+ approvalManagerOwnerId = null;
195
211
  cachedConversationIds = [];
196
212
  running = false;
197
213
  runtimeHeartbeatTimer = null;
@@ -199,6 +215,7 @@ export class CanonAgent {
199
215
  lastSeenSignal = new Map();
200
216
  primitiveRequestDedupe = new Map();
201
217
  activeAbortControllers = new Map();
218
+ activeTurns = new Map();
202
219
  conversationMemberIds = new Map();
203
220
  pendingMembershipChanges = new Map();
204
221
  constructor(options) {
@@ -243,6 +260,47 @@ export class CanonAgent {
243
260
  }
244
261
  }
245
262
  }
263
+ ensureApprovalManager(agentContext = this.agentContext) {
264
+ const agentId = agentContext?.agentId || this.agentId;
265
+ const ownerId = agentContext?.ownerId;
266
+ if (!agentId || !ownerId)
267
+ return null;
268
+ if (this.approvalManager
269
+ && this.approvalManagerAgentId === agentId
270
+ && this.approvalManagerOwnerId === ownerId) {
271
+ return this.approvalManager;
272
+ }
273
+ if (this.approvalManager?.pendingCount) {
274
+ return this.approvalManager;
275
+ }
276
+ this.approvalManager?.dispose();
277
+ this.approvalManager = new ApprovalManager(this.apiClient, agentId, ownerId);
278
+ this.approvalManagerAgentId = agentId;
279
+ this.approvalManagerOwnerId = ownerId;
280
+ return this.approvalManager;
281
+ }
282
+ filterApprovalReplyMessages(conversationId, messages) {
283
+ const manager = this.ensureApprovalManager();
284
+ if (!manager) {
285
+ return messages.filter((message) => {
286
+ const metadata = message.metadata;
287
+ return !(metadata && typeof metadata === 'object' && !Array.isArray(metadata)
288
+ && metadata.type === 'approval_reply');
289
+ });
290
+ }
291
+ return messages.filter((message) => {
292
+ const metadata = message.metadata;
293
+ const metadataRecord = metadata && typeof metadata === 'object' && !Array.isArray(metadata)
294
+ ? metadata
295
+ : null;
296
+ const consumed = manager.handleMessage(conversationId, {
297
+ senderId: message.senderId,
298
+ ...(typeof message.text === 'string' ? { text: message.text } : {}),
299
+ ...(metadataRecord ? { metadata: metadataRecord } : {}),
300
+ });
301
+ return !consumed && metadataRecord?.type !== 'approval_reply';
302
+ });
303
+ }
246
304
  on(event, handler) {
247
305
  if (event === 'message') {
248
306
  this.handler = handler;
@@ -397,9 +455,9 @@ export class CanonAgent {
397
455
  this.agentId = agentId;
398
456
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
399
457
  // 2. Wire debouncer to handler
400
- this.debouncer.setCallback(async (conversationId, messages) => {
458
+ this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
401
459
  this.rememberConversationId(conversationId);
402
- await this.handleMessages(conversationId, messages);
460
+ await this.handleMessages(conversationId, messages, provenanceByMessageId);
403
461
  });
404
462
  // 3. Fetch conversations (used for delivery mode + session state)
405
463
  let conversations = [];
@@ -423,6 +481,7 @@ export class CanonAgent {
423
481
  // 3b. Fetch agent context (identity, owner, access level)
424
482
  try {
425
483
  this.agentContext = await this.apiClient.getAgentMe();
484
+ this.ensureApprovalManager(this.agentContext);
426
485
  }
427
486
  catch {
428
487
  console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
@@ -448,6 +507,7 @@ export class CanonAgent {
448
507
  const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
449
508
  rtm.setOnAgentContext((ctx) => {
450
509
  this.agentContext = ctx;
510
+ this.ensureApprovalManager(ctx);
451
511
  });
452
512
  rtm.setContactRequestHandlers({
453
513
  onContactRequest: (request) => {
@@ -832,6 +892,7 @@ export class CanonAgent {
832
892
  await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
833
893
  return;
834
894
  }
895
+ const activeTurn = this.firstActiveTurn(conversationId);
835
896
  const abortSignal = this.abortActiveTurns(conversationId);
836
897
  const droppedMessages = signal === 'new_session'
837
898
  ? this.sessionManager?.resetSession(conversationId) ?? []
@@ -844,6 +905,10 @@ export class CanonAgent {
844
905
  return Promise.resolve();
845
906
  return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
846
907
  }));
908
+ await this.publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, {
909
+ hasActiveTurn: Boolean(abortSignal),
910
+ droppedCount: droppedMessages.length,
911
+ });
847
912
  await Promise.resolve(handler?.({
848
913
  conversationId,
849
914
  signal: signal,
@@ -855,6 +920,39 @@ export class CanonAgent {
855
920
  });
856
921
  await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
857
922
  }
923
+ firstActiveTurn(conversationId) {
924
+ const turns = this.activeTurns.get(conversationId);
925
+ if (!turns || turns.size === 0)
926
+ return null;
927
+ return turns.values().next().value ?? null;
928
+ }
929
+ async publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, outcome) {
930
+ if (!this.agentId)
931
+ return;
932
+ const shouldPublishInterrupted = signal === 'interrupt'
933
+ || signal === 'stop_and_drop'
934
+ || outcome.hasActiveTurn
935
+ || outcome.droppedCount > 0;
936
+ const runtimeState = shouldPublishInterrupted
937
+ ? this.createRuntimeStatePublisher()
938
+ : null;
939
+ if (runtimeState) {
940
+ await Promise.resolve(runtimeState.writeTurnState(conversationId, {
941
+ turnId: activeTurn?.turnId ?? null,
942
+ state: 'interrupted',
943
+ queueDepth: this.sessionManager?.getQueueDepth(conversationId) ?? 0,
944
+ currentSpeakerId: this.agentId,
945
+ activeMessageIds: activeTurn?.activeMessageIds ?? [],
946
+ capabilities: this.buildRuntimeCapabilities(),
947
+ ...(activeTurn?.openedAt ? { openedAt: activeTurn.openedAt } : {}),
948
+ completedAt: { '.sv': 'timestamp' },
949
+ })).catch(() => { });
950
+ }
951
+ await Promise.all([
952
+ this.apiClient.clearStreaming(conversationId).catch(() => { }),
953
+ this.apiClient.setTyping(conversationId, false).catch(() => { }),
954
+ ]);
955
+ }
858
956
  abortActiveTurns(conversationId) {
859
957
  const controllers = this.activeAbortControllers.get(conversationId);
860
958
  if (!controllers || controllers.size === 0)
@@ -898,7 +996,12 @@ export class CanonAgent {
898
996
  }
899
997
  return publisher;
900
998
  }
901
- async handleMessages(conversationId, messages) {
999
+ async handleMessages(conversationId, messages, provenanceByMessageId) {
1000
+ const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
1001
+ if (actionableMessages.length === 0) {
1002
+ return;
1003
+ }
1004
+ messages = actionableMessages;
902
1005
  if (!this.handler) {
903
1006
  console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
904
1007
  return;
@@ -909,14 +1012,14 @@ export class CanonAgent {
909
1012
  await this.notifyMessageInterrupt(conversationId, abortSignal);
910
1013
  if (this.sessionManager) {
911
1014
  await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
912
- await this.executeHandler(conversationId, newMessages, session);
1015
+ await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId);
913
1016
  }, { toFront: shouldInterrupt });
914
1017
  }
915
1018
  else {
916
- await this.executeHandler(conversationId, messages);
1019
+ await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId);
917
1020
  }
918
1021
  }
919
- async executeHandler(conversationId, messages, session) {
1022
+ async executeHandler(conversationId, messages, session, provenanceByMessageId) {
920
1023
  if (!this.handler)
921
1024
  return;
922
1025
  const turnId = randomUUID();
@@ -927,9 +1030,21 @@ export class CanonAgent {
927
1030
  const runtimeState = this.createRuntimeStatePublisher();
928
1031
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
929
1032
  const abortController = new AbortController();
1033
+ const throwIfAborted = () => {
1034
+ if (abortController.signal.aborted) {
1035
+ throw createTurnAbortError();
1036
+ }
1037
+ };
930
1038
  const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
931
1039
  activeControllers.add(abortController);
932
1040
  this.activeAbortControllers.set(conversationId, activeControllers);
1041
+ const activeTurns = this.activeTurns.get(conversationId) ?? new Map();
1042
+ activeTurns.set(abortController, {
1043
+ turnId,
1044
+ openedAt: turnOpenedAt,
1045
+ activeMessageIds: messages.map((message) => message.id).filter(Boolean),
1046
+ });
1047
+ this.activeTurns.set(conversationId, activeTurns);
933
1048
  const writeTurn = async (state) => {
934
1049
  if (!runtimeState || !agentId)
935
1050
  return;
@@ -947,6 +1062,7 @@ export class CanonAgent {
947
1062
  })).catch(() => { });
948
1063
  };
949
1064
  const setLiveState = async (state, text, streamingStatus) => {
1065
+ throwIfAborted();
950
1066
  await writeTurn(state);
951
1067
  if (streamingStatus) {
952
1068
  try {
@@ -1007,10 +1123,12 @@ export class CanonAgent {
1007
1123
  return;
1008
1124
  // Build reply functions
1009
1125
  const replyFinal = async (text, options) => {
1126
+ throwIfAborted();
1010
1127
  try {
1011
1128
  await this.apiClient.setTyping(conversationId, true, 'typing');
1012
1129
  }
1013
1130
  catch { }
1131
+ throwIfAborted();
1014
1132
  const sendOptions = withActiveSelfContext(options);
1015
1133
  const result = await this.apiClient.sendMessage(conversationId, text, {
1016
1134
  ...sendOptions,
@@ -1029,10 +1147,12 @@ export class CanonAgent {
1029
1147
  return result;
1030
1148
  };
1031
1149
  const replyProgress = async (text, options) => {
1150
+ throwIfAborted();
1032
1151
  await setLiveState('streaming', text, 'streaming');
1033
1152
  if (!options?.durable) {
1034
1153
  return { turnId, durable: false, messageId: null };
1035
1154
  }
1155
+ throwIfAborted();
1036
1156
  const { durable: _durable, ...sendOptions } = options;
1037
1157
  const sendOptionsWithContext = withActiveSelfContext(sendOptions);
1038
1158
  const result = await this.apiClient.sendMessage(conversationId, text, {
@@ -1054,6 +1174,9 @@ export class CanonAgent {
1054
1174
  }
1055
1175
  }
1056
1176
  const latestMessage = hydratedMessages[hydratedMessages.length - 1] ?? null;
1177
+ let replyContext = latestMessage
1178
+ ? resolveCanonReplyContext({ message: latestMessage, messages: history })
1179
+ : null;
1057
1180
  const resolvedActiveSelfContextId = resolveMessageActiveSelfContextId({
1058
1181
  messageId: latestMessage?.id,
1059
1182
  activeSelfContextIdByMessageId: page.activeSelfContextIdByMessageId,
@@ -1075,6 +1198,44 @@ export class CanonAgent {
1075
1198
  inboundPolicy: 'approval-required',
1076
1199
  groupJoinPolicy: 'approval-required',
1077
1200
  };
1201
+ const provenance = latestMessage
1202
+ ? resolveRuntimeProvenance({
1203
+ provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
1204
+ conversationId,
1205
+ conversationType: conversation.type,
1206
+ memberCount: conversation.memberIds.length,
1207
+ senderId: latestMessage.senderId,
1208
+ senderName: latestMessage.senderName ?? latestMessage.senderId,
1209
+ senderType: latestMessage.senderType,
1210
+ isOwner: latestMessage.isOwner,
1211
+ agentId: agent.agentId,
1212
+ mentions: latestMessage.mentions,
1213
+ activeSelfContextId,
1214
+ selfContexts,
1215
+ })
1216
+ : resolveRuntimeProvenance({
1217
+ conversationId,
1218
+ conversationType: conversation.type,
1219
+ memberCount: conversation.memberIds.length,
1220
+ senderId: '',
1221
+ senderType: 'human',
1222
+ isOwner: false,
1223
+ agentId: agent.agentId,
1224
+ activeSelfContextId,
1225
+ selfContexts,
1226
+ });
1227
+ if (replyContext?.found && replyContext.attachments?.length) {
1228
+ try {
1229
+ const materializedReply = await materializeReplyContextMedia(replyContext, {
1230
+ agentId: agent.agentId,
1231
+ conversationId,
1232
+ });
1233
+ replyContext = materializedReply.replyContext;
1234
+ }
1235
+ catch (error) {
1236
+ console.error(`[canon-sdk] Failed to materialize reply context media for ${conversationId}:`, error instanceof Error ? error.message : error);
1237
+ }
1238
+ }
1078
1239
  const membershipChange = this.pendingMembershipChanges.get(conversationId) ?? null;
1079
1240
  this.pendingMembershipChanges.delete(conversationId);
1080
1241
  const groupContext = this.buildGroupContext({
@@ -1110,12 +1271,60 @@ export class CanonAgent {
1110
1271
  ...(options ?? {}),
1111
1272
  sourceConversationId: conversationId,
1112
1273
  });
1274
+ const requestApproval = async (request) => {
1275
+ throwIfAborted();
1276
+ const manager = this.ensureApprovalManager(agent);
1277
+ if (!manager) {
1278
+ return { decision: 'deny' };
1279
+ }
1280
+ shouldPersistTurnState = true;
1281
+ try {
1282
+ try {
1283
+ await this.apiClient.clearStreaming(conversationId);
1284
+ }
1285
+ catch { }
1286
+ await writeTurn('waiting_input');
1287
+ try {
1288
+ await this.apiClient.setTyping(conversationId, false);
1289
+ }
1290
+ catch { }
1291
+ const result = await manager.requestApproval(conversationId, request.toolName, request.toolInput ?? {}, {
1292
+ riskLevel: request.riskLevel,
1293
+ risk: request.risk,
1294
+ category: request.category,
1295
+ runtimeId: request.runtimeId,
1296
+ turnId: request.turnId ?? turnId,
1297
+ native: request.native,
1298
+ toolSummary: request.toolSummary,
1299
+ details: request.details,
1300
+ ignoreSessionRules: request.ignoreSessionRules,
1301
+ allowSessionRule: request.allowSessionRule,
1302
+ });
1303
+ throwIfAborted();
1304
+ shouldPersistTurnState = false;
1305
+ try {
1306
+ await this.apiClient.setTyping(conversationId, true, 'thinking');
1307
+ }
1308
+ catch { }
1309
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1310
+ return result;
1311
+ }
1312
+ catch (error) {
1313
+ if (abortController.signal.aborted || isAbortLikeError(error)) {
1314
+ throw error;
1315
+ }
1316
+ shouldPersistTurnState = false;
1317
+ return { decision: 'deny' };
1318
+ }
1319
+ };
1113
1320
  const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, options);
1114
1321
  const replyWithFile = async (filePath, text = '', options) => {
1322
+ throwIfAborted();
1115
1323
  try {
1116
1324
  await this.apiClient.setTyping(conversationId, true, 'typing');
1117
1325
  }
1118
1326
  catch { }
1327
+ throwIfAborted();
1119
1328
  try {
1120
1329
  const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
1121
1330
  ...(options?.replyTo ? { replyTo: options.replyTo } : {}),
@@ -1147,9 +1356,11 @@ export class CanonAgent {
1147
1356
  }
1148
1357
  };
1149
1358
  // Invoke handler
1359
+ throwIfAborted();
1150
1360
  await this.handler({
1151
1361
  messages: hydratedMessages,
1152
1362
  history,
1363
+ replyContext,
1153
1364
  conversationId,
1154
1365
  conversation,
1155
1366
  ...(groupContext ? { groupContext } : {}),
@@ -1166,6 +1377,8 @@ export class CanonAgent {
1166
1377
  agent,
1167
1378
  activeSelfContextId,
1168
1379
  selfContexts,
1380
+ provenance,
1381
+ requestApproval,
1169
1382
  abortSignal: abortController.signal,
1170
1383
  media: {
1171
1384
  materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
@@ -1236,6 +1449,10 @@ export class CanonAgent {
1236
1449
  }
1237
1450
  }
1238
1451
  catch (err) {
1452
+ if (abortController.signal.aborted || isAbortLikeError(err)) {
1453
+ await writeTurn('interrupted');
1454
+ return;
1455
+ }
1239
1456
  console.error(`[canon-sdk] Handler error for ${conversationId}:`, err);
1240
1457
  await writeTurn('interrupted');
1241
1458
  }
@@ -1245,6 +1462,11 @@ export class CanonAgent {
1245
1462
  if (activeControllers?.size === 0) {
1246
1463
  this.activeAbortControllers.delete(conversationId);
1247
1464
  }
1465
+ const activeTurns = this.activeTurns.get(conversationId);
1466
+ activeTurns?.delete(abortController);
1467
+ if (activeTurns?.size === 0) {
1468
+ this.activeTurns.delete(conversationId);
1469
+ }
1248
1470
  clearInterval(thinkingKeepalive);
1249
1471
  // Always clear typing when done
1250
1472
  try {
@@ -1,13 +1,14 @@
1
- import type { CanonMessage } from '@canonmsg/core';
1
+ import type { CanonMessage, CanonRuntimeProvenance } from '@canonmsg/core';
2
2
  export declare class Debouncer {
3
3
  private debounceMs;
4
4
  private pending;
5
+ private provenanceByMessageId;
5
6
  private timers;
6
7
  private orderedFlags;
7
8
  private callback;
8
9
  constructor(debounceMs: number);
9
- setCallback(cb: (conversationId: string, messages: CanonMessage[]) => void): void;
10
- add(conversationId: string, message: CanonMessage): void;
10
+ setCallback(cb: (conversationId: string, messages: CanonMessage[], provenanceByMessageId: ReadonlyMap<string, CanonRuntimeProvenance>) => void): void;
11
+ add(conversationId: string, message: CanonMessage, provenance?: CanonRuntimeProvenance | null): void;
11
12
  private flush;
12
13
  destroy(): void;
13
14
  }
package/dist/debouncer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export class Debouncer {
2
2
  debounceMs;
3
3
  pending = new Map();
4
+ provenanceByMessageId = new Map();
4
5
  timers = new Map();
5
6
  // Track whether each conversation's pending messages are already in sorted
6
7
  // order so we can skip the sort on flush when possible (#10)
@@ -12,8 +13,11 @@ export class Debouncer {
12
13
  setCallback(cb) {
13
14
  this.callback = cb;
14
15
  }
15
- add(conversationId, message) {
16
+ add(conversationId, message, provenance) {
16
17
  const existing = this.pending.get(conversationId) || [];
18
+ if (provenance) {
19
+ this.provenanceByMessageId.set(message.id, provenance);
20
+ }
17
21
  // Deduplicate by message ID
18
22
  if (!existing.some((m) => m.id === message.id)) {
19
23
  if (existing.length === 0) {
@@ -50,7 +54,15 @@ export class Debouncer {
50
54
  if (!isOrdered) {
51
55
  messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
52
56
  }
53
- this.callback(conversationId, messages);
57
+ const provenanceByMessageId = new Map();
58
+ for (const message of messages) {
59
+ const provenance = this.provenanceByMessageId.get(message.id);
60
+ if (provenance) {
61
+ provenanceByMessageId.set(message.id, provenance);
62
+ this.provenanceByMessageId.delete(message.id);
63
+ }
64
+ }
65
+ this.callback(conversationId, messages, provenanceByMessageId);
54
66
  }
55
67
  }
56
68
  destroy() {
@@ -59,6 +71,7 @@ export class Debouncer {
59
71
  }
60
72
  this.timers.clear();
61
73
  this.pending.clear();
74
+ this.provenanceByMessageId.clear();
62
75
  this.orderedFlags.clear();
63
76
  }
64
77
  }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export type { AgentContactsAPI, AgentUsersAPI } from './canon-agent.js';
3
- export { CanonApiError, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, } from '@canonmsg/core';
4
- export type { CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, } from '@canonmsg/core';
3
+ export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, parseTextApprovalReply, redactSecrets, } from '@canonmsg/core';
4
+ export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, SessionRule, } from '@canonmsg/core';
5
5
  export { SessionManager } from './session-manager.js';
6
- export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
7
- export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
6
+ export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
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, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
10
- export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
9
+ export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonReplyContext, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
10
+ export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
- export { CanonApiError, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, } from '@canonmsg/core';
2
+ export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, parseTextApprovalReply, redactSecrets, } from '@canonmsg/core';
3
3
  export { SessionManager } from './session-manager.js';
4
- export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
4
+ export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
package/dist/media.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CanonClient, type CanonMessage, type MediaAttachment, type SendMessageOptions } from '@canonmsg/core';
1
+ import { CanonClient, type CanonReplyContext, type CanonMessage, type MediaAttachment, type SendMessageOptions } from '@canonmsg/core';
2
2
  export interface MaterializeMediaOptions {
3
3
  agentId: string;
4
4
  conversationId: string;
@@ -21,6 +21,10 @@ export interface MaterializedCanonAttachment extends MediaAttachment {
21
21
  conversationId: string;
22
22
  messageId: string;
23
23
  }
24
+ export interface MaterializedCanonReplyContext {
25
+ replyContext: CanonReplyContext | null;
26
+ materialized: MaterializedCanonAttachment[];
27
+ }
24
28
  /**
25
29
  * Anthropic `image` content blocks only accept these MIME types for
26
30
  * base64 sources. Anything outside this set must either be re-encoded or
@@ -46,6 +50,7 @@ export declare function materializeAttachment(attachment: MediaAttachment, optio
46
50
  index?: number;
47
51
  }): Promise<MaterializedCanonAttachment>;
48
52
  export declare function materializeMessageMedia(message: Pick<CanonMessage, 'id' | 'attachments'>, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonAttachment[]>;
53
+ export declare function materializeReplyContextMedia(replyContext: CanonReplyContext | null, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonReplyContext>;
49
54
  export declare function inferUploadMimeType(filePath: string, overrideMimeType?: string): string;
50
55
  export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<{
51
56
  url: string;
package/dist/media.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
2
  import { basename, dirname, extname, join } from 'node:path';
3
- import { CANON_DIR, } from '@canonmsg/core';
3
+ import { CANON_DIR, renderCanonHostInboundContent, } from '@canonmsg/core';
4
4
  const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
5
5
  'image/jpeg',
6
6
  'image/png',
@@ -141,6 +141,31 @@ export async function materializeMessageMedia(message, options) {
141
141
  index,
142
142
  })));
143
143
  }
144
+ export async function materializeReplyContextMedia(replyContext, options) {
145
+ if (!replyContext?.found || !replyContext.attachments?.length) {
146
+ return { replyContext, materialized: [] };
147
+ }
148
+ const materialized = (await Promise.all(replyContext.attachments.map((attachment, index) => attachment.url
149
+ ? materializeAttachment(attachment, {
150
+ ...options,
151
+ messageId: replyContext.messageId,
152
+ index,
153
+ })
154
+ : Promise.resolve(null)))).filter((attachment) => attachment !== null);
155
+ return {
156
+ replyContext: {
157
+ ...replyContext,
158
+ body: renderCanonHostInboundContent({
159
+ text: replyContext.text,
160
+ contentType: replyContext.contentType,
161
+ attachments: replyContext.attachments,
162
+ contactCard: replyContext.contactCard,
163
+ senderType: replyContext.senderType ?? undefined,
164
+ }, materialized),
165
+ },
166
+ materialized,
167
+ };
168
+ }
144
169
  export function inferUploadMimeType(filePath, overrideMimeType) {
145
170
  if (overrideMimeType)
146
171
  return overrideMimeType;
package/dist/realtime.js CHANGED
@@ -52,7 +52,7 @@ export class RealtimeManager {
52
52
  ...(m.contactCard ? { contactCard: m.contactCard } : {}),
53
53
  ...(m.metadata ? { metadata: m.metadata } : {}),
54
54
  };
55
- this.debouncer.add(payload.conversationId, message);
55
+ this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
56
56
  },
57
57
  onAgentContext: (ctx) => {
58
58
  this.onAgentContext?.(ctx);
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonMessage, CanonConversation, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, SendMessageOptions, SendContextualSelfContextInput, SessionConfig } from '@canonmsg/core';
1
+ export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, SendMessageOptions, SendContextualSelfContextInput, SessionConfig } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -34,9 +34,33 @@ export interface TurnController {
34
34
  setTool: (text: string) => Promise<void>;
35
35
  setWaitingInput: (text?: string) => Promise<void>;
36
36
  }
37
+ export interface RuntimeApprovalRequest {
38
+ /** Native runtime/tool/action name that is asking for permission. */
39
+ toolName: string;
40
+ /** Structured native input. Raw values are summarized/redacted before storage. */
41
+ toolInput?: Record<string, unknown>;
42
+ /** Optional pre-redacted summary when a runtime already has user-facing copy. */
43
+ toolSummary?: string;
44
+ category?: ApprovalRequestCategory;
45
+ risk?: ApprovalRisk;
46
+ riskLevel?: 'normal' | 'destructive';
47
+ runtimeId?: string;
48
+ turnId?: string;
49
+ native?: ApprovalNativeRequestMetadata;
50
+ details?: ApprovalRequestDetail[];
51
+ /**
52
+ * Ignore in-memory session rules for this approval request. Useful when the
53
+ * action was triggered by someone other than the agent owner.
54
+ */
55
+ ignoreSessionRules?: boolean;
56
+ /** Whether an approval reply may set an in-memory session rule. */
57
+ allowSessionRule?: boolean;
58
+ }
37
59
  export interface MessageHandlerContext {
38
60
  messages: CanonMessage[];
39
61
  history: CanonMessage[];
62
+ /** Resolved message/media content for the latest swipe-reply target, if any. */
63
+ replyContext: CanonReplyContext | null;
40
64
  conversationId: string;
41
65
  conversation: CanonConversation;
42
66
  /** Lightweight group awareness, present for group conversations. */
@@ -77,6 +101,14 @@ export interface MessageHandlerContext {
77
101
  activeSelfContextId: string | null;
78
102
  /** Canon-provided private context explaining this agent's cross-session actions. */
79
103
  selfContexts?: import('@canonmsg/core').CanonSelfContext[];
104
+ /** Trusted Canon provenance for the latest inbound message in this handler batch. */
105
+ provenance: CanonRuntimeProvenance;
106
+ /**
107
+ * Ask the conversation owner to approve a native runtime action. This only
108
+ * renders Canon's inline approval card; runtimes must explicitly wait for
109
+ * and enforce the returned decision in their own approval hook.
110
+ */
111
+ requestApproval: (request: RuntimeApprovalRequest) => Promise<ApprovalResult>;
80
112
  /** Canon-managed local media access for the current conversation. */
81
113
  media: {
82
114
  materialize: (message?: CanonMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "1.4.0",
3
+ "version": "1.5.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": "^0.18.0"
31
+ "@canonmsg/core": "^0.19.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"