@canonmsg/agent-sdk 10.4.0 → 11.0.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 +26 -18
- package/dist/attached-session.js +45 -8
- package/dist/canon-agent.js +85 -58
- package/dist/media.d.ts +2 -0
- package/dist/media.js +39 -90
- package/dist/realtime.d.ts +7 -7
- package/dist/realtime.js +51 -65
- package/dist/types.d.ts +12 -1
- package/dist/work-session-host.js +15 -5
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -34,9 +34,15 @@ await agent.start();
|
|
|
34
34
|
npm install @canonmsg/agent-sdk
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
The
|
|
37
|
+
The SDK uses `@canonmsg/core` and the shared `@canonmsg/endpoint` engine. Node.js 22.22.3 or later is required.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
The endpoint owns stable installation and operation IDs, SQLite outbox/inbox state, live transport, receipt recovery, conversation synchronization, and upload dependencies. `CanonAgent` adds handler and native-session behavior. Set `CANON_ENDPOINT_STATE_DIR` to a persistent private directory; the default is `~/.canon/endpoint`. Keep this directory when upgrading or restarting a runtime.
|
|
40
|
+
|
|
41
|
+
Replicated or ephemeral cloud deployments must inject an operator-owned endpoint store through `endpoint: { endpoint: sharedEndpoint }`. File delivery also needs a durable `mediaBytes` adapter or persistent `mediaDirectory`; a temporary container filesystem is not a delivery journal. All replicas for one installation share the same store namespace. A process must not replay an uncertain provider call automatically.
|
|
42
|
+
|
|
43
|
+
Existing conversations require the rollout tool's reconciled history baseline before agent execution. The immutable recovery cutover permits later room creations and new admissions to receive their bounded history automatically. Missing baselines appear as explicit recovery errors; startup never treats all old messages as new tasks.
|
|
44
|
+
|
|
45
|
+
Runtime heartbeats come from Core through the shared endpoint, using the same machinery as the integrated plugins. No separate runtime Firebase token exchange is needed. The SDK adds handler dispatch and lifecycle wiring. Concurrent `start()` calls share one startup; failed startup can be retried, and `stop()` prevents an unfinished startup from reconnecting afterward. Heartbeat failures are reported and later heartbeats retry. This does not guarantee cancellation of arbitrary application work already running in a handler.
|
|
40
46
|
|
|
41
47
|
## Configuration
|
|
42
48
|
|
|
@@ -46,8 +52,8 @@ Runtime heartbeats and Firebase token refresh come from Core, using the same mac
|
|
|
46
52
|
| `environmentId` | `string` | **required** | Canon trust domain shared by API, stream, RTDB, and future cryptographic state |
|
|
47
53
|
| `baseUrl` | `string` | Environment default | Override the API base URL when the selected environment has no packaged default |
|
|
48
54
|
| `streamUrl` | `string` | Environment default | Override the SSE stream URL when the selected environment has no packaged default |
|
|
49
|
-
| `rtdbUrl` | `string` | Environment default |
|
|
50
|
-
| `firebaseApiKey` | `string` | Environment default |
|
|
55
|
+
| `rtdbUrl` | `string` | Environment default | Legacy connection option; runtime state uses the shared endpoint |
|
|
56
|
+
| `firebaseApiKey` | `string` | Environment default | Legacy connection option; no runtime Firebase token exchange |
|
|
51
57
|
| `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
|
|
52
58
|
| `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
|
|
53
59
|
| `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
|
|
@@ -123,15 +129,15 @@ The SDK provides a standard catalog of seven runtime primitives. It advertises a
|
|
|
123
129
|
|
|
124
130
|
## Delivery
|
|
125
131
|
|
|
126
|
-
The SDK receives messages
|
|
132
|
+
The SDK receives messages through the shared endpoint's SSE connection. `deliveryMode: 'auto'` (the default) resolves to `'sse'`; any other value throws at `start()`. There is no polling mode.
|
|
127
133
|
|
|
128
|
-
A single connection receives events for all conversations. It auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces
|
|
134
|
+
A single connection receives events for all conversations. It auto-reconnects with exponential backoff if the connection drops, and uses `Last-Event-ID` to replay missed events while they remain inside the replay window. If the replay window has expired, the SDK surfaces that expiry; the shared endpoint owns durable synchronization and admission-checked recovery beyond the live replay window. Adapters must not add a second history dispatch loop.
|
|
129
135
|
|
|
130
136
|
The SDK publishes runtime heartbeats every 30 seconds while SSE is connected and clears its runtime freshness on disconnect. Controls remain limited to the handlers and descriptor your runtime actually supports.
|
|
131
137
|
|
|
132
138
|
Handler reply helpers carry the inbound SSE event's reply authority automatically. Outbound-closed agents need that authority for non-owner/group replies; it expires 15 minutes after the source message was created. Delayed processing or fetching history does not renew it. See [Replies and polling](https://canonmail.com/agents/contracts#replies-and-polling) for the current limits.
|
|
133
139
|
|
|
134
|
-
With `autoMarkRead: true`, the SDK marks the conversation read after successful handler completion. The current
|
|
140
|
+
With `autoMarkRead: true`, the SDK marks the conversation read after successful handler completion. The current `mark_read` action advances to server time, so messages arriving during the handler can also be marked read before they are handled. `ctx.markAsRead()` uses the same endpoint. Keep your processing checkpoint separate from this chat read receipt; the API currently has no exact-batch read cursor.
|
|
135
141
|
|
|
136
142
|
## Message Handler
|
|
137
143
|
|
|
@@ -144,20 +150,20 @@ The `message` event handler receives a context object with:
|
|
|
144
150
|
| `replyContext` | `CanonReplyContext \| null` | Resolved swipe-reply target for the latest inbound message, when available |
|
|
145
151
|
| `conversationId` | `string` | The conversation these messages belong to |
|
|
146
152
|
| `conversation` | `CanonConversation` | Full conversation metadata |
|
|
147
|
-
| `groupContext` | `CanonGroupContext \| undefined` |
|
|
153
|
+
| `groupContext` | `CanonGroupContext \| undefined` | Participant context based on the current conversation roster |
|
|
148
154
|
| `replyFinal` | `(text: string, options?) => Promise<{ messageId: string; messageIds: string[] }>` | Send the durable final reply for a turn |
|
|
149
155
|
| `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null; messageIds?: string[] }>` | Update the live turn progress; add `durable: true` to also persist it |
|
|
150
156
|
| `deleteMessage` | `(messageId: string) => Promise<void>` | Soft-delete a message sent by this agent |
|
|
151
157
|
| `markAsRead` | `() => Promise<void>` | Advance this agent's read cursor for the conversation |
|
|
152
|
-
| `leave` | `() => Promise<void>` | Leave the current
|
|
158
|
+
| `leave` | `() => Promise<void>` | Leave the current conversation |
|
|
153
159
|
| `react` | `(messageId, emoji) => Promise<void>` | Toggle an emoji reaction |
|
|
154
|
-
| `addMember` / `removeMember` | functions | Manage
|
|
155
|
-
| `communicate` | function | Message an existing conversation, start a
|
|
160
|
+
| `addMember` / `removeMember` | functions | Manage participants when the agent has permission |
|
|
161
|
+
| `communicate` | function | Message an existing conversation, start a conversation, create a conversation with several participants, forward an exact message, share a contact, or manage participants |
|
|
156
162
|
| `agent` | `AgentContext` | Trusted Canon agent identity and access context |
|
|
157
163
|
| `activeSelfContextId` | `string \| null` | Active private self-context id for this turn |
|
|
158
164
|
| `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
|
|
159
165
|
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
160
|
-
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields
|
|
166
|
+
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields reflect the current roster, sender and participation policy |
|
|
161
167
|
| `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
|
|
162
168
|
| `turnVerbosity` | `'verbose' \| 'quiet'` | Resolved emission mode for this turn — see [Turn verbosity](#turn-verbosity). Fixed for the whole turn |
|
|
163
169
|
| `requestApproval` | `(request) => Promise<ApprovalResult>` | Render a Canon approval card and wait for the decision. Fail-closed: returns `{ decision: 'deny' }` on any non-abort failure instead of throwing |
|
|
@@ -171,7 +177,7 @@ The `message` event handler receives a context object with:
|
|
|
171
177
|
|
|
172
178
|
Messages from the agent itself are automatically filtered out -- your handler only receives messages from other participants.
|
|
173
179
|
|
|
174
|
-
`ctx.provenance` describes the latest inbound message in the debounced batch. Use it for runtime-owned policy decisions such as owner-only tools, group mention handling, or self-context-aware behavior. `ctx.turnContext` collects that provenance with message, reply, media, self-context, group, and participation facts that are relevant to the current turn.
|
|
180
|
+
`ctx.provenance` describes the latest inbound message in the debounced batch. Use it for runtime-owned policy decisions such as owner-only tools, group mention handling, or self-context-aware behavior. `ctx.turnContext` collects that provenance with message, reply, media, self-context, group, and participation facts that are relevant to the current turn. Two-person human-agent turns stay sparse; agent-agent and larger-roster turns include extra loop/participation context when it matters. Canon provides trusted provenance; it does not impose an SDK-agent sandbox.
|
|
175
181
|
|
|
176
182
|
## Events
|
|
177
183
|
|
|
@@ -451,25 +457,27 @@ It governs teardown only. Calling `replyFinal()` as well is two explicit decisio
|
|
|
451
457
|
|
|
452
458
|
## Turn verbosity
|
|
453
459
|
|
|
454
|
-
By default an agent is **quiet
|
|
460
|
+
By default an agent is **quiet with more than two participants and verbose with two**, based on the refreshed roster. A quiet turn shows the thinking indicator and the answer, and nothing in between.
|
|
455
461
|
|
|
456
462
|
```ts
|
|
457
463
|
const agent = new CanonAgent({
|
|
458
464
|
apiKey: process.env.CANON_API_KEY!,
|
|
459
465
|
environmentId: 'canon-prod-v1',
|
|
460
466
|
turnVerbosity: 'verbose', // scalar: applies everywhere
|
|
461
|
-
// turnVerbosity: { group: 'verbose' }, // object: override one
|
|
467
|
+
// turnVerbosity: { group: 'verbose' }, // object: override one roster policy scope
|
|
462
468
|
});
|
|
463
469
|
```
|
|
464
470
|
|
|
465
471
|
| Value | Effect |
|
|
466
472
|
|---|---|
|
|
467
|
-
| `'auto'` (default, same as omitting the option) | Verbose
|
|
473
|
+
| `'auto'` (default, same as omitting the option) | Verbose with two participants, quiet with more than two |
|
|
468
474
|
| `'verbose'` | Live turn state and the margin activity trail, everywhere |
|
|
469
475
|
| `'quiet'` | Thinking indicator and the final message only, everywhere |
|
|
470
|
-
| `{ direct?, group? }` |
|
|
476
|
+
| `{ direct?, group? }` | Compatibility keys for the two-person/larger-roster scopes; the unnamed scope keeps its default |
|
|
471
477
|
|
|
472
|
-
|
|
478
|
+
Adding or removing participants can change the default on the next turn. The SDK
|
|
479
|
+
holds verbosity fixed within a turn. Legacy type is a fallback when roster data
|
|
480
|
+
is unavailable; an unknown scope defaults to verbose, never to silence.
|
|
473
481
|
|
|
474
482
|
**Quiet suppresses**: every `/streaming` publication — the `'Thinking...'` seed and its keepalive, `turn.setThinking/setStreaming/setTool`, `turn.appendDelta`/`appendBlock`/segment updates, `turn.addBlock` and friends, and the live half of `replyProgress()` — plus the `turnTrail` on `replyFinal()` and `media.replyWithFile()`. Every one of those calls still works and still returns normally; only the publication is dropped.
|
|
475
483
|
|
package/dist/attached-session.js
CHANGED
|
@@ -26,7 +26,7 @@ export function createCanonAttachedSession(options) {
|
|
|
26
26
|
rtdbUrl: connection.rtdbUrl,
|
|
27
27
|
firebaseWebApiKey: connection.firebaseApiKey,
|
|
28
28
|
});
|
|
29
|
-
const client = options.transport?.client ?? new CanonClient(connection.apiKey, runtimeConnection.apiBaseUrl);
|
|
29
|
+
const client = options.transport?.client ?? new CanonClient(connection.apiKey, runtimeConnection.apiBaseUrl, { environmentId: runtimeConnection.environmentId, streamUrl: runtimeConnection.streamUrl });
|
|
30
30
|
let stopped = false;
|
|
31
31
|
let running = false;
|
|
32
32
|
let connected = false;
|
|
@@ -36,6 +36,8 @@ export function createCanonAttachedSession(options) {
|
|
|
36
36
|
let coreStopPromise;
|
|
37
37
|
let timer;
|
|
38
38
|
let stream;
|
|
39
|
+
let endpoint;
|
|
40
|
+
let inbound;
|
|
39
41
|
let unsubscribeStream;
|
|
40
42
|
let publisher;
|
|
41
43
|
let heartbeat;
|
|
@@ -82,6 +84,7 @@ export function createCanonAttachedSession(options) {
|
|
|
82
84
|
const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
|
|
83
85
|
const core = createAttachedNativeSession({
|
|
84
86
|
binding, store: options.store, adapter: options.native,
|
|
87
|
+
beforeNativeSubmit: (input) => endpoint.claimInbound(`message:${binding.conversationId}:${input.messageId}`),
|
|
85
88
|
isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
|
|
86
89
|
publisher: {
|
|
87
90
|
prepare(input) {
|
|
@@ -181,10 +184,9 @@ export function createCanonAttachedSession(options) {
|
|
|
181
184
|
void stop().catch((error) => report(error, 'detach'));
|
|
182
185
|
}
|
|
183
186
|
async function verifyMembership() {
|
|
184
|
-
const
|
|
187
|
+
const conversation = await client.getConversation(binding.conversationId);
|
|
185
188
|
if (stopped)
|
|
186
189
|
return false;
|
|
187
|
-
const conversation = conversations.find((entry) => entry.id === binding.conversationId);
|
|
188
190
|
currentConversation = conversation ?? null;
|
|
189
191
|
if (!conversation?.memberIds.includes(binding.agentId)) {
|
|
190
192
|
detach('conversation-membership-lost');
|
|
@@ -236,6 +238,15 @@ export function createCanonAttachedSession(options) {
|
|
|
236
238
|
text: [...rosterLines, `Canon message from ${message.senderName ?? message.senderId} (${message.senderType}):\n\n${message.text}`].join('\n\n'),
|
|
237
239
|
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
238
240
|
});
|
|
241
|
+
// A conclusive provider acknowledgement settles command delivery. Native
|
|
242
|
+
// turn progress and transcript correlation remain in the provider journal.
|
|
243
|
+
const inputId = `message:${binding.conversationId}:${message.id}`;
|
|
244
|
+
if (result.status === 'uncertain' && result.messageId === message.id) {
|
|
245
|
+
await endpoint.setInboundState(inputId, 'uncertain', 'native-reconciliation-required');
|
|
246
|
+
}
|
|
247
|
+
else if (!(result.status === 'not_submitted' && result.reason === 'This input already has another execution owner.')) {
|
|
248
|
+
await endpoint.setInboundState(inputId, 'settled', `native-${result.status}`);
|
|
249
|
+
}
|
|
239
250
|
if (stopped)
|
|
240
251
|
return;
|
|
241
252
|
reportedHistory.delete(message.id);
|
|
@@ -375,8 +386,8 @@ export function createCanonAttachedSession(options) {
|
|
|
375
386
|
}
|
|
376
387
|
if (stopped)
|
|
377
388
|
return;
|
|
378
|
-
|
|
379
|
-
const conversation =
|
|
389
|
+
endpoint = await client.getEndpoint();
|
|
390
|
+
const conversation = await client.getConversation(binding.conversationId);
|
|
380
391
|
if (!conversation || !conversation.memberIds.includes(binding.agentId)) {
|
|
381
392
|
throw new Error('The agent is not a member of the specified Canon conversation');
|
|
382
393
|
}
|
|
@@ -417,8 +428,31 @@ export function createCanonAttachedSession(options) {
|
|
|
417
428
|
});
|
|
418
429
|
}
|
|
419
430
|
running = true;
|
|
431
|
+
inbound = endpoint.acceptInbound({ kind: 'message.created', conversationId: binding.conversationId,
|
|
432
|
+
offer: async (event) => {
|
|
433
|
+
if (!running || stopped || options.transport?.isRouteActive?.(binding.conversationId) === false) {
|
|
434
|
+
await endpoint.setInboundState(event.id, 'deferred', 'attachment-not-ready');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const payload = event.data;
|
|
438
|
+
if (core.getState().pendingSourceMessageId === payload.message.id && core.getState().status === 'uncertain') {
|
|
439
|
+
await endpoint.setInboundState(event.id, 'uncertain', 'native-reconciliation-required');
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
await receive(payload);
|
|
443
|
+
const record = await endpoint.getInbound(event.id);
|
|
444
|
+
if (record && record.state !== 'uncertain' && record.state !== 'settled') {
|
|
445
|
+
await endpoint.setInboundState(event.id, 'settled', 'native-input-reviewed');
|
|
446
|
+
}
|
|
447
|
+
}, onError: (error) => report(error, 'inbound-recovery') });
|
|
448
|
+
inbound.start();
|
|
420
449
|
const handler = {
|
|
421
|
-
onMessage: (payload) =>
|
|
450
|
+
onMessage: (payload) => {
|
|
451
|
+
if (payload.conversationId !== binding.conversationId)
|
|
452
|
+
return;
|
|
453
|
+
track(inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`, kind: 'message.created',
|
|
454
|
+
conversationId: payload.conversationId, durable: true, data: payload }), 'receive');
|
|
455
|
+
},
|
|
422
456
|
onAgentContext: (context) => {
|
|
423
457
|
currentOwnerId = context.ownerId;
|
|
424
458
|
if (context.agentId !== binding.agentId) {
|
|
@@ -429,8 +463,10 @@ export function createCanonAttachedSession(options) {
|
|
|
429
463
|
onConversationUpdated: (payload) => {
|
|
430
464
|
if (stopped || payload.conversationId !== binding.conversationId)
|
|
431
465
|
return;
|
|
432
|
-
if (currentConversation)
|
|
466
|
+
if (currentConversation) {
|
|
433
467
|
currentConversation = { ...currentConversation, ...payload.changes };
|
|
468
|
+
track(client.rememberConversation(currentConversation), 'conversation-state');
|
|
469
|
+
}
|
|
434
470
|
const members = payload.changes.memberIds;
|
|
435
471
|
if ((Array.isArray(members) && !members.includes(binding.agentId))
|
|
436
472
|
|| payload.membershipChange?.removedMemberIds.includes(binding.agentId)
|
|
@@ -464,7 +500,7 @@ export function createCanonAttachedSession(options) {
|
|
|
464
500
|
unsubscribeStream = options.transport.subscribe(handler);
|
|
465
501
|
else
|
|
466
502
|
stream = new CanonStream({
|
|
467
|
-
|
|
503
|
+
endpoint: await client.getEndpoint(), agentId: binding.agentId, handler,
|
|
468
504
|
});
|
|
469
505
|
publishState(core.getState());
|
|
470
506
|
// CanonStream.start runs for the lifetime of a fetch stream. Do not
|
|
@@ -498,6 +534,7 @@ export function createCanonAttachedSession(options) {
|
|
|
498
534
|
stopCore();
|
|
499
535
|
stopPromise = (async () => {
|
|
500
536
|
await startPromise?.catch(() => { });
|
|
537
|
+
await inbound?.close();
|
|
501
538
|
await cleanup();
|
|
502
539
|
emit({ status: 'stopped' });
|
|
503
540
|
})();
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome,
|
|
1
|
+
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { Debouncer } from './debouncer.js';
|
|
4
4
|
import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
|
|
@@ -346,6 +346,7 @@ export class CanonAgent {
|
|
|
346
346
|
historyLimit: 50,
|
|
347
347
|
autoMarkRead: true,
|
|
348
348
|
runtimeControlSurface: 'agent',
|
|
349
|
+
endpoint: {},
|
|
349
350
|
...options,
|
|
350
351
|
environmentId: this.runtimeConnection.environmentId,
|
|
351
352
|
baseUrl: this.runtimeConnection.apiBaseUrl,
|
|
@@ -353,7 +354,9 @@ export class CanonAgent {
|
|
|
353
354
|
rtdbUrl: this.runtimeConnection.rtdbUrl,
|
|
354
355
|
firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
|
|
355
356
|
};
|
|
356
|
-
this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl
|
|
357
|
+
this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl, {
|
|
358
|
+
environmentId: this.runtimeConnection.environmentId, streamUrl: this.runtimeConnection.streamUrl, ...options.endpoint,
|
|
359
|
+
});
|
|
357
360
|
this.typingSignals = createTypingStatusPublisher({
|
|
358
361
|
setTyping: (conversationId, typing, status) => status
|
|
359
362
|
? this.apiClient.setTyping(conversationId, typing, status)
|
|
@@ -682,7 +685,7 @@ export class CanonAgent {
|
|
|
682
685
|
if (generation !== this.lifecycleGeneration)
|
|
683
686
|
return;
|
|
684
687
|
this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
|
|
685
|
-
const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled });
|
|
688
|
+
const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled, endpoint: await this.apiClient.getEndpoint() });
|
|
686
689
|
if (this.voiceEventsEnabled) {
|
|
687
690
|
rtm.setCallHandlers({
|
|
688
691
|
onCallStarted: (payload) => {
|
|
@@ -1253,12 +1256,26 @@ export class CanonAgent {
|
|
|
1253
1256
|
}
|
|
1254
1257
|
async handleMessages(conversationId, messages, provenanceByMessageId) {
|
|
1255
1258
|
const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
|
|
1256
|
-
|
|
1257
|
-
|
|
1259
|
+
const consumed = messages.filter((message) => !actionableMessages.includes(message));
|
|
1260
|
+
if (consumed.length) {
|
|
1261
|
+
const endpoint = await this.apiClient.getEndpoint();
|
|
1262
|
+
for (const message of consumed) {
|
|
1263
|
+
const id = `message:${conversationId}:${message.id}`;
|
|
1264
|
+
if (await endpoint.getInbound(id))
|
|
1265
|
+
await endpoint.setInboundState(id, 'settled', 'interaction-consumed');
|
|
1266
|
+
}
|
|
1258
1267
|
}
|
|
1268
|
+
if (actionableMessages.length === 0)
|
|
1269
|
+
return;
|
|
1259
1270
|
messages = actionableMessages;
|
|
1260
1271
|
if (!this.handler) {
|
|
1261
|
-
|
|
1272
|
+
const endpoint = await this.apiClient.getEndpoint();
|
|
1273
|
+
for (const message of messages) {
|
|
1274
|
+
const id = `message:${conversationId}:${message.id}`;
|
|
1275
|
+
if (await endpoint.getInbound(id))
|
|
1276
|
+
await endpoint.setInboundState(id, 'deferred', 'no-handler');
|
|
1277
|
+
}
|
|
1278
|
+
console.warn(`[canon-sdk] No message handler registered — input for ${conversationId} is deferred. Call agent.on('message', handler) before starting.`);
|
|
1262
1279
|
return;
|
|
1263
1280
|
}
|
|
1264
1281
|
const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
|
|
@@ -1280,50 +1297,40 @@ export class CanonAgent {
|
|
|
1280
1297
|
// Message rediscovery can precede the SSE roster snapshot after admission.
|
|
1281
1298
|
// Refresh before trusting cached exclusion, accepting queued input, or
|
|
1282
1299
|
// freezing output policy. Reuse this same snapshot for handler context.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
// Realtime delivery has already deduplicated these IDs. Retry this read
|
|
1290
|
-
// here once; throwing does not arrange replay or requeue the input.
|
|
1291
|
-
await sleep(100);
|
|
1292
|
-
membersBeforeRefresh = this.conversationMemberIds.get(conversationId);
|
|
1293
|
-
try {
|
|
1294
|
-
conversations = await this.apiClient.getConversations();
|
|
1295
|
-
}
|
|
1296
|
-
catch (error) {
|
|
1297
|
-
console.error(`[canon-sdk] Pre-turn conversation refresh failed for ${conversationId}:`, error);
|
|
1298
|
-
return;
|
|
1300
|
+
const endpoint = await this.apiClient.getEndpoint();
|
|
1301
|
+
const setInputState = async (state, reason) => {
|
|
1302
|
+
for (const message of messages) {
|
|
1303
|
+
const id = `message:${conversationId}:${message.id}`;
|
|
1304
|
+
if (await endpoint.getInbound(id))
|
|
1305
|
+
await endpoint.setInboundState(id, state, reason);
|
|
1299
1306
|
}
|
|
1307
|
+
};
|
|
1308
|
+
let conversation;
|
|
1309
|
+
try {
|
|
1310
|
+
conversation = await this.apiClient.getConversation(conversationId);
|
|
1300
1311
|
}
|
|
1301
|
-
|
|
1302
|
-
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
await setInputState('deferred', 'membership-unavailable');
|
|
1314
|
+
console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
|
|
1303
1315
|
return;
|
|
1304
|
-
const membersDuringRefresh = this.conversationMemberIds.get(conversationId);
|
|
1305
|
-
const currentRevision = this.conversationMembershipRevisions.get(conversationId);
|
|
1306
|
-
const refreshedRevision = knownMembershipRevision(conversation.membershipRevision);
|
|
1307
|
-
const comparableRevisions = currentRevision !== undefined && refreshedRevision !== undefined;
|
|
1308
|
-
const keepCachedRoster = comparableRevisions
|
|
1309
|
-
? currentRevision > refreshedRevision
|
|
1310
|
-
: membersDuringRefresh !== membersBeforeRefresh;
|
|
1311
|
-
if (membersDuringRefresh && keepCachedRoster) {
|
|
1312
|
-
// Prefer the higher known revision. For legacy unversioned snapshots,
|
|
1313
|
-
// retain an event received while the request was pending.
|
|
1314
|
-
conversation = { ...conversation, memberIds: membersDuringRefresh, membershipRevision: currentRevision };
|
|
1315
1316
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1317
|
+
if (!conversation) {
|
|
1318
|
+
await setInputState('settled', 'conversation-unavailable');
|
|
1319
|
+
return;
|
|
1318
1320
|
}
|
|
1321
|
+
this.rememberConversationMembers([conversation]);
|
|
1319
1322
|
const currentMembers = this.conversationMemberIds.get(conversationId);
|
|
1320
|
-
if (currentMembers && this.agentId && !currentMembers.includes(this.agentId))
|
|
1323
|
+
if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
|
|
1324
|
+
await setInputState('settled', 'membership-lost');
|
|
1321
1325
|
return;
|
|
1326
|
+
}
|
|
1322
1327
|
this.rememberConversationId(conversationId);
|
|
1323
1328
|
await this.markQueuedMessagesAccepted(conversationId, messages);
|
|
1324
1329
|
const acceptedMembers = this.conversationMemberIds.get(conversationId);
|
|
1325
|
-
if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId))
|
|
1330
|
+
if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
|
|
1331
|
+
await setInputState('settled', 'membership-lost');
|
|
1326
1332
|
return;
|
|
1333
|
+
}
|
|
1327
1334
|
const turnId = randomUUID();
|
|
1328
1335
|
const turnOpenedAt = Date.now();
|
|
1329
1336
|
let turnState = 'thinking';
|
|
@@ -1477,7 +1484,7 @@ export class CanonAgent {
|
|
|
1477
1484
|
this.sessionManager.seedHistory(conversationId, history);
|
|
1478
1485
|
}
|
|
1479
1486
|
// Build reply functions
|
|
1480
|
-
const replyFinal = async (text, options) => {
|
|
1487
|
+
const replyFinal = async (text, options, delivery) => {
|
|
1481
1488
|
throwIfAborted();
|
|
1482
1489
|
try {
|
|
1483
1490
|
await this.typingSignals.start(conversationId, 'typing');
|
|
@@ -1501,11 +1508,11 @@ export class CanonAgent {
|
|
|
1501
1508
|
};
|
|
1502
1509
|
let result;
|
|
1503
1510
|
try {
|
|
1504
|
-
result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId]);
|
|
1511
|
+
result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId], delivery);
|
|
1505
1512
|
}
|
|
1506
1513
|
catch (error) {
|
|
1507
1514
|
const chunked = isChunkedSendMessageError(error) ? error : null;
|
|
1508
|
-
if (!chunked || chunked.deliveredMessageIds.length === 0 || isAbortLikeError(error)) {
|
|
1515
|
+
if (!chunked || chunked.deliveredMessageIds.length === 0 || sendOptions.messageId || isAbortLikeError(error) || isPendingCanonOperation(error)) {
|
|
1509
1516
|
throw error;
|
|
1510
1517
|
}
|
|
1511
1518
|
const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
|
|
@@ -1598,7 +1605,9 @@ export class CanonAgent {
|
|
|
1598
1605
|
? normalizeResponseUserId(latestMessage.senderId)
|
|
1599
1606
|
: undefined;
|
|
1600
1607
|
let replyContext = latestMessage
|
|
1601
|
-
?
|
|
1608
|
+
? await hydrateCanonReplyContext({
|
|
1609
|
+
client: this.apiClient, conversationId, message: latestMessage, messages: history,
|
|
1610
|
+
})
|
|
1602
1611
|
: null;
|
|
1603
1612
|
const resolvedActiveSelfContextId = resolveMessageActiveSelfContextId({
|
|
1604
1613
|
messageId: latestMessage?.id,
|
|
@@ -1623,9 +1632,11 @@ export class CanonAgent {
|
|
|
1623
1632
|
// override below only covers a retry backoff.
|
|
1624
1633
|
const abortAwareClient = Object.create(this.apiClient, {
|
|
1625
1634
|
sendMessage: {
|
|
1626
|
-
value: (targetConversationId, targetText, targetOptions) => {
|
|
1635
|
+
value: (targetConversationId, targetText, targetOptions, delivery) => {
|
|
1627
1636
|
throwIfAborted();
|
|
1628
|
-
return
|
|
1637
|
+
return delivery?.operationId
|
|
1638
|
+
? this.apiClient.sendMessage(targetConversationId, targetText, targetOptions, delivery)
|
|
1639
|
+
: this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
|
|
1629
1640
|
},
|
|
1630
1641
|
},
|
|
1631
1642
|
});
|
|
@@ -1637,7 +1648,7 @@ export class CanonAgent {
|
|
|
1637
1648
|
// that FITS still goes out as a single message under the plain id with
|
|
1638
1649
|
// untouched metadata, so the common case (and the interim→final handoff
|
|
1639
1650
|
// that keys on that id) is unchanged.
|
|
1640
|
-
const sendDurableMessage = async (text, options, fallbackMessageIdParts) => {
|
|
1651
|
+
const sendDurableMessage = async (text, options, fallbackMessageIdParts, delivery) => {
|
|
1641
1652
|
// Counts every durable send the turn attempts, not just the ones that
|
|
1642
1653
|
// needed a generated id: teardown reads `durableMessageSequence` to
|
|
1643
1654
|
// decide whether the turn genuinely ended silent, and a reply sent
|
|
@@ -1649,7 +1660,9 @@ export class CanonAgent {
|
|
|
1649
1660
|
...(options ?? {}),
|
|
1650
1661
|
messageId,
|
|
1651
1662
|
}, {
|
|
1652
|
-
|
|
1663
|
+
signal: abortController.signal,
|
|
1664
|
+
...(delivery?.operationId ? { operationId: delivery.operationId } : {}),
|
|
1665
|
+
...(delivery?.replacesOperationId ? { replacesOperationId: delivery.replacesOperationId } : {}),
|
|
1653
1666
|
}, {
|
|
1654
1667
|
resumable: true,
|
|
1655
1668
|
});
|
|
@@ -1659,16 +1672,9 @@ export class CanonAgent {
|
|
|
1659
1672
|
// the end of the answer, not its head. `messageIds` has the full set.
|
|
1660
1673
|
return { messageId: messageIds[messageIds.length - 1], messageIds };
|
|
1661
1674
|
};
|
|
1662
|
-
//
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
ownerId: '',
|
|
1666
|
-
ownerName: '',
|
|
1667
|
-
discoverable: false,
|
|
1668
|
-
inboundPolicy: 'approval-required',
|
|
1669
|
-
outboundPolicy: 'approval-required',
|
|
1670
|
-
groupJoinPolicy: 'approval-required',
|
|
1671
|
-
};
|
|
1675
|
+
// Runtime context must come from authenticated agent metadata. A synthetic
|
|
1676
|
+
// generation cannot authorize an endpoint or a new owner's native work.
|
|
1677
|
+
const agent = this.agentContext ?? await this.apiClient.getAgentMe();
|
|
1672
1678
|
const provenance = latestMessage
|
|
1673
1679
|
? resolveRuntimeProvenance({
|
|
1674
1680
|
provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
|
|
@@ -2187,8 +2193,15 @@ export class CanonAgent {
|
|
|
2187
2193
|
catch { }
|
|
2188
2194
|
}
|
|
2189
2195
|
};
|
|
2190
|
-
//
|
|
2196
|
+
// Fence uncertain native/business effects before handing execution out.
|
|
2197
|
+
// A crash after this write requires provider/operator reconciliation.
|
|
2191
2198
|
throwIfAborted();
|
|
2199
|
+
// Every SDK message handler invocation originates in Canon admission. A
|
|
2200
|
+
// restored native queue without its journal must await migration instead
|
|
2201
|
+
// of treating a missing record as permission to execute.
|
|
2202
|
+
const journaled = messages.map((message) => `message:${conversationId}:${message.id}`);
|
|
2203
|
+
if (!journaled.length || !await endpoint.claimInbound(journaled))
|
|
2204
|
+
return;
|
|
2192
2205
|
await this.handler({
|
|
2193
2206
|
messages: hydratedMessages,
|
|
2194
2207
|
history,
|
|
@@ -2197,6 +2210,19 @@ export class CanonAgent {
|
|
|
2197
2210
|
conversation,
|
|
2198
2211
|
...(groupContext ? { groupContext } : {}),
|
|
2199
2212
|
replyFinal,
|
|
2213
|
+
getReplyOperation: (messageId, delivery) => this.apiClient.getMessageOperation(conversationId, messageId, delivery),
|
|
2214
|
+
waitForReply: async (operationId) => {
|
|
2215
|
+
throwIfAborted();
|
|
2216
|
+
durableMessageSequence += 1;
|
|
2217
|
+
const result = await this.apiClient.waitForOperation(operationId, { signal: abortController.signal });
|
|
2218
|
+
if (typeof result?.messageId !== 'string' || !result.messageId)
|
|
2219
|
+
throw new Error('Canon returned a malformed message receipt');
|
|
2220
|
+
try {
|
|
2221
|
+
await this.typingSignals.clear(conversationId);
|
|
2222
|
+
}
|
|
2223
|
+
catch { }
|
|
2224
|
+
return { messageId: result.messageId, messageIds: result.messageIds ?? [result.messageId] };
|
|
2225
|
+
},
|
|
2200
2226
|
replyProgress,
|
|
2201
2227
|
deleteMessage,
|
|
2202
2228
|
markAsRead,
|
|
@@ -2349,6 +2375,7 @@ export class CanonAgent {
|
|
|
2349
2375
|
},
|
|
2350
2376
|
},
|
|
2351
2377
|
});
|
|
2378
|
+
await setInputState('settled', 'handler-completed');
|
|
2352
2379
|
// Auto-mark conversation as read after successful processing
|
|
2353
2380
|
if (this.options.autoMarkRead) {
|
|
2354
2381
|
try {
|
package/dist/media.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface MaterializeMediaOptions {
|
|
|
15
15
|
onError?: (error: unknown, attachment: MediaAttachment, index: number) => void;
|
|
16
16
|
}
|
|
17
17
|
export interface UploadMediaFileOptions {
|
|
18
|
+
/** Stable native/product job key for upload recovery across restarts. */
|
|
19
|
+
attachmentId?: string;
|
|
18
20
|
fileName?: string;
|
|
19
21
|
mimeType?: string;
|
|
20
22
|
durationMs?: number;
|
package/dist/media.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWriteStream } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, join } from 'node:path';
|
|
@@ -321,98 +321,47 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
|
|
|
321
321
|
}
|
|
322
322
|
const mimeType = inferUploadMimeType(filePath, options?.mimeType);
|
|
323
323
|
const fileName = options?.fileName ?? basename(filePath);
|
|
324
|
-
const
|
|
325
|
-
options?.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
if (
|
|
330
|
-
throw new Error('Canon
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
throw new Error('Canon returned an invalid resumable upload MIME type');
|
|
334
|
-
}
|
|
335
|
-
let uploadUrl;
|
|
336
|
-
try {
|
|
337
|
-
uploadUrl = new URL(session.uploadUrl);
|
|
338
|
-
}
|
|
339
|
-
catch {
|
|
340
|
-
throw new Error('Canon returned an invalid resumable upload URL');
|
|
341
|
-
}
|
|
342
|
-
if (uploadUrl.protocol !== 'https:') {
|
|
343
|
-
throw new Error('Canon returned a non-HTTPS resumable upload URL');
|
|
344
|
-
}
|
|
345
|
-
if (uploadUrl.hostname !== 'storage.googleapis.com'
|
|
346
|
-
|| uploadUrl.username.length > 0
|
|
347
|
-
|| uploadUrl.password.length > 0) {
|
|
348
|
-
throw new Error('Canon returned an untrusted resumable upload URL');
|
|
349
|
-
}
|
|
350
|
-
const fetchImpl = ensureFetch(options?.fetchImpl);
|
|
351
|
-
const uploadResponse = await fetchImpl(uploadUrl, {
|
|
352
|
-
method: 'PUT',
|
|
353
|
-
headers: {
|
|
354
|
-
'Content-Type': session.mimeType,
|
|
355
|
-
'Content-Length': String(fileStat.size),
|
|
356
|
-
'Content-Range': `bytes 0-${fileStat.size - 1}/${fileStat.size}`,
|
|
357
|
-
},
|
|
358
|
-
body: createReadStream(filePath),
|
|
359
|
-
signal: options?.signal,
|
|
360
|
-
// Node requires this for streaming request bodies. It is intentionally
|
|
361
|
-
// outside the DOM RequestInit type but supported by the built-in fetch.
|
|
362
|
-
duplex: 'half',
|
|
363
|
-
});
|
|
364
|
-
if (!uploadResponse.ok) {
|
|
365
|
-
throw new Error(`Failed to upload Canon media (${uploadResponse.status} ${uploadResponse.statusText})`);
|
|
366
|
-
}
|
|
367
|
-
options?.signal?.throwIfAborted();
|
|
368
|
-
const finalized = await client.finalizeResumableMediaUpload(session.uploadId);
|
|
369
|
-
options?.signal?.throwIfAborted();
|
|
370
|
-
if (finalized.uploadId !== session.uploadId) {
|
|
371
|
-
throw new Error('Canon resumable upload result does not match its session');
|
|
372
|
-
}
|
|
373
|
-
if (finalized.attachment.uploadId !== undefined
|
|
374
|
-
&& finalized.attachment.uploadId !== session.uploadId) {
|
|
375
|
-
throw new Error('Canon resumable upload attachment does not match its session');
|
|
376
|
-
}
|
|
377
|
-
const uploaded = {
|
|
378
|
-
...finalized,
|
|
379
|
-
attachment: {
|
|
380
|
-
...finalized.attachment,
|
|
381
|
-
uploadId: session.uploadId,
|
|
382
|
-
},
|
|
383
|
-
};
|
|
384
|
-
if (uploaded.attachment.kind === 'audio'
|
|
385
|
-
&& typeof options?.durationMs === 'number'
|
|
386
|
-
&& Number.isFinite(options.durationMs)
|
|
387
|
-
&& options.durationMs > 0) {
|
|
388
|
-
return {
|
|
389
|
-
...uploaded,
|
|
390
|
-
attachment: {
|
|
391
|
-
...uploaded.attachment,
|
|
392
|
-
durationMs: Math.round(options.durationMs),
|
|
393
|
-
},
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
return uploaded;
|
|
324
|
+
const media = await client.getFileMediaPipeline(options?.fetchImpl);
|
|
325
|
+
const [attachment] = await media.prepare({ conversationId, attachmentId: options?.attachmentId ?? randomUUID(),
|
|
326
|
+
files: [{ source: filePath, descriptor: { mimeType, sizeBytes: fileStat.size, fileName,
|
|
327
|
+
...(options?.durationMs !== undefined ? { durationMs: options.durationMs } : {}) } }],
|
|
328
|
+
}, { signal: options?.signal });
|
|
329
|
+
if (typeof attachment?.uploadId !== 'string' || typeof attachment.url !== 'string') {
|
|
330
|
+
throw new Error('Canon returned an invalid prepared attachment');
|
|
331
|
+
}
|
|
332
|
+
return { uploadId: attachment.uploadId, url: attachment.url, attachment: attachment };
|
|
397
333
|
}
|
|
398
334
|
export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
|
|
399
|
-
const { fileName, mimeType, durationMs, fetchImpl, signal, canPublish, ...sendOptions } = options ?? {};
|
|
400
|
-
const uploaded = await uploadMediaFile(client, conversationId, filePath, {
|
|
401
|
-
...(fileName ? { fileName } : {}),
|
|
402
|
-
...(mimeType ? { mimeType } : {}),
|
|
403
|
-
...(durationMs != null ? { durationMs } : {}),
|
|
404
|
-
...(fetchImpl ? { fetchImpl } : {}),
|
|
405
|
-
...(signal ? { signal } : {}),
|
|
406
|
-
});
|
|
335
|
+
const { fileName, mimeType, durationMs, fetchImpl, signal, canPublish, attachmentId: _attachmentId, ...sendOptions } = options ?? {};
|
|
407
336
|
signal?.throwIfAborted();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
337
|
+
const info = await stat(filePath);
|
|
338
|
+
if (!info.isFile() || info.size <= 0 || info.size > MAX_CANON_MEDIA_BYTES) {
|
|
339
|
+
throw new Error('Canon media must be a regular file from 1 byte to 100 MiB');
|
|
340
|
+
}
|
|
341
|
+
const effectiveMimeType = inferUploadMimeType(filePath, mimeType);
|
|
342
|
+
const contentType = effectiveMimeType.startsWith('image/') ? 'image'
|
|
343
|
+
: effectiveMimeType.startsWith('video/') ? 'video'
|
|
344
|
+
: effectiveMimeType.startsWith('audio/') ? 'audio' : 'file';
|
|
345
|
+
// Automatic artifacts keep their audience across upload and every later
|
|
346
|
+
// receipt retry. A membership change may reduce, but never expand, that audience.
|
|
347
|
+
const initialRoom = canPublish ? await client.getConversation(conversationId) : null;
|
|
348
|
+
const originalMembers = new Set(initialRoom?.memberIds ?? []);
|
|
349
|
+
const beforePublish = canPublish ? async () => {
|
|
350
|
+
const current = await client.getConversation(conversationId);
|
|
351
|
+
return !!current && originalMembers.size > 0
|
|
352
|
+
&& current.memberIds.every((id) => originalMembers.has(id)) && canPublish();
|
|
353
|
+
} : undefined;
|
|
354
|
+
const messageId = sendOptions.messageId ?? randomUUID();
|
|
355
|
+
const pipeline = await client.getFileMediaPipeline(fetchImpl);
|
|
356
|
+
const delivered = await pipeline.send({
|
|
357
|
+
conversationId, messageId,
|
|
358
|
+
message: { ...sendOptions, text, contentType },
|
|
359
|
+
files: [{ source: filePath, descriptor: {
|
|
360
|
+
mimeType: effectiveMimeType, sizeBytes: info.size, fileName: fileName ?? basename(filePath),
|
|
361
|
+
...(durationMs != null ? { durationMs } : {}),
|
|
362
|
+
} }],
|
|
363
|
+
}, { ...(signal ? { signal } : {}), ...(beforePublish ? { beforePublish } : {}) });
|
|
364
|
+
return { messageId: delivered };
|
|
416
365
|
}
|
|
417
366
|
/**
|
|
418
367
|
* Resolve the effective MIME type of a materialized attachment, falling back
|
package/dist/realtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AgentContext, type ContactAddedPayload, type ContactRemovedPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type ParticipationSuppressedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
|
|
1
|
+
import { type CanonEndpoint, type AgentContext, type ContactAddedPayload, type ContactRemovedPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type ParticipationSuppressedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
|
|
2
2
|
import { Debouncer } from './debouncer.js';
|
|
3
3
|
/**
|
|
4
4
|
* Wraps @canonmsg/core's CanonStream with SDK-specific features:
|
|
@@ -9,8 +9,8 @@ import { Debouncer } from './debouncer.js';
|
|
|
9
9
|
export declare class RealtimeManager {
|
|
10
10
|
private debouncer;
|
|
11
11
|
private stream;
|
|
12
|
-
|
|
13
|
-
private readonly
|
|
12
|
+
private readonly endpoint;
|
|
13
|
+
private readonly inbound;
|
|
14
14
|
private lastSseErrorKey;
|
|
15
15
|
private lastSseErrorAt;
|
|
16
16
|
private suppressedSseErrorCount;
|
|
@@ -25,12 +25,12 @@ export declare class RealtimeManager {
|
|
|
25
25
|
private onDisconnected;
|
|
26
26
|
private onCallStarted;
|
|
27
27
|
private onCallEnded;
|
|
28
|
-
constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl
|
|
28
|
+
constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl: string | undefined, options: {
|
|
29
29
|
enableVoiceEvents?: boolean;
|
|
30
|
+
endpoint: CanonEndpoint;
|
|
30
31
|
});
|
|
31
|
-
private
|
|
32
|
-
private
|
|
33
|
-
private pruneRecentInboundMessageIds;
|
|
32
|
+
private receive;
|
|
33
|
+
private offer;
|
|
34
34
|
private logSseError;
|
|
35
35
|
setOnAgentContext(cb: (ctx: AgentContext) => void): void;
|
|
36
36
|
setContactGraphHandlers(handlers: {
|
package/dist/realtime.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { CanonStream, } from '@canonmsg/core';
|
|
2
|
-
const RECENT_INBOUND_TTL_MS = 30 * 60 * 1000;
|
|
3
|
-
const MAX_RECENT_INBOUND_MESSAGE_IDS = 5000;
|
|
4
2
|
/**
|
|
5
3
|
* Wraps @canonmsg/core's CanonStream with SDK-specific features:
|
|
6
4
|
* - Debouncer integration (message batching)
|
|
@@ -10,8 +8,8 @@ const MAX_RECENT_INBOUND_MESSAGE_IDS = 5000;
|
|
|
10
8
|
export class RealtimeManager {
|
|
11
9
|
debouncer;
|
|
12
10
|
stream;
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
endpoint;
|
|
12
|
+
inbound;
|
|
15
13
|
lastSseErrorKey = null;
|
|
16
14
|
lastSseErrorAt = 0;
|
|
17
15
|
suppressedSseErrorCount = 0;
|
|
@@ -28,10 +26,13 @@ export class RealtimeManager {
|
|
|
28
26
|
onCallEnded = null;
|
|
29
27
|
constructor(apiKey, debouncer, agentId, streamUrl, options) {
|
|
30
28
|
this.debouncer = debouncer;
|
|
29
|
+
this.endpoint = options.endpoint;
|
|
30
|
+
this.inbound = this.endpoint.acceptInbound({ kind: 'message.created',
|
|
31
|
+
offer: (event) => this.offer(event.data),
|
|
32
|
+
onError: (error) => this.logSseError(error instanceof Error ? error : new Error(String(error))), });
|
|
31
33
|
this.stream = new CanonStream({
|
|
32
|
-
|
|
34
|
+
endpoint: this.endpoint,
|
|
33
35
|
agentId,
|
|
34
|
-
streamUrl,
|
|
35
36
|
handler: {
|
|
36
37
|
// The voice family is requested from handler PRESENCE at stream
|
|
37
38
|
// construction, so these delegating closures exist only when the
|
|
@@ -49,44 +50,7 @@ export class RealtimeManager {
|
|
|
49
50
|
}
|
|
50
51
|
: {}),
|
|
51
52
|
onMessage: (payload) => {
|
|
52
|
-
|
|
53
|
-
// a turn for the same message.
|
|
54
|
-
if (this.hasSeenInboundMessage(payload.conversationId, payload.message.id)) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
this.recordSeenInboundMessage(payload.conversationId, payload.message.id);
|
|
58
|
-
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
59
|
-
console.error(`[canon-sdk] Ignoring server-dispatched observe-only message in ${payload.conversationId}: ${payload.turnDispatch.reason}`);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const m = payload.message;
|
|
63
|
-
const message = {
|
|
64
|
-
id: m.id,
|
|
65
|
-
senderId: m.senderId,
|
|
66
|
-
...(m.senderName ? { senderName: m.senderName } : {}),
|
|
67
|
-
senderType: m.senderType ?? 'human',
|
|
68
|
-
isOwner: m.isOwner ?? false,
|
|
69
|
-
contentType: m.contentType ?? 'text',
|
|
70
|
-
text: m.text ?? null,
|
|
71
|
-
attachments: m.attachments ?? [],
|
|
72
|
-
mentions: m.mentions ?? [],
|
|
73
|
-
...(m.reactions ? { reactions: m.reactions } : {}),
|
|
74
|
-
replyTo: m.replyTo ?? null,
|
|
75
|
-
replyToPosition: m.replyToPosition ?? null,
|
|
76
|
-
...(m.forwarded === true || m.forwardedFrom
|
|
77
|
-
? { forwarded: true }
|
|
78
|
-
: {}),
|
|
79
|
-
...(m.forwardedFrom
|
|
80
|
-
? { forwardedFrom: m.forwardedFrom }
|
|
81
|
-
: {}),
|
|
82
|
-
status: 'sent',
|
|
83
|
-
deleted: false,
|
|
84
|
-
createdAt: m.createdAt ?? new Date().toISOString(),
|
|
85
|
-
...(m.contactCard ? { contactCard: m.contactCard } : {}),
|
|
86
|
-
...(m.metadata ? { metadata: m.metadata } : {}),
|
|
87
|
-
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
88
|
-
};
|
|
89
|
-
this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
|
|
53
|
+
void this.receive(payload).catch((error) => this.logSseError(error instanceof Error ? error : new Error(String(error))));
|
|
90
54
|
},
|
|
91
55
|
onMessageDeleted: (payload) => {
|
|
92
56
|
this.debouncer.removeMessage(payload.conversationId, payload.messageId);
|
|
@@ -105,7 +69,9 @@ export class RealtimeManager {
|
|
|
105
69
|
this.onContactRemoved?.(payload);
|
|
106
70
|
},
|
|
107
71
|
onConversationUpdated: (payload) => {
|
|
108
|
-
this.
|
|
72
|
+
void this.endpoint.applyConversationSnapshot({ id: payload.conversationId, ...payload.changes })
|
|
73
|
+
.then(({ id: _id, ...changes }) => this.onConversationUpdated?.({ ...payload, changes }))
|
|
74
|
+
.catch((error) => this.logSseError(error instanceof Error ? error : new Error(String(error))));
|
|
109
75
|
},
|
|
110
76
|
onParticipationSuppressed: (payload) => {
|
|
111
77
|
this.onParticipationSuppressed?.(payload);
|
|
@@ -117,7 +83,7 @@ export class RealtimeManager {
|
|
|
117
83
|
this.onDisconnected?.();
|
|
118
84
|
},
|
|
119
85
|
onReplayExpired: (payload) => {
|
|
120
|
-
console.error(`[canon-sdk] SSE replay window expired${payload.lastAvailableId ? ` (oldest available event: ${payload.lastAvailableId})` : ''};
|
|
86
|
+
console.error(`[canon-sdk] SSE replay window expired${payload.lastAvailableId ? ` (oldest available event: ${payload.lastAvailableId})` : ''}; the endpoint is recovering the durable gap`);
|
|
121
87
|
},
|
|
122
88
|
onError: (err) => {
|
|
123
89
|
this.logSseError(err);
|
|
@@ -125,27 +91,45 @@ export class RealtimeManager {
|
|
|
125
91
|
},
|
|
126
92
|
});
|
|
127
93
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
this.recentInboundMessageIds.set(`${conversationId}:${messageId}`, now);
|
|
134
|
-
this.pruneRecentInboundMessageIds(now);
|
|
94
|
+
async receive(payload) {
|
|
95
|
+
await this.inbound.receive({ id: `message:${payload.conversationId}:${payload.message.id}`,
|
|
96
|
+
kind: 'message.created', conversationId: payload.conversationId, durable: true,
|
|
97
|
+
data: payload });
|
|
135
98
|
}
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
while (this.recentInboundMessageIds.size > MAX_RECENT_INBOUND_MESSAGE_IDS) {
|
|
144
|
-
const oldestKey = this.recentInboundMessageIds.keys().next().value;
|
|
145
|
-
if (!oldestKey)
|
|
146
|
-
break;
|
|
147
|
-
this.recentInboundMessageIds.delete(oldestKey);
|
|
99
|
+
async offer(payload) {
|
|
100
|
+
const id = `message:${payload.conversationId}:${payload.message.id}`;
|
|
101
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
102
|
+
await this.endpoint.setInboundState(id, 'settled', 'observe-only');
|
|
103
|
+
return;
|
|
148
104
|
}
|
|
105
|
+
const m = payload.message;
|
|
106
|
+
const message = {
|
|
107
|
+
id: m.id,
|
|
108
|
+
senderId: m.senderId,
|
|
109
|
+
...(m.senderName ? { senderName: m.senderName } : {}),
|
|
110
|
+
senderType: m.senderType ?? 'human',
|
|
111
|
+
isOwner: m.isOwner ?? false,
|
|
112
|
+
contentType: m.contentType ?? 'text',
|
|
113
|
+
text: m.text ?? null,
|
|
114
|
+
attachments: m.attachments ?? [],
|
|
115
|
+
mentions: m.mentions ?? [],
|
|
116
|
+
...(m.reactions ? { reactions: m.reactions } : {}),
|
|
117
|
+
replyTo: m.replyTo ?? null,
|
|
118
|
+
replyToPosition: m.replyToPosition ?? null,
|
|
119
|
+
...(m.forwarded === true || m.forwardedFrom
|
|
120
|
+
? { forwarded: true }
|
|
121
|
+
: {}),
|
|
122
|
+
...(m.forwardedFrom
|
|
123
|
+
? { forwardedFrom: m.forwardedFrom }
|
|
124
|
+
: {}),
|
|
125
|
+
status: 'sent',
|
|
126
|
+
deleted: false,
|
|
127
|
+
createdAt: m.createdAt ?? new Date().toISOString(),
|
|
128
|
+
...(m.contactCard ? { contactCard: m.contactCard } : {}),
|
|
129
|
+
...(m.metadata ? { metadata: m.metadata } : {}),
|
|
130
|
+
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
131
|
+
};
|
|
132
|
+
this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
|
|
149
133
|
}
|
|
150
134
|
logSseError(err) {
|
|
151
135
|
const code = err.code;
|
|
@@ -196,9 +180,11 @@ export class RealtimeManager {
|
|
|
196
180
|
this.onCallEnded = handlers.onCallEnded ?? null;
|
|
197
181
|
}
|
|
198
182
|
async start() {
|
|
183
|
+
this.inbound.start();
|
|
199
184
|
await this.stream.start();
|
|
200
185
|
}
|
|
201
186
|
stop() {
|
|
187
|
+
void this.inbound.close();
|
|
202
188
|
this.stream.stop();
|
|
203
189
|
}
|
|
204
190
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -166,7 +166,16 @@ export interface MessageHandlerContext {
|
|
|
166
166
|
conversation: CanonConversation;
|
|
167
167
|
/** Lightweight group awareness, present for group conversations. */
|
|
168
168
|
groupContext?: CanonGroupContext;
|
|
169
|
-
replyFinal: (text: string, options?: SendMessageOptions
|
|
169
|
+
replyFinal: (text: string, options?: SendMessageOptions, delivery?: {
|
|
170
|
+
operationId?: string;
|
|
171
|
+
replacesOperationId?: string;
|
|
172
|
+
}) => Promise<FinalMessageResult>;
|
|
173
|
+
/** Inspect a prior reply before renewing media or recording product completion. */
|
|
174
|
+
getReplyOperation: (messageId: string, delivery?: {
|
|
175
|
+
operationId?: string;
|
|
176
|
+
}) => Promise<import('@canonmsg/core').CanonMessageOperation | undefined>;
|
|
177
|
+
/** Reconcile every part of a journaled reply without rebuilding its content. */
|
|
178
|
+
waitForReply: (operationId: string) => Promise<FinalMessageResult>;
|
|
170
179
|
replyProgress: (text: string, options?: ProgressMessageOptions) => Promise<ProgressMessageResult>;
|
|
171
180
|
/** Soft-delete a message (agent must be the sender) */
|
|
172
181
|
deleteMessage: (messageId: string) => Promise<void>;
|
|
@@ -307,6 +316,8 @@ export interface CanonAgentConnectionOptions {
|
|
|
307
316
|
firebaseApiKey?: string;
|
|
308
317
|
}
|
|
309
318
|
export interface CanonAgentOptions extends CanonAgentConnectionOptions {
|
|
319
|
+
/** Shared durable engine or persistent installation profile for this runtime. */
|
|
320
|
+
endpoint?: import('@canonmsg/core').CanonClientEndpointOptions;
|
|
310
321
|
apiKey: string;
|
|
311
322
|
/** `auto` resolves to SSE. */
|
|
312
323
|
deliveryMode?: DeliveryMode;
|
|
@@ -9,7 +9,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
9
9
|
const runtime = resolveCanonRuntimeConnection({ environmentId: connection.environmentId,
|
|
10
10
|
apiBaseUrl: connection.baseUrl, streamUrl: connection.streamUrl,
|
|
11
11
|
rtdbUrl: connection.rtdbUrl, firebaseWebApiKey: connection.firebaseApiKey });
|
|
12
|
-
const client = new CanonClient(connection.apiKey, runtime.apiBaseUrl);
|
|
12
|
+
const client = new CanonClient(connection.apiKey, runtime.apiBaseUrl, { environmentId: runtime.environmentId, streamUrl: runtime.streamUrl });
|
|
13
13
|
const journal = createWorkSessionJournal(options.store, {
|
|
14
14
|
environmentId: connection.environmentId, agentId: connection.agentId, hostId: options.hostId,
|
|
15
15
|
});
|
|
@@ -30,6 +30,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
30
30
|
let transport;
|
|
31
31
|
let stream;
|
|
32
32
|
let connected = false;
|
|
33
|
+
let agentContext;
|
|
33
34
|
let stopped = false;
|
|
34
35
|
let startPromise;
|
|
35
36
|
let stopPromise;
|
|
@@ -134,7 +135,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
134
135
|
throw new Error('This conversation already has a work session.');
|
|
135
136
|
if ([...rooms.values()].some((room) => room.binding.nativeSessionId === binding.nativeSessionId))
|
|
136
137
|
throw new Error('This native session already has a different Canon audience.');
|
|
137
|
-
const conversation =
|
|
138
|
+
const conversation = await client.getConversation(binding.conversationId);
|
|
138
139
|
if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
|
|
139
140
|
throw new Error('The owner and agent must both be members of this conversation.');
|
|
140
141
|
fence();
|
|
@@ -446,11 +447,19 @@ export function createCanonWorkSessionHost(options) {
|
|
|
446
447
|
transport = { agentId: connection.agentId, environmentId: connection.environmentId, client, publisher,
|
|
447
448
|
hasPendingInteraction: (id) => pending.has(id),
|
|
448
449
|
isRouteActive: (id) => !stopped && rooms.get(id)?.acknowledged === true,
|
|
449
|
-
subscribe: (handler) => {
|
|
450
|
-
|
|
450
|
+
subscribe: (handler) => {
|
|
451
|
+
handlers.add(handler);
|
|
452
|
+
// Context is account state, not a one-shot notification. An attachment
|
|
453
|
+
// created after stream startup needs the same trusted owner identity.
|
|
454
|
+
if (!stopped && agentContext)
|
|
455
|
+
handler.onAgentContext?.(agentContext);
|
|
456
|
+
if (connected && !stopped)
|
|
457
|
+
handler.onConnected?.();
|
|
458
|
+
return () => { handlers.delete(handler); };
|
|
459
|
+
} };
|
|
451
460
|
const connectionReady = new Promise((resolve, reject) => { resolveConnection = resolve; rejectConnection = reject; });
|
|
452
461
|
const connectionTimer = setTimeout(() => rejectConnection?.(new Error('Canon stream did not connect; work-session setup was not started.')), connectTimeoutMs);
|
|
453
|
-
stream = new CanonStream({
|
|
462
|
+
stream = new CanonStream({ endpoint: await client.getEndpoint(), agentId: connection.agentId, handler: {
|
|
454
463
|
onMessage: (payload) => {
|
|
455
464
|
if (stopped || !connected)
|
|
456
465
|
return;
|
|
@@ -462,6 +471,7 @@ export function createCanonWorkSessionHost(options) {
|
|
|
462
471
|
fail(new Error('Canon stream identity changed.'), 'stream-identity');
|
|
463
472
|
return;
|
|
464
473
|
}
|
|
474
|
+
agentContext = context;
|
|
465
475
|
fanout('onAgentContext', context);
|
|
466
476
|
},
|
|
467
477
|
onConversationUpdated: (payload) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.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",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"prepack": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
28
|
+
"node": ">=22.22.3"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^
|
|
31
|
+
"@canonmsg/core": "^13.0.4"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|