@canonmsg/agent-sdk 1.0.0 → 1.1.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
@@ -41,6 +41,7 @@ No additional dependencies required — the SDK uses native `fetch` and `Readabl
41
41
  | `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
42
42
  | `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
43
43
  | `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
44
+ | `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional interrupt / stop-clear handlers for Canon working-state controls |
44
45
  | `sessionState` | `boolean` | `false` | Publish RTDB session-state for the conversations this agent is active in |
45
46
 
46
47
  ### Optional runtime controls
@@ -88,6 +89,29 @@ const agent = new CanonAgent({
88
89
  });
89
90
  ```
90
91
 
92
+ SDK agents only advertise Stop or Send Now when they register runtime-control handlers. Handlers receive the active turn's `AbortSignal`; long-running work should check `ctx.abortSignal.aborted` or pass the signal into cancellable APIs.
93
+
94
+ ```typescript
95
+ const agent = new CanonAgent({
96
+ apiKey: process.env.CANON_API_KEY!,
97
+ sessions: { enabled: true },
98
+ runtimeControls: {
99
+ onInterrupt: ({ conversationId }) => {
100
+ console.log(`Canon asked to interrupt ${conversationId}`);
101
+ },
102
+ onStopAndDrop: ({ droppedMessageIds }) => {
103
+ console.log('Dropped queued messages:', droppedMessageIds);
104
+ },
105
+ },
106
+ });
107
+
108
+ agent.on('message', async ({ messages, replyFinal, abortSignal }) => {
109
+ const result = await runWork(messages, { signal: abortSignal });
110
+ if (abortSignal.aborted) return;
111
+ await replyFinal(result);
112
+ });
113
+ ```
114
+
91
115
  The descriptor only drives Canon UI and validation. Your SDK agent is still responsible for reading session config and safely mapping selected values to local directories.
92
116
 
93
117
  Node SDK builders can reuse `buildConfiguredWorkspaceOptionsWithRoots` from `@canonmsg/core` to produce the same stable project IDs and root metadata used by the first-party Claude Code and Codex hosts.
@@ -1,5 +1,5 @@
1
1
  import { type AddMemberResult, type CanonContact, type ContactCardPayload, type CreateContactRequestResult } from '@canonmsg/core';
2
- import type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler } from './types.js';
2
+ import type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler, RuntimeSignalHandler } from './types.js';
3
3
  /**
4
4
  * Contact-graph operations exposed under `agent.contacts`. Wraps the REST
5
5
  * endpoints in CanonClient — the same surface a human user would hit through
@@ -30,6 +30,8 @@ export declare class CanonAgent {
30
30
  private contactApprovedHandler;
31
31
  private contactAddedHandler;
32
32
  private contactRemovedHandler;
33
+ private interruptHandler;
34
+ private stopAndDropHandler;
33
35
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
34
36
  readonly contacts: AgentContactsAPI;
35
37
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -40,12 +42,17 @@ export declare class CanonAgent {
40
42
  private cachedConversationIds;
41
43
  private running;
42
44
  private runtimeHeartbeatTimer;
45
+ private runtimeControlPollTimer;
46
+ private readonly lastSeenSignal;
47
+ private readonly activeAbortControllers;
43
48
  constructor(options: CanonAgentOptions);
44
49
  on(event: 'message', handler: MessageHandler): void;
45
50
  on(event: 'contactRequest', handler: ContactRequestHandler): void;
46
51
  on(event: 'contactApproved', handler: ContactRequestHandler): void;
47
52
  on(event: 'contactAdded', handler: ContactAddedHandler): void;
48
53
  on(event: 'contactRemoved', handler: ContactRemovedHandler): void;
54
+ on(event: 'interrupt', handler: RuntimeSignalHandler): void;
55
+ on(event: 'stopAndDrop', handler: RuntimeSignalHandler): void;
49
56
  /**
50
57
  * Resolve admission live for a target user (typically read off a shared
51
58
  * contact card) and route into either an immediate message or a contact
@@ -55,7 +62,6 @@ export declare class CanonAgent {
55
62
  */
56
63
  reachOut(card: ContactCardPayload, options?: ReachOutOptions): Promise<ReachOutResult>;
57
64
  private executeReachOut;
