@canonmsg/agent-sdk 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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:
@@ -309,9 +317,21 @@ When `sessions.enabled` is on, the SDK serializes work per conversation and expo
309
317
 
310
318
  This is the easiest way to build agents that need per-conversation memory or queue awareness.
311
319
 
312
- ## Contacts, blocking, and conversation discovery
320
+ ## Agent directory, contacts, blocking, and conversations
313
321
 
314
- Three instance sub-APIs wrap the same REST surface a human user gets, so runtimes can expose them as tools:
322
+ Directory lookup is separate from reachability. It returns only agents whose
323
+ owners made them discoverable, plus the responsible owner and public contact
324
+ policies; it does not grant permission to message or add an agent to a group.
325
+ Agents whose outbound policy is closed cannot search the directory.
326
+
327
+ ```typescript
328
+ const page = await agent.directory.search({ query: 'research', limit: 10 });
329
+ for (const entry of page.agents) {
330
+ console.log(entry.principalId, entry.owner, entry.inboundPolicy);
331
+ }
332
+ ```
333
+
334
+ The other instance sub-APIs wrap Canon's authenticated REST surface:
315
335
 
316
336
  ```typescript
317
337
  await agent.contacts.list(); // CanonContact[]
@@ -377,7 +397,13 @@ Register `callStarted` / `callEnded` before `start()` — see [Events](#events).
377
397
 
378
398
  ## Agent Registration
379
399
 
380
- 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`:
381
407
 
