@canonmsg/agent-sdk 8.2.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
@@ -476,7 +476,7 @@ While a handler runs, the SDK automatically publishes Canon turn state and clear
476
476
  - `setWaitingInput(text?)`
477
477
  - `noReply(reason?)`
478
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.
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
480
 
481
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.
482
482
 
@@ -1,4 +1,4 @@
1
- import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, 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';
@@ -1252,11 +1252,18 @@ export class CanonAgent {
1252
1252
  // Governs TEARDOWN only — `replyFinal` still sends, because in the SDK the
1253
1253
  // developer's two explicit calls are two explicit decisions.
1254
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;
1255
1259
  // Set when the handler threw or the turn was aborted. Deliberate silence
1256
1260
  // must never blank the live node on those paths: the streamed content is
1257
1261
  // all that survives a turn that died mid-flight.
1258
1262
  let turnEndedAbnormally = false;
1259
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;
1260
1267
  const agentId = this.agentId;
1261
1268
  const runtimeState = this.createRuntimeStatePublisher();
1262
1269
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
@@ -1309,7 +1316,7 @@ export class CanonAgent {
1309
1316
  //
1310
1317
  // Re-deriving it mid-turn is what must never happen: a turn that started
1311
1318
  // quiet and finished verbose would emit a trail nothing narrated.
1312
- const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(messages[messages.length - 1]?.id ?? '')?.conversation?.type);
1319
+ const inboundConversationType = normalizeTurnVerbosityConversationType(provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation?.type);
1313
1320
  const turnVerbosity = resolveTurnVerbosity({
1314
1321
  configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
1315
1322
  conversationType: inboundConversationType,
@@ -1468,6 +1475,10 @@ export class CanonAgent {
1468
1475
  return { turnId, durable: false, messageId: null };
1469
1476
  }
1470
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;
1471
1482
  const { durable: _durable, ...sendOptions } = options;
1472
1483
  const sendOptionsWithContext = withActiveSelfContext(sendOptions);
1473
1484
  // Progress text is caller-controlled too, so it can also blow past
@@ -1554,8 +1565,13 @@ export class CanonAgent {
1554
1565
  // untouched metadata, so the common case (and the interim→final handoff
1555
1566
  // that keys on that id) is unchanged.
1556
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;
1557
1573
  const messageId = options?.messageId
1558
- ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence += 1]);
1574
+ ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence]);
1559
1575
  const { messageIds } = await sendMessageWithRetryChunked(abortAwareClient, conversationId, text, {
1560
1576
  ...(options ?? {}),
1561
1577
  messageId,
@@ -2022,6 +2038,7 @@ export class CanonAgent {
2022
2038
  ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
2023
2039
  ...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
2024
2040
  });
2041
+ durableMessageSequence += 1;
2025
2042
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
2026
2043
  return result;
2027
2044
  }
@@ -2146,11 +2163,19 @@ export class CanonAgent {
2146
2163
  },
2147
2164
  noReply: async (reason) => {
2148
2165
  throwIfAborted();
2166
+ // First call wins — one silence decision per turn.
2167
+ if (!deliberatelySilent && reason?.trim()) {
2168
+ noReplyReason = reason;
2169
+ }
2149
2170
  deliberatelySilent = true;
2150
- // `reason` is handler-authored free text: record only that one was
2151
- // given, never the text itself.
2171
+ // `reason` is handler-authored free text: log only that one was given.
2152
2172
  console.error(`[canon-sdk] Turn chose no_reply for ${conversationId}`
2153
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.
2154
2179
  },
2155
2180
  setTool: async (text) => {
2156
2181
  await writeTurn('tool');
@@ -2224,6 +2249,22 @@ export class CanonAgent {
2224
2249
  // here there is nothing to hand off to, and holding the indicator would
2225
2250
  // read as "started to answer, then gave up".
2226
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
+ }
2227
2268
  // Always clear typing when done
2228
2269
  try {
2229
2270
  await this.typingSignals.clear(conversationId);
@@ -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
  }
package/dist/types.d.ts CHANGED
@@ -68,7 +68,9 @@ export interface TurnController {
68
68
  * message, so nothing is rendered and no other member or agent is triggered —
69
69
  * the agent-turn trigger is message-driven.
70
70
  *
71
- * `reason` is private: recorded as present or absent, never sent or rendered.
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.
72
74
  *
73
75
  * Unlike the model-driven runtimes, this does NOT gate `replyFinal`. Calling
74
76
  * both is two explicit decisions by the same author, so the text still lands;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "8.2.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": "^10.0.0"
31
+ "@canonmsg/core": "^10.2.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"