@canonmsg/agent-sdk 8.0.0 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/canon-agent.d.ts +10 -1
- package/dist/canon-agent.js +123 -28
- package/dist/index.d.ts +2 -1
- package/dist/turn-streaming-request.d.ts +17 -0
- package/dist/turn-streaming-request.js +22 -0
- package/dist/turn-verbosity-option.d.ts +33 -0
- package/dist/turn-verbosity-option.js +23 -0
- package/dist/types.d.ts +34 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -57,6 +57,7 @@ The only runtime dependency is `@canonmsg/core`, which npm installs for you. Eve
|
|
|
57
57
|
| `runtimeControlSurface` | `'agent' \| 'host'` | `'agent'` | Runtime publishing surface. Use `host` when this SDK agent owns live runtime controls. |
|
|
58
58
|
| `runtimePrimitives` | `RuntimePrimitiveHandlers` | `undefined` | Optional typed primitive command handlers for descriptor-backed runtime commands |
|
|
59
59
|
| `sessionState` | `boolean` | `false` | Publish runtime-applied state to the canonical agent-session snapshot |
|
|
60
|
+
| `turnVerbosity` | `'verbose' \| 'quiet' \| 'auto'` or `{ direct?, group? }` | `'auto'` | How much of a turn's middle readers see. See [Turn verbosity](#turn-verbosity). |
|
|
60
61
|
|
|
61
62
|
### Optional runtime controls
|
|
62
63
|
|
|
@@ -197,6 +198,7 @@ The `message` event handler receives a context object with:
|
|
|
197
198
|
| `provenance` | `CanonRuntimeProvenance` | Canon-computed sender/conversation context for the latest inbound message in this batch |
|
|
198
199
|
| `turnContext` | `CanonTurnContextV2` | Compact structured turn context; fields are intentionally shaped by conversation type and sender type |
|
|
199
200
|
| `requestedTurnMode` | `string \| null` | Runtime turn mode the sender requested for this inbound turn, if any |
|
|
201
|
+
| `turnVerbosity` | `'verbose' \| 'quiet'` | Resolved emission mode for this turn — see [Turn verbosity](#turn-verbosity). Fixed for the whole turn |
|
|
200
202
|
| `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 |
|
|
201
203
|
| `requestRuntimeInput` | `(request) => Promise<RuntimeInputResult>` | Render a Canon input card for clarification, sudo, or secret values |
|
|
202
204
|
| `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 |
|
|
@@ -472,11 +474,55 @@ While a handler runs, the SDK automatically publishes Canon turn state and clear
|
|
|
472
474
|
- `setStreaming(text)`
|
|
473
475
|
- `setTool(text)`
|
|
474
476
|
- `setWaitingInput(text?)`
|
|
477
|
+
- `noReply(reason?)`
|
|
478
|
+
|
|
479
|
+
`noReply()` is Canon's `no_reply` verb on the SDK side: end this turn without posting anything. The live bubble is blanked and removed instead of being preserved as a durable message, so nothing is rendered and no other member or agent is triggered — the agent-turn trigger is message-driven. Use it in groups when the handler decides it has nothing to add. `reason` is private: the SDK records that one was given, never the text.
|
|
480
|
+
|
|
481
|
+
It governs teardown only. Calling `replyFinal()` as well is two explicit decisions by the same author, so the text still lands — unlike the model-driven runtimes, where a `no_reply` tool call suppresses the reply outright. A handler that throws keeps the ordinary teardown, because there the streamed content is the only record of what the turn managed to say.
|
|
475
482
|
|
|
476
483
|
`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.
|
|
477
484
|
|
|
478
485
|
`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
486
|
|
|
487
|
+
## Turn verbosity
|
|
488
|
+
|
|
489
|
+
By default an agent is **quiet in group conversations and verbose in direct chats**. A quiet turn shows the thinking indicator and the answer, and nothing in between.
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
const agent = new CanonAgent({
|
|
493
|
+
apiKey: process.env.CANON_API_KEY!,
|
|
494
|
+
environmentId: 'canon-prod-v1',
|
|
495
|
+
turnVerbosity: 'verbose', // scalar: applies everywhere
|
|
496
|
+
// turnVerbosity: { group: 'verbose' }, // object: override one conversation type
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
| Value | Effect |
|
|
501
|
+
|---|---|
|
|
502
|
+
| `'auto'` (default, same as omitting the option) | Verbose in direct chats, quiet in groups |
|
|
503
|
+
| `'verbose'` | Live turn state and the margin activity trail, everywhere |
|
|
504
|
+
| `'quiet'` | Thinking indicator and the final message only, everywhere |
|
|
505
|
+
| `{ direct?, group? }` | Overrides the named conversation type; the unnamed one keeps its default |
|
|
506
|
+
|
|
507
|
+
A conversation whose type Canon could not determine falls back to verbose, never to silence.
|
|
508
|
+
|
|
509
|
+
**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.
|
|
510
|
+
|
|
511
|
+
**Quiet does not suppress**: the typing/thinking indicator (which stays up for the turn's whole working phase; while the turn is parked on an approval the clients suppress an agent's dots and the header line carries the state), turn state, `replyFinal()` including every part of a chunked reply, the partial-final notice, `media.replyWithFile()` itself, `turn.setWaitingInput()`'s note, `sendContextualMessage()`, `publishRuntimeActivity()`, approval/input/card requests, and their outcome receipts.
|
|
512
|
+
|
|
513
|
+
**`replyProgress(text, { durable: true })` still posts.** Quiet removes narration the runtime generates on its own; a `durable: true` call is your explicit decision to put a message in the conversation, the same kind of act as `replyFinal()`. Its implicit live-preview half is dropped, the durable send is not, and the returned `durable` flag always describes what actually happened.
|
|
514
|
+
|
|
515
|
+
`ctx.turnVerbosity` carries the resolved value into the handler, so a handler that would otherwise build an expensive live preview can skip it:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
agent.on('message', async (ctx) => {
|
|
519
|
+
if (ctx.turnVerbosity === 'verbose') await ctx.turn.setThinking('Reading the repo…');
|
|
520
|
+
await ctx.replyFinal(await answer(ctx));
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
This is a developer setting. Canon never changes it, and users cannot set it per conversation.
|
|
525
|
+
|
|
480
526
|
### Long text
|
|
481
527
|
|
|
482
528
|
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:
|
package/dist/canon-agent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AddMemberResult, type CanonContact, type CanonConversation, type CreateConversationResult, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ContactCardPayload, type ClearRuntimeActivityOptions, type CreateContactRequestResult, type CanonVoiceSession, type CanonVoiceSessionToken, type CreateVoiceSessionOptions, type VoiceSessionEventPayload } from '@canonmsg/core';
|
|
1
|
+
import { type AddMemberResult, type CanonContact, type CanonConversation, type CanonConversationsPage, type CanonConversationsPageOptions, type CreateConversationResult, type CanonRuntimeActivityItem, type CanonRuntimeCommandDescriptor, type CanonRuntimeFact, type CanonRuntimePrimitiveId, type ContactCardPayload, type ClearRuntimeActivityOptions, type CreateContactRequestResult, type CanonVoiceSession, type CanonVoiceSessionToken, type CreateVoiceSessionOptions, type VoiceSessionEventPayload } from '@canonmsg/core';
|
|
2
2
|
import type { CanonAgentConnectionOptions, CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, MessageUpdatedHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler, RuntimeSignalHandler, RuntimePrimitiveHandler } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Contact-graph operations exposed under `agent.contacts`. Wraps the REST
|
|
@@ -22,6 +22,15 @@ export interface AgentConversationsAPI {
|
|
|
22
22
|
list(options?: {
|
|
23
23
|
targetUserId?: string;
|
|
24
24
|
}): Promise<CanonConversation[]>;
|
|
25
|
+
/**
|
|
26
|
+
* One page of conversations plus the cursor to continue from, for agents
|
|
27
|
+
* with enough conversations that fetching all of them on every poll is
|
|
28
|
+
* wasteful. Continue with `before: page.nextBefore` until it is null.
|
|
29
|
+
*
|
|
30
|
+
* Kept separate from `list` on purpose: `list` filters by `targetUserId`
|
|
31
|
+
* over the complete set, which cannot be honored a page at a time.
|
|
32
|
+
*/
|
|
33
|
+
page(options?: CanonConversationsPageOptions): Promise<CanonConversationsPage>;
|
|
25
34
|
}
|
|
26
35
|
export declare class CanonAgent {
|
|
27
36
|
private options;
|
package/dist/canon-agent.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
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, normalizeTurnVerbosityConversationType, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, 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';
|
|
5
5
|
import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
|
|
6
6
|
import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
|
|
7
7
|
import { SessionManager } from './session-manager.js';
|
|
8
|
+
import { buildTurnStreamingRequest } from './turn-streaming-request.js';
|
|
9
|
+
import { selectConfiguredTurnVerbosity } from './turn-verbosity-option.js';
|
|
8
10
|
const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
|
|
9
11
|
const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
|
|
10
12
|
const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
@@ -345,6 +347,7 @@ export class CanonAgent {
|
|
|
345
347
|
return conversations;
|
|
346
348
|
return conversations.filter((conversation) => conversation.memberIds.includes(options.targetUserId));
|
|
347
349
|
},
|
|
350
|
+
page: (options) => apiClient.getConversationsPage(options),
|
|
348
351
|
};
|
|
349
352
|
if (options.sessions?.enabled) {
|
|
350
353
|
this.sessionManager = new SessionManager({
|
|
@@ -1245,6 +1248,14 @@ export class CanonAgent {
|
|
|
1245
1248
|
const turnOpenedAt = Date.now();
|
|
1246
1249
|
let turnState = 'thinking';
|
|
1247
1250
|
let shouldPersistTurnState = false;
|
|
1251
|
+
// `ctx.turn.noReply()`: the handler chose to end this turn without posting.
|
|
1252
|
+
// Governs TEARDOWN only — `replyFinal` still sends, because in the SDK the
|
|
1253
|
+
// developer's two explicit calls are two explicit decisions.
|
|
1254
|
+
let deliberatelySilent = false;
|
|
1255
|
+
// Set when the handler threw or the turn was aborted. Deliberate silence
|
|
1256
|
+
// must never blank the live node on those paths: the streamed content is
|
|
1257
|
+
// all that survives a turn that died mid-flight.
|
|
1258
|
+
let turnEndedAbnormally = false;
|
|
1248
1259
|
let durableMessageSequence = 0;
|
|
1249
1260
|
const agentId = this.agentId;
|
|
1250
1261
|
const runtimeState = this.createRuntimeStatePublisher();
|
|
@@ -1286,17 +1297,32 @@ export class CanonAgent {
|
|
|
1286
1297
|
: {}),
|
|
1287
1298
|
})).catch(() => { });
|
|
1288
1299
|
};
|
|
1300
|
+
// Quiet mode is resolved HERE, once, and carried for the whole turn.
|
|
1301
|
+
//
|
|
1302
|
+
// It has to be here because everything downstream is already too late: the
|
|
1303
|
+
// controller's mode is fixed at construction two lines below, the
|
|
1304
|
+
// `'Thinking...'` seed lands before that, and the conversation fetch that
|
|
1305
|
+
// would otherwise name the conversation type is ~30 lines further down,
|
|
1306
|
+
// inside the try. Provenance already carries the type — the stream service
|
|
1307
|
+
// stamps it on every `message.created` frame — so the answer is available
|
|
1308
|
+
// before the first publish of any kind.
|
|
1309
|
+
//
|
|
1310
|
+
// Re-deriving it mid-turn is what must never happen: a turn that started
|
|
1311
|
+
// quiet and finished verbose would emit a trail nothing narrated.
|
|
1312
|
+
const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(messages[messages.length - 1]?.id ?? '')?.conversation?.type);
|
|
1313
|
+
const turnVerbosity = resolveTurnVerbosity({
|
|
1314
|
+
configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
|
|
1315
|
+
conversationType: inboundConversationType,
|
|
1316
|
+
});
|
|
1289
1317
|
const turnOutput = createTurnOutputController({
|
|
1290
1318
|
turnId,
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
blocks: snapshot.blocks,
|
|
1299
|
-
}),
|
|
1319
|
+
// `'status'` keeps every controller call site working and drops only the
|
|
1320
|
+
// RTDB write, which covers all ~12 of them at once — the thinking seed
|
|
1321
|
+
// and its keepalive, `turn.appendDelta`/`appendBlock`/`setTool`, the
|
|
1322
|
+
// approval block, and `replyProgress`'s implicit live half. Blocks still
|
|
1323
|
+
// accumulate in memory, so the trail gate below is a SEPARATE decision.
|
|
1324
|
+
mode: turnVerbosity === 'quiet' ? 'status' : 'snapshot',
|
|
1325
|
+
writeSnapshot: (snapshot) => this.apiClient.setStreaming(buildTurnStreamingRequest({ conversationId, snapshot })),
|
|
1300
1326
|
clearSnapshot: () => this.apiClient.clearStreaming(conversationId),
|
|
1301
1327
|
});
|
|
1302
1328
|
const setLiveState = async (state, text, streamingStatus) => {
|
|
@@ -1306,6 +1332,36 @@ export class CanonAgent {
|
|
|
1306
1332
|
await turnOutput.setStatus(streamingStatus, text ?? '');
|
|
1307
1333
|
}
|
|
1308
1334
|
};
|
|
1335
|
+
/**
|
|
1336
|
+
* Put the turn back to work after an interaction request settles.
|
|
1337
|
+
*
|
|
1338
|
+
* Both halves matter: the dots were cleared when the turn parked (the
|
|
1339
|
+
* clients suppress an agent's dots on `waiting_input`), and `/turn-state`
|
|
1340
|
+
* plus the live node are still sitting on `waiting_input`. The error paths
|
|
1341
|
+
* need this as much as the success paths — a request that failed still
|
|
1342
|
+
* leaves the handler running, and a quiet turn has no live row to stand in
|
|
1343
|
+
* for the missing dots. Aborts still propagate: `setLiveState` re-checks
|
|
1344
|
+
* the signal, which is how an interrupt lands during cleanup.
|
|
1345
|
+
*/
|
|
1346
|
+
const resumeTurnFromWaiting = async () => {
|
|
1347
|
+
try {
|
|
1348
|
+
await this.typingSignals.start(conversationId, 'thinking');
|
|
1349
|
+
}
|
|
1350
|
+
catch { }
|
|
1351
|
+
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1352
|
+
};
|
|
1353
|
+
if (turnVerbosity === 'quiet') {
|
|
1354
|
+
// A quiet turn never writes to `/streaming`, so it cannot rely on the
|
|
1355
|
+
// 'Thinking...' seed below to overwrite a node an earlier verbose turn
|
|
1356
|
+
// left behind — and a surviving node under the PREVIOUS turn's id would
|
|
1357
|
+
// sit there, invisible, for this whole turn, then be deleted at teardown:
|
|
1358
|
+
// `onStreamingCleared` would salvage it into a durable bubble landing
|
|
1359
|
+
// AFTER this turn's answer. Deleting at turn open puts that salvage back
|
|
1360
|
+
// where the design intends it, ahead of the answer. Both coding hosts do
|
|
1361
|
+
// the same, one for free (its seed clears unconditionally) and one
|
|
1362
|
+
// explicitly.
|
|
1363
|
+
await turnOutput.clear().catch(() => { });
|
|
1364
|
+
}
|
|
1309
1365
|
// Show thinking indicator and keep it alive (5s client-side expiry)
|
|
1310
1366
|
try {
|
|
1311
1367
|
await this.typingSignals.start(conversationId, 'thinking');
|
|
@@ -1352,7 +1408,11 @@ export class CanonAgent {
|
|
|
1352
1408
|
catch { }
|
|
1353
1409
|
throwIfAborted();
|
|
1354
1410
|
const sendOptions = withActiveSelfContext(options);
|
|
1355
|
-
|
|
1411
|
+
// Separate from the controller's mode on purpose: `'status'` silences
|
|
1412
|
+
// the live writes but still accumulates blocks, so `getFinalTrail()`
|
|
1413
|
+
// would happily hand back a full trail for a turn that narrated
|
|
1414
|
+
// nothing.
|
|
1415
|
+
const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
|
|
1356
1416
|
const finalOptions = {
|
|
1357
1417
|
...sendOptions,
|
|
1358
1418
|
metadata: {
|
|
@@ -1395,6 +1455,14 @@ export class CanonAgent {
|
|
|
1395
1455
|
};
|
|
1396
1456
|
const replyProgress = async (text, options) => {
|
|
1397
1457
|
throwIfAborted();
|
|
1458
|
+
// Owner ruling D1: in quiet the IMPLICIT half of this call — the live
|
|
1459
|
+
// `/streaming` narration — is dropped by the controller's `'status'`
|
|
1460
|
+
// mode, while the turn state it also publishes stays. The EXPLICIT half
|
|
1461
|
+
// below does not: `durable: true` is a developer asking Canon to post a
|
|
1462
|
+
// message, structurally the same act as `replyFinal`, and silently
|
|
1463
|
+
// no-op'ing it would leave a caller reading `durable: true` from a send
|
|
1464
|
+
// that never happened. The result object therefore keeps describing
|
|
1465
|
+
// what actually occurred in both modes.
|
|
1398
1466
|
await setLiveState('streaming', text, 'streaming');
|
|
1399
1467
|
if (!options?.durable) {
|
|
1400
1468
|
return { turnId, durable: false, messageId: null };
|
|
@@ -1666,11 +1734,7 @@ export class CanonAgent {
|
|
|
1666
1734
|
});
|
|
1667
1735
|
}
|
|
1668
1736
|
catch { }
|
|
1669
|
-
|
|
1670
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1671
|
-
}
|
|
1672
|
-
catch { }
|
|
1673
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1737
|
+
await resumeTurnFromWaiting();
|
|
1674
1738
|
return result;
|
|
1675
1739
|
}
|
|
1676
1740
|
catch (error) {
|
|
@@ -1678,6 +1742,10 @@ export class CanonAgent {
|
|
|
1678
1742
|
throw error;
|
|
1679
1743
|
}
|
|
1680
1744
|
shouldPersistTurnState = false;
|
|
1745
|
+
// The turn parked before the request went out and the handler is
|
|
1746
|
+
// about to carry on with a denial, so put it back to work — the
|
|
1747
|
+
// success path is not the only way out of `waiting_input`.
|
|
1748
|
+
await resumeTurnFromWaiting();
|
|
1681
1749
|
return { decision: 'deny' };
|
|
1682
1750
|
}
|
|
1683
1751
|
};
|
|
@@ -1760,11 +1828,7 @@ export class CanonAgent {
|
|
|
1760
1828
|
});
|
|
1761
1829
|
}
|
|
1762
1830
|
catch { }
|
|
1763
|
-
|
|
1764
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1765
|
-
}
|
|
1766
|
-
catch { }
|
|
1767
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1831
|
+
await resumeTurnFromWaiting();
|
|
1768
1832
|
return result;
|
|
1769
1833
|
}
|
|
1770
1834
|
catch (error) {
|
|
@@ -1785,6 +1849,10 @@ export class CanonAgent {
|
|
|
1785
1849
|
throw error;
|
|
1786
1850
|
}
|
|
1787
1851
|
shouldPersistTurnState = false;
|
|
1852
|
+
// Parked, then failed rather than answered — the handler carries on
|
|
1853
|
+
// with the fallback result, so the turn has to look like it is
|
|
1854
|
+
// working again. The success path is not the only way out.
|
|
1855
|
+
await resumeTurnFromWaiting();
|
|
1788
1856
|
return result;
|
|
1789
1857
|
}
|
|
1790
1858
|
};
|
|
@@ -1887,11 +1955,7 @@ export class CanonAgent {
|
|
|
1887
1955
|
});
|
|
1888
1956
|
}
|
|
1889
1957
|
catch { }
|
|
1890
|
-
|
|
1891
|
-
await this.typingSignals.start(conversationId, 'thinking');
|
|
1892
|
-
}
|
|
1893
|
-
catch { }
|
|
1894
|
-
await setLiveState('thinking', 'Thinking...', 'thinking');
|
|
1958
|
+
await resumeTurnFromWaiting();
|
|
1895
1959
|
return result;
|
|
1896
1960
|
}
|
|
1897
1961
|
catch (error) {
|
|
@@ -1921,6 +1985,10 @@ export class CanonAgent {
|
|
|
1921
1985
|
throw error;
|
|
1922
1986
|
}
|
|
1923
1987
|
shouldPersistTurnState = false;
|
|
1988
|
+
// Parked, then failed rather than answered — the handler carries on
|
|
1989
|
+
// with the fallback result, so the turn has to look like it is
|
|
1990
|
+
// working again. The success path is not the only way out.
|
|
1991
|
+
await resumeTurnFromWaiting();
|
|
1924
1992
|
return result;
|
|
1925
1993
|
}
|
|
1926
1994
|
};
|
|
@@ -1933,7 +2001,8 @@ export class CanonAgent {
|
|
|
1933
2001
|
catch { }
|
|
1934
2002
|
throwIfAborted();
|
|
1935
2003
|
try {
|
|
1936
|
-
|
|
2004
|
+
// Same gate as `replyFinal` — this send IS a final, trail included.
|
|
2005
|
+
const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
|
|
1937
2006
|
const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
|
|
1938
2007
|
...(options?.replyTo ? { replyTo: options.replyTo } : {}),
|
|
1939
2008
|
...(options?.replyToPosition != null
|
|
@@ -1988,6 +2057,7 @@ export class CanonAgent {
|
|
|
1988
2057
|
provenance,
|
|
1989
2058
|
turnContext,
|
|
1990
2059
|
requestedTurnMode,
|
|
2060
|
+
turnVerbosity,
|
|
1991
2061
|
requestApproval,
|
|
1992
2062
|
requestRuntimeInput,
|
|
1993
2063
|
requestCard,
|
|
@@ -2074,6 +2144,14 @@ export class CanonAgent {
|
|
|
2074
2144
|
clear: async () => {
|
|
2075
2145
|
await turnOutput.clear();
|
|
2076
2146
|
},
|
|
2147
|
+
noReply: async (reason) => {
|
|
2148
|
+
throwIfAborted();
|
|
2149
|
+
deliberatelySilent = true;
|
|
2150
|
+
// `reason` is handler-authored free text: record only that one was
|
|
2151
|
+
// given, never the text itself.
|
|
2152
|
+
console.error(`[canon-sdk] Turn chose no_reply for ${conversationId}`
|
|
2153
|
+
+ ` (reason: ${reason?.trim() ? 'given' : 'none'})`);
|
|
2154
|
+
},
|
|
2077
2155
|
setTool: async (text) => {
|
|
2078
2156
|
await writeTurn('tool');
|
|
2079
2157
|
await turnOutput.addBlock({
|
|
@@ -2118,6 +2196,7 @@ export class CanonAgent {
|
|
|
2118
2196
|
}
|
|
2119
2197
|
}
|
|
2120
2198
|
catch (err) {
|
|
2199
|
+
turnEndedAbnormally = true;
|
|
2121
2200
|
if (abortController.signal.aborted || isAbortLikeError(err)) {
|
|
2122
2201
|
await writeTurn('interrupted');
|
|
2123
2202
|
return;
|
|
@@ -2137,13 +2216,29 @@ export class CanonAgent {
|
|
|
2137
2216
|
this.activeTurns.delete(conversationId);
|
|
2138
2217
|
}
|
|
2139
2218
|
clearInterval(thinkingKeepalive);
|
|
2219
|
+
// Sequenced AFTER the keepalive stops, and with no handoff delay in front
|
|
2220
|
+
// of it: the 3.5 s keepalive would rewrite 'Thinking...' over a blank
|
|
2221
|
+
// scheduled earlier, and every other runtime retires the row and the
|
|
2222
|
+
// typing dots together the moment a turn goes silent. `replyFinal`'s
|
|
2223
|
+
// handoff window overlaps a durable message that has already landed;
|
|
2224
|
+
// here there is nothing to hand off to, and holding the indicator would
|
|
2225
|
+
// read as "started to answer, then gave up".
|
|
2226
|
+
const silentTeardown = deliberatelySilent && !turnEndedAbnormally;
|
|
2140
2227
|
// Always clear typing when done
|
|
2141
2228
|
try {
|
|
2142
2229
|
await this.typingSignals.clear(conversationId);
|
|
2143
2230
|
}
|
|
2144
2231
|
catch { }
|
|
2145
2232
|
try {
|
|
2146
|
-
|
|
2233
|
+
// Deliberate silence blanks the node (text '' AND an explicit empty
|
|
2234
|
+
// blocks array) before deleting it, so onStreamingCleared sees empty
|
|
2235
|
+
// content and does not salvage the turn's narration into a durable
|
|
2236
|
+
// bubble. A crashed or interrupted turn keeps the plain clear: there
|
|
2237
|
+
// the salvage is the only record of what the turn managed to say.
|
|
2238
|
+
if (silentTeardown)
|
|
2239
|
+
await turnOutput.blankAndClear();
|
|
2240
|
+
else
|
|
2241
|
+
await turnOutput.clear();
|
|
2147
2242
|
}
|
|
2148
2243
|
catch { }
|
|
2149
2244
|
if (runtimeState && !shouldPersistTurnState) {
|
package/dist/index.d.ts
CHANGED
|
@@ -6,5 +6,6 @@ export { SessionManager } from './session-manager.js';
|
|
|
6
6
|
export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
|
|
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
|
-
export type {
|
|
9
|
+
export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
|
|
10
|
+
export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
|
|
10
11
|
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';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CanonClient, TurnOutputSnapshot } from '@canonmsg/core';
|
|
2
|
+
export type TurnStreamingRequest = Parameters<CanonClient['setStreaming']>[0];
|
|
3
|
+
/**
|
|
4
|
+
* Builds the `POST /streaming` body for one turn-output snapshot.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from the turn loop for one reason: `blocks` must be forwarded
|
|
7
|
+
* VERBATIM, empty array included. `POST /streaming` merges into the RTDB node,
|
|
8
|
+
* so an absent `blocks` key leaves the previous trail in place — and the
|
|
9
|
+
* `onStreamingCleared` trigger rebuilds salvage text out of block titles. A
|
|
10
|
+
* silent turn blanks the node with `text: ''` and `blocks: []`; drop the empty
|
|
11
|
+
* array on the way out and the turn the model declined to send comes back as a
|
|
12
|
+
* durable bubble made of its own tool-trail headings.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildTurnStreamingRequest(input: {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
snapshot: TurnOutputSnapshot;
|
|
17
|
+
}): TurnStreamingRequest;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the `POST /streaming` body for one turn-output snapshot.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the turn loop for one reason: `blocks` must be forwarded
|
|
5
|
+
* VERBATIM, empty array included. `POST /streaming` merges into the RTDB node,
|
|
6
|
+
* so an absent `blocks` key leaves the previous trail in place — and the
|
|
7
|
+
* `onStreamingCleared` trigger rebuilds salvage text out of block titles. A
|
|
8
|
+
* silent turn blanks the node with `text: ''` and `blocks: []`; drop the empty
|
|
9
|
+
* array on the way out and the turn the model declined to send comes back as a
|
|
10
|
+
* durable bubble made of its own tool-trail headings.
|
|
11
|
+
*/
|
|
12
|
+
export function buildTurnStreamingRequest(input) {
|
|
13
|
+
const { conversationId, snapshot } = input;
|
|
14
|
+
return {
|
|
15
|
+
conversationId,
|
|
16
|
+
text: snapshot.text,
|
|
17
|
+
status: snapshot.status,
|
|
18
|
+
messageId: snapshot.messageId,
|
|
19
|
+
turnId: snapshot.turnId,
|
|
20
|
+
blocks: snapshot.blocks,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type TurnVerbosityConfig, type TurnVerbosityConversationType } from '@canonmsg/core';
|
|
2
|
+
/**
|
|
3
|
+
* What a developer may write for `CanonAgentOptions.turnVerbosity`.
|
|
4
|
+
*
|
|
5
|
+
* The SDK is the one runtime where a per-conversation-type OBJECT is idiomatic:
|
|
6
|
+
* it has no argv and no config file, so `{ group: 'verbose' }` costs a
|
|
7
|
+
* developer nothing while the hosts would have to invent a flag grammar for the
|
|
8
|
+
* same thing. Everything else — the vocabulary, the defaults, `'auto'` ≡ unset
|
|
9
|
+
* — stays in core's `resolveTurnVerbosity`, which this folds into rather than
|
|
10
|
+
* reimplements.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT promoted to `@canonmsg/core`: the Claude host, the Codex
|
|
13
|
+
* host, OpenClaw and Hermes all take a scalar (a CLI flag, an env var or a
|
|
14
|
+
* config-file string), so core would grow a shape with exactly one consumer.
|
|
15
|
+
*/
|
|
16
|
+
export type CanonAgentTurnVerbosityOption = TurnVerbosityConfig | {
|
|
17
|
+
/** Applies to direct chats — Hermes's `'dm'` spelling included. */
|
|
18
|
+
direct?: TurnVerbosityConfig;
|
|
19
|
+
group?: TurnVerbosityConfig;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Picks the configured value this turn's conversation type is subject to, in
|
|
23
|
+
* the shape `resolveTurnVerbosity` takes.
|
|
24
|
+
*
|
|
25
|
+
* `null` means "nothing configured for this type" and lets core's per-type
|
|
26
|
+
* default decide — which is also what an `'auto'` entry and an unrecognized
|
|
27
|
+
* string produce, so an object that names only `group` leaves DMs on the
|
|
28
|
+
* default rather than forcing them anywhere. A conversation type that resolves
|
|
29
|
+
* to `'unknown'` (the fetch failed, or provenance was absent) matches no key
|
|
30
|
+
* and therefore falls through to the fail-open default too: an object form
|
|
31
|
+
* cannot silence a turn whose shape Canon could not name.
|
|
32
|
+
*/
|
|
33
|
+
export declare function selectConfiguredTurnVerbosity(option: CanonAgentTurnVerbosityOption | null | undefined, conversationType: TurnVerbosityConversationType | null | undefined): TurnVerbosityConfig | null;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeTurnVerbosityConversationType, parseTurnVerbosityConfig, } from '@canonmsg/core';
|
|
2
|
+
/**
|
|
3
|
+
* Picks the configured value this turn's conversation type is subject to, in
|
|
4
|
+
* the shape `resolveTurnVerbosity` takes.
|
|
5
|
+
*
|
|
6
|
+
* `null` means "nothing configured for this type" and lets core's per-type
|
|
7
|
+
* default decide — which is also what an `'auto'` entry and an unrecognized
|
|
8
|
+
* string produce, so an object that names only `group` leaves DMs on the
|
|
9
|
+
* default rather than forcing them anywhere. A conversation type that resolves
|
|
10
|
+
* to `'unknown'` (the fetch failed, or provenance was absent) matches no key
|
|
11
|
+
* and therefore falls through to the fail-open default too: an object form
|
|
12
|
+
* cannot silence a turn whose shape Canon could not name.
|
|
13
|
+
*/
|
|
14
|
+
export function selectConfiguredTurnVerbosity(option, conversationType) {
|
|
15
|
+
if (option === null || option === undefined)
|
|
16
|
+
return null;
|
|
17
|
+
if (typeof option === 'string')
|
|
18
|
+
return parseTurnVerbosityConfig(option);
|
|
19
|
+
const key = normalizeTurnVerbosityConversationType(conversationType);
|
|
20
|
+
if (key === 'unknown')
|
|
21
|
+
return null;
|
|
22
|
+
return parseTurnVerbosityConfig(option[key]);
|
|
23
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -62,6 +62,19 @@ export interface TurnController {
|
|
|
62
62
|
replaceSnapshot: (text: string) => Promise<void>;
|
|
63
63
|
flush: () => Promise<void>;
|
|
64
64
|
clear: () => Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* End this turn without posting anything (`canon.verbs.v1` `no_reply`). The
|
|
67
|
+
* live bubble is blanked and removed instead of being salvaged into a durable
|
|
68
|
+
* message, so nothing is rendered and no other member or agent is triggered —
|
|
69
|
+
* the agent-turn trigger is message-driven.
|
|
70
|
+
*
|
|
71
|
+
* `reason` is private: recorded as present or absent, never sent or rendered.
|
|
72
|
+
*
|
|
73
|
+
* Unlike the model-driven runtimes, this does NOT gate `replyFinal`. Calling
|
|
74
|
+
* both is two explicit decisions by the same author, so the text still lands;
|
|
75
|
+
* call `noReply` when the handler has decided not to answer at all.
|
|
76
|
+
*/
|
|
77
|
+
noReply: (reason?: string) => Promise<void>;
|
|
65
78
|
setTool: (text: string) => Promise<void>;
|
|
66
79
|
setWaitingInput: (text?: string) => Promise<void>;
|
|
67
80
|
}
|
|
@@ -179,6 +192,14 @@ export interface MessageHandlerContext {
|
|
|
179
192
|
turnContext: CanonTurnContextV2;
|
|
180
193
|
/** Runtime turn mode requested by the sender for this inbound turn, if any. */
|
|
181
194
|
requestedTurnMode: string | null;
|
|
195
|
+
/**
|
|
196
|
+
* How much of this turn's middle the reader sees, already resolved from
|
|
197
|
+
* `CanonAgentOptions.turnVerbosity` and the conversation type — groups are
|
|
198
|
+
* quiet by default, DMs verbose. In `'quiet'` the SDK publishes no
|
|
199
|
+
* `/streaming` narration and attaches no turn trail to the final, so a
|
|
200
|
+
* handler can skip building either. It stays fixed for the whole turn.
|
|
201
|
+
*/
|
|
202
|
+
turnVerbosity: import('@canonmsg/core').TurnVerbosity;
|
|
182
203
|
/**
|
|
183
204
|
* Ask the triggering human to approve a native runtime action. This only
|
|
184
205
|
* renders Canon's inline approval card; runtimes must explicitly wait for
|
|
@@ -299,6 +320,19 @@ export interface CanonAgentOptions extends CanonAgentConnectionOptions {
|
|
|
299
320
|
* Turn-state reporting is automatic while handlers run.
|
|
300
321
|
*/
|
|
301
322
|
sessionState?: boolean;
|
|
323
|
+
/**
|
|
324
|
+
* How much of a turn's middle the reader sees. Unset (or `'auto'`) resolves
|
|
325
|
+
* per turn from the conversation type: DMs verbose, groups quiet. A scalar
|
|
326
|
+
* overrides everywhere; the object form overrides one conversation type and
|
|
327
|
+
* leaves the other on its default.
|
|
328
|
+
*
|
|
329
|
+
* `'quiet'` drops the live `/streaming` narration and the final's margin
|
|
330
|
+
* turn trail. It never touches the typing indicator, the turn state, the
|
|
331
|
+
* final message, failure notices, or interaction cards and their receipts —
|
|
332
|
+
* and an explicit `ctx.replyProgress(text, { durable: true })` still posts,
|
|
333
|
+
* because a developer's explicit send is not runtime narration.
|
|
334
|
+
*/
|
|
335
|
+
turnVerbosity?: import('./turn-verbosity-option.js').CanonAgentTurnVerbosityOption;
|
|
302
336
|
}
|
|
303
337
|
export type ContactRequestHandler = (request: import('@canonmsg/core').CanonContactRequest) => void | Promise<void>;
|
|
304
338
|
export type ContactAddedHandler = (contact: import('@canonmsg/core').ContactAddedPayload) => void | Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/agent-sdk",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.2.0",
|
|
4
4
|
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@canonmsg/core": "^
|
|
31
|
+
"@canonmsg/core": "^10.0.0"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
34
|
"access": "public"
|