@canonmsg/backend-contracts 5.1.0 → 5.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
@@ -16,7 +16,7 @@ Node.js 18+. Ships both ESM and CommonJS builds — Cloud Functions consume the
16
16
 
17
17
  ## What is in it
18
18
 
19
- **The verb contract.** `canon.verbs.v1` (`verbContract.ts`) is the plaintext *intent* schema — sixteen verbs, their input and result schemas, byte limits, and rate limits. `canon.verb-wire.v1` (`verbWire.ts`) is what actually crosses the network: a server-readable envelope plus a body that is either `{ encoding: 'json', value }` or `{ encoding: 'mls', … }`. `projectVerbIntentToWire` and `mergeVerbWireToIntent` are the two halves of that split, so bindings and the server never disagree about which fields are envelope and which are content.
19
+ **The verb contract.** `canon.verbs.v1` (`verbContract.ts`) is the plaintext *intent* schema — seventeen verbs, their input and result schemas, byte limits, and rate limits. `canon.verb-wire.v1` (`verbWire.ts`) is what actually crosses the network: a server-readable envelope plus a body that is either `{ encoding: 'json', value }` or `{ encoding: 'mls', … }`. `projectVerbIntentToWire` and `mergeVerbWireToIntent` are the two halves of that split, so bindings and the server never disagree about which fields are envelope and which are content.
20
20
 
21
21
  ```ts
22
22
  import {
@@ -1,3 +1,5 @@
1
+ /** Conversation scope a policy is resolved for. Absent means "agent scope". */
2
+ export type PolicyConversationType = 'direct' | 'group' | 'unknown';
1
3
  export type ParticipationStyle = 'natural' | 'collaborative' | 'mention-first' | 'approval-gated' | 'handoff-only' | 'observer';
2
4
  /**
3
5
  * The stored/parsed shape. This is the RETURN type of
@@ -45,11 +47,17 @@ export interface ResolvedAgentBehaviorPolicyRecord {
45
47
  };
46
48
  }
47
49
  export interface ParticipationDecisionInput {
48
- conversationType: 'direct' | 'group' | 'unknown';
50
+ conversationType: PolicyConversationType;
49
51
  senderType: 'human' | 'ai_agent';
50
52
  isOwner: boolean;
51
53
  mentionedAgent: boolean;
52
54
  recentHumanCount?: number;
55
+ /**
56
+ * Consecutive agent turns already in the streak, NOT counting the message
57
+ * being evaluated. The message itself adds one more when its sender is an
58
+ * agent, and a delivery would add a further one — see the turn-cap check in
59
+ * {@link evaluateParticipationPolicy}.
60
+ */
53
61
  consecutiveAgentTurns?: number;
54
62
  currentAgentStreakStartedByHuman?: boolean;
55
63
  }
@@ -72,13 +80,50 @@ export interface ParticipationHistorySnapshot {
72
80
  currentAgentStreakStartedByHuman: boolean;
73
81
  }
74
82
  export declare const PARTICIPATION_HISTORY_FETCH_LIMIT = 50;
83
+ /**
84
+ * Safety backstop for groups with no explicit turn cap: at most this many
85
+ * consecutive agent turns before a human has to speak again.
86
+ *
87
+ * This is a backstop, not the steering wheel — agents are expected to stop on
88
+ * their own. Direct conversations deliberately stay unlimited: only a non-agent
89
+ * message resets the streak, so a default cap in a pure agent-to-agent DM would
90
+ * halt the room permanently instead of merely bounding a loop.
91
+ *
92
+ * The same caveat applies to the one group shape this default does cover: a
93
+ * group whose members are all agents (reachable — agent-created groups need no
94
+ * human member) has nothing that resets the streak, so it stops auto-replying
95
+ * for good once the cap is reached. That is deliberate: an unattended room is
96
+ * where a runaway loop is most expensive. Recovery needs a human member — the
97
+ * per-conversation `null` opt-out is writable only by a group owner or admin,
98
+ * who must be a member. Teaching the resolver "does this room contain a
99
+ * human?" would mean loading member types at every resolution site, including
100
+ * the stream gate, which does not read them today.
101
+ */
102
+ export declare const DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = 4;
75
103
  export declare function parseAgentBehaviorSettings(raw: unknown): AgentBehaviorSettingsRecord;
76
104
  export declare function normalizeStoredAgentBehaviorPolicy(raw: Record<string, unknown> | undefined): AgentBehaviorSettingsRecord | null;
77
105
  export declare function normalizeAgentBehaviorInstructions(value: string | null | undefined): string | null;
106
+ /**
107
+ * The scope-independent participation defaults. `maxConsecutiveAgentTurns` is
108
+ * `null` here because the turn cap is scope-dependent — a group with no stored
109
+ * setting resolves to {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS}. Use
110
+ * {@link resolveAgentBehaviorPolicy} with a `conversationType` to learn what a
111
+ * given conversation actually enforces.
112
+ */
78
113
  export declare function getDefaultParticipationPolicy(): ParticipationPolicy;
114
+ /**
115
+ * Coalesce agent defaults and a conversation override into the resolved record
116
+ * every runtime reads. `conversationType` selects the Canon-wide fallbacks that
117
+ * differ per scope — today only the turn cap, which defaults to
118
+ * {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS} in groups and to unlimited
119
+ * everywhere else. Omit it when resolving for the agent scope (no conversation);
120
+ * an absent or unknown type always resolves to the unlimited default, so a call
121
+ * site that forgets to thread it can never invent a cap.
122
+ */
79
123
  export declare function resolveAgentBehaviorPolicy(params?: {
80
124
  agentDefault?: AgentBehaviorSettingsInput | null;
81
125
  conversationOverride?: AgentBehaviorSettingsInput | null;
126
+ conversationType?: PolicyConversationType;
82
127
  }): ResolvedAgentBehaviorPolicyRecord;
83
128
  export declare function buildParticipationHistorySnapshot(messages: ParticipationHistoryMessage[], agentId?: string): ParticipationHistorySnapshot;
84
129
  export declare function appendParticipationHistoryMessage(snapshot: ParticipationHistorySnapshot, message: ParticipationHistoryMessage, limit?: number): ParticipationHistorySnapshot;
@@ -1,5 +1,30 @@
1
- import { resolveTurnMessageSemantics } from './turnProtocol.js';
1
+ import { shouldTriggerAgentTurn } from './turnProtocol.js';
2
2
  export const PARTICIPATION_HISTORY_FETCH_LIMIT = 50;
3
+ /**
4
+ * Safety backstop for groups with no explicit turn cap: at most this many
5
+ * consecutive agent turns before a human has to speak again.
6
+ *
7
+ * This is a backstop, not the steering wheel — agents are expected to stop on
8
+ * their own. Direct conversations deliberately stay unlimited: only a non-agent
9
+ * message resets the streak, so a default cap in a pure agent-to-agent DM would
10
+ * halt the room permanently instead of merely bounding a loop.
11
+ *
12
+ * The same caveat applies to the one group shape this default does cover: a
13
+ * group whose members are all agents (reachable — agent-created groups need no
14
+ * human member) has nothing that resets the streak, so it stops auto-replying
15
+ * for good once the cap is reached. That is deliberate: an unattended room is
16
+ * where a runaway loop is most expensive. Recovery needs a human member — the
17
+ * per-conversation `null` opt-out is writable only by a group owner or admin,
18
+ * who must be a member. Teaching the resolver "does this room contain a
19
+ * human?" would mean loading member types at every resolution site, including
20
+ * the stream gate, which does not read them today.
21
+ */
22
+ export const DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = 4;
23
+ function defaultMaxConsecutiveAgentTurns(conversationType) {
24
+ return conversationType === 'group'
25
+ ? DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS
26
+ : null;
27
+ }
3
28
  const VALID_PARTICIPATION_STYLES = new Set([
4
29
  'natural',
5
30
  'collaborative',
@@ -119,9 +144,25 @@ export function normalizeAgentBehaviorInstructions(value) {
119
144
  const trimmed = value.trim();
120
145
  return trimmed.length > 0 ? trimmed : null;
121
146
  }
147
+ /**
148
+ * The scope-independent participation defaults. `maxConsecutiveAgentTurns` is
149
+ * `null` here because the turn cap is scope-dependent — a group with no stored
150
+ * setting resolves to {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS}. Use
151
+ * {@link resolveAgentBehaviorPolicy} with a `conversationType` to learn what a
152
+ * given conversation actually enforces.
153
+ */
122
154
  export function getDefaultParticipationPolicy() {
123
155
  return { ...DEFAULT_POLICY.participation };
124
156
  }
157
+ /**
158
+ * Coalesce agent defaults and a conversation override into the resolved record
159
+ * every runtime reads. `conversationType` selects the Canon-wide fallbacks that
160
+ * differ per scope — today only the turn cap, which defaults to
161
+ * {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS} in groups and to unlimited
162
+ * everywhere else. Omit it when resolving for the agent scope (no conversation);
163
+ * an absent or unknown type always resolves to the unlimited default, so a call
164
+ * site that forgets to thread it can never invent a cap.
165
+ */
125
166
  export function resolveAgentBehaviorPolicy(params) {
126
167
  const agentDefault = params?.agentDefault ?? null;
127
168
  const conversationOverride = params?.conversationOverride ?? null;
@@ -140,7 +181,7 @@ export function resolveAgentBehaviorPolicy(params) {
140
181
  ? conversationOverride.maxConsecutiveAgentTurns ?? null
141
182
  : agentDefault?.maxConsecutiveAgentTurns !== undefined
142
183
  ? agentDefault.maxConsecutiveAgentTurns ?? null
143
- : DEFAULT_POLICY.participation.maxConsecutiveAgentTurns,
184
+ : defaultMaxConsecutiveAgentTurns(params?.conversationType),
144
185
  },
145
186
  instructions: [
146
187
  ...(defaultInstructions ? [defaultInstructions] : []),
@@ -152,14 +193,22 @@ export function resolveAgentBehaviorPolicy(params) {
152
193
  },
153
194
  };
154
195
  }
196
+ /**
197
+ * The streak counts exactly the agent messages that can trigger another agent —
198
+ * the only messages a loop can be built from. Reusing the trigger gate keeps the
199
+ * two in lockstep: it admits `control` messages that do not suppress auto-reply
200
+ * (hand-crafted runtime metadata can otherwise drive an uncounted loop) and
201
+ * still drops chunk parts, interim progress, card round-trips and media/failure
202
+ * notices, none of which trigger anyone and none of which should burn a slot.
203
+ */
155
204
  function toHistorySenderType(message) {
156
205
  if (message.senderType !== 'ai_agent') {
157
206
  return 'human';
158
207
  }
159
- return resolveTurnMessageSemantics({
160
- senderType: message.senderType,
208
+ return shouldTriggerAgentTurn({
209
+ senderType: 'ai_agent',
161
210
  metadata: message.metadata,
162
- }) === 'turn_complete'
211
+ }).allow
163
212
  ? 'ai_agent'
164
213
  : null;
165
214
  }
@@ -198,9 +247,17 @@ export function appendParticipationHistoryMessage(snapshot, message, limit = PAR
198
247
  ].slice(0, limit));
199
248
  }
200
249
  export function evaluateParticipationPolicy(policy, input) {
201
- const resolved = policy ?? resolveAgentBehaviorPolicy();
250
+ const resolved = policy ?? resolveAgentBehaviorPolicy({ conversationType: input.conversationType });
202
251
  const participation = resolved.participation;
203
- const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
252
+ const priorAgentTurns = Math.max(input.consecutiveAgentTurns ?? 0, 0);
253
+ /** Streak length once the message being evaluated is counted. */
254
+ const agentTurnsWithCurrent = priorAgentTurns
255
+ + (input.senderType === 'ai_agent' ? 1 : 0);
256
+ /**
257
+ * The long-running-collaboration brake reads "more than the current turn",
258
+ * so it keeps its historical floor rather than the exact streak length.
259
+ */
260
+ const consecutiveAgentTurns = Math.max(priorAgentTurns, input.senderType === 'ai_agent' ? 1 : 0);
204
261
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
205
262
  if (input.conversationType === 'group'
206
263
  && participation.requireMentionForGroupReplies
@@ -237,9 +294,13 @@ export function evaluateParticipationPolicy(policy, input) {
237
294
  reason: 'a fresh human steer is required before continuing agent collaboration',
238
295
  };
239
296
  }
297
+ // `maxConsecutiveAgentTurns: N` means at most N consecutive agent turns
298
+ // without a human in between. Delivering this message triggers turn
299
+ // `agentTurnsWithCurrent + 1`, so it is suppressed as soon as the streak has
300
+ // already reached N.
240
301
  if (typeof participation.maxConsecutiveAgentTurns === 'number'
241
302
  && participation.maxConsecutiveAgentTurns >= 0
242
- && consecutiveAgentTurns > participation.maxConsecutiveAgentTurns) {
303
+ && agentTurnsWithCurrent >= participation.maxConsecutiveAgentTurns) {
243
304
  return {
244
305
  allow: false,
245
306
  reasonCode: 'agent_turn_limit_reached',
@@ -32,7 +32,8 @@
32
32
  "leave_conversation",
33
33
  "list_contacts",
34
34
  "list_contact_requests",
35
- "list_conversations"
35
+ "list_conversations",
36
+ "no_reply"
36
37
  ]
37
38
  },
38
39
  "envelope": {
@@ -506,7 +507,8 @@
506
507
  "leave_conversation",
507
508
  "list_contacts",
508
509
  "list_contact_requests",
509
- "list_conversations"
510
+ "list_conversations",
511
+ "no_reply"
510
512
  ]
511
513
  },
512
514
  "result": {
@@ -45,7 +45,8 @@
45
45
  "maxApprovalTimeoutMs": 259200000,
46
46
  "turnIdChars": 128,
47
47
  "reactionEmojiChars": 64,
48
- "groupNameChars": 100
48
+ "groupNameChars": 100,
49
+ "noReplyReasonChars": 500
49
50
  },
50
51
  "rateLimits": {
51
52
  "senderMessagesPer5Min": 300,
@@ -1891,6 +1891,45 @@
1891
1891
  }
1892
1892
  }
1893
1893
  }
1894
+ },
1895
+ "no_reply_input": {
1896
+ "type": "object",
1897
+ "description": "End this turn without posting anything to the conversation. Nothing is rendered and no other member or agent is triggered.",
1898
+ "additionalProperties": false,
1899
+ "properties": {
1900
+ "conversationId": {
1901
+ "type": "string",
1902
+ "pattern": "^[A-Za-z0-9_.:-]{1,160}$"
1903
+ },
1904
+ "messageId": {
1905
+ "type": "string",
1906
+ "pattern": "^[A-Za-z0-9_.:-]{1,160}$",
1907
+ "description": "The triggering message this silence answers, so the record cannot be misread against a later message."
1908
+ },
1909
+ "reason": {
1910
+ "type": "string",
1911
+ "maxLength": 500,
1912
+ "description": "Never rendered; logged only."
1913
+ }
1914
+ }
1915
+ },
1916
+ "no_reply_result": {
1917
+ "type": "object",
1918
+ "required": [
1919
+ "status"
1920
+ ],
1921
+ "additionalProperties": true,
1922
+ "properties": {
1923
+ "status": {
1924
+ "const": "acknowledged"
1925
+ },
1926
+ "conversationId": {
1927
+ "type": "string"
1928
+ },
1929
+ "note": {
1930
+ "type": "string"
1931
+ }
1932
+ }
1894
1933
  }
1895
1934
  }
1896
1935
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PARTICIPATION_HISTORY_FETCH_LIMIT = void 0;
3
+ exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = exports.PARTICIPATION_HISTORY_FETCH_LIMIT = void 0;
4
4
  exports.parseAgentBehaviorSettings = parseAgentBehaviorSettings;
5
5
  exports.normalizeStoredAgentBehaviorPolicy = normalizeStoredAgentBehaviorPolicy;
6
6
  exports.normalizeAgentBehaviorInstructions = normalizeAgentBehaviorInstructions;
@@ -11,6 +11,31 @@ exports.appendParticipationHistoryMessage = appendParticipationHistoryMessage;
11
11
  exports.evaluateParticipationPolicy = evaluateParticipationPolicy;
12
12
  const turnProtocol_js_1 = require("./turnProtocol.js");
13
13
  exports.PARTICIPATION_HISTORY_FETCH_LIMIT = 50;
14
+ /**
15
+ * Safety backstop for groups with no explicit turn cap: at most this many
16
+ * consecutive agent turns before a human has to speak again.
17
+ *
18
+ * This is a backstop, not the steering wheel — agents are expected to stop on
19
+ * their own. Direct conversations deliberately stay unlimited: only a non-agent
20
+ * message resets the streak, so a default cap in a pure agent-to-agent DM would
21
+ * halt the room permanently instead of merely bounding a loop.
22
+ *
23
+ * The same caveat applies to the one group shape this default does cover: a
24
+ * group whose members are all agents (reachable — agent-created groups need no
25
+ * human member) has nothing that resets the streak, so it stops auto-replying
26
+ * for good once the cap is reached. That is deliberate: an unattended room is
27
+ * where a runaway loop is most expensive. Recovery needs a human member — the
28
+ * per-conversation `null` opt-out is writable only by a group owner or admin,
29
+ * who must be a member. Teaching the resolver "does this room contain a
30
+ * human?" would mean loading member types at every resolution site, including
31
+ * the stream gate, which does not read them today.
32
+ */
33
+ exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS = 4;
34
+ function defaultMaxConsecutiveAgentTurns(conversationType) {
35
+ return conversationType === 'group'
36
+ ? exports.DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS
37
+ : null;
38
+ }
14
39
  const VALID_PARTICIPATION_STYLES = new Set([
15
40
  'natural',
16
41
  'collaborative',
@@ -130,9 +155,25 @@ function normalizeAgentBehaviorInstructions(value) {
130
155
  const trimmed = value.trim();
131
156
  return trimmed.length > 0 ? trimmed : null;
132
157
  }
158
+ /**
159
+ * The scope-independent participation defaults. `maxConsecutiveAgentTurns` is
160
+ * `null` here because the turn cap is scope-dependent — a group with no stored
161
+ * setting resolves to {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS}. Use
162
+ * {@link resolveAgentBehaviorPolicy} with a `conversationType` to learn what a
163
+ * given conversation actually enforces.
164
+ */
133
165
  function getDefaultParticipationPolicy() {
134
166
  return { ...DEFAULT_POLICY.participation };
135
167
  }
168
+ /**
169
+ * Coalesce agent defaults and a conversation override into the resolved record
170
+ * every runtime reads. `conversationType` selects the Canon-wide fallbacks that
171
+ * differ per scope — today only the turn cap, which defaults to
172
+ * {@link DEFAULT_GROUP_MAX_CONSECUTIVE_AGENT_TURNS} in groups and to unlimited
173
+ * everywhere else. Omit it when resolving for the agent scope (no conversation);
174
+ * an absent or unknown type always resolves to the unlimited default, so a call
175
+ * site that forgets to thread it can never invent a cap.
176
+ */
136
177
  function resolveAgentBehaviorPolicy(params) {
137
178
  const agentDefault = params?.agentDefault ?? null;
138
179
  const conversationOverride = params?.conversationOverride ?? null;
@@ -151,7 +192,7 @@ function resolveAgentBehaviorPolicy(params) {
151
192
  ? conversationOverride.maxConsecutiveAgentTurns ?? null
152
193
  : agentDefault?.maxConsecutiveAgentTurns !== undefined
153
194
  ? agentDefault.maxConsecutiveAgentTurns ?? null
154
- : DEFAULT_POLICY.participation.maxConsecutiveAgentTurns,
195
+ : defaultMaxConsecutiveAgentTurns(params?.conversationType),
155
196
  },
156
197
  instructions: [
157
198
  ...(defaultInstructions ? [defaultInstructions] : []),
@@ -163,14 +204,22 @@ function resolveAgentBehaviorPolicy(params) {
163
204
  },
164
205
  };
165
206
  }
207
+ /**
208
+ * The streak counts exactly the agent messages that can trigger another agent —
209
+ * the only messages a loop can be built from. Reusing the trigger gate keeps the
210
+ * two in lockstep: it admits `control` messages that do not suppress auto-reply
211
+ * (hand-crafted runtime metadata can otherwise drive an uncounted loop) and
212
+ * still drops chunk parts, interim progress, card round-trips and media/failure
213
+ * notices, none of which trigger anyone and none of which should burn a slot.
214
+ */
166
215
  function toHistorySenderType(message) {
167
216
  if (message.senderType !== 'ai_agent') {
168
217
  return 'human';
169
218
  }
170
- return (0, turnProtocol_js_1.resolveTurnMessageSemantics)({
171
- senderType: message.senderType,
219
+ return (0, turnProtocol_js_1.shouldTriggerAgentTurn)({
220
+ senderType: 'ai_agent',
172
221
  metadata: message.metadata,
173
- }) === 'turn_complete'
222
+ }).allow
174
223
  ? 'ai_agent'
175
224
  : null;
176
225
  }
@@ -209,9 +258,17 @@ function appendParticipationHistoryMessage(snapshot, message, limit = exports.PA
209
258
  ].slice(0, limit));
210
259
  }
211
260
  function evaluateParticipationPolicy(policy, input) {
212
- const resolved = policy ?? resolveAgentBehaviorPolicy();
261
+ const resolved = policy ?? resolveAgentBehaviorPolicy({ conversationType: input.conversationType });
213
262
  const participation = resolved.participation;
214
- const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
263
+ const priorAgentTurns = Math.max(input.consecutiveAgentTurns ?? 0, 0);
264
+ /** Streak length once the message being evaluated is counted. */
265
+ const agentTurnsWithCurrent = priorAgentTurns
266
+ + (input.senderType === 'ai_agent' ? 1 : 0);
267
+ /**
268
+ * The long-running-collaboration brake reads "more than the current turn",
269
+ * so it keeps its historical floor rather than the exact streak length.
270
+ */
271
+ const consecutiveAgentTurns = Math.max(priorAgentTurns, input.senderType === 'ai_agent' ? 1 : 0);
215
272
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
216
273
  if (input.conversationType === 'group'
217
274
  && participation.requireMentionForGroupReplies
@@ -248,9 +305,13 @@ function evaluateParticipationPolicy(policy, input) {
248
305
  reason: 'a fresh human steer is required before continuing agent collaboration',
249
306
  };
250
307
  }
308
+ // `maxConsecutiveAgentTurns: N` means at most N consecutive agent turns
309
+ // without a human in between. Delivering this message triggers turn
310
+ // `agentTurnsWithCurrent + 1`, so it is suppressed as soon as the streak has
311
+ // already reached N.
251
312
  if (typeof participation.maxConsecutiveAgentTurns === 'number'
252
313
  && participation.maxConsecutiveAgentTurns >= 0
253
- && consecutiveAgentTurns > participation.maxConsecutiveAgentTurns) {
314
+ && agentTurnsWithCurrent >= participation.maxConsecutiveAgentTurns) {
254
315
  return {
255
316
  allow: false,
256
317
  reasonCode: 'agent_turn_limit_reached',
@@ -16,7 +16,9 @@
16
16
  *
17
17
  * Deliberately NOT verbs: replying in the active conversation, streaming
18
18
  * partials, typing, and read receipts stay host-mediated — the model talks
19
- * and the platform delivers. Still out of scope pending their own design
19
+ * and the platform delivers. `no_reply` is the deliberate exception: it is the
20
+ * ABSENCE of a reply, and only an intent can express that — silence has no
21
+ * host-mediated channel of its own. Still out of scope pending their own design
20
22
  * pass: plan approval (the fourth runtime-interaction kind — coding-host
21
23
  * concern today) and block/mute. Their REST surfaces remain directly
22
24
  * callable.
@@ -42,7 +44,7 @@
42
44
  * canon-verbs.limits.json) after schema validation.
43
45
  */
44
46
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.CANON_VERB_LIMITS_ARTIFACT = exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = exports.VERB_NATIVE_METADATA_KEYS = exports.CANON_VERB_NAMES = exports.SELF_CONTEXT_TYPE = exports.VERB_RATE_LIMITS = exports.VERB_LIMITS = exports.VERB_ID_PATTERNS = exports.CANON_CARD_SCHEMA_ID = exports.CANON_VERB_NAMESPACE = exports.CANON_VERBS_SCHEMA_ID = exports.CANON_VERBS_SCHEMA_VERSION = void 0;
47
+ exports.CANON_VERB_LIMITS_ARTIFACT = exports.NO_REPLY_ACK_NOTE = exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = exports.VERB_NATIVE_METADATA_KEYS = exports.CANON_VERB_NAMES = exports.SELF_CONTEXT_TYPE = exports.VERB_RATE_LIMITS = exports.VERB_LIMITS = exports.VERB_ID_PATTERNS = exports.CANON_CARD_SCHEMA_ID = exports.CANON_VERB_NAMESPACE = exports.CANON_VERBS_SCHEMA_ID = exports.CANON_VERBS_SCHEMA_VERSION = void 0;
46
48
  exports.normalizeVerbNativeMetadata = normalizeVerbNativeMetadata;
47
49
  exports.canonVerbToolName = canonVerbToolName;
48
50
  exports.findVerbByteLimitViolations = findVerbByteLimitViolations;
@@ -165,6 +167,12 @@ exports.VERB_LIMITS = {
165
167
  * CREATE does not length-check today — treat as the authoring contract).
166
168
  */
167
169
  groupNameChars: 100,
170
+ /**
171
+ * `no_reply.reason` authoring cap. There is no server enforcement beyond the
172
+ * intent schema — the handler logs the reason's presence and nothing else —
173
+ * so this constant is the canonical figure.
174
+ */
175
+ noReplyReasonChars: 500,
168
176
  };
169
177
  /**
170
178
  * Sender-side rate limits enforced by POST /messages/send
@@ -196,6 +204,7 @@ exports.CANON_VERB_NAMES = [
196
204
  'list_contacts',
197
205
  'list_contact_requests',
198
206
  'list_conversations',
207
+ 'no_reply',
199
208
  ];
200
209
  exports.VERB_NATIVE_METADATA_KEYS = [
201
210
  'runtime',
@@ -271,6 +280,13 @@ exports.CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = 'CREATE_GROUP_NO_ADDABLE_MEMBERS'
271
280
  * creates the group from the app instead).
272
281
  */
273
282
  exports.CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = 'CREATE_GROUP_CREATOR_SETUP_REQUIRED';
283
+ /**
284
+ * The `note` the server returns on a `no_reply` ack — the model's closure
285
+ * sentence. It lives here, not in a host, because every binding surfaces the
286
+ * server's result verbatim and a runtime that answers `no_reply` locally must
287
+ * say the same thing.
288
+ */
289
+ exports.NO_REPLY_ACK_NOTE = 'Acknowledged — nothing was posted to the conversation.';
274
290
  /** Flat tool name for a verb on namespaced tool surfaces (e.g. `canon_send_to`). */
275
291
  function canonVerbToolName(verb) {
276
292
  return `${exports.CANON_VERB_NAMESPACE}_${verb}`;
@@ -1058,6 +1058,35 @@ const list_conversations_result = {
1058
1058
  },
1059
1059
  },
1060
1060
  };
1061
+ const no_reply_input = {
1062
+ type: 'object',
1063
+ description: 'End this turn without posting anything to the conversation. Nothing is '
1064
+ + 'rendered and no other member or agent is triggered.',
1065
+ additionalProperties: false,
1066
+ properties: {
1067
+ conversationId: { type: 'string', pattern: verbContract_js_1.VERB_ID_PATTERNS.runtimeId },
1068
+ messageId: {
1069
+ type: 'string',
1070
+ pattern: verbContract_js_1.VERB_ID_PATTERNS.runtimeId,
1071
+ description: 'The triggering message this silence answers, so the record cannot be misread against a later message.',
1072
+ },
1073
+ reason: {
1074
+ type: 'string',
1075
+ maxLength: verbContract_js_1.VERB_LIMITS.noReplyReasonChars,
1076
+ description: 'Never rendered; logged only.',
1077
+ },
1078
+ },
1079
+ };
1080
+ const no_reply_result = {
1081
+ type: 'object',
1082
+ required: ['status'],
1083
+ additionalProperties: true,
1084
+ properties: {
1085
+ status: { const: 'acknowledged' },
1086
+ conversationId: { type: 'string' },
1087
+ note: { type: 'string' },
1088
+ },
1089
+ };
1061
1090
  // ---------------------------------------------------------------------------
1062
1091
  // Bundle
1063
1092
  // ---------------------------------------------------------------------------
@@ -1116,6 +1145,8 @@ exports.CANON_VERBS_JSON_SCHEMA = {
1116
1145
  list_contact_requests_result,
1117
1146
  list_conversations_input,
1118
1147
  list_conversations_result,
1148
+ no_reply_input,
1149
+ no_reply_result,
1119
1150
  },
1120
1151
  };
1121
1152
  exports.CANON_VERB_SCHEMA_REFS = Object.fromEntries(verbContract_js_1.CANON_VERB_NAMES.map((name) => [
@@ -118,6 +118,7 @@ exports.VERB_WIRE_ENVELOPE_FIELDS = {
118
118
  list_contacts: { required: [], optional: [] },
119
119
  list_contact_requests: { required: [], optional: [] },
120
120
  list_conversations: { required: [], optional: ['limit'] },
121
+ no_reply: { required: [], optional: ['conversationId', 'messageId'] },
121
122
  };
122
123
  // ---------------------------------------------------------------------------
123
124
  // JSON Schema
@@ -476,6 +477,11 @@ function projectVerbIntentToWire(verb, intent, options) {
476
477
  envelope = compact({ limit: input.limit });
477
478
  break;
478
479
  }
480
+ case 'no_reply': {
481
+ envelope = compact({ conversationId: input.conversationId, messageId: input.messageId });
482
+ value = compact({ reason: input.reason });
483
+ break;
484
+ }
479
485
  }
480
486
  return {
481
487
  wire: exports.CANON_VERB_WIRE_SCHEMA_VERSION,
@@ -613,6 +619,8 @@ function mergeVerbWireToIntent(request) {
613
619
  return {};
614
620
  case 'list_conversations':
615
621
  return compact({ limit: envelope.limit });
622
+ case 'no_reply':
623
+ return compact({ conversationId: envelope.conversationId, messageId: envelope.messageId, reason: value.reason });
616
624
  }
617
625
  }
618
626
  /**
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * Deliberately NOT verbs: replying in the active conversation, streaming
17
17
  * partials, typing, and read receipts stay host-mediated — the model talks
18
- * and the platform delivers. Still out of scope pending their own design
18
+ * and the platform delivers. `no_reply` is the deliberate exception: it is the
19
+ * ABSENCE of a reply, and only an intent can express that — silence has no
20
+ * host-mediated channel of its own. Still out of scope pending their own design
19
21
  * pass: plan approval (the fourth runtime-interaction kind — coding-host
20
22
  * concern today) and block/mute. Their REST surfaces remain directly
21
23
  * callable.
@@ -159,6 +161,12 @@ export declare const VERB_LIMITS: {
159
161
  * CREATE does not length-check today — treat as the authoring contract).
160
162
  */
161
163
  readonly groupNameChars: 100;
164
+ /**
165
+ * `no_reply.reason` authoring cap. There is no server enforcement beyond the
166
+ * intent schema — the handler logs the reason's presence and nothing else —
167
+ * so this constant is the canonical figure.
168
+ */
169
+ readonly noReplyReasonChars: 500;
162
170
  };
163
171
  /**
164
172
  * Sender-side rate limits enforced by POST /messages/send
@@ -173,7 +181,7 @@ export declare const VERB_RATE_LIMITS: {
173
181
  /** The one self-context type the platform accepts today. */
174
182
  export declare const SELF_CONTEXT_TYPE = "cross_session";
175
183
  /** Canonical verb names. */
176
- export declare const CANON_VERB_NAMES: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations"];
184
+ export declare const CANON_VERB_NAMES: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
177
185
  export type CanonVerbName = (typeof CANON_VERB_NAMES)[number];
178
186
  /** Private note-to-self attached to a cross-conversation send. */
179
187
  export interface VerbSelfContext {
@@ -692,6 +700,25 @@ export interface VerbConversationSummary {
692
700
  export interface ListConversationsResult {
693
701
  conversations: VerbConversationSummary[];
694
702
  }
703
+ /** Deliberate silence: end the turn without posting anything. */
704
+ export interface NoReplyInput {
705
+ /** Bindings default this to the active conversation when they have one. */
706
+ conversationId?: string;
707
+ /** Private rationale — logged only, never rendered. <= noReplyReasonChars. */
708
+ reason?: string;
709
+ }
710
+ export interface NoReplyResult {
711
+ status: 'acknowledged';
712
+ conversationId?: string;
713
+ note?: string;
714
+ }
715
+ /**
716
+ * The `note` the server returns on a `no_reply` ack — the model's closure
717
+ * sentence. It lives here, not in a host, because every binding surfaces the
718
+ * server's result verbatim and a runtime that answers `no_reply` locally must
719
+ * say the same thing.
720
+ */
721
+ export declare const NO_REPLY_ACK_NOTE = "Acknowledged \u2014 nothing was posted to the conversation.";
695
722
  /** Flat tool name for a verb on namespaced tool surfaces (e.g. `canon_send_to`). */
696
723
  export declare function canonVerbToolName(verb: CanonVerbName): string;
697
724
  export interface VerbLimitViolation {
@@ -791,6 +818,12 @@ export declare const CANON_VERB_LIMITS_ARTIFACT: {
791
818
  * CREATE does not length-check today — treat as the authoring contract).
792
819
  */
793
820
  readonly groupNameChars: 100;
821
+ /**
822
+ * `no_reply.reason` authoring cap. There is no server enforcement beyond the
823
+ * intent schema — the handler logs the reason's presence and nothing else —
824
+ * so this constant is the canonical figure.
825
+ */
826
+ readonly noReplyReasonChars: 500;
794
827
  };
795
828
  readonly rateLimits: {
796
829
  readonly senderMessagesPer5Min: 300;
@@ -15,7 +15,9 @@
15
15
  *
16
16
  * Deliberately NOT verbs: replying in the active conversation, streaming
17
17
  * partials, typing, and read receipts stay host-mediated — the model talks
18
- * and the platform delivers. Still out of scope pending their own design
18
+ * and the platform delivers. `no_reply` is the deliberate exception: it is the
19
+ * ABSENCE of a reply, and only an intent can express that — silence has no
20
+ * host-mediated channel of its own. Still out of scope pending their own design
19
21
  * pass: plan approval (the fourth runtime-interaction kind — coding-host
20
22
  * concern today) and block/mute. Their REST surfaces remain directly
21
23
  * callable.
@@ -159,6 +161,12 @@ export const VERB_LIMITS = {
159
161
  * CREATE does not length-check today — treat as the authoring contract).
160
162
  */
161
163
  groupNameChars: 100,
164
+ /**
165
+ * `no_reply.reason` authoring cap. There is no server enforcement beyond the
166
+ * intent schema — the handler logs the reason's presence and nothing else —
167
+ * so this constant is the canonical figure.
168
+ */
169
+ noReplyReasonChars: 500,
162
170
  };
163
171
  /**
164
172
  * Sender-side rate limits enforced by POST /messages/send
@@ -190,6 +198,7 @@ export const CANON_VERB_NAMES = [
190
198
  'list_contacts',
191
199
  'list_contact_requests',
192
200
  'list_conversations',
201
+ 'no_reply',
193
202
  ];
194
203
  export const VERB_NATIVE_METADATA_KEYS = [
195
204
  'runtime',
@@ -265,6 +274,13 @@ export const CREATE_GROUP_NO_ADDABLE_MEMBERS_CODE = 'CREATE_GROUP_NO_ADDABLE_MEM
265
274
  * creates the group from the app instead).
266
275
  */
267
276
  export const CREATE_GROUP_CREATOR_SETUP_REQUIRED_CODE = 'CREATE_GROUP_CREATOR_SETUP_REQUIRED';
277
+ /**
278
+ * The `note` the server returns on a `no_reply` ack — the model's closure
279
+ * sentence. It lives here, not in a host, because every binding surfaces the
280
+ * server's result verbatim and a runtime that answers `no_reply` locally must
281
+ * say the same thing.
282
+ */
283
+ export const NO_REPLY_ACK_NOTE = 'Acknowledged — nothing was posted to the conversation.';
268
284
  /** Flat tool name for a verb on namespaced tool surfaces (e.g. `canon_send_to`). */
269
285
  export function canonVerbToolName(verb) {
270
286
  return `${CANON_VERB_NAMESPACE}_${verb}`;
@@ -1700,6 +1700,43 @@ export declare const CANON_VERBS_JSON_SCHEMA: {
1700
1700
  };
1701
1701
  };
1702
1702
  };
1703
+ readonly no_reply_input: {
1704
+ readonly type: "object";
1705
+ readonly description: string;
1706
+ readonly additionalProperties: false;
1707
+ readonly properties: {
1708
+ readonly conversationId: {
1709
+ readonly type: "string";
1710
+ readonly pattern: "^[A-Za-z0-9_.:-]{1,160}$";
1711
+ };
1712
+ readonly messageId: {
1713
+ readonly type: "string";
1714
+ readonly pattern: "^[A-Za-z0-9_.:-]{1,160}$";
1715
+ readonly description: "The triggering message this silence answers, so the record cannot be misread against a later message.";
1716
+ };
1717
+ readonly reason: {
1718
+ readonly type: "string";
1719
+ readonly maxLength: 500;
1720
+ readonly description: "Never rendered; logged only.";
1721
+ };
1722
+ };
1723
+ };
1724
+ readonly no_reply_result: {
1725
+ readonly type: "object";
1726
+ readonly required: readonly ["status"];
1727
+ readonly additionalProperties: true;
1728
+ readonly properties: {
1729
+ readonly status: {
1730
+ readonly const: "acknowledged";
1731
+ };
1732
+ readonly conversationId: {
1733
+ readonly type: "string";
1734
+ };
1735
+ readonly note: {
1736
+ readonly type: "string";
1737
+ };
1738
+ };
1739
+ };
1703
1740
  };
1704
1741
  };
1705
1742
  /** Pointer pair into the bundle for one verb. */
@@ -1053,6 +1053,35 @@ const list_conversations_result = {
1053
1053
  },
1054
1054
  },
1055
1055
  };
1056
+ const no_reply_input = {
1057
+ type: 'object',
1058
+ description: 'End this turn without posting anything to the conversation. Nothing is '
1059
+ + 'rendered and no other member or agent is triggered.',
1060
+ additionalProperties: false,
1061
+ properties: {
1062
+ conversationId: { type: 'string', pattern: VERB_ID_PATTERNS.runtimeId },
1063
+ messageId: {
1064
+ type: 'string',
1065
+ pattern: VERB_ID_PATTERNS.runtimeId,
1066
+ description: 'The triggering message this silence answers, so the record cannot be misread against a later message.',
1067
+ },
1068
+ reason: {
1069
+ type: 'string',
1070
+ maxLength: VERB_LIMITS.noReplyReasonChars,
1071
+ description: 'Never rendered; logged only.',
1072
+ },
1073
+ },
1074
+ };
1075
+ const no_reply_result = {
1076
+ type: 'object',
1077
+ required: ['status'],
1078
+ additionalProperties: true,
1079
+ properties: {
1080
+ status: { const: 'acknowledged' },
1081
+ conversationId: { type: 'string' },
1082
+ note: { type: 'string' },
1083
+ },
1084
+ };
1056
1085
  // ---------------------------------------------------------------------------
1057
1086
  // Bundle
1058
1087
  // ---------------------------------------------------------------------------
@@ -1111,6 +1140,8 @@ export const CANON_VERBS_JSON_SCHEMA = {
1111
1140
  list_contact_requests_result,
1112
1141
  list_conversations_input,
1113
1142
  list_conversations_result,
1143
+ no_reply_input,
1144
+ no_reply_result,
1114
1145
  },
1115
1146
  };
1116
1147
  export const CANON_VERB_SCHEMA_REFS = Object.fromEntries(CANON_VERB_NAMES.map((name) => [
@@ -137,7 +137,7 @@ export declare const CANON_VERB_WIRE_JSON_SCHEMA: {
137
137
  readonly const: "canon.verb-wire.v1";
138
138
  };
139
139
  readonly verb: {
140
- readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations"];
140
+ readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
141
141
  };
142
142
  readonly envelope: {
143
143
  readonly $ref: "#/$defs/envelope";
@@ -509,7 +509,7 @@ export declare const CANON_VERB_WIRE_JSON_SCHEMA: {
509
509
  readonly const: "canon.verb-wire.v1";
510
510
  };
511
511
  readonly verb: {
512
- readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations"];
512
+ readonly enum: readonly ["send_to", "request_input", "request_approval", "check_approval", "send_card", "request_card", "share_contact", "react", "forward", "create_group", "add_member", "remove_member", "leave_conversation", "list_contacts", "list_contact_requests", "list_conversations", "no_reply"];
513
513
  };
514
514
  readonly result: {
515
515
  readonly $ref: "#/$defs/body";
package/dist/verbWire.js CHANGED
@@ -112,6 +112,7 @@ export const VERB_WIRE_ENVELOPE_FIELDS = {
112
112
  list_contacts: { required: [], optional: [] },
113
113
  list_contact_requests: { required: [], optional: [] },
114
114
  list_conversations: { required: [], optional: ['limit'] },
115
+ no_reply: { required: [], optional: ['conversationId', 'messageId'] },
115
116
  };
116
117
  // ---------------------------------------------------------------------------
117
118
  // JSON Schema
@@ -470,6 +471,11 @@ export function projectVerbIntentToWire(verb, intent, options) {
470
471
  envelope = compact({ limit: input.limit });
471
472
  break;
472
473
  }
474
+ case 'no_reply': {
475
+ envelope = compact({ conversationId: input.conversationId, messageId: input.messageId });
476
+ value = compact({ reason: input.reason });
477
+ break;
478
+ }
473
479
  }
474
480
  return {
475
481
  wire: CANON_VERB_WIRE_SCHEMA_VERSION,
@@ -607,6 +613,8 @@ export function mergeVerbWireToIntent(request) {
607
613
  return {};
608
614
  case 'list_conversations':
609
615
  return compact({ limit: envelope.limit });
616
+ case 'no_reply':
617
+ return compact({ conversationId: envelope.conversationId, messageId: envelope.messageId, reason: value.reason });
610
618
  }
611
619
  }
612
620
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/backend-contracts",
3
- "version": "5.1.0",
3
+ "version": "5.3.0",
4
4
  "description": "Canon backend contract helpers shared by Functions and stream-service",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "devDependencies": {
46
- "@canonmsg/rich-cards": "^0.8.7",
46
+ "@canonmsg/rich-cards": "^0.9.1",
47
47
  "@types/node": "^22.0.0",
48
48
  "ajv": "^8.20.0",
49
49
  "typescript": "~5.7.0",