@canonmsg/agent-sdk 8.1.0 → 8.3.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 text never leaves the process. Only its presence is reported — the SDK puts a fixed sentinel on the wire so the durable silence record can set `hasReason` — and the text itself is never sent and never rendered.
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,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, reportNoReplyOutcome, 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;
@@ -1246,7 +1248,22 @@ export class CanonAgent {
1246
1248
  const turnOpenedAt = Date.now();
1247
1249
  let turnState = 'thinking';
1248
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
+ // The FIRST noReply call's reason (one silence decision per turn), carried
1256
+ // to teardown where the outcome report fires. Only its presence rides the
1257
+ // wire — see reportNoReplyOutcome's sentinel.
1258
+ let noReplyReason;
1259
+ // Set when the handler threw or the turn was aborted. Deliberate silence
1260
+ // must never blank the live node on those paths: the streamed content is
1261
+ // all that survives a turn that died mid-flight.
1262
+ let turnEndedAbnormally = false;
1249
1263
  let durableMessageSequence = 0;
1264
+ // The freshest message in the batch is the turn's trigger — same convention
1265
+ // as the provenance lookup for turn verbosity below.
1266
+ const triggeringMessageId = messages[messages.length - 1]?.id;
1250
1267
  const agentId = this.agentId;
1251
1268
  const runtimeState = this.createRuntimeStatePublisher();
1252
1269
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
@@ -1287,17 +1304,32 @@ export class CanonAgent {
1287
1304
  : {}),
1288
1305
  })).catch(() => { });
1289
1306
  };
1307
+ // Quiet mode is resolved HERE, once, and carried for the whole turn.
1308
+ //
1309
+ // It has to be here because everything downstream is already too late: the
1310
+ // controller's mode is fixed at construction two lines below, the
1311
+ // `'Thinking...'` seed lands before that, and the conversation fetch that
1312
+ // would otherwise name the conversation type is ~30 lines further down,
1313
+ // inside the try. Provenance already carries the type — the stream service
1314
+ // stamps it on every `message.created` frame — so the answer is available
1315
+ // before the first publish of any kind.
1316
+ //
1317
+ // Re-deriving it mid-turn is what must never happen: a turn that started
1318
+ // quiet and finished verbose would emit a trail nothing narrated.
1319
+ const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation?.type);
1320
+ const turnVerbosity = resolveTurnVerbosity({
1321
+ configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
1322
+ conversationType: inboundConversationType,
1323
+ });
1290
1324
  const turnOutput = createTurnOutputController({
1291
1325
  turnId,
1292
- mode: 'snapshot',
1293
- writeSnapshot: (snapshot) => this.apiClient.setStreaming({
1294
- conversationId,
1295
- text: snapshot.text,
1296
- status: snapshot.status,
1297
- messageId: snapshot.messageId,
1298
- turnId: snapshot.turnId,
1299
- blocks: snapshot.blocks,
1300
- }),
1326
+ // `'status'` keeps every controller call site working and drops only the
1327
+ // RTDB write, which covers all ~12 of them at once — the thinking seed
1328
+ // and its keepalive, `turn.appendDelta`/`appendBlock`/`setTool`, the
1329
+ // approval block, and `replyProgress`'s implicit live half. Blocks still
1330
+ // accumulate in memory, so the trail gate below is a SEPARATE decision.
1331
+ mode: turnVerbosity === 'quiet' ? 'status' : 'snapshot',
1332
+ writeSnapshot: (snapshot) => this.apiClient.setStreaming(buildTurnStreamingRequest({ conversationId, snapshot })),
1301
1333
  clearSnapshot: () => this.apiClient.clearStreaming(conversationId),
1302
1334
  });
1303
1335
  const setLiveState = async (state, text, streamingStatus) => {
@@ -1307,6 +1339,36 @@ export class CanonAgent {
1307
1339
  await turnOutput.setStatus(streamingStatus, text ?? '');
1308
1340
  }
1309
1341
  };
