@canonmsg/agent-sdk 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/canon-agent.d.ts +22 -2
- package/dist/canon-agent.js +222 -41
- package/dist/session-manager.d.ts +9 -1
- package/dist/session-manager.js +63 -17
- package/dist/types.d.ts +21 -2
- package/package.json +2 -2
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
|
|
@@ -55,7 +62,6 @@ export declare class CanonAgent {
|
|
|
55
62
|
*/
|
|
56
63
|
reachOut(card: ContactCardPayload, options?: ReachOutOptions): Promise<ReachOutResult>;
|
|
57
64
|
private executeReachOut;
|
|
58
|
-
private openConversationAndMaybeMessage;
|
|
59
65
|
start(): Promise<void>;
|
|
60
66
|
createConversation(options: CreateConversationOptions): Promise<{
|
|
61
67
|
conversationId: string;
|
|
@@ -86,10 +92,24 @@ export declare class CanonAgent {
|
|
|
86
92
|
private handleContactRequestEvent;
|
|
87
93
|
private handleContactGraphEvent;
|
|
88
94
|
stop(): Promise<void>;
|
|
95
|
+
private hasInterruptSupport;
|
|
96
|
+
private hasStopAndDropSupport;
|
|
97
|
+
private hasRuntimeSignalSupport;
|
|
98
|
+
private buildRuntimeDescriptor;
|
|
99
|
+
private buildRuntimeCapabilities;
|
|
89
100
|
private publishAgentRuntime;
|
|
90
101
|
private startRuntimeHeartbeat;
|
|
91
102
|
private stopRuntimeHeartbeat;
|
|
92
103
|
private clearAgentRuntime;
|
|
104
|
+
private rememberConversationId;
|
|
105
|
+
private baselineRuntimeControlSignals;
|
|
106
|
+
private startRuntimeControlPolling;
|
|
107
|
+
private stopRuntimeControlPolling;
|
|
108
|
+
private pollRuntimeControlSignals;
|
|
109
|
+
private handleRuntimeSignal;
|
|
110
|
+
private abortActiveTurns;
|
|
111
|
+
private resolveBatchDeliveryIntent;
|
|
112
|
+
private notifyMessageInterrupt;
|
|
93
113
|
private createRuntimeStatePublisher;
|
|
94
114
|
private handleMessages;
|
|
95
115
|
private executeHandler;
|
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, reachOutToCanonContact, } from '@canonmsg/core';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { AuthManager } from './auth.js';
|
|
4
4
|
import { Debouncer } from './debouncer.js';
|
|
@@ -18,6 +18,26 @@ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
|
|
|
18
18
|
supportsInterrupt: false,
|
|
19
19
|
streamingTextMode: 'snapshot',
|
|
20
20
|
};
|
|
21
|
+
const SDK_STOP_ACTION = {
|
|
22
|
+
id: 'stop',
|
|
23
|
+
label: 'Stop',
|
|
24
|
+
description: 'Interrupt the current SDK agent turn.',
|
|
25
|
+
aliases: ['stop'],
|
|
26
|
+
category: 'turn',
|
|
27
|
+
placements: ['composer_slash', 'command_palette'],
|
|
28
|
+
availability: ['busy'],
|
|
29
|
+
dispatch: { kind: 'signal', signal: 'interrupt' },
|
|
30
|
+
};
|
|
31
|
+
const SDK_STOP_AND_DROP_ACTION = {
|
|
32
|
+
id: 'stop-and-clear-queue',
|
|
33
|
+
label: 'Stop & clear queue',
|
|
34
|
+
description: 'Interrupt the current SDK agent turn and drop queued Canon messages.',
|
|
35
|
+
aliases: ['stop-clear', 'clear-queue'],
|
|
36
|
+
category: 'turn',
|
|
37
|
+
placements: ['composer_slash', 'command_palette', 'session_strip'],
|
|
38
|
+
availability: ['busy_with_queue'],
|
|
39
|
+
dispatch: { kind: 'signal', signal: 'stop_and_drop' },
|
|
40
|
+
};
|
|
21
41
|
function sleep(ms) {
|
|
22
42
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
43
|
}
|
|
@@ -33,6 +53,8 @@ export class CanonAgent {
|
|
|
33
53
|
contactApprovedHandler = null;
|
|
34
54
|
contactAddedHandler = null;
|
|
35
55
|
contactRemovedHandler = null;
|
|
56
|
+
interruptHandler = null;
|
|
57
|
+
stopAndDropHandler = null;
|
|
36
58
|
/** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
|
|
37
59
|
contacts;
|
|
38
60
|
/** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
|
|
@@ -43,6 +65,9 @@ export class CanonAgent {
|
|
|
43
65
|
cachedConversationIds = [];
|
|
44
66
|
running = false;
|
|
45
67
|
runtimeHeartbeatTimer = null;
|
|
68
|
+
runtimeControlPollTimer = null;
|
|
69
|
+
lastSeenSignal = new Map();
|
|
70
|
+
activeAbortControllers = new Map();
|
|
46
71
|
constructor(options) {
|
|
47
72
|
this.options = {
|
|
48
73
|
baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
|
|
@@ -73,6 +98,8 @@ export class CanonAgent {
|
|
|
73
98
|
idleTimeoutMs: options.sessions.idleTimeoutMs,
|
|
74
99
|
});
|
|
75
100
|
}
|
|
101
|
+
this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
|
|
102
|
+
this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
|
|
76
103
|
}
|
|
77
104
|
on(event, handler) {
|
|
78
105
|
if (event === 'message') {
|
|
@@ -91,6 +118,26 @@ export class CanonAgent {
|
|
|
91
118
|
this.contactAddedHandler = handler;
|
|
92
119
|
return;
|
|
93
120
|
}
|
|
121
|
+
if (event === 'interrupt') {
|
|
122
|
+
this.interruptHandler = handler;
|
|
123
|
+
if (this.running) {
|
|
124
|
+
void this.baselineRuntimeControlSignals(this.cachedConversationIds)
|
|
125
|
+
.then(() => this.startRuntimeControlPolling())
|
|
126
|
+
.catch(() => { });
|
|
127
|
+
}
|
|
128
|
+
void this.publishAgentRuntime().catch(() => { });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (event === 'stopAndDrop') {
|
|
132
|
+
this.stopAndDropHandler = handler;
|
|
133
|
+
if (this.running) {
|
|
134
|
+
void this.baselineRuntimeControlSignals(this.cachedConversationIds)
|
|
135
|
+
.then(() => this.startRuntimeControlPolling())
|
|
136
|
+
.catch(() => { });
|
|
137
|
+
}
|
|
138
|
+
void this.publishAgentRuntime().catch(() => { });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
94
141
|
this.contactRemovedHandler = handler;
|
|
95
142
|
}
|
|
96
143
|
/**
|
|
@@ -116,45 +163,11 @@ export class CanonAgent {
|
|
|
116
163
|
return promise;
|
|
117
164
|
}
|
|
118
165
|
async executeReachOut(targetUserId, options) {
|
|
119
|
-
|
|
120
|
-
if (admission.state === 'allowed' && admission.canMessage) {
|
|
121
|
-
return this.openConversationAndMaybeMessage(targetUserId, options);
|
|
122
|
-
}
|
|
123
|
-
if (admission.state === 'pending-outbound') {
|
|
124
|
-
return { status: 'pending', requestId: admission.pendingRequestId ?? null };
|
|
125
|
-
}
|
|
126
|
-
if (admission.state === 'request-required' && admission.canRequestContact) {
|
|
127
|
-
const result = await this.apiClient.createContactRequest(targetUserId, options?.requestMessage ?? null);
|
|
128
|
-
// The server may report 'open' if the target's policy flipped between
|
|
129
|
-
// the resolveAdmission call and the request. No request doc was
|
|
130
|
-
// written — fall through to the messaging path so the caller's intent
|
|
131
|
-
// ("reach this user") is honored end-to-end. Without this, the caller
|
|
132
|
-
// would get { status: 'requested' } despite no request existing.
|
|
133
|
-
if (result.status === 'open') {
|
|
134
|
-
return this.openConversationAndMaybeMessage(targetUserId, options);
|
|
135
|
-
}
|
|
136
|
-
// 'duplicate' means a pending request already existed; surface as
|
|
137
|
-
// 'pending'. 'created' is the normal "request just landed" path.
|
|
138
|
-
if (result.status === 'duplicate') {
|
|
139
|
-
return { status: 'pending', requestId: result.requestId };
|
|
140
|
-
}
|
|
141
|
-
return { status: 'requested', requestId: result.requestId };
|
|
142
|
-
}
|
|
143
|
-
if (admission.state === 'blocked') {
|
|
144
|
-
return { status: 'blocked', reason: 'blocked' };
|
|
145
|
-
}
|
|
146
|
-
return { status: 'unavailable', reason: admission.state };
|
|
147
|
-
}
|
|
148
|
-
async openConversationAndMaybeMessage(targetUserId, options) {
|
|
149
|
-
const { conversationId } = await this.apiClient.createConversation({
|
|
150
|
-
type: 'direct',
|
|
166
|
+
return reachOutToCanonContact(this.apiClient, {
|
|
151
167
|
targetUserId,
|
|
168
|
+
text: options?.text ?? null,
|
|
169
|
+
requestMessage: options?.requestMessage ?? null,
|
|
152
170
|
});
|
|
153
|
-
if (options?.text) {
|
|
154
|
-
const { messageId } = await this.apiClient.sendMessage(conversationId, options.text);
|
|
155
|
-
return { status: 'messaged', conversationId, messageId };
|
|
156
|
-
}
|
|
157
|
-
return { status: 'messaged', conversationId };
|
|
158
171
|
}
|
|
159
172
|
async start() {
|
|
160
173
|
if (this.running)
|
|
@@ -167,6 +180,7 @@ export class CanonAgent {
|
|
|
167
180
|
console.log(`[canon-sdk] Authenticated as ${agentId}`);
|
|
168
181
|
// 2. Wire debouncer to handler
|
|
169
182
|
this.debouncer.setCallback(async (conversationId, messages) => {
|
|
183
|
+
this.rememberConversationId(conversationId);
|
|
170
184
|
await this.handleMessages(conversationId, messages);
|
|
171
185
|
});
|
|
172
186
|
// 3. Fetch conversations (used for delivery mode + session state)
|
|
@@ -208,6 +222,8 @@ export class CanonAgent {
|
|
|
208
222
|
console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
|
|
209
223
|
}
|
|
210
224
|
}
|
|
225
|
+
await this.baselineRuntimeControlSignals(this.cachedConversationIds);
|
|
226
|
+
this.startRuntimeControlPolling();
|
|
211
227
|
// 4. Start delivery
|
|
212
228
|
const { RealtimeManager } = await import('./realtime.js');
|
|
213
229
|
const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, this.apiClient);
|
|
@@ -297,6 +313,7 @@ export class CanonAgent {
|
|
|
297
313
|
if (!this.running)
|
|
298
314
|
return;
|
|
299
315
|
this.running = false;
|
|
316
|
+
this.stopRuntimeControlPolling();
|
|
300
317
|
// Clear session state if enabled (uses cached IDs — no network call during shutdown)
|
|
301
318
|
const runtimeState = this.createRuntimeStatePublisher();
|
|
302
319
|
if (this.options.sessionState && runtimeState) {
|
|
@@ -316,12 +333,55 @@ export class CanonAgent {
|
|
|
316
333
|
this.debouncer.destroy();
|
|
317
334
|
console.log('[canon-sdk] Stopped');
|
|
318
335
|
}
|
|
336
|
+
hasInterruptSupport() {
|
|
337
|
+
return Boolean(this.interruptHandler);
|
|
338
|
+
}
|
|
339
|
+
hasStopAndDropSupport() {
|
|
340
|
+
return Boolean(this.stopAndDropHandler);
|
|
341
|
+
}
|
|
342
|
+
hasRuntimeSignalSupport() {
|
|
343
|
+
return this.hasInterruptSupport() || this.hasStopAndDropSupport();
|
|
344
|
+
}
|
|
345
|
+
buildRuntimeDescriptor() {
|
|
346
|
+
const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
|
|
347
|
+
const hasInterrupt = this.hasInterruptSupport();
|
|
348
|
+
const hasStopAndDrop = this.hasStopAndDropSupport();
|
|
349
|
+
const actions = [...(source.actions ?? [])].filter((action) => {
|
|
350
|
+
if (action.dispatch.kind !== 'signal')
|
|
351
|
+
return true;
|
|
352
|
+
if (action.dispatch.signal === 'interrupt')
|
|
353
|
+
return hasInterrupt;
|
|
354
|
+
if (action.dispatch.signal === 'stop_and_drop')
|
|
355
|
+
return hasStopAndDrop;
|
|
356
|
+
return false;
|
|
357
|
+
});
|
|
358
|
+
const hasInterruptAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'interrupt');
|
|
359
|
+
const hasStopAndDropAction = actions.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'stop_and_drop');
|
|
360
|
+
if (hasInterrupt && !hasInterruptAction) {
|
|
361
|
+
actions.push(SDK_STOP_ACTION);
|
|
362
|
+
}
|
|
363
|
+
if (hasStopAndDrop && this.sessionManager && !hasStopAndDropAction) {
|
|
364
|
+
actions.push(SDK_STOP_AND_DROP_ACTION);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
...source,
|
|
368
|
+
supportsInterrupt: hasInterrupt,
|
|
369
|
+
actions,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
buildRuntimeCapabilities() {
|
|
373
|
+
return {
|
|
374
|
+
...SDK_RUNTIME_CAPABILITIES,
|
|
375
|
+
supportsInterrupt: this.hasInterruptSupport(),
|
|
376
|
+
supportsQueue: Boolean(this.sessionManager),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
319
379
|
async publishAgentRuntime() {
|
|
320
380
|
const publisher = this.createRuntimeStatePublisher();
|
|
321
381
|
if (!publisher)
|
|
322
382
|
return;
|
|
323
383
|
await publisher.publishAgentRuntime({
|
|
324
|
-
runtimeDescriptor: this.
|
|
384
|
+
runtimeDescriptor: this.buildRuntimeDescriptor(),
|
|
325
385
|
});
|
|
326
386
|
}
|
|
327
387
|
startRuntimeHeartbeat() {
|
|
@@ -343,6 +403,113 @@ export class CanonAgent {
|
|
|
343
403
|
async clearAgentRuntime() {
|
|
344
404
|
await Promise.resolve(this.createRuntimeStatePublisher()?.clearAgentRuntime()).catch(() => { });
|
|
345
405
|
}
|
|
406
|
+
rememberConversationId(conversationId) {
|
|
407
|
+
if (this.cachedConversationIds.includes(conversationId))
|
|
408
|
+
return;
|
|
409
|
+
this.cachedConversationIds.push(conversationId);
|
|
410
|
+
}
|
|
411
|
+
async baselineRuntimeControlSignals(conversationIds) {
|
|
412
|
+
if (!this.agentId || !this.hasRuntimeSignalSupport())
|
|
413
|
+
return;
|
|
414
|
+
await Promise.all(conversationIds.map(async (conversationId) => {
|
|
415
|
+
const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
|
|
416
|
+
if (!raw || typeof raw !== 'object')
|
|
417
|
+
return;
|
|
418
|
+
const timestamp = Number(raw.updatedAt ?? 0);
|
|
419
|
+
if (timestamp > 0) {
|
|
420
|
+
this.lastSeenSignal.set(conversationId, timestamp);
|
|
421
|
+
}
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
startRuntimeControlPolling() {
|
|
425
|
+
if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeSignalSupport())
|
|
426
|
+
return;
|
|
427
|
+
this.runtimeControlPollTimer = setInterval(() => {
|
|
428
|
+
void this.pollRuntimeControlSignals();
|
|
429
|
+
}, 2_000);
|
|
430
|
+
this.runtimeControlPollTimer.unref?.();
|
|
431
|
+
}
|
|
432
|
+
stopRuntimeControlPolling() {
|
|
433
|
+
if (!this.runtimeControlPollTimer)
|
|
434
|
+
return;
|
|
435
|
+
clearInterval(this.runtimeControlPollTimer);
|
|
436
|
+
this.runtimeControlPollTimer = null;
|
|
437
|
+
}
|
|
438
|
+
async pollRuntimeControlSignals() {
|
|
439
|
+
if (!this.agentId || !this.hasRuntimeSignalSupport())
|
|
440
|
+
return;
|
|
441
|
+
await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
|
|
442
|
+
const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
|
|
443
|
+
if (!raw || typeof raw !== 'object')
|
|
444
|
+
return;
|
|
445
|
+
await this.handleRuntimeSignal(conversationId, raw);
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
async handleRuntimeSignal(conversationId, raw) {
|
|
449
|
+
if (!this.agentId)
|
|
450
|
+
return;
|
|
451
|
+
const signal = raw.type;
|
|
452
|
+
if (signal !== 'interrupt' && signal !== 'stop_and_drop')
|
|
453
|
+
return;
|
|
454
|
+
const timestamp = Number(raw.updatedAt ?? 0);
|
|
455
|
+
if (timestamp <= (this.lastSeenSignal.get(conversationId) ?? 0))
|
|
456
|
+
return;
|
|
457
|
+
this.lastSeenSignal.set(conversationId, timestamp);
|
|
458
|
+
const handler = signal === 'stop_and_drop'
|
|
459
|
+
? this.stopAndDropHandler
|
|
460
|
+
: this.interruptHandler;
|
|
461
|
+
if (!handler) {
|
|
462
|
+
await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const abortSignal = this.abortActiveTurns(conversationId);
|
|
466
|
+
const droppedMessages = signal === 'stop_and_drop'
|
|
467
|
+
? this.sessionManager?.dropQueued(conversationId) ?? []
|
|
468
|
+
: [];
|
|
469
|
+
const droppedMessageIds = droppedMessages.map((message) => message.id);
|
|
470
|
+
await Promise.all(droppedMessages.map((message) => {
|
|
471
|
+
if (message.metadata?.inboundDisposition !== 'queued')
|
|
472
|
+
return Promise.resolve();
|
|
473
|
+
return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
|
|
474
|
+
}));
|
|
475
|
+
await Promise.resolve(handler?.({
|
|
476
|
+
conversationId,
|
|
477
|
+
signal: signal,
|
|
478
|
+
updatedAt: timestamp || undefined,
|
|
479
|
+
abortSignal,
|
|
480
|
+
droppedMessageIds,
|
|
481
|
+
})).catch((error) => {
|
|
482
|
+
console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
|
|
483
|
+
});
|
|
484
|
+
await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/signal`, null)).catch(() => { });
|
|
485
|
+
}
|
|
486
|
+
abortActiveTurns(conversationId) {
|
|
487
|
+
const controllers = this.activeAbortControllers.get(conversationId);
|
|
488
|
+
if (!controllers || controllers.size === 0)
|
|
489
|
+
return undefined;
|
|
490
|
+
const abortSignal = controllers.values().next().value?.signal;
|
|
491
|
+
for (const controller of controllers) {
|
|
492
|
+
controller.abort();
|
|
493
|
+
}
|
|
494
|
+
return abortSignal;
|
|
495
|
+
}
|
|
496
|
+
resolveBatchDeliveryIntent(messages) {
|
|
497
|
+
return messages.some((message) => normalizeTurnMetadata(message.metadata)?.deliveryIntent === 'interrupt')
|
|
498
|
+
? 'interrupt'
|
|
499
|
+
: 'queue';
|
|
500
|
+
}
|
|
501
|
+
async notifyMessageInterrupt(conversationId, abortSignal) {
|
|
502
|
+
if (!abortSignal || !this.interruptHandler)
|
|
503
|
+
return;
|
|
504
|
+
await Promise.resolve(this.interruptHandler({
|
|
505
|
+
conversationId,
|
|
506
|
+
signal: 'interrupt',
|
|
507
|
+
abortSignal,
|
|
508
|
+
droppedMessageIds: [],
|
|
509
|
+
})).catch((error) => {
|
|
510
|
+
console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
346
513
|
createRuntimeStatePublisher() {
|
|
347
514
|
if (!this.agentId)
|
|
348
515
|
return null;
|
|
@@ -357,10 +524,14 @@ export class CanonAgent {
|
|
|
357
524
|
console.warn(`[canon-sdk] No message handler registered — messages for ${conversationId} dropped. Call agent.on('message', handler) before starting.`);
|
|
358
525
|
return;
|
|
359
526
|
}
|
|
527
|
+
const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
|
|
528
|
+
const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
|
|
529
|
+
const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
|
|
530
|
+
await this.notifyMessageInterrupt(conversationId, abortSignal);
|
|
360
531
|
if (this.sessionManager) {
|
|
361
532
|
await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
|
|
362
533
|
await this.executeHandler(conversationId, newMessages, session);
|
|
363
|
-
});
|
|
534
|
+
}, { toFront: shouldInterrupt });
|
|
364
535
|
}
|
|
365
536
|
else {
|
|
366
537
|
await this.executeHandler(conversationId, messages);
|
|
@@ -376,6 +547,10 @@ export class CanonAgent {
|
|
|
376
547
|
const agentId = this.agentId;
|
|
377
548
|
const runtimeState = this.createRuntimeStatePublisher();
|
|
378
549
|
const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
|
|
550
|
+
const abortController = new AbortController();
|
|
551
|
+
const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
|
|
552
|
+
activeControllers.add(abortController);
|
|
553
|
+
this.activeAbortControllers.set(conversationId, activeControllers);
|
|
379
554
|
const writeTurn = async (state) => {
|
|
380
555
|
if (!runtimeState || !agentId)
|
|
381
556
|
return;
|
|
@@ -385,7 +560,7 @@ export class CanonAgent {
|
|
|
385
560
|
state,
|
|
386
561
|
queueDepth: queueDepth(),
|
|
387
562
|
currentSpeakerId: agentId,
|
|
388
|
-
capabilities:
|
|
563
|
+
capabilities: this.buildRuntimeCapabilities(),
|
|
389
564
|
openedAt: turnOpenedAt,
|
|
390
565
|
...(state === 'completed' || state === 'interrupted' || state === 'idle'
|
|
391
566
|
? { completedAt: { '.sv': 'timestamp' } }
|
|
@@ -598,6 +773,7 @@ export class CanonAgent {
|
|
|
598
773
|
agent,
|
|
599
774
|
workSession,
|
|
600
775
|
activeWorkSessions,
|
|
776
|
+
abortSignal: abortController.signal,
|
|
601
777
|
media: {
|
|
602
778
|
materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
|
|
603
779
|
if (!message)
|
|
@@ -671,6 +847,11 @@ export class CanonAgent {
|
|
|
671
847
|
await writeTurn('interrupted');
|
|
672
848
|
}
|
|
673
849
|
finally {
|
|
850
|
+
const activeControllers = this.activeAbortControllers.get(conversationId);
|
|
851
|
+
activeControllers?.delete(abortController);
|
|
852
|
+
if (activeControllers?.size === 0) {
|
|
853
|
+
this.activeAbortControllers.delete(conversationId);
|
|
854
|
+
}
|
|
674
855
|
clearInterval(thinkingKeepalive);
|
|
675
856
|
// Always clear typing when done
|
|
676
857
|
try {
|
|
@@ -18,6 +18,10 @@ export interface Session {
|
|
|
18
18
|
lastActiveAt: number;
|
|
19
19
|
}
|
|
20
20
|
type SessionHandler = (session: Session, newMessages: CanonMessage[]) => Promise<void>;
|
|
21
|
+
export interface EnqueueOptions {
|
|
22
|
+
/** Place this batch ahead of other not-yet-running batches. */
|
|
23
|
+
toFront?: boolean;
|
|
24
|
+
}
|
|
21
25
|
/**
|
|
22
26
|
* Manages per-conversation sessions with:
|
|
23
27
|
* - In-memory message buffer per session
|
|
@@ -40,6 +44,8 @@ export declare class SessionManager {
|
|
|
40
44
|
constructor(config?: SessionConfig);
|
|
41
45
|
/** Get or create a session for a conversation */
|
|
42
46
|
getSession(conversationId: string): Session;
|
|
47
|
+
private appendMessagesToSession;
|
|
48
|
+
private removeMessagesFromSession;
|
|
43
49
|
/**
|
|
44
50
|
* Enqueue messages for processing in a specific session.
|
|
45
51
|
* Returns a promise that resolves when the handler completes for these messages.
|
|
@@ -47,7 +53,7 @@ export declare class SessionManager {
|
|
|
47
53
|
* - Messages within the same session are serialized (queued).
|
|
48
54
|
* - Messages across different sessions run concurrently up to the concurrency limit.
|
|
49
55
|
*/
|
|
50
|
-
enqueue(conversationId: string, messages: CanonMessage[], handler: SessionHandler): Promise<void>;
|
|
56
|
+
enqueue(conversationId: string, messages: CanonMessage[], handler: SessionHandler, options?: EnqueueOptions): Promise<void>;
|
|
51
57
|
private drainSession;
|
|
52
58
|
/** Seed a session with historical messages (e.g. fetched from API) */
|
|
53
59
|
seedHistory(conversationId: string, history: CanonMessage[]): void;
|
|
@@ -57,6 +63,8 @@ export declare class SessionManager {
|
|
|
57
63
|
get sessionCount(): number;
|
|
58
64
|
/** Number of queued batches waiting behind the active turn for this conversation. */
|
|
59
65
|
getQueueDepth(conversationId: string): number;
|
|
66
|
+
/** Drop queued, not-yet-running batches for a conversation. */
|
|
67
|
+
dropQueued(conversationId: string): CanonMessage[];
|
|
60
68
|
/** Clean up all state */
|
|
61
69
|
destroy(): void;
|
|
62
70
|
}
|
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.
|
|
@@ -153,6 +172,6 @@ export type ReachOutResult = {
|
|
|
153
172
|
export interface ReachOutOptions {
|
|
154
173
|
/** Optional first message to send when admission is `allowed`. */
|
|
155
174
|
text?: string;
|
|
156
|
-
/** Optional
|
|
175
|
+
/** Optional contact-request note. Defaults to `text` when admission is `request-required`. */
|
|
157
176
|
requestMessage?: string;
|
|
158
177
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^0.15.
|
|
31
|
+
"@canonmsg/core": "^0.15.3"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|