@canonmsg/agent-sdk 4.0.0 → 5.1.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.
@@ -0,0 +1,1992 @@
1
+ import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, sendMessageWithRetry, } from '@canonmsg/core';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { AuthManager } from './auth.js';
4
+ import { Debouncer } from './debouncer.js';
5
+ import { buildRuntimeCardCreateArgs } from './runtime-card.js';
6
+ import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
7
+ import { SessionManager } from './session-manager.js';
8
+ const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
9
+ const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
10
+ const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
11
+ const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
12
+ const DEFAULT_RUNTIME_INPUT_TIMEOUT_MS = 5 * 60_000;
13
+ const RUNTIME_INPUT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/;
14
+ const SDK_MESSAGE_ID_READABLE_MAX = 120;
15
+ const SDK_RUNTIME_CAPABILITIES = {
16
+ supportsInterrupt: false,
17
+ supportsInputInterrupt: false,
18
+ supportsQueue: true,
19
+ supportsInterleave: false,
20
+ supportsRequiresAction: true,
21
+ supportsNonFinalPermanentMessages: false,
22
+ };
23
+ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
24
+ coreControls: [],
25
+ runtimeControls: [],
26
+ supportsInterrupt: false,
27
+ streamingTextMode: 'snapshot',
28
+ runtimeCards: {
29
+ rich: {
30
+ schema: 'canon.card.v1',
31
+ lifecycle: 'blocking_requires_action',
32
+ responder: 'agent_owner',
33
+ result: 'action_or_values',
34
+ maxTimeoutMs: 30 * 60_000,
35
+ blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions', 'mediaPreview', 'details'],
36
+ actionFieldTypes: ['text', 'textarea', 'select', 'multiSelect', 'boolean', 'date', 'number', 'currency', 'searchSelect', 'lineItems'],
37
+ native: true,
38
+ },
39
+ },
40
+ };
41
+ const STANDARD_PRIMITIVE_COMMANDS = {
42
+ 'runtime.status': {
43
+ id: 'runtime-status',
44
+ label: 'Runtime status',
45
+ description: 'Ask the runtime for its current status.',
46
+ primitive: 'runtime.status',
47
+ aliases: ['status'],
48
+ category: 'runtime',
49
+ placements: ['composer_slash', 'command_palette'],
50
+ availability: ['always'],
51
+ dispatch: { kind: 'primitive', primitive: 'runtime.status' },
52
+ },
53
+ 'runtime.reasoning.set': {
54
+ id: 'thinking-level',
55
+ label: 'Thinking level',
56
+ description: 'Set the runtime reasoning or effort level.',
57
+ primitive: 'runtime.reasoning.set',
58
+ aliases: ['think', 'effort'],
59
+ category: 'runtime',
60
+ placements: ['composer_slash', 'command_palette'],
61
+ availability: ['always'],
62
+ args: [{
63
+ id: 'level',
64
+ label: 'Level',
65
+ kind: 'enum',
66
+ required: true,
67
+ choices: [
68
+ { value: 'low', label: 'Low' },
69
+ { value: 'medium', label: 'Medium' },
70
+ { value: 'high', label: 'High' },
71
+ ],
72
+ }],
73
+ dispatch: { kind: 'primitive', primitive: 'runtime.reasoning.set' },
74
+ },
75
+ 'runtime.verbosity.set': {
76
+ id: 'verbosity',
77
+ label: 'Verbosity',
78
+ description: 'Set runtime verbosity.',
79
+ primitive: 'runtime.verbosity.set',
80
+ aliases: ['verbose'],
81
+ category: 'runtime',
82
+ placements: ['composer_slash', 'command_palette'],
83
+ availability: ['always'],
84
+ args: [{
85
+ id: 'level',
86
+ label: 'Level',
87
+ kind: 'enum',
88
+ required: true,
89
+ choices: [
90
+ { value: 'off', label: 'Off' },
91
+ { value: 'on', label: 'On' },
92
+ { value: 'full', label: 'Full' },
93
+ ],
94
+ }],
95
+ dispatch: { kind: 'primitive', primitive: 'runtime.verbosity.set' },
96
+ },
97
+ 'runtime.usage': {
98
+ id: 'usage',
99
+ label: 'Usage',
100
+ description: 'Show or change runtime usage reporting.',
101
+ primitive: 'runtime.usage',
102
+ aliases: ['usage'],
103
+ category: 'runtime',
104
+ placements: ['composer_slash', 'command_palette'],
105
+ availability: ['always'],
106
+ dispatch: { kind: 'primitive', primitive: 'runtime.usage' },
107
+ },
108
+ 'context.compact': {
109
+ id: 'compact-context',
110
+ label: 'Compact context',
111
+ description: 'Ask the runtime to compact its conversation context.',
112
+ primitive: 'context.compact',
113
+ aliases: ['compact'],
114
+ category: 'session',
115
+ placements: ['composer_slash', 'command_palette'],
116
+ availability: ['always'],
117
+ dispatch: { kind: 'primitive', primitive: 'context.compact' },
118
+ },
119
+ 'session.new': {
120
+ id: 'new-session-primitive',
121
+ label: 'New session',
122
+ description: 'Ask the runtime to start a fresh session.',
123
+ primitive: 'session.new',
124
+ aliases: ['new'],
125
+ category: 'session',
126
+ placements: ['composer_slash', 'command_palette', 'session_strip'],
127
+ availability: ['always'],
128
+ dispatch: { kind: 'primitive', primitive: 'session.new' },
129
+ },
130
+ 'session.reset': {
131
+ id: 'reset-session',
132
+ label: 'Reset session',
133
+ description: 'Ask the runtime to reset the current session.',
134
+ primitive: 'session.reset',
135
+ aliases: ['reset'],
136
+ category: 'session',
137
+ placements: ['composer_slash', 'command_palette'],
138
+ availability: ['always'],
139
+ dispatch: { kind: 'primitive', primitive: 'session.reset' },
140
+ },
141
+ };
142
+ function sleep(ms) {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
145
+ function sleepWithAbort(ms, signal) {
146
+ if (signal.aborted)
147
+ return Promise.reject(createTurnAbortError());
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(resolve, ms);
150
+ signal.addEventListener('abort', () => {
151
+ clearTimeout(timer);
152
+ reject(createTurnAbortError());
153
+ }, { once: true });
154
+ });
155
+ }
156
+ function safeRuntimeInputId(value, kind) {
157
+ const raw = value?.trim() || `${kind}_${randomUUID()}`;
158
+ const normalized = raw.replace(/[.#$\[\]\/\s]+/g, '_').slice(0, 160);
159
+ return RUNTIME_INPUT_ID_PATTERN.test(normalized)
160
+ ? normalized
161
+ : `${kind}_${randomUUID()}`;
162
+ }
163
+ function safeRuntimeCardId(value) {
164
+ const raw = value?.trim() || `card_${randomUUID()}`;
165
+ const normalized = raw.replace(/[.#$\[\]\/\s]+/g, '_').slice(0, 80);
166
+ return RUNTIME_INPUT_ID_PATTERN.test(normalized)
167
+ ? normalized
168
+ : `card_${randomUUID()}`;
169
+ }
170
+ function buildSdkMessageId(parts) {
171
+ const raw = parts
172
+ .map((part) => part == null ? '' : String(part))
173
+ .filter(Boolean)
174
+ .join(':');
175
+ const hash = createHash('sha256').update(raw || 'sdk-message').digest('hex').slice(0, 16);
176
+ const readable = (raw || 'sdk-message')
177
+ .replace(/[^A-Za-z0-9_.:-]+/g, '_')
178
+ .replace(/_+/g, '_')
179
+ .replace(/^[:_.-]+|[:_.-]+$/g, '')
180
+ .slice(0, SDK_MESSAGE_ID_READABLE_MAX);
181
+ return `${readable || 'sdk-message'}:${hash}`;
182
+ }
183
+ function isRuntimePrimitiveId(value) {
184
+ return value === 'runtime.status'
185
+ || value === 'runtime.reasoning.set'
186
+ || value === 'runtime.verbosity.set'
187
+ || value === 'runtime.usage'
188
+ || value === 'context.compact'
189
+ || value === 'session.new'
190
+ || value === 'session.reset';
191
+ }
192
+ function normalizePrimitiveArgs(value) {
193
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
194
+ return {};
195
+ }
196
+ const args = {};
197
+ for (const [key, rawValue] of Object.entries(value)) {
198
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
199
+ continue;
200
+ if (typeof rawValue === 'boolean' || typeof rawValue === 'string') {
201
+ args[key] = rawValue;
202
+ }
203
+ }
204
+ return args;
205
+ }
206
+ function normalizeRuntimeFact(fact) {
207
+ const id = fact.id.trim();
208
+ const label = fact.label.trim();
209
+ const value = fact.value.trim();
210
+ if (!id || !label || !value)
211
+ return null;
212
+ return {
213
+ ...fact,
214
+ id,
215
+ label,
216
+ value,
217
+ };
218
+ }
219
+ function normalizeRuntimeActivityItem(item) {
220
+ return {
221
+ ...item,
222
+ id: item.id.trim(),
223
+ title: item.title.trim() || item.kind,
224
+ updatedAt: item.updatedAt || Date.now(),
225
+ };
226
+ }
227
+ function createTurnAbortError() {
228
+ const error = new Error('Canon turn was interrupted before reply delivery.');
229
+ error.name = 'AbortError';
230
+ return error;
231
+ }
232
+ function isAbortLikeError(error) {
233
+ if (!error || typeof error !== 'object')
234
+ return false;
235
+ const record = error;
236
+ if (record.name === 'AbortError' || record.code === 'ABORT_ERR')
237
+ return true;
238
+ return typeof record.message === 'string' && /\babort(?:ed)?\b/i.test(record.message);
239
+ }
240
+ export class CanonAgent {
241
+ options;
242
+ apiClient;
243
+ authManager;
244
+ debouncer;
245
+ realtimeManager = null;
246
+ sessionManager = null;
247
+ handler = null;
248
+ contactRequestHandler = null;
249
+ contactApprovedHandler = null;
250
+ contactAddedHandler = null;
251
+ contactRemovedHandler = null;
252
+ messageUpdatedHandler = null;
253
+ interruptHandler = null;
254
+ stopAndDropHandler = null;
255
+ newSessionHandler = null;
256
+ primitiveHandlers = new Map();
257
+ primitiveFallbackHandler = null;
258
+ /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
259
+ contacts;
260
+ /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
261
+ users;
262
+ /** Conversation discovery for choosing existing sessions intentionally. */
263
+ conversations;
264
+ reachOutInFlight = new Map();
265
+ agentId = null;
266
+ agentContext = null;
267
+ approvalManager = null;
268
+ approvalManagerAgentId = null;
269
+ approvalManagerOwnerId = null;
270
+ /**
271
+ * Shared poll/timeout/abort engine for interactive runtime input + card
272
+ * requests. The server request is still created inline (host-specific
273
+ * `native`/`responseUserId`), so the descriptors register a no-op `create`
274
+ * but keep the built-in `cancel`: passing the turn's abort `signal` lets the
275
+ * manager fire `consume({ cancel: true })` on interrupt, so the hosts no
276
+ * longer hand-roll a `cancelPendingRequest`.
277
+ */
278
+ runtimeRequestManager = null;
279
+ cachedConversationIds = [];
280
+ running = false;
281
+ runtimeHeartbeatTimer = null;
282
+ rtdbHandle = null;
283
+ controlPoller = null;
284
+ activeAbortControllers = new Map();
285
+ activeTurns = new Map();
286
+ conversationMemberIds = new Map();
287
+ pendingMembershipChanges = new Map();
288
+ typingSignals;
289
+ sseConnectedLogged = false;
290
+ constructor(options) {
291
+ this.options = {
292
+ baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
293
+ deliveryMode: 'auto',
294
+ debounceMs: 2000,
295
+ historyLimit: 50,
296
+ autoMarkRead: true,
297
+ runtimeControlSurface: 'agent',
298
+ ...options,
299
+ };
300
+ this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl);
301
+ this.typingSignals = createTypingStatusPublisher({
302
+ setTyping: (conversationId, typing, status) => status
303
+ ? this.apiClient.setTyping(conversationId, typing, status)
304
+ : this.apiClient.setTyping(conversationId, typing),
305
+ });
306
+ this.authManager = new AuthManager(this.apiClient);
307
+ this.debouncer = new Debouncer(this.options.debounceMs);
308
+ const apiClient = this.apiClient;
309
+ this.contacts = {
310
+ list: () => apiClient.listContacts(),
311
+ get: (contactId) => apiClient.getContact(contactId),
312
+ remove: (contactId) => apiClient.deleteContact(contactId),
313
+ request: (targetUserId, message) => apiClient.createContactRequest(targetUserId, message ?? null),
314
+ };
315
+ this.users = {
316
+ block: (userId) => apiClient.blockUser(userId),
317
+ unblock: (userId) => apiClient.unblockUser(userId),
318
+ };
319
+ this.conversations = {
320
+ list: async (options = {}) => {
321
+ const conversations = await apiClient.getConversations();
322
+ if (!options.targetUserId)
323
+ return conversations;
324
+ return conversations.filter((conversation) => conversation.memberIds.includes(options.targetUserId));
325
+ },
326
+ };
327
+ if (options.sessions?.enabled) {
328
+ this.sessionManager = new SessionManager({
329
+ contextLimit: options.sessions.contextLimit,
330
+ concurrency: options.sessions.concurrency,
331
+ idleTimeoutMs: options.sessions.idleTimeoutMs,
332
+ });
333
+ }
334
+ this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
335
+ this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
336
+ this.newSessionHandler = options.runtimeControls?.onNewSession ?? null;
337
+ for (const [primitive, handler] of Object.entries(options.runtimePrimitives ?? {})) {
338
+ if (primitive === '*') {
339
+ this.primitiveFallbackHandler = handler;
340
+ }
341
+ else if (isRuntimePrimitiveId(primitive)) {
342
+ this.primitiveHandlers.set(primitive, handler);
343
+ }
344
+ }
345
+ }
346
+ ensureApprovalManager(agentContext = this.agentContext) {
347
+ const agentId = agentContext?.agentId || this.agentId;
348
+ const ownerId = agentContext?.ownerId;
349
+ if (!agentId || !ownerId)
350
+ return null;
351
+ if (this.approvalManager
352
+ && this.approvalManagerAgentId === agentId
353
+ && this.approvalManagerOwnerId === ownerId) {
354
+ return this.approvalManager;
355
+ }
356
+ if (this.approvalManager?.pendingCount) {
357
+ return this.approvalManager;
358
+ }
359
+ this.approvalManager?.dispose();
360
+ this.approvalManager = new ApprovalManager(this.apiClient, agentId, ownerId);
361
+ this.approvalManagerAgentId = agentId;
362
+ this.approvalManagerOwnerId = ownerId;
363
+ return this.approvalManager;
364
+ }
365
+ /**
366
+ * Shared engine for interactive runtime input + card polling. Unlike approval,
367
+ * these families do not need an owner (the request is created inline with the
368
+ * caller's own `responseUserId` policy), so the manager is always available.
369
+ * The registered descriptors keep the built-in poll/consume/timeout logic and
370
+ * the built-in `cancel` (best-effort `consume({ cancel: true })`), replacing
371
+ * only `create` with a no-op because the request is already created inline.
372
+ * On abort the manager runs that cancel via the passed `signal`.
373
+ */
374
+ ensureRuntimeRequestManager() {
375
+ if (!this.runtimeRequestManager) {
376
+ const manager = new RuntimeRequestManager(this.apiClient, {
377
+ agentId: this.agentId ?? '',
378
+ ownerId: '',
379
+ });
380
+ manager.register('input', {
381
+ ...runtimeInputDescriptor,
382
+ create: async ({ requestId }) => ({ requestId }),
383
+ });
384
+ manager.register('card', {
385
+ ...runtimeCardDescriptor,
386
+ create: async ({ requestId }) => ({ requestId }),
387
+ });
388
+ this.runtimeRequestManager = manager;
389
+ }
390
+ return this.runtimeRequestManager;
391
+ }
392
+ filterApprovalReplyMessages(conversationId, messages) {
393
+ const manager = this.ensureApprovalManager();
394
+ if (!manager) {
395
+ return messages.filter((message) => {
396
+ const metadata = message.metadata;
397
+ return !(metadata && typeof metadata === 'object' && !Array.isArray(metadata)
398
+ && metadata.type === 'approval_reply');
399
+ });
400
+ }
401
+ return messages.filter((message) => {
402
+ const metadata = message.metadata;
403
+ const metadataRecord = metadata && typeof metadata === 'object' && !Array.isArray(metadata)
404
+ ? metadata
405
+ : null;
406
+ const consumed = manager.handleMessage(conversationId, {
407
+ senderId: message.senderId,
408
+ ...(metadataRecord ? { metadata: metadataRecord } : {}),
409
+ });
410
+ return !consumed && metadataRecord?.type !== 'approval_reply';
411
+ });
412
+ }
413
+ on(event, handler) {
414
+ if (event === 'message') {
415
+ this.handler = handler;
416
+ return;
417
+ }
418
+ if (event === 'messageUpdated') {
419
+ this.messageUpdatedHandler = handler;
420
+ return;
421
+ }
422
+ if (event === 'contactRequest') {
423
+ this.contactRequestHandler = handler;
424
+ return;
425
+ }
426
+ if (event === 'contactApproved') {
427
+ this.contactApprovedHandler = handler;
428
+ return;
429
+ }
430
+ if (event === 'contactAdded') {
431
+ this.contactAddedHandler = handler;
432
+ return;
433
+ }
434
+ if (event === 'interrupt') {
435
+ this.interruptHandler = handler;
436
+ if (this.running) {
437
+ void this.baselineRuntimeControlSignals(this.cachedConversationIds)
438
+ .then(() => this.startRuntimeControlPolling())
439
+ .catch(() => { });
440
+ }
441
+ void this.publishAgentRuntime().catch(() => { });
442
+ return;
443
+ }
444
+ if (event === 'stopAndDrop') {
445
+ this.stopAndDropHandler = handler;
446
+ if (this.running) {
447
+ void this.baselineRuntimeControlSignals(this.cachedConversationIds)
448
+ .then(() => this.startRuntimeControlPolling())
449
+ .catch(() => { });
450
+ }
451
+ void this.publishAgentRuntime().catch(() => { });
452
+ return;
453
+ }
454
+ if (event === 'newSession') {
455
+ this.newSessionHandler = handler;
456
+ if (this.running) {
457
+ void this.baselineRuntimeControlSignals(this.cachedConversationIds)
458
+ .then(() => this.startRuntimeControlPolling())
459
+ .catch(() => { });
460
+ }
461
+ void this.publishAgentRuntime().catch(() => { });
462
+ return;
463
+ }
464
+ this.contactRemovedHandler = handler;
465
+ }
466
+ onPrimitive(primitive, handler) {
467
+ if (primitive === '*') {
468
+ this.primitiveFallbackHandler = handler;
469
+ }
470
+ else {
471
+ this.primitiveHandlers.set(primitive, handler);
472
+ }
473
+ if (this.running) {
474
+ this.startRuntimeControlPolling();
475
+ }
476
+ void this.publishAgentRuntime().catch(() => { });
477
+ }
478
+ describeCommands(_provider) {
479
+ return this.buildRuntimeDescriptor().commands ?? [];
480
+ }
481
+ async publishRuntimeFacts(conversationId, facts) {
482
+ this.rememberConversationId(conversationId);
483
+ const publisher = this.requireRuntimeStatePublisher();
484
+ const normalizedFacts = facts
485
+ .map(normalizeRuntimeFact)
486
+ .filter((fact) => Boolean(fact));
487
+ await publisher.patchRuntimeInfo(conversationId, {
488
+ descriptor: this.buildRuntimeDescriptor(),
489
+ facts: normalizedFacts,
490
+ });
491
+ }
492
+ async publishRuntimeActivity(conversationId, item) {
493
+ this.rememberConversationId(conversationId);
494
+ const normalized = normalizeRuntimeActivityItem(item);
495
+ if (!normalized.id) {
496
+ throw new Error('Runtime activity item id is required.');
497
+ }
498
+ await this.requireRuntimeStatePublisher().writeRuntimeActivity(conversationId, normalized);
499
+ }
500
+ async clearRuntimeActivity(conversationId, options) {
501
+ this.rememberConversationId(conversationId);
502
+ await this.requireRuntimeStatePublisher().clearRuntimeActivity(conversationId, options);
503
+ }
504
+ /**
505
+ * Resolve admission live for a target user (typically read off a shared
506
+ * contact card) and route into either an immediate message or a contact
507
+ * request. Never reads `card.accessLevel` — that snapshot is stale by the
508
+ * time an LLM acts on it. Instead defers to `resolveAdmission` so the
509
+ * answer reflects the target's *current* inbound policy.
510
+ */
511
+ async reachOut(card, options) {
512
+ const target = {
513
+ targetUserId: card.userId,
514
+ ...(card.userType === 'ai_agent' && card.canonContactId
515
+ ? { canonContactId: card.canonContactId }
516
+ : {}),
517
+ };
518
+ const targetKey = target.canonContactId ?? target.targetUserId;
519
+ // Include the opener/request payloads in the dedupe key so two concurrent
520
+ // calls with different `text`, `requestMessage`, or setup choices don't silently collapse
521
+ // and lose the second caller's intended side effect.
522
+ const contextualKey = options?.selfContext
523
+ ? `${options.sourceConversationId ?? ''}\u0000${options.selfContext.type}\u0000${options.selfContext.context}`
524
+ : '';
525
+ const inFlightKey = `${targetKey}\u0000${options?.text ?? ''}\u0000${options?.requestMessage ?? ''}\u0000${JSON.stringify(options?.sessionConfig ?? null)}\u0000${JSON.stringify(options?.sessionSelection ?? null)}\u0000${contextualKey}`;
526
+ const inFlight = this.reachOutInFlight.get(inFlightKey);
527
+ if (inFlight)
528
+ return inFlight;
529
+ const promise = this.executeReachOut(target, options).finally(() => {
530
+ this.reachOutInFlight.delete(inFlightKey);
531
+ });
532
+ this.reachOutInFlight.set(inFlightKey, promise);
533
+ return promise;
534
+ }
535
+ async executeReachOut(target, options) {
536
+ const { targetUserId } = target;
537
+ if (options?.selfContext) {
538
+ if (!options.sourceConversationId) {
539
+ throw new Error('sourceConversationId is required for contextual reachOut');
540
+ }
541
+ if (!options.text) {
542
+ throw new Error('text is required for contextual reachOut');
543
+ }
544
+ const result = await this.apiClient.sendContextualMessage({
545
+ sourceConversationId: options.sourceConversationId,
546
+ targetUserId,
547
+ text: options.text,
548
+ selfContext: options.selfContext,
549
+ requestMessage: options.requestMessage ?? null,
550
+ sessionConfig: options.sessionConfig ?? null,
551
+ sessionSelection: options.sessionSelection,
552
+ });
553
+ return result.status === 'messaged'
554
+ ? {
555
+ status: 'messaged',
556
+ conversationId: result.conversationId,
557
+ messageId: result.messageId,
558
+ selfContextId: result.selfContextId,
559
+ created: result.created,
560
+ reused: result.reused,
561
+ sessionSelection: result.sessionSelection,
562
+ }
563
+ : result;
564
+ }
565
+ return reachOutToCanonContact(this.apiClient, {
566
+ ...(target.canonContactId
567
+ ? { canonContactId: target.canonContactId }
568
+ : { targetUserId }),
569
+ text: options?.text ?? null,
570
+ requestMessage: options?.requestMessage ?? null,
571
+ sessionConfig: options?.sessionConfig ?? null,
572
+ sessionSelection: options?.sessionSelection,
573
+ });
574
+ }
575
+ async start() {
576
+ if (this.running)
577
+ return;
578
+ this.running = true;
579
+ // The single scoped RTDB client for this agent. Every RTDB consumer in
580
+ // the SDK (control poller, runtime-state publishers) threads this handle;
581
+ // the SDK never reads through core's deprecated module-global default,
582
+ // so multiple CanonAgents in one process cannot clobber each other.
583
+ this.rtdbHandle = initRTDBAuth(this.apiClient);
584
+ // 1. Authenticate
585
+ const { agentId } = await this.authManager.authenticate();
586
+ this.agentId = agentId;
587
+ console.log(`[canon-sdk] Authenticated as ${agentId}`);
588
+ // 2. Wire debouncer to handler
589
+ this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
590
+ this.rememberConversationId(conversationId);
591
+ await this.handleMessages(conversationId, messages, provenanceByMessageId);
592
+ });
593
+ // 3. Fetch conversations (used for delivery mode + session state)
594
+ let conversations = [];
595
+ try {
596
+ conversations = await this.apiClient.getConversations();
597
+ this.cachedConversationIds = conversations.map((c) => c.id);
598
+ this.rememberConversationMembers(conversations);
599
+ }
600
+ catch {
601
+ // Non-fatal — delivery mode will fall back to default
602
+ }
603
+ // 3a. Determine delivery mode
604
+ let mode = this.options.deliveryMode;
605
+ if (mode === 'auto') {
606
+ mode = 'sse';
607
+ console.log(`[canon-sdk] Auto-selected ${mode} mode (${conversations.length} conversations)`);
608
+ }
609
+ if (mode !== 'sse') {
610
+ throw new Error(`Unsupported deliveryMode: ${mode}. Use 'auto' or 'sse'.`);
611
+ }
612
+ // 3b. Fetch agent context (identity, owner, access level)
613
+ try {
614
+ this.agentContext = await this.apiClient.getAgentMe();
615
+ this.ensureApprovalManager(this.agentContext);
616
+ }
617
+ catch {
618
+ console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
619
+ }
620
+ // 3c. Initialize RTDB session state reporting (opt-in)
621
+ if (this.options.sessionState) {
622
+ const runtimeState = this.createRuntimeStatePublisher();
623
+ for (const id of this.cachedConversationIds) {
624
+ runtimeState?.writeSessionState(id, {
625
+ cwd: process.cwd(),
626
+ isActive: true,
627
+ ...(this.options.clientType ? { clientType: this.options.clientType } : {}),
628
+ }).catch(() => { });
629
+ }
630
+ if (this.cachedConversationIds.length > 0) {
631
+ console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
632
+ }
633
+ }
634
+ await this.baselineRuntimeControlSignals(this.cachedConversationIds);
635
+ this.startRuntimeControlPolling();
636
+ // 4. Start delivery
637
+ const { RealtimeManager } = await import('./realtime.js');
638
+ const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
639
+ rtm.setOnAgentContext((ctx) => {
640
+ this.agentContext = ctx;
641
+ this.ensureApprovalManager(ctx);
642
+ });
643
+ rtm.setContactRequestHandlers({
644
+ onContactRequest: (request) => {
645
+ void this.handleContactRequestEvent(this.contactRequestHandler, request);
646
+ },
647
+ onContactApproved: (request) => {
648
+ void this.handleContactRequestEvent(this.contactApprovedHandler, request);
649
+ },
650
+ });
651
+ rtm.setContactGraphHandlers({
652
+ onContactAdded: (payload) => {
653
+ void this.handleContactGraphEvent(this.contactAddedHandler, payload);
654
+ },
655
+ onContactRemoved: (payload) => {
656
+ void this.handleContactGraphEvent(this.contactRemovedHandler, payload);
657
+ },
658
+ });
659
+ rtm.setConversationUpdatedHandler((payload) => {
660
+ this.handleConversationUpdated(payload);
661
+ });
662
+ rtm.setMessageUpdatedHandler((payload) => {
663
+ void this.handleMessageUpdatedEvent(payload);
664
+ });
665
+ rtm.setMessageDeletedHandler((payload) => {
666
+ this.sessionManager?.dropQueuedMessage(payload.conversationId, payload.messageId);
667
+ });
668
+ rtm.setConnectionHandlers({
669
+ onConnected: () => {
670
+ this.startRuntimeHeartbeat();
671
+ if (!this.sseConnectedLogged) {
672
+ this.sseConnectedLogged = true;
673
+ console.log('[canon-sdk] SSE stream connected');
674
+ }
675
+ },
676
+ onDisconnected: () => this.stopRuntimeHeartbeat(),
677
+ });
678
+ this.realtimeManager = rtm;
679
+ await rtm.start();
680
+ }
681
+ async createConversation(options) {
682
+ return this.apiClient.createConversation(options);
683
+ }
684
+ async updateTopic(conversationId, topic) {
685
+ return this.apiClient.updateTopic(conversationId, topic);
686
+ }
687
+ async leaveConversation(conversationId) {
688
+ return this.apiClient.leaveConversation(conversationId);
689
+ }
690
+ async updateConversationName(conversationId, name) {
691
+ return this.apiClient.updateConversationName(conversationId, name);
692
+ }
693
+ /**
694
+ * Add a member to a group conversation.
695
+ *
696
+ * Outcome depends on the target's `groupJoinPolicy` and the relationship
697
+ * graph:
698
+ * - `{ status: 'added' }` — the member was added immediately.
699
+ * - `{ status: 'pending', requestId }` — the target requires approval; the
700
+ * server created a contact-request (kind: 'group_invite') routed to the
701
+ * approver. The actual group join happens when that request is approved
702
+ * (you can listen for `contact.approved` SSE events to know when).
703
+ *
704
+ * Throws `CanonApiError` for hard failures (block, inactive, owner-only,
705
+ * member cap, requester not authorized).
706
+ */
707
+ async addMember(conversationId, userId) {
708
+ return this.apiClient.addMember(conversationId, userId);
709
+ }
710
+ async removeMember(conversationId, userId) {
711
+ return this.apiClient.removeMember(conversationId, userId);
712
+ }
713
+ async uploadMedia(conversationId, data, mimeType, fileName) {
714
+ return this.apiClient.uploadMedia(conversationId, data, mimeType, fileName);
715
+ }
716
+ async handleContactRequestEvent(handler, request) {
717
+ if (!handler)
718
+ return;
719
+ try {
720
+ await handler(request);
721
+ }
722
+ catch (error) {
723
+ console.error('[canon-sdk] Contact-request handler failed:', error instanceof Error ? error.message : error);
724
+ }
725
+ }
726
+ async handleContactGraphEvent(handler, payload) {
727
+ if (!handler)
728
+ return;
729
+ try {
730
+ await handler(payload);
731
+ }
732
+ catch (error) {
733
+ console.error('[canon-sdk] Contact-graph handler failed:', error instanceof Error ? error.message : error);
734
+ }
735
+ }
736
+ async handleMessageUpdatedEvent(payload) {
737
+ if (!this.messageUpdatedHandler)
738
+ return;
739
+ try {
740
+ await this.messageUpdatedHandler(payload);
741
+ }
742
+ catch (error) {
743
+ console.error('[canon-sdk] Message-updated handler failed:', error instanceof Error ? error.message : error);
744
+ }
745
+ }
746
+ async stop() {
747
+ if (!this.running)
748
+ return;
749
+ this.running = false;
750
+ this.stopRuntimeControlPolling();
751
+ // Clear session state if enabled (uses cached IDs — no network call during shutdown)
752
+ const runtimeState = this.createRuntimeStatePublisher();
753
+ if (this.options.sessionState && runtimeState) {
754
+ for (const id of this.cachedConversationIds) {
755
+ Promise.resolve(runtimeState.clearSessionState(id)).catch(() => { });
756
+ }
757
+ }
758
+ if (runtimeState) {
759
+ for (const id of this.cachedConversationIds) {
760
+ Promise.resolve(runtimeState.clearTurnState(id)).catch(() => { });
761
+ }
762
+ }
763
+ await this.clearAgentRuntime();
764
+ this.realtimeManager?.stop();
765
+ this.sessionManager?.destroy();
766
+ this.authManager.destroy();
767
+ this.debouncer.destroy();
768
+ console.log('[canon-sdk] Stopped');
769
+ }
770
+ hasInterruptSupport() {
771
+ return Boolean(this.interruptHandler);
772
+ }
773
+ hasStopAndDropSupport() {
774
+ return Boolean(this.stopAndDropHandler);
775
+ }
776
+ hasNewSessionSupport() {
777
+ return Boolean(this.newSessionHandler);
778
+ }
779
+ hasRuntimeSignalSupport() {
780
+ return this.hasInterruptSupport() || this.hasStopAndDropSupport() || this.hasNewSessionSupport();
781
+ }
782
+ hasRuntimePrimitiveSupport() {
783
+ return this.primitiveHandlers.size > 0 || Boolean(this.primitiveFallbackHandler);
784
+ }
785
+ hasRuntimeControlSupport() {
786
+ return this.hasRuntimeSignalSupport() || this.hasRuntimePrimitiveSupport();
787
+ }
788
+ supportsInputInterrupt() {
789
+ return this.hasInterruptSupport() && this.options.runtimeDescriptor?.supportsInputInterrupt !== false;
790
+ }
791
+ buildRuntimeDescriptor() {
792
+ const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
793
+ const hasInterrupt = this.hasInterruptSupport();
794
+ const hasStopAndDrop = this.hasStopAndDropSupport();
795
+ const hasNewSession = this.hasNewSessionSupport();
796
+ const commands = [...(source.commands ?? [])].filter((command) => {
797
+ if (command.dispatch.kind === 'signal') {
798
+ if (command.dispatch.signal === 'interrupt')
799
+ return hasInterrupt;
800
+ if (command.dispatch.signal === 'stop_and_drop')
801
+ return hasStopAndDrop;
802
+ if (command.dispatch.signal === 'new_session')
803
+ return hasNewSession;
804
+ return false;
805
+ }
806
+ if (command.dispatch.kind !== 'primitive')
807
+ return true;
808
+ return this.primitiveHandlers.has(command.dispatch.primitive)
809
+ || Boolean(this.primitiveFallbackHandler);
810
+ });
811
+ const hasInterruptAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'interrupt');
812
+ const hasStopAndDropAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'stop_and_drop');
813
+ const hasNewSessionAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'new_session');
814
+ if (hasInterrupt && !hasInterruptAction) {
815
+ commands.push(RUNTIME_STOP_ACTION);
816
+ }
817
+ if (hasStopAndDrop && this.sessionManager && !hasStopAndDropAction) {
818
+ commands.push(RUNTIME_STOP_AND_DROP_ACTION);
819
+ }
820
+ if (hasNewSession && !hasNewSessionAction) {
821
+ commands.push(RUNTIME_NEW_SESSION_ACTION);
822
+ }
823
+ const hasCommandForPrimitive = (primitive) => commands.some((command) => (command.primitive === primitive
824
+ || (command.dispatch.kind === 'primitive' && command.dispatch.primitive === primitive)));
825
+ for (const primitive of this.primitiveHandlers.keys()) {
826
+ if (!hasCommandForPrimitive(primitive)) {
827
+ commands.push(STANDARD_PRIMITIVE_COMMANDS[primitive]);
828
+ }
829
+ }
830
+ return {
831
+ ...source,
832
+ supportsInterrupt: hasInterrupt,
833
+ supportsInputInterrupt: source.supportsInputInterrupt === false ? false : hasInterrupt,
834
+ commands: normalizeRuntimeCommandDescriptors(commands),
835
+ };
836
+ }
837
+ buildRuntimeCapabilities() {
838
+ return {
839
+ ...SDK_RUNTIME_CAPABILITIES,
840
+ supportsInterrupt: this.hasInterruptSupport(),
841
+ supportsInputInterrupt: this.supportsInputInterrupt(),
842
+ supportsQueue: Boolean(this.sessionManager),
843
+ };
844
+ }
845
+ async publishAgentRuntime() {
846
+ const publisher = this.createRuntimeStatePublisher();
847
+ if (!publisher)
848
+ return;
849
+ await publisher.publishAgentRuntime({
850
+ runtimeDescriptor: this.buildRuntimeDescriptor(),
851
+ });
852
+ }
853
+ startRuntimeHeartbeat() {
854
+ void this.publishAgentRuntime();
855
+ if (this.runtimeHeartbeatTimer)
856
+ return;
857
+ this.runtimeHeartbeatTimer = setInterval(() => {
858
+ void this.publishAgentRuntime();
859
+ }, AGENT_RUNTIME_HEARTBEAT_MS);
860
+ this.runtimeHeartbeatTimer.unref?.();
861
+ }
862
+ stopRuntimeHeartbeat() {
863
+ if (this.runtimeHeartbeatTimer) {
864
+ clearInterval(this.runtimeHeartbeatTimer);
865
+ this.runtimeHeartbeatTimer = null;
866
+ }
867
+ void this.clearAgentRuntime();
868
+ }
869
+ async clearAgentRuntime() {
870
+ await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
871
+ }
872
+ rememberConversationId(conversationId) {
873
+ if (this.cachedConversationIds.includes(conversationId))
874
+ return;
875
+ this.cachedConversationIds.push(conversationId);
876
+ }
877
+ rememberConversationMembers(conversations) {
878
+ for (const conversation of conversations) {
879
+ this.conversationMemberIds.set(conversation.id, [...(conversation.memberIds ?? [])]);
880
+ }
881
+ }
882
+ handleConversationUpdated(payload) {
883
+ const rawMemberIds = payload.changes.memberIds;
884
+ if (!Array.isArray(rawMemberIds))
885
+ return;
886
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
887
+ const hadPreviousMemberIds = this.conversationMemberIds.has(payload.conversationId);
888
+ const previousMemberIds = this.conversationMemberIds.get(payload.conversationId) ?? [];
889
+ const membershipChange = payload.membershipChange
890
+ ?? (hadPreviousMemberIds ? diffCanonMemberIds(previousMemberIds, memberIds) : null);
891
+ this.conversationMemberIds.set(payload.conversationId, memberIds);
892
+ if (membershipChange) {
893
+ this.pendingMembershipChanges.set(payload.conversationId, membershipChange);
894
+ }
895
+ if (this.agentId && !memberIds.includes(this.agentId)) {
896
+ this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
897
+ }
898
+ else {
899
+ this.rememberConversationId(payload.conversationId);
900
+ }
901
+ }
902
+ buildGroupContext(input) {
903
+ return buildCanonGroupContext({
904
+ conversation: input.conversation,
905
+ messages: [...input.history, ...input.messages],
906
+ agentId: input.agent.agentId,
907
+ ownerId: input.agent.ownerId,
908
+ ownerName: input.agent.ownerName,
909
+ membershipChange: input.membershipChange,
910
+ });
911
+ }
912
+ /**
913
+ * Shared `/control` channel poller, configured to the agent-sdk host
914
+ * profile pinned by core's characterization tests: flat 2s single-flight
915
+ * cadence, parallel conversations, signal + primitive keys (no session),
916
+ * eager signal baseline, and TTL'd primitive dedupe released on successful
917
+ * consume. The poller talks only to the scoped RTDB handle captured in
918
+ * start() — never the module-global default client.
919
+ */
920
+ ensureControlPoller() {
921
+ if (this.controlPoller)
922
+ return this.controlPoller;
923
+ if (!this.rtdbHandle)
924
+ return null;
925
+ this.controlPoller = new ControlChannelPoller({
926
+ rtdb: this.rtdbHandle,
927
+ agentId: () => this.agentId,
928
+ conversationIds: () => this.cachedConversationIds,
929
+ cadence: { kind: 'fixed', intervalMs: RUNTIME_CONTROL_POLL_INTERVAL_MS },
930
+ pollOnStart: false,
931
+ conversationConcurrency: 'parallel',
932
+ handlers: {
933
+ signal: {
934
+ handle: (event) => this.handleRuntimeSignalEvent(event),
935
+ consumeOnError: true,
936
+ },
937
+ primitive: {
938
+ handle: (event) => this.handleRuntimePrimitiveEvent(event),
939
+ consumeOnError: true,
940
+ ordering: 'sequential',
941
+ dedupeTtlMs: RUNTIME_PRIMITIVE_DEDUPE_TTL_MS,
942
+ dedupeMaxEntries: RUNTIME_PRIMITIVE_DEDUPE_MAX,
943
+ releaseDedupeOnConsume: true,
944
+ },
945
+ },
946
+ onError: (error) => {
947
+ // Read/consume failures stay silent (the legacy loop swallowed them);
948
+ // handler-scope errors are host dispatch bugs worth surfacing.
949
+ if (error.scope === 'handler') {
950
+ console.error(`[canon-sdk] Runtime control ${error.key ?? 'poll'} dispatch failed for ${error.conversationId}:`, error.error);
951
+ }
952
+ },
953
+ });
954
+ return this.controlPoller;
955
+ }
956
+ async baselineRuntimeControlSignals(conversationIds) {
957
+ if (!this.hasRuntimeSignalSupport())
958
+ return;
959
+ await this.ensureControlPoller()?.baseline(conversationIds);
960
+ }
961
+ startRuntimeControlPolling() {
962
+ if (!this.hasRuntimeControlSupport())
963
+ return;
964
+ this.ensureControlPoller()?.start();
965
+ }
966
+ stopRuntimeControlPolling() {
967
+ this.controlPoller?.stop();
968
+ }
969
+ async handleRuntimePrimitiveEvent(event) {
970
+ // Requests only belong to this runtime once primitive handlers exist —
971
+ // defer otherwise: the request stays in RTDB AND unseen, so a handler
972
+ // registered via onPrimitive() after start still receives it.
973
+ if (!this.hasRuntimePrimitiveSupport())
974
+ return { defer: true };
975
+ const { conversationId, requestId, value } = event;
976
+ const primitive = value.id;
977
+ // Unknown primitives and unhandled ids fall through so the poller
978
+ // consumes the request without dispatching, matching the legacy loop.
979
+ if (!isRuntimePrimitiveId(primitive))
980
+ return;
981
+ const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
982
+ if (!handler)
983
+ return;
984
+ await Promise.resolve(handler({
985
+ conversationId,
986
+ primitive,
987
+ args: normalizePrimitiveArgs(value.args),
988
+ requestId,
989
+ updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
990
+ rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
991
+ alias: typeof value.alias === 'string' ? value.alias : undefined,
992
+ })).catch((error) => {
993
+ console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
994
+ });
995
+ }
996
+ async handleRuntimeSignalEvent(event) {
997
+ // Signals only belong to this runtime once signal handlers exist — defer
998
+ // otherwise: the signal stays in RTDB AND unseen, so a handler registered
999
+ // via on('interrupt' | ...) after start still receives it.
1000
+ if (!this.hasRuntimeSignalSupport())
1001
+ return { defer: true };
1002
+ const { conversationId, type: signal, updatedAt } = event;
1003
+ const handler = signal === 'new_session'
1004
+ ? this.newSessionHandler
1005
+ : signal === 'stop_and_drop'
1006
+ ? this.stopAndDropHandler
1007
+ : this.interruptHandler;
1008
+ // No handler for this specific signal: fall through so the poller
1009
+ // consumes the node without dispatching, matching the legacy loop.
1010
+ if (!handler)
1011
+ return;
1012
+ const activeTurn = this.firstActiveTurn(conversationId);
1013
+ const abortSignal = this.abortActiveTurns(conversationId);
1014
+ const droppedMessages = signal === 'new_session'
1015
+ ? this.sessionManager?.resetSession(conversationId) ?? []
1016
+ : signal === 'stop_and_drop'
1017
+ ? this.sessionManager?.dropQueued(conversationId) ?? []
1018
+ : [];
1019
+ const droppedMessageIds = droppedMessages.map((message) => message.id);
1020
+ await Promise.all(droppedMessages.map((message) => {
1021
+ if (message.metadata?.inboundDisposition !== 'queued')
1022
+ return Promise.resolve();
1023
+ return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
1024
+ }));
1025
+ await this.publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, {
1026
+ hasActiveTurn: Boolean(abortSignal),
1027
+ droppedCount: droppedMessages.length,
1028
+ });
1029
+ await Promise.resolve(handler({
1030
+ conversationId,
1031
+ signal,
1032
+ updatedAt: updatedAt || undefined,
1033
+ abortSignal,
1034
+ droppedMessageIds,
1035
+ })).catch((error) => {
1036
+ console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
1037
+ });
1038
+ }
1039
+ firstActiveTurn(conversationId) {
1040
+ const turns = this.activeTurns.get(conversationId);
1041
+ if (!turns || turns.size === 0)
1042
+ return null;
1043
+ return turns.values().next().value ?? null;
1044
+ }
1045
+ async publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, outcome) {
1046
+ if (!this.agentId)
1047
+ return;
1048
+ const shouldPublishInterrupted = signal === 'interrupt'
1049
+ || signal === 'stop_and_drop'
1050
+ || outcome.hasActiveTurn
1051
+ || outcome.droppedCount > 0;
1052
+ const runtimeState = shouldPublishInterrupted
1053
+ ? this.createRuntimeStatePublisher()
1054
+ : null;
1055
+ if (runtimeState) {
1056
+ await Promise.resolve(runtimeState.writeTurnState(conversationId, {
1057
+ turnId: activeTurn?.turnId ?? null,
1058
+ state: 'interrupted',
1059
+ queueDepth: this.sessionManager?.getQueueDepth(conversationId) ?? 0,
1060
+ currentSpeakerId: this.agentId,
1061
+ activeMessageIds: activeTurn?.activeMessageIds ?? [],
1062
+ capabilities: this.buildRuntimeCapabilities(),
1063
+ ...(activeTurn?.openedAt ? { openedAt: activeTurn.openedAt } : {}),
1064
+ completedAt: { '.sv': 'timestamp' },
1065
+ })).catch(() => { });
1066
+ }
1067
+ await Promise.all([
1068
+ this.apiClient.clearStreaming(conversationId).catch(() => { }),
1069
+ this.typingSignals.clear(conversationId).catch(() => { }),
1070
+ ]);
1071
+ }
1072
+ abortActiveTurns(conversationId) {
1073
+ const controllers = this.activeAbortControllers.get(conversationId);
1074
+ if (!controllers || controllers.size === 0)
1075
+ return undefined;
1076
+ const abortSignal = controllers.values().next().value?.signal;
1077
+ for (const controller of controllers) {
1078
+ controller.abort();
1079
+ }
1080
+ return abortSignal;
1081
+ }
1082
+ resolveBatchDeliveryIntent(messages) {
1083
+ return messages.some((message) => normalizeTurnMetadata(message.metadata)?.deliveryIntent === 'interrupt')
1084
+ ? 'interrupt'
1085
+ : 'queue';
1086
+ }
1087
+ async markQueuedMessagesAccepted(conversationId, messages) {
1088
+ await Promise.all(messages.map((message) => {
1089
+ if (!message.id || normalizeTurnMetadata(message.metadata)?.inboundDisposition !== 'queued') {
1090
+ return Promise.resolve();
1091
+ }
1092
+ return this.apiClient.updateMessageDisposition(conversationId, message.id, 'accepted_now').catch(() => { });
1093
+ }));
1094
+ }
1095
+ async notifyMessageInterrupt(conversationId, abortSignal) {
1096
+ if (!abortSignal || !this.interruptHandler)
1097
+ return;
1098
+ await Promise.resolve(this.interruptHandler({
1099
+ conversationId,
1100
+ signal: 'interrupt',
1101
+ abortSignal,
1102
+ droppedMessageIds: [],
1103
+ })).catch((error) => {
1104
+ console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
1105
+ });
1106
+ }
1107
+ /**
1108
+ * Builds a runtime-state publisher bound to this agent's scoped RTDB
1109
+ * handle (captured in start()). Threading the handle keeps every
1110
+ * publish on this agent's own credentials — without it the publisher
1111
+ * would fall back to core's deprecated module-global RTDB client,
1112
+ * where the last-started agent's token wins in multi-agent processes.
1113
+ */
1114
+ createRuntimeStatePublisher() {
1115
+ if (!this.agentId)
1116
+ return null;
1117
+ return createRuntimeStatePublisher({
1118
+ agentId: this.agentId,
1119
+ clientType: this.options.clientType ?? 'generic',
1120
+ hostMode: this.options.runtimeControlSurface === 'host',
1121
+ ...(this.rtdbHandle ? { rtdb: this.rtdbHandle } : {}),
1122
+ });
1123
+ }
1124
+ requireRuntimeStatePublisher() {
1125
+ const publisher = this.createRuntimeStatePublisher();
1126
+ if (!publisher) {
1127
+ throw new Error('Canon agent must be started before publishing runtime operations.');
1128
+ }
1129
+ return publisher;
1130
+ }
1131
+ async handleMessages(conversationId, messages, provenanceByMessageId) {
1132
+ const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
1133
+ if (actionableMessages.length === 0) {
1134
+ return;
1135
+ }
1136
+ messages = actionableMessages;
1137
+ if (!this.handler) {
1138
+ console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
1139
+ return;
1140
+ }
1141
+ const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
1142
+ const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
1143
+ const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
1144
+ await this.notifyMessageInterrupt(conversationId, abortSignal);
1145
+ if (this.sessionManager) {
1146
+ await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
1147
+ await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId);
1148
+ }, { toFront: shouldInterrupt });
1149
+ }
1150
+ else {
1151
+ await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId);
1152
+ }
1153
+ }
1154
+ async executeHandler(conversationId, messages, session, provenanceByMessageId) {
1155
+ if (!this.handler)
1156
+ return;
1157
+ await this.markQueuedMessagesAccepted(conversationId, messages);
1158
+ const turnId = randomUUID();
1159
+ const turnOpenedAt = Date.now();
1160
+ let turnState = 'thinking';
1161
+ let shouldPersistTurnState = false;
1162
+ let durableMessageSequence = 0;
1163
+ const agentId = this.agentId;
1164
+ const runtimeState = this.createRuntimeStatePublisher();
1165
+ const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
1166
+ const abortController = new AbortController();
1167
+ const throwIfAborted = () => {
1168
+ if (abortController.signal.aborted) {
1169
+ throw createTurnAbortError();
1170
+ }
1171
+ };
1172
+ const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
1173
+ activeControllers.add(abortController);
1174
+ this.activeAbortControllers.set(conversationId, activeControllers);
1175
+ const activeTurns = this.activeTurns.get(conversationId) ?? new Map();
1176
+ activeTurns.set(abortController, {
1177
+ turnId,
1178
+ openedAt: turnOpenedAt,
1179
+ activeMessageIds: messages.map((message) => message.id).filter(Boolean),
1180
+ });
1181
+ this.activeTurns.set(conversationId, activeTurns);
1182
+ const writeTurn = async (state) => {
1183
+ if (!runtimeState || !agentId)
1184
+ return;
1185
+ turnState = state;
1186
+ const isOpenTurn = state === 'thinking'
1187
+ || state === 'streaming'
1188
+ || state === 'tool'
1189
+ || state === 'waiting_input';
1190
+ await Promise.resolve(runtimeState.writeTurnState(conversationId, {
1191
+ turnId,
1192
+ state,
1193
+ queueDepth: queueDepth(),
1194
+ currentSpeakerId: agentId,
1195
+ capabilities: this.buildRuntimeCapabilities(),
1196
+ openedAt: turnOpenedAt,
1197
+ ...(isOpenTurn ? { turnUpdatedAt: Date.now() } : {}),
1198
+ ...(state === 'completed' || state === 'interrupted' || state === 'idle'
1199
+ ? { completedAt: { '.sv': 'timestamp' } }
1200
+ : {}),
1201
+ })).catch(() => { });
1202
+ };
1203
+ const turnOutput = createTurnOutputController({
1204
+ turnId,
1205
+ mode: 'snapshot',
1206
+ writeSnapshot: (snapshot) => this.apiClient.setStreaming({
1207
+ conversationId,
1208
+ text: snapshot.text,
1209
+ status: snapshot.status,
1210
+ messageId: snapshot.messageId,
1211
+ turnId: snapshot.turnId,
1212
+ blocks: snapshot.blocks,
1213
+ }),
1214
+ clearSnapshot: () => this.apiClient.clearStreaming(conversationId),
1215
+ });
1216
+ const setLiveState = async (state, text, streamingStatus) => {
1217
+ throwIfAborted();
1218
+ await writeTurn(state);
1219
+ if (streamingStatus) {
1220
+ await turnOutput.setStatus(streamingStatus, text ?? '');
1221
+ }
1222
+ };
1223
+ // Show thinking indicator and keep it alive (5s client-side expiry)
1224
+ try {
1225
+ await this.typingSignals.start(conversationId, 'thinking');
1226
+ }
1227
+ catch {
1228
+ // Non-critical
1229
+ }
1230
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1231
+ const thinkingKeepalive = setInterval(() => {
1232
+ if (turnState === 'thinking') {
1233
+ turnOutput.setStatus('thinking', 'Thinking...').catch(() => { });
1234
+ }
1235
+ }, 3500);
1236
+ try {
1237
+ // Fetch hydrated history/context from API
1238
+ const page = await this.apiClient.getMessagesPage(conversationId, this.options.historyLimit);
1239
+ const history = page.messages;
1240
+ const historyById = new Map(history.map((message) => [message.id, message]));
1241
+ const hydratedMessages = messages.map((message) => {
1242
+ const hydrated = historyById.get(message.id);
1243
+ if (hydrated)
1244
+ return hydrated;
1245
+ return {
1246
+ ...message,
1247
+ attachments: message.attachments ?? [],
1248
+ };
1249
+ });
1250
+ // If sessions enabled, seed the session with fetched history
1251
+ if (this.sessionManager && session) {
1252
+ this.sessionManager.seedHistory(conversationId, history);
1253
+ }
1254
+ // Get conversation info
1255
+ const conversations = await this.apiClient.getConversations();
1256
+ this.rememberConversationMembers(conversations);
1257
+ const conversation = conversations.find((c) => c.id === conversationId);
1258
+ if (!conversation)
1259
+ return;
1260
+ // Build reply functions
1261
+ const replyFinal = async (text, options) => {
1262
+ throwIfAborted();
1263
+ try {
1264
+ await this.typingSignals.start(conversationId, 'typing');
1265
+ }
1266
+ catch { }
1267
+ throwIfAborted();
1268
+ const sendOptions = withActiveSelfContext(options);
1269
+ const turnTrail = turnOutput.getFinalTrail();
1270
+ const result = await sendDurableMessage(text, {
1271
+ ...sendOptions,
1272
+ metadata: {
1273
+ ...(sendOptions.metadata ?? {}),
1274
+ turnId,
1275
+ turnSemantics: 'turn_complete',
1276
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1277
+ },
1278
+ }, ['sdk', 'final', conversationId, turnId]);
1279
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
1280
+ try {
1281
+ await this.typingSignals.clear(conversationId);
1282
+ }
1283
+ catch { }
1284
+ return result;
1285
+ };
1286
+ const replyProgress = async (text, options) => {
1287
+ throwIfAborted();
1288
+ await setLiveState('streaming', text, 'streaming');
1289
+ if (!options?.durable) {
1290
+ return { turnId, durable: false, messageId: null };
1291
+ }
1292
+ throwIfAborted();
1293
+ const { durable: _durable, ...sendOptions } = options;
1294
+ const sendOptionsWithContext = withActiveSelfContext(sendOptions);
1295
+ const result = await this.apiClient.sendMessage(conversationId, text, {
1296
+ ...sendOptionsWithContext,
1297
+ metadata: {
1298
+ ...(sendOptionsWithContext.metadata ?? {}),
1299
+ turnId,
1300
+ turnSemantics: 'progress',
1301
+ },
1302
+ });
1303
+ return { turnId, durable: true, messageId: result.messageId };
1304
+ };
1305
+ // Enrich history messages with isOwner
1306
+ if (this.agentContext?.ownerId) {
1307
+ const ownerId = this.agentContext.ownerId;
1308
+ for (const m of history) {
1309
+ m.isOwner = m.senderId === ownerId;
1310
+ }
1311
+ }
1312
+ const latestMessage = hydratedMessages[hydratedMessages.length - 1] ?? null;
1313
+ let replyContext = latestMessage
1314
+ ? resolveCanonReplyContext({ message: latestMessage, messages: history })
1315
+ : null;
1316
+ const resolvedActiveSelfContextId = resolveMessageActiveSelfContextId({
1317
+ messageId: latestMessage?.id,
1318
+ activeSelfContextIdByMessageId: page.activeSelfContextIdByMessageId,
1319
+ });
1320
+ const selfContexts = selectActiveSelfContexts(page.selfContexts, resolvedActiveSelfContextId);
1321
+ const activeSelfContextId = selfContexts.length > 0 ? resolvedActiveSelfContextId : null;
1322
+ const withActiveSelfContext = (options) => {
1323
+ const base = { ...(options ?? {}) };
1324
+ if (base.selfContextId !== undefined)
1325
+ return base;
1326
+ return activeSelfContextId ? { ...base, selfContextId: activeSelfContextId } : base;
1327
+ };
1328
+ const sendDurableMessage = (text, options, fallbackMessageIdParts) => {
1329
+ const messageId = options?.messageId
1330
+ ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence += 1]);
1331
+ return sendMessageWithRetry(this.apiClient, conversationId, text, {
1332
+ ...(options ?? {}),
1333
+ messageId,
1334
+ }, {
1335
+ sleep: (ms) => sleepWithAbort(ms, abortController.signal),
1336
+ });
1337
+ };
1338
+ // Build agent context (fallback to minimal if not yet received)
1339
+ const agent = this.agentContext ?? {
1340
+ agentId: this.agentId,
1341
+ ownerId: '',
1342
+ ownerName: '',
1343
+ discoverable: false,
1344
+ inboundPolicy: 'approval-required',
1345
+ groupJoinPolicy: 'approval-required',
1346
+ };
1347
+ const provenance = latestMessage
1348
+ ? resolveRuntimeProvenance({
1349
+ provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
1350
+ conversationId,
1351
+ conversationType: conversation.type,
1352
+ memberCount: conversation.memberIds.length,
1353
+ senderId: latestMessage.senderId,
1354
+ senderName: latestMessage.senderName ?? latestMessage.senderId,
1355
+ senderType: latestMessage.senderType,
1356
+ isOwner: latestMessage.isOwner,
1357
+ agentId: agent.agentId,
1358
+ mentions: latestMessage.mentions,
1359
+ activeSelfContextId,
1360
+ selfContexts,
1361
+ })
1362
+ : resolveRuntimeProvenance({
1363
+ conversationId,
1364
+ conversationType: conversation.type,
1365
+ memberCount: conversation.memberIds.length,
1366
+ senderId: '',
1367
+ senderType: 'human',
1368
+ isOwner: false,
1369
+ agentId: agent.agentId,
1370
+ activeSelfContextId,
1371
+ selfContexts,
1372
+ });
1373
+ if (replyContext?.found && replyContext.attachments?.length) {
1374
+ try {
1375
+ const materializedReply = await materializeReplyContextMedia(replyContext, {
1376
+ agentId: agent.agentId,
1377
+ conversationId,
1378
+ });
1379
+ replyContext = materializedReply.replyContext;
1380
+ }
1381
+ catch (error) {
1382
+ console.error(`[canon-sdk] Failed to materialize reply context media for ${conversationId}:`, error instanceof Error ? error.message : error);
1383
+ }
1384
+ }
1385
+ const membershipChange = this.pendingMembershipChanges.get(conversationId) ?? null;
1386
+ this.pendingMembershipChanges.delete(conversationId);
1387
+ const groupContext = this.buildGroupContext({
1388
+ conversation,
1389
+ history,
1390
+ messages: hydratedMessages,
1391
+ agent,
1392
+ membershipChange,
1393
+ });
1394
+ const participationHistory = buildParticipationHistorySnapshot(history, agent.agentId);
1395
+ const turnContext = buildCanonTurnContextV2({
1396
+ content: latestMessage ? renderCanonHostInboundContent(latestMessage) : '[Empty message]',
1397
+ conversationId,
1398
+ participantContext: {
1399
+ conversationType: conversation.type,
1400
+ memberCount: conversation.memberIds.length,
1401
+ senderType: latestMessage?.senderType ?? provenance.sender.type,
1402
+ senderName: latestMessage?.senderName
1403
+ ?? provenance.sender.name
1404
+ ?? latestMessage?.senderId
1405
+ ?? provenance.sender.id
1406
+ ?? 'unknown',
1407
+ isOwner: provenance.sender.isOwner,
1408
+ mentionedAgent: provenance.mentionedAgent,
1409
+ ...(groupContext ? { groupContext } : {}),
1410
+ ...(groupContext && membershipChange ? { groupContextMode: 'membership_change' } : {}),
1411
+ recentSenderTypes: participationHistory.recentSenderTypes,
1412
+ recentHumanCount: participationHistory.recentHumanCount,
1413
+ recentAgentCount: participationHistory.recentAgentCount,
1414
+ consecutiveAgentTurns: participationHistory.consecutiveAgentTurns,
1415
+ currentAgentStreakStartedByHuman: participationHistory.currentAgentStreakStartedByHuman,
1416
+ },
1417
+ behavior: page.behavior ?? conversation.behavior,
1418
+ selfContexts,
1419
+ activeSelfContextId,
1420
+ provenance,
1421
+ replyContext,
1422
+ message: latestMessage ?? undefined,
1423
+ });
1424
+ const requestedTurnMode = latestMessage
1425
+ ? normalizeTurnMetadata(latestMessage.metadata)?.requestedTurnMode ?? null
1426
+ : null;
1427
+ // Build context methods bound to this conversation
1428
+ const deleteMessage = (messageId) => this.apiClient.deleteMessage(conversationId, messageId);
1429
+ const markAsRead = () => this.apiClient.markAsRead(conversationId);
1430
+ const leave = () => this.apiClient.leaveConversation(conversationId);
1431
+ const react = (messageId, emoji) => this.apiClient.react(conversationId, messageId, emoji);
1432
+ const addMember = (userId) => this.apiClient.addMember(conversationId, userId);
1433
+ const removeMember = (userId) => this.apiClient.removeMember(conversationId, userId);
1434
+ const sendContextualMessage = (target, text, options) => this.apiClient.sendContextualMessage({
1435
+ sourceConversationId: conversationId,
1436
+ ...target,
1437
+ text,
1438
+ ...options,
1439
+ messageOptions: {
1440
+ ...(options.messageOptions ?? {}),
1441
+ metadata: {
1442
+ ...(options.messageOptions?.metadata ?? {}),
1443
+ turnId,
1444
+ turnSemantics: 'turn_complete',
1445
+ },
1446
+ },
1447
+ });
1448
+ const reachOut = (card, options) => this.reachOut(card, {
1449
+ ...(options ?? {}),
1450
+ sourceConversationId: conversationId,
1451
+ });
1452
+ const requestApproval = async (request) => {
1453
+ throwIfAborted();
1454
+ const manager = this.ensureApprovalManager(agent);
1455
+ if (!manager) {
1456
+ return { decision: 'deny' };
1457
+ }
1458
+ shouldPersistTurnState = true;
1459
+ try {
1460
+ try {
1461
+ await turnOutput.addBlock({
1462
+ id: `approval:${request.runtimeId ?? request.toolName}:${turnId}`,
1463
+ kind: 'approval',
1464
+ status: 'pending',
1465
+ title: request.toolSummary ?? request.toolName,
1466
+ summary: request.risk ?? request.category,
1467
+ });
1468
+ await turnOutput.waitingInput();
1469
+ }
1470
+ catch { }
1471
+ await writeTurn('waiting_input');
1472
+ try {
1473
+ await this.typingSignals.clear(conversationId);
1474
+ }
1475
+ catch { }
1476
+ const result = await manager.requestApproval(conversationId, request.toolName, request.toolInput ?? {}, {
1477
+ riskLevel: request.riskLevel,
1478
+ risk: request.risk,
1479
+ category: request.category,
1480
+ runtimeId: request.runtimeId,
1481
+ turnId: request.turnId ?? turnId,
1482
+ native: request.native,
1483
+ toolSummary: request.toolSummary,
1484
+ details: request.details,
1485
+ ignoreSessionRules: request.ignoreSessionRules,
1486
+ allowSessionRule: request.allowSessionRule,
1487
+ });
1488
+ throwIfAborted();
1489
+ shouldPersistTurnState = false;
1490
+ try {
1491
+ await turnOutput.completeBlock(`approval:${request.runtimeId ?? request.toolName}:${turnId}`, {
1492
+ summary: `Decision: ${result.decision}`,
1493
+ });
1494
+ }
1495
+ catch { }
1496
+ try {
1497
+ await this.typingSignals.start(conversationId, 'thinking');
1498
+ }
1499
+ catch { }
1500
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1501
+ return result;
1502
+ }
1503
+ catch (error) {
1504
+ if (abortController.signal.aborted || isAbortLikeError(error)) {
1505
+ throw error;
1506
+ }
1507
+ shouldPersistTurnState = false;
1508
+ return { decision: 'deny' };
1509
+ }
1510
+ };
1511
+ const requestRuntimeInput = async (request) => {
1512
+ throwIfAborted();
1513
+ const inputId = safeRuntimeInputId(request.inputId, request.kind);
1514
+ const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1515
+ const expiresAtMs = Date.now() + timeoutMs;
1516
+ const expiresAt = new Date(expiresAtMs).toISOString();
1517
+ let result = { status: 'timeout', inputId };
1518
+ let requestCreated = false;
1519
+ shouldPersistTurnState = true;
1520
+ try {
1521
+ await this.apiClient.createRuntimeInputRequest({
1522
+ conversationId,
1523
+ inputId,
1524
+ kind: request.kind,
1525
+ expiresAt: expiresAtMs,
1526
+ responseUserId: agent.ownerId,
1527
+ title: request.title,
1528
+ prompt: request.prompt,
1529
+ ...(request.choices ? { choices: request.choices } : {}),
1530
+ ...(request.questions ? { questions: request.questions } : {}),
1531
+ ...(request.secretName ? { secretName: request.secretName } : {}),
1532
+ ...(request.native ? { native: request.native } : {}),
1533
+ ...(request.sensitive !== undefined ? { sensitive: request.sensitive } : {}),
1534
+ turnId: request.turnId ?? turnId,
1535
+ });
1536
+ requestCreated = true;
1537
+ try {
1538
+ await turnOutput.addBlock({
1539
+ id: `input:${inputId}`,
1540
+ kind: 'input',
1541
+ status: 'pending',
1542
+ title: request.title ?? request.prompt ?? request.kind,
1543
+ summary: request.kind,
1544
+ });
1545
+ await turnOutput.waitingInput();
1546
+ }
1547
+ catch { }
1548
+ await writeTurn('waiting_input');
1549
+ try {
1550
+ await this.typingSignals.clear(conversationId);
1551
+ }
1552
+ catch { }
1553
+ // The shared engine owns the poll-with-abort loop + post-deadline
1554
+ // consume; the request was already created above, so the descriptor's
1555
+ // create is a no-op. On abort the manager fires the descriptor's
1556
+ // built-in cancel (consume({ cancel: true })) via the passed signal.
1557
+ result = await this.ensureRuntimeRequestManager().request('input', conversationId, { kind: request.kind }, {
1558
+ requestId: inputId,
1559
+ expiresAt: expiresAtMs,
1560
+ signal: abortController.signal,
1561
+ });
1562
+ const outcome = buildRuntimeInputOutcome(inputId, result.status, {
1563
+ kind: request.kind,
1564
+ reason: result.status,
1565
+ });
1566
+ await sendDurableMessage(outcome.text, {
1567
+ metadata: {
1568
+ ...outcome.metadata,
1569
+ turnId: request.turnId ?? turnId,
1570
+ turnSemantics: 'control',
1571
+ replyBehavior: 'suppress_auto_reply',
1572
+ },
1573
+ }, ['sdk', 'runtime-input-outcome', conversationId, request.turnId ?? turnId, inputId, result.status]);
1574
+ throwIfAborted();
1575
+ shouldPersistTurnState = false;
1576
+ try {
1577
+ await turnOutput.completeBlock(`input:${inputId}`, {
1578
+ summary: `Input ${result.status}`,
1579
+ });
1580
+ }
1581
+ catch { }
1582
+ try {
1583
+ await this.typingSignals.start(conversationId, 'thinking');
1584
+ }
1585
+ catch { }
1586
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1587
+ return result;
1588
+ }
1589
+ catch (error) {
1590
+ if (abortController.signal.aborted || isAbortLikeError(error)) {
1591
+ if (requestCreated) {
1592
+ // Abort landing before the manager wired its cancel (e.g. during
1593
+ // the pre-request writeTurn/typing round-trips) leaves the
1594
+ // server-side request pending + responder-actionable; cancel it so
1595
+ // a late (possibly sensitive) answer can't resolve a dead request.
1596
+ await this.apiClient
1597
+ .consumeRuntimeInputResponse({ conversationId, inputId, cancel: true })
1598
+ .catch(() => { });
1599
+ }
1600
+ throw error;
1601
+ }
1602
+ if (!requestCreated) {
1603
+ shouldPersistTurnState = false;
1604
+ throw error;
1605
+ }
1606
+ shouldPersistTurnState = false;
1607
+ return result;
1608
+ }
1609
+ };
1610
+ const sendCard = async (request) => {
1611
+ throwIfAborted();
1612
+ const cardId = safeRuntimeCardId(request.cardId ?? request.card.cardId);
1613
+ const explicitExpiresAt = request.expiresAt instanceof Date
1614
+ ? request.expiresAt.getTime()
1615
+ : typeof request.expiresAt === 'number'
1616
+ ? request.expiresAt
1617
+ : typeof request.expiresAt === 'string'
1618
+ ? Date.parse(request.expiresAt)
1619
+ : null;
1620
+ const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1621
+ const expiresAtMs = explicitExpiresAt && Number.isFinite(explicitExpiresAt)
1622
+ ? explicitExpiresAt
1623
+ : Date.now() + timeoutMs;
1624
+ // Fire-and-forget: post the durable card and return. The backend treats an
1625
+ // action-less card as display (no pending state, no response expected).
1626
+ await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({ request, conversationId, cardId, fallbackTurnId: turnId, expiresAtMs }));
1627
+ try {
1628
+ await turnOutput.addBlock({
1629
+ id: `card:${cardId}`,
1630
+ kind: 'status',
1631
+ status: 'completed',
1632
+ title: request.card.title,
1633
+ summary: request.card.template ?? 'runtime card',
1634
+ });
1635
+ }
1636
+ catch { }
1637
+ return { status: 'displayed', cardId };
1638
+ };
1639
+ const requestCard = async (request) => {
1640
+ throwIfAborted();
1641
+ // Action-less cards are fire-and-forget display/report cards — never block.
1642
+ const hasActions = Array.isArray(request.card.blocks)
1643
+ && request.card.blocks.some((block) => block.kind === 'actions');
1644
+ if (!hasActions)
1645
+ return sendCard(request);
1646
+ const cardId = safeRuntimeCardId(request.cardId ?? request.card.cardId);
1647
+ const explicitExpiresAt = request.expiresAt instanceof Date
1648
+ ? request.expiresAt.getTime()
1649
+ : typeof request.expiresAt === 'number'
1650
+ ? request.expiresAt
1651
+ : typeof request.expiresAt === 'string'
1652
+ ? Date.parse(request.expiresAt)
1653
+ : null;
1654
+ const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1655
+ const expiresAtMs = explicitExpiresAt && Number.isFinite(explicitExpiresAt)
1656
+ ? explicitExpiresAt
1657
+ : Date.now() + timeoutMs;
1658
+ let result = { status: 'timeout', cardId };
1659
+ let requestCreated = false;
1660
+ let requestResolved = false;
1661
+ shouldPersistTurnState = true;
1662
+ try {
1663
+ await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({ request, conversationId, cardId, fallbackTurnId: turnId, expiresAtMs }));
1664
+ requestCreated = true;
1665
+ try {
1666
+ await turnOutput.addBlock({
1667
+ id: `card:${cardId}`,
1668
+ kind: 'input',
1669
+ status: 'pending',
1670
+ title: request.card.title,
1671
+ summary: request.card.template ?? 'runtime card',
1672
+ });
1673
+ await turnOutput.waitingInput();
1674
+ }
1675
+ catch { }
1676
+ await writeTurn('waiting_input');
1677
+ try {
1678
+ await this.typingSignals.clear(conversationId);
1679
+ }
1680
+ catch { }
1681
+ // The shared engine owns the poll-with-abort loop + post-deadline
1682
+ // consume; the request was already created above, so the descriptor's
1683
+ // create is a no-op. On abort the manager fires the descriptor's
1684
+ // built-in cancel (consume({ cancel: true })) via the passed signal.
1685
+ result = await this.ensureRuntimeRequestManager().request('card', conversationId, { card: request.card }, {
1686
+ requestId: cardId,
1687
+ expiresAt: expiresAtMs,
1688
+ signal: abortController.signal,
1689
+ });
1690
+ requestResolved = true;
1691
+ // The interactive path never yields 'displayed' (that returns early via
1692
+ // sendCard); narrow for buildRuntimeCardOutcome's resolution status.
1693
+ const resolutionStatus = result.status === 'displayed' ? 'timeout' : result.status;
1694
+ const outcome = buildRuntimeCardOutcome(cardId, resolutionStatus, {
1695
+ reason: resolutionStatus,
1696
+ });
1697
+ await sendDurableMessage(outcome.text, {
1698
+ metadata: {
1699
+ ...outcome.metadata,
1700
+ turnId: request.turnId ?? turnId,
1701
+ turnSemantics: 'control',
1702
+ replyBehavior: 'suppress_auto_reply',
1703
+ },
1704
+ }, ['sdk', 'runtime-card-outcome', conversationId, request.turnId ?? turnId, cardId, resolutionStatus]);
1705
+ throwIfAborted();
1706
+ shouldPersistTurnState = false;
1707
+ try {
1708
+ await turnOutput.completeBlock(`card:${cardId}`, {
1709
+ summary: `Card ${result.status}`,
1710
+ });
1711
+ }
1712
+ catch { }
1713
+ try {
1714
+ await this.typingSignals.start(conversationId, 'thinking');
1715
+ }
1716
+ catch { }
1717
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1718
+ return result;
1719
+ }
1720
+ catch (error) {
1721
+ const shouldSendInterruptedOutcome = requestCreated && !requestResolved;
1722
+ if (abortController.signal.aborted || isAbortLikeError(error)) {
1723
+ if (shouldSendInterruptedOutcome) {
1724
+ // Cancel the server-side card if the abort landed before the
1725
+ // manager wired its cancel, so a late action can't resolve a dead
1726
+ // request (mirrors the runtime-input path).
1727
+ await this.apiClient
1728
+ .consumeRuntimeCardResponse({ conversationId, cardId, cancel: true })
1729
+ .catch(() => { });
1730
+ const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1731
+ await sendDurableMessage(outcome.text, {
1732
+ metadata: {
1733
+ ...outcome.metadata,
1734
+ turnId: request.turnId ?? turnId,
1735
+ turnSemantics: 'control',
1736
+ replyBehavior: 'suppress_auto_reply',
1737
+ },
1738
+ }, ['sdk', 'runtime-card-outcome', conversationId, request.turnId ?? turnId, cardId, 'interrupted']).catch(() => { });
1739
+ }
1740
+ throw error;
1741
+ }
1742
+ if (!requestCreated) {
1743
+ shouldPersistTurnState = false;
1744
+ throw error;
1745
+ }
1746
+ shouldPersistTurnState = false;
1747
+ return result;
1748
+ }
1749
+ };
1750
+ const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, options);
1751
+ const replyWithFile = async (filePath, text = '', options) => {
1752
+ throwIfAborted();
1753
+ try {
1754
+ await this.typingSignals.start(conversationId, 'typing');
1755
+ }
1756
+ catch { }
1757
+ throwIfAborted();
1758
+ try {
1759
+ const turnTrail = turnOutput.getFinalTrail();
1760
+ const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
1761
+ ...(options?.replyTo ? { replyTo: options.replyTo } : {}),
1762
+ ...(options?.replyToPosition != null
1763
+ ? { replyToPosition: options.replyToPosition }
1764
+ : {}),
1765
+ ...(options?.mentions ? { mentions: options.mentions } : {}),
1766
+ ...withActiveSelfContext(options?.selfContextId !== undefined
1767
+ ? { selfContextId: options.selfContextId }
1768
+ : undefined),
1769
+ metadata: {
1770
+ ...(options?.metadata ?? {}),
1771
+ turnId,
1772
+ turnSemantics: 'turn_complete',
1773
+ ...(turnTrail.length > 0 ? { turnTrail } : {}),
1774
+ },
1775
+ ...(options?.fileName ? { fileName: options.fileName } : {}),
1776
+ ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
1777
+ ...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
1778
+ });
1779
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
1780
+ return result;
1781
+ }
1782
+ finally {
1783
+ try {
1784
+ await this.typingSignals.clear(conversationId);
1785
+ }
1786
+ catch { }
1787
+ }
1788
+ };
1789
+ // Invoke handler
1790
+ throwIfAborted();
1791
+ await this.handler({
1792
+ messages: hydratedMessages,
1793
+ history,
1794
+ replyContext,
1795
+ conversationId,
1796
+ conversation,
1797
+ ...(groupContext ? { groupContext } : {}),
1798
+ replyFinal,
1799
+ replyProgress,
1800
+ deleteMessage,
1801
+ markAsRead,
1802
+ leave,
1803
+ react,
1804
+ addMember,
1805
+ removeMember,
1806
+ sendContextualMessage,
1807
+ reachOut,
1808
+ agent,
1809
+ activeSelfContextId,
1810
+ selfContexts,
1811
+ provenance,
1812
+ turnContext,
1813
+ requestedTurnMode,
1814
+ requestApproval,
1815
+ requestRuntimeInput,
1816
+ requestCard,
1817
+ sendCard,
1818
+ abortSignal: abortController.signal,
1819
+ media: {
1820
+ materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
1821
+ if (!message)
1822
+ return Promise.resolve([]);
1823
+ return materializeMessageMedia(message, {
1824
+ agentId: agent.agentId,
1825
+ conversationId,
1826
+ ...(options ?? {}),
1827
+ });
1828
+ },
1829
+ uploadFile,
1830
+ replyWithFile,
1831
+ },
1832
+ session: session
1833
+ ? {
1834
+ id: session.id,
1835
+ messages: session.messages,
1836
+ metadata: session.metadata,
1837
+ queueDepth: queueDepth(),
1838
+ }
1839
+ : undefined,
1840
+ turn: {
1841
+ id: turnId,
1842
+ get state() {
1843
+ return turnState;
1844
+ },
1845
+ setThinking: async (text) => {
1846
+ await setLiveState('thinking', text ?? 'Thinking...', 'thinking');
1847
+ },
1848
+ setStreaming: async (text) => {
1849
+ await setLiveState('streaming', text, 'streaming');
1850
+ },
1851
+ appendDelta: (delta) => {
1852
+ throwIfAborted();
1853
+ turnState = 'streaming';
1854
+ turnOutput.appendDelta(delta);
1855
+ void writeTurn('streaming');
1856
+ },
1857
+ appendBlock: (block) => {
1858
+ throwIfAborted();
1859
+ turnState = 'streaming';
1860
+ turnOutput.appendBlock(block);
1861
+ void writeTurn('streaming');
1862
+ },
1863
+ appendTextSegmentDelta: (id, delta) => {
1864
+ throwIfAborted();
1865
+ turnState = 'streaming';
1866
+ turnOutput.appendTextSegmentDelta(id, delta);
1867
+ void writeTurn('streaming');
1868
+ },
1869
+ replaceTextSegmentSnapshot: (id, text) => {
1870
+ throwIfAborted();
1871
+ turnState = 'streaming';
1872
+ turnOutput.replaceTextSegmentSnapshot(id, text);
1873
+ void writeTurn('streaming');
1874
+ },
1875
+ addBlock: async (block) => {
1876
+ throwIfAborted();
1877
+ return turnOutput.addBlock(block);
1878
+ },
1879
+ updateBlock: async (id, patch) => {
1880
+ throwIfAborted();
1881
+ return turnOutput.updateBlock(id, patch);
1882
+ },
1883
+ completeBlock: async (id, patch) => {
1884
+ throwIfAborted();
1885
+ return turnOutput.completeBlock(id, patch);
1886
+ },
1887
+ failBlock: async (id, patch) => {
1888
+ throwIfAborted();
1889
+ return turnOutput.failBlock(id, patch);
1890
+ },
1891
+ replaceSnapshot: async (text) => {
1892
+ await setLiveState('streaming', text, 'streaming');
1893
+ },
1894
+ flush: async () => {
1895
+ await turnOutput.flush();
1896
+ },
1897
+ clear: async () => {
1898
+ await turnOutput.clear();
1899
+ },
1900
+ setTool: async (text) => {
1901
+ await writeTurn('tool');
1902
+ await turnOutput.addBlock({
1903
+ id: `tool:${Date.now()}`,
1904
+ kind: 'tool',
1905
+ status: 'running',
1906
+ title: text,
1907
+ });
1908
+ await turnOutput.setStatus('tool');
1909
+ },
1910
+ setWaitingInput: async (text) => {
1911
+ shouldPersistTurnState = true;
1912
+ try {
1913
+ await turnOutput.waitingInput();
1914
+ }
1915
+ catch { }
1916
+ await writeTurn('waiting_input');
1917
+ try {
1918
+ await this.typingSignals.clear(conversationId);
1919
+ }
1920
+ catch { }
1921
+ if (text) {
1922
+ await sendDurableMessage(text, {
1923
+ metadata: {
1924
+ turnId,
1925
+ turnSemantics: 'control',
1926
+ replyBehavior: 'suppress_auto_reply',
1927
+ },
1928
+ }, ['sdk', 'waiting-input-note', conversationId, turnId]);
1929
+ }
1930
+ },
1931
+ },
1932
+ });
1933
+ // Auto-mark conversation as read after successful processing
1934
+ if (this.options.autoMarkRead) {
1935
+ try {
1936
+ await this.apiClient.markAsRead(conversationId);
1937
+ }
1938
+ catch {
1939
+ // Non-critical
1940
+ }
1941
+ }
1942
+ }
1943
+ catch (err) {
1944
+ if (abortController.signal.aborted || isAbortLikeError(err)) {
1945
+ await writeTurn('interrupted');
1946
+ return;
1947
+ }
1948
+ console.error(`[canon-sdk] Handler error for ${conversationId}:`, err);
1949
+ await writeTurn('interrupted');
1950
+ }
1951
+ finally {
1952
+ const activeControllers = this.activeAbortControllers.get(conversationId);
1953
+ activeControllers?.delete(abortController);
1954
+ if (activeControllers?.size === 0) {
1955
+ this.activeAbortControllers.delete(conversationId);
1956
+ }
1957
+ const activeTurns = this.activeTurns.get(conversationId);
1958
+ activeTurns?.delete(abortController);
1959
+ if (activeTurns?.size === 0) {
1960
+ this.activeTurns.delete(conversationId);
1961
+ }
1962
+ clearInterval(thinkingKeepalive);
1963
+ // Always clear typing when done
1964
+ try {
1965
+ await this.typingSignals.clear(conversationId);
1966
+ }
1967
+ catch { }
1968
+ try {
1969
+ await turnOutput.clear();
1970
+ }
1971
+ catch { }
1972
+ if (runtimeState && !shouldPersistTurnState) {
1973
+ await Promise.resolve(runtimeState.clearTurnState(conversationId)).catch(() => { });
1974
+ }
1975
+ }
1976
+ }
1977
+ // Static registration helpers (unauthenticated)
1978
+ static async register(options) {
1979
+ const { baseUrl, ...body } = options;
1980
+ return CanonClient.register(baseUrl, body);
1981
+ }
1982
+ static async checkStatus(requestId, options) {
1983
+ const baseUrl = typeof options === 'string' ? options : options?.baseUrl;
1984
+ const pollToken = typeof options === 'string' ? undefined : options?.pollToken;
1985
+ return CanonClient.checkStatus(baseUrl, requestId, pollToken);
1986
+ }
1987
+ static async ackStatus(requestId, options) {
1988
+ const baseUrl = typeof options === 'string' ? options : options?.baseUrl;
1989
+ const pollToken = typeof options === 'string' ? undefined : options?.pollToken;
1990
+ await CanonClient.ackRegistrationStatus(baseUrl, requestId, pollToken);
1991
+ }
1992
+ }