382
408
  ```typescript
383
409
  import { CanonAgent } from '@canonmsg/agent-sdk';
@@ -467,6 +493,8 @@ It governs teardown only. Calling `replyFinal()` as well is two explicit decisio
467
493
 
468
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.
469
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
+
470
498
  ## Turn verbosity
471
499
 
472
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 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
@@ -31,11 +31,14 @@ export interface AgentConversationsAPI {
31
31
  */
32
32
  page(options?: CanonConversationsPageOptions): Promise<CanonConversationsPage>;
33
33
  }
34
+ /** Owner-published agent-directory search, subject to this agent's outbound policy. */
35
+ export interface AgentDirectoryAPI {
36
+ search(input?: DiscoverAgentsInput): Promise<DiscoverAgentsResult>;
37
+ }
34
38
  export declare class CanonAgent {
35
39
  private options;
36
40
  private readonly runtimeConnection;
37
41
  private apiClient;
38
- private authManager;
39
42
  private debouncer;
40
43
  private realtimeManager;
41
44
  private sessionManager;
@@ -59,6 +62,8 @@ export declare class CanonAgent {
59
62
  readonly users: AgentUsersAPI;
60
63
  /** Conversation discovery for choosing existing sessions intentionally. */
61
64
  readonly conversations: AgentConversationsAPI;
65
+ /** Discoverable Canon agents that can be selected for a later communication action. */
66
+ readonly directory: AgentDirectoryAPI;
62
67
  private agentId;
63
68
  private agentContext;
64
69
  private approvalManager;
@@ -75,7 +80,11 @@ export declare class CanonAgent {
75
80
  private runtimeRequestManager;
76
81
  private cachedConversationIds;
77
82
  private running;
78
- private runtimeHeartbeatTimer;
83
+ private startPromise;
84
+ private stopPromise;
85
+ private lifecycleGeneration;
86
+ private runtimeHeartbeat;
87
+ private runtimeStatePublisher;
79
88
  private rtdbHandle;
80
89
  private controlPoller;
81
90
  private readonly activeAbortControllers;
@@ -127,6 +136,7 @@ export declare class CanonAgent {
127
136
  */
128
137
  communicate(input: CommunicateInput): Promise<CommunicateResult>;
129
138
  start(): Promise<void>;
139
+ private startRuntime;
130
140
  createGroup(options: CreateGroupOptions): Promise<CreateGroupResult>;
131
141
  /** Start (or rejoin) a call in a conversation and get the room token. */
132
142
  startCall(options: CreateVoiceSessionOptions): Promise<CanonVoiceSessionToken>;
@@ -164,6 +174,7 @@ export declare class CanonAgent {
164
174
  private handleParticipationSuppressedEvent;
165
175
  private handleMessageUpdatedEvent;
166
176
  stop(): Promise<void>;
177
+ private cleanupRuntime;
167
178
  private hasInterruptSupport;
168
179
  private hasStopAndDropSupport;
169
180
  private hasNewSessionSupport;
@@ -173,10 +184,6 @@ export declare class CanonAgent {
173
184
  private supportsInputInterrupt;
174
185
  private buildRuntimeDescriptor;
175
186
  private buildRuntimeCapabilities;
176
- private publishAgentRuntime;
177
- private startRuntimeHeartbeat;
178
- private stopRuntimeHeartbeat;
179
- private clearAgentRuntime;
180
187
  private rememberConversationId;
181
188
  private rememberConversationMembers;
182
189
  private handleConversationUpdated;
@@ -191,6 +198,7 @@ export declare class CanonAgent {
191
198
  */
192
199
  private ensureControlPoller;
193
200
  private baselineRuntimeControlSignals;
201
+ private baselineAndStartRuntimeControlPolling;
194
202
  private startRuntimeControlPolling;
195
203
  private stopRuntimeControlPolling;
196
204
  private handleRuntimePrimitiveEvent;
@@ -212,25 +220,15 @@ export declare class CanonAgent {
212
220
  private requireRuntimeStatePublisher;
213
221
  private handleMessages;
214
222
  private executeHandler;
215
- static register(options: {
216
- name: string;
217
- description: string;
218
- ownerPhone: string;
223
+ static register(options: Omit<RegistrationInput, 'baseUrl' | 'developerInfo'> & {
219
224
  developerInfo: string;
220
- avatarUrl?: string;
221
225
  } & CanonAgentConnectionOptions): Promise<{
222
226
  requestId: string;
223
227
  pollToken?: string;
224
228
  }>;
225
229
  static checkStatus(requestId: string, options: CanonAgentConnectionOptions & {
226
230
  pollToken?: string;
227
- }): Promise<{
228
- status: string;
229
- agentName: string;
230
- agentId?: string;
231
- apiKey?: string;
232
- apiKeyDelivered?: boolean;
233
- }>;
231
+ }): Promise<RegistrationStatus>;
234
232
  static ackStatus(requestId: string, options: CanonAgentConnectionOptions & {
235
233
  pollToken?: string;
236
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;
@@ -300,6 +297,8 @@ export class CanonAgent {
300
297
  users;
301
298
  /** Conversation discovery for choosing existing sessions intentionally. */
302
299
  conversations;
300
+ /** Discoverable Canon agents that can be selected for a later communication action. */
301
+ directory;
303
302
  agentId = null;
304
303
  agentContext = null;
305
304
  approvalManager = null;
@@ -316,7 +315,11 @@ export class CanonAgent {
316
315
  runtimeRequestManager = null;
317
316
  cachedConversationIds = [];
318
317
  running = false;
319
- runtimeHeartbeatTimer = null;
318
+ startPromise = null;
319
+ stopPromise = null;
320
+ lifecycleGeneration = 0;
321
+ runtimeHeartbeat = null;
322
+ runtimeStatePublisher = null;
320
323
  rtdbHandle = null;
321
324
  controlPoller = null;
322
325
  activeAbortControllers = new Map();
@@ -352,7 +355,6 @@ export class CanonAgent {
352
355
  ? this.apiClient.setTyping(conversationId, typing, status)
353
356
  : this.apiClient.setTyping(conversationId, typing),
354
357
  });
355
- this.authManager = new AuthManager(this.apiClient);
356
358
  this.debouncer = new Debouncer(this.options.debounceMs);
357
359
  const apiClient = this.apiClient;
358
360
  this.contacts = {
@@ -373,6 +375,9 @@ export class CanonAgent {
373
375
  },
374
376
  page: (options) => apiClient.getConversationsPage(options),
375
377
  };
378
+ this.directory = {
379
+ search: (input) => apiClient.discoverAgents(input),
380
+ };
376
381
  if (options.sessions?.enabled) {
377
382
  this.sessionManager = new SessionManager({
378
383
  contextLimit: options.sessions.contextLimit,
@@ -493,31 +498,25 @@ export class CanonAgent {
493
498
  if (event === 'interrupt') {
494
499
  this.interruptHandler = handler;
495
500
  if (this.running) {
496
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
497
- .then(() => this.startRuntimeControlPolling())
498
- .catch(() => { });
501
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
499
502
  }
500
- void this.publishAgentRuntime().catch(() => { });
503
+ this.runtimeHeartbeat?.refresh();
501
504
  return;
502
505
  }
503
506
  if (event === 'stopAndDrop') {
504
507
  this.stopAndDropHandler = handler;
505
508
  if (this.running) {
506
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
507
- .then(() => this.startRuntimeControlPolling())
508
- .catch(() => { });
509
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
509
510
  }
510
- void this.publishAgentRuntime().catch(() => { });
511
+ this.runtimeHeartbeat?.refresh();
511
512
  return;
512
513
  }
513
514
  if (event === 'newSession') {
514
515
  this.newSessionHandler = handler;
515
516
  if (this.running) {
516
- void this.baselineRuntimeControlSignals(this.cachedConversationIds)
517
- .then(() => this.startRuntimeControlPolling())
518
- .catch(() => { });
517
+ void this.baselineAndStartRuntimeControlPolling().catch(() => { });
519
518
  }
520
- void this.publishAgentRuntime().catch(() => { });
519
+ this.runtimeHeartbeat?.refresh();
521
520
  return;
522
521
  }
523
522
  this.contactRemovedHandler = handler;
@@ -532,7 +531,7 @@ export class CanonAgent {
532
531
  if (this.running) {
533
532
  this.startRuntimeControlPolling();
534
533
  }
535
- void this.publishAgentRuntime().catch(() => { });
534
+ this.runtimeHeartbeat?.refresh();
536
535
  }
537
536
  describeCommands() {
538
537
  return this.buildRuntimeDescriptor().commands ?? [];
@@ -568,13 +567,37 @@ export class CanonAgent {
568
567
  async communicate(input) {
569
568
  return this.apiClient.communicate(input);
570
569
  }
571
- async start() {
570
+ start() {
571
+ if (this.stopPromise)
572
+ return this.stopPromise.then(() => this.start());
573
+ if (this.startPromise)
574
+ return this.startPromise;
572
575
  if (this.running)
573
- 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) {
574
594
  await verifyCanonRuntimeConnection(this.runtimeConnection);
575
- if (this.running)
595
+ if (generation !== this.lifecycleGeneration)
576
596
  return;
577
597
  this.running = true;
598
+ if (this.options.sessions?.enabled && !this.sessionManager) {
599
+ this.sessionManager = new SessionManager(this.options.sessions);
600
+ }
578
601
  // The single scoped RTDB client for this agent. Every RTDB consumer in
579
602
  // the SDK (control poller, runtime-state publishers) threads this handle;
580
603
  // the SDK never reads through core's deprecated module-global default,
@@ -583,9 +606,19 @@ export class CanonAgent {
583
606
  rtdbUrl: this.runtimeConnection.rtdbUrl,
584
607
  firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
585
608
  });
586
- // 1. Authenticate
587
- 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;
588
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
+ });
589
622
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
590
623
  // 2. Wire debouncer to handler
591
624
  this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
@@ -602,6 +635,8 @@ export class CanonAgent {
602
635
  catch {
603
636
  // Non-fatal — delivery mode will fall back to default
604
637
  }
638
+ if (generation !== this.lifecycleGeneration)
639
+ return;
605
640
  // 3a. Determine delivery mode
606
641
  let mode = this.options.deliveryMode;
607
642
  if (mode === 'auto') {
@@ -619,23 +654,29 @@ export class CanonAgent {
619
654
  catch {
620
655
  console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
621
656
  }
657
+ if (generation !== this.lifecycleGeneration)
658
+ return;
622
659
  // 3c. Initialize RTDB session state reporting (opt-in)
623
660
  if (this.options.sessionState) {
624
661
  const runtimeState = this.createRuntimeStatePublisher();
625
- for (const id of this.cachedConversationIds) {
626
- runtimeState?.writeSessionState(id, {
627
- isActive: true,
628
- ...(this.options.clientType ? { clientType: this.options.clientType } : {}),
629
- }).catch(() => { });
630
- }
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;
631
668
  if (this.cachedConversationIds.length > 0) {
632
669
  console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
633
670
  }
634
671
  }
635
672
  await this.baselineRuntimeControlSignals(this.cachedConversationIds);
673
+ if (generation !== this.lifecycleGeneration)
674
+ return;
636
675
  this.startRuntimeControlPolling();
637
676
  // 4. Start delivery
638
677
  const { RealtimeManager } = await import('./realtime.js');
678
+ if (generation !== this.lifecycleGeneration)
679
+ return;
639
680
  this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
640
681
  const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled });
641
682
  if (this.voiceEventsEnabled) {
@@ -680,16 +721,24 @@ export class CanonAgent {
680
721
  });
681
722
  rtm.setConnectionHandlers({
682
723
  onConnected: () => {
683
- this.startRuntimeHeartbeat();
724
+ if (!this.running || generation !== this.lifecycleGeneration)
725
+ return;
726
+ this.runtimeHeartbeat?.connect();
684
727
  if (!this.sseConnectedLogged) {
685
728
  this.sseConnectedLogged = true;
686
729
  console.log('[canon-sdk] SSE stream connected');
687
730
  }
688
731
  },
689
- onDisconnected: () => this.stopRuntimeHeartbeat(),
732
+ onDisconnected: () => {
733
+ if (generation === this.lifecycleGeneration) {
734
+ void this.runtimeHeartbeat?.disconnect();
735
+ }
736
+ },
690
737
  });
691
738
  this.realtimeManager = rtm;
692
739
  await rtm.start();
740
+ if (generation !== this.lifecycleGeneration)
741
+ rtm.stop();
693
742
  }
694
743
  async createGroup(options) {
695
744
  return this.apiClient.createGroup(options);
@@ -780,29 +829,58 @@ export class CanonAgent {
780
829
  console.error('[canon-sdk] Message-updated handler failed:', error instanceof Error ? error.message : error);
781
830
  }
782
831
  }
783
- async stop() {
784
- if (!this.running)
785
- return;
832
+ stop() {
833
+ if (this.stopPromise)
834
+ return this.stopPromise;
835
+ if (!this.running && !this.startPromise)
836
+ return Promise.resolve();
837
+ ++this.lifecycleGeneration;
786
838
  this.running = false;
787
839
  this.stopRuntimeControlPolling();
788
- // Clear session state if enabled (uses cached IDs — no network call during shutdown)
789
- const runtimeState = this.createRuntimeStatePublisher();
790
- if (this.options.sessionState && runtimeState) {
791
- for (const id of this.cachedConversationIds) {
792
- Promise.resolve(runtimeState.clearSessionState(id)).catch(() => { });
793
- }
794
- }
795
- if (runtimeState) {
796
- for (const id of this.cachedConversationIds) {
797
- Promise.resolve(runtimeState.clearTurnState(id)).catch(() => { });
798
- }
799
- }
800
- await this.clearAgentRuntime();
801
840
  this.realtimeManager?.stop();
802
- this.sessionManager?.destroy();
803
- 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;
804
863
  this.debouncer.destroy();
805
- 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;
806
884
  }
807
885
  hasInterruptSupport() {
808
886
  return Boolean(this.interruptHandler);
@@ -879,33 +957,6 @@ export class CanonAgent {
879
957
  supportsQueue: Boolean(this.sessionManager),
880
958
  };
881
959
  }
882
- async publishAgentRuntime() {
883
- const publisher = this.createRuntimeStatePublisher();
884
- if (!publisher)
885
- return;
886
- await publisher.publishAgentRuntime({
887
- runtimeDescriptor: this.buildRuntimeDescriptor(),
888
- });
889
- }
890
- startRuntimeHeartbeat() {
891
- void this.publishAgentRuntime();
892
- if (this.runtimeHeartbeatTimer)
893
- return;
894
- this.runtimeHeartbeatTimer = setInterval(() => {
895
- void this.publishAgentRuntime();
896
- }, AGENT_RUNTIME_HEARTBEAT_MS);
897
- this.runtimeHeartbeatTimer.unref?.();
898
- }
899
- stopRuntimeHeartbeat() {
900
- if (this.runtimeHeartbeatTimer) {
901
- clearInterval(this.runtimeHeartbeatTimer);
902
- this.runtimeHeartbeatTimer = null;
903
- }
904
- void this.clearAgentRuntime();
905
- }
906
- async clearAgentRuntime() {
907
- await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
908
- }
909
960
  rememberConversationId(conversationId) {
910
961
  if (this.cachedConversationIds.includes(conversationId))
911
962
  return;
@@ -995,6 +1046,13 @@ export class CanonAgent {
995
1046
  return;
996
1047
  await this.ensureControlPoller()?.baseline(conversationIds);
997
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
+ }
998
1056
  startRuntimeControlPolling() {
999
1057
  if (!this.hasRuntimeControlSupport())
1000
1058
  return;
@@ -1155,12 +1213,13 @@ export class CanonAgent {
1155
1213
  // when the agent has not started — same condition as the guard above.
1156
1214
  if (!this.rtdbHandle)
1157
1215
  return null;
1158
- return createRuntimeStatePublisher({
1216
+ this.runtimeStatePublisher ??= createRuntimeStatePublisher({
1159
1217
  agentId: this.agentId,
1160
1218
  clientType: this.options.clientType ?? 'generic',
1161
1219
  hostMode: this.options.runtimeControlSurface === 'host',
1162
1220
  rtdb: this.rtdbHandle,
1163
1221
  });
1222
+ return this.runtimeStatePublisher;
1164
1223
  }
1165
1224
  requireRuntimeStatePublisher() {
1166
1225
  const publisher = this.createRuntimeStatePublisher();
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
- export type { AgentContactsAPI, AgentConversationsAPI, AgentUsersAPI } 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';
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
- export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, ResumableMediaUploadResult, MediaAttachment, VideoProcessingStatus, VideoProcessingErrorCode, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimePlanRequestPayload, RuntimePlanRequestResult, SessionRule, } from '@canonmsg/core';
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';
5
7
  export { SessionManager } from './session-manager.js';
6
8
  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';
7
9
  export type { AnthropicImageBlock, AnthropicImageBudgetOptions, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "10.0.0",
3
+ "version": "10.2.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^12.0.0"
31
+ "@canonmsg/core": "^12.2.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
- }