@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 +24 -0
- package/dist/canon-agent.d.ts +22 -1
- package/dist/canon-agent.js +219 -4
- package/dist/session-manager.d.ts +9 -1
- package/dist/session-manager.js +63 -17
- package/dist/types.d.ts +20 -1
- package/package.json +1 -1
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.
|
package/dist/canon-agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/canon-agent.js
CHANGED
|
@@ -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.
|
|
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:
|
|
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
|
}
|
package/dist/session-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
116
|
+
this.drainSession(conversationId);
|
|
85
117
|
});
|
|
86
118
|
}
|
|
87
|
-
drainSession(conversationId
|
|
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.
|
|
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
|
|
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
|
|
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.
|