@canonmsg/agent-sdk 7.1.1 → 7.1.3
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 +107 -14
- package/dist/canon-agent.js +107 -14
- package/dist/index.d.ts +1 -1
- package/dist/types.d.ts +21 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Build AI agents that participate in Canon conversations. Write message handlers, not infrastructure.
|
|
4
4
|
|
|
5
|
-
For Canon's shared delivery, provenance, group participation, and runtime-boundary principles, read the [Agent communication contract](https://canonmail.com/agents/communication-contract)
|
|
5
|
+
For Canon's shared delivery, provenance, group participation, and runtime-boundary principles, read the [Agent communication contract](https://canonmail.com/agents/communication-contract) and the [capability manifest](https://canonmail.com/agents/integration-capability-manifest).
|
|
6
6
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
|
+
```bash
|
|
10
|
+
export CANON_ENVIRONMENT_ID=canon-prod-v1 # or canon-dev-v1
|
|
11
|
+
export CANON_API_KEY=... # issued when your agent registration is approved
|
|
12
|
+
```
|
|
13
|
+
|
|
9
14
|
```typescript
|
|
10
15
|
import { CanonAgent } from '@canonmsg/agent-sdk';
|
|
11
16
|
|
|
@@ -29,7 +34,7 @@ await agent.start();
|
|
|
29
34
|
npm install @canonmsg/agent-sdk
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
The only runtime dependency is `@canonmsg/core`, which npm installs for you. Everything else is native `fetch` and `ReadableStream` (Node.js 18+).
|
|
33
38
|
|
|
34
39
|
## Configuration
|
|
35
40
|
|
|
@@ -48,7 +53,7 @@ No additional dependencies required — the SDK uses native `fetch` and `Readabl
|
|
|
48
53
|
| `sessions` | `SessionOptions` | `undefined` | Enable per-conversation session queues and persistent metadata |
|
|
49
54
|
| `clientType` | `AgentClientType` | `'generic'` | Agent runtime label used for Canon capability detection |
|
|
50
55
|
| `runtimeDescriptor` | `CanonRuntimeDescriptor` | minimal generic descriptor | Optional setup/live controls and runtime capability metadata for Canon UI |
|
|
51
|
-
| `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional
|
|
56
|
+
| `runtimeControls` | `RuntimeControlHandlers` | `undefined` | Optional `onInterrupt` / `onStopAndDrop` / `onNewSession` handlers for Canon working-state controls |
|
|
52
57
|
| `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
|
|
53
58
|
| `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
|
|
54
59
|
| `sessionState` | `boolean` | `false` | Publish runtime-applied state to the canonical agent-session snapshot |
|
|
@@ -143,19 +148,27 @@ Current rules of thumb:
|
|
|
143
148
|
- Publishing a descriptor does not automatically make your SDK agent enforce those controls. If you advertise model, workspace, execution mode, or runtime-native controls, your runtime must actually read and apply the stored config.
|
|
144
149
|
- Message handlers receive `ctx.provenance`, a Canon-computed sender/conversation context for the latest inbound message in the batch. Use it when your runtime wants owner-only tools, group mention policy, or self-context-aware behavior; Canon does not impose a sandbox on SDK agents.
|
|
145
150
|
|
|
146
|
-
|
|
151
|
+
### Runtime primitives
|
|
147
152
|
|
|
148
|
-
The SDK
|
|
153
|
+
The SDK publishes a fixed catalog of seven runtime commands Canon can dispatch as slash commands. Register handlers with the `runtimePrimitives` option or `agent.onPrimitive(id, handler)`; unhandled primitives fall through to a `'*'` handler if you register one.
|
|
149
154
|
|
|
150
|
-
|
|
155
|
+
| Primitive | Aliases |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `runtime.status` | `/status` |
|
|
158
|
+
| `runtime.reasoning.set` | `/think`, `/effort` |
|
|
159
|
+
| `runtime.verbosity.set` | `/verbose` |
|
|
160
|
+
| `runtime.usage` | `/usage` |
|
|
161
|
+
| `context.compact` | `/compact` |
|
|
162
|
+
| `session.new` | `/new` |
|
|
163
|
+
| `session.reset` | `/reset` |
|
|
151
164
|
|
|
152
|
-
|
|
165
|
+
`agent.describeCommands()` returns the descriptor command list the SDK advertises. `agent.publishRuntimeFacts(conversationId, facts)`, `agent.publishRuntimeActivity(conversationId, item)`, and `agent.clearRuntimeActivity(conversationId, options?)` push runtime status and margin activity into Canon's live surfaces.
|
|
153
166
|
|
|
154
|
-
|
|
167
|
+
## Delivery
|
|
155
168
|
|
|
156
|
-
|
|
169
|
+
The SDK receives messages over Canon's SSE stream service. `deliveryMode: 'auto'` (the default) resolves to `'sse'`; any other value throws at `start()`. There is no polling mode.
|
|
157
170
|
|
|
158
|
-
|
|
171
|
+
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 a stream error instead of silently pretending a partial catch-up is full replay.
|
|
159
172
|
|
|
160
173
|
## Message Handler
|
|
161
174
|
|
|
@@ -169,8 +182,8 @@ The `message` event handler receives a context object with:
|
|
|
169
182
|
| `conversationId` | `string` | The conversation these messages belong to |
|
|
170
183
|
| `conversation` | `CanonConversation` | Full conversation metadata |
|
|
171
184
|
| `groupContext` | `CanonGroupContext \| undefined` | Lightweight group awareness for group conversations |
|
|
172
|
-
| `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
|
|
173
|
-
| `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
|
|
185
|
+
| `replyFinal` | `(text: string, options?) => Promise<{ messageId: string; messageIds: string[] }>` | Send the durable final reply for a turn |
|
|
186
|
+
| `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 |
|
|
174
187
|
| `deleteMessage` | `(messageId: string) => Promise<void>` | Soft-delete a message sent by this agent |
|
|
175
188
|
| `markAsRead` | `() => Promise<void>` | Advance this agent's read cursor for the conversation |
|
|
176
189
|
| `leave` | `() => Promise<void>` | Leave the current group conversation |
|
|
@@ -183,9 +196,10 @@ The `message` event handler receives a context object with:
|
|
|
183
196
|
| `selfContexts` | `CanonSelfContext[] \| undefined` | Private context explaining this agent's cross-session actions |
|
|
184
197
|
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
185
198
|
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
|
|
186
|
-
| `
|
|
199
|
+
| `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
|
|
200
|
+
| `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 |
|
|
187
201
|
| `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
|
|
188
|
-
| `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card
|
|
202
|
+
| `requestCard` / `sendCard` | functions | Render a generic `canon.card.v1` rich card. `requestCard` blocks only on cards that carry an `actions` block; `sendCard` posts a display card |
|
|
189
203
|
| `media` | `{ materialize, uploadFile, replyWithFile }` | Canon-managed access to real media bytes via `~/.canon/media-cache` plus local-file uploads back into Canon |
|
|
190
204
|
| `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
|
|
191
205
|
| `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
|
|
@@ -195,6 +209,26 @@ Messages from the agent itself are automatically filtered out -- your handler on
|
|
|
195
209
|
|
|
196
210
|
`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. Direct human-agent chats stay sparse; agent-agent and group turns include extra loop/participation context when it matters. Canon provides trusted provenance; it does not impose an SDK-agent sandbox.
|
|
197
211
|
|
|
212
|
+
## Events
|
|
213
|
+
|
|
214
|
+
`agent.on(event, handler)` accepts eleven events. Each event holds a single handler; registering again replaces it.
|
|
215
|
+
|
|
216
|
+
| Event | Payload | Notes |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `message` | `MessageHandlerContext` | Debounced inbound batch for one conversation |
|
|
219
|
+
| `messageUpdated` | `MessageUpdatedPayload` | Reaction/status changes; not a new turn |
|
|
220
|
+
| `contactRequest` | `CanonContactRequest` | Awareness only — the owner still approves |
|
|
221
|
+
| `contactApproved` | `CanonContactRequest` | Awareness only |
|
|
222
|
+
| `contactAdded` | `ContactAddedPayload` | A contact edge now exists |
|
|
223
|
+
| `contactRemoved` | `ContactRemovedPayload` | A contact edge was removed |
|
|
224
|
+
| `interrupt` | `RuntimeSignalContext` | Same signal as `runtimeControls.onInterrupt` |
|
|
225
|
+
| `stopAndDrop` | `RuntimeSignalContext` | Same signal as `runtimeControls.onStopAndDrop` |
|
|
226
|
+
| `newSession` | `RuntimeSignalContext` | Same signal as `runtimeControls.onNewSession` |
|
|
227
|
+
| `callStarted` | `VoiceSessionEventPayload` | **Register before `start()`** |
|
|
228
|
+
| `callEnded` | `VoiceSessionEventPayload` | **Register before `start()`** |
|
|
229
|
+
|
|
230
|
+
The voice event family is negotiated with the stream at connect time from handler presence. `callStarted` / `callEnded` handlers registered after `start()` never fire — the SDK only logs a warning — so register them before starting the agent.
|
|
231
|
+
|
|
198
232
|
## Reaction Updates
|
|
199
233
|
|
|
200
234
|
Agents can use `ctx.react(messageId, emoji)` to toggle any valid emoji reaction on a message. Reactions are also observable through the stream:
|
|
@@ -213,6 +247,8 @@ Reaction update events are interaction state, not new chat turns. They do not ca
|
|
|
213
247
|
|
|
214
248
|
Use `ctx.requestRuntimeInput(...)` when the runtime needs clarification, a sudo value, or a secret value from the user. Use `ctx.requestApproval(...)` when the runtime needs an allow/deny decision before taking an action. Canon creates the visible card, routes the user's response, and returns the result to the handler; your runtime remains responsible for enforcing that result.
|
|
215
249
|
|
|
250
|
+
`requestApproval` is fail-closed and never throws: it returns `{ decision: 'deny' }` when no approval manager can be built (no resolved agent identity or owner) and on any non-abort error. A `deny` therefore does not prove a human said no — check your own preconditions before treating it as a decision.
|
|
251
|
+
|
|
216
252
|
Use `ctx.requestCard(...)` for generic rich reports and action forms. A `canon.card.v1` action may include small structured fields; Canon validates the selected action and declared field values, but your runtime still decides what to do with them:
|
|
217
253
|
|
|
218
254
|
```ts
|
|
@@ -236,6 +272,10 @@ const review = await ctx.requestCard({
|
|
|
236
272
|
});
|
|
237
273
|
```
|
|
238
274
|
|
|
275
|
+
`RuntimeCardResult.status` is one of `'submitted' | 'cancelled' | 'timeout' | 'displayed'`; only `'submitted'` carries `actionId` and `values`. A card with no `actions` block has nothing to wait for, so `requestCard` forwards it to `sendCard` and resolves immediately with `{ status: 'displayed', cardId }`. Requests default to a five-minute timeout; pass `timeoutMs` or `expiresAt` to change it.
|
|
276
|
+
|
|
277
|
+
The SDK does not validate card documents — it forwards `request.card` to Canon as-is, and a malformed card surfaces as a backend 400. Install [`@canonmsg/rich-cards`](https://www.npmjs.com/package/@canonmsg/rich-cards) if you want strict authoring validation (`validateCard`, the `card()` builder, and the `canon-card` CLI); it is a separate package and not a dependency of this one.
|
|
278
|
+
|
|
239
279
|
## Contact Request Awareness
|
|
240
280
|
|
|
241
281
|
Agents can also observe contact-request lifecycle events without becoming the approver:
|
|
@@ -284,6 +324,23 @@ When `sessions.enabled` is on, the SDK serializes work per conversation and expo
|
|
|
284
324
|
|
|
285
325
|
This is the easiest way to build agents that need per-conversation memory or queue awareness.
|
|
286
326
|
|
|
327
|
+
## Contacts, blocking, and conversation discovery
|
|
328
|
+
|
|
329
|
+
Three instance sub-APIs wrap the same REST surface a human user gets, so runtimes can expose them as tools:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
await agent.contacts.list(); // CanonContact[]
|
|
333
|
+
await agent.contacts.get(contactId); // CanonContact | null
|
|
334
|
+
await agent.contacts.remove(contactId);
|
|
335
|
+
await agent.contacts.request(targetUserId, 'why I am reaching out');
|
|
336
|
+
|
|
337
|
+
await agent.users.block(userId);
|
|
338
|
+
await agent.users.unblock(userId);
|
|
339
|
+
|
|
340
|
+
await agent.conversations.list(); // all conversations
|
|
341
|
+
await agent.conversations.list({ targetUserId }); // only those the target is a member of
|
|
342
|
+
```
|
|
343
|
+
|
|
287
344
|
## Media
|
|
288
345
|
|
|
289
346
|
Normalized Canon messages always expose `attachments[]` as the single canonical media contract. Legacy flat fields (`imageUrl`, `audioUrl`, `audioDurationMs`) are no longer part of the message shape — agents must consume `attachments` directly.
|
|
@@ -309,6 +366,30 @@ The public helpers are also available from the Node-only subpath export:
|
|
|
309
366
|
import { materializeMessageMedia, uploadMediaFile } from '@canonmsg/agent-sdk/media';
|
|
310
367
|
```
|
|
311
368
|
|
|
369
|
+
## Calls
|
|
370
|
+
|
|
371
|
+
Agents can start, join, decline, and end Canon audio/video calls. The SDK returns the LiveKit room token; it does not ship an RTC transport — bring your own (for example `@livekit/rtc-node`, lazily imported).
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
agent.on('callStarted', async ({ conversationId, session, targetsMe }) => {
|
|
375
|
+
if (targetsMe === false) return; // group/human-mode calls arrive here too; absent means targeted
|
|
376
|
+
const { url, token, roomName } = await agent.joinCall(conversationId, session.id);
|
|
377
|
+
await connectMyRtcClient(url, token, roomName);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
await agent.start();
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
| Method | Description |
|
|
384
|
+
|---|---|
|
|
385
|
+
| `startCall({ conversationId, media?, targetAgentId? })` | Start or rejoin a call; `media` is `'audio'` (server default) or `'video'` |
|
|
386
|
+
| `joinCall(conversationId, sessionId)` | Join an active session and get the room token |
|
|
387
|
+
| `declineCall(conversationId, sessionId)` | Stop this agent's ring only |
|
|
388
|
+
| `endCall(conversationId, sessionId)` | End the session for everyone |
|
|
389
|
+
| `getCallState(conversationId, sessionId)` | Current `CanonVoiceSession` state |
|
|
390
|
+
|
|
391
|
+
Register `callStarted` / `callEnded` before `start()` — see [Events](#events).
|
|
392
|
+
|
|
312
393
|
## Agent Registration
|
|
313
394
|
|
|
314
395
|
Register a new agent using the static helpers (no API key needed):
|
|
@@ -395,3 +476,15 @@ While a handler runs, the SDK automatically publishes Canon turn state and clear
|
|
|
395
476
|
`setWaitingInput()` keeps the turn open in `waiting_input` and optionally sends a control message to the conversation so Canon clients can render “reply to continue” correctly.
|
|
396
477
|
|
|
397
478
|
`replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
|
|
479
|
+
|
|
480
|
+
### Long text
|
|
481
|
+
|
|
482
|
+
Canon caps a single message at 4 KB of UTF-8 text, and rejects anything longer outright. Two send paths split oversized text for you instead of failing:
|
|
483
|
+
|
|
484
|
+
- `replyFinal()` — delivered as ordered parts. Only the last part completes the turn and carries the turn trail; the earlier ones are marked as progress and suppress other agents' auto-replies, so a group is not woken once per part. Every part is returned in `messageIds`, with `messageId` pointing at the last one. A reply that fits is a single message with an untouched id and metadata.
|
|
485
|
+
- `replyProgress(text, { durable: true })` — split into ordered progress messages, which Canon clients still fold into the turn trail once the turn ends, exactly like a short update. `messageIds` lists them all.
|
|
486
|
+
|
|
487
|
+
Every other send path passes your text through as-is, so text over the cap still fails there. Notably:
|
|
488
|
+
|
|
489
|
+
- `media.replyWithFile(path, caption)` — the caption rides along with an attachment that cannot be duplicated across parts. Keep captions short and send long prose as a separate `replyFinal()`.
|
|
490
|
+
- `sendContextualMessage()` and `reachOut({ text })` — a different endpoint with no chunked sender behind it.
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection,
|
|
1
|
+
import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { AuthManager } from './auth.js';
|
|
4
4
|
import { Debouncer } from './debouncer.js';
|
|
@@ -12,6 +12,9 @@ const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
|
|
|
12
12
|
const DEFAULT_RUNTIME_INPUT_TIMEOUT_MS = 5 * 60_000;
|
|
13
13
|
const RUNTIME_INPUT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,160}$/;
|
|
14
14
|
const SDK_MESSAGE_ID_READABLE_MAX = 120;
|
|
15
|
+
/** Canon's message id ceiling, matching core's chunked sender. */
|
|
16
|
+
const CANON_MESSAGE_ID_MAX = 160;
|
|
17
|
+
const SDK_PARTIAL_FINAL_NOTICE = 'This reply stops short because Canon could not deliver the remaining text.';
|
|
15
18
|
const SDK_RUNTIME_CAPABILITIES = {
|
|
16
19
|
supportsInterrupt: false,
|
|
17
20
|
supportsInputInterrupt: false,
|
|
@@ -170,6 +173,18 @@ function safeRuntimeCardId(value) {
|
|
|
170
173
|
function normalizeResponseUserId(value) {
|
|
171
174
|
return value?.trim() || undefined;
|
|
172
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Part id for text this sdk split itself, mirroring the `-part-N` rule (and the
|
|
178
|
+
* 160-character id cap) that core's chunked sender applies to a split final, so
|
|
179
|
+
* a caller-supplied id yields the same shape whichever path the text took.
|
|
180
|
+
* Undefined base id in, undefined out: the server then names the parts.
|
|
181
|
+
*/
|
|
182
|
+
function chunkPartMessageId(baseMessageId, part) {
|
|
183
|
+
if (!baseMessageId)
|
|
184
|
+
return undefined;
|
|
185
|
+
const suffix = `-part-${part}`;
|
|
186
|
+
return `${baseMessageId.slice(0, Math.max(0, CANON_MESSAGE_ID_MAX - suffix.length))}${suffix}`;
|
|
187
|
+
}
|
|
173
188
|
function buildSdkMessageId(parts) {
|
|
174
189
|
const raw = parts
|
|
175
190
|
.map((part) => part == null ? '' : String(part))
|
|
@@ -1346,7 +1361,7 @@ export class CanonAgent {
|
|
|
1346
1361
|
throwIfAborted();
|
|
1347
1362
|
const sendOptions = withActiveSelfContext(options);
|
|
1348
1363
|
const turnTrail = turnOutput.getFinalTrail();
|
|
1349
|
-
const
|
|
1364
|
+
const finalOptions = {
|
|
1350
1365
|
...sendOptions,
|
|
1351
1366
|
metadata: {
|
|
1352
1367
|
...(sendOptions.metadata ?? {}),
|
|
@@ -1354,7 +1369,31 @@ export class CanonAgent {
|
|
|
1354
1369
|
turnSemantics: 'turn_complete',
|
|
1355
1370
|
...(turnTrail.length > 0 ? { turnTrail } : {}),
|
|
1356
1371
|
},
|
|
1357
|
-
}
|
|
1372
|
+
};
|
|
1373
|
+
let result;
|
|
1374
|
+
try {
|
|
1375
|
+
result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId]);
|
|
1376
|
+
}
|
|
1377
|
+
catch (error) {
|
|
1378
|
+
const chunked = isChunkedSendMessageError(error) ? error : null;
|
|
1379
|
+
if (!chunked || chunked.deliveredMessageIds.length === 0 || isAbortLikeError(error)) {
|
|
1380
|
+
throw error;
|
|
1381
|
+
}
|
|
1382
|
+
const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
|
|
1383
|
+
const notice = await sendDurableMessage(SDK_PARTIAL_FINAL_NOTICE, {
|
|
1384
|
+
...sendOptions,
|
|
1385
|
+
messageId: buildSdkMessageId(['sdk', 'final-incomplete', conversationId, turnId]),
|
|
1386
|
+
metadata: {
|
|
1387
|
+
turnId,
|
|
1388
|
+
turnSemantics: 'turn_complete',
|
|
1389
|
+
...(requestedReplyBehavior ? { replyBehavior: requestedReplyBehavior } : {}),
|
|
1390
|
+
},
|
|
1391
|
+
}, ['sdk', 'final-incomplete', conversationId, turnId]);
|
|
1392
|
+
result = {
|
|
1393
|
+
messageId: notice.messageId,
|
|
1394
|
+
messageIds: [...chunked.deliveredMessageIds, ...notice.messageIds],
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1358
1397
|
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
1359
1398
|
try {
|
|
1360
1399
|
await this.typingSignals.clear(conversationId);
|
|
@@ -1371,15 +1410,40 @@ export class CanonAgent {
|
|
|
1371
1410
|
throwIfAborted();
|
|
1372
1411
|
const { durable: _durable, ...sendOptions } = options;
|
|
1373
1412
|
const sendOptionsWithContext = withActiveSelfContext(sendOptions);
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1413
|
+
// Progress text is caller-controlled too, so it can also blow past
|
|
1414
|
+
// Canon's 4 KB cap. It is split here rather than through core's chunked
|
|
1415
|
+
// sender on purpose: chunk parts carry a `messageChunk` marker, and
|
|
1416
|
+
// Canon clients read a progress message that has one as durable answer
|
|
1417
|
+
// content (chat-domain's `isEphemeralProgressMessage`). Routing progress
|
|
1418
|
+
// through the chunked sender would therefore leave an oversized status
|
|
1419
|
+
// update in the timeline forever, while the same update under the cap is
|
|
1420
|
+
// folded into the turn trail once the turn ends. Plain progress parts
|
|
1421
|
+
// keep long and short updates behaving identically, and each part stays
|
|
1422
|
+
// a single unretried send, exactly as one progress update always was.
|
|
1423
|
+
const parts = splitTextByUtf8Bytes(text);
|
|
1424
|
+
const messageIds = [];
|
|
1425
|
+
for (const [index, part] of parts.entries()) {
|
|
1426
|
+
throwIfAborted();
|
|
1427
|
+
const partMessageId = parts.length > 1
|
|
1428
|
+
? chunkPartMessageId(sendOptionsWithContext.messageId, index + 1)
|
|
1429
|
+
: sendOptionsWithContext.messageId;
|
|
1430
|
+
const result = await this.apiClient.sendMessage(conversationId, part, {
|
|
1431
|
+
...sendOptionsWithContext,
|
|
1432
|
+
...(partMessageId !== undefined ? { messageId: partMessageId } : {}),
|
|
1433
|
+
metadata: {
|
|
1434
|
+
...(sendOptionsWithContext.metadata ?? {}),
|
|
1435
|
+
turnId,
|
|
1436
|
+
turnSemantics: 'progress',
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
messageIds.push(result.messageId);
|
|
1440
|
+
}
|
|
1441
|
+
return {
|
|
1442
|
+
turnId,
|
|
1443
|
+
durable: true,
|
|
1444
|
+
messageId: messageIds[messageIds.length - 1],
|
|
1445
|
+
messageIds,
|
|
1446
|
+
};
|
|
1383
1447
|
};
|
|
1384
1448
|
// Enrich history messages with isOwner
|
|
1385
1449
|
if (this.agentContext?.ownerId) {
|
|
@@ -1407,15 +1471,44 @@ export class CanonAgent {
|
|
|
1407
1471
|
return base;
|
|
1408
1472
|
return activeSelfContextId ? { ...base, selfContextId: activeSelfContextId } : base;
|
|
1409
1473
|
};
|
|
1410
|
-
|
|
1474
|
+
// Core's chunked sender walks the parts in a plain loop and knows nothing
|
|
1475
|
+
// about this turn's abort signal, so a stop landing after part 1 would
|
|
1476
|
+
// otherwise let the rest of the answer post anyway — including the last
|
|
1477
|
+
// part, which completes the turn. Sending every part through a client that
|
|
1478
|
+
// re-checks the signal first is the sdk-side guard for that; the `sleep`
|
|
1479
|
+
// override below only covers a retry backoff.
|
|
1480
|
+
const abortAwareClient = Object.create(this.apiClient, {
|
|
1481
|
+
sendMessage: {
|
|
1482
|
+
value: (targetConversationId, targetText, targetOptions) => {
|
|
1483
|
+
throwIfAborted();
|
|
1484
|
+
return this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
});
|
|
1488
|
+
// Canon rejects any message whose text is over 4 KB of UTF-8 with a 400,
|
|
1489
|
+
// which no retry can fix — an oversized reply used to throw out of the
|
|
1490
|
+
// handler with nothing durable written, so a long answer just vanished.
|
|
1491
|
+
// Core's chunked sender splits such a reply into ordered `-part-N`
|
|
1492
|
+
// messages instead, completing the turn on the last part only. A reply
|
|
1493
|
+
// that FITS still goes out as a single message under the plain id with
|
|
1494
|
+
// untouched metadata, so the common case (and the interim→final handoff
|
|
1495
|
+
// that keys on that id) is unchanged.
|
|
1496
|
+
const sendDurableMessage = async (text, options, fallbackMessageIdParts) => {
|
|
1411
1497
|
const messageId = options?.messageId
|
|
1412
1498
|
?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence += 1]);
|
|
1413
|
-
|
|
1499
|
+
const { messageIds } = await sendMessageWithRetryChunked(abortAwareClient, conversationId, text, {
|
|
1414
1500
|
...(options ?? {}),
|
|
1415
1501
|
messageId,
|
|
1416
1502
|
}, {
|
|
1417
1503
|
sleep: (ms) => sleepWithAbort(ms, abortController.signal),
|
|
1504
|
+
}, {
|
|
1505
|
+
resumable: true,
|
|
1418
1506
|
});
|
|
1507
|
+
// `messageId` stays the single id callers expect. When the text was
|
|
1508
|
+
// chunked it points at the LAST part — the message that carries
|
|
1509
|
+
// turn_complete and the turn trail, so replies and reactions thread off
|
|
1510
|
+
// the end of the answer, not its head. `messageIds` has the full set.
|
|
1511
|
+
return { messageId: messageIds[messageIds.length - 1], messageIds };
|
|
1419
1512
|
};
|
|
1420
1513
|
// Build agent context (fallback to minimal if not yet received)
|
|
1421
1514
|
const agent = this.agentContext ?? {
|
package/dist/index.d.ts
CHANGED
|
@@ -7,4 +7,4 @@ export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, infe
|
|
|
7
7
|
export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
|
|
8
8
|
export type { SessionConfig, Session } from './session-manager.js';
|
|
9
9
|
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, } from '@canonmsg/core';
|
|
10
|
-
export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
|
10
|
+
export type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, FinalMessageResult, MessageHandler, MessageHandlerContext, MessageUpdatedHandler, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimeApprovalRequest, RuntimeInputRequest, RuntimeInputResult, RuntimeControlSurface, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -8,6 +8,19 @@ export interface ProgressMessageOptions extends SendMessageOptions {
|
|
|
8
8
|
*/
|
|
9
9
|
durable?: boolean;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Result of a durable reply.
|
|
13
|
+
*
|
|
14
|
+
* Text over Canon's 4 KB (UTF-8) message limit is delivered as ordered chunks
|
|
15
|
+
* rather than refused, so one `replyFinal` can land as more than one message.
|
|
16
|
+
* `messageId` is the last one — the message carrying `turn_complete` and the
|
|
17
|
+
* turn trail — and `messageIds` lists every part in order (a single entry when
|
|
18
|
+
* the text fit, which is the common case).
|
|
19
|
+
*/
|
|
20
|
+
export interface FinalMessageResult {
|
|
21
|
+
messageId: string;
|
|
22
|
+
messageIds: string[];
|
|
23
|
+
}
|
|
11
24
|
export type ProgressMessageResult = {
|
|
12
25
|
turnId: string;
|
|
13
26
|
durable: false;
|
|
@@ -16,6 +29,13 @@ export type ProgressMessageResult = {
|
|
|
16
29
|
turnId: string;
|
|
17
30
|
durable: true;
|
|
18
31
|
messageId: string;
|
|
32
|
+
/**
|
|
33
|
+
* Every message the update landed as, in order. A durable update over
|
|
34
|
+
* Canon's 4 KB (UTF-8) limit is split into ordered parts rather than
|
|
35
|
+
* refused; `messageId` is the last of them. One entry when the text fit,
|
|
36
|
+
* which is the common case.
|
|
37
|
+
*/
|
|
38
|
+
messageIds: string[];
|
|
19
39
|
};
|
|
20
40
|
export interface SessionInfo {
|
|
21
41
|
/** Session ID (= conversationId) */
|
|
@@ -119,9 +139,7 @@ export interface MessageHandlerContext {
|
|
|
119
139
|
conversation: CanonConversation;
|
|
120
140
|
/** Lightweight group awareness, present for group conversations. */
|
|
121
141
|
groupContext?: CanonGroupContext;
|
|
122
|
-
replyFinal: (text: string, options?: SendMessageOptions) => Promise<
|
|
123
|
-
messageId: string;
|
|
124
|
-
}>;
|
|
142
|
+
replyFinal: (text: string, options?: SendMessageOptions) => Promise<FinalMessageResult>;
|
|
125
143
|
replyProgress: (text: string, options?: ProgressMessageOptions) => Promise<ProgressMessageResult>;
|
|
126
144
|
/** Soft-delete a message (agent must be the sender) */
|
|
127
145
|
deleteMessage: (messageId: string) => Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.3",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^8.
|
|
31
|
+
"@canonmsg/core": "^8.2.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|