@canonmsg/agent-sdk 10.1.0 → 10.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,8 @@ npm install @canonmsg/agent-sdk
36
36
 
37
37
  The only runtime dependency is `@canonmsg/core`, which npm installs for you. Everything else is native `fetch` and `ReadableStream` (Node.js 18+).
38
38
 
39
+ 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
+
39
41
  ## Configuration
40
42
 
41
43
  | Option | Type | Default | Description |
@@ -49,7 +51,7 @@ The only runtime dependency is `@canonmsg/core`, which npm installs for you. Eve
49
51
  | `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
50
52
  | `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
51
53
  | `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
52
- | `autoMarkRead` | `boolean` | `true` | Advance Canon's read cursor explicitly after handling inbound messages. History fetches are read-only. |
54
+ | `autoMarkRead` | `boolean` | `true` | Advance Canon's read cursor after successful handler completion. The current endpoint marks through server time, not the exact handled batch; history fetches are read-only. |
53
55
  | `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
54
56
  | `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
55
57
  | `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
@@ -151,7 +153,7 @@ Current rules of thumb:
151
153
 
152
154
  ### Runtime primitives
153
155
 
154
- The SDK publishes a fixed catalog of seven runtime commands Canon can dispatch as slash commands. Register handlers with the `runtimePrimitives` option or `agent.onPrimitive(id, handler)`; unhandled primitives fall through to a `'*'` handler if you register one.
156
+ The SDK provides a standard catalog of seven runtime primitives. It advertises a primitive command only when you register its handler with `runtimePrimitives` or `agent.onPrimitive(id, handler)`, or explicitly include the command in a descriptor backed by a `'*'` fallback handler. Generic agents publish no commands by default. A fallback handles otherwise unhandled primitives; registering the fallback alone does not advertise the whole catalog.
155
157
 
156
158
  | Primitive | Aliases |
157
159
  |---|---|
@@ -171,6 +173,12 @@ The SDK receives messages over Canon's SSE stream service. `deliveryMode: 'auto'
171
173
 
172
174
  A single connection receives events for all conversations. It auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces a stream error instead of silently pretending a partial catch-up is full replay.
173
175
 
176
+ The SDK publishes runtime heartbeats every 30 seconds while SSE is connected and clears its runtime freshness on disconnect. Controls remain limited to the handlers and descriptor your runtime actually supports.
177
+
178
+ Handler reply helpers carry the inbound SSE event's reply authority automatically. Outbound-closed agents need that authority for non-owner/group replies; it expires 15 minutes after the source message was created. Delayed processing or fetching history does not renew it. See [Replies and polling](https://canonmail.com/agents/contracts#replies-and-polling) for the current limits.
179
+
180
+ With `autoMarkRead: true`, the SDK marks the conversation read after successful handler completion. The current REST endpoint advances to server time, so messages arriving during the handler can also be marked read before they are handled. `ctx.markAsRead()` uses the same endpoint. Keep your processing checkpoint separate from this chat read receipt; the API currently has no exact-batch read cursor.
181
+
174
182
  ## Message Handler
175
183
 
176
184
  The `message` event handler receives a context object with:
