@canonmsg/agent-sdk 11.0.0 → 11.0.2
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 +19 -17
- package/dist/attached-session.js +42 -25
- package/dist/canon-agent.d.ts +2 -0
- package/dist/canon-agent.js +69 -52
- package/dist/debouncer.d.ts +7 -4
- package/dist/debouncer.js +48 -13
- package/dist/inbound-handoff.d.ts +6 -0
- package/dist/inbound-handoff.js +17 -0
- package/dist/realtime.js +5 -2
- package/dist/session-manager.d.ts +5 -1
- package/dist/session-manager.js +21 -6
- package/dist/work-session-host.js +16 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Replicated or ephemeral cloud deployments must inject an operator-owned endpoint
|
|
|
42
42
|
|
|
43
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
44
|
|
|
45
|
-
Runtime heartbeats
|
|
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.
|
|
46
46
|
|
|
47
47
|
## Configuration
|
|
48
48
|
|
|
@@ -52,8 +52,8 @@ Runtime heartbeats and Firebase token refresh come from Core, using the same mac
|
|
|
52
52
|
| `environmentId` | `string` | **required** | Canon trust domain shared by API, stream, RTDB, and future cryptographic state |
|
|
53
53
|
| `baseUrl` | `string` | Environment default | Override the API base URL when the selected environment has no packaged default |
|
|
54
54
|
| `streamUrl` | `string` | Environment default | Override the SSE stream URL when the selected environment has no packaged default |
|
|
55
|
-
| `rtdbUrl` | `string` | Environment default |
|
|
56
|
-
| `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 |
|
|
57
57
|
| `deliveryMode` | `'auto' \| 'sse'` | `'auto'` | How the SDK receives new messages |
|
|
58
58
|
| `debounceMs` | `number` | `2000` | Batching window for incoming messages per conversation |
|
|
59
59
|
| `historyLimit` | `number` | `50` | Number of historical messages to fetch (max 100) |
|
|
@@ -129,15 +129,15 @@ The SDK provides a standard catalog of seven runtime primitives. It advertises a
|
|
|
129
129
|
|
|
130
130
|
## Delivery
|
|
131
131
|
|
|
132
|
-
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.
|
|
133
133
|
|
|
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
|
|
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.
|
|
135
135
|
|
|
136
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.
|
|
137
137
|
|
|
138
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.
|
|
139
139
|
|
|
140
|
-
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.
|
|
141
141
|
|
|
142
142
|
## Message Handler
|
|
143
143
|
|
|
@@ -150,20 +150,20 @@ The `message` event handler receives a context object with:
|
|
|
150
150
|
| `replyContext` | `CanonReplyContext \| null` | Resolved swipe-reply target for the latest inbound message, when available |
|
|
151
151
|
| `conversationId` | `string` | The conversation these messages belong to |
|
|
152
152
|
| `conversation` | `CanonConversation` | Full conversation metadata |
|
|
153
|
-
| `groupContext` | `CanonGroupContext \| undefined` |
|
|
153
|
+
| `groupContext` | `CanonGroupContext \| undefined` | Participant context based on the current conversation roster |
|
|
154
154
|
| `replyFinal` | `(text: string, options?) => Promise<{ messageId: string; messageIds: string[] }>` | Send the durable final reply for a turn |
|
|
155
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 |
|
|
156
156
|
| `deleteMessage` | `(messageId: string) => Promise<void>` | Soft-delete a message sent by this agent |
|
|
157
157
|
| `markAsRead` | `() => Promise<void>` | Advance this agent's read cursor for the conversation |
|
|
158
|
-
| `leave` | `() => Promise<void>` | Leave the current
|
|
158
|
+
| `leave` | `() => Promise<void>` | Leave the current conversation |
|
|
159
159
|
| `react` | `(messageId, emoji) => Promise<void>` | Toggle an emoji reaction |
|
|
160
|
-
| `addMember` / `removeMember` | functions | Manage
|
|
161
|
-
| `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 |
|
|
162
162
|
| `agent` | `AgentContext` | Trusted Canon agent identity and access context |
|
|
163
163
|
| `activeSelfContextId` | `string \| null` | Active private self-context id for this turn |
|
|
164
164
|
| `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
|
|
165
165
|
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
166
|
-
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields
|
|
166
|
+
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields reflect the current roster, sender and participation policy |
|
|
167
167
|
| `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
|
|
168
168
|
| `turnVerbosity` | `'verbose' \| 'quiet'` | Resolved emission mode for this turn — see [Turn verbosity](#turn-verbosity). Fixed for the whole turn |
|
|
169
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 |
|
|
@@ -177,7 +177,7 @@ The `message` event handler receives a context object with:
|
|
|
177
177
|
|
|
178
178
|
Messages from the agent itself are automatically filtered out -- your handler only receives messages from other participants.
|
|
179
179
|
|
|
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.
|
|
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.
|
|
181
181
|
|
|
182
182
|
## Events
|
|
183
183
|
|
|
@@ -457,25 +457,27 @@ It governs teardown only. Calling `replyFinal()` as well is two explicit decisio
|
|
|
457
457
|
|
|
458
458
|
## Turn verbosity
|
|
459
459
|
|
|
460
|
-
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.
|
|
461
461
|
|
|
462
462
|
```ts
|
|
463
463
|
const agent = new CanonAgent({
|
|
464
464
|
apiKey: process.env.CANON_API_KEY!,
|
|
465
465
|
environmentId: 'canon-prod-v1',
|
|
466
466
|
turnVerbosity: 'verbose', // scalar: applies everywhere
|
|
467
|
-
// turnVerbosity: { group: 'verbose' }, // object: override one
|
|
467
|
+
// turnVerbosity: { group: 'verbose' }, // object: override one roster policy scope
|
|
468
468
|
});
|
|
469
469
|
```
|
|
470
470
|
|
|
471
471
|
| Value | Effect |
|
|
472
472
|
|---|---|
|
|
473
|
-
| `'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 |
|
|
474
474
|
| `'verbose'` | Live turn state and the margin activity trail, everywhere |
|
|
475
475
|
| `'quiet'` | Thinking indicator and the final message only, everywhere |
|
|
476
|
-
| `{ direct?, group? }` |
|
|
476
|
+
| `{ direct?, group? }` | Compatibility keys for the two-person/larger-roster scopes; the unnamed scope keeps its default |
|
|
477
477
|
|
|
478
|
-
|
|
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.
|
|
479
481
|
|
|
480
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.
|
|
481
483
|
|
package/dist/attached-session.js
CHANGED
|
@@ -45,7 +45,7 @@ export function createCanonAttachedSession(options) {
|
|
|
45
45
|
let recoveryPromise;
|
|
46
46
|
let nativeReconcilePromise;
|
|
47
47
|
const tasks = new Set();
|
|
48
|
-
const receiving = new
|
|
48
|
+
const receiving = new Map();
|
|
49
49
|
const reportedHistory = new Set();
|
|
50
50
|
let publicationFailed = false;
|
|
51
51
|
let historyIncomplete = false;
|
|
@@ -84,7 +84,10 @@ export function createCanonAttachedSession(options) {
|
|
|
84
84
|
const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
|
|
85
85
|
const core = createAttachedNativeSession({
|
|
86
86
|
binding, store: options.store, adapter: options.native,
|
|
87
|
-
|
|
87
|
+
submitNative: (input, submit) => {
|
|
88
|
+
const handoff = receiving.get(input.messageId);
|
|
89
|
+
return handoff ? endpoint.startInbound(handoff, submit) : Promise.resolve({ status: 'unavailable' });
|
|
90
|
+
},
|
|
88
91
|
isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
|
|
89
92
|
publisher: {
|
|
90
93
|
prepare(input) {
|
|
@@ -194,11 +197,11 @@ export function createCanonAttachedSession(options) {
|
|
|
194
197
|
}
|
|
195
198
|
return true;
|
|
196
199
|
}
|
|
197
|
-
async function receive(payload) {
|
|
200
|
+
async function receive(payload, handoff) {
|
|
198
201
|
if (!running || stopped || payload.conversationId !== binding.conversationId)
|
|
199
|
-
return;
|
|
202
|
+
return 'deferred';
|
|
200
203
|
if (options.transport?.isRouteActive?.(binding.conversationId) === false)
|
|
201
|
-
return;
|
|
204
|
+
return 'deferred';
|
|
202
205
|
const message = payload.message;
|
|
203
206
|
if (message.senderId === binding.agentId || core.hasCanonMessage(message.id)
|
|
204
207
|
|| core.isBeforeCanonBaseline(message.id, message.createdAt ?? '') || receiving.has(message.id))
|
|
@@ -208,7 +211,7 @@ export function createCanonAttachedSession(options) {
|
|
|
208
211
|
emit({ status: 'blocked', reason: 'invalid-provenance', messageId: message.id });
|
|
209
212
|
return;
|
|
210
213
|
}
|
|
211
|
-
receiving.
|
|
214
|
+
receiving.set(message.id, handoff);
|
|
212
215
|
try {
|
|
213
216
|
if (payload.turnDispatch?.kind !== 'run_turn') {
|
|
214
217
|
await core.recordSkippedCanonMessage(message.id, 'not-dispatched');
|
|
@@ -240,12 +243,10 @@ export function createCanonAttachedSession(options) {
|
|
|
240
243
|
});
|
|
241
244
|
// A conclusive provider acknowledgement settles command delivery. Native
|
|
242
245
|
// turn progress and transcript correlation remain in the provider journal.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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}`);
|
|
246
|
+
if (result.status === 'deferred')
|
|
247
|
+
return 'deferred';
|
|
248
|
+
if (!(result.status === 'uncertain' && result.messageId === message.id)) {
|
|
249
|
+
await handoff.settle(`native-${result.status}`);
|
|
249
250
|
}
|
|
250
251
|
if (stopped)
|
|
251
252
|
return;
|
|
@@ -430,20 +431,32 @@ export function createCanonAttachedSession(options) {
|
|
|
430
431
|
running = true;
|
|
431
432
|
inbound = endpoint.acceptInbound({ kind: 'message.created', conversationId: binding.conversationId,
|
|
432
433
|
offer: async (event) => {
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
const handoff = await endpoint.acquireInbound(event.id);
|
|
435
|
+
if (!handoff)
|
|
435
436
|
return;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
437
|
+
// The handoff owns deduplication while Core decides busy/accepted.
|
|
438
|
+
// Do not block the inbox pump on an external provider acknowledgement.
|
|
439
|
+
const received = (async () => {
|
|
440
|
+
try {
|
|
441
|
+
if (!running || stopped || options.transport?.isRouteActive?.(binding.conversationId) === false)
|
|
442
|
+
return;
|
|
443
|
+
const payload = event.data;
|
|
444
|
+
if (core.getState().pendingSourceMessageId === payload.message.id && core.getState().status === 'uncertain') {
|
|
445
|
+
// A recovered native journal can predate endpoint adoption. This
|
|
446
|
+
// is provider proof of uncertainty, not permission to resubmit.
|
|
447
|
+
await endpoint.setInboundState(event.id, 'uncertain', 'native-reconciliation-required');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const result = await receive(payload, handoff);
|
|
451
|
+
if (result !== 'deferred' && handoff.phase === 'queued') {
|
|
452
|
+
await handoff.cancel('native-input-reviewed');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
await handoff.release('attached-offer-finished');
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
track(received, 'receive');
|
|
447
460
|
}, onError: (error) => report(error, 'inbound-recovery') });
|
|
448
461
|
inbound.start();
|
|
449
462
|
const handler = {
|
|
@@ -527,6 +540,10 @@ export function createCanonAttachedSession(options) {
|
|
|
527
540
|
return stopPromise;
|
|
528
541
|
stopped = true;
|
|
529
542
|
running = false;
|
|
543
|
+
for (const handoff of receiving.values()) {
|
|
544
|
+
// Register release before waiting for the pending native submission.
|
|
545
|
+
track(handoff.release('attachment-closed'), 'release-inbound');
|
|
546
|
+
}
|
|
530
547
|
if (timer)
|
|
531
548
|
clearInterval(timer);
|
|
532
549
|
stream?.stop();
|
package/dist/canon-agent.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export declare class CanonAgent {
|
|
|
87
87
|
private runtimeStatePublisher;
|
|
88
88
|
private rtdbHandle;
|
|
89
89
|
private controlPoller;
|
|
90
|
+
private readonly activeInbound;
|
|
90
91
|
private readonly activeAbortControllers;
|
|
91
92
|
private readonly activeTurns;
|
|
92
93
|
private readonly conversationMemberIds;
|
|
@@ -221,6 +222,7 @@ export declare class CanonAgent {
|
|
|
221
222
|
private requireRuntimeStatePublisher;
|
|
222
223
|
private handleMessages;
|
|
223
224
|
private executeHandler;
|
|
225
|
+
private executeOwnedHandler;
|
|
224
226
|
static register(options: Omit<RegistrationInput, 'baseUrl' | 'developerInfo'> & {
|
|
225
227
|
developerInfo: string;
|
|
226
228
|
} & CanonAgentConnectionOptions): Promise<{
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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,
|
|
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
|
+
import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
|
|
4
5
|
import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
|
|
5
6
|
import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
|
|
6
7
|
import { SessionManager } from './session-manager.js';
|
|
@@ -325,6 +326,7 @@ export class CanonAgent {
|
|
|
325
326
|
runtimeStatePublisher = null;
|
|
326
327
|
rtdbHandle = null;
|
|
327
328
|
controlPoller = null;
|
|
329
|
+
activeInbound = new Map();
|
|
328
330
|
activeAbortControllers = new Map();
|
|
329
331
|
activeTurns = new Map();
|
|
330
332
|
conversationMemberIds = new Map();
|
|
@@ -628,9 +630,9 @@ export class CanonAgent {
|
|
|
628
630
|
});
|
|
629
631
|
console.log(`[canon-sdk] Authenticated as ${agentId}`);
|
|
630
632
|
// 2. Wire debouncer to handler
|
|
631
|
-
this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
|
|
633
|
+
this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId, handoffs) => {
|
|
632
634
|
this.rememberConversationId(conversationId);
|
|
633
|
-
await this.handleMessages(conversationId, messages, provenanceByMessageId);
|
|
635
|
+
await this.handleMessages(conversationId, messages, provenanceByMessageId, handoffs);
|
|
634
636
|
});
|
|
635
637
|
// 3. Fetch conversations (used for delivery mode + session state)
|
|
636
638
|
let conversations = [];
|
|
@@ -725,6 +727,9 @@ export class CanonAgent {
|
|
|
725
727
|
});
|
|
726
728
|
rtm.setMessageDeletedHandler((payload) => {
|
|
727
729
|
this.sessionManager?.dropQueuedMessage(payload.conversationId, payload.messageId);
|
|
730
|
+
for (const handoffs of this.activeInbound.get(payload.conversationId) ?? []) {
|
|
731
|
+
void disposeInboundHandoffs(selectInboundHandoffs(handoffs, [payload.messageId]), 'cancel', 'message-deleted');
|
|
732
|
+
}
|
|
728
733
|
});
|
|
729
734
|
rtm.setConnectionHandlers({
|
|
730
735
|
onConnected: () => {
|
|
@@ -868,6 +873,10 @@ export class CanonAgent {
|
|
|
868
873
|
this.realtimeManager?.stop();
|
|
869
874
|
this.realtimeManager = null;
|
|
870
875
|
this.debouncer.destroy();
|
|
876
|
+
for (const batches of this.activeInbound.values()) {
|
|
877
|
+
for (const handoffs of batches)
|
|
878
|
+
void disposeInboundHandoffs(handoffs, 'release', 'runtime-closed');
|
|
879
|
+
}
|
|
871
880
|
this.sessionManager?.destroy();
|
|
872
881
|
this.sessionManager = null;
|
|
873
882
|
const heartbeat = this.runtimeHeartbeat;
|
|
@@ -1005,7 +1014,8 @@ export class CanonAgent {
|
|
|
1005
1014
|
}
|
|
1006
1015
|
if (this.agentId && !memberIds.includes(this.agentId)) {
|
|
1007
1016
|
this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
|
|
1008
|
-
this.abortActiveTurns(payload.conversationId);
|
|
1017
|
+
this.abortActiveTurns(payload.conversationId, false);
|
|
1018
|
+
this.debouncer.dropConversation(payload.conversationId);
|
|
1009
1019
|
this.sessionManager?.dropQueued(payload.conversationId);
|
|
1010
1020
|
this.pendingMembershipChanges.delete(payload.conversationId);
|
|
1011
1021
|
}
|
|
@@ -1132,11 +1142,14 @@ export class CanonAgent {
|
|
|
1132
1142
|
return;
|
|
1133
1143
|
const activeTurn = this.firstActiveTurn(conversationId);
|
|
1134
1144
|
const abortSignal = this.abortActiveTurns(conversationId);
|
|
1135
|
-
const
|
|
1145
|
+
const debouncedMessages = signal === 'new_session' || signal === 'stop_and_drop'
|
|
1146
|
+
? this.debouncer.dropConversation(conversationId) : [];
|
|
1147
|
+
const sessionMessages = signal === 'new_session'
|
|
1136
1148
|
? this.sessionManager?.resetSession(conversationId) ?? []
|
|
1137
1149
|
: signal === 'stop_and_drop'
|
|
1138
1150
|
? this.sessionManager?.dropQueued(conversationId) ?? []
|
|
1139
1151
|
: [];
|
|
1152
|
+
const droppedMessages = [...debouncedMessages, ...sessionMessages];
|
|
1140
1153
|
const droppedMessageIds = droppedMessages.map((message) => message.id);
|
|
1141
1154
|
await Promise.all(droppedMessages.map((message) => {
|
|
1142
1155
|
if (message.metadata?.inboundDisposition !== 'queued')
|
|
@@ -1190,7 +1203,12 @@ export class CanonAgent {
|
|
|
1190
1203
|
this.typingSignals.clear(conversationId).catch(() => { }),
|
|
1191
1204
|
]);
|
|
1192
1205
|
}
|
|
1193
|
-
abortActiveTurns(conversationId) {
|
|
1206
|
+
abortActiveTurns(conversationId, cancelPreparing = true) {
|
|
1207
|
+
// Membership snapshots may arrive while a newer authoritative refresh is
|
|
1208
|
+
// pending. Only explicit controls discard those still-preparing inputs.
|
|
1209
|
+
for (const handoffs of cancelPreparing ? this.activeInbound.get(conversationId) ?? [] : []) {
|
|
1210
|
+
void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
|
|
1211
|
+
}
|
|
1194
1212
|
const controllers = this.activeAbortControllers.get(conversationId);
|
|
1195
1213
|
if (!controllers || controllers.size === 0)
|
|
1196
1214
|
return undefined;
|
|
@@ -1254,81 +1272,79 @@ export class CanonAgent {
|
|
|
1254
1272
|
}
|
|
1255
1273
|
return publisher;
|
|
1256
1274
|
}
|
|
1257
|
-
async handleMessages(conversationId, messages, provenanceByMessageId) {
|
|
1275
|
+
async handleMessages(conversationId, messages, provenanceByMessageId, handoffs = new Map()) {
|
|
1258
1276
|
const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
|
|
1259
1277
|
const consumed = messages.filter((message) => !actionableMessages.includes(message));
|
|
1260
|
-
|
|
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
|
-
}
|
|
1267
|
-
}
|
|
1278
|
+
void disposeInboundHandoffs(selectInboundHandoffs(handoffs, consumed.map((message) => message.id)), 'cancel', 'interaction-consumed');
|
|
1268
1279
|
if (actionableMessages.length === 0)
|
|
1269
1280
|
return;
|
|
1270
1281
|
messages = actionableMessages;
|
|
1282
|
+
handoffs = selectInboundHandoffs(handoffs, messages.map((message) => message.id));
|
|
1271
1283
|
if (!this.handler) {
|
|
1272
|
-
|
|
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
|
-
}
|
|
1284
|
+
await disposeInboundHandoffs(handoffs, 'release', 'no-handler');
|
|
1278
1285
|
console.warn(`[canon-sdk] No message handler registered — input for ${conversationId} is deferred. Call agent.on('message', handler) before starting.`);
|
|
1279
1286
|
return;
|
|
1280
1287
|
}
|
|
1281
1288
|
const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
|
|
1282
1289
|
const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
|
|
1283
1290
|
const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
|
|
1284
|
-
|
|
1291
|
+
const notification = this.notifyMessageInterrupt(conversationId, abortSignal);
|
|
1285
1292
|
if (this.sessionManager) {
|
|
1286
|
-
await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
|
|
1287
|
-
await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId);
|
|
1288
|
-
}, { toFront: shouldInterrupt });
|
|
1293
|
+
await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages, batchHandoffs) => {
|
|
1294
|
+
await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId, batchHandoffs, notification);
|
|
1295
|
+
}, { toFront: shouldInterrupt, handoffs });
|
|
1289
1296
|
}
|
|
1290
1297
|
else {
|
|
1291
|
-
await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId);
|
|
1298
|
+
await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId, handoffs, notification);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
async executeHandler(conversationId, messages, session, provenanceByMessageId, handoffs = new Map(), notification) {
|
|
1302
|
+
const batches = this.activeInbound.get(conversationId) ?? new Set();
|
|
1303
|
+
batches.add(handoffs);
|
|
1304
|
+
this.activeInbound.set(conversationId, batches);
|
|
1305
|
+
try {
|
|
1306
|
+
await notification;
|
|
1307
|
+
await this.executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs);
|
|
1308
|
+
}
|
|
1309
|
+
finally {
|
|
1310
|
+
batches.delete(handoffs);
|
|
1311
|
+
if (batches.size === 0)
|
|
1312
|
+
this.activeInbound.delete(conversationId);
|
|
1313
|
+
// Queued/deferred inputs go back to the engine's durable retry loop.
|
|
1314
|
+
// Submitted effects cannot be released: failures remain uncertain.
|
|
1315
|
+
await disposeInboundHandoffs(handoffs, 'release', 'sdk-batch-finished');
|
|
1292
1316
|
}
|
|
1293
1317
|
}
|
|
1294
|
-
async
|
|
1295
|
-
if (!this.handler
|
|
1318
|
+
async executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs) {
|
|
1319
|
+
if (!this.handler || messages.length === 0 || handoffs.size !== messages.length
|
|
1320
|
+
|| [...handoffs.values()].some((handoff) => handoff.phase !== 'queued'))
|
|
1296
1321
|
return;
|
|
1297
|
-
//
|
|
1298
|
-
// Refresh before trusting cached exclusion, accepting queued input, or
|
|
1299
|
-
// freezing output policy. Reuse this same snapshot for handler context.
|
|
1322
|
+
// Refresh admission before freezing output policy or constructing context.
|
|
1300
1323
|
const endpoint = await this.apiClient.getEndpoint();
|
|
1301
|
-
const
|
|
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);
|
|
1306
|
-
}
|
|
1307
|
-
};
|
|
1324
|
+
const cancelInputs = (reason) => disposeInboundHandoffs(handoffs, 'cancel', reason);
|
|
1308
1325
|
let conversation;
|
|
1309
1326
|
try {
|
|
1310
1327
|
conversation = await this.apiClient.getConversation(conversationId);
|
|
1311
1328
|
}
|
|
1312
1329
|
catch (error) {
|
|
1313
|
-
await setInputState('deferred', 'membership-unavailable');
|
|
1314
1330
|
console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
|
|
1315
1331
|
return;
|
|
1316
1332
|
}
|
|
1317
1333
|
if (!conversation) {
|
|
1318
|
-
await
|
|
1334
|
+
await cancelInputs('conversation-unavailable');
|
|
1319
1335
|
return;
|
|
1320
1336
|
}
|
|
1321
1337
|
this.rememberConversationMembers([conversation]);
|
|
1322
1338
|
const currentMembers = this.conversationMemberIds.get(conversationId);
|
|
1323
1339
|
if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
|
|
1324
|
-
await
|
|
1340
|
+
await cancelInputs('membership-lost');
|
|
1325
1341
|
return;
|
|
1326
1342
|
}
|
|
1327
1343
|
this.rememberConversationId(conversationId);
|
|
1328
1344
|
await this.markQueuedMessagesAccepted(conversationId, messages);
|
|
1329
1345
|
const acceptedMembers = this.conversationMemberIds.get(conversationId);
|
|
1330
1346
|
if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
|
|
1331
|
-
await
|
|
1347
|
+
await cancelInputs('membership-lost');
|
|
1332
1348
|
return;
|
|
1333
1349
|
}
|
|
1334
1350
|
const turnId = randomUUID();
|
|
@@ -1356,6 +1372,9 @@ export class CanonAgent {
|
|
|
1356
1372
|
const runtimeState = this.createRuntimeStatePublisher();
|
|
1357
1373
|
const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
|
|
1358
1374
|
const abortController = new AbortController();
|
|
1375
|
+
abortController.signal.addEventListener('abort', () => {
|
|
1376
|
+
void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
|
|
1377
|
+
}, { once: true });
|
|
1359
1378
|
const throwIfAborted = () => {
|
|
1360
1379
|
if (abortController.signal.aborted) {
|
|
1361
1380
|
throw createTurnAbortError();
|
|
@@ -1605,7 +1624,9 @@ export class CanonAgent {
|
|
|
1605
1624
|
? normalizeResponseUserId(latestMessage.senderId)
|
|
1606
1625
|
: undefined;
|
|
1607
1626
|
let replyContext = latestMessage
|
|
1608
|
-
?
|
|
1627
|
+
? await hydrateCanonReplyContext({
|
|
1628
|
+
client: this.apiClient, conversationId, message: latestMessage, messages: history,
|
|
1629
|
+
})
|
|
1609
1630
|
: null;
|
|
1610
1631
|
const resolvedActiveSelfContextId = resolveMessageActiveSelfContextId({
|
|
1611
1632
|
messageId: latestMessage?.id,
|
|
@@ -2194,13 +2215,7 @@ export class CanonAgent {
|
|
|
2194
2215
|
// Fence uncertain native/business effects before handing execution out.
|
|
2195
2216
|
// A crash after this write requires provider/operator reconciliation.
|
|
2196
2217
|
throwIfAborted();
|
|
2197
|
-
|
|
2198
|
-
// restored native queue without its journal must await migration instead
|
|
2199
|
-
// of treating a missing record as permission to execute.
|
|
2200
|
-
const journaled = messages.map((message) => `message:${conversationId}:${message.id}`);
|
|
2201
|
-
if (!journaled.length || !await endpoint.claimInbound(journaled))
|
|
2202
|
-
return;
|
|
2203
|
-
await this.handler({
|
|
2218
|
+
const submission = await endpoint.startInbound([...handoffs.values()], () => this.handler({
|
|
2204
2219
|
messages: hydratedMessages,
|
|
2205
2220
|
history,
|
|
2206
2221
|
replyContext,
|
|
@@ -2372,8 +2387,10 @@ export class CanonAgent {
|
|
|
2372
2387
|
}
|
|
2373
2388
|
},
|
|
2374
2389
|
},
|
|
2375
|
-
});
|
|
2376
|
-
|
|
2390
|
+
}));
|
|
2391
|
+
if (submission.status !== 'submitted')
|
|
2392
|
+
return;
|
|
2393
|
+
await disposeInboundHandoffs(handoffs, 'settle', 'handler-completed');
|
|
2377
2394
|
// Auto-mark conversation as read after successful processing
|
|
2378
2395
|
if (this.options.autoMarkRead) {
|
|
2379
2396
|
try {
|
package/dist/debouncer.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import type { CanonMessage, CanonRuntimeProvenance } from '@canonmsg/core';
|
|
1
|
+
import type { CanonMessage, CanonRuntimeProvenance, EndpointInboundHandoff } from '@canonmsg/core';
|
|
2
|
+
import { type InboundHandoffs } from './inbound-handoff.js';
|
|
2
3
|
export declare class Debouncer {
|
|
3
4
|
private debounceMs;
|
|
4
5
|
private pending;
|
|
5
|
-
private
|
|
6
|
+
private provenanceByConversation;
|
|
7
|
+
private handoffsByConversation;
|
|
6
8
|
private timers;
|
|
7
9
|
private orderedFlags;
|
|
8
10
|
private callback;
|
|
9
11
|
constructor(debounceMs: number);
|
|
10
|
-
setCallback(cb: (conversationId: string, messages: CanonMessage[], provenanceByMessageId: ReadonlyMap<string, CanonRuntimeProvenance
|
|
11
|
-
add(conversationId: string, message: CanonMessage, provenance?: CanonRuntimeProvenance | null): void;
|
|
12
|
+
setCallback(cb: (conversationId: string, messages: CanonMessage[], provenanceByMessageId: ReadonlyMap<string, CanonRuntimeProvenance>, handoffs: InboundHandoffs) => void | Promise<void>): void;
|
|
13
|
+
add(conversationId: string, message: CanonMessage, provenance?: CanonRuntimeProvenance | null, handoff?: EndpointInboundHandoff): void;
|
|
12
14
|
removeMessage(conversationId: string, messageId: string): boolean;
|
|
13
15
|
private flush;
|
|
16
|
+
dropConversation(conversationId: string): CanonMessage[];
|
|
14
17
|
destroy(): void;
|
|
15
18
|
}
|
package/dist/debouncer.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { disposeInboundHandoffs } from './inbound-handoff.js';
|
|
1
2
|
export class Debouncer {
|
|
2
3
|
debounceMs;
|
|
3
4
|
pending = new Map();
|
|
4
|
-
|
|
5
|
+
provenanceByConversation = new Map();
|
|
6
|
+
handoffsByConversation = new Map();
|
|
5
7
|
timers = new Map();
|
|
6
8
|
// Track whether each conversation's pending messages are already in sorted
|
|
7
9
|
// order so we can skip the sort on flush when possible (#10)
|
|
@@ -13,10 +15,17 @@ export class Debouncer {
|
|
|
13
15
|
setCallback(cb) {
|
|
14
16
|
this.callback = cb;
|
|
15
17
|
}
|
|
16
|
-
add(conversationId, message, provenance) {
|
|
18
|
+
add(conversationId, message, provenance, handoff) {
|
|
17
19
|
const existing = this.pending.get(conversationId) || [];
|
|
18
20
|
if (provenance) {
|
|
19
|
-
this.
|
|
21
|
+
const provenanceByMessageId = this.provenanceByConversation.get(conversationId) ?? new Map();
|
|
22
|
+
provenanceByMessageId.set(message.id, provenance);
|
|
23
|
+
this.provenanceByConversation.set(conversationId, provenanceByMessageId);
|
|
24
|
+
}
|
|
25
|
+
if (handoff) {
|
|
26
|
+
const handoffs = this.handoffsByConversation.get(conversationId) ?? new Map();
|
|
27
|
+
handoffs.set(message.id, handoff);
|
|
28
|
+
this.handoffsByConversation.set(conversationId, handoffs);
|
|
20
29
|
}
|
|
21
30
|
// Deduplicate by message ID
|
|
22
31
|
if (!existing.some((m) => m.id === message.id)) {
|
|
@@ -50,9 +59,15 @@ export class Debouncer {
|
|
|
50
59
|
const next = existing.filter((message) => message.id !== messageId);
|
|
51
60
|
if (next.length === existing.length)
|
|
52
61
|
return false;
|
|
53
|
-
this.
|
|
62
|
+
this.provenanceByConversation.get(conversationId)?.delete(messageId);
|
|
63
|
+
const handoff = this.handoffsByConversation.get(conversationId)?.get(messageId);
|
|
64
|
+
if (handoff)
|
|
65
|
+
void disposeInboundHandoffs(new Map([[messageId, handoff]]), 'cancel', 'message-deleted');
|
|
66
|
+
this.handoffsByConversation.get(conversationId)?.delete(messageId);
|
|
54
67
|
if (next.length === 0) {
|
|
55
68
|
this.pending.delete(conversationId);
|
|
69
|
+
this.provenanceByConversation.delete(conversationId);
|
|
70
|
+
this.handoffsByConversation.delete(conversationId);
|
|
56
71
|
this.orderedFlags.delete(conversationId);
|
|
57
72
|
const timer = this.timers.get(conversationId);
|
|
58
73
|
if (timer)
|
|
@@ -75,24 +90,44 @@ export class Debouncer {
|
|
|
75
90
|
if (!isOrdered) {
|
|
76
91
|
messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
77
92
|
}
|
|
78
|
-
const provenanceByMessageId = new Map();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
const provenanceByMessageId = this.provenanceByConversation.get(conversationId) ?? new Map();
|
|
94
|
+
const handoffs = this.handoffsByConversation.get(conversationId) ?? new Map();
|
|
95
|
+
this.provenanceByConversation.delete(conversationId);
|
|
96
|
+
this.handoffsByConversation.delete(conversationId);
|
|
97
|
+
const failed = async (error) => {
|
|
98
|
+
await disposeInboundHandoffs(handoffs, 'release', 'dispatch-failed');
|
|
99
|
+
console.error('[canon-sdk] Inbound dispatch failed:', error);
|
|
100
|
+
};
|
|
101
|
+
try {
|
|
102
|
+
void Promise.resolve(this.callback(conversationId, messages, provenanceByMessageId, handoffs)).catch(failed);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
void failed(error);
|
|
85
106
|
}
|
|
86
|
-
this.callback(conversationId, messages, provenanceByMessageId);
|
|
87
107
|
}
|
|
108
|
+
else {
|
|
109
|
+
void disposeInboundHandoffs(this.handoffsByConversation.get(conversationId) ?? new Map(), 'release', 'no-dispatcher');
|
|
110
|
+
this.provenanceByConversation.delete(conversationId);
|
|
111
|
+
this.handoffsByConversation.delete(conversationId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
dropConversation(conversationId) {
|
|
115
|
+
const messages = this.pending.get(conversationId) ?? [];
|
|
116
|
+
for (const message of messages)
|
|
117
|
+
this.removeMessage(conversationId, message.id);
|
|
118
|
+
return messages;
|
|
88
119
|
}
|
|
89
120
|
destroy() {
|
|
121
|
+
for (const handoffs of this.handoffsByConversation.values()) {
|
|
122
|
+
void disposeInboundHandoffs(handoffs, 'release', 'debouncer-closed');
|
|
123
|
+
}
|
|
124
|
+
this.handoffsByConversation.clear();
|
|
90
125
|
for (const timer of this.timers.values()) {
|
|
91
126
|
clearTimeout(timer);
|
|
92
127
|
}
|
|
93
128
|
this.timers.clear();
|
|
94
129
|
this.pending.clear();
|
|
95
|
-
this.
|
|
130
|
+
this.provenanceByConversation.clear();
|
|
96
131
|
this.orderedFlags.clear();
|
|
97
132
|
}
|
|
98
133
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { EndpointInboundHandoff } from '@canonmsg/core';
|
|
2
|
+
/** Message-keyed handles within one conversation, carried with its queue entry. */
|
|
3
|
+
export type InboundHandoffs = ReadonlyMap<string, EndpointInboundHandoff>;
|
|
4
|
+
export declare function selectInboundHandoffs(handoffs: InboundHandoffs, messageIds: readonly string[]): InboundHandoffs;
|
|
5
|
+
/** Calls record intent synchronously, even when a durable claim is awaiting IO. */
|
|
6
|
+
export declare function disposeInboundHandoffs(handoffs: InboundHandoffs, disposition: 'cancel' | 'release' | 'settle', reason: string): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function selectInboundHandoffs(handoffs, messageIds) {
|
|
2
|
+
const selected = new Map();
|
|
3
|
+
for (const id of messageIds) {
|
|
4
|
+
const handoff = handoffs.get(id);
|
|
5
|
+
if (handoff)
|
|
6
|
+
selected.set(id, handoff);
|
|
7
|
+
}
|
|
8
|
+
return selected;
|
|
9
|
+
}
|
|
10
|
+
/** Calls record intent synchronously, even when a durable claim is awaiting IO. */
|
|
11
|
+
export async function disposeInboundHandoffs(handoffs, disposition, reason) {
|
|
12
|
+
const results = await Promise.allSettled([...handoffs.values()].map((handoff) => handoff[disposition](reason)));
|
|
13
|
+
for (const result of results) {
|
|
14
|
+
if (result.status === 'rejected')
|
|
15
|
+
console.error(`[canon-sdk] Inbound ${disposition} failed:`, result.reason);
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/realtime.js
CHANGED
|
@@ -98,8 +98,11 @@ export class RealtimeManager {
|
|
|
98
98
|
}
|
|
99
99
|
async offer(payload) {
|
|
100
100
|
const id = `message:${payload.conversationId}:${payload.message.id}`;
|
|
101
|
+
const handoff = await this.endpoint.acquireInbound(id);
|
|
102
|
+
if (!handoff)
|
|
103
|
+
return;
|
|
101
104
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
102
|
-
await
|
|
105
|
+
await handoff.cancel('observe-only');
|
|
103
106
|
return;
|
|
104
107
|
}
|
|
105
108
|
const m = payload.message;
|
|
@@ -129,7 +132,7 @@ export class RealtimeManager {
|
|
|
129
132
|
...(m.metadata ? { metadata: m.metadata } : {}),
|
|
130
133
|
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
131
134
|
};
|
|
132
|
-
this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
|
|
135
|
+
this.debouncer.add(payload.conversationId, message, payload.provenance ?? null, handoff);
|
|
133
136
|
}
|
|
134
137
|
logSseError(err) {
|
|
135
138
|
const code = err.code;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CanonMessage } from '@canonmsg/core';
|
|
2
|
+
import { type InboundHandoffs } from './inbound-handoff.js';
|
|
2
3
|
export interface SessionConfig {
|
|
3
4
|
/** Max messages to retain per session (default: 50) */
|
|
4
5
|
contextLimit?: number;
|
|
@@ -17,10 +18,12 @@ export interface Session {
|
|
|
17
18
|
/** When this session last processed a message */
|
|
18
19
|
lastActiveAt: number;
|
|
19
20
|
}
|
|
20
|
-
type SessionHandler = (session: Session, newMessages: CanonMessage[]) => Promise<void>;
|
|
21
|
+
type SessionHandler = (session: Session, newMessages: CanonMessage[], handoffs: InboundHandoffs) => Promise<void>;
|
|
21
22
|
export interface EnqueueOptions {
|
|
22
23
|
/** Place this batch ahead of other not-yet-running batches. */
|
|
23
24
|
toFront?: boolean;
|
|
25
|
+
/** Execution receipts carried with this batch, independent of retained history. */
|
|
26
|
+
handoffs?: InboundHandoffs;
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
29
|
* Manages per-conversation sessions with:
|
|
@@ -33,6 +36,7 @@ export declare class SessionManager {
|
|
|
33
36
|
private sessions;
|
|
34
37
|
private queues;
|
|
35
38
|
private activeCount;
|
|
39
|
+
private destroyed;
|
|
36
40
|
private pending;
|
|
37
41
|
private processing;
|
|
38
42
|
private seenMessages;
|
package/dist/session-manager.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
|
|
1
2
|
const DEFAULT_CONTEXT_LIMIT = 50;
|
|
2
3
|
const DEFAULT_CONCURRENCY = 10;
|
|
3
4
|
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
@@ -13,6 +14,7 @@ export class SessionManager {
|
|
|
13
14
|
sessions = new Map();
|
|
14
15
|
queues = new Map();
|
|
15
16
|
activeCount = 0;
|
|
17
|
+
destroyed = false;
|
|
16
18
|
// Use Set<string> instead of Array for O(1) dedup and correct pop semantics (#1, #2)
|
|
17
19
|
pending = new Set();
|
|
18
20
|
processing = new Set();
|
|
@@ -96,6 +98,10 @@ export class SessionManager {
|
|
|
96
98
|
* - Messages across different sessions run concurrently up to the concurrency limit.
|
|
97
99
|
*/
|
|
98
100
|
async enqueue(conversationId, messages, handler, options = {}) {
|
|
101
|
+
if (this.destroyed) {
|
|
102
|
+
await disposeInboundHandoffs(options.handoffs ?? new Map(), 'release', 'session-manager-closed');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
99
105
|
const session = this.getSession(conversationId);
|
|
100
106
|
session.lastActiveAt = Date.now();
|
|
101
107
|
return new Promise((resolve, reject) => {
|
|
@@ -105,7 +111,7 @@ export class SessionManager {
|
|
|
105
111
|
queue = [];
|
|
106
112
|
this.queues.set(conversationId, queue);
|
|
107
113
|
}
|
|
108
|
-
const batch = { messages, handler, resolve, reject };
|
|
114
|
+
const batch = { messages, handler, resolve, reject, handoffs: options.handoffs ?? new Map() };
|
|
109
115
|
if (options.toFront) {
|
|
110
116
|
queue.unshift(batch);
|
|
111
117
|
}
|
|
@@ -118,7 +124,7 @@ export class SessionManager {
|
|
|
118
124
|
}
|
|
119
125
|
drainSession(conversationId) {
|
|
120
126
|
// Already processing this session — the current run will pick up queued items
|
|
121
|
-
if (this.processing.has(conversationId))
|
|
127
|
+
if (this.destroyed || this.processing.has(conversationId))
|
|
122
128
|
return;
|
|
123
129
|
const queue = this.queues.get(conversationId);
|
|
124
130
|
if (!queue || queue.length === 0)
|
|
@@ -137,7 +143,7 @@ export class SessionManager {
|
|
|
137
143
|
this.activeCount++;
|
|
138
144
|
this.processing.add(conversationId);
|
|
139
145
|
const session = this.appendMessagesToSession(conversationId, item.messages);
|
|
140
|
-
item.handler(session, item.messages)
|
|
146
|
+
item.handler(session, item.messages, item.handoffs)
|
|
141
147
|
.then(() => item.resolve())
|
|
142
148
|
.catch((err) => item.reject(err))
|
|
143
149
|
.finally(() => {
|
|
@@ -205,15 +211,17 @@ export class SessionManager {
|
|
|
205
211
|
for (const item of queue) {
|
|
206
212
|
const keptMessages = item.messages.filter((message) => {
|
|
207
213
|
const drop = message.id === messageId;
|
|
208
|
-
if (drop)
|
|
214
|
+
if (drop) {
|
|
209
215
|
droppedMessages.push(message);
|
|
216
|
+
void disposeInboundHandoffs(selectInboundHandoffs(item.handoffs, [message.id]), 'cancel', 'message-deleted');
|
|
217
|
+
}
|
|
210
218
|
return !drop;
|
|
211
219
|
});
|
|
212
220
|
if (keptMessages.length === 0) {
|
|
213
221
|
item.resolve();
|
|
214
222
|
}
|
|
215
223
|
else if (keptMessages.length !== item.messages.length) {
|
|
216
|
-
nextQueue.push({ ...item, messages: keptMessages });
|
|
224
|
+
nextQueue.push({ ...item, messages: keptMessages, handoffs: selectInboundHandoffs(item.handoffs, keptMessages.map((message) => message.id)) });
|
|
217
225
|
}
|
|
218
226
|
else {
|
|
219
227
|
nextQueue.push(item);
|
|
@@ -241,6 +249,7 @@ export class SessionManager {
|
|
|
241
249
|
const droppedMessages = queue.flatMap((item) => item.messages);
|
|
242
250
|
this.removeMessagesFromSession(conversationId, droppedMessages);
|
|
243
251
|
for (const item of queue) {
|
|
252
|
+
void disposeInboundHandoffs(item.handoffs, 'cancel', 'queue-dropped');
|
|
244
253
|
item.resolve();
|
|
245
254
|
}
|
|
246
255
|
return droppedMessages;
|
|
@@ -255,6 +264,13 @@ export class SessionManager {
|
|
|
255
264
|
}
|
|
256
265
|
/** Clean up all state */
|
|
257
266
|
destroy() {
|
|
267
|
+
this.destroyed = true;
|
|
268
|
+
for (const queue of this.queues.values()) {
|
|
269
|
+
for (const item of queue) {
|
|
270
|
+
void disposeInboundHandoffs(item.handoffs, 'release', 'session-manager-closed');
|
|
271
|
+
item.resolve();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
258
274
|
if (this.sweepTimer) {
|
|
259
275
|
clearInterval(this.sweepTimer);
|
|
260
276
|
this.sweepTimer = null;
|
|
@@ -265,6 +281,5 @@ export class SessionManager {
|
|
|
265
281
|
this.processing.clear();
|
|
266
282
|
this.seenMessages.clear();
|
|
267
283
|
this.seededSessions.clear();
|
|
268
|
-
this.activeCount = 0;
|
|
269
284
|
}
|
|
270
285
|
}
|
|
@@ -397,11 +397,22 @@ export function createCanonWorkSessionHost(options) {
|
|
|
397
397
|
}
|
|
398
398
|
async function renew() {
|
|
399
399
|
fence();
|
|
400
|
-
|
|
401
|
-
for
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
400
|
+
// Renewal only needs the bound audience. Never enumerate the account's
|
|
401
|
+
// unrelated rooms, and bound concurrent reads even for a busy host.
|
|
402
|
+
const boundRooms = [...rooms.values()];
|
|
403
|
+
for (let offset = 0; offset < boundRooms.length; offset += 8) {
|
|
404
|
+
fence();
|
|
405
|
+
const checked = await Promise.all(boundRooms.slice(offset, offset + 8).map(async (room) => ({
|
|
406
|
+
room, conversation: await client.getConversation(room.binding.conversationId),
|
|
407
|
+
})));
|
|
408
|
+
fence();
|
|
409
|
+
for (const { room, conversation } of checked) {
|
|
410
|
+
// A stream update may already have removed/replaced this attachment.
|
|
411
|
+
if (rooms.get(room.binding.conversationId) !== room)
|
|
412
|
+
continue;
|
|
413
|
+
if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
|
|
414
|
+
await removeRoom(room.binding.conversationId, true);
|
|
415
|
+
}
|
|
405
416
|
}
|
|
406
417
|
// Catalog has its own freshness deadline; it is refreshed independently of public presence.
|
|
407
418
|
const catalog = await options.provider.catalog();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "11.0.
|
|
3
|
+
"version": "11.0.2",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=22.22.3"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^13.0.
|
|
31
|
+
"@canonmsg/core": "^13.0.5"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|