1342
+ /**
1343
+ * Put the turn back to work after an interaction request settles.
1344
+ *
1345
+ * Both halves matter: the dots were cleared when the turn parked (the
1346
+ * clients suppress an agent's dots on `waiting_input`), and `/turn-state`
1347
+ * plus the live node are still sitting on `waiting_input`. The error paths
1348
+ * need this as much as the success paths — a request that failed still
1349
+ * leaves the handler running, and a quiet turn has no live row to stand in
1350
+ * for the missing dots. Aborts still propagate: `setLiveState` re-checks
1351
+ * the signal, which is how an interrupt lands during cleanup.
1352
+ */
1353
+ const resumeTurnFromWaiting = async () => {
1354
+ try {
1355
+ await this.typingSignals.start(conversationId, 'thinking');
1356
+ }
1357
+ catch { }
1358
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1359
+ };
1360
+ if (turnVerbosity === 'quiet') {
1361
+ // A quiet turn never writes to `/streaming`, so it cannot rely on the
1362
+ // 'Thinking...' seed below to overwrite a node an earlier verbose turn
1363
+ // left behind — and a surviving node under the PREVIOUS turn's id would
1364
+ // sit there, invisible, for this whole turn, then be deleted at teardown:
1365
+ // `onStreamingCleared` would salvage it into a durable bubble landing
1366
+ // AFTER this turn's answer. Deleting at turn open puts that salvage back
1367
+ // where the design intends it, ahead of the answer. Both coding hosts do
1368
+ // the same, one for free (its seed clears unconditionally) and one
1369
+ // explicitly.
1370
+ await turnOutput.clear().catch(() => { });
1371
+ }
1310
1372
  // Show thinking indicator and keep it alive (5s client-side expiry)