@@ -389,7 +397,13 @@ Register `callStarted` / `callEnded` before `start()` — see [Events](#events).
389
397
 
390
398
  ## Agent Registration
391
399
 
392
- Register a new agent using the static helpers (no API key needed):
400
+ For persistent local onboarding, import `ensureAgentProfile` from this package. It reuses an environment-bound profile in `~/.canon/agents.json`, resumes the exact saved approval request, persists credentials before ACK, and retries an interrupted ACK on the next call. `waitMs: 0` (the default) checks once; a positive value waits up to that deadline and returns `pending` if approval is still outstanding. See the [complete runnable quickstart](https://canonmail.com/agents/build#run-a-complete-agent).
401
+
402
+ Adapters with provider-native configuration can use the exported `resumeRegistration` and `RegistrationStore` contract. Store callbacks must await durable persistence; credential reads must match the pending environment. Serialize callers sharing a store. Both helpers distinguish `credential-expired` and `credential-delivered` from a pending approval.
403
+
404
+ For an intentional retry after rejection or a terminal credential outcome, `clearPendingRegistration(profileName)` removes the local pending record. Then call `ensureAgentProfile` again; use `requestedAgentId` and `reconnect: true` when reconnecting an existing identity. A transport failure should resume its saved request.
405
+
406
+ The original static helpers remain available for clients that manage their own lifecycle (no API key needed). `register` also accepts `localRegistrationId`, `requestedAgentId`, and `clientType`; `checkStatus` exposes both `apiKeyDelivered` and `apiKeyExpired`:
393
407
 
394
408
  ```typescript
395
409
  import { CanonAgent } from '@canonmsg/agent-sdk';
@@ -479,6 +493,8 @@ It governs teardown only. Calling `replyFinal()` as well is two explicit decisio
479
493
 
480
494
  `replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
481
495
 
496
+ `replyFinal()` supplies `turnSemantics: 'turn_complete'` by default, making the final eligible to trigger another agent under its participation policy and loop limits. `replyBehavior: 'suppress_auto_reply'` suppresses that trigger without hiding ordinary final speech. Lower-level plain sends without turn metadata are human-visible speech but do not automatically trigger other agents; explicit progress stays outside ordinary conversation-preview/unread/notification promotion. Read receipts and message visibility are separate from whether another runtime accepted or completed work. See [Agent speech and handoffs](https://canonmail.com/agents/contracts#agent-speech-and-handoffs).
497
+
482
498
  ## Turn verbosity
483
499
 
484
500
  By default an agent is **quiet in group conversations and verbose in direct chats**. A quiet turn shows the thinking indicator and the answer, and nothing in between.
@@ -1,4 +1,4 @@
1
- import { type AddMemberResult, type CanonContact, type CommunicateInput, type CommunicateResult, type DiscoverAgentsInput, type DiscoverAgentsResult, type CanonConversation, type CanonConversationsPage, type CanonConversationsPageOptions, type CreateGroupOptions, type CreateGroupResult, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ClearRuntimeActivityOptions, type CanonVoiceSession, type CanonVoiceSessionToken, type CreateVoiceSessionOptions, type VoiceSessionEventPayload } from '@canonmsg/core';
1
+ import { type AddMemberResult, type CanonContact, type CommunicateInput, type CommunicateResult, type DiscoverAgentsInput, type DiscoverAgentsResult, type CanonConversation, type CanonConversationsPage, type CanonConversationsPageOptions, type CreateGroupOptions, type CreateGroupResult, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ClearRuntimeActivityOptions, type RegistrationInput, type RegistrationStatus, type CanonVoiceSession, type CanonVoiceSessionToken, type CreateVoiceSessionOptions, type VoiceSessionEventPayload } from '@canonmsg/core';
2
2
  import type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, MessageHandler, MessageUpdatedHandler, ParticipationSuppressedHandler, RuntimeSignalHandler, RuntimePrimitiveHandler } from './types.js';
3
3
  /**
4
4
  * Contact-graph operations exposed under `agent.contacts`. Wraps the REST
@@ -39,7 +39,6 @@ export declare class CanonAgent {
39
39
  private options;
40
40
  private readonly runtimeConnection;
41
41
  private apiClient;
42
- private authManager;
43
42
  private debouncer;
44
43
  private realtimeManager;
45
44
  private sessionManager;
@@ -81,7 +80,11 @@ export declare class CanonAgent {
81
80
  private runtimeRequestManager;
82
81
  private cachedConversationIds;
83
82
  private running;
84
- private runtimeHeartbeatTimer;
83
+ private startPromise;
84
+ private stopPromise;
85
+ private lifecycleGeneration;
86
+ private runtimeHeartbeat;
87
+ private runtimeStatePublisher;
85
88
  private rtdbHandle;
86
89
  private controlPoller;
87
90
  private readonly activeAbortControllers;
@@ -133,6 +136,7 @@ export declare class CanonAgent {
133
136
  */
134
137
  communicate(input: CommunicateInput): Promise<CommunicateResult>;
135
138
  start(): Promise<void>;
139
+ private startRuntime;
136
140
  createGroup(options: CreateGroupOptions): Promise<CreateGroupResult>;
137
141
  /** Start (or rejoin) a call in a conversation and get the room token. */
138
142
  startCall(options: CreateVoiceSessionOptions): Promise<CanonVoiceSessionToken>;
@@ -170,6 +174,7 @@ export declare class CanonAgent {
170
174
  private handleParticipationSuppressedEvent;
171
175
  private handleMessageUpdatedEvent;
172
176
  stop(): Promise<void>;
177
+ private cleanupRuntime;
173
178
  private hasInterruptSupport;
174
179
  private hasStopAndDropSupport;
175
180
  private hasNewSessionSupport;
@@ -179,10 +184,6 @@ export declare class CanonAgent {
179
184
  private supportsInputInterrupt;
180
185
  private buildRuntimeDescriptor;
181
186
  private buildRuntimeCapabilities;
182
- private publishAgentRuntime;
183
- private startRuntimeHeartbeat;
184
- private stopRuntimeHeartbeat;
185
- private clearAgentRuntime;
186
187
  private rememberConversationId;
187
188
  private rememberConversationMembers;
188
189
  private handleConversationUpdated;
@@ -197,6 +198,7 @@ export declare class CanonAgent {
197
198
  */
198
199
  private ensureControlPoller;
199
200
  private baselineRuntimeControlSignals;
201
+ private baselineAndStartRuntimeControlPolling;
200
202
  private startRuntimeControlPolling;
201
203
  private stopRuntimeControlPolling;
202
204
  private handleRuntimePrimitiveEvent;
@@ -218,25 +220,15 @@ export declare class CanonAgent {
218
220
  private requireRuntimeStatePublisher;
219
221
  private handleMessages;
220
222
  private executeHandler;
221
- static register(options: {
222
- name: string;
223
- description: string;
224
- ownerPhone: string;
223
+ static register(options: Omit<RegistrationInput, 'baseUrl' | 'developerInfo'> & {
225
224
  developerInfo: string;
226
- avatarUrl?: string;
227
225
  } & CanonAgentConnectionOptions): Promise<{
228
226
  requestId: string;
229
227
  pollToken?: string;
230
228
  }>;
231
229
  static checkStatus(requestId: string, options: CanonAgentConnectionOptions & {
232
230
  pollToken?: string;
233
- }): Promise<{
234
- status: string;
235
- agentName: string;
236
- agentId?: string;
237
- apiKey?: string;
238
- apiKeyDelivered?: boolean;
239
- }>;
231
+ }): Promise<RegistrationStatus>;
240
232
  static ackStatus(requestId: string, options: CanonAgentConnectionOptions & {
241
233
  pollToken?: string;
242
234
  }): Promise<void>;
@@ -1,13 +1,11 @@
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, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
1
+ import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
- import { AuthManager } from './auth.js';
4
3
  import { Debouncer } from './debouncer.js';
5
4
  import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
6
5
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
7
6
  import { SessionManager } from './session-manager.js';
8
7
  import { buildTurnStreamingRequest } from './turn-streaming-request.js';
9
8
  import { selectConfiguredTurnVerbosity } from './turn-verbosity-option.js';
10
- const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
11
9
  const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
12
10
  const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
13
11
  const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
@@ -276,7 +274,6 @@ export class CanonAgent {
276
274
  options;
277
275
  runtimeConnection;
278
276
  apiClient;
279
- authManager;
280
277
  debouncer;
281
278
  realtimeManager = null;
282
279
  sessionManager = null;
@@ -318,7 +315,11 @@ export class CanonAgent {
318
315
  runtimeRequestManager = null;
319
316
  cachedConversationIds = [];
320
317
  running = false;
321
- runtimeHeartbeatTimer = null;
318
+ startPromise = null;
319
+ stopPromise = null;
320
+ lifecycleGeneration = 0;
321
+ runtimeHeartbeat = null;
322
+ runtimeStatePublisher = null;
322
323
  rtdbHandle = null;
323
324
  controlPoller = null;
324
325
  activeAbortControllers = new Map();
@@ -354,7 +355,6 @@ export class CanonAgent {
354
355
  ? this.apiClient.setTyping(conversationId, typing, status)
355
356
  : this.apiClient.setTyping(conversationId, typing),
356
357
  });
357
- this.authManager = new AuthManager(this.apiClient);
358
358
  this.debouncer = new Debouncer(this.options.debounceMs);
359
359
  const apiClient = this.apiClient;
360
360
  this.contacts = {
@@ -498,31 +498,25 @@ export class CanonAgent {
498
498
  if (event === 'interrupt') {
499
499
  this.interruptHandler = handler;
500
500
  if (this.running) {
501
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
502
- .then(() => this.startRuntimeControlPolling())
503
- .catch(() => { });
501
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
504
502
  }
505
- void this.publishAgentRuntime().catch(() => { });
503
+ this.runtimeHeartbeat?.refresh();
506
504
  return;
507
505
  }
508
506
  if (event === 'stopAndDrop') {
509
507
  this.stopAndDropHandler = handler;
510
508
  if (this.running) {
511
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
512
- .then(() => this.startRuntimeControlPolling())
513
- .catch(() => { });
509
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
514
510
  }
515
- void this.publishAgentRuntime().catch(() => { });
511
+ this.runtimeHeartbeat?.refresh();
516
512
  return;
517
513
  }
518
514
  if (event === 'newSession') {
519
515
  this.newSessionHandler = handler;
520
516
  if (this.running) {
521
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
522
- .then(() => this.startRuntimeControlPolling())
523
- .catch(() => { });
517
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
524
518
  }
525
- void this.publishAgentRuntime().catch(() => { });
519
+ this.runtimeHeartbeat?.refresh();
526
520
  return;
527
521
  }
528
522
  this.contactRemovedHandler = handler;
@@ -537,7 +531,7 @@ export class CanonAgent {
537
531
  if (this.running) {
538
532
  this.startRuntimeControlPolling();
539
533
  }
540
- void this.publishAgentRuntime().catch(() => { });
534
+ this.runtimeHeartbeat?.refresh();
541
535
  }
542
536
  describeCommands() {
543
537
  return this.buildRuntimeDescriptor().commands ?? [];
@@ -573,13 +567,37 @@ export class CanonAgent {
573
567
  async communicate(input) {
574
568
  return this.apiClient.communicate(input);
575
569
  }
576
- async start() {
570
+ start() {
571
+ if (this.stopPromise)
572
+ return this.stopPromise.then(() => this.start());
573
+ if (this.startPromise)
574
+ return this.startPromise;
577
575
  if (this.running)
578
- return;
576
+ return Promise.resolve();
577
+ const generation = ++this.lifecycleGeneration;
578
+ const startup = this.startRuntime(generation).catch(async (error) => {
579
+ if (generation === this.lifecycleGeneration) {
580
+ this.running = false;
581
+ await this.cleanupRuntime();
582
+ }
583
+ throw error;
584
+ });
585
+ this.startPromise = startup;
586
+ const clearStartup = () => {
587
+ if (this.startPromise === startup)
588
+ this.startPromise = null;
589
+ };
590
+ void startup.then(clearStartup, clearStartup);
591
+ return startup;
592
+ }
593
+ async startRuntime(generation) {
579
594
  await verifyCanonRuntimeConnection(this.runtimeConnection);
580
- if (this.running)
595
+ if (generation !== this.lifecycleGeneration)
581
596
  return;
582
597
  this.running = true;
598
+ if (this.options.sessions?.enabled && !this.sessionManager) {
599
+ this.sessionManager = new SessionManager(this.options.sessions);
600
+ }
583
601
  // The single scoped RTDB client for this agent. Every RTDB consumer in
584
602
  // the SDK (control poller, runtime-state publishers) threads this handle;
585
603
  // the SDK never reads through core's deprecated module-global default,
@@ -588,9 +606,19 @@ export class CanonAgent {
588
606
  rtdbUrl: this.runtimeConnection.rtdbUrl,
589
607
  firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
590
608
  });
591
- // 1. Authenticate
592
- const { agentId } = await this.authManager.authenticate();
609
+ // Identity lookup only. The scoped RTDB client owns Firebase token
610
+ // exchange and refresh; the SSE stream authenticates with the API key.
611
+ const { agentId } = await this.apiClient.getAuthToken();
612
+ if (generation !== this.lifecycleGeneration)
613
+ return;
593
614
  this.agentId = agentId;
615
+ this.runtimeHeartbeat = createRuntimeHeartbeat({
616
+ publisher: this.requireRuntimeStatePublisher(),
617
+ getRuntime: () => ({ runtimeDescriptor: this.buildRuntimeDescriptor() }),
618
+ onError: (error, operation) => {
619
+ console.error(`[canon-sdk] Runtime heartbeat ${operation} failed:`, error);
620
+ },
621
+ });
594
622
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
595
623
  // 2. Wire debouncer to handler
596
624
  this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
@@ -607,6 +635,8 @@ export class CanonAgent {
607
635
  catch {
608
636
  // Non-fatal — delivery mode will fall back to default
609
637
  }
638
+ if (generation !== this.lifecycleGeneration)
639
+ return;
610
640
  // 3a. Determine delivery mode
611
641
  let mode = this.options.deliveryMode;
612
642
  if (mode === 'auto') {
@@ -624,23 +654,29 @@ export class CanonAgent {
624
654
  catch {
625
655
  console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
626
656
  }
657
+ if (generation !== this.lifecycleGeneration)
658
+ return;
627
659
  // 3c. Initialize RTDB session state reporting (opt-in)
628
660
  if (this.options.sessionState) {
629
661
  const runtimeState = this.createRuntimeStatePublisher();
630
- for (const id of this.cachedConversationIds) {
631
- runtimeState?.writeSessionState(id, {
632
- isActive: true,
633
- ...(this.options.clientType ? { clientType: this.options.clientType } : {}),
634
- }).catch(() => { });
635
- }
662
+ await Promise.all(this.cachedConversationIds.map((id) => (runtimeState?.writeSessionState(id, {
663
+ isActive: true,
664
+ ...(this.options.clientType ? { clientType: this.options.clientType } : {}),
665
+ }).catch(() => { }))));
666
+ if (generation !== this.lifecycleGeneration)
667
+ return;
636
668
  if (this.cachedConversationIds.length > 0) {
637
669
  console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
638
670
  }
639
671
  }
640
672
  await this.baselineRuntimeControlSignals(this.cachedConversationIds);
673
+ if (generation !== this.lifecycleGeneration)
674
+ return;
641
675
  this.startRuntimeControlPolling();
642
676
  // 4. Start delivery
643
677
  const { RealtimeManager } = await import('./realtime.js');
678
+ if (generation !== this.lifecycleGeneration)
679
+ return;
644
680
  this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
645
681
  const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled });
646
682
  if (this.voiceEventsEnabled) {
@@ -685,16 +721,24 @@ export class CanonAgent {
685
721
  });
686
722
  rtm.setConnectionHandlers({
687
723
  onConnected: () => {
688
- this.startRuntimeHeartbeat();
724
+ if (!this.running || generation !== this.lifecycleGeneration)
725
+ return;
726
+ this.runtimeHeartbeat?.connect();
689
727
  if (!this.sseConnectedLogged) {
690
728
  this.sseConnectedLogged = true;
691
729
  console.log('[canon-sdk] SSE stream connected');
692
730
  }
693
731
  },
694
- onDisconnected: () => this.stopRuntimeHeartbeat(),
732
+ onDisconnected: () => {
733
+ if (generation === this.lifecycleGeneration) {
734
+ void this.runtimeHeartbeat?.disconnect();
735
+ }
736
+ },
695
737
  });
696
738
  this.realtimeManager = rtm;
697
739
  await rtm.start();
740
+ if (generation !== this.lifecycleGeneration)
741
+ rtm.stop();
698
742
  }
699
743
  async createGroup(options) {
700
744
  return this.apiClient.createGroup(options);
@@ -785,29 +829,58 @@ export class CanonAgent {
785
829
  console.error('[canon-sdk] Message-updated handler failed:', error instanceof Error ? error.message : error);
786
830
  }
787
831
  }
788
- async stop() {
789
- if (!this.running)
790
- return;
832
+ stop() {
833
+ if (this.stopPromise)
834
+ return this.stopPromise;
835
+ if (!this.running && !this.startPromise)
836
+ return Promise.resolve();
837
+ ++this.lifecycleGeneration;
791
838
  this.running = false;
792
839
  this.stopRuntimeControlPolling();
793
- // Clear session state if enabled (uses cached IDs — no network call during shutdown)
794
- const runtimeState = this.createRuntimeStatePublisher();
795
- if (this.options.sessionState && runtimeState) {
796
- for (const id of this.cachedConversationIds) {
797
- Promise.resolve(runtimeState.clearSessionState(id)).catch(() => { });
798
- }
799
- }
800
- if (runtimeState) {
801
- for (const id of this.cachedConversationIds) {
802
- Promise.resolve(runtimeState.clearTurnState(id)).catch(() => { });
803
- }
804
- }
805
- await this.clearAgentRuntime();
806
840
  this.realtimeManager?.stop();
807
- this.sessionManager?.destroy();
808
- this.authManager.destroy();
841
+ void this.runtimeHeartbeat?.disconnect();
842
+ const startup = this.startPromise;
843
+ const stopping = (async () => {
844
+ // Startup checks the generation after each await, so it cannot install
845
+ // new resources after this shutdown or a subsequent start.
846
+ await startup?.catch(() => { });
847
+ await this.cleanupRuntime();
848
+ console.log('[canon-sdk] Stopped');
849
+ })();
850
+ this.stopPromise = stopping;
851
+ const clearStopping = () => {
852
+ if (this.stopPromise === stopping)
853
+ this.stopPromise = null;
854
+ };
855
+ void stopping.then(clearStopping, clearStopping);
856
+ return stopping;
857
+ }
858
+ async cleanupRuntime() {
859
+ this.stopRuntimeControlPolling();
860
+ this.controlPoller = null;
861
+ this.realtimeManager?.stop();
862
+ this.realtimeManager = null;
809
863
  this.debouncer.destroy();
810
- console.log('[canon-sdk] Stopped');
864
+ this.sessionManager?.destroy();
865
+ this.sessionManager = null;
866
+ const heartbeat = this.runtimeHeartbeat;
867
+ this.runtimeHeartbeat = null;
868
+ await heartbeat?.dispose();
869
+ // Startup awaits its initial session writes, so they finish before these
870
+ // clears. Already-running message handlers keep their existing lifecycle.
871
+ const runtimeState = this.runtimeStatePublisher;
872
+ if (runtimeState) {
873
+ await Promise.all(this.cachedConversationIds.flatMap((id) => [
874
+ runtimeState.clearTurnState(id).catch(() => { }),
875
+ ...(this.options.sessionState ? [runtimeState.clearSessionState(id).catch(() => { })] : []),
876
+ ]));
877
+ }
878
+ this.runtimeStatePublisher = null;
879
+ this.rtdbHandle = null;
880
+ this.agentId = null;
881
+ this.agentContext = null;
882
+ this.cachedConversationIds = [];
883
+ this.sseConnectedLogged = false;
811
884
  }
812
885
  hasInterruptSupport() {
813
886
  return Boolean(this.interruptHandler);
@@ -884,33 +957,6 @@ export class CanonAgent {
884
957
  supportsQueue: Boolean(this.sessionManager),
885
958
  };
886
959
  }
887
- async publishAgentRuntime() {
888
- const publisher = this.createRuntimeStatePublisher();
889
- if (!publisher)
890
- return;
891
- await publisher.publishAgentRuntime({
892
- runtimeDescriptor: this.buildRuntimeDescriptor(),
893
- });
894
- }
895
- startRuntimeHeartbeat() {
896
- void this.publishAgentRuntime();
897
- if (this.runtimeHeartbeatTimer)
898
- return;
899
- this.runtimeHeartbeatTimer = setInterval(() => {
900
- void this.publishAgentRuntime();
901
- }, AGENT_RUNTIME_HEARTBEAT_MS);
902
- this.runtimeHeartbeatTimer.unref?.();
903
- }
904
- stopRuntimeHeartbeat() {
905
- if (this.runtimeHeartbeatTimer) {
906
- clearInterval(this.runtimeHeartbeatTimer);
907
- this.runtimeHeartbeatTimer = null;
908
- }
909
- void this.clearAgentRuntime();
910
- }
911
- async clearAgentRuntime() {
912
- await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
913
- }
914
960
  rememberConversationId(conversationId) {
915
961
  if (this.cachedConversationIds.includes(conversationId))
916
962
  return;
@@ -1000,6 +1046,13 @@ export class CanonAgent {
1000
1046
  return;
1001
1047
  await this.ensureControlPoller()?.baseline(conversationIds);
1002
1048
  }
1049
+ async baselineAndStartRuntimeControlPolling() {
1050
+ const generation = this.lifecycleGeneration;
1051
+ await this.baselineRuntimeControlSignals(this.cachedConversationIds);
1052
+ if (this.running && generation === this.lifecycleGeneration) {
1053
+ this.startRuntimeControlPolling();
1054
+ }
1055
+ }
1003
1056
  startRuntimeControlPolling() {
1004
1057
  if (!this.hasRuntimeControlSupport())
1005
1058
  return;
@@ -1160,12 +1213,13 @@ export class CanonAgent {
1160
1213
  // when the agent has not started — same condition as the guard above.
1161
1214
  if (!this.rtdbHandle)
1162
1215
  return null;
1163
- return createRuntimeStatePublisher({
1216
+ this.runtimeStatePublisher ??= createRuntimeStatePublisher({
1164
1217
  agentId: this.agentId,
1165
1218
  clientType: this.options.clientType ?? 'generic',
1166
1219
  hostMode: this.options.runtimeControlSurface === 'host',
1167
1220
  rtdb: this.rtdbHandle,
1168
1221
  });
1222
+ return this.runtimeStatePublisher;
1169
1223
  }
1170
1224
  requireRuntimeStatePublisher() {
1171
1225
  const publisher = this.createRuntimeStatePublisher();
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
+ export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
3
+ export type { EnsureAgentProfileOptions, RegistrationCredentials, RegistrationProgress, RegistrationSession, RegistrationStatus, RegistrationStore, ResumeRegistrationOptions } from '@canonmsg/core';
2
4
  export type { AgentContactsAPI, AgentConversationsAPI, AgentDirectoryAPI, AgentUsersAPI, } from './canon-agent.js';
3
5
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
4
6
  export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, CanonAgentDirectoryEntry, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, DiscoverAgentsInput, DiscoverAgentsResult, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, ResumableMediaUploadResult, MediaAttachment, VideoProcessingStatus, VideoProcessingErrorCode, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimePlanRequestPayload, RuntimePlanRequestResult, SessionRule, } from '@canonmsg/core';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
+ export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
2
3
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
3
4
  export { SessionManager } from './session-manager.js';
4
5
  export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
package/dist/media.js CHANGED
@@ -4,7 +4,7 @@ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, join } from 'node:path';
5
5
  import { Readable, Transform } from 'node:stream';
6
6
  import { pipeline } from 'node:stream/promises';
7
- import { CANON_DIR, renderCanonHostInboundContent, } from '@canonmsg/core';
7
+ import { CANON_DIR, renderCanonHostInboundContent, sanitizeDownloadFileName, } from '@canonmsg/core';
8
8
  import { BoundedFileReadError, readBoundedRegularFileHandle, } from './bounded-file-read.js';
9
9
  const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
10
10
  'image/jpeg',
@@ -65,10 +65,6 @@ function sanitizeSegment(value, fallback) {
65
65
  const sanitized = value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
66
66
  return sanitized || fallback;
67
67
  }
68
- function sanitizeFileName(fileName, fallback) {
69
- const sanitized = basename(fileName).replace(/[^a-zA-Z0-9._-]+/g, '_');
70
- return sanitized || fallback;
71
- }
72
68
  function inferExtension(input) {
73
69
  const explicitExtension = extname(input.attachment.fileName ?? '').toLowerCase();
74
70
  if (explicitExtension) {
@@ -94,7 +90,7 @@ function buildCachePath(input) {
94
90
  });
95
91
  const fallbackName = `${input.attachment.kind}-${input.index}${extension}`;
96
92
  const preferredName = input.attachment.fileName
97
- ? sanitizeFileName(input.attachment.fileName, fallbackName)
93
+ ? sanitizeDownloadFileName(input.attachment.fileName, fallbackName)
98
94
  : fallbackName;
99
95
  const fileName = extname(preferredName) ? preferredName : `${preferredName}${extension}`;
100
96
  return join(rootDir, sanitizeSegment(input.agentId, 'agent'), sanitizeSegment(input.conversationId, 'conversation'), sanitizeSegment(input.messageId, 'message'), `${String(input.index).padStart(2, '0')}-${fileName}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "10.1.0",
3
+ "version": "10.2.1",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^12.1.0"
31
+ "@canonmsg/core": "^12.3.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
package/dist/auth.d.ts DELETED
@@ -1,16 +0,0 @@
1
- import { CanonClient } from '@canonmsg/core';
2
- export declare class AuthManager {
3
- private apiClient;
4
- private expiresAt;
5
- private refreshTimer;
6
- private refreshRetryCount;
7
- constructor(apiClient: CanonClient);
8
- authenticate(): Promise<{
9
- token: string;
10
- agentId: string;
11
- }>;
12
- private scheduleRefresh;
13
- /** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
14
- private scheduleRetry;
15
- destroy(): void;
16
- }
package/dist/auth.js DELETED
@@ -1,54 +0,0 @@
1
- const MAX_REFRESH_RETRIES = 10;
2
- const BASE_RETRY_MS = 30_000;
3
- const MAX_RETRY_BACKOFF_MS = 240_000;
4
- export class AuthManager {
5
- apiClient;
6
- expiresAt = 0;
7
- refreshTimer = null;
8
- refreshRetryCount = 0;
9
- constructor(apiClient) {
10
- this.apiClient = apiClient;
11
- }
12
- async authenticate() {
13
- const result = await this.apiClient.getAuthToken();
14
- this.expiresAt = new Date(result.expiresAt).getTime();
15
- this.refreshRetryCount = 0;
16
- this.scheduleRefresh();
17
- return { token: result.token, agentId: result.agentId };
18
- }
19
- scheduleRefresh() {
20
- if (this.refreshTimer)
21
- clearTimeout(this.refreshTimer);
22
- // Refresh 5 minutes before expiry
23
- const refreshIn = Math.max(0, this.expiresAt - Date.now() - 5 * 60 * 1000);
24
- this.refreshTimer = setTimeout(async () => {
25
- try {
26
- const result = await this.apiClient.getAuthToken();
27
- this.expiresAt = new Date(result.expiresAt).getTime();
28
- this.refreshRetryCount = 0;
29
- this.scheduleRefresh();
30
- }
31
- catch (err) {
32
- console.error('[canon-sdk] Token refresh failed:', err);
33
- this.scheduleRetry();
34
- }
35
- }, refreshIn);
36
- }
37
- /** Retry with exponential backoff (30s -> 60s -> 120s -> 240s cap, max 10 attempts) */
38
- scheduleRetry() {
39
- if (this.refreshRetryCount >= MAX_REFRESH_RETRIES) {
40
- console.error('[canon-sdk] Token refresh failed after maximum retries — agent may stop receiving messages');
41
- return;
42
- }
43
- const backoff = Math.min(BASE_RETRY_MS * Math.pow(2, this.refreshRetryCount), MAX_RETRY_BACKOFF_MS);
44
- this.refreshRetryCount++;
45
- console.warn(`[canon-sdk] Retrying token refresh in ${backoff / 1000}s (attempt ${this.refreshRetryCount}/${MAX_REFRESH_RETRIES})`);
46
- this.refreshTimer = setTimeout(() => this.scheduleRefresh(), backoff);
47
- }
48
- destroy() {
49
- if (this.refreshTimer) {
50
- clearTimeout(this.refreshTimer);
51
- this.refreshTimer = null;
52
- }
53
- }
54
- }