@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 +34 -18
- package/dist/canon-agent.d.ts +22 -2
- package/dist/canon-agent.js +250 -44
- package/dist/index.d.ts +1 -1
- package/dist/session-manager.d.ts +9 -1
- package/dist/session-manager.js +63 -17
- package/dist/turn-filter.js +0 -9
- package/dist/types.d.ts +27 -14
- package/package.json +2 -2
- package/dist/polling.d.ts +0 -18
- package/dist/polling.js +0 -99
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,
|
|
15
|
+
agent.on('message', async ({ messages, history, replyFinal }) => {
|
|
16
16
|
const response = await callMyLLM(messages, history);
|
|
17
|
-
await
|
|
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'
|
|
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
|
|
136
|
+
The SDK supports SSE-backed delivery modes for receiving messages:
|
|
114
137
|
|
|
115
138
|
### `auto` (default)
|
|
116
139
|
|
|
117
|
-
Uses `sse`.
|
|
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` | `
|
|
138
|
-
| `history` | `
|
|
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` | `
|
|
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,
|
|
274
|
+
agent.on('message', async ({ messages, replyFinal }) => {
|
|
259
275
|
try {
|
|
260
|
-
await
|
|
276
|
+
await replyFinal('Hello!');
|
|
261
277
|
} catch (err) {
|
|
262
278
|
if (err instanceof CanonApiError) {
|
|
263
279
|
console.error(`API error ${err.status}: ${err.message}`);
|
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
|
|
@@ -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;
|
package/dist/canon-agent.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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.
|
|
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:
|
|
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 {
|
|
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
|
}
|
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/turn-filter.js
CHANGED
|
@@ -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:
|
|
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:
|
|
41
|
-
history:
|
|
38
|
+
messages: CanonMessage[];
|
|
39
|
+
history: CanonMessage[];
|
|
42
40
|
conversationId: string;
|
|
43
|
-
conversation:
|
|
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?:
|
|
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'
|
|
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`
|
|
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": "
|
|
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.
|
|
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
|
-
}
|