1311
1373
  try {
1312
1374
  await this.typingSignals.start(conversationId, 'thinking');
@@ -1353,7 +1415,11 @@ export class CanonAgent {
1353
1415
  catch { }
1354
1416
  throwIfAborted();
1355
1417
  const sendOptions = withActiveSelfContext(options);
1356
- const turnTrail = turnOutput.getFinalTrail();
1418
+ // Separate from the controller's mode on purpose: `'status'` silences
1419
+ // the live writes but still accumulates blocks, so `getFinalTrail()`
1420
+ // would happily hand back a full trail for a turn that narrated
1421
+ // nothing.
1422
+ const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
1357
1423
  const finalOptions = {
1358
1424
  ...sendOptions,
1359
1425
  metadata: {
@@ -1396,11 +1462,23 @@ export class CanonAgent {
1396
1462
  };
1397
1463
  const replyProgress = async (text, options) => {
1398
1464
  throwIfAborted();
1465
+ // Owner ruling D1: in quiet the IMPLICIT half of this call — the live
1466
+ // `/streaming` narration — is dropped by the controller's `'status'`
1467
+ // mode, while the turn state it also publishes stays. The EXPLICIT half
1468
+ // below does not: `durable: true` is a developer asking Canon to post a
1469
+ // message, structurally the same act as `replyFinal`, and silently
1470
+ // no-op'ing it would leave a caller reading `durable: true` from a send
1471
+ // that never happened. The result object therefore keeps describing
1472
+ // what actually occurred in both modes.
1399
1473
  await setLiveState('streaming', text, 'streaming');
1400
1474
  if (!options?.durable) {
1401
1475
  return { turnId, durable: false, messageId: null };
1402
1476
  }
1403
1477
  throwIfAborted();
1478
+ // Durable progress skips `sendDurableMessage` (see the chunking note
1479
+ // below) but it is still a durable message in the timeline, so it must
1480
+ // advance the same counter teardown reads for the silence report.
1481
+ durableMessageSequence += 1;
1404
1482
  const { durable: _durable, ...sendOptions } = options;
1405
1483
  const sendOptionsWithContext = withActiveSelfContext(sendOptions);
1406
1484
  // Progress text is caller-controlled too, so it can also blow past
@@ -1487,8 +1565,13 @@ export class CanonAgent {
1487
1565
  // untouched metadata, so the common case (and the interim→final handoff
1488
1566
  // that keys on that id) is unchanged.
1489
1567
  const sendDurableMessage = async (text, options, fallbackMessageIdParts) => {
1568
+ // Counts every durable send the turn attempts, not just the ones that
1569
+ // needed a generated id: teardown reads `durableMessageSequence` to
1570
+ // decide whether the turn genuinely ended silent, and a reply sent
1571
+ // under an explicit caller messageId must not look like silence.
1572
+ durableMessageSequence += 1;
1490
1573
  const messageId = options?.messageId
1491
- ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence += 1]);
1574
+ ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence]);
1492
1575
  const { messageIds } = await sendMessageWithRetryChunked(abortAwareClient, conversationId, text, {
1493
1576
  ...(options ?? {}),
1494
1577
  messageId,
@@ -1667,11 +1750,7 @@ export class CanonAgent {
1667
1750
  });
1668
1751
  }
1669
1752
  catch { }
1670
- try {
1671
- await this.typingSignals.start(conversationId, 'thinking');
1672
- }
1673
- catch { }
1674
- await setLiveState('thinking', 'Thinking...', 'thinking');
1753
+ await resumeTurnFromWaiting();
1675
1754
  return result;
1676
1755
  }
1677
1756
  catch (error) {
@@ -1679,6 +1758,10 @@ export class CanonAgent {
1679
1758
  throw error;
1680
1759
  }
1681
1760
  shouldPersistTurnState = false;
1761
+ // The turn parked before the request went out and the handler is
1762
+ // about to carry on with a denial, so put it back to work — the
1763
+ // success path is not the only way out of `waiting_input`.
1764
+ await resumeTurnFromWaiting();
1682
1765
  return { decision: 'deny' };
1683
1766
  }
1684
1767
  };
@@ -1761,11 +1844,7 @@ export class CanonAgent {
1761
1844
  });
1762
1845
  }
1763
1846
  catch { }
1764
- try {
1765
- await this.typingSignals.start(conversationId, 'thinking');
1766
- }
1767
- catch { }
1768
- await setLiveState('thinking', 'Thinking...', 'thinking');
1847
+ await resumeTurnFromWaiting();
1769
1848
  return result;
1770
1849
  }
1771
1850
  catch (error) {
@@ -1786,6 +1865,10 @@ export class CanonAgent {
1786
1865
  throw error;
1787
1866
  }
1788
1867
  shouldPersistTurnState = false;
1868
+ // Parked, then failed rather than answered — the handler carries on
1869
+ // with the fallback result, so the turn has to look like it is
1870
+ // working again. The success path is not the only way out.
1871
+ await resumeTurnFromWaiting();
1789
1872
  return result;
1790
1873
  }
1791
1874
  };
@@ -1888,11 +1971,7 @@ export class CanonAgent {
1888
1971
  });
1889
1972
  }
1890
1973
  catch { }
1891
- try {
1892
- await this.typingSignals.start(conversationId, 'thinking');
1893
- }
1894
- catch { }
1895
- await setLiveState('thinking', 'Thinking...', 'thinking');
1974
+ await resumeTurnFromWaiting();
1896
1975
  return result;
1897
1976
  }
1898
1977
  catch (error) {
@@ -1922,6 +2001,10 @@ export class CanonAgent {
1922
2001
  throw error;
1923
2002
  }
1924
2003
  shouldPersistTurnState = false;
2004
+ // Parked, then failed rather than answered — the handler carries on
2005
+ // with the fallback result, so the turn has to look like it is
2006
+ // working again. The success path is not the only way out.
2007
+ await resumeTurnFromWaiting();
1925
2008
  return result;
1926
2009
  }
