@canonmsg/agent-sdk 10.3.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 +7 -1
- package/dist/attached-session.js +55 -9
- package/dist/canon-agent.d.ts +1 -0
- package/dist/canon-agent.js +129 -46
- package/dist/media.d.ts +8 -0
- package/dist/media.js +39 -87
- package/dist/realtime.d.ts +7 -7
- package/dist/realtime.js +51 -65
- package/dist/types.d.ts +12 -1
- package/dist/work-session-host.js +15 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -34,7 +34,13 @@ await agent.start();
|
|
|
34
34
|
npm install @canonmsg/agent-sdk
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
The
|
|
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
|
|
package/dist/attached-session.js
CHANGED
|
@@ -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
|
|
@@ -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;
|
|
@@ -47,6 +49,8 @@ export function createCanonAttachedSession(options) {
|
|
|
47
49
|
const reportedHistory = new Set();
|
|
48
50
|
let publicationFailed = false;
|
|
49
51
|
let historyIncomplete = false;
|
|
52
|
+
let currentConversation = null;
|
|
53
|
+
let currentOwnerId;
|
|
50
54
|
let inputError = null;
|
|
51
55
|
let observedTurnId;
|
|
52
56
|
let turnUpdatedAt = null;
|
|
@@ -80,6 +84,7 @@ export function createCanonAttachedSession(options) {
|
|
|
80
84
|
const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
|
|
81
85
|
const core = createAttachedNativeSession({
|
|
82
86
|
binding, store: options.store, adapter: options.native,
|
|
87
|
+
beforeNativeSubmit: (input) => endpoint.claimInbound(`message:${binding.conversationId}:${input.messageId}`),
|
|
83
88
|
isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
|
|
84
89
|
publisher: {
|
|
85
90
|
prepare(input) {
|
|
@@ -179,10 +184,10 @@ export function createCanonAttachedSession(options) {
|
|
|
179
184
|
void stop().catch((error) => report(error, 'detach'));
|
|
180
185
|
}
|
|
181
186
|
async function verifyMembership() {
|
|
182
|
-
const
|
|
187
|
+
const conversation = await client.getConversation(binding.conversationId);
|
|
183
188
|
if (stopped)
|
|
184
189
|
return false;
|
|
185
|
-
|
|
190
|
+
currentConversation = conversation ?? null;
|
|
186
191
|
if (!conversation?.memberIds.includes(binding.agentId)) {
|
|
187
192
|
detach('conversation-membership-lost');
|
|
188
193
|
return false;
|
|
@@ -226,11 +231,22 @@ export function createCanonAttachedSession(options) {
|
|
|
226
231
|
await notice(payload, inputError);
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
234
|
+
const roster = buildCanonGroupContext({ conversation: currentConversation, messages: [message], agentId: binding.agentId, ownerId: currentOwnerId });
|
|
235
|
+
const rosterLines = roster ? buildCompactGroupContextLines(roster, 'initial') : [];
|
|
229
236
|
const result = await core.submit({
|
|
230
237
|
messageId: message.id,
|
|
231
|
-
text: `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}
|
|
238
|
+
text: [...rosterLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
|
|
232
239
|
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
233
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
|
+
}
|
|
234
250
|
if (stopped)
|
|
235
251
|
return;
|
|
236
252
|
reportedHistory.delete(message.id);
|
|
@@ -370,11 +386,12 @@ export function createCanonAttachedSession(options) {
|
|
|
370
386
|
}
|
|
371
387
|
if (stopped)
|
|
372
388
|
return;
|
|
373
|
-
|
|
374
|
-
const conversation =
|
|
389
|
+
endpoint = await client.getEndpoint();
|
|
390
|
+
const conversation = await client.getConversation(binding.conversationId);
|
|
375
391
|
if (!conversation || !conversation.memberIds.includes(binding.agentId)) {
|
|
376
392
|
throw new Error('The agent is not a member of the specified Canon conversation');
|
|
377
393
|
}
|
|
394
|
+
currentConversation = conversation;
|
|
378
395
|
const initial = await client.getMessagesPage(binding.conversationId, 100);
|
|
379
396
|
if (stopped)
|
|
380
397
|
return;
|
|
@@ -411,9 +428,33 @@ export function createCanonAttachedSession(options) {
|
|
|
411
428
|
});
|
|
412
429
|
}
|
|
413
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();
|
|
414
449
|
const handler = {
|
|
415
|
-
onMessage: (payload) =>
|
|
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
|
+
},
|
|
416
456
|
onAgentContext: (context) => {
|
|
457
|
+
currentOwnerId = context.ownerId;
|
|
417
458
|
if (context.agentId !== binding.agentId) {
|
|
418
459
|
report(new Error('Canon stream context belongs to a different agent'), 'stream-identity');
|
|
419
460
|
detach('stream-identity-mismatch');
|
|
@@ -422,6 +463,10 @@ export function createCanonAttachedSession(options) {
|
|
|
422
463
|
onConversationUpdated: (payload) => {
|
|
423
464
|
if (stopped || payload.conversationId !== binding.conversationId)
|
|
424
465
|
return;
|
|
466
|
+
if (currentConversation) {
|
|
467
|
+
currentConversation = { ...currentConversation, ...payload.changes };
|
|
468
|
+
track(client.rememberConversation(currentConversation), 'conversation-state');
|
|
469
|
+
}
|
|
425
470
|
const members = payload.changes.memberIds;
|
|
426
471
|
if ((Array.isArray(members) && !members.includes(binding.agentId))
|
|
427
472
|
|| payload.membershipChange?.removedMemberIds.includes(binding.agentId)
|
|
@@ -455,7 +500,7 @@ export function createCanonAttachedSession(options) {
|
|
|
455
500
|
unsubscribeStream = options.transport.subscribe(handler);
|
|
456
501
|
else
|
|
457
502
|
stream = new CanonStream({
|
|
458
|
-
|
|
503
|
+
endpoint: await client.getEndpoint(), agentId: binding.agentId, handler,
|
|
459
504
|
});
|
|
460
505
|
publishState(core.getState());
|
|
461
506
|
// CanonStream.start runs for the lifetime of a fetch stream. Do not
|
|
@@ -489,6 +534,7 @@ export function createCanonAttachedSession(options) {
|
|
|
489
534
|
stopCore();
|
|
490
535
|
stopPromise = (async () => {
|
|
491
536
|
await startPromise?.catch(() => { });
|
|
537
|
+
await inbound?.close();
|
|
492
538
|
await cleanup();
|
|
493
539
|
emit({ status: 'stopped' });
|
|
494
540
|
})();
|
package/dist/canon-agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/canon-agent.js
CHANGED
|
@@ -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, 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';
|
|
@@ -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;
|
|
@@ -342,6 +346,7 @@ export class CanonAgent {
|
|
|
342
346
|
historyLimit: 50,
|
|
343
347
|
autoMarkRead: true,
|
|
344
348
|
runtimeControlSurface: 'agent',
|
|
349
|
+
endpoint: {},
|
|
345
350
|
...options,
|
|
346
351
|
environmentId: this.runtimeConnection.environmentId,
|
|
347
352
|
baseUrl: this.runtimeConnection.apiBaseUrl,
|
|
@@ -349,7 +354,9 @@ export class CanonAgent {
|
|
|
349
354
|
rtdbUrl: this.runtimeConnection.rtdbUrl,
|
|
350
355
|
firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
|
|
351
356
|
};
|
|
352
|
-
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
|
+
});
|
|
353
360
|
this.typingSignals = createTypingStatusPublisher({
|
|
354
361
|
setTyping: (conversationId, typing, status) => status
|
|
355
362
|
? this.apiClient.setTyping(conversationId, typing, status)
|
|
@@ -678,7 +685,7 @@ export class CanonAgent {
|
|
|
678
685
|
if (generation !== this.lifecycleGeneration)
|
|
679
686
|
return;
|
|
680
687
|
this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
|
|
681
|
-
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() });
|
|
682
689
|
if (this.voiceEventsEnabled) {
|
|
683
690
|
rtm.setCallHandlers({
|
|
684
691
|
onCallStarted: (payload) => {
|
|
@@ -964,24 +971,43 @@ export class CanonAgent {
|
|
|
964
971
|
}
|
|
965
972
|
rememberConversationMembers(conversations) {
|
|
966
973
|
for (const conversation of conversations) {
|
|
974
|
+
const revision = knownMembershipRevision(conversation.membershipRevision);
|
|
975
|
+
const currentRevision = this.conversationMembershipRevisions.get(conversation.id);
|
|
976
|
+
if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
|
|
977
|
+
continue;
|
|
967
978
|
this.conversationMemberIds.set(conversation.id, [...(conversation.memberIds ?? [])]);
|
|
979
|
+
if (revision !== undefined)
|
|
980
|
+
this.conversationMembershipRevisions.set(conversation.id, revision);
|
|
981
|
+
else
|
|
982
|
+
this.conversationMembershipRevisions.delete(conversation.id);
|
|
968
983
|
}
|
|
969
984
|
}
|
|
970
985
|
handleConversationUpdated(payload) {
|
|
971
986
|
const rawMemberIds = payload.changes.memberIds;
|
|
972
987
|
if (!Array.isArray(rawMemberIds))
|
|
973
988
|
return;
|
|
989
|
+
const revision = knownMembershipRevision(payload.changes.membershipRevision);
|
|
990
|
+
const currentRevision = this.conversationMembershipRevisions.get(payload.conversationId);
|
|
991
|
+
if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
|
|
992
|
+
return;
|
|
974
993
|
const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
|
|
975
994
|
const hadPreviousMemberIds = this.conversationMemberIds.has(payload.conversationId);
|
|
976
995
|
const previousMemberIds = this.conversationMemberIds.get(payload.conversationId) ?? [];
|
|
977
996
|
const membershipChange = payload.membershipChange
|
|
978
997
|
?? (hadPreviousMemberIds ? diffCanonMemberIds(previousMemberIds, memberIds) : null);
|
|
979
998
|
this.conversationMemberIds.set(payload.conversationId, memberIds);
|
|
999
|
+
if (revision !== undefined)
|
|
1000
|
+
this.conversationMembershipRevisions.set(payload.conversationId, revision);
|
|
1001
|
+
else
|
|
1002
|
+
this.conversationMembershipRevisions.delete(payload.conversationId);
|
|
980
1003
|
if (membershipChange) {
|
|
981
1004
|
this.pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
982
1005
|
}
|
|
983
1006
|
if (this.agentId && !memberIds.includes(this.agentId)) {
|
|
984
1007
|
this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
|
|
1008
|
+
this.abortActiveTurns(payload.conversationId);
|
|
1009
|
+
this.sessionManager?.dropQueued(payload.conversationId);
|
|
1010
|
+
this.pendingMembershipChanges.delete(payload.conversationId);
|
|
985
1011
|
}
|
|
986
1012
|
else {
|
|
987
1013
|
this.rememberConversationId(payload.conversationId);
|
|
@@ -1230,12 +1256,26 @@ export class CanonAgent {
|
|
|
1230
1256
|
}
|
|
1231
1257
|
async handleMessages(conversationId, messages, provenanceByMessageId) {
|
|
1232
1258
|
const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
+
}
|
|
1235
1267
|
}
|
|
1268
|
+
if (actionableMessages.length === 0)
|
|
1269
|
+
return;
|
|
1236
1270
|
messages = actionableMessages;
|
|
1237
1271
|
if (!this.handler) {
|
|
1238
|
-
|
|
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.`);
|
|
1239
1279
|
return;
|
|
1240
1280
|
}
|
|
1241
1281
|
const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
|
|
@@ -1254,7 +1294,43 @@ export class CanonAgent {
|
|
|
1254
1294
|
async executeHandler(conversationId, messages, session, provenanceByMessageId) {
|
|
1255
1295
|
if (!this.handler)
|
|
1256
1296
|
return;
|
|
1297
|
+
// Message rediscovery can precede the SSE roster snapshot after admission.
|
|
1298
|
+
// Refresh before trusting cached exclusion, accepting queued input, or
|
|
1299
|
+
// freezing output policy. Reuse this same snapshot for handler context.
|
|
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);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
let conversation;
|
|
1309
|
+
try {
|
|
1310
|
+
conversation = await this.apiClient.getConversation(conversationId);
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
await setInputState('deferred', 'membership-unavailable');
|
|
1314
|
+
console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (!conversation) {
|
|
1318
|
+
await setInputState('settled', 'conversation-unavailable');
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
this.rememberConversationMembers([conversation]);
|
|
1322
|
+
const currentMembers = this.conversationMemberIds.get(conversationId);
|
|
1323
|
+
if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
|
|
1324
|
+
await setInputState('settled', 'membership-lost');
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
this.rememberConversationId(conversationId);
|
|
1257
1328
|
await this.markQueuedMessagesAccepted(conversationId, messages);
|
|
1329
|
+
const acceptedMembers = this.conversationMemberIds.get(conversationId);
|
|
1330
|
+
if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
|
|
1331
|
+
await setInputState('settled', 'membership-lost');
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1258
1334
|
const turnId = randomUUID();
|
|
1259
1335
|
const turnOpenedAt = Date.now();
|
|
1260
1336
|
let turnState = 'thinking';
|
|
@@ -1316,19 +1392,14 @@ export class CanonAgent {
|
|
|
1316
1392
|
: {}),
|
|
1317
1393
|
})).catch(() => { });
|
|
1318
1394
|
};
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
//
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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);
|
|
1395
|
+
// Resolve once from the refreshed roster before constructing the output
|
|
1396
|
+
// controller or publishing the first thinking seed. Keep it fixed for the
|
|
1397
|
+
// turn so the final trail agrees with what was actually narrated.
|
|
1398
|
+
const inboundProvenance = provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation;
|
|
1399
|
+
const inboundRoster = this.conversationMemberIds.get(conversationId) ?? inboundProvenance?.memberCount;
|
|
1400
|
+
const inboundConversationType = inboundRoster != null
|
|
1401
|
+
? resolveConversationPolicyScope(inboundRoster)
|
|
1402
|
+
: normalizeTurnVerbosityConversationType(inboundProvenance?.type);
|
|
1332
1403
|
const turnVerbosity = resolveTurnVerbosity({
|
|
1333
1404
|
configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
|
|
1334
1405
|
conversationType: inboundConversationType,
|
|
@@ -1412,14 +1483,8 @@ export class CanonAgent {
|
|
|
1412
1483
|
if (this.sessionManager && session) {
|
|
1413
1484
|
this.sessionManager.seedHistory(conversationId, history);
|
|
1414
1485
|
}
|
|
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
1486
|
// Build reply functions
|
|
1422
|
-
const replyFinal = async (text, options) => {
|
|
1487
|
+
const replyFinal = async (text, options, delivery) => {
|
|
1423
1488
|
throwIfAborted();
|
|
1424
1489
|
try {
|
|
1425
1490
|
await this.typingSignals.start(conversationId, 'typing');
|
|
@@ -1443,11 +1508,11 @@ export class CanonAgent {
|
|
|
1443
1508
|
};
|
|
1444
1509
|
let result;
|
|
1445
1510
|
try {
|
|
1446
|
-
result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId]);
|
|
1511
|
+
result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId], delivery);
|
|
1447
1512
|
}
|
|
1448
1513
|
catch (error) {
|
|
1449
1514
|
const chunked = isChunkedSendMessageError(error) ? error : null;
|
|
1450
|
-
if (!chunked || chunked.deliveredMessageIds.length === 0 || isAbortLikeError(error)) {
|
|
1515
|
+
if (!chunked || chunked.deliveredMessageIds.length === 0 || sendOptions.messageId || isAbortLikeError(error) || isPendingCanonOperation(error)) {
|
|
1451
1516
|
throw error;
|
|
1452
1517
|
}
|
|
1453
1518
|
const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
|
|
@@ -1565,9 +1630,11 @@ export class CanonAgent {
|
|
|
1565
1630
|
// override below only covers a retry backoff.
|
|
1566
1631
|
const abortAwareClient = Object.create(this.apiClient, {
|
|
1567
1632
|
sendMessage: {
|
|
1568
|
-
value: (targetConversationId, targetText, targetOptions) => {
|
|
1633
|
+
value: (targetConversationId, targetText, targetOptions, delivery) => {
|
|
1569
1634
|
throwIfAborted();
|
|
1570
|
-
return
|
|
1635
|
+
return delivery?.operationId
|
|
1636
|
+
? this.apiClient.sendMessage(targetConversationId, targetText, targetOptions, delivery)
|
|
1637
|
+
: this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
|
|
1571
1638
|
},
|
|
1572
1639
|
},
|
|
1573
1640
|
});
|
|
@@ -1579,7 +1646,7 @@ export class CanonAgent {
|
|
|
1579
1646
|
// that FITS still goes out as a single message under the plain id with
|
|
1580
1647
|
// untouched metadata, so the common case (and the interim→final handoff
|
|
1581
1648
|
// that keys on that id) is unchanged.
|
|
1582
|
-
const sendDurableMessage = async (text, options, fallbackMessageIdParts) => {
|
|
1649
|
+
const sendDurableMessage = async (text, options, fallbackMessageIdParts, delivery) => {
|
|
1583
1650
|
// Counts every durable send the turn attempts, not just the ones that
|
|
1584
1651
|
// needed a generated id: teardown reads `durableMessageSequence` to
|
|
1585
1652
|
// decide whether the turn genuinely ended silent, and a reply sent
|
|
@@ -1591,7 +1658,9 @@ export class CanonAgent {
|
|
|
1591
1658
|
...(options ?? {}),
|
|
1592
1659
|
messageId,
|
|
1593
1660
|
}, {
|
|
1594
|
-
|
|
1661
|
+
signal: abortController.signal,
|
|
1662
|
+
...(delivery?.operationId ? { operationId: delivery.operationId } : {}),
|
|
1663
|
+
...(delivery?.replacesOperationId ? { replacesOperationId: delivery.replacesOperationId } : {}),
|
|
1595
1664
|
}, {
|
|
1596
1665
|
resumable: true,
|
|
1597
1666
|
});
|
|
@@ -1601,21 +1670,14 @@ export class CanonAgent {
|
|
|
1601
1670
|
// the end of the answer, not its head. `messageIds` has the full set.
|
|
1602
1671
|
return { messageId: messageIds[messageIds.length - 1], messageIds };
|
|
1603
1672
|
};
|
|
1604
|
-
//
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
ownerId: '',
|
|
1608
|
-
ownerName: '',
|
|
1609
|
-
discoverable: false,
|
|
1610
|
-
inboundPolicy: 'approval-required',
|
|
1611
|
-
outboundPolicy: 'approval-required',
|
|
1612
|
-
groupJoinPolicy: 'approval-required',
|
|
1613
|
-
};
|
|
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();
|
|
1614
1676
|
const provenance = latestMessage
|
|
1615
1677
|
? resolveRuntimeProvenance({
|
|
1616
1678
|
provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
|
|
1617
1679
|
conversationId,
|
|
1618
|
-
conversationType: conversation.
|
|
1680
|
+
conversationType: resolveConversationPolicyScope(conversation.memberIds),
|
|
1619
1681
|
memberCount: conversation.memberIds.length,
|
|
1620
1682
|
senderId: latestMessage.senderId,
|
|
1621
1683
|
senderName: latestMessage.senderName ?? latestMessage.senderId,
|
|
@@ -1628,7 +1690,7 @@ export class CanonAgent {
|
|
|
1628
1690
|
})
|
|
1629
1691
|
: resolveRuntimeProvenance({
|
|
1630
1692
|
conversationId,
|
|
1631
|
-
conversationType: conversation.
|
|
1693
|
+
conversationType: resolveConversationPolicyScope(conversation.memberIds),
|
|
1632
1694
|
memberCount: conversation.memberIds.length,
|
|
1633
1695
|
senderId: '',
|
|
1634
1696
|
senderType: 'human',
|
|
@@ -1663,7 +1725,7 @@ export class CanonAgent {
|
|
|
1663
1725
|
content: latestMessage ? renderCanonHostInboundContent(latestMessage) : '[Empty message]',
|
|
1664
1726
|
conversationId,
|
|
1665
1727
|
participantContext: {
|
|
1666
|
-
conversationType: conversation.
|
|
1728
|
+
conversationType: resolveConversationPolicyScope(conversation.memberIds),
|
|
1667
1729
|
memberCount: conversation.memberIds.length,
|
|
1668
1730
|
senderType: latestMessage?.senderType ?? provenance.sender.type,
|
|
1669
1731
|
senderName: latestMessage?.senderName
|
|
@@ -2129,8 +2191,15 @@ export class CanonAgent {
|
|
|
2129
2191
|
catch { }
|
|
2130
2192
|
}
|
|
2131
2193
|
};
|
|
2132
|
-
//
|
|
2194
|
+
// Fence uncertain native/business effects before handing execution out.
|
|
2195
|
+
// A crash after this write requires provider/operator reconciliation.
|
|
2133
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;
|
|
2134
2203
|
await this.handler({
|
|
2135
2204
|
messages: hydratedMessages,
|
|
2136
2205
|
history,
|
|
@@ -2139,6 +2208,19 @@ export class CanonAgent {
|
|
|
2139
2208
|
conversation,
|
|
2140
2209
|
...(groupContext ? { groupContext } : {}),
|
|
2141
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
|
+
},
|
|
2142
2224
|
replyProgress,
|
|
2143
2225
|
deleteMessage,
|
|
2144
2226
|
markAsRead,
|
|
@@ -2291,6 +2373,7 @@ export class CanonAgent {
|
|
|
2291
2373
|
},
|
|
2292
2374
|
},
|
|
2293
2375
|
});
|
|
2376
|
+
await setInputState('settled', 'handler-completed');
|
|
2294
2377
|
// Auto-mark conversation as read after successful processing
|
|
2295
2378
|
if (this.options.autoMarkRead) {
|
|
2296
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;
|
|
@@ -22,6 +24,12 @@ export interface UploadMediaFileOptions {
|
|
|
22
24
|
signal?: AbortSignal;
|
|
23
25
|
}
|
|
24
26
|
export interface ReplyWithFileOptions extends Omit<SendMessageOptions, 'attachments' | 'contentType'>, UploadMediaFileOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Optional local policy check after upload/finalization, immediately before
|
|
29
|
+
* publishing the attachment. Returning false withholds the message. Automatic
|
|
30
|
+
* artifact routing uses this to recheck its current audience.
|
|
31
|
+
*/
|
|
32
|
+
canPublish?: () => boolean;
|
|
25
33
|
}
|
|
26
34
|
export interface MaterializedCanonAttachment extends MediaAttachment {
|
|
27
35
|
index: number;
|
package/dist/media.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,95 +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
|
|
325
|
-
options?.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
if (
|
|
330
|
-
throw new Error('Canon
|
|
331
|
-
}
|
|
332
|
-
|
|
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, ...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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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 };
|
|
413
365
|
}
|
|
414
366
|
/**
|
|
415
367
|
* Resolve the effective MIME type of a materialized attachment, falling back
|
package/dist/realtime.d.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
private readonly
|
|
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
|
|
28
|
+
constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl: string | undefined, options: {
|
|
29
29
|
enableVoiceEvents?: boolean;
|
|
30
|
+
endpoint: CanonEndpoint;
|
|
30
31
|
});
|
|
31
|
-
private
|
|
32
|
-
private
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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})` : ''};
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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 =
|
|
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) => {
|
|
450
|
-
|
|
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({
|
|
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": "
|
|
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": ">=
|
|
28
|
+
"node": ">=22.22.3"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^
|
|
31
|
+
"@canonmsg/core": "^13.0.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|