@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 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:
@@ -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;
@@ -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
- mode: 'snapshot',
1292
- writeSnapshot: (snapshot) => this.apiClient.setStreaming({
1293
- conversationId,
1294
- text: snapshot.text,
1295
- status: snapshot.status,
1296
- messageId: snapshot.messageId,
1297
- turnId: snapshot.turnId,
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
- const turnTrail = turnOutput.getFinalTrail();
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
- try {
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
- try {
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
- try {
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
- const turnTrail = turnOutput.getFinalTrail();
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
- await turnOutput.clear();
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 { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, } from '@canonmsg/core';
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.0.0",
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": "^9.0.0"
31
+ "@canonmsg/core": "^10.0.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"