1927
2010
  };
@@ -1934,7 +2017,8 @@ export class CanonAgent {
1934
2017
  catch { }
1935
2018
  throwIfAborted();
1936
2019
  try {
1937
- const turnTrail = turnOutput.getFinalTrail();
2020
+ // Same gate as `replyFinal` — this send IS a final, trail included.
2021
+ const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
1938
2022
  const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
1939
2023
  ...(options?.replyTo ? { replyTo: options.replyTo } : {}),
1940
2024
  ...(options?.replyToPosition != null
@@ -1954,6 +2038,7 @@ export class CanonAgent {
1954
2038
  ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
1955
2039
  ...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
1956
2040
  });
2041
+ durableMessageSequence += 1;
1957
2042
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
1958
2043
  return result;
1959
2044
  }
@@ -1989,6 +2074,7 @@ export class CanonAgent {
1989
2074
  provenance,
1990
2075
  turnContext,
1991
2076
  requestedTurnMode,
2077
+ turnVerbosity,
1992
2078
  requestApproval,
1993
2079
  requestRuntimeInput,
1994
2080
  requestCard,
@@ -2075,6 +2161,22 @@ export class CanonAgent {
2075
2161
  clear: async () => {
2076
2162
  await turnOutput.clear();
2077
2163
  },
2164
+ noReply: async (reason) => {
2165
+ throwIfAborted();
2166
+ // First call wins — one silence decision per turn.
2167
+ if (!deliberatelySilent && reason?.trim()) {
2168
+ noReplyReason = reason;
2169
+ }
2170
+ deliberatelySilent = true;
2171
+ // `reason` is handler-authored free text: log only that one was given.
2172
+ console.error(`[canon-sdk] Turn chose no_reply for ${conversationId}`
2173
+ + ` (reason: ${reason?.trim() ? 'given' : 'none'})`);
2174
+ // The outcome report deliberately does NOT fire here: `replyFinal`
2175
+ // still sends after `noReply` (the SDK carve-out — see
2176
+ // TurnController.noReply in types.ts) and the handler may yet throw
2177
+ // or be aborted, so only teardown knows whether the silence was
2178
+ // real. It fires there, once, off the flag set above.
2179
+ },
2078
2180
  setTool: async (text) => {
2079
2181
  await writeTurn('tool');
2080
2182
  await turnOutput.addBlock({
@@ -2119,6 +2221,7 @@ export class CanonAgent {
2119
2221
  }
2120
2222
  }
2121
2223
  catch (err) {
2224
+ turnEndedAbnormally = true;
2122
2225
  if (abortController.signal.aborted || isAbortLikeError(err)) {
2123
2226
  await writeTurn('interrupted');
2124
2227
  return;
@@ -2138,13 +2241,45 @@ export class CanonAgent {
2138
2241
  this.activeTurns.delete(conversationId);
2139
2242
  }
2140
2243
  clearInterval(thinkingKeepalive);
2244
+ // Sequenced AFTER the keepalive stops, and with no handoff delay in front
2245
+ // of it: the 3.5 s keepalive would rewrite 'Thinking...' over a blank
2246
+ // scheduled earlier, and every other runtime retires the row and the
2247
+ // typing dots together the moment a turn goes silent. `replyFinal`'s
2248
+ // handoff window overlaps a durable message that has already landed;
2249
+ // here there is nothing to hand off to, and holding the indicator would
2250
+ // read as "started to answer, then gave up".
2251
+ const silentTeardown = deliberatelySilent && !turnEndedAbnormally;
2252
+ // Telemetry parity with the server-answered verb path, decided HERE
2253
+ // because only teardown knows how the turn actually ended: `noReply`
2254
+ // does not gate `replyFinal` in the SDK, and the handler may throw or be
2255
+ // aborted after choosing silence — either would make a report sent at
2256
+ // noReply-call time record silence for a turn that delivered or died.
2257
+ // `durableMessageSequence` counts the turn's durable sends: any durable
2258
+ // message means the turn did not end silent, deliberate flag or not.
2259
+ // reportNoReplyOutcome is fire-and-forget and never throws, so a failed
2260
+ // or rejecting report degrades to exactly the pre-report behavior.
2261
+ if (silentTeardown && durableMessageSequence === 0) {
2262
+ reportNoReplyOutcome(this.apiClient, {
2263
+ conversationId,
2264
+ ...(triggeringMessageId ? { messageId: triggeringMessageId } : {}),
2265
+ ...(noReplyReason ? { reason: noReplyReason } : {}),
2266
+ });
2267
+ }
2141
2268
  // Always clear typing when done
2142
2269
  try {
2143
2270
  await this.typingSignals.clear(conversationId);
2144
2271
  }
2145
2272
  catch { }
2146
2273
  try {
2147
- await turnOutput.clear();
2274
+ // Deliberate silence blanks the node (text '' AND an explicit empty
2275
+ // blocks array) before deleting it, so onStreamingCleared sees empty
2276
+ // content and does not salvage the turn's narration into a durable
2277
+ // bubble. A crashed or interrupted turn keeps the plain clear: there
2278
+ // the salvage is the only record of what the turn managed to say.
2279
+ if (silentTeardown)
2280
+ await turnOutput.blankAndClear();
2281
+ else
2282
+ await turnOutput.clear();
2148
2283
  }
2149
2284
  catch { }
2150
2285
  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, CanonConversationsPage, CanonConversationsPageOptions, 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';
@@ -1,4 +1,4 @@
1
- import { type AgentContext, type ContactAddedPayload, type ContactApprovedPayload, type ContactRemovedPayload, type ContactRequestPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
1
+ import { type AgentContext, type ContactAddedPayload, type ContactApprovedPayload, type ContactRemovedPayload, type ContactRequestPayload, type ConversationUpdatedPayload, type MessageUpdatedPayload, type ParticipationSuppressedPayload, type VoiceSessionEventPayload } from '@canonmsg/core';
2
2
  import { Debouncer } from './debouncer.js';
3
3
  /**
4
4
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
@@ -20,6 +20,7 @@ export declare class RealtimeManager {
20
20
  private onContactAdded;
21
21
  private onContactRemoved;
22
22
  private onConversationUpdated;
23
+ private onParticipationSuppressed;
23
24
  private onMessageUpdated;
24
25
  private onMessageDeleted;
25
26
  private onConnected;
@@ -43,6 +44,12 @@ export declare class RealtimeManager {
43
44
  onContactRemoved?: (payload: ContactRemovedPayload) => void;
44
45
  }): void;
45
46
  setConversationUpdatedHandler(cb: (payload: ConversationUpdatedPayload) => void): void;
47
+ /**
48
+ * Observe-only notice that Canon's participation gate withheld a turn this
49
+ * agent would otherwise have been dispatched. Never run a turn off it — it
50
+ * exists so an agent can tell deliberate policy from a dead stream.
51
+ */
52
+ setParticipationSuppressedHandler(cb: (payload: ParticipationSuppressedPayload) => void): void;
46
53
  setMessageUpdatedHandler(cb: (payload: MessageUpdatedPayload) => void): void;
47
54
  setMessageDeletedHandler(cb: (payload: {
48
55
  conversationId: string;
package/dist/realtime.js CHANGED
@@ -21,6 +21,7 @@ export class RealtimeManager {
21
21
  onContactAdded = null;
22
22
  onContactRemoved = null;
23
23
  onConversationUpdated = null;
24
+ onParticipationSuppressed = null;
24
25
  onMessageUpdated = null;
25
26
  onMessageDeleted = null;
26
27
  onConnected = null;
@@ -113,6 +114,9 @@ export class RealtimeManager {
113
114
  onConversationUpdated: (payload) => {
114
115
  this.onConversationUpdated?.(payload);
115
116
  },
117
+ onParticipationSuppressed: (payload) => {
118
+ this.onParticipationSuppressed?.(payload);
119
+ },
116
120
  onConnected: () => {
117
121
  this.onConnected?.();
118
122
  },
@@ -180,6 +184,14 @@ export class RealtimeManager {
180
184
  setConversationUpdatedHandler(cb) {
181
185
  this.onConversationUpdated = cb;
182
186
  }
187
+ /**
188
+ * Observe-only notice that Canon's participation gate withheld a turn this
189
+ * agent would otherwise have been dispatched. Never run a turn off it — it
190
+ * exists so an agent can tell deliberate policy from a dead stream.
191
+ */
192
+ setParticipationSuppressedHandler(cb) {
193
+ this.onParticipationSuppressed = cb;
194
+ }
183
195
  setMessageUpdatedHandler(cb) {
184
196
  this.onMessageUpdated = cb;
185
197
  }
@@ -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,21 @@ 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: the text never leaves the process. Only its presence
72
+ * is reported (a fixed sentinel on the wire), so the durable silence record
73
+ * can set `hasReason` — the text itself is never sent and never rendered.
74
+ *
75
+ * Unlike the model-driven runtimes, this does NOT gate `replyFinal`. Calling
76
+ * both is two explicit decisions by the same author, so the text still lands;
77
+ * call `noReply` when the handler has decided not to answer at all.
78
+ */
79
+ noReply: (reason?: string) => Promise<void>;
65
80
  setTool: (text: string) => Promise<void>;
66
81
  setWaitingInput: (text?: string) => Promise<void>;
67
82
  }
@@ -179,6 +194,14 @@ export interface MessageHandlerContext {
179
194
  turnContext: CanonTurnContextV2;
180
195
  /** Runtime turn mode requested by the sender for this inbound turn, if any. */
181
196
  requestedTurnMode: string | null;
197
+ /**
198
+ * How much of this turn's middle the reader sees, already resolved from
199
+ * `CanonAgentOptions.turnVerbosity` and the conversation type — groups are
200
+ * quiet by default, DMs verbose. In `'quiet'` the SDK publishes no
201
+ * `/streaming` narration and attaches no turn trail to the final, so a
202
+ * handler can skip building either. It stays fixed for the whole turn.
203
+ */
204
+ turnVerbosity: import('@canonmsg/core').TurnVerbosity;
182
205
  /**
183
206
  * Ask the triggering human to approve a native runtime action. This only
184
207
  * renders Canon's inline approval card; runtimes must explicitly wait for
@@ -299,6 +322,19 @@ export interface CanonAgentOptions extends CanonAgentConnectionOptions {
299
322
  * Turn-state reporting is automatic while handlers run.
300
323
  */
301
324
  sessionState?: boolean;
325
+ /**
326
+ * How much of a turn's middle the reader sees. Unset (or `'auto'`) resolves
327
+ * per turn from the conversation type: DMs verbose, groups quiet. A scalar
328
+ * overrides everywhere; the object form overrides one conversation type and
329
+ * leaves the other on its default.
330
+ *
331
+ * `'quiet'` drops the live `/streaming` narration and the final's margin
332
+ * turn trail. It never touches the typing indicator, the turn state, the
333
+ * final message, failure notices, or interaction cards and their receipts —
334
+ * and an explicit `ctx.replyProgress(text, { durable: true })` still posts,
335
+ * because a developer's explicit send is not runtime narration.
336
+ */
337
+ turnVerbosity?: import('./turn-verbosity-option.js').CanonAgentTurnVerbosityOption;
302
338
  }
303
339
  export type ContactRequestHandler = (request: import('@canonmsg/core').CanonContactRequest) => void | Promise<void>;
304
340
  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.1.0",
3
+ "version": "8.3.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.2.0"
31
+ "@canonmsg/core": "^10.2.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"