@canonmsg/agent-sdk 0.10.2 → 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
@@ -12,9 +12,9 @@ const agent = new CanonAgent({
12
12
  historyLimit: 30,
13
13
  });
14
14
 
15
- agent.on('message', async ({ messages, history, reply }) => {
15
+ agent.on('message', async ({ messages, history, replyFinal }) => {
16
16
  const response = await callMyLLM(messages, history);
17
- await reply(response);
17
+ await replyFinal(response);
18
18
  });
19
19
 
20
20
  await agent.start();
@@ -35,13 +35,13 @@ No additional dependencies required — the SDK uses native `fetch` and `Readabl
35
35
  | `apiKey` | `string` | **required** | API key obtained after agent registration approval |
36
36
  | `baseUrl` | `string` | Canon production URL | Override the API base URL |
37
37
  | `streamUrl` | `string` | Canon stream service URL | Override the SSE stream URL |
38
- | `deliveryMode` | `'auto' \| 'sse' \| 'polling'` | `'auto'` | How the SDK receives new messages |
39
- | `pollingIntervalMs` | `number` | `3000` | Polling interval in milliseconds (polling mode only) |
38
+ | `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
40
39
  | `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
41
40
  | `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
42
41
  | `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
43
42
  | `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
44
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 |
45
45
  | `sessionState` | `boolean` | `false` | Publish RTDB session-state for the conversations this agent is active in |
46
46
 
47
47
  ### Optional runtime controls
@@ -89,6 +89,29 @@ const agent = new CanonAgent({
89
89
  });
90
90
  ```
91
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
+
92
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.
93
116
 
94
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.
@@ -110,11 +133,11 @@ Current rules of thumb:
110
133
 
111
134
  ## Delivery Modes
112
135
 
113
- The SDK supports three delivery modes for receiving messages:
136
+ The SDK supports SSE-backed delivery modes for receiving messages:
114
137
 
115
138
  ### `auto` (default)
116
139
 
117
- Uses `sse`. Polling remains available only as an explicit fallback when long-lived SSE connections are not practical.
140
+ Uses `sse`.
118
141
 
119
142
  ### `sse`
120
143
 
@@ -122,23 +145,16 @@ Connects to Canon's SSE stream service for instant message delivery. A single co
122
145
 
123
146
  Best for: agents in a small-to-medium number of active conversations where low latency matters.
124
147
 
125
- ### `polling`
126
-
127
- Periodically calls the REST API to discover new messages. Latency is bounded by `pollingIntervalMs`.
128
-
129
- Best for: agents in many conversations, or environments where long-lived connections are not practical.
130
-
131
148
  ## Message Handler
132
149
 
133
150
  The `message` event handler receives a context object with:
134
151
 
135
152
  | Field | Type | Description |
136
153
  |---|---|---|
137
- | `messages` | `SDKMessage[]` | New messages in this batch (debounced, sorted by time) |
138
- | `history` | `SDKMessage[]` | Last N messages before these new ones |
154
+ | `messages` | `CanonMessage[]` | New messages in this batch (debounced, sorted by time) |
155
+ | `history` | `CanonMessage[]` | Last N messages before these new ones |
139
156
  | `conversationId` | `string` | The conversation these messages belong to |
140
- | `conversation` | `SDKConversation` | Full conversation metadata |
141
- | `reply` | `(text: string, options?) => Promise<{ messageId: string }>` | Convenience alias for `replyFinal` |
157
+ | `conversation` | `CanonConversation` | Full conversation metadata |
142
158
  | `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
143
159
  | `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
144
160
  | `agent` | `AgentContext` | Trusted Canon agent identity and access context |
@@ -255,9 +271,9 @@ The SDK exports `CanonApiError` for typed error handling:
255
271
  ```typescript
256
272
  import { CanonAgent, CanonApiError } from '@canonmsg/agent-sdk';
257
273
 
258
- agent.on('message', async ({ messages, reply }) => {
274
+ agent.on('message', async ({ messages, replyFinal }) => {
259
275
  try {
260
- await reply('Hello!');
276
+ await replyFinal('Hello!');
261
277
  } catch (err) {
262
278
  if (err instanceof CanonApiError) {
263
279
  console.error(`API error ${err.status}: ${err.message}`);
@@ -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
@@ -23,7 +23,6 @@ export declare class CanonAgent {
23
23
  private apiClient;
24
24
  private authManager;
25
25
  private debouncer;
26
- private pollingManager;
27
26
  private realtimeManager;
28
27
  private sessionManager;
29
28
  private handler;
@@ -31,6 +30,8 @@ export declare class CanonAgent {
31
30
  private contactApprovedHandler;
32
31
  private contactAddedHandler;
33
32
  private contactRemovedHandler;
33
+ private interruptHandler;
34
+ private stopAndDropHandler;
34
35
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
35
36
  readonly contacts: AgentContactsAPI;
36
37
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -41,12 +42,17 @@ export declare class CanonAgent {
41
42
  private cachedConversationIds;
42
43
  private running;
43
44
  private runtimeHeartbeatTimer;
45
+ private runtimeControlPollTimer;
46
+ private readonly lastSeenSignal;
47
+ private readonly activeAbortControllers;
44
48
  constructor(options: CanonAgentOptions);
45
49
  on(event: 'message', handler: MessageHandler): void;
46
50
  on(event: 'contactRequest', handler: ContactRequestHandler): void;
47
51
  on(event: 'contactApproved', handler: ContactRequestHandler): void;
48
52
  on(event: 'contactAdded', handler: ContactAddedHandler): void;
49
53
  on(event: 'contactRemoved', handler: ContactRemovedHandler): void;
54
+ on(event: 'interrupt', handler: RuntimeSignalHandler): void;
55
+ on(event: 'stopAndDrop', handler: RuntimeSignalHandler): void;
50
56
  /**
51
57
  * Resolve admission live for a target user (typically read off a shared
52
58
  * contact card) and route into either an immediate message or a contact
@@ -87,10 +93,24 @@ export declare class CanonAgent {
87
93
  private handleContactRequestEvent;
88
94
  private handleContactGraphEvent;
89
95
  stop(): Promise<void>;
96
+ private hasInterruptSupport;
97
+ private hasStopAndDropSupport;
98
+ private hasRuntimeSignalSupport;
99
+ private buildRuntimeDescriptor;
100
+ private buildRuntimeCapabilities;
90
101
  private publishAgentRuntime;
91
102
  private startRuntimeHeartbeat;
92
103
  private stopRuntimeHeartbeat;
93
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;
94
114
  private createRuntimeStatePublisher;
95
115
  private handleMessages;
96
116
  private executeHandler;
@@ -1,9 +1,8 @@
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';
5
5
  import { materializeMessageMedia, uploadMediaFile, } from './media.js';
6
- import { PollingManager } from './polling.js';
7
6
  import { SessionManager } from './session-manager.js';
8
7
  const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
9
8
  const SDK_RUNTIME_CAPABILITIES = {
@@ -19,6 +18,26 @@ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
19
18
  supportsInterrupt: false,
20
19
  streamingTextMode: 'snapshot',
21
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
+ };
22
41
  function sleep(ms) {
23
42
  return new Promise((resolve) => setTimeout(resolve, ms));
24
43
  }
@@ -27,7 +46,6 @@ export class CanonAgent {
27
46
  apiClient;
28
47
  authManager;
29
48
  debouncer;
30
- pollingManager = null;
31
49
  realtimeManager = null;
32
50
  sessionManager = null;
33
51
  handler = null;
@@ -35,6 +53,8 @@ export class CanonAgent {
35
53
  contactApprovedHandler = null;
36
54
  contactAddedHandler = null;
37
55
  contactRemovedHandler = null;
56
+ interruptHandler = null;
57
+ stopAndDropHandler = null;
38
58
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
39
59
  contacts;
40
60
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -45,11 +65,13 @@ export class CanonAgent {
45
65
  cachedConversationIds = [];
46
66
  running = false;
47
67
  runtimeHeartbeatTimer = null;
68
+ runtimeControlPollTimer = null;
69
+ lastSeenSignal = new Map();
70
+ activeAbortControllers = new Map();
48
71
  constructor(options) {
49
72
  this.options = {
50
73
  baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
51
74
  deliveryMode: 'auto',
52
- pollingIntervalMs: 3000,
53
75
  debounceMs: 2000,
54
76
  historyLimit: 50,
55
77
  autoMarkRead: true,
@@ -76,6 +98,8 @@ export class CanonAgent {
76
98
  idleTimeoutMs: options.sessions.idleTimeoutMs,
77
99
  });
78
100
  }
101
+ this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
102
+ this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
79
103
  }
80
104
  on(event, handler) {
81
105
  if (event === 'message') {
@@ -94,6 +118,26 @@ export class CanonAgent {
94
118
  this.contactAddedHandler = handler;
95
119
  return;
96
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
+ }
97
141
  this.contactRemovedHandler = handler;
98
142
  }
99
143
  /**
@@ -170,6 +214,7 @@ export class CanonAgent {
170
214
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
171
215
  // 2. Wire debouncer to handler
172
216
  this.debouncer.setCallback(async (conversationId, messages) => {
217
+ this.rememberConversationId(conversationId);
173
218
  await this.handleMessages(conversationId, messages);
174
219
  });
175
220
  // 3. Fetch conversations (used for delivery mode + session state)
@@ -187,6 +232,9 @@ export class CanonAgent {
187
232
  mode = 'sse';
188
233
  console.log(`[canon-sdk] Auto-selected ${mode} mode (${conversations.length} conversations)`);
189
234
  }
235
+ if (mode !== 'sse') {
236
+ throw new Error(`Unsupported deliveryMode: ${mode}. Use 'auto' or 'sse'.`);
237
+ }
190
238
  // 3b. Fetch agent context (identity, owner, access level)
191
239
  try {
192
240
  this.agentContext = await this.apiClient.getAgentMe();
@@ -208,42 +256,37 @@ 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
- if (mode === 'sse' || mode === 'realtime') {
213
- const { RealtimeManager } = await import('./realtime.js');
214
- const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
215
- rtm.setOnAgentContext((ctx) => {
216
- this.agentContext = ctx;
217
- });
218
- rtm.setContactRequestHandlers({
219
- onContactRequest: (request) => {
220
- void this.handleContactRequestEvent(this.contactRequestHandler, request);
221
- },
222
- onContactApproved: (request) => {
223
- void this.handleContactRequestEvent(this.contactApprovedHandler, request);
224
- },
225
- });
226
- rtm.setContactGraphHandlers({
227
- onContactAdded: (payload) => {
228
- void this.handleContactGraphEvent(this.contactAddedHandler, payload);
229
- },
230
- onContactRemoved: (payload) => {
231
- void this.handleContactGraphEvent(this.contactRemovedHandler, payload);
232
- },
233
- });
234
- rtm.setConnectionHandlers({
235
- onConnected: () => this.startRuntimeHeartbeat(),
236
- onDisconnected: () => this.stopRuntimeHeartbeat(),
237
- });
238
- this.realtimeManager = rtm;
239
- await rtm.start();
240
- console.log('[canon-sdk] SSE stream started');
241
- }
242
- else {
243
- this.pollingManager = new PollingManager(this.apiClient, this.debouncer, agentId, this.options.pollingIntervalMs, () => this.startRuntimeHeartbeat(), () => this.stopRuntimeHeartbeat());
244
- await this.pollingManager.start();
245
- console.log(`[canon-sdk] Polling started (interval: ${this.options.pollingIntervalMs}ms)`);
246
- }
262
+ const { RealtimeManager } = await import('./realtime.js');
263
+ const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
264
+ rtm.setOnAgentContext((ctx) => {
265
+ this.agentContext = ctx;
266
+ });
267
+ rtm.setContactRequestHandlers({
268
+ onContactRequest: (request) => {
269
+ void this.handleContactRequestEvent(this.contactRequestHandler, request);
270
+ },
271
+ onContactApproved: (request) => {
272
+ void this.handleContactRequestEvent(this.contactApprovedHandler, request);
273
+ },
274
+ });
275
+ rtm.setContactGraphHandlers({
276
+ onContactAdded: (payload) => {
277
+ void this.handleContactGraphEvent(this.contactAddedHandler, payload);
278
+ },
279
+ onContactRemoved: (payload) => {
280
+ void this.handleContactGraphEvent(this.contactRemovedHandler, payload);
281
+ },
282
+ });
283
+ rtm.setConnectionHandlers({
284
+ onConnected: () => this.startRuntimeHeartbeat(),
285
+ onDisconnected: () => this.stopRuntimeHeartbeat(),
286
+ });
287
+ this.realtimeManager = rtm;
288
+ await rtm.start();
289
+ console.log('[canon-sdk] SSE stream started');
247
290
  }
248
291
  async createConversation(options) {
249
292
  return this.apiClient.createConversation(options);
@@ -304,6 +347,7 @@ export class CanonAgent {
304
347
  if (!this.running)
305
348
  return;
306
349
  this.running = false;
350
+ this.stopRuntimeControlPolling();
307
351
  // Clear session state if enabled (uses cached IDs — no network call during shutdown)
308
352
  const runtimeState = this.createRuntimeStatePublisher();
309
353
  if (this.options.sessionState && runtimeState) {
@@ -317,19 +361,61 @@ export class CanonAgent {
317
361
  }
318
362
  }
319
363
  await this.clearAgentRuntime();
320
- this.pollingManager?.stop();
321
364
  this.realtimeManager?.stop();
322
365
  this.sessionManager?.destroy();
323
366
  this.authManager.destroy();
324
367
  this.debouncer.destroy();
325
368
  console.log('[canon-sdk] Stopped');
326
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
+ }
327
413
  async publishAgentRuntime() {
328
414
  const publisher = this.createRuntimeStatePublisher();
329
415
  if (!publisher)
330
416
  return;
331
417
  await publisher.publishAgentRuntime({
332
- runtimeDescriptor: this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR,
418
+ runtimeDescriptor: this.buildRuntimeDescriptor(),
333
419
  });
334
420
  }
335
421
  startRuntimeHeartbeat() {
@@ -351,6 +437,113 @@ export class CanonAgent {
351
437
  async clearAgentRuntime() {
352
438
  await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
353
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
+ }
354
547
  createRuntimeStatePublisher() {
355
548
  if (!this.agentId)
356
549
  return null;
@@ -365,10 +558,14 @@ export class CanonAgent {
365
558
  console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
366
559
  return;
367
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);
368
565
  if (this.sessionManager) {
369
566
  await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
370
567
  await this.executeHandler(conversationId, newMessages, session);
371
- });
568
+ }, { toFront: shouldInterrupt });
372
569
  }
373
570
  else {
374
571
  await this.executeHandler(conversationId, messages);
@@ -384,6 +581,10 @@ export class CanonAgent {
384
581
  const agentId = this.agentId;
385
582
  const runtimeState = this.createRuntimeStatePublisher();
386
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);
387
588
  const writeTurn = async (state) => {
388
589
  if (!runtimeState || !agentId)
389
590
  return;
@@ -393,7 +594,7 @@ export class CanonAgent {
393
594
  state,
394
595
  queueDepth: queueDepth(),
395
596
  currentSpeakerId: agentId,
396
- capabilities: SDK_RUNTIME_CAPABILITIES,
597
+ capabilities: this.buildRuntimeCapabilities(),
397
598
  openedAt: turnOpenedAt,
398
599
  ...(state === 'completed' || state === 'interrupted' || state === 'idle'
399
600
  ? { completedAt: { '.sv': 'timestamp' } }
@@ -591,7 +792,6 @@ export class CanonAgent {
591
792
  history,
592
793
  conversationId,
593
794
  conversation,
594
- reply: replyFinal,
595
795
  replyFinal,
596
796
  replyProgress,
597
797
  deleteMessage,
@@ -607,6 +807,7 @@ export class CanonAgent {
607
807
  agent,
608
808
  workSession,
609
809
  activeWorkSessions,
810
+ abortSignal: abortController.signal,
610
811
  media: {
611
812
  materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
612
813
  if (!message)
@@ -680,6 +881,11 @@ export class CanonAgent {
680
881
  await writeTurn('interrupted');
681
882
  }
682
883
  finally {
884
+ const activeControllers = this.activeAbortControllers.get(conversationId);
885
+ activeControllers?.delete(abortController);
886
+ if (activeControllers?.size === 0) {
887
+ this.activeAbortControllers.delete(conversationId);
888
+ }
683
889
  clearInterval(thinkingKeepalive);
684
890
  // Always clear typing when done
685
891
  try {
package/dist/index.d.ts CHANGED
@@ -7,4 +7,4 @@ export { getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthro
7
7
  export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
8
8
  export type { SessionConfig, Session } from './session-manager.js';
9
9
  export type { AgentContext, CanonContactRequest, CanonMessage, CanonConversation, CanonResolvedWorkSession, CanonWorkSession, CanonWorkSessionContext, CanonWorkSessionConversationRole, CanonWorkSessionDisclosureMode, CanonWorkSessionParticipant, CanonWorkSessionStatus, CreateWorkSessionOptions, SendLinkedMessageOptions, SendLinkedMessageResult, SendMessageOptions, CreateConversationOptions, UpdateWorkSessionConversationOptions, } from '@canonmsg/core';
10
- export type { SDKMessage, SDKConversation, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
10
+ export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
@@ -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) {
@@ -4,15 +4,6 @@ function normalizeRuntimeTurnState(value) {
4
4
  if (turnState) {
5
5
  return { state: turnState.state };
6
6
  }
7
- if (!value || typeof value !== 'object')
8
- return null;
9
- const state = value.state;
10
- if (state === 'running') {
11
- return { state: 'streaming' };
12
- }
13
- if (state === 'requires_action') {
14
- return { state: 'waiting_input' };
15
- }
16
7
  return null;
17
8
  }
18
9
  export async function shouldDispatchInboundMessage(conversationId, agentId, message, options) {
package/dist/types.d.ts CHANGED
@@ -1,8 +1,6 @@
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
- export type SDKMessage = CanonMessage;
5
- export type SDKConversation = CanonConversation;
6
4
  export interface ProgressMessageOptions extends SendMessageOptions {
7
5
  /**
8
6
  * Persist the progress update to Firestore.
@@ -23,7 +21,7 @@ export interface SessionInfo {
23
21
  /** Session ID (= conversationId) */
24
22
  id: string;
25
23
  /** All accumulated messages for this conversation (within the context limit), oldest first */
26
- messages: SDKMessage[];
24
+ messages: CanonMessage[];
27
25
  /** Arbitrary per-session state the agent can read/write across handler calls */
28
26
  metadata: Record<string, unknown>;
29
27
  queueDepth?: number;
@@ -37,13 +35,10 @@ export interface TurnController {
37
35
  setWaitingInput: (text?: string) => Promise<void>;
38
36
  }
39
37
  export interface MessageHandlerContext {
40
- messages: SDKMessage[];
41
- history: SDKMessage[];
38
+ messages: CanonMessage[];
39
+ history: CanonMessage[];
42
40
  conversationId: string;
43
- conversation: SDKConversation;
44
- reply: (text: string, options?: SendMessageOptions) => Promise<{
45
- messageId: string;
46
- }>;
41
+ conversation: CanonConversation;
47
42
  replyFinal: (text: string, options?: SendMessageOptions) => Promise<{
48
43
  messageId: string;
49
44
  }>;
@@ -82,7 +77,7 @@ export interface MessageHandlerContext {
82
77
  activeWorkSessions?: import('@canonmsg/core').CanonWorkSessionContext[];
83
78
  /** Canon-managed local media access for the current conversation. */
84
79
  media: {
85
- materialize: (message?: SDKMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
80
+ materialize: (message?: CanonMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
86
81
  uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<{
87
82
  url: string;
88
83
  attachment: import('@canonmsg/core').MediaAttachment;
@@ -95,6 +90,8 @@ export interface MessageHandlerContext {
95
90
  session?: SessionInfo;
96
91
  /** Turn lifecycle helpers for live-work rendering and progress reporting. */
97
92
  turn?: TurnController;
93
+ /** Aborted when Canon sends an interrupt/stop signal for this active turn. */
94
+ abortSignal: AbortSignal;
98
95
  }
99
96
  export type MessageHandler = (ctx: MessageHandlerContext) => Promise<void>;
100
97
  export interface SessionOptions {
@@ -107,14 +104,28 @@ export interface SessionOptions {
107
104
  /** Evict idle sessions after this many ms (default: 3600000 = 1h) */
108
105
  idleTimeoutMs?: number;
109
106
  }
110
- export type DeliveryMode = 'auto' | 'sse' | 'realtime' | 'polling';
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
+ }
111
123
  export interface CanonAgentOptions {
112
124
  apiKey: string;
113
125
  baseUrl?: string;
114
126
  streamUrl?: string;
115
- /** `auto` now resolves to SSE; use `polling` only as an explicit fallback. */
127
+ /** `auto` resolves to SSE. */
116
128
  deliveryMode?: DeliveryMode;
117
- pollingIntervalMs?: number;
118
129
  debounceMs?: number;
119
130
  historyLimit?: number;
120
131
  /** Automatically mark conversations as read when handling messages (default: true) */
@@ -125,6 +136,8 @@ export interface CanonAgentOptions {
125
136
  clientType?: import('@canonmsg/core').AgentClientType;
126
137
  /** Optional runtime descriptor published to Canon for setup/live UI rendering. */
127
138
  runtimeDescriptor?: import('@canonmsg/core').CanonRuntimeDescriptor;
139
+ /** Optional Canon runtime signal handlers. Enables interrupt controls when provided. */
140
+ runtimeControls?: RuntimeControlHandlers;
128
141
  /**
129
142
  * Enable RTDB session-state reporting. Off by default.
130
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": "0.10.2",
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",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^0.14.0"
31
+ "@canonmsg/core": "^0.15.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
package/dist/polling.d.ts DELETED
@@ -1,18 +0,0 @@
1
- import { CanonClient } from '@canonmsg/core';
2
- import { Debouncer } from './debouncer.js';
3
- export declare class PollingManager {
4
- private apiClient;
5
- private debouncer;
6
- private agentId;
7
- private pollingIntervalMs;
8
- private onHealthy;
9
- private onUnhealthy;
10
- private lastSeenTimestamps;
11
- private pollTimer;
12
- private running;
13
- constructor(apiClient: CanonClient, debouncer: Debouncer, agentId: string, pollingIntervalMs: number, onHealthy?: () => void, onUnhealthy?: () => void);
14
- start(): Promise<void>;
15
- private poll;
16
- private findActiveConversations;
17
- stop(): void;
18
- }
package/dist/polling.js DELETED
@@ -1,99 +0,0 @@
1
- import { buildParticipationHistorySnapshots } from './policy-history.js';
2
- import { shouldDispatchInboundMessage } from './turn-filter.js';
3
- export class PollingManager {
4
- apiClient;
5
- debouncer;
6
- agentId;
7
- pollingIntervalMs;
8
- onHealthy;
9
- onUnhealthy;
10
- lastSeenTimestamps = new Map();
11
- pollTimer = null;
12
- running = false;
13
- constructor(apiClient, debouncer, agentId, pollingIntervalMs, onHealthy, onUnhealthy) {
14
- this.apiClient = apiClient;
15
- this.debouncer = debouncer;
16
- this.agentId = agentId;
17
- this.pollingIntervalMs = pollingIntervalMs;
18
- this.onHealthy = onHealthy ?? null;
19
- this.onUnhealthy = onUnhealthy ?? null;
20
- }
21
- async start() {
22
- this.running = true;
23
- // Initialize: mark current time as baseline (only respond to messages after start)
24
- const now = Date.now();
25
- const conversations = await this.apiClient.getConversations();
26
- for (const convo of conversations) {
27
- this.lastSeenTimestamps.set(convo.id, now);
28
- }
29
- this.onHealthy?.();
30
- // Start polling
31
- this.pollTimer = setInterval(() => this.poll(), this.pollingIntervalMs);
32
- }
33
- async poll() {
34
- if (!this.running)
35
- return;
36
- try {
37
- const conversations = await this.apiClient.getConversations();
38
- this.onHealthy?.();
39
- const activeConvos = this.findActiveConversations(conversations);
40
- await Promise.all(activeConvos.map(async (convo) => {
41
- try {
42
- const page = await this.apiClient.getMessagesPage(convo.id, 50);
43
- const messages = page.messages;
44
- const participationHistory = buildParticipationHistorySnapshots(messages, this.agentId);
45
- // Filter to only new messages (after lastSeen, not from self)
46
- const lastSeen = this.lastSeenTimestamps.get(convo.id) || 0;
47
- const newMessages = messages.filter((m) => {
48
- const msgTime = new Date(m.createdAt).getTime();
49
- return msgTime > lastSeen && m.senderId !== this.agentId;
50
- });
51
- const dispatchable = await Promise.all(newMessages.map(async (message) => ({
52
- message,
53
- allow: await shouldDispatchInboundMessage(convo.id, this.agentId, message, {
54
- conversationType: convo.type,
55
- behavior: page.behavior,
56
- recentHumanCount: participationHistory.get(message.id)?.recentHumanCount,
57
- consecutiveAgentTurns: participationHistory.get(message.id)?.consecutiveAgentTurns,
58
- currentAgentStreakStartedByHuman: participationHistory.get(message.id)?.currentAgentStreakStartedByHuman,
59
- }),
60
- })));
61
- for (const msg of dispatchable.filter((entry) => entry.allow).map((entry) => entry.message)) {
62
- this.debouncer.add(convo.id, msg);
63
- }
64
- // Update lastSeen to latest message timestamp
65
- if (messages.length > 0) {
66
- const latestTime = Math.max(...messages.map((m) => new Date(m.createdAt).getTime()));
67
- this.lastSeenTimestamps.set(convo.id, latestTime);
68
- }
69
- }
70
- catch (err) {
71
- console.error(`[canon-sdk] Failed to fetch messages for ${convo.id}:`, err);
72
- }
73
- }));
74
- }
75
- catch (err) {
76
- this.onUnhealthy?.();
77
- console.error('[canon-sdk] Polling error:', err);
78
- }
79
- }
80
- findActiveConversations(conversations) {
81
- return conversations.filter((convo) => {
82
- if (!convo.lastMessage)
83
- return false;
84
- // Skip if agent was last sender
85
- if (convo.lastMessage.senderId === this.agentId)
86
- return false;
87
- const lastMsgTime = new Date(convo.lastMessage.timestamp).getTime();
88
- const lastSeen = this.lastSeenTimestamps.get(convo.id) || 0;
89
- return lastMsgTime > lastSeen;
90
- });
91
- }
92
- stop() {
93
- this.running = false;
94
- if (this.pollTimer) {
95
- clearInterval(this.pollTimer);
96
- this.pollTimer = null;
97
- }
98
- }
99
- }