@canonmsg/agent-sdk 10.2.1 → 10.3.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 +17 -63
- package/dist/attached-session.d.ts +58 -0
- package/dist/attached-session.js +555 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/work-session-host.d.ts +31 -0
- package/dist/work-session-host.js +573 -0
- package/dist/work-session-interactions.d.ts +12 -0
- package/dist/work-session-interactions.js +173 -0
- package/dist/work-session-state.d.ts +36 -0
- package/dist/work-session-state.js +62 -0
- package/package.json +7 -3
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { buildChunkedMessagePartMetadata, CanonClient, CanonStream, createAttachedNativeSession, createRuntimeHeartbeat, createRuntimeStatePublisher, initRTDBAuth, resolveCanonRuntimeConnection, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
3
|
+
/**
|
|
4
|
+
* Connect one existing native session to one existing Canon conversation.
|
|
5
|
+
* This is account-level participation, not a second group member. The caller
|
|
6
|
+
* must hold exclusive account and native-session ownership so no competing
|
|
7
|
+
* runtime writes this account's live state or republishes the native thread.
|
|
8
|
+
*/
|
|
9
|
+
export function createCanonAttachedSession(options) {
|
|
10
|
+
const binding = structuredClone(options.binding);
|
|
11
|
+
const connection = { ...options.connection };
|
|
12
|
+
if (binding.agentId !== connection.agentId || binding.environmentId !== connection.environmentId) {
|
|
13
|
+
throw new Error('Attached session account/environment does not match its Canon connection');
|
|
14
|
+
}
|
|
15
|
+
if (options.transport && (options.transport.agentId !== binding.agentId
|
|
16
|
+
|| options.transport.environmentId !== binding.environmentId)) {
|
|
17
|
+
throw new Error('Shared attachment transport belongs to a different account or environment');
|
|
18
|
+
}
|
|
19
|
+
const intervalMs = options.reconcileIntervalMs ?? 10_000;
|
|
20
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 1)
|
|
21
|
+
throw new Error('Invalid attachment reconciliation interval');
|
|
22
|
+
const runtimeConnection = resolveCanonRuntimeConnection({
|
|
23
|
+
environmentId: connection.environmentId,
|
|
24
|
+
apiBaseUrl: connection.baseUrl,
|
|
25
|
+
streamUrl: connection.streamUrl,
|
|
26
|
+
rtdbUrl: connection.rtdbUrl,
|
|
27
|
+
firebaseWebApiKey: connection.firebaseApiKey,
|
|
28
|
+
});
|
|
29
|
+
const client = options.transport?.client ?? new CanonClient(connection.apiKey, runtimeConnection.apiBaseUrl);
|
|
30
|
+
let stopped = false;
|
|
31
|
+
let running = false;
|
|
32
|
+
let connected = false;
|
|
33
|
+
let startPromise;
|
|
34
|
+
let stopPromise;
|
|
35
|
+
let cleanupPromise;
|
|
36
|
+
let coreStopPromise;
|
|
37
|
+
let timer;
|
|
38
|
+
let stream;
|
|
39
|
+
let unsubscribeStream;
|
|
40
|
+
let publisher;
|
|
41
|
+
let heartbeat;
|
|
42
|
+
let stateWrites = Promise.resolve();
|
|
43
|
+
let recoveryPromise;
|
|
44
|
+
let nativeReconcilePromise;
|
|
45
|
+
const tasks = new Set();
|
|
46
|
+
const receiving = new Set();
|
|
47
|
+
const reportedHistory = new Set();
|
|
48
|
+
let publicationFailed = false;
|
|
49
|
+
let historyIncomplete = false;
|
|
50
|
+
let inputError = null;
|
|
51
|
+
let observedTurnId;
|
|
52
|
+
let turnUpdatedAt = null;
|
|
53
|
+
const observedTurnItems = new Set();
|
|
54
|
+
const report = (error, operation) => {
|
|
55
|
+
if (operation === 'publish') {
|
|
56
|
+
publicationFailed = true;
|
|
57
|
+
publishState(core.getState());
|
|
58
|
+
}
|
|
59
|
+
else if (operation === 'history-recovery') {
|
|
60
|
+
historyIncomplete = true;
|
|
61
|
+
publishState(core.getState());
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
options.onError?.(error, operation);
|
|
65
|
+
}
|
|
66
|
+
catch { /* Callbacks do not own the lifecycle. */ }
|
|
67
|
+
};
|
|
68
|
+
const emit = (status) => {
|
|
69
|
+
try {
|
|
70
|
+
options.onStatus?.(status);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
report(error, 'status');
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const track = (task, operation) => {
|
|
77
|
+
tasks.add(task);
|
|
78
|
+
void task.catch((error) => report(error, operation)).finally(() => tasks.delete(task));
|
|
79
|
+
};
|
|
80
|
+
const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
|
|
81
|
+
const core = createAttachedNativeSession({
|
|
82
|
+
binding, store: options.store, adapter: options.native,
|
|
83
|
+
isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
|
|
84
|
+
publisher: {
|
|
85
|
+
prepare(input) {
|
|
86
|
+
const parts = transcriptPublisher.prepare(input);
|
|
87
|
+
if (parts.length && input.item.turnId === observedTurnId) {
|
|
88
|
+
const key = JSON.stringify([input.item.itemId, input.item.role]);
|
|
89
|
+
if (!observedTurnItems.has(key)) {
|
|
90
|
+
observedTurnItems.add(key);
|
|
91
|
+
turnUpdatedAt = Date.now();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return parts;
|
|
95
|
+
},
|
|
96
|
+
send: (part) => transcriptPublisher.send(part),
|
|
97
|
+
},
|
|
98
|
+
onError: report,
|
|
99
|
+
onState: (nativeState) => {
|
|
100
|
+
emit({ status: 'native', nativeState });
|
|
101
|
+
publishState(nativeState);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
function publishState(state) {
|
|
105
|
+
if (!publisher || !running || stopped)
|
|
106
|
+
return;
|
|
107
|
+
if (state.activeTurnId !== observedTurnId) {
|
|
108
|
+
observedTurnId = state.activeTurnId;
|
|
109
|
+
observedTurnItems.clear();
|
|
110
|
+
turnUpdatedAt = state.activeTurnId ? Date.now() : null;
|
|
111
|
+
}
|
|
112
|
+
// Core also notifies on unchanged inspection and outbox writes. Preserve
|
|
113
|
+
// turn freshness across those liveness updates. This adapter observes
|
|
114
|
+
// completed text and accepted input, not every native tool/generation event.
|
|
115
|
+
const observedProgressAt = turnUpdatedAt;
|
|
116
|
+
// Clear only on a current notification; an older queued RTDB write must
|
|
117
|
+
// not erase a publication failure observed while it awaited the network.
|
|
118
|
+
if (state.pendingPublicationCount === 0)
|
|
119
|
+
publicationFailed = false;
|
|
120
|
+
const canReceive = state.status === 'ready' && options.transport?.isRouteActive?.(binding.conversationId) !== false;
|
|
121
|
+
if (connected && (canReceive || state.status === 'uncertain'))
|
|
122
|
+
heartbeat?.connect();
|
|
123
|
+
else
|
|
124
|
+
track(heartbeat?.disconnect() ?? Promise.resolve(), 'heartbeat-disconnect');
|
|
125
|
+
stateWrites = stateWrites.then(async () => {
|
|
126
|
+
if (!publisher || !running || stopped)
|
|
127
|
+
return;
|
|
128
|
+
const errors = [
|
|
129
|
+
state.status === 'uncertain' ? 'Native input acceptance is uncertain; new inputs are blocked and will not be queued.' : null,
|
|
130
|
+
publicationFailed && state.pendingPublicationCount > 0
|
|
131
|
+
? `${state.pendingPublicationCount} native transcript part(s) await publication. Canon has not accepted this output; delivery will retry.` : null,
|
|
132
|
+
reportedHistory.size > 0
|
|
133
|
+
? `${reportedHistory.size} Canon message(s) were observed in history without live dispatch permission and were not submitted. Send a new message to request work.` : null,
|
|
134
|
+
historyIncomplete ? 'Canon history recovery could not establish the full gap. Historical inputs are not automatically submitted.' : null,
|
|
135
|
+
inputError,
|
|
136
|
+
].filter(Boolean).join(' ');
|
|
137
|
+
await publisher.writeSessionState(binding.conversationId, { isActive: connected && canReceive, lastError: errors });
|
|
138
|
+
await publisher.writeTurnState(binding.conversationId, {
|
|
139
|
+
state: state.activeTurnId
|
|
140
|
+
? options.transport?.hasPendingInteraction?.(binding.conversationId) ? 'waiting_input' : 'thinking'
|
|
141
|
+
: 'idle',
|
|
142
|
+
turnId: state.activeTurnId,
|
|
143
|
+
turnUpdatedAt: observedProgressAt,
|
|
144
|
+
queueDepth: 0,
|
|
145
|
+
capabilities: {
|
|
146
|
+
supportsQueue: false, supportsInputInterrupt: false, supportsInterrupt: false,
|
|
147
|
+
supportsInterleave: false, supportsRequiresAction: Boolean(options.transport?.hasPendingInteraction), supportsNonFinalPermanentMessages: false,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
await publisher.patchAgentSessionSnapshot(binding.conversationId, {
|
|
151
|
+
supportsQueue: false,
|
|
152
|
+
supportsInputInterrupt: false,
|
|
153
|
+
lastError: errors || null,
|
|
154
|
+
});
|
|
155
|
+
}).catch((error) => report(error, 'runtime-state'));
|
|
156
|
+
}
|
|
157
|
+
async function notice(payload, text) {
|
|
158
|
+
if (stopped)
|
|
159
|
+
return;
|
|
160
|
+
const authority = payload.replyAuthority;
|
|
161
|
+
const id = `attached_notice_${createHash('sha256').update(JSON.stringify([
|
|
162
|
+
binding, payload.message.id,
|
|
163
|
+
])).digest('hex').slice(0, 40)}`;
|
|
164
|
+
// A refusal notice responds only to this Canon input. Shared native
|
|
165
|
+
// output never receives this capability.
|
|
166
|
+
if (authority && authority.sourceMessageId === payload.message.id
|
|
167
|
+
&& Date.parse(authority.expiresAt) > Date.now()) {
|
|
168
|
+
await client.sendMessage(binding.conversationId, text, {
|
|
169
|
+
messageId: id, replyTo: payload.message.id, replyAuthority: authority,
|
|
170
|
+
metadata: { type: 'attached_input_notice', turnId: id, replyBehavior: 'suppress_auto_reply' },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
report(new Error(text), 'input-unavailable');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function detach(reason) {
|
|
178
|
+
emit({ status: 'blocked', reason });
|
|
179
|
+
void stop().catch((error) => report(error, 'detach'));
|
|
180
|
+
}
|
|
181
|
+
async function verifyMembership() {
|
|
182
|
+
const conversations = await client.getConversations();
|
|
183
|
+
if (stopped)
|
|
184
|
+
return false;
|
|
185
|
+
const conversation = conversations.find((entry) => entry.id === binding.conversationId);
|
|
186
|
+
if (!conversation?.memberIds.includes(binding.agentId)) {
|
|
187
|
+
detach('conversation-membership-lost');
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
async function receive(payload) {
|
|
193
|
+
if (!running || stopped || payload.conversationId !== binding.conversationId)
|
|
194
|
+
return;
|
|
195
|
+
if (options.transport?.isRouteActive?.(binding.conversationId) === false)
|
|
196
|
+
return;
|
|
197
|
+
const message = payload.message;
|
|
198
|
+
if (message.senderId === binding.agentId || core.hasCanonMessage(message.id)
|
|
199
|
+
|| core.isBeforeCanonBaseline(message.id, message.createdAt ?? '') || receiving.has(message.id))
|
|
200
|
+
return;
|
|
201
|
+
if (payload.provenance && (payload.provenance.conversation.id !== binding.conversationId
|
|
202
|
+
|| payload.provenance.sender.id !== message.senderId)) {
|
|
203
|
+
emit({ status: 'blocked', reason: 'invalid-provenance', messageId: message.id });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
receiving.add(message.id);
|
|
207
|
+
try {
|
|
208
|
+
if (payload.turnDispatch?.kind !== 'run_turn') {
|
|
209
|
+
await core.recordSkippedCanonMessage(message.id, 'not-dispatched');
|
|
210
|
+
emit({ status: 'blocked', reason: 'not-dispatched', messageId: message.id });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!message.createdAt || !Number.isFinite(Date.parse(message.createdAt))) {
|
|
214
|
+
await core.recordSkippedCanonMessage(message.id, 'missing-timestamp');
|
|
215
|
+
emit({ status: 'blocked', reason: 'missing-timestamp', messageId: message.id });
|
|
216
|
+
inputError = 'This message has no valid Canon timestamp, so it could not safely be distinguished from attachment history and was not submitted.';
|
|
217
|
+
publishState(core.getState());
|
|
218
|
+
await notice(payload, inputError);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (message.contentType !== 'text' || !message.text?.trim() || message.attachments?.length) {
|
|
222
|
+
await core.recordSkippedCanonMessage(message.id, 'unsupported-content');
|
|
223
|
+
emit({ status: 'blocked', reason: 'unsupported-content', messageId: message.id });
|
|
224
|
+
inputError = 'This attached session accepts text messages. This message was not submitted to the native session.';
|
|
225
|
+
publishState(core.getState());
|
|
226
|
+
await notice(payload, inputError);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const result = await core.submit({
|
|
230
|
+
messageId: message.id,
|
|
231
|
+
text: `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`,
|
|
232
|
+
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
233
|
+
});
|
|
234
|
+
if (stopped)
|
|
235
|
+
return;
|
|
236
|
+
reportedHistory.delete(message.id);
|
|
237
|
+
if (result.status === 'accepted') {
|
|
238
|
+
if (result.turnId === observedTurnId && !result.replayed)
|
|
239
|
+
turnUpdatedAt = Date.now();
|
|
240
|
+
// Acceptance is not completion. The current read API advances the
|
|
241
|
+
// account's conversation read state, not an independent session cursor.
|
|
242
|
+
await client.markAsRead(binding.conversationId).catch((error) => report(error, 'mark-read'));
|
|
243
|
+
inputError = null;
|
|
244
|
+
emit({ status: 'accepted', messageId: message.id, reason: result.mode });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
// A refusal caused by another uncertain input, or a disconnected
|
|
248
|
+
// adapter, is not journaled by Core. Retain this rejected ID so replay
|
|
249
|
+
// cannot turn a "not submitted" notice into later automatic work.
|
|
250
|
+
if (!core.hasCanonMessage(message.id))
|
|
251
|
+
await core.recordSkippedCanonMessage(message.id, result.status);
|
|
252
|
+
emit({ status: 'blocked', messageId: message.id, reason: result.status });
|
|
253
|
+
const text = result.status === 'uncertain'
|
|
254
|
+
? result.messageId === message.id
|
|
255
|
+
? 'The native session may have accepted this input, but its acknowledgement is uncertain. It will not be submitted again automatically.'
|
|
256
|
+
: 'An earlier native input has an uncertain acknowledgement. This message was not submitted or queued; send it again after the attachment recovers.'
|
|
257
|
+
: result.status === 'busy'
|
|
258
|
+
? 'The attachment is handling another input submission. This message was not queued or submitted; send it again when the attachment is ready.'
|
|
259
|
+
: 'This message was not submitted because the native session is unavailable. Send it again when the attachment is ready.';
|
|
260
|
+
inputError = text;
|
|
261
|
+
publishState(core.getState());
|
|
262
|
+
await notice(payload, text);
|
|
263
|
+
}
|
|
264
|
+
publishState(core.getState());
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
receiving.delete(message.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function recoverHistory() {
|
|
271
|
+
if (recoveryPromise)
|
|
272
|
+
return recoveryPromise;
|
|
273
|
+
recoveryPromise = (async () => {
|
|
274
|
+
let before;
|
|
275
|
+
const cursors = new Set();
|
|
276
|
+
const missing = [];
|
|
277
|
+
let establishedBoundary = false;
|
|
278
|
+
// REST cannot mint the run_turn dispatch and reply capability supplied
|
|
279
|
+
// by SSE. Inspect gaps for visibility; never execute these observations.
|
|
280
|
+
for (let pageNumber = 0; pageNumber < 20 && !stopped; pageNumber += 1) {
|
|
281
|
+
const page = await client.getMessagesPage(binding.conversationId, 100, before);
|
|
282
|
+
if (!page.messages.length)
|
|
283
|
+
break;
|
|
284
|
+
let reachedKnown = false;
|
|
285
|
+
for (const message of page.messages) {
|
|
286
|
+
if (core.hasCanonMessage(message.id) || core.isBeforeCanonBaseline(message.id, message.createdAt)) {
|
|
287
|
+
reachedKnown = true;
|
|
288
|
+
}
|
|
289
|
+
else if (message.senderId !== binding.agentId && !receiving.has(message.id)
|
|
290
|
+
&& !reportedHistory.has(message.id)) {
|
|
291
|
+
missing.push(message.id);
|
|
292
|
+
reportedHistory.add(message.id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (reachedKnown) {
|
|
296
|
+
establishedBoundary = true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
const cursor = page.messages[page.messages.length - 1].id;
|
|
300
|
+
if (cursors.has(cursor))
|
|
301
|
+
throw new Error('Canon history cursor did not advance');
|
|
302
|
+
cursors.add(cursor);
|
|
303
|
+
before = cursor;
|
|
304
|
+
}
|
|
305
|
+
if (!stopped && missing.length)
|
|
306
|
+
emit({
|
|
307
|
+
status: 'blocked', reason: 'history-without-live-dispatch', pendingMessageIds: missing,
|
|
308
|
+
});
|
|
309
|
+
// The current endpoint applies its limit before filtering hidden rows
|
|
310
|
+
// and has no continuation token. An empty page cannot prove exhaustion.
|
|
311
|
+
if (missing.length && !establishedBoundary)
|
|
312
|
+
historyIncomplete = true;
|
|
313
|
+
publishState(core.getState());
|
|
314
|
+
})().finally(() => { recoveryPromise = undefined; });
|
|
315
|
+
return recoveryPromise;
|
|
316
|
+
}
|
|
317
|
+
async function cleanup() {
|
|
318
|
+
cleanupPromise ??= (async () => {
|
|
319
|
+
running = false;
|
|
320
|
+
connected = false;
|
|
321
|
+
if (timer)
|
|
322
|
+
clearInterval(timer);
|
|
323
|
+
stream?.stop();
|
|
324
|
+
unsubscribeStream?.();
|
|
325
|
+
try {
|
|
326
|
+
await stopCore();
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
await Promise.allSettled([...tasks, stateWrites, nativeReconcilePromise, recoveryPromise].filter(Boolean));
|
|
330
|
+
try {
|
|
331
|
+
await heartbeat?.dispose();
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
if (publisher) {
|
|
335
|
+
const results = await Promise.allSettled([
|
|
336
|
+
publisher.clearTurnState(binding.conversationId),
|
|
337
|
+
publisher.clearSessionState(binding.conversationId),
|
|
338
|
+
]);
|
|
339
|
+
for (const result of results)
|
|
340
|
+
if (result.status === 'rejected')
|
|
341
|
+
report(result.reason, 'clear-runtime');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
return cleanupPromise;
|
|
347
|
+
}
|
|
348
|
+
function stopCore() {
|
|
349
|
+
coreStopPromise ??= core.stop();
|
|
350
|
+
// Shutdown can begin before startup unwinds. Attach a handler immediately;
|
|
351
|
+
// cleanup still awaits and reports the original failure.
|
|
352
|
+
void coreStopPromise.catch(() => { });
|
|
353
|
+
return coreStopPromise;
|
|
354
|
+
}
|
|
355
|
+
function start() {
|
|
356
|
+
if (stopped)
|
|
357
|
+
return Promise.reject(new Error('Attachment has stopped; create a new attachment to restart'));
|
|
358
|
+
if (startPromise)
|
|
359
|
+
return startPromise;
|
|
360
|
+
startPromise = (async () => {
|
|
361
|
+
emit({ status: 'connecting' });
|
|
362
|
+
if (!options.transport)
|
|
363
|
+
await verifyCanonRuntimeConnection(runtimeConnection);
|
|
364
|
+
if (stopped)
|
|
365
|
+
return;
|
|
366
|
+
if (!options.transport) {
|
|
367
|
+
const auth = await client.getAuthToken();
|
|
368
|
+
if (auth.agentId !== binding.agentId)
|
|
369
|
+
throw new Error('Canon credential belongs to a different agent');
|
|
370
|
+
}
|
|
371
|
+
if (stopped)
|
|
372
|
+
return;
|
|
373
|
+
const conversations = await client.getConversations();
|
|
374
|
+
const conversation = conversations.find((entry) => entry.id === binding.conversationId);
|
|
375
|
+
if (!conversation || !conversation.memberIds.includes(binding.agentId)) {
|
|
376
|
+
throw new Error('The agent is not a member of the specified Canon conversation');
|
|
377
|
+
}
|
|
378
|
+
const initial = await client.getMessagesPage(binding.conversationId, 100);
|
|
379
|
+
if (stopped)
|
|
380
|
+
return;
|
|
381
|
+
const latest = initial.messages.reduce((value, message) => {
|
|
382
|
+
if (!Number.isFinite(Date.parse(message.createdAt)))
|
|
383
|
+
throw new Error('Invalid Canon message timestamp');
|
|
384
|
+
return !value || Date.parse(message.createdAt) > Date.parse(value) ? message.createdAt : value;
|
|
385
|
+
}, null);
|
|
386
|
+
// Include the entire newest millisecond in the baseline. Otherwise a
|
|
387
|
+
// historical tie outside this filtered REST page could become new work
|
|
388
|
+
// when SSE backfills it. A new message stamped in that same millisecond
|
|
389
|
+
// is conservatively baseline history too; IDs alone cannot resolve it.
|
|
390
|
+
const cutoff = latest ? new Date(Date.parse(latest) + 1).toISOString() : null;
|
|
391
|
+
await core.start();
|
|
392
|
+
if (stopped)
|
|
393
|
+
return;
|
|
394
|
+
await core.initializeCanonBaseline(initial.messages.map((message) => message.id), cutoff);
|
|
395
|
+
if (stopped)
|
|
396
|
+
return;
|
|
397
|
+
if (options.transport)
|
|
398
|
+
publisher = options.transport.publisher;
|
|
399
|
+
else {
|
|
400
|
+
const rtdb = initRTDBAuth(client, {
|
|
401
|
+
rtdbUrl: runtimeConnection.rtdbUrl, firebaseApiKey: runtimeConnection.firebaseWebApiKey,
|
|
402
|
+
});
|
|
403
|
+
publisher = createRuntimeStatePublisher({ agentId: binding.agentId, clientType: connection.clientType ?? 'generic', hostMode: false, rtdb });
|
|
404
|
+
heartbeat = createRuntimeHeartbeat({
|
|
405
|
+
publisher,
|
|
406
|
+
getRuntime: () => ({ runtimeDescriptor: {
|
|
407
|
+
coreControls: [], runtimeControls: [], commands: [],
|
|
408
|
+
supportsInterrupt: false, supportsInputInterrupt: false, streamingTextMode: 'snapshot',
|
|
409
|
+
} }),
|
|
410
|
+
onError: report,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
running = true;
|
|
414
|
+
const handler = {
|
|
415
|
+
onMessage: (payload) => track(receive(payload), 'receive'),
|
|
416
|
+
onAgentContext: (context) => {
|
|
417
|
+
if (context.agentId !== binding.agentId) {
|
|
418
|
+
report(new Error('Canon stream context belongs to a different agent'), 'stream-identity');
|
|
419
|
+
detach('stream-identity-mismatch');
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
onConversationUpdated: (payload) => {
|
|
423
|
+
if (stopped || payload.conversationId !== binding.conversationId)
|
|
424
|
+
return;
|
|
425
|
+
const members = payload.changes.memberIds;
|
|
426
|
+
if ((Array.isArray(members) && !members.includes(binding.agentId))
|
|
427
|
+
|| payload.membershipChange?.removedMemberIds.includes(binding.agentId)
|
|
428
|
+
|| payload.changes.deleted === true)
|
|
429
|
+
detach('conversation-membership-lost');
|
|
430
|
+
},
|
|
431
|
+
onParticipationSuppressed: (payload) => {
|
|
432
|
+
if (stopped || payload.conversationId !== binding.conversationId)
|
|
433
|
+
return;
|
|
434
|
+
emit({ status: 'blocked', reason: payload.reasonCode, messageId: payload.messageId });
|
|
435
|
+
},
|
|
436
|
+
onConnected: () => {
|
|
437
|
+
if (stopped || !running)
|
|
438
|
+
return;
|
|
439
|
+
connected = true;
|
|
440
|
+
emit({ status: 'connected' });
|
|
441
|
+
publishState(core.getState());
|
|
442
|
+
track(verifyMembership().then((member) => member ? recoverHistory() : undefined), 'history-recovery');
|
|
443
|
+
},
|
|
444
|
+
onDisconnected: () => {
|
|
445
|
+
connected = false;
|
|
446
|
+
if (!stopped)
|
|
447
|
+
emit({ status: 'disconnected' });
|
|
448
|
+
publishState(core.getState());
|
|
449
|
+
track(heartbeat?.disconnect() ?? Promise.resolve(), 'heartbeat-disconnect');
|
|
450
|
+
},
|
|
451
|
+
onReplayExpired: () => track(recoverHistory(), 'history-recovery'),
|
|
452
|
+
onError: (error) => report(error, 'stream'),
|
|
453
|
+
};
|
|
454
|
+
if (options.transport)
|
|
455
|
+
unsubscribeStream = options.transport.subscribe(handler);
|
|
456
|
+
else
|
|
457
|
+
stream = new CanonStream({
|
|
458
|
+
apiKey: connection.apiKey, agentId: binding.agentId, streamUrl: runtimeConnection.streamUrl, handler,
|
|
459
|
+
});
|
|
460
|
+
publishState(core.getState());
|
|
461
|
+
// CanonStream.start runs for the lifetime of a fetch stream. Do not
|
|
462
|
+
// make CLI startup wait until the connection closes.
|
|
463
|
+
if (stream)
|
|
464
|
+
void stream.start().catch((error) => report(error, 'stream-start'));
|
|
465
|
+
timer = setInterval(() => {
|
|
466
|
+
if (stopped || nativeReconcilePromise)
|
|
467
|
+
return;
|
|
468
|
+
nativeReconcilePromise = verifyMembership().then((member) => member ? core.reconcile() : undefined)
|
|
469
|
+
.finally(() => { nativeReconcilePromise = undefined; });
|
|
470
|
+
track(nativeReconcilePromise, 'native-reconcile');
|
|
471
|
+
}, intervalMs);
|
|
472
|
+
timer.unref?.();
|
|
473
|
+
})().catch(async (error) => {
|
|
474
|
+
stopped = true;
|
|
475
|
+
await cleanup().catch((cleanupError) => report(cleanupError, 'cleanup'));
|
|
476
|
+
throw error;
|
|
477
|
+
});
|
|
478
|
+
return startPromise;
|
|
479
|
+
}
|
|
480
|
+
function stop() {
|
|
481
|
+
if (stopPromise)
|
|
482
|
+
return stopPromise;
|
|
483
|
+
stopped = true;
|
|
484
|
+
running = false;
|
|
485
|
+
if (timer)
|
|
486
|
+
clearInterval(timer);
|
|
487
|
+
stream?.stop();
|
|
488
|
+
unsubscribeStream?.();
|
|
489
|
+
stopCore();
|
|
490
|
+
stopPromise = (async () => {
|
|
491
|
+
await startPromise?.catch(() => { });
|
|
492
|
+
await cleanup();
|
|
493
|
+
emit({ status: 'stopped' });
|
|
494
|
+
})();
|
|
495
|
+
return stopPromise;
|
|
496
|
+
}
|
|
497
|
+
function refreshRuntimeState() {
|
|
498
|
+
publishState(core.getState());
|
|
499
|
+
if (running && !stopped && options.transport?.isRouteActive?.(binding.conversationId) !== false) {
|
|
500
|
+
track(core.flush(), 'publish');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return { start, stop, getState: core.getState, refreshRuntimeState };
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Observed native turns may combine local and Canon input. They are proactive
|
|
507
|
+
* agent transcript publications, never replies authorized by a particular
|
|
508
|
+
* Canon input. Core persists these immutable parts before calling send.
|
|
509
|
+
*/
|
|
510
|
+
export function createCanonAttachedSessionPublisher(client, binding) {
|
|
511
|
+
return {
|
|
512
|
+
prepare({ item, origin }) {
|
|
513
|
+
const text = item.role === 'user'
|
|
514
|
+
? `Local user (reported by the agent):\n\n${item.text}`
|
|
515
|
+
: item.text;
|
|
516
|
+
if (!text.trim())
|
|
517
|
+
return [];
|
|
518
|
+
const groupId = `attached_${createHash('sha256').update(JSON.stringify([
|
|
519
|
+
binding.environmentId, binding.agentId, binding.conversationId,
|
|
520
|
+
binding.provider, binding.nativeSessionId, item.turnId, item.itemId, item.role,
|
|
521
|
+
])).digest('hex').slice(0, 40)}`;
|
|
522
|
+
const chunks = splitTextByUtf8Bytes(text, 3_800);
|
|
523
|
+
const metadata = {
|
|
524
|
+
type: 'attached_native_message',
|
|
525
|
+
replyBehavior: 'suppress_auto_reply',
|
|
526
|
+
observedRole: item.role,
|
|
527
|
+
native: {
|
|
528
|
+
runtime: binding.provider,
|
|
529
|
+
threadId: binding.nativeSessionId,
|
|
530
|
+
turnId: item.turnId,
|
|
531
|
+
itemId: item.itemId,
|
|
532
|
+
},
|
|
533
|
+
...(origin.kind === 'shared' ? { sourceMessageIds: [...origin.sourceMessageIds] } : {}),
|
|
534
|
+
};
|
|
535
|
+
if (Buffer.byteLength(JSON.stringify(metadata), 'utf8') > 3_500) {
|
|
536
|
+
throw new Error('Native transcript correlation exceeds Canon message metadata limits');
|
|
537
|
+
}
|
|
538
|
+
return chunks.map((chunk, index) => ({
|
|
539
|
+
messageId: `${groupId}_${index + 1}`,
|
|
540
|
+
text: chunk,
|
|
541
|
+
options: {
|
|
542
|
+
metadata: buildChunkedMessagePartMetadata({
|
|
543
|
+
groupId, metadata, index: index + 1, count: chunks.length,
|
|
544
|
+
}),
|
|
545
|
+
},
|
|
546
|
+
}));
|
|
547
|
+
},
|
|
548
|
+
async send(part) {
|
|
549
|
+
await client.sendProactiveMessage(binding.conversationId, part.text, {
|
|
550
|
+
metadata: part.options.metadata,
|
|
551
|
+
messageId: part.messageId,
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,3 +11,11 @@ export type { SessionConfig, Session } from './session-manager.js';
|
|
|
11
11
|
export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
|
|
12
12
|
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonMessage, CanonConversation, CommunicateInput, CommunicateResult, DirectConversationSelection, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendMessageOptions, CreateGroupOptions, CreateGroupResult, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
|
|
13
13
|
export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, FinalMessageResult, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ParticipationSuppressedHandler, ProgressMessageOptions, ProgressMessageResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimePlanReviewRequest, RuntimePlanReviewResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
|
14
|
+
export { createCanonAttachedSession, createCanonAttachedSessionPublisher } from './attached-session.js';
|
|
15
|
+
export { createCanonWorkSessionHost } from './work-session-host.js';
|
|
16
|
+
export type { CanonWorkSessionHost, CanonWorkSessionHostOptions } from './work-session-host.js';
|
|
17
|
+
export { createFileWorkSessionHostStore } from './work-session-state.js';
|
|
18
|
+
export type { WorkSessionHostStore, WorkSessionHostJournal } from './work-session-state.js';
|
|
19
|
+
export { createFileAttachedSessionStore } from '@canonmsg/core';
|
|
20
|
+
export type { CanonAttachedSession, CanonAttachedSessionConnection, CanonAttachedSessionOptions, CanonAttachedSessionStatus, } from './attached-session.js';
|
|
21
|
+
export type { AttachedNativeSessionAdapter, AttachedSessionBinding, AttachedSessionJournal, AttachedSessionState, AttachedSessionStore, NativeSessionEvent, NativeSessionItem, NativeSessionSnapshot, NativeSubmissionResult, } from '@canonmsg/core';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,7 @@ export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from
|
|
|
3
3
|
export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
|
|
4
4
|
export { SessionManager } from './session-manager.js';
|
|
5
5
|
export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
|
|
6
|
+
export { createCanonAttachedSession, createCanonAttachedSessionPublisher } from './attached-session.js';
|
|
7
|
+
export { createCanonWorkSessionHost } from './work-session-host.js';
|
|
8
|
+
export { createFileWorkSessionHostStore } from './work-session-state.js';
|
|
9
|
+
export { createFileAttachedSessionStore } from '@canonmsg/core';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type AttachedSessionBinding, type AttachedSessionStore, type NativeWorkSessionProvider } from '@canonmsg/core';
|
|
2
|
+
import { type CanonAttachedSessionConnection, type CanonAttachedSessionStatus } from './attached-session.js';
|
|
3
|
+
import { type WorkSessionHostStore } from './work-session-state.js';
|
|
4
|
+
export interface CanonWorkSessionHostOptions {
|
|
5
|
+
connection: CanonAttachedSessionConnection;
|
|
6
|
+
/** Stable installation identity. A new random runtime epoch is used each start. */
|
|
7
|
+
hostId: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
provider: NativeWorkSessionProvider;
|
|
10
|
+
store: WorkSessionHostStore;
|
|
11
|
+
createSessionStore(binding: AttachedSessionBinding): AttachedSessionStore;
|
|
12
|
+
/** Process-wide native audience lock, shared with any standalone attachment CLI. */
|
|
13
|
+
acquireNativeSession(binding: AttachedSessionBinding): (() => void) | Promise<() => void>;
|
|
14
|
+
onStatus?: (event: {
|
|
15
|
+
status: 'available' | 'stopped' | 'session';
|
|
16
|
+
conversationId?: string;
|
|
17
|
+
session?: CanonAttachedSessionStatus;
|
|
18
|
+
}) => void;
|
|
19
|
+
onError?: (error: unknown, operation: string) => void;
|
|
20
|
+
pollIntervalMs?: number;
|
|
21
|
+
heartbeatIntervalMs?: number;
|
|
22
|
+
connectTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CanonWorkSessionHost {
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
stop(): Promise<void>;
|
|
27
|
+
/** Refresh discovery and process one durable request; serialized with the poll loop. */
|
|
28
|
+
reconcile(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** One authenticated transport and liveness publisher, with durable per-room native routes. */
|
|
31
|
+
export declare function createCanonWorkSessionHost(options: CanonWorkSessionHostOptions): CanonWorkSessionHost;
|