@canonmsg/agent-sdk 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -86,10 +93,24 @@ export declare class CanonAgent {
86
93
  private handleContactRequestEvent;
87
94
  private handleContactGraphEvent;
88
95
  stop(): Promise<void>;
96
+ private hasInterruptSupport;
97
+ private hasStopAndDropSupport;
98
+ private hasRuntimeSignalSupport;
99
+ private buildRuntimeDescriptor;
100
+ private buildRuntimeCapabilities;
89
101
  private publishAgentRuntime;
90
102
  private startRuntimeHeartbeat;
91
103
  private stopRuntimeHeartbeat;
92
104
  private clearAgentRuntime;
105
+ private rememberConversationId;
106
+ private baselineRuntimeControlSignals;
107
+ private startRuntimeControlPolling;
108
+ private stopRuntimeControlPolling;
109
+ private pollRuntimeControlSignals;
110
+ private handleRuntimeSignal;
111
+ private abortActiveTurns;
112
+ private resolveBatchDeliveryIntent;
113
+ private notifyMessageInterrupt;
93
114
  private createRuntimeStatePublisher;
94
115
  private handleMessages;
95
116
  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, } 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
  /**
@@ -167,6 +214,7 @@ export class CanonAgent {
167
214
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
168
215
  // 2. Wire debouncer to handler
169
216
  this.debouncer.setCallback(async (conversationId, messages) => {
217
+ this.rememberConversationId(conversationId);
170
218
  await this.handleMessages(conversationId, messages);
171
219
  });
172
220
  // 3. Fetch conversations (used for delivery mode + session state)
@@ -208,6 +256,8 @@ export class CanonAgent {
208
256
  console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
209
257
  }
210
258
  }
259
+ await this.baselineRuntimeControlSignals(this.cachedConversationIds);
260
+ this.startRuntimeControlPolling();
211
261
  // 4. Start delivery
212
262
  const { RealtimeManager } = await import('./realtime.js');
213
263
  const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
@@ -297,6 +347,7 @@ export class CanonAgent {
297
347
  if (!this.running)
298
348
  return;
299
349
  this.running = false;
350
+ this.stopRuntimeControlPolling();
300
351
  // Clear session state if enabled (uses cached IDs — no network call during shutdown)
301
352
  const runtimeState = this.createRuntimeStatePublisher();
302
353
  if (this.options.sessionState && runtimeState) {
@@ -316,12 +367,55 @@ export class CanonAgent {
316
367
  this.debouncer.destroy();
317
368
  console.log('[canon-sdk] Stopped');
318
369
  }
370
+ hasInterruptSupport() {
371
+ return Boolean(this.interruptHandler);
372
+ }
373
+ hasStopAndDropSupport() {
374
+ return Boolean(this.stopAndDropHandler);
375
+ }
376
+ hasRuntimeSignalSupport() {
377
+ return this.hasInterruptSupport() || this.hasStopAndDropSupport();
378
+ }
379
+ buildRuntimeDescriptor() {
380
+ const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
381
+ const hasInterrupt = this.hasInterruptSupport();
382
+ const hasStopAndDrop = this.hasStopAndDropSupport();
383
+ const actions = [...(source.actions ?? [])].filter((action) => {
384
+ if (action.dispatch.kind !== 'signal')
385
+ return true;
386
+ if (action.dispatch.signal === 'interrupt')
387
+ return hasInterrupt;
388
+ if (action.dispatch.signal === 'stop_and_drop')
389
+ return hasStopAndDrop;
390
+ return false;
391
+ });
392
+ const hasInterruptAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'interrupt');
393
+ const hasStopAndDropAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'stop_and_drop');
394
+ if (hasInterrupt && !hasInterruptAction) {
395
+ actions.push(SDK_STOP_ACTION);
396
+ }
397
+ if (hasStopAndDrop && this.sessionManager && !hasStopAndDropAction) {
398
+ actions.push(SDK_STOP_AND_DROP_ACTION);
399
+ }
400
+ return {
401
+ ...source,
402
+ supportsInterrupt: hasInterrupt,
403
+ actions,
404
+ };
405
+ }
406
+ buildRuntimeCapabilities() {
407
+ return {
408
+ ...SDK_RUNTIME_CAPABILITIES,
409
+ supportsInterrupt: this.hasInterruptSupport(),
410
+ supportsQueue: Boolean(this.sessionManager),
411
+ };
412
+ }
319
413
  async publishAgentRuntime() {
320
414
  const publisher = this.createRuntimeStatePublisher();
321
415
  if (!publisher)
322
416
  return;
323
417
  await publisher.publishAgentRuntime({
324
- runtimeDescriptor: this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR,
418
+ runtimeDescriptor: this.buildRuntimeDescriptor(),
325
419
  });
326
420
  }
327
421
  startRuntimeHeartbeat() {
@@ -343,6 +437,113 @@ export class CanonAgent {
343
437
  async clearAgentRuntime() {
344
438
  await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
345
439
  }
440
+ rememberConversationId(conversationId) {
441
+ if (this.cachedConversationIds.includes(conversationId))
442
+ return;
443
+ this.cachedConversationIds.push(conversationId);
444
+ }
445
+ async baselineRuntimeControlSignals(conversationIds) {
446
+ if (!this.agentId || !this.hasRuntimeSignalSupport())
447
+ return;
448
+ await Promise.all(conversationIds.map(async (conversationId) => {
449
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
450
+ if (!raw || typeof raw !== 'object')
451
+ return;
452
+ const timestamp = Number(raw.updatedAt ?? 0);
453
+ if (timestamp > 0) {
454
+ this.lastSeenSignal.set(conversationId, timestamp);
455
+ }
456
+ }));
457
+ }
458
+ startRuntimeControlPolling() {
459
+ if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeSignalSupport())
460
+ return;
461
+ this.runtimeControlPollTimer = setInterval(() => {
462
+ void this.pollRuntimeControlSignals();
463
+ }, 2_000);
464
+ this.runtimeControlPollTimer.unref?.();
465
+ }
466
+ stopRuntimeControlPolling() {
467
+ if (!this.runtimeControlPollTimer)
468
+ return;
469
+ clearInterval(this.runtimeControlPollTimer);
470
+ this.runtimeControlPollTimer = null;
471
+ }
472
+ async pollRuntimeControlSignals() {
473
+ if (!this.agentId || !this.hasRuntimeSignalSupport())
474
+ return;
475
+ await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
476
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
477
+ if (!raw || typeof raw !== 'object')
478
+ return;
479
+ await this.handleRuntimeSignal(conversationId, raw);
480
+ }));
481
+ }
482
+ async handleRuntimeSignal(conversationId, raw) {
483
+ if (!this.agentId)
484
+ return;
485
+ const signal = raw.type;
486
+ if (signal !== 'interrupt' && signal !== 'stop_and_drop')
487
+ return;
488
+ const timestamp = Number(raw.updatedAt ?? 0);
489
+ if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
490
+ return;
491
+ this.lastSeenSignal.set(conversationId, timestamp);
492
+ const handler = signal === 'stop_and_drop'
493
+ ? this.stopAndDropHandler
494
+ : this.interruptHandler;
495
+ if (!handler) {
496
+ await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
497
+ return;
498
+ }
499
+ const abortSignal = this.abortActiveTurns(conversationId);
500
+ const droppedMessages = signal === 'stop_and_drop'
501
+ ? this.sessionManager?.dropQueued(conversationId) ?? []
502
+ : [];
503
+ const droppedMessageIds = droppedMessages.map((message) => message.id);
504
+ await Promise.all(droppedMessages.map((message) => {
505
+ if (message.metadata?.inboundDisposition !== 'queued')
506
+ return Promise.resolve();
507
+ return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
508
+ }));
509
+ await Promise.resolve(handler?.({
510
+ conversationId,
511
+ signal: signal,
512
+ updatedAt: timestamp || undefined,
513
+ abortSignal,
514
+ droppedMessageIds,
515
+ })).catch((error) => {
516
+ console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
517
+ });
518
+ await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
519
+ }
520
+ abortActiveTurns(conversationId) {
521
+ const controllers = this.activeAbortControllers.get(conversationId);
522
+ if (!controllers || controllers.size === 0)
523
+ return undefined;
524
+ const abortSignal = controllers.values().next().value?.signal;
525
+ for (const controller of controllers) {
526
+ controller.abort();
527
+ }
528
+ return abortSignal;
529
+ }
530
+ resolveBatchDeliveryIntent(messages) {
531
+ return messages.some((message) => normalizeTurnMetadata(message.metadata)?.deliveryIntent === 'interrupt')
532
+ ? 'interrupt'
533
+ : 'queue';
534
+ }
535
+ async notifyMessageInterrupt(conversationId, abortSignal) {
536
+ if (!abortSignal || !this.interruptHandler)
537
+ return;
538
+ await Promise.resolve(this.interruptHandler({
539
+ conversationId,
540
+ signal: 'interrupt',
541
+ abortSignal,
542
+ droppedMessageIds: [],
543
+ })).catch((error) => {
544
+ console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
545
+ });
546
+ }
346
547
  createRuntimeStatePublisher() {
347
548
  if (!this.agentId)
348
549
  return null;
@@ -357,10 +558,14 @@ export class CanonAgent {
357
558
  console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
358
559
  return;
359
560
  }
561
+ const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
562
+ const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
563
+ const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
564
+ await this.notifyMessageInterrupt(conversationId, abortSignal);
360
565
  if (this.sessionManager) {
361
566
  await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
362
567
  await this.executeHandler(conversationId, newMessages, session);
363
- });
568
+ }, { toFront: shouldInterrupt });
364
569
  }
365
570
  else {
366
571
  await this.executeHandler(conversationId, messages);
@@ -376,6 +581,10 @@ export class CanonAgent {
376
581
  const agentId = this.agentId;
377
582
  const runtimeState = this.createRuntimeStatePublisher();
378
583
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
584
+ const abortController = new AbortController();
585
+ const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
586
+ activeControllers.add(abortController);
587
+ this.activeAbortControllers.set(conversationId, activeControllers);
379
588
  const writeTurn = async (state) => {
380
589
  if (!runtimeState || !agentId)
381
590
  return;
@@ -385,7 +594,7 @@ export class CanonAgent {
385
594
  state,
386
595
  queueDepth: queueDepth(),
387
596
  currentSpeakerId: agentId,
388
- capabilities: SDK_RUNTIME_CAPABILITIES,
597
+ capabilities: this.buildRuntimeCapabilities(),
389
598
  openedAt: turnOpenedAt,
390
599
  ...(state === 'completed' || state === 'interrupted' || state === 'idle'
391
600
  ? { completedAt: { '.sv': 'timestamp' } }
@@ -598,6 +807,7 @@ export class CanonAgent {
598
807
  agent,
599
808
  workSession,
600
809
  activeWorkSessions,
810
+ abortSignal: abortController.signal,
601
811
  media: {
602
812
  materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
603
813
  if (!message)
@@ -671,6 +881,11 @@ export class CanonAgent {
671
881
  await writeTurn('interrupted');
672
882
  }
673
883
  finally {
884
+ const activeControllers = this.activeAbortControllers.get(conversationId);
885
+ activeControllers?.delete(abortController);
886
+ if (activeControllers?.size === 0) {
887
+ this.activeAbortControllers.delete(conversationId);
888
+ }
674
889
  clearInterval(thinkingKeepalive);
675
890
  // Always clear typing when done
676
891
  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.
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.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",