58
- private openConversationAndMaybeMessage;
59
65
  start(): Promise<void>;
60
66
  createConversation(options: CreateConversationOptions): Promise<{
61
67
  conversationId: string;
@@ -86,10 +92,24 @@ export declare class CanonAgent {
86
92
  private handleContactRequestEvent;
87
93
  private handleContactGraphEvent;
88
94
  stop(): Promise<void>;
95
+ private hasInterruptSupport;
96
+ private hasStopAndDropSupport;
97
+ private hasRuntimeSignalSupport;
98
+ private buildRuntimeDescriptor;
99
+ private buildRuntimeCapabilities;
89
100
  private publishAgentRuntime;
90
101
  private startRuntimeHeartbeat;
91
102
  private stopRuntimeHeartbeat;
92
103
  private clearAgentRuntime;
104
+ private rememberConversationId;
105
+ private baselineRuntimeControlSignals;
106
+ private startRuntimeControlPolling;
107
+ private stopRuntimeControlPolling;
108
+ private pollRuntimeControlSignals;
109
+ private handleRuntimeSignal;
110
+ private abortActiveTurns;
111
+ private resolveBatchDeliveryIntent;
112
+ private notifyMessageInterrupt;
93
113
  private createRuntimeStatePublisher;
94
114
  private handleMessages;
95
115
  private executeHandler;
@@ -1,4 +1,4 @@
1
- import { CanonClient, createRuntimeStatePublisher, FINAL_MESSAGE_HANDOFF_MS, initRTDBAuth, mergeWorkSessionContexts, } from '@canonmsg/core';
1
+ import { CanonClient, createRuntimeStatePublisher, FINAL_MESSAGE_HANDOFF_MS, initRTDBAuth, rtdbRead, rtdbWrite, mergeWorkSessionContexts, normalizeTurnMetadata, reachOutToCanonContact, } from '@canonmsg/core';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
@@ -18,6 +18,26 @@ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
18
18
  supportsInterrupt: false,
19
19
  streamingTextMode: 'snapshot',
20
20
  };
21
+ const SDK_STOP_ACTION = {
22
+ id: 'stop',
23
+ label: 'Stop',
24
+ description: 'Interrupt the current SDK agent turn.',
25
+ aliases: ['stop'],
26
+ category: 'turn',
27
+ placements: ['composer_slash', 'command_palette'],
28
+ availability: ['busy'],
29
+ dispatch: { kind: 'signal', signal: 'interrupt' },
30
+ };
31
+ const SDK_STOP_AND_DROP_ACTION = {
32
+ id: 'stop-and-clear-queue',
33
+ label: 'Stop & clear queue',
34
+ description: 'Interrupt the current SDK agent turn and drop queued Canon messages.',
35
+ aliases: ['stop-clear', 'clear-queue'],
36
+ category: 'turn',
37
+ placements: ['composer_slash', 'command_palette', 'session_strip'],
38
+ availability: ['busy_with_queue'],
39
+ dispatch: { kind: 'signal', signal: 'stop_and_drop' },
40
+ };
21
41
  function sleep(ms) {
22
42
  return new Promise((resolve) => setTimeout(resolve, ms));
23
43
  }
@@ -33,6 +53,8 @@ export class CanonAgent {
33
53
  contactApprovedHandler = null;
34
54
  contactAddedHandler = null;
35
55
  contactRemovedHandler = null;
56
+ interruptHandler = null;
57
+ stopAndDropHandler = null;
36
58
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
37
59
  contacts;
38
60
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -43,6 +65,9 @@ export class CanonAgent {
43
65
  cachedConversationIds = [];
44
66
  running = false;
45
67
  runtimeHeartbeatTimer = null;
68
+ runtimeControlPollTimer = null;
69
+ lastSeenSignal = new Map();
70
+ activeAbortControllers = new Map();
46
71
  constructor(options) {
47
72
  this.options = {
48
73
  baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
@@ -73,6 +98,8 @@ export class CanonAgent {
73
98
  idleTimeoutMs: options.sessions.idleTimeoutMs,
74
99
  });
75
100
  }
101
+ this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
102
+ this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
76
103
  }
77
104
  on(event, handler) {
78
105
  if (event === 'message') {
@@ -91,6 +118,26 @@ export class CanonAgent {
91
118
  this.contactAddedHandler = handler;
92
119
  return;
93
120
  }
121
+ if (event === 'interrupt') {
122
+ this.interruptHandler = handler;
123
+ if (this.running) {
124
+ void this.baselineRuntimeControlSignals(this.cachedConversationIds)
125
+ .then(() => this.startRuntimeControlPolling())
126
+ .catch(() => { });
127
+ }
128
+ void this.publishAgentRuntime().catch(() => { });
129
+ return;
130
+ }
131
+ if (event === 'stopAndDrop') {
132
+ this.stopAndDropHandler = handler;
133
+ if (this.running) {
134
+ void this.baselineRuntimeControlSignals(this.cachedConversationIds)
135
+ .then(() => this.startRuntimeControlPolling())
136
+ .catch(() => { });
137
+ }
138
+ void this.publishAgentRuntime().catch(() => { });
139
+ return;
140
+ }
94
141
  this.contactRemovedHandler = handler;
95
142
  }
96
143
  /**
@@ -116,45 +163,11 @@ export class CanonAgent {
116
163
  return promise;
117
164
  }
118
165
  async executeReachOut(targetUserId, options) {
119
- const { admission } = await this.apiClient.resolveAdmission(targetUserId);
120
- if (admission.state === 'allowed' && admission.canMessage) {
121
- return this.openConversationAndMaybeMessage(targetUserId, options);
122
- }
123
- if (admission.state === 'pending-outbound') {
124
- return { status: 'pending', requestId: admission.pendingRequestId ?? null };
125
- }
126
- if (admission.state === 'request-required' && admission.canRequestContact) {
127
- const result = await this.apiClient.createContactRequest(targetUserId, options?.requestMessage ?? null);
128
- // The server may report 'open' if the target's policy flipped between
129
- // the resolveAdmission call and the request. No request doc was
130
- // written — fall through to the messaging path so the caller's intent
131
- // ("reach this user") is honored end-to-end. Without this, the caller
132
- // would get { status: 'requested' } despite no request existing.
133
- if (result.status === 'open') {
134
- return this.openConversationAndMaybeMessage(targetUserId, options);
135
- }
136
- // 'duplicate' means a pending request already existed; surface as
137
- // 'pending'. 'created' is the normal "request just landed" path.
138
- if (result.status === 'duplicate') {
139
- return { status: 'pending', requestId: result.requestId };
140
- }
141
- return { status: 'requested', requestId: result.requestId };
142
- }
143
- if (admission.state === 'blocked') {
144
- return { status: 'blocked', reason: 'blocked' };
145
- }
146
- return { status: 'unavailable', reason: admission.state };
147
- }
148
- async openConversationAndMaybeMessage(targetUserId, options) {
149
- const { conversationId } = await this.apiClient.createConversation({
150
- type: 'direct',
166
+ return reachOutToCanonContact(this.apiClient, {
151
167
  targetUserId,
168
+ text: options?.text ?? null,
169
+ requestMessage: options?.requestMessage ?? null,
152
170
  });
153
- if (options?.text) {
154
- const { messageId } = await this.apiClient.sendMessage(conversationId, options.text);
155
- return { status: 'messaged', conversationId, messageId };
156
- }
157
- return { status: 'messaged', conversationId };
158
171
  }
159
172
  async start() {
160
173
  if (this.running)
@@ -167,6 +180,7 @@ export class CanonAgent {
167
180
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
168
181
  // 2. Wire debouncer to handler
169
182
  this.debouncer.setCallback(async (conversationId, messages) => {
183
+ this.rememberConversationId(conversationId);
170
184
  await this.handleMessages(conversationId, messages);
171
185
  });
172
186
  // 3. Fetch conversations (used for delivery mode + session state)
@@ -208,6 +222,8 @@ export class CanonAgent {
208
222
  console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
209
223
  }
210
224
  }
225
+ await this.baselineRuntimeControlSignals(this.cachedConversationIds);
226
+ this.startRuntimeControlPolling();
211
227
  // 4. Start delivery
212
228
  const { RealtimeManager } = await import('./realtime.js');
213
229
  const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
@@ -297,6 +313,7 @@ export class CanonAgent {
297
313
  if (!this.running)
298
314
  return;
299
315
  this.running = false;
316
+ this.stopRuntimeControlPolling();
300
317
  // Clear session state if enabled (uses cached IDs — no network call during shutdown)
301
318
  const runtimeState = this.createRuntimeStatePublisher();
302
319
  if (this.options.sessionState && runtimeState) {
@@ -316,12 +333,55 @@ export class CanonAgent {
316
333
  this.debouncer.destroy();
317
334
  console.log('[canon-sdk] Stopped');
318
335
  }
336
+ hasInterruptSupport() {
337
+ return Boolean(this.interruptHandler);
338
+ }
339
+ hasStopAndDropSupport() {
340
+ return Boolean(this.stopAndDropHandler);
341
+ }
342
+ hasRuntimeSignalSupport() {
343
+ return this.hasInterruptSupport() || this.hasStopAndDropSupport();
344
+ }
345
+ buildRuntimeDescriptor() {
346
+ const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
347
+ const hasInterrupt = this.hasInterruptSupport();
348
+ const hasStopAndDrop = this.hasStopAndDropSupport();
349
+ const actions = [...(source.actions ?? [])].filter((action) => {
350
+ if (action.dispatch.kind !== 'signal')
351
+ return true;
352
+ if (action.dispatch.signal === 'interrupt')
353
+ return hasInterrupt;
354
+ if (action.dispatch.signal === 'stop_and_drop')
355
+ return hasStopAndDrop;
356
+ return false;
357
+ });
358
+ const hasInterruptAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'interrupt');
359
+ const hasStopAndDropAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'stop_and_drop');
360
+ if (hasInterrupt && !hasInterruptAction) {
361
+ actions.push(SDK_STOP_ACTION);
362
+ }
363
+ if (hasStopAndDrop && this.sessionManager && !hasStopAndDropAction) {
364
+ actions.push(SDK_STOP_AND_DROP_ACTION);
365
+ }
366
+ return {
367
+ ...source,
368
+ supportsInterrupt: hasInterrupt,
369
+ actions,
370
+ };
371
+ }
372
+ buildRuntimeCapabilities() {
373
+ return {
374
+ ...SDK_RUNTIME_CAPABILITIES,
375
+ supportsInterrupt: this.hasInterruptSupport(),
376
+ supportsQueue: Boolean(this.sessionManager),
377
+ };
378
+ }
319
379
  async publishAgentRuntime() {
320
380
  const publisher = this.createRuntimeStatePublisher();
321
381
  if (!publisher)
322
382
  return;
323
383
  await publisher.publishAgentRuntime({
324
- runtimeDescriptor: this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR,
384
+ runtimeDescriptor: this.buildRuntimeDescriptor(),
325
385
  });
326
386
  }
327
387
  startRuntimeHeartbeat() {
@@ -343,6 +403,113 @@ export class CanonAgent {
343
403
  async clearAgentRuntime() {
344
404
  await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
345
405
  }
406
+ rememberConversationId(conversationId) {
407
+ if (this.cachedConversationIds.includes(conversationId))
408
+ return;
409
+ this.cachedConversationIds.push(conversationId);
410
+ }
411
+ async baselineRuntimeControlSignals(conversationIds) {
412
+ if (!this.agentId || !this.hasRuntimeSignalSupport())
413
+ return;
414
+ await Promise.all(conversationIds.map(async (conversationId) => {
415
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
416
+ if (!raw || typeof raw !== 'object')
417
+ return;
418
+ const timestamp = Number(raw.updatedAt ?? 0);
419
+ if (timestamp > 0) {
420
+ this.lastSeenSignal.set(conversationId, timestamp);
421
+ }
422
+ }));
423
+ }
424
+ startRuntimeControlPolling() {
425
+ if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeSignalSupport())
426
+ return;
427
+ this.runtimeControlPollTimer = setInterval(() => {
428
+ void this.pollRuntimeControlSignals();
429
+ }, 2_000);
430
+ this.runtimeControlPollTimer.unref?.();
431
+ }
432
+ stopRuntimeControlPolling() {
433
+ if (!this.runtimeControlPollTimer)
434
+ return;
435
+ clearInterval(this.runtimeControlPollTimer);
436
+ this.runtimeControlPollTimer = null;
437
+ }
438
+ async pollRuntimeControlSignals() {
439
+ if (!this.agentId || !this.hasRuntimeSignalSupport())
440
+ return;
441
+ await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
442
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
443
+ if (!raw || typeof raw !== 'object')
444
+ return;
445
+ await this.handleRuntimeSignal(conversationId, raw);
446
+ }));
447
+ }
448
+ async handleRuntimeSignal(conversationId, raw) {
449
+ if (!this.agentId)
450
+ return;
451
+ const signal = raw.type;
452
+ if (signal !== 'interrupt' && signal !== 'stop_and_drop')
453
+ return;
454
+ const timestamp = Number(raw.updatedAt ?? 0);
455
+ if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
456
+ return;
457
+ this.lastSeenSignal.set(conversationId, timestamp);
458
+ const handler = signal === 'stop_and_drop'
459
+ ? this.stopAndDropHandler
460
+ : this.interruptHandler;
461
+ if (!handler) {
462
+ await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
463
+ return;
464
+ }
465
+ const abortSignal = this.abortActiveTurns(conversationId);
466
+ const droppedMessages = signal === 'stop_and_drop'
467
+ ? this.sessionManager?.dropQueued(conversationId) ?? []
468
+ : [];
469
+ const droppedMessageIds = droppedMessages.map((message) => message.id);
470
+ await Promise.all(droppedMessages.map((message) => {
471
+ if (message.metadata?.inboundDisposition !== 'queued')
472
+ return Promise.resolve();
473
+ return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
474
+ }));
475
+ await Promise.resolve(handler?.({
476
+ conversationId,
477
+ signal: signal,
478
+ updatedAt: timestamp || undefined,
479
+ abortSignal,
480
+ droppedMessageIds,
481
+ })).catch((error) => {
482
+ console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
483
+ });
484
+ await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
485
+ }
486
+ abortActiveTurns(conversationId) {
487
+ const controllers = this.activeAbortControllers.get(conversationId);
488
+ if (!controllers || controllers.size === 0)
489
+ return undefined;
490
+ const abortSignal = controllers.values().next().value?.signal;
491
+ for (const controller of controllers) {
492
+ controller.abort();
493
+ }
494
+ return abortSignal;
495
+ }
496
+ resolveBatchDeliveryIntent(messages) {
497
+ return messages.some((message) => normalizeTurnMetadata(message.metadata)?.deliveryIntent === 'interrupt')
498
+ ? 'interrupt'
499
+ : 'queue';
500
+ }
501
+ async notifyMessageInterrupt(conversationId, abortSignal) {
502
+ if (!abortSignal || !this.interruptHandler)
503
+ return;
504
+ await Promise.resolve(this.interruptHandler({
505
+ conversationId,
506
+ signal: 'interrupt',
507
+ abortSignal,
508
+ droppedMessageIds: [],
509
+ })).catch((error) => {
510
+ console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
511
+ });
512
+ }
346
513
  createRuntimeStatePublisher() {
347
514
  if (!this.agentId)
348
515
  return null;
@@ -357,10 +524,14 @@ export class CanonAgent {
357
524
  console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
358
525
  return;
359
526
  }
527
+ const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
528
+ const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
529
+ const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
530
+ await this.notifyMessageInterrupt(conversationId, abortSignal);
360
531
  if (this.sessionManager) {
361
532
  await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
362
533
  await this.executeHandler(conversationId, newMessages, session);
363
- });
534
+ }, { toFront: shouldInterrupt });
364
535
  }
365
536
  else {
366
537
  await this.executeHandler(conversationId, messages);
@@ -376,6 +547,10 @@ export class CanonAgent {
376
547
  const agentId = this.agentId;
377
548
  const runtimeState = this.createRuntimeStatePublisher();
378
549
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
550
+ const abortController = new AbortController();
551
+ const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
552
+ activeControllers.add(abortController);
553
+ this.activeAbortControllers.set(conversationId, activeControllers);
379
554
  const writeTurn = async (state) => {
380
555
  if (!runtimeState || !agentId)
381
556
  return;
@@ -385,7 +560,7 @@ export class CanonAgent {
385
560
  state,
386
561
  queueDepth: queueDepth(),
387
562
  currentSpeakerId: agentId,
388
- capabilities: SDK_RUNTIME_CAPABILITIES,
563
+ capabilities: this.buildRuntimeCapabilities(),
389
564
  openedAt: turnOpenedAt,
390
565
  ...(state === 'completed' || state === 'interrupted' || state === 'idle'
391
566
  ? { completedAt: { '.sv': 'timestamp' } }
@@ -598,6 +773,7 @@ export class CanonAgent {
598
773
  agent,
599
774
  workSession,
600
775
  activeWorkSessions,
776
+ abortSignal: abortController.signal,
601
777
  media: {
602
778
  materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
603
779
  if (!message)
@@ -671,6 +847,11 @@ export class CanonAgent {
671
847
  await writeTurn('interrupted');
672
848
  }
673
849
  finally {
850
+ const activeControllers = this.activeAbortControllers.get(conversationId);
851
+ activeControllers?.delete(abortController);
852
+ if (activeControllers?.size === 0) {
853
+ this.activeAbortControllers.delete(conversationId);
854
+ }
674
855
  clearInterval(thinkingKeepalive);
675
856
  // Always clear typing when done
676
857
  try {
@@ -18,6 +18,10 @@ export interface Session {
18
18
  lastActiveAt: number;
19
19
  }
20
20
  type SessionHandler = (session: Session, newMessages: CanonMessage[]) => Promise<void>;
21
+ export interface EnqueueOptions {
22
+ /** Place this batch ahead of other not-yet-running batches. */
23
+ toFront?: boolean;
24
+ }
21
25
  /**
22
26
  * Manages per-conversation sessions with:
23
27
  * - In-memory message buffer per session
@@ -40,6 +44,8 @@ export declare class SessionManager {
40
44
  constructor(config?: SessionConfig);
41
45
  /** Get or create a session for a conversation */
42
46
  getSession(conversationId: string): Session;
47
+ private appendMessagesToSession;
48
+ private removeMessagesFromSession;
43
49
  /**
44
50
  * Enqueue messages for processing in a specific session.
45
51
  * Returns a promise that resolves when the handler completes for these messages.
@@ -47,7 +53,7 @@ export declare class SessionManager {
47
53
  * - Messages within the same session are serialized (queued).
48
54
  * - Messages across different sessions run concurrently up to the concurrency limit.
49
55
  */
50
- enqueue(conversationId: string, messages: CanonMessage[], handler: SessionHandler): Promise<void>;
56
+ enqueue(conversationId: string, messages: CanonMessage[], handler: SessionHandler, options?: EnqueueOptions): Promise<void>;
51
57
  private drainSession;
52
58
  /** Seed a session with historical messages (e.g. fetched from API) */
53
59
  seedHistory(conversationId: string, history: CanonMessage[]): void;
@@ -57,6 +63,8 @@ export declare class SessionManager {
57
63
  get sessionCount(): number;
58
64
  /** Number of queued batches waiting behind the active turn for this conversation. */
59
65
  getQueueDepth(conversationId: string): number;
66
+ /** Drop queued, not-yet-running batches for a conversation. */
67
+ dropQueued(conversationId: string): CanonMessage[];
60
68
  /** Clean up all state */
61
69
  destroy(): void;
62
70
  }
@@ -50,28 +50,54 @@ export class SessionManager {
50
50
  session.lastActiveAt = Date.now();
51
51
  return session;
52
52
  }
53
- /**
54
- * Enqueue messages for processing in a specific session.
55
- * Returns a promise that resolves when the handler completes for these messages.
56
- *
57
- * - Messages within the same session are serialized (queued).
58
- * - Messages across different sessions run concurrently up to the concurrency limit.
59
- */
60
- async enqueue(conversationId, messages, handler) {
53
+ appendMessagesToSession(conversationId, messages) {
61
54
  const session = this.getSession(conversationId);
62
55
  const seen = this.seenMessages.get(conversationId);
63
- // Append new messages using O(1) Set lookup for dedup (#9)
64
56
  for (const msg of messages) {
65
57
  if (!seen.has(msg.id)) {
66
58
  seen.add(msg.id);
67
59
  session.messages.push(msg);
68
60
  }
69
61
  }
70
- // Trim to context limit (keep most recent)
71
62
  if (session.messages.length > this.contextLimit) {
72
63
  session.messages = session.messages.slice(-this.contextLimit);
64
+ const retainedIds = new Set(session.messages.map((message) => message.id));
65
+ for (const id of Array.from(seen)) {
66
+ if (!retainedIds.has(id)) {
67
+ seen.delete(id);
68
+ }
69
+ }
73
70
  }
74
71
  session.lastActiveAt = Date.now();
72
+ return session;
73
+ }
74
+ removeMessagesFromSession(conversationId, messages) {
75
+ if (messages.length === 0)
76
+ return;
77
+ const session = this.sessions.get(conversationId);
78
+ const seen = this.seenMessages.get(conversationId);
79
+ if (!session && !seen)
80
+ return;
81
+ const droppedIds = new Set(messages.map((message) => message.id));
82
+ if (session) {
83
+ session.messages = session.messages.filter((message) => !droppedIds.has(message.id));
84
+ }
85
+ if (seen) {
86
+ for (const id of droppedIds) {
87
+ seen.delete(id);
88
+ }
89
+ }
90
+ }
91
+ /**
92
+ * Enqueue messages for processing in a specific session.
93
+ * Returns a promise that resolves when the handler completes for these messages.
94
+ *
95
+ * - Messages within the same session are serialized (queued).
96
+ * - Messages across different sessions run concurrently up to the concurrency limit.
97
+ */
98
+ async enqueue(conversationId, messages, handler, options = {}) {
99
+ const session = this.getSession(conversationId);
100
+ session.lastActiveAt = Date.now();
75
101
  return new Promise((resolve, reject) => {
76
102
  // Add to this session's queue
77
103
  let queue = this.queues.get(conversationId);
@@ -79,12 +105,18 @@ export class SessionManager {
79
105
  queue = [];
80
106
  this.queues.set(conversationId, queue);
81
107
  }
82
- queue.push({ messages, resolve, reject });
108
+ const batch = { messages, handler, resolve, reject };
109
+ if (options.toFront) {
110
+ queue.unshift(batch);
111
+ }
112
+ else {
113
+ queue.push(batch);
114
+ }
83
115
  // Try to drain
84
- this.drainSession(conversationId, handler);
116
+ this.drainSession(conversationId);
85
117
  });
86
118
  }
87
- drainSession(conversationId, handler) {
119
+ drainSession(conversationId) {
88
120
  // Already processing this session — the current run will pick up queued items
89
121
  if (this.processing.has(conversationId))
90
122
  return;
@@ -104,8 +136,8 @@ export class SessionManager {
104
136
  this.queues.delete(conversationId);
105
137
  this.activeCount++;
106
138
  this.processing.add(conversationId);
107
- const session = this.getSession(conversationId);
108
- handler(session, item.messages)
139
+ const session = this.appendMessagesToSession(conversationId, item.messages);
140
+ item.handler(session, item.messages)
109
141
  .then(() => item.resolve())
110
142
  .catch((err) => item.reject(err))
111
143
  .finally(() => {
@@ -114,7 +146,7 @@ export class SessionManager {
114
146
  // Continue draining this session if more items queued
115
147
  const remaining = this.queues.get(conversationId);
116
148
  if (remaining && remaining.length > 0) {
117
- this.drainSession(conversationId, handler);
149
+ this.drainSession(conversationId);
118
150
  }
119
151
  // Unpark the next pending session.
120
152
  // Use Set iteration + explicit delete to get the correct conversation ID
@@ -122,7 +154,7 @@ export class SessionManager {
122
154
  if (this.pending.size > 0) {
123
155
  const nextId = this.pending.values().next().value;
124
156
  this.pending.delete(nextId);
125
- this.drainSession(nextId, handler);
157
+ this.drainSession(nextId);
126
158
  }
127
159
  });
128
160
  }
@@ -167,6 +199,20 @@ export class SessionManager {
167
199
  getQueueDepth(conversationId) {
168
200
  return this.queues.get(conversationId)?.length ?? 0;
169
201
  }
202
+ /** Drop queued, not-yet-running batches for a conversation. */
203
+ dropQueued(conversationId) {
204
+ const queue = this.queues.get(conversationId);
205
+ if (!queue || queue.length === 0)
206
+ return [];
207
+ this.queues.delete(conversationId);
208
+ this.pending.delete(conversationId);
209
+ const droppedMessages = queue.flatMap((item) => item.messages);
210
+ this.removeMessagesFromSession(conversationId, droppedMessages);
211
+ for (const item of queue) {
212
+ item.resolve();
213
+ }
214
+ return droppedMessages;
215
+ }
170
216
  /** Clean up all state */
171
217
  destroy() {
172
218
  if (this.sweepTimer) {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { AddMemberResult, AgentClientType, CanonRuntimeDescriptor, CanonMessage, CanonConversation, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonResolvedWorkSession, CanonWorkSession, CanonWorkSessionContext, CanonWorkSessionConversationRole, CanonWorkSessionDisclosureMode, CanonWorkSessionParticipant, CanonWorkSessionStatus, CreateWorkSessionOptions, SendLinkedMessageOptions, SendLinkedMessageResult, SendMessageOptions, CreateConversationOptions, TurnLifecycleState, UpdateWorkSessionConversationOptions, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonMessage, CanonConversation, CreateWorkSessionOptions, SendMessageOptions, UpdateWorkSessionConversationOptions } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonMessage, CanonConversation, CanonRuntimeActionDispatch, CreateWorkSessionOptions, SendMessageOptions, UpdateWorkSessionConversationOptions } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -90,6 +90,8 @@ export interface MessageHandlerContext {
90
90
  session?: SessionInfo;
91
91
  /** Turn lifecycle helpers for live-work rendering and progress reporting. */
92
92
  turn?: TurnController;
93
+ /** Aborted when Canon sends an interrupt/stop signal for this active turn. */
94
+ abortSignal: AbortSignal;
93
95
  }
94
96
  export type MessageHandler = (ctx: MessageHandlerContext) => Promise<void>;
95
97
  export interface SessionOptions {
@@ -103,6 +105,21 @@ export interface SessionOptions {
103
105
  idleTimeoutMs?: number;
104
106
  }
105
107
  export type DeliveryMode = 'auto' | 'sse';
108
+ export type RuntimeSignalType = Extract<CanonRuntimeActionDispatch, {
109
+ kind: 'signal';
110
+ }>['signal'];
111
+ export interface RuntimeSignalContext {
112
+ conversationId: string;
113
+ signal: RuntimeSignalType;
114
+ updatedAt?: number;
115
+ abortSignal?: AbortSignal;
116
+ droppedMessageIds: string[];
117
+ }
118
+ export type RuntimeSignalHandler = (context: RuntimeSignalContext) => void | Promise<void>;
119
+ export interface RuntimeControlHandlers {
120
+ onInterrupt?: RuntimeSignalHandler;
121
+ onStopAndDrop?: RuntimeSignalHandler;
122
+ }
106
123
  export interface CanonAgentOptions {
107
124
  apiKey: string;
108
125
  baseUrl?: string;
@@ -119,6 +136,8 @@ export interface CanonAgentOptions {
119
136
  clientType?: import('@canonmsg/core').AgentClientType;
120
137
  /** Optional runtime descriptor published to Canon for setup/live UI rendering. */
121
138
  runtimeDescriptor?: import('@canonmsg/core').CanonRuntimeDescriptor;
139
+ /** Optional Canon runtime signal handlers. Enables interrupt controls when provided. */
140
+ runtimeControls?: RuntimeControlHandlers;
122
141
  /**
123
142
  * Enable RTDB session-state reporting. Off by default.
124
143
  * Turn-state reporting is automatic while handlers run.
@@ -153,6 +172,6 @@ export type ReachOutResult = {
153
172
  export interface ReachOutOptions {
154
173
  /** Optional first message to send when admission is `allowed`. */
155
174
  text?: string;
156
- /** Optional message to attach to the contact request when admission is `request-required`. */
175
+ /** Optional contact-request note. Defaults to `text` when admission is `request-required`. */
157
176
  requestMessage?: string;
158
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": "^0.15.0"
31
+ "@canonmsg/core": "^0.15.3"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"