@canonmsg/backend-contracts 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +3 -1
  2. package/dist/accessPolicy.d.ts +9 -0
  3. package/dist/accessPolicy.js +12 -0
  4. package/dist/agentBehaviorPolicy.d.ts +86 -15
  5. package/dist/agentBehaviorPolicy.js +103 -17
  6. package/dist/agentSearch.d.ts +17 -0
  7. package/dist/agentSearch.js +58 -0
  8. package/dist/canon-verb-wire.schema.json +4 -2
  9. package/dist/canon-verbs.limits.json +2 -1
  10. package/dist/canon-verbs.schema.json +34 -0
  11. package/dist/cjs/accessPolicy.js +15 -0
  12. package/dist/cjs/agentBehaviorPolicy.js +104 -17
  13. package/dist/cjs/agentSearch.js +64 -0
  14. package/dist/cjs/contactSources.js +45 -0
  15. package/dist/cjs/firestoreValues.js +27 -0
  16. package/dist/cjs/index.js +6 -0
  17. package/dist/cjs/moderation.js +12 -0
  18. package/dist/cjs/runtimeCardFields.js +30 -1
  19. package/dist/cjs/selfContext.js +33 -0
  20. package/dist/cjs/verbContract.js +18 -2
  21. package/dist/cjs/verbSchemas.js +26 -0
  22. package/dist/cjs/verbWire.js +8 -0
  23. package/dist/contactSources.d.ts +31 -0
  24. package/dist/contactSources.js +41 -0
  25. package/dist/firestoreValues.d.ts +9 -0
  26. package/dist/firestoreValues.js +23 -0
  27. package/dist/index.d.ts +6 -0
  28. package/dist/index.js +6 -0
  29. package/dist/moderation.d.ts +7 -0
  30. package/dist/moderation.js +9 -0
  31. package/dist/runtimeCardFields.d.ts +14 -2
  32. package/dist/runtimeCardFields.js +29 -0
  33. package/dist/selfContext.d.ts +9 -0
  34. package/dist/selfContext.js +29 -0
  35. package/dist/verbContract.d.ts +35 -2
  36. package/dist/verbContract.js +17 -1
  37. package/dist/verbSchemas.d.ts +32 -0
  38. package/dist/verbSchemas.js +26 -0
  39. package/dist/verbWire.d.ts +2 -2
  40. package/dist/verbWire.js +8 -0
  41. package/package.json +2 -2
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 {
@@ -34,6 +34,8 @@ The JSON Schemas are also emitted as files for non-JavaScript consumers: `@canon
34
34
 
35
35
  **Behavior policy and contact requests.** The participation evaluator the stream service runs before dispatching a turn, and the contact-request serializer both sides validate against.
36
36
 
37
+ **Server-side normalizers.** `readCanonicalPolicy` resolves a stored `inboundPolicy` / `groupJoinPolicy` field to the access triplet's three values, defaulting to `approval-required` rather than widening to `open`. `serializeSelfContext` is the agent-facing projection of a stored self-context. `readModerationStatus` is the single definition of an ejected account. All take plain document-shaped data — the package still performs no I/O.
38
+
37
39
  **Environments.** `CANON_ENVIRONMENT_CONTRACTS` binds `canon-dev-v1` and `canon-prod-v1` to their project and region; `getCanonEnvironmentContract` rejects anything else rather than defaulting.
38
40
 
39
41
  **Diff redaction.** Approval diffs carry pre-image context into a message every conversation member can read, so secret-shaped paths and tokens are suppressed before send — by the emitting host, and again defensively by the server.
@@ -0,0 +1,9 @@
1
+ /** The canonical access triplet's policy values (`inboundPolicy`, `groupJoinPolicy`). */
2
+ export type CanonicalPolicy = 'open' | 'approval-required' | 'owner-only';
3
+ /**
4
+ * Read a stored policy field, defaulting to the value Canon assigns on account
5
+ * creation. Anything unrecognized — missing, misspelled, or the retired
6
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
7
+ * 'open'.
8
+ */
9
+ export declare function readCanonicalPolicy(value: unknown): CanonicalPolicy;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read a stored policy field, defaulting to the value Canon assigns on account
3
+ * creation. Anything unrecognized — missing, misspelled, or the retired
4
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
5
+ * 'open'.
6
+ */
7
+ export function readCanonicalPolicy(value) {
8
+ if (value === 'open' || value === 'approval-required' || value === 'owner-only') {
9
+ return value;
10
+ }
11
+ return 'approval-required';
12
+ }
@@ -1,4 +1,13 @@
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';
4
+ /**
5
+ * The stored/parsed shape. This is the RETURN type of
6
+ * {@link parseAgentBehaviorSettings} and {@link normalizeStoredAgentBehaviorPolicy},
7
+ * so its optional fields deliberately stay `T | undefined` — widening them to
8
+ * `T | null` would be a breaking change for every reader of those results.
9
+ * Parameter positions take {@link AgentBehaviorSettingsInput} instead.
10
+ */
2
11
  export interface AgentBehaviorSettingsRecord {
3
12
  participationStyle?: ParticipationStyle;
4
13
  allowAgentToAgent?: boolean;
@@ -7,15 +16,30 @@ export interface AgentBehaviorSettingsRecord {
7
16
  maxConsecutiveAgentTurns?: number | null;
8
17
  instructions?: string | null;
9
18
  }
19
+ /**
20
+ * The input shape accepted by {@link resolveAgentBehaviorPolicy}. Every field
21
+ * additionally accepts an explicit `null` (an editor clearing a value), which
22
+ * coalesces exactly like `undefined` — the field falls through to the next
23
+ * layer. {@link AgentBehaviorSettingsRecord} is assignable to this type.
24
+ */
25
+ export interface AgentBehaviorSettingsInput {
26
+ participationStyle?: ParticipationStyle | null;
27
+ allowAgentToAgent?: boolean | null;
28
+ allowLongRunningCollaboration?: boolean | null;
29
+ requireMentionForGroupReplies?: boolean | null;
30
+ maxConsecutiveAgentTurns?: number | null;
31
+ instructions?: string | null;
32
+ }
33
+ export interface ParticipationPolicy {
34
+ style: ParticipationStyle;
35
+ allowAgentToAgent: boolean;
36
+ allowHumanToAgent: boolean;
37
+ allowLongRunningCollaboration: boolean;
38
+ requireMentionForGroupReplies: boolean;
39
+ maxConsecutiveAgentTurns?: number | null;
40
+ }
10
41
  export interface ResolvedAgentBehaviorPolicyRecord {
11
- participation: {
12
- style: ParticipationStyle;
13
- allowAgentToAgent: boolean;
14
- allowHumanToAgent: boolean;
15
- allowLongRunningCollaboration: boolean;
16
- requireMentionForGroupReplies: boolean;
17
- maxConsecutiveAgentTurns?: number | null;
18
- };
42
+ participation: ParticipationPolicy;
19
43
  instructions: string[];
20
44
  source: {
21
45
  hasAgentDefault: boolean;
@@ -23,15 +47,27 @@ export interface ResolvedAgentBehaviorPolicyRecord {
23
47
  };
24
48
  }
25
49
  export interface ParticipationDecisionInput {
26
- conversationType: 'direct' | 'group' | 'unknown';
50
+ conversationType: PolicyConversationType;
27
51
  senderType: 'human' | 'ai_agent';
28
52
  isOwner: boolean;
29
53
  mentionedAgent: boolean;
30
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
+ */
31
61
  consecutiveAgentTurns?: number;
32
62
  currentAgentStreakStartedByHuman?: boolean;
33
63
  }
64
+ export interface ParticipationDecision {
65
+ allow: boolean;
66
+ reasonCode: string;
67
+ reason: string;
68
+ }
34
69
  export interface ParticipationHistoryMessage {
70
+ id?: string;
35
71
  senderId: string;
36
72
  senderType: 'human' | 'ai_agent';
37
73
  metadata?: unknown;
@@ -44,16 +80,51 @@ export interface ParticipationHistorySnapshot {
44
80
  currentAgentStreakStartedByHuman: boolean;
45
81
  }
46
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;
47
103
  export declare function parseAgentBehaviorSettings(raw: unknown): AgentBehaviorSettingsRecord;
48
104
  export declare function normalizeStoredAgentBehaviorPolicy(raw: Record<string, unknown> | undefined): AgentBehaviorSettingsRecord | null;
49
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
+ */
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
+ */
50
123
  export declare function resolveAgentBehaviorPolicy(params?: {
51
- agentDefault?: AgentBehaviorSettingsRecord | null;
52
- conversationOverride?: AgentBehaviorSettingsRecord | null;
124
+ agentDefault?: AgentBehaviorSettingsInput | null;
125
+ conversationOverride?: AgentBehaviorSettingsInput | null;
126
+ conversationType?: PolicyConversationType;
53
127
  }): ResolvedAgentBehaviorPolicyRecord;
54
128
  export declare function buildParticipationHistorySnapshot(messages: ParticipationHistoryMessage[], agentId?: string): ParticipationHistorySnapshot;
55
129
  export declare function appendParticipationHistoryMessage(snapshot: ParticipationHistorySnapshot, message: ParticipationHistoryMessage, limit?: number): ParticipationHistorySnapshot;
56
- export declare function evaluateParticipationPolicy(policy: ResolvedAgentBehaviorPolicyRecord, input: ParticipationDecisionInput): {
57
- allow: boolean;
58
- reason: string;
59
- };
130
+ export declare function evaluateParticipationPolicy(policy: ResolvedAgentBehaviorPolicyRecord | null | undefined, input: ParticipationDecisionInput): ParticipationDecision;
@@ -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,6 +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
+ */
154
+ export function getDefaultParticipationPolicy() {
155
+ return { ...DEFAULT_POLICY.participation };
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
+ */
122
166
  export function resolveAgentBehaviorPolicy(params) {
123
167
  const agentDefault = params?.agentDefault ?? null;
124
168
  const conversationOverride = params?.conversationOverride ?? null;
@@ -137,7 +181,7 @@ export function resolveAgentBehaviorPolicy(params) {
137
181
  ? conversationOverride.maxConsecutiveAgentTurns ?? null
138
182
  : agentDefault?.maxConsecutiveAgentTurns !== undefined
139
183
  ? agentDefault.maxConsecutiveAgentTurns ?? null
140
- : DEFAULT_POLICY.participation.maxConsecutiveAgentTurns,
184
+ : defaultMaxConsecutiveAgentTurns(params?.conversationType),
141
185
  },
142
186
  instructions: [
143
187
  ...(defaultInstructions ? [defaultInstructions] : []),
@@ -149,14 +193,22 @@ export function resolveAgentBehaviorPolicy(params) {
149
193
  },
150
194
  };
151
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
+ */
152
204
  function toHistorySenderType(message) {
153
205
  if (message.senderType !== 'ai_agent') {
154
206
  return 'human';
155
207
  }
156
- return resolveTurnMessageSemantics({
157
- senderType: message.senderType,
208
+ return shouldTriggerAgentTurn({
209
+ senderType: 'ai_agent',
158
210
  metadata: message.metadata,
159
- }) === 'turn_complete'
211
+ }).allow
160
212
  ? 'ai_agent'
161
213
  : null;
162
214
  }
@@ -195,37 +247,71 @@ export function appendParticipationHistoryMessage(snapshot, message, limit = PAR
195
247
  ].slice(0, limit));
196
248
  }
197
249
  export function evaluateParticipationPolicy(policy, input) {
198
- const consecutiveAgentTurns = Math.max(input.consecutiveAgentTurns ?? (input.senderType === 'ai_agent' ? 1 : 0), input.senderType === 'ai_agent' ? 1 : 0);
250
+ const resolved = policy ?? resolveAgentBehaviorPolicy({ conversationType: input.conversationType });
251
+ const participation = resolved.participation;
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);
199
261
  const currentAgentStreakStartedByHuman = input.currentAgentStreakStartedByHuman === true;
200
262
  if (input.conversationType === 'group'
201
- && policy.participation.requireMentionForGroupReplies
263
+ && participation.requireMentionForGroupReplies
202
264
  && !input.mentionedAgent) {
203
265
  return {
204
266
  allow: false,
267
+ reasonCode: 'group_mention_required',
205
268
  reason: 'group replies require a direct mention',
206
269
  };
207
270
  }
208
271
  if (input.isOwner) {
209
- return { allow: true, reason: 'owner messages pass through outside mention-required group turns' };
272
+ return {
273
+ allow: true,
274
+ reasonCode: 'owner_sender',
275
+ reason: 'owner messages pass through outside mention-required group turns',
276
+ };
210
277
  }
211
278
  if (input.senderType !== 'ai_agent') {
212
- return { allow: true, reason: 'latest sender is human' };
279
+ return { allow: true, reasonCode: 'human_sender', reason: 'latest sender is human' };
213
280
  }
214
- if (!policy.participation.allowAgentToAgent) {
215
- return { allow: false, reason: 'agent-to-agent participation disabled by policy' };
281
+ if (!participation.allowAgentToAgent) {
282
+ return {
283
+ allow: false,
284
+ reasonCode: 'agent_to_agent_disabled',
285
+ reason: 'agent-to-agent participation is disabled by policy',
286
+ };
216
287
  }
217
- if (!policy.participation.allowLongRunningCollaboration
288
+ if (!participation.allowLongRunningCollaboration
218
289
  && (consecutiveAgentTurns > 1
219
290
  || !currentAgentStreakStartedByHuman)) {
220
291
  return {
221
292
  allow: false,
293
+ reasonCode: 'human_reset_required',
222
294
  reason: 'a fresh human steer is required before continuing agent collaboration',
223
295
  };
224
296
  }
225
- if (typeof policy.participation.maxConsecutiveAgentTurns === 'number'
226
- && policy.participation.maxConsecutiveAgentTurns >= 0
227
- && consecutiveAgentTurns > policy.participation.maxConsecutiveAgentTurns) {
228
- return { allow: false, reason: 'maximum consecutive agent turns reached' };
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.
301
+ if (typeof participation.maxConsecutiveAgentTurns === 'number'
302
+ && participation.maxConsecutiveAgentTurns >= 0
303
+ && agentTurnsWithCurrent >= participation.maxConsecutiveAgentTurns) {
304
+ return {
305
+ allow: false,
306
+ reasonCode: 'agent_turn_limit_reached',
307
+ reason: 'maximum consecutive agent turns reached',
308
+ };
229
309
  }
230
- return { allow: true, reason: 'agent-to-agent participation allowed by policy' };
310
+ return {
311
+ allow: true,
312
+ reasonCode: input.conversationType === 'group' ? 'group_agent_allowed' : 'direct_agent_allowed',
313
+ reason: participation.requireMentionForGroupReplies
314
+ ? 'policy allows this directly mentioned group reply'
315
+ : 'agent-to-agent participation allowed by policy',
316
+ };
231
317
  }
@@ -0,0 +1,17 @@
1
+ /** The indexable fields a stored agent contributes to `searchPrefixes`. */
2
+ export interface AgentSearchIndexFields {
3
+ displayName?: string | null;
4
+ description?: string | null;
5
+ }
6
+ /** The agent shape a directory filter reads. */
7
+ export interface AgentSearchProfile {
8
+ displayName?: string | null;
9
+ agentConfig?: {
10
+ description?: string | null;
11
+ } | null;
12
+ }
13
+ /** Tolerant: non-string input normalises to ''. */
14
+ export declare function normalizeAgentDirectoryQuery(value: unknown): string;
15
+ export declare function getAgentDirectoryQueryTokens(query: string): string[];
16
+ export declare function buildAgentSearchPrefixes(input: AgentSearchIndexFields): string[];
17
+ export declare function matchesAgentSearchQuery(profile: AgentSearchProfile, query: string): boolean;
@@ -0,0 +1,58 @@
1
+ // The single agent-directory search implementation, shared by Cloud Functions
2
+ // (which writes `users/{agentId}.searchPrefixes` and filters discovery reads)
3
+ // and `@canonmsg/chat-domain` (which filters the same agents client-side in the
4
+ // app and on web). The server index and the client filter must agree, so both
5
+ // sides derive from these bodies rather than from hand-synced copies.
6
+ //
7
+ // Pure ECMAScript: no imports, no Node built-ins, no Firestore types — safe in
8
+ // CJS, in the browser bundle, and on Hermes.
9
+ const MAX_QUERY_LENGTH = 64;
10
+ const MAX_TOKEN_PREFIX_LENGTH = 32;
11
+ const MAX_SEARCH_PREFIXES = 256;
12
+ const TOKEN_PATTERN = /[\p{L}\p{N}]+/gu;
13
+ function tokenizeAgentSearchValue(value) {
14
+ const normalized = value
15
+ .normalize('NFKD')
16
+ .toLowerCase()
17
+ .trim();
18
+ return normalized.match(TOKEN_PATTERN) ?? [];
19
+ }
20
+ /** Tolerant: non-string input normalises to ''. */
21
+ export function normalizeAgentDirectoryQuery(value) {
22
+ if (typeof value !== 'string')
23
+ return '';
24
+ return tokenizeAgentSearchValue(value).join(' ').slice(0, MAX_QUERY_LENGTH);
25
+ }
26
+ export function getAgentDirectoryQueryTokens(query) {
27
+ return tokenizeAgentSearchValue(query).map((token) => token.slice(0, MAX_QUERY_LENGTH));
28
+ }
29
+ export function buildAgentSearchPrefixes(input) {
30
+ const seen = new Set();
31
+ for (const token of tokenizeAgentSearchValue([input.displayName, input.description].filter(Boolean).join(' '))) {
32
+ const normalizedToken = token.slice(0, MAX_QUERY_LENGTH);
33
+ const prefixLength = Math.min(normalizedToken.length, MAX_TOKEN_PREFIX_LENGTH);
34
+ for (let index = 1; index <= prefixLength; index += 1) {
35
+ seen.add(normalizedToken.slice(0, index));
36
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
37
+ return [...seen];
38
+ }
39
+ }
40
+ if (normalizedToken.length > MAX_TOKEN_PREFIX_LENGTH) {
41
+ seen.add(normalizedToken);
42
+ if (seen.size >= MAX_SEARCH_PREFIXES) {
43
+ return [...seen];
44
+ }
45
+ }
46
+ }
47
+ return [...seen];
48
+ }
49
+ export function matchesAgentSearchQuery(profile, query) {
50
+ const tokens = getAgentDirectoryQueryTokens(query);
51
+ if (tokens.length === 0)
52
+ return true;
53
+ const prefixes = new Set(buildAgentSearchPrefixes({
54
+ displayName: profile.displayName,
55
+ description: profile.agentConfig?.description,
56
+ }));
57
+ return tokens.every((token) => prefixes.has(token));
58
+ }
@@ -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,40 @@
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
+ "reason": {
1905
+ "type": "string",
1906
+ "maxLength": 500,
1907
+ "description": "Never rendered; logged only."
1908
+ }
1909
+ }
1910
+ },
1911
+ "no_reply_result": {
1912
+ "type": "object",
1913
+ "required": [
1914
+ "status"
1915
+ ],
1916
+ "additionalProperties": true,
1917
+ "properties": {
1918
+ "status": {
1919
+ "const": "acknowledged"
1920
+ },
1921
+ "conversationId": {
1922
+ "type": "string"
1923
+ },
1924
+ "note": {
1925
+ "type": "string"
1926
+ }
1927
+ }
1894
1928
  }
1895
1929
  }
1896
1930
  }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readCanonicalPolicy = readCanonicalPolicy;
4
+ /**
5
+ * Read a stored policy field, defaulting to the value Canon assigns on account
6
+ * creation. Anything unrecognized — missing, misspelled, or the retired
7
+ * `'private'` enum — reads as 'approval-required'. This must never widen to
8
+ * 'open'.
9
+ */
10
+ function readCanonicalPolicy(value) {
11
+ if (value === 'open' || value === 'approval-required' || value === 'owner-only') {
12
+ return value;
13
+ }
14
+ return 'approval-required';
15
+ }