@canonmsg/agent-sdk 11.2.1 → 12.1.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 (181) hide show
  1. package/README.md +248 -523
  2. package/dist/chunks/chunk-7N6OPPW4.js +5430 -0
  3. package/dist/chunks/chunk-N722GWCP.js +4897 -0
  4. package/dist/chunks/chunk-QZ4PPRQW.js +14247 -0
  5. package/dist/chunks/mls-crypto-LMNQZOS6.js +10 -0
  6. package/dist/cli.js +572 -0
  7. package/dist/drivers/claude.d.ts +42 -0
  8. package/dist/drivers/code.d.ts +18 -0
  9. package/dist/drivers/codex.d.ts +46 -0
  10. package/dist/drivers/connector.d.ts +34 -0
  11. package/dist/index.d.ts +20 -25
  12. package/dist/index.js +91 -13
  13. package/dist/internal/backend-contracts/agentBehaviorPolicy.d.ts +122 -0
  14. package/dist/internal/backend-contracts/agentDirectory.d.ts +32 -0
  15. package/dist/internal/backend-contracts/agentOrigin.d.ts +23 -0
  16. package/dist/internal/backend-contracts/agentSearch.d.ts +17 -0
  17. package/dist/internal/backend-contracts/api-origins.generated.d.ts +4 -0
  18. package/dist/internal/backend-contracts/apiErrors.d.ts +1 -0
  19. package/dist/internal/backend-contracts/artifactActions.d.ts +8 -0
  20. package/dist/internal/backend-contracts/artifacts.d.ts +386 -0
  21. package/dist/internal/backend-contracts/clientFloor.d.ts +41 -0
  22. package/dist/internal/backend-contracts/communication.d.ts +146 -0
  23. package/dist/internal/backend-contracts/connectorRelay.d.ts +179 -0
  24. package/dist/internal/backend-contracts/conversationMembership.d.ts +28 -0
  25. package/dist/internal/backend-contracts/deliveryObservation.d.ts +23 -0
  26. package/dist/internal/backend-contracts/diffRedaction.d.ts +6 -0
  27. package/dist/internal/backend-contracts/endpoint-unread.d.ts +4 -0
  28. package/dist/internal/backend-contracts/endpoint.d.ts +120 -0
  29. package/dist/internal/backend-contracts/endpointContentPolicy.d.ts +48 -0
  30. package/dist/internal/backend-contracts/environment.d.ts +20 -0
  31. package/dist/internal/backend-contracts/externalInbound.d.ts +24 -0
  32. package/dist/internal/backend-contracts/firestoreValues.d.ts +9 -0
  33. package/dist/internal/backend-contracts/index.d.ts +54 -0
  34. package/dist/internal/backend-contracts/media-url.d.ts +20 -0
  35. package/dist/internal/backend-contracts/media.d.ts +51 -0
  36. package/dist/internal/backend-contracts/mediaDelivery.d.ts +46 -0
  37. package/dist/internal/backend-contracts/mediaTransferProtocol.d.ts +17 -0
  38. package/dist/internal/backend-contracts/membershipChange.d.ts +21 -0
  39. package/dist/internal/backend-contracts/message.d.ts +86 -0
  40. package/dist/internal/backend-contracts/messageText.d.ts +6 -0
  41. package/dist/internal/backend-contracts/mlsDevApplication.d.ts +161 -0
  42. package/dist/internal/backend-contracts/mlsDevCredential.d.ts +78 -0
  43. package/dist/internal/backend-contracts/mlsDevCredentialReconciliation.d.ts +45 -0
  44. package/dist/internal/backend-contracts/mlsDevDeviceCustody.d.ts +52 -0
  45. package/dist/internal/backend-contracts/mlsDevEnrollment.d.ts +75 -0
  46. package/dist/internal/backend-contracts/mlsDevFeedReceipt.d.ts +43 -0
  47. package/dist/internal/backend-contracts/mlsDevGroup.d.ts +321 -0
  48. package/dist/internal/backend-contracts/mlsDevGroupFeed.d.ts +51 -0
  49. package/dist/internal/backend-contracts/mlsDevInteraction.d.ts +159 -0
  50. package/dist/internal/backend-contracts/mlsDevKeyPackage.d.ts +42 -0
  51. package/dist/internal/backend-contracts/mlsDevKeyPackages.d.ts +55 -0
  52. package/dist/internal/backend-contracts/mlsEnvironment.d.ts +35 -0
  53. package/dist/internal/backend-contracts/mlsPrincipalAlias.d.ts +23 -0
  54. package/dist/internal/backend-contracts/moderation.d.ts +7 -0
  55. package/dist/internal/backend-contracts/plaintextProcessors.d.ts +63 -0
  56. package/dist/internal/backend-contracts/preparedContent.d.ts +54 -0
  57. package/dist/internal/backend-contracts/replyAuthority.d.ts +14 -0
  58. package/dist/internal/backend-contracts/runtimeCardFields.d.ts +66 -0
  59. package/dist/internal/backend-contracts/runtimeCardStorage.d.ts +3 -0
  60. package/dist/internal/backend-contracts/runtimeDescriptor.d.ts +88 -0
  61. package/dist/internal/backend-contracts/selfContext.d.ts +9 -0
  62. package/dist/internal/backend-contracts/turnProtocol.d.ts +43 -0
  63. package/dist/internal/backend-contracts/upload-mime-types.d.ts +17 -0
  64. package/dist/internal/backend-contracts/verbContract.d.ts +856 -0
  65. package/dist/internal/backend-contracts/verbSchemas.d.ts +1717 -0
  66. package/dist/internal/backend-contracts/verbWire.d.ts +493 -0
  67. package/dist/internal/backend-contracts/webAuth.d.ts +8 -0
  68. package/dist/internal/core/agent-profiles.d.ts +65 -0
  69. package/dist/internal/core/approval-format.d.ts +22 -0
  70. package/dist/internal/core/approval-manager.d.ts +43 -0
  71. package/dist/internal/core/approval-types.d.ts +73 -0
  72. package/dist/internal/core/base-url.d.ts +1 -0
  73. package/dist/internal/core/cli-metadata.d.ts +13 -0
  74. package/dist/internal/core/client.d.ts +432 -0
  75. package/dist/internal/core/completed-output-journal.d.ts +39 -0
  76. package/dist/internal/core/constants.d.ts +1 -0
  77. package/dist/internal/core/endpoint-client.d.ts +56 -0
  78. package/dist/internal/core/execution-environment-mode.d.ts +6 -0
  79. package/dist/internal/core/gifs.d.ts +29 -0
  80. package/dist/internal/core/host-runtime.d.ts +53 -0
  81. package/dist/internal/core/index.d.ts +68 -0
  82. package/dist/internal/core/node-media.d.ts +6 -0
  83. package/dist/internal/core/policy.d.ts +2 -0
  84. package/dist/internal/core/registration-profile.d.ts +10 -0
  85. package/dist/internal/core/registration-session.d.ts +55 -0
  86. package/dist/internal/core/registration.d.ts +5 -0
  87. package/dist/internal/core/retry.d.ts +113 -0
  88. package/dist/internal/core/rtdb-rest.d.ts +141 -0
  89. package/dist/internal/core/runtime-cards.d.ts +286 -0
  90. package/dist/internal/core/runtime-descriptor.d.ts +16 -0
  91. package/dist/internal/core/runtime-endpoints.d.ts +26 -0
  92. package/dist/internal/core/runtime-heartbeat.d.ts +29 -0
  93. package/dist/internal/core/runtime-presentation.d.ts +9 -0
  94. package/dist/internal/core/runtime-request-manager.d.ts +283 -0
  95. package/dist/internal/core/runtime-state-publisher.d.ts +42 -0
  96. package/dist/internal/core/self-context.d.ts +22 -0
  97. package/dist/internal/core/silent-turn.d.ts +49 -0
  98. package/dist/internal/core/stream.d.ts +78 -0
  99. package/dist/internal/core/turn-protocol.d.ts +28 -0
  100. package/dist/internal/core/turn-verbosity.d.ts +85 -0
  101. package/dist/internal/core/types.d.ts +964 -0
  102. package/dist/internal/core/unified-diff.d.ts +35 -0
  103. package/dist/internal/endpoint/artifact-images.d.ts +60 -0
  104. package/dist/internal/endpoint/artifact-reducer.d.ts +35 -0
  105. package/dist/internal/endpoint/artifact-views.d.ts +113 -0
  106. package/dist/internal/endpoint/artifacts.d.ts +75 -0
  107. package/dist/internal/endpoint/delivery.d.ts +47 -0
  108. package/dist/internal/endpoint/engine.d.ts +249 -0
  109. package/dist/internal/endpoint/ephemeral.d.ts +8 -0
  110. package/dist/internal/endpoint/events.d.ts +32 -0
  111. package/dist/internal/endpoint/external-inbound.d.ts +36 -0
  112. package/dist/internal/endpoint/handoff.d.ts +50 -0
  113. package/dist/internal/endpoint/history.d.ts +22 -0
  114. package/dist/internal/endpoint/image-generation-errors.d.ts +2 -0
  115. package/dist/internal/endpoint/inbound.d.ts +29 -0
  116. package/dist/internal/endpoint/index.d.ts +32 -0
  117. package/dist/internal/endpoint/media.d.ts +177 -0
  118. package/dist/internal/endpoint/migration.d.ts +50 -0
  119. package/dist/internal/endpoint/mls-crypto.d.ts +32 -0
  120. package/dist/internal/endpoint/mls-dev-client.d.ts +52 -0
  121. package/dist/internal/endpoint/mls-dev-device.d.ts +69 -0
  122. package/dist/internal/endpoint/mls-dev-groups.d.ts +174 -0
  123. package/dist/internal/endpoint/mls-dev-interactions.d.ts +99 -0
  124. package/dist/internal/endpoint/mls-dev-key-package-service.d.ts +46 -0
  125. package/dist/internal/endpoint/mls-dev-key-packages.d.ts +62 -0
  126. package/dist/internal/endpoint/mls-dev-text.d.ts +67 -0
  127. package/dist/internal/endpoint/mls-dev-transport.d.ts +38 -0
  128. package/dist/internal/endpoint/node-mls-custody-protocol.d.ts +29 -0
  129. package/dist/internal/endpoint/node-mls-custody.d.ts +15 -0
  130. package/dist/internal/endpoint/node.d.ts +7 -0
  131. package/dist/internal/endpoint/observe-query.d.ts +17 -0
  132. package/dist/internal/endpoint/portable-node-mls-custody.d.ts +23 -0
  133. package/dist/internal/endpoint/queue.d.ts +22 -0
  134. package/dist/internal/endpoint/recovery.d.ts +27 -0
  135. package/dist/internal/endpoint/retention.d.ts +7 -0
  136. package/dist/internal/endpoint/store.d.ts +22 -0
  137. package/dist/internal/endpoint/sync.d.ts +33 -0
  138. package/dist/internal/endpoint/transient-publisher.d.ts +8 -0
  139. package/dist/internal/endpoint/transport.d.ts +63 -0
  140. package/dist/media.d.ts +1 -1
  141. package/dist/media.js +42 -541
  142. package/dist/runner/errands.d.ts +118 -0
  143. package/dist/runner/mls.d.ts +97 -0
  144. package/dist/runner/relay.d.ts +87 -0
  145. package/dist/runner/runner.d.ts +98 -0
  146. package/dist/runner/types.d.ts +215 -0
  147. package/package.json +18 -5
  148. package/dist/attached-session.d.ts +0 -58
  149. package/dist/attached-session.js +0 -638
  150. package/dist/bounded-file-read.d.ts +0 -14
  151. package/dist/bounded-file-read.js +0 -34
  152. package/dist/canon-agent.d.ts +0 -236
  153. package/dist/canon-agent.js +0 -2531
  154. package/dist/debouncer.d.ts +0 -18
  155. package/dist/debouncer.js +0 -133
  156. package/dist/inbound-handoff.d.ts +0 -6
  157. package/dist/inbound-handoff.js +0 -17
  158. package/dist/managed-session-host.d.ts +0 -27
  159. package/dist/managed-session-host.js +0 -195
  160. package/dist/managed-session-setup.d.ts +0 -99
  161. package/dist/managed-session-setup.js +0 -234
  162. package/dist/realtime.d.ts +0 -62
  163. package/dist/realtime.js +0 -193
  164. package/dist/runtime-card.d.ts +0 -48
  165. package/dist/runtime-card.js +0 -64
  166. package/dist/session-manager.d.ts +0 -77
  167. package/dist/session-manager.js +0 -285
  168. package/dist/turn-streaming-request.d.ts +0 -17
  169. package/dist/turn-streaming-request.js +0 -22
  170. package/dist/turn-verbosity-option.d.ts +0 -33
  171. package/dist/turn-verbosity-option.js +0 -23
  172. package/dist/types.d.ts +0 -387
  173. package/dist/types.js +0 -1
  174. package/dist/work-session-coordination.d.ts +0 -21
  175. package/dist/work-session-coordination.js +0 -53
  176. package/dist/work-session-host.d.ts +0 -31
  177. package/dist/work-session-host.js +0 -617
  178. package/dist/work-session-interactions.d.ts +0 -12
  179. package/dist/work-session-interactions.js +0 -173
  180. package/dist/work-session-state.d.ts +0 -36
  181. package/dist/work-session-state.js +0 -62
@@ -1,2531 +0,0 @@
1
- import { readArtifactContext } from '@canonmsg/core';
2
- import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectInboundSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
3
- import { createHash, randomUUID } from 'node:crypto';
4
- import { Debouncer } from './debouncer.js';
5
- import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
6
- import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
7
- import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
8
- import { SessionManager } from './session-manager.js';
9
- import { buildTurnStreamingRequest } from './turn-streaming-request.js';
10
- import { selectConfiguredTurnVerbosity } from './turn-verbosity-option.js';
11
- const RUNTIME_CONTROL_POLL_INTERVAL_MS = 2_000;
12
- const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
13
- const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
14
- const SDK_MESSAGE_ID_READABLE_MAX = 120;
15
- /** Canon's message id ceiling, matching core's chunked sender. */
16
- const CANON_MESSAGE_ID_MAX = 160;
17
- const SDK_PARTIAL_FINAL_NOTICE = 'This reply stops short because Canon could not deliver the remaining text.';
18
- const SDK_RUNTIME_CAPABILITIES = {
19
- supportsInterrupt: false,
20
- supportsInputInterrupt: false,
21
- supportsQueue: true,
22
- supportsInterleave: false,
23
- supportsRequiresAction: true,
24
- supportsNonFinalPermanentMessages: false,
25
- };
26
- const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
27
- coreControls: [],
28
- runtimeControls: [],
29
- supportsInterrupt: false,
30
- streamingTextMode: 'snapshot',
31
- runtimeCards: {
32
- rich: {
33
- schema: 'canon.card.v1',
34
- lifecycle: 'blocking_requires_action',
35
- responder: 'agent_owner',
36
- result: 'action_or_values',
37
- maxTimeoutMs: 30 * 60_000,
38
- blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions', 'mediaPreview', 'details'],
39
- actionFieldTypes: ['text', 'textarea', 'select', 'multiSelect', 'boolean', 'date', 'number', 'currency', 'searchSelect', 'lineItems'],
40
- native: true,
41
- },
42
- },
43
- };
44
- const STANDARD_PRIMITIVE_COMMANDS = {
45
- 'runtime.status': {
46
- id: 'runtime-status',
47
- label: 'Runtime status',
48
- description: 'Ask the runtime for its current status.',
49
- primitive: 'runtime.status',
50
- aliases: ['status'],
51
- category: 'runtime',
52
- placements: ['composer_slash', 'command_palette'],
53
- availability: ['always'],
54
- dispatch: { kind: 'primitive', primitive: 'runtime.status' },
55
- },
56
- 'runtime.reasoning.set': {
57
- id: 'thinking-level',
58
- label: 'Thinking level',
59
- description: 'Set the runtime reasoning or effort level.',
60
- primitive: 'runtime.reasoning.set',
61
- aliases: ['think', 'effort'],
62
- category: 'runtime',
63
- placements: ['composer_slash', 'command_palette'],
64
- availability: ['always'],
65
- args: [{
66
- id: 'level',
67
- label: 'Level',
68
- kind: 'enum',
69
- required: true,
70
- choices: [
71
- { value: 'low', label: 'Low' },
72
- { value: 'medium', label: 'Medium' },
73
- { value: 'high', label: 'High' },
74
- ],
75
- }],
76
- dispatch: { kind: 'primitive', primitive: 'runtime.reasoning.set' },
77
- },
78
- 'runtime.verbosity.set': {
79
- id: 'verbosity',
80
- label: 'Verbosity',
81
- description: 'Set runtime verbosity.',
82
- primitive: 'runtime.verbosity.set',
83
- aliases: ['verbose'],
84
- category: 'runtime',
85
- placements: ['composer_slash', 'command_palette'],
86
- availability: ['always'],
87
- args: [{
88
- id: 'level',
89
- label: 'Level',
90
- kind: 'enum',
91
- required: true,
92
- choices: [
93
- { value: 'off', label: 'Off' },
94
- { value: 'on', label: 'On' },
95
- { value: 'full', label: 'Full' },
96
- ],
97
- }],
98
- dispatch: { kind: 'primitive', primitive: 'runtime.verbosity.set' },
99
- },
100
- 'runtime.usage': {
101
- id: 'usage',
102
- label: 'Usage',
103
- description: 'Show or change runtime usage reporting.',
104
- primitive: 'runtime.usage',
105
- aliases: ['usage'],
106
- category: 'runtime',
107
- placements: ['composer_slash', 'command_palette'],
108
- availability: ['always'],
109
- dispatch: { kind: 'primitive', primitive: 'runtime.usage' },
110
- },
111
- 'context.compact': {
112
- id: 'compact-context',
113
- label: 'Compact context',
114
- description: 'Ask the runtime to compact its conversation context.',
115
- primitive: 'context.compact',
116
- aliases: ['compact'],
117
- category: 'session',
118
- placements: ['composer_slash', 'command_palette'],
119
- availability: ['always'],
120
- dispatch: { kind: 'primitive', primitive: 'context.compact' },
121
- },
122
- 'session.new': {
123
- id: 'new-session-primitive',
124
- label: 'New session',
125
- description: 'Ask the runtime to start a fresh session.',
126
- primitive: 'session.new',
127
- aliases: ['new'],
128
- category: 'session',
129
- placements: ['composer_slash', 'command_palette', 'session_strip'],
130
- availability: ['always'],
131
- dispatch: { kind: 'primitive', primitive: 'session.new' },
132
- },
133
- 'session.reset': {
134
- id: 'reset-session',
135
- label: 'Reset session',
136
- description: 'Ask the runtime to reset the current session.',
137
- primitive: 'session.reset',
138
- aliases: ['reset'],
139
- category: 'session',
140
- placements: ['composer_slash', 'command_palette'],
141
- availability: ['always'],
142
- dispatch: { kind: 'primitive', primitive: 'session.reset' },
143
- },
144
- };
145
- function sleep(ms) {
146
- return new Promise((resolve) => setTimeout(resolve, ms));
147
- }
148
- function knownMembershipRevision(value) {
149
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
150
- }
151
- function sleepWithAbort(ms, signal) {
152
- if (signal.aborted)
153
- return Promise.reject(createTurnAbortError());
154
- return new Promise((resolve, reject) => {
155
- const timer = setTimeout(resolve, ms);
156
- signal.addEventListener('abort', () => {
157
- clearTimeout(timer);
158
- reject(createTurnAbortError());
159
- }, { once: true });
160
- });
161
- }
162
- function safeRuntimeInputId(value, kind) {
163
- const raw = value?.trim() || `${kind}_${randomUUID()}`;
164
- const normalized = raw.replace(/[.#$\[\]\/\s]+/g, '_').slice(0, 160);
165
- return RUNTIME_INPUT_ID_PATTERN.test(normalized)
166
- ? normalized
167
- : `${kind}_${randomUUID()}`;
168
- }
169
- /**
170
- * Part id for text this sdk split itself, mirroring the `-part-N` rule (and the
171
- * 160-character id cap) that core's chunked sender applies to a split final, so
172
- * a caller-supplied id yields the same shape whichever path the text took.
173
- * Undefined base id in, undefined out: the server then names the parts.
174
- */
175
- function chunkPartMessageId(baseMessageId, part) {
176
- if (!baseMessageId)
177
- return undefined;
178
- const suffix = `-part-${part}`;
179
- return `${baseMessageId.slice(0, Math.max(0, CANON_MESSAGE_ID_MAX - suffix.length))}${suffix}`;
180
- }
181
- function buildSdkMessageId(parts) {
182
- const raw = parts
183
- .map((part) => part == null ? '' : String(part))
184
- .filter(Boolean)
185
- .join(':');
186
- const hash = createHash('sha256').update(raw || 'sdk-message').digest('hex').slice(0, 16);
187
- const readable = (raw || 'sdk-message')
188
- .replace(/[^A-Za-z0-9_.:-]+/g, '_')
189
- .replace(/_+/g, '_')
190
- .replace(/^[:_.-]+|[:_.-]+$/g, '')
191
- .slice(0, SDK_MESSAGE_ID_READABLE_MAX);
192
- return `${readable || 'sdk-message'}:${hash}`;
193
- }
194
- function isRuntimePrimitiveId(value) {
195
- return value === 'runtime.status'
196
- || value === 'runtime.reasoning.set'
197
- || value === 'runtime.verbosity.set'
198
- || value === 'runtime.usage'
199
- || value === 'context.compact'
200
- || value === 'session.new'
201
- || value === 'session.reset';
202
- }
203
- function normalizePrimitiveArgs(value) {
204
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
205
- return {};
206
- }
207
- const args = {};
208
- for (const [key, rawValue] of Object.entries(value)) {
209
- if (!/^[A-Za-z0-9_-]+$/.test(key))
210
- continue;
211
- if (typeof rawValue === 'boolean' || typeof rawValue === 'string') {
212
- args[key] = rawValue;
213
- }
214
- }
215
- return args;
216
- }
217
- function normalizeRuntimeFact(fact) {
218
- const id = fact.id.trim();
219
- const label = fact.label.trim();
220
- const value = fact.value.trim();
221
- if (!id || !label || !value)
222
- return null;
223
- return {
224
- ...fact,
225
- id,
226
- label,
227
- value,
228
- };
229
- }
230
- function normalizeRuntimeActivityItem(item) {
231
- return {
232
- ...item,
233
- id: item.id.trim(),
234
- title: item.title.trim() || item.kind,
235
- updatedAt: item.updatedAt || Date.now(),
236
- };
237
- }
238
- function createTurnAbortError() {
239
- const error = new Error('Canon turn was interrupted before reply delivery.');
240
- error.name = 'AbortError';
241
- return error;
242
- }
243
- function isAbortLikeError(error) {
244
- if (!error || typeof error !== 'object')
245
- return false;
246
- const record = error;
247
- if (record.name === 'AbortError' || record.code === 'ABORT_ERR')
248
- return true;
249
- return typeof record.message === 'string' && /\babort(?:ed)?\b/i.test(record.message);
250
- }
251
- function linkAbortSignals(turnSignal, requestSignal) {
252
- if (!requestSignal || requestSignal === turnSignal) {
253
- return { signal: turnSignal, dispose: () => { } };
254
- }
255
- const controller = new AbortController();
256
- const abortFrom = (source) => {
257
- if (!controller.signal.aborted)
258
- controller.abort(source.reason);
259
- };
260
- const onTurnAbort = () => abortFrom(turnSignal);
261
- const onRequestAbort = () => abortFrom(requestSignal);
262
- if (turnSignal.aborted)
263
- abortFrom(turnSignal);
264
- else
265
- turnSignal.addEventListener('abort', onTurnAbort, { once: true });
266
- if (requestSignal.aborted)
267
- abortFrom(requestSignal);
268
- else
269
- requestSignal.addEventListener('abort', onRequestAbort, { once: true });
270
- return {
271
- signal: controller.signal,
272
- dispose: () => {
273
- turnSignal.removeEventListener('abort', onTurnAbort);
274
- requestSignal.removeEventListener('abort', onRequestAbort);
275
- },
276
- };
277
- }
278
- export class CanonAgent {
279
- options;
280
- runtimeConnection;
281
- apiClient;
282
- debouncer;
283
- realtimeManager = null;
284
- sessionManager = null;
285
- handler = null;
286
- contactAddedHandler = null;
287
- contactRemovedHandler = null;
288
- messageUpdatedHandler = null;
289
- participationSuppressedHandler = null;
290
- interruptHandler = null;
291
- stopAndDropHandler = null;
292
- newSessionHandler = null;
293
- callStartedHandler = null;
294
- callEndedHandler = null;
295
- /** Whether the live SSE stream was opened with the voice family attached. */
296
- voiceEventsEnabled = false;
297
- primitiveHandlers = new Map();
298
- primitiveFallbackHandler = null;
299
- /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
300
- contacts;
301
- /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
302
- users;
303
- /** Conversation discovery for choosing existing sessions intentionally. */
304
- conversations;
305
- /** Discoverable Canon agents that can be selected for a later communication action. */
306
- directory;
307
- agentId = null;
308
- agentContext = null;
309
- approvalManager = null;
310
- approvalManagerAgentId = null;
311
- approvalManagerOwnerId = null;
312
- /**
313
- * Shared poll/timeout/abort engine for interactive runtime input + card
314
- * requests. The server request is still created inline (host-specific
315
- * `native`/`responseUserId`), so the descriptors register a no-op `create`
316
- * but keep the built-in `cancel`: passing the turn's abort `signal` lets the
317
- * manager fire `consume({ cancel: true })` on interrupt, so the hosts no
318
- * longer hand-roll a `cancelPendingRequest`.
319
- */
320
- runtimeRequestManager = null;
321
- cachedConversationIds = [];
322
- running = false;
323
- startPromise = null;
324
- stopPromise = null;
325
- lifecycleGeneration = 0;
326
- runtimeHeartbeat = null;
327
- runtimeStatePublisher = null;
328
- rtdbHandle = null;
329
- controlPoller = null;
330
- activeInbound = new Map();
331
- activeAbortControllers = new Map();
332
- activeTurns = new Map();
333
- conversationMemberIds = new Map();
334
- conversationMembershipRevisions = new Map();
335
- pendingMembershipChanges = new Map();
336
- typingSignals;
337
- sseConnectedLogged = false;
338
- constructor(options) {
339
- this.runtimeConnection = resolveCanonRuntimeConnection({
340
- environmentId: options.environmentId,
341
- apiBaseUrl: options.baseUrl,
342
- streamUrl: options.streamUrl,
343
- rtdbUrl: options.rtdbUrl,
344
- firebaseWebApiKey: options.firebaseApiKey,
345
- });
346
- this.options = {
347
- deliveryMode: 'auto',
348
- debounceMs: 2000,
349
- historyLimit: 50,
350
- autoMarkRead: true,
351
- runtimeControlSurface: 'agent',
352
- endpoint: {},
353
- ...options,
354
- environmentId: this.runtimeConnection.environmentId,
355
- baseUrl: this.runtimeConnection.apiBaseUrl,
356
- streamUrl: this.runtimeConnection.streamUrl,
357
- rtdbUrl: this.runtimeConnection.rtdbUrl,
358
- firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
359
- };
360
- this.apiClient = new CanonClient(this.options.apiKey, this.options.baseUrl, {
361
- environmentId: this.runtimeConnection.environmentId, streamUrl: this.runtimeConnection.streamUrl, ...options.endpoint,
362
- });
363
- this.typingSignals = createTypingStatusPublisher({
364
- setTyping: (conversationId, typing, status) => status
365
- ? this.apiClient.setTyping(conversationId, typing, status)
366
- : this.apiClient.setTyping(conversationId, typing),
367
- });
368
- this.debouncer = new Debouncer(this.options.debounceMs);
369
- const apiClient = this.apiClient;
370
- this.contacts = {
371
- list: () => apiClient.listContacts(),
372
- get: (contactId) => apiClient.getContact(contactId),
373
- remove: (contactId) => apiClient.deleteContact(contactId),
374
- };
375
- this.users = {
376
- block: (userId) => apiClient.blockUser(userId),
377
- unblock: (userId) => apiClient.unblockUser(userId),
378
- };
379
- this.conversations = {
380
- list: async (options = {}) => {
381
- const conversations = await apiClient.getConversations();
382
- if (!options.targetUserId)
383
- return conversations;
384
- return conversations.filter((conversation) => conversation.memberIds.includes(options.targetUserId));
385
- },
386
- page: (options) => apiClient.getConversationsPage(options),
387
- };
388
- this.directory = {
389
- search: (input) => apiClient.discoverAgents(input),
390
- };
391
- if (options.sessions?.enabled) {
392
- this.sessionManager = new SessionManager({
393
- contextLimit: options.sessions.contextLimit,
394
- concurrency: options.sessions.concurrency,
395
- idleTimeoutMs: options.sessions.idleTimeoutMs,
396
- });
397
- }
398
- this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
399
- this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
400
- this.newSessionHandler = options.runtimeControls?.onNewSession ?? null;
401
- for (const [primitive, handler] of Object.entries(options.runtimePrimitives ?? {})) {
402
- if (primitive === '*') {
403
- this.primitiveFallbackHandler = handler;
404
- }
405
- else if (isRuntimePrimitiveId(primitive)) {
406
- this.primitiveHandlers.set(primitive, handler);
407
- }
408
- }
409
- }
410
- ensureApprovalManager(agentContext = this.agentContext) {
411
- const agentId = agentContext?.agentId || this.agentId;
412
- const ownerId = agentContext?.ownerId;
413
- if (!agentId || !ownerId)
414
- return null;
415
- if (this.approvalManager
416
- && this.approvalManagerAgentId === agentId
417
- && this.approvalManagerOwnerId === ownerId) {
418
- return this.approvalManager;
419
- }
420
- if (this.approvalManager?.pendingCount) {
421
- return this.approvalManager;
422
- }
423
- this.approvalManager?.dispose();
424
- this.approvalManager = new ApprovalManager(this.apiClient, agentId, ownerId);
425
- this.approvalManagerAgentId = agentId;
426
- this.approvalManagerOwnerId = ownerId;
427
- return this.approvalManager;
428
- }
429
- /**
430
- * Shared engine for interactive runtime input + card polling. Unlike approval,
431
- * these families do not need an owner (the request is created inline with the
432
- * caller's own `responseUserId` policy), so the manager is always available.
433
- * The registered descriptors keep the built-in poll/consume/timeout logic and
434
- * the built-in `cancel` (best-effort `consume({ cancel: true })`), replacing
435
- * only `create` with a no-op because the request is already created inline.
436
- * On abort the manager runs that cancel via the passed `signal`.
437
- */
438
- ensureRuntimeRequestManager() {
439
- if (!this.runtimeRequestManager) {
440
- const manager = new RuntimeRequestManager(this.apiClient, {
441
- agentId: this.agentId ?? '',
442
- ownerId: '',
443
- });
444
- manager.register('input', {
445
- ...runtimeInputDescriptor,
446
- create: async ({ requestId }) => ({ requestId }),
447
- });
448
- manager.register('card', {
449
- ...runtimeCardDescriptor,
450
- create: async ({ requestId }) => ({ requestId }),
451
- });
452
- this.runtimeRequestManager = manager;
453
- }
454
- return this.runtimeRequestManager;
455
- }
456
- filterApprovalReplyMessages(conversationId, messages) {
457
- const manager = this.ensureApprovalManager();
458
- if (!manager) {
459
- return messages.filter((message) => {
460
- const metadata = message.metadata;
461
- return !(metadata && typeof metadata === 'object' && !Array.isArray(metadata)
462
- && metadata.type === 'approval_reply');
463
- });
464
- }
465
- return messages.filter((message) => {
466
- const metadata = message.metadata;
467
- const metadataRecord = metadata && typeof metadata === 'object' && !Array.isArray(metadata)
468
- ? metadata
469
- : null;
470
- const consumed = manager.handleMessage(conversationId, {
471
- senderId: message.senderId,
472
- ...(metadataRecord ? { metadata: metadataRecord } : {}),
473
- });
474
- return !consumed && metadataRecord?.type !== 'approval_reply';
475
- });
476
- }
477
- on(event, handler) {
478
- if (event === 'callStarted' || event === 'callEnded') {
479
- // The voice SSE family is negotiated at connect from handler presence,
480
- // so call handlers registered after start() would never fire.
481
- if (this.running && !this.voiceEventsEnabled) {
482
- console.warn('[canon-sdk] on(%s) registered after connect: the voice event family was not requested at stream start, so call events will not arrive until the agent restarts. Register call handlers before start().', event);
483
- }
484
- if (event === 'callStarted') {
485
- this.callStartedHandler = handler;
486
- }
487
- else {
488
- this.callEndedHandler = handler;
489
- }
490
- return;
491
- }
492
- if (event === 'message') {
493
- this.handler = handler;
494
- return;
495
- }
496
- if (event === 'messageUpdated') {
497
- this.messageUpdatedHandler = handler;
498
- return;
499
- }
500
- if (event === 'contactAdded') {
501
- this.contactAddedHandler = handler;
502
- return;
503
- }
504
- if (event === 'participationSuppressed') {
505
- this.participationSuppressedHandler = handler;
506
- return;
507
- }
508
- if (event === 'interrupt') {
509
- this.interruptHandler = handler;
510
- if (this.running) {
511
- void this.baselineAndStartRuntimeControlPolling().catch(() => { });
512
- }
513
- this.runtimeHeartbeat?.refresh();
514
- return;
515
- }
516
- if (event === 'stopAndDrop') {
517
- this.stopAndDropHandler = handler;
518
- if (this.running) {
519
- void this.baselineAndStartRuntimeControlPolling().catch(() => { });
520
- }
521
- this.runtimeHeartbeat?.refresh();
522
- return;
523
- }
524
- if (event === 'newSession') {
525
- this.newSessionHandler = handler;
526
- if (this.running) {
527
- void this.baselineAndStartRuntimeControlPolling().catch(() => { });
528
- }
529
- this.runtimeHeartbeat?.refresh();
530
- return;
531
- }
532
- this.contactRemovedHandler = handler;
533
- }
534
- onPrimitive(primitive, handler) {
535
- if (primitive === '*') {
536
- this.primitiveFallbackHandler = handler;
537
- }
538
- else {
539
- this.primitiveHandlers.set(primitive, handler);
540
- }
541
- if (this.running) {
542
- this.startRuntimeControlPolling();
543
- }
544
- this.runtimeHeartbeat?.refresh();
545
- }
546
- describeCommands() {
547
- return this.buildRuntimeDescriptor().commands ?? [];
548
- }
549
- async publishRuntimeFacts(conversationId, facts) {
550
- this.rememberConversationId(conversationId);
551
- const publisher = this.requireRuntimeStatePublisher();
552
- const normalizedFacts = facts
553
- .map(normalizeRuntimeFact)
554
- .filter((fact) => Boolean(fact));
555
- await publisher.patchRuntimeInfo(conversationId, {
556
- descriptor: this.buildRuntimeDescriptor(),
557
- facts: normalizedFacts,
558
- });
559
- }
560
- async publishRuntimeActivity(conversationId, item) {
561
- this.rememberConversationId(conversationId);
562
- const normalized = normalizeRuntimeActivityItem(item);
563
- if (!normalized.id) {
564
- throw new Error('Runtime activity item id is required.');
565
- }
566
- await this.requireRuntimeStatePublisher().writeRuntimeActivity(conversationId, normalized);
567
- }
568
- async clearRuntimeActivity(conversationId, options) {
569
- this.rememberConversationId(conversationId);
570
- await this.requireRuntimeStatePublisher().clearRuntimeActivity(conversationId, options);
571
- }
572
- /**
573
- * Message an existing Canon conversation or start/continue a direct one.
574
- * Policy and admission are enforced by Canon; this method deliberately
575
- * exposes no runtime configuration or trusted source-context fields.
576
- */
577
- async communicate(input) {
578
- return this.apiClient.communicate(input);
579
- }
580
- start() {
581
- if (this.stopPromise)
582
- return this.stopPromise.then(() => this.start());
583
- if (this.startPromise)
584
- return this.startPromise;
585
- if (this.running)
586
- return Promise.resolve();
587
- const generation = ++this.lifecycleGeneration;
588
- const startup = this.startRuntime(generation).catch(async (error) => {
589
- if (generation === this.lifecycleGeneration) {
590
- this.running = false;
591
- await this.cleanupRuntime();
592
- }
593
- throw error;
594
- });
595
- this.startPromise = startup;
596
- const clearStartup = () => {
597
- if (this.startPromise === startup)
598
- this.startPromise = null;
599
- };
600
- void startup.then(clearStartup, clearStartup);
601
- return startup;
602
- }
603
- async startRuntime(generation) {
604
- await verifyCanonRuntimeConnection(this.runtimeConnection);
605
- if (generation !== this.lifecycleGeneration)
606
- return;
607
- this.running = true;
608
- if (this.options.sessions?.enabled && !this.sessionManager) {
609
- this.sessionManager = new SessionManager(this.options.sessions);
610
- }
611
- // One scoped endpoint-backed runtime-state handle per agent. Control
612
- // pollers and publishers thread it through this agent's CanonClient;
613
- // there is no direct RTDB connection or module-global fallback.
614
- this.rtdbHandle = initRTDBAuth(this.apiClient, {
615
- rtdbUrl: this.runtimeConnection.rtdbUrl,
616
- firebaseApiKey: this.runtimeConnection.firebaseWebApiKey,
617
- });
618
- // Identity lookup only. Runtime state uses the endpoint-backed handle;
619
- // the SSE stream authenticates with the API key.
620
- const { agentId } = await this.apiClient.getAuthToken();
621
- if (generation !== this.lifecycleGeneration)
622
- return;
623
- this.agentId = agentId;
624
- this.runtimeHeartbeat = createRuntimeHeartbeat({
625
- publisher: this.requireRuntimeStatePublisher(),
626
- getRuntime: () => ({ runtimeDescriptor: this.buildRuntimeDescriptor() }),
627
- onError: (error, operation) => {
628
- console.error(`[canon-sdk] Runtime heartbeat ${operation} failed:`, error);
629
- },
630
- });
631
- console.log(`[canon-sdk] Authenticated as ${agentId}`);
632
- // 2. Wire debouncer to handler
633
- this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId, handoffs) => {
634
- this.rememberConversationId(conversationId);
635
- await this.handleMessages(conversationId, messages, provenanceByMessageId, handoffs);
636
- });
637
- // 3. Fetch conversations (used for delivery mode + session state)
638
- let conversations = [];
639
- try {
640
- conversations = await this.apiClient.getConversations();
641
- this.cachedConversationIds = conversations.map((c) => c.id);
642
- this.rememberConversationMembers(conversations);
643
- }
644
- catch {
645
- // Non-fatal — delivery mode will fall back to default
646
- }
647
- if (generation !== this.lifecycleGeneration)
648
- return;
649
- // 3a. Determine delivery mode
650
- let mode = this.options.deliveryMode;
651
- if (mode === 'auto') {
652
- mode = 'sse';
653
- console.log(`[canon-sdk] Auto-selected ${mode} mode (${conversations.length} conversations)`);
654
- }
655
- if (mode !== 'sse') {
656
- throw new Error(`Unsupported deliveryMode: ${mode}. Use 'auto' or 'sse'.`);
657
- }
658
- // 3b. Fetch agent context (identity, owner, access level)
659
- try {
660
- this.agentContext = await this.apiClient.getAgentMe();
661
- this.ensureApprovalManager(this.agentContext);
662
- }
663
- catch {
664
- console.warn('[canon-sdk] Failed to fetch agent context — owner/access info unavailable');
665
- }
666
- if (generation !== this.lifecycleGeneration)
667
- return;
668
- // 3c. Initialize RTDB session state reporting (opt-in)
669
- if (this.options.sessionState) {
670
- const runtimeState = this.createRuntimeStatePublisher();
671
- await Promise.all(this.cachedConversationIds.map((id) => (runtimeState?.writeSessionState(id, {
672
- isActive: true,
673
- ...(this.options.clientType ? { clientType: this.options.clientType } : {}),
674
- }).catch(() => { }))));
675
- if (generation !== this.lifecycleGeneration)
676
- return;
677
- if (this.cachedConversationIds.length > 0) {
678
- console.log(`[canon-sdk] Session state reported for ${this.cachedConversationIds.length} conversations`);
679
- }
680
- }
681
- await this.baselineRuntimeControlSignals(this.cachedConversationIds);
682
- if (generation !== this.lifecycleGeneration)
683
- return;
684
- this.startRuntimeControlPolling();
685
- // 4. Start delivery
686
- const { RealtimeManager } = await import('./realtime.js');
687
- if (generation !== this.lifecycleGeneration)
688
- return;
689
- this.voiceEventsEnabled = Boolean(this.callStartedHandler || this.callEndedHandler);
690
- const rtm = new RealtimeManager(this.options.apiKey, this.debouncer, agentId, this.options.streamUrl, { enableVoiceEvents: this.voiceEventsEnabled, endpoint: await this.apiClient.getEndpoint() });
691
- if (this.voiceEventsEnabled) {
692
- rtm.setCallHandlers({
693
- onCallStarted: (payload) => {
694
- void Promise.resolve(this.callStartedHandler?.(payload)).catch((error) => {
695
- console.error('[canon-sdk] callStarted handler failed:', error);
696
- });
697
- },
698
- onCallEnded: (payload) => {
699
- void Promise.resolve(this.callEndedHandler?.(payload)).catch((error) => {
700
- console.error('[canon-sdk] callEnded handler failed:', error);
701
- });
702
- },
703
- });
704
- }
705
- rtm.setOnAgentContext((ctx) => {
706
- this.agentContext = ctx;
707
- this.ensureApprovalManager(ctx);
708
- });
709
- rtm.setContactGraphHandlers({
710
- onContactAdded: (payload) => {
711
- void this.handleContactGraphEvent(this.contactAddedHandler, payload);
712
- },
713
- onContactRemoved: (payload) => {
714
- void this.handleContactGraphEvent(this.contactRemovedHandler, payload);
715
- },
716
- });
717
- rtm.setConversationUpdatedHandler((payload) => {
718
- this.handleConversationUpdated(payload);
719
- });
720
- rtm.setParticipationSuppressedHandler((payload) => {
721
- // Observe-only by construction: this path never touches the debouncer,
722
- // the message handler, or the turn pipeline.
723
- void this.handleParticipationSuppressedEvent(payload);
724
- });
725
- rtm.setMessageUpdatedHandler((payload) => {
726
- void this.handleMessageUpdatedEvent(payload);
727
- });
728
- rtm.setMessageDeletedHandler((payload) => {
729
- this.sessionManager?.dropQueuedMessage(payload.conversationId, payload.messageId);
730
- for (const handoffs of this.activeInbound.get(payload.conversationId) ?? []) {
731
- void disposeInboundHandoffs(selectInboundHandoffs(handoffs, [payload.messageId]), 'cancel', 'message-deleted');
732
- }
733
- });
734
- rtm.setConnectionHandlers({
735
- onConnected: () => {
736
- if (!this.running || generation !== this.lifecycleGeneration)
737
- return;
738
- this.runtimeHeartbeat?.connect();
739
- if (!this.sseConnectedLogged) {
740
- this.sseConnectedLogged = true;
741
- console.log('[canon-sdk] SSE stream connected');
742
- }
743
- },
744
- onDisconnected: () => {
745
- if (generation === this.lifecycleGeneration) {
746
- void this.runtimeHeartbeat?.disconnect();
747
- }
748
- },
749
- });
750
- this.realtimeManager = rtm;
751
- await rtm.start();
752
- if (generation !== this.lifecycleGeneration)
753
- rtm.stop();
754
- }
755
- async createGroup(options) {
756
- return this.apiClient.createGroup(options);
757
- }
758
- // ── Calls ────────────────────────────────────────────────────────────
759
- // These return the LiveKit room token payload; the agent brings its own
760
- // RTC transport (e.g. @livekit/rtc-node as an optional, lazily imported
761
- // dependency — the openclaw voice bridge is the reference implementation).
762
- /** Start (or rejoin) a call in a conversation and get the room token. */
763
- async startCall(options) {
764
- return this.apiClient.createVoiceSession(options);
765
- }
766
- /** Join an active call session and get the room token. */
767
- async joinCall(conversationId, sessionId) {
768
- return this.apiClient.joinVoiceSession(conversationId, sessionId);
769
- }
770
- /** Decline an incoming call: stops this agent's ring only. */
771
- async declineCall(conversationId, sessionId) {
772
- return this.apiClient.declineVoiceSession(conversationId, sessionId);
773
- }
774
- /** End an active call session for everyone. */
775
- async endCall(conversationId, sessionId) {
776
- return this.apiClient.endVoiceSession(conversationId, sessionId);
777
- }
778
- /** Fetch the current state of a call session. */
779
- async getCallState(conversationId, sessionId) {
780
- const result = await this.apiClient.getVoiceSessionState(conversationId, sessionId);
781
- return result.session;
782
- }
783
- async updateTopic(conversationId, topic) {
784
- return this.apiClient.updateTopic(conversationId, topic);
785
- }
786
- async leaveConversation(conversationId) {
787
- return this.apiClient.leaveConversation(conversationId);
788
- }
789
- async updateConversationName(conversationId, name) {
790
- return this.apiClient.updateConversationName(conversationId, name);
791
- }
792
- /**
793
- * Add a member to a group conversation.
794
- *
795
- * Outcome depends on the target's `groupJoinPolicy` and the relationship
796
- * graph:
797
- * - `{ status: 'added' }` — the member was added immediately.
798
- * - `{ status: 'pending', requestId }` — the target needs policy approval.
799
- * The server created one `group_invite`; membership activates after approval
800
- * The approved membership arrives through the normal conversation update.
801
- *
802
- * Throws `CanonApiError` for hard failures (block, inactive, closed policy,
803
- * member cap, requester not authorized).
804
- */
805
- async addMember(conversationId, userId) {
806
- return this.apiClient.addMember(conversationId, userId);
807
- }
808
- async removeMember(conversationId, userId) {
809
- return this.apiClient.removeMember(conversationId, userId);
810
- }
811
- async uploadMedia(conversationId, data, mimeType, fileName) {
812
- return this.apiClient.uploadMedia(conversationId, data, mimeType, fileName);
813
- }
814
- async handleContactGraphEvent(handler, payload) {
815
- if (!handler)
816
- return;
817
- try {
818
- await handler(payload);
819
- }
820
- catch (error) {
821
- console.error('[canon-sdk] Contact-graph handler failed:', error instanceof Error ? error.message : error);
822
- }
823
- }
824
- async handleParticipationSuppressedEvent(payload) {
825
- if (!this.participationSuppressedHandler)
826
- return;
827
- try {
828
- await this.participationSuppressedHandler(payload);
829
- }
830
- catch (error) {
831
- console.error('[canon-sdk] participationSuppressed handler failed:', error instanceof Error ? error.message : error);
832
- }
833
- }
834
- async handleMessageUpdatedEvent(payload) {
835
- if (!this.messageUpdatedHandler)
836
- return;
837
- try {
838
- await this.messageUpdatedHandler(payload);
839
- }
840
- catch (error) {
841
- console.error('[canon-sdk] Message-updated handler failed:', error instanceof Error ? error.message : error);
842
- }
843
- }
844
- stop() {
845
- if (this.stopPromise)
846
- return this.stopPromise;
847
- if (!this.running && !this.startPromise)
848
- return Promise.resolve();
849
- ++this.lifecycleGeneration;
850
- this.running = false;
851
- this.stopRuntimeControlPolling();
852
- this.realtimeManager?.stop();
853
- void this.runtimeHeartbeat?.disconnect();
854
- const startup = this.startPromise;
855
- const stopping = (async () => {
856
- // Startup checks the generation after each await, so it cannot install
857
- // new resources after this shutdown or a subsequent start.
858
- await startup?.catch(() => { });
859
- await this.cleanupRuntime();
860
- console.log('[canon-sdk] Stopped');
861
- })();
862
- this.stopPromise = stopping;
863
- const clearStopping = () => {
864
- if (this.stopPromise === stopping)
865
- this.stopPromise = null;
866
- };
867
- void stopping.then(clearStopping, clearStopping);
868
- return stopping;
869
- }
870
- async cleanupRuntime() {
871
- this.stopRuntimeControlPolling();
872
- this.controlPoller = null;
873
- this.realtimeManager?.stop();
874
- this.realtimeManager = null;
875
- this.debouncer.destroy();
876
- for (const batches of this.activeInbound.values()) {
877
- for (const handoffs of batches)
878
- void disposeInboundHandoffs(handoffs, 'release', 'runtime-closed');
879
- }
880
- this.sessionManager?.destroy();
881
- this.sessionManager = null;
882
- const heartbeat = this.runtimeHeartbeat;
883
- this.runtimeHeartbeat = null;
884
- await heartbeat?.dispose();
885
- // Startup awaits its initial session writes, so they finish before these
886
- // clears. Already-running message handlers keep their existing lifecycle.
887
- const runtimeState = this.runtimeStatePublisher;
888
- if (runtimeState) {
889
- await Promise.all(this.cachedConversationIds.flatMap((id) => [
890
- runtimeState.clearTurnState(id).catch(() => { }),
891
- ...(this.options.sessionState ? [runtimeState.clearSessionState(id).catch(() => { })] : []),
892
- ]));
893
- }
894
- this.runtimeStatePublisher = null;
895
- this.rtdbHandle = null;
896
- this.agentId = null;
897
- this.agentContext = null;
898
- this.cachedConversationIds = [];
899
- this.sseConnectedLogged = false;
900
- }
901
- hasInterruptSupport() {
902
- return Boolean(this.interruptHandler);
903
- }
904
- hasStopAndDropSupport() {
905
- return Boolean(this.stopAndDropHandler);
906
- }
907
- hasNewSessionSupport() {
908
- return Boolean(this.newSessionHandler);
909
- }
910
- hasRuntimeSignalSupport() {
911
- return this.hasInterruptSupport() || this.hasStopAndDropSupport() || this.hasNewSessionSupport();
912
- }
913
- hasRuntimePrimitiveSupport() {
914
- return this.primitiveHandlers.size > 0 || Boolean(this.primitiveFallbackHandler);
915
- }
916
- hasRuntimeControlSupport() {
917
- return this.hasRuntimeSignalSupport() || this.hasRuntimePrimitiveSupport();
918
- }
919
- supportsInputInterrupt() {
920
- return this.hasInterruptSupport() && this.options.runtimeDescriptor?.supportsInputInterrupt !== false;
921
- }
922
- buildRuntimeDescriptor() {
923
- const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
924
- const hasInterrupt = this.hasInterruptSupport();
925
- const hasStopAndDrop = this.hasStopAndDropSupport();
926
- const hasNewSession = this.hasNewSessionSupport();
927
- const commands = [...(source.commands ?? [])].filter((command) => {
928
- if (command.dispatch.kind === 'signal') {
929
- if (command.dispatch.signal === 'interrupt')
930
- return hasInterrupt;
931
- if (command.dispatch.signal === 'stop_and_drop')
932
- return hasStopAndDrop;
933
- if (command.dispatch.signal === 'new_session')
934
- return hasNewSession;
935
- return false;
936
- }
937
- if (command.dispatch.kind !== 'primitive')
938
- return true;
939
- return this.primitiveHandlers.has(command.dispatch.primitive)
940
- || Boolean(this.primitiveFallbackHandler);
941
- });
942
- const hasInterruptAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'interrupt');
943
- const hasStopAndDropAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'stop_and_drop');
944
- const hasNewSessionAction = commands.some((action) => action.dispatch.kind === 'signal' && action.dispatch.signal === 'new_session');
945
- if (hasInterrupt && !hasInterruptAction) {
946
- commands.push(RUNTIME_STOP_ACTION);
947
- }
948
- if (hasStopAndDrop && this.sessionManager && !hasStopAndDropAction) {
949
- commands.push(RUNTIME_STOP_AND_DROP_ACTION);
950
- }
951
- if (hasNewSession && !hasNewSessionAction) {
952
- commands.push(RUNTIME_NEW_SESSION_ACTION);
953
- }
954
- const hasCommandForPrimitive = (primitive) => commands.some((command) => (command.primitive === primitive
955
- || (command.dispatch.kind === 'primitive' && command.dispatch.primitive === primitive)));
956
- for (const primitive of this.primitiveHandlers.keys()) {
957
- if (!hasCommandForPrimitive(primitive)) {
958
- commands.push(STANDARD_PRIMITIVE_COMMANDS[primitive]);
959
- }
960
- }
961
- return {
962
- ...source,
963
- supportsInterrupt: hasInterrupt,
964
- supportsInputInterrupt: source.supportsInputInterrupt === false ? false : hasInterrupt,
965
- commands: normalizeRuntimeCommandDescriptors(commands),
966
- };
967
- }
968
- buildRuntimeCapabilities() {
969
- return {
970
- ...SDK_RUNTIME_CAPABILITIES,
971
- supportsInterrupt: this.hasInterruptSupport(),
972
- supportsInputInterrupt: this.supportsInputInterrupt(),
973
- supportsQueue: Boolean(this.sessionManager),
974
- };
975
- }
976
- rememberConversationId(conversationId) {
977
- if (this.cachedConversationIds.includes(conversationId))
978
- return;
979
- this.cachedConversationIds.push(conversationId);
980
- }
981
- rememberConversationMembers(conversations) {
982
- for (const conversation of conversations) {
983
- const revision = knownMembershipRevision(conversation.membershipRevision);
984
- const currentRevision = this.conversationMembershipRevisions.get(conversation.id);
985
- if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
986
- continue;
987
- this.conversationMemberIds.set(conversation.id, [...(conversation.memberIds ?? [])]);
988
- if (revision !== undefined)
989
- this.conversationMembershipRevisions.set(conversation.id, revision);
990
- else
991
- this.conversationMembershipRevisions.delete(conversation.id);
992
- }
993
- }
994
- handleConversationUpdated(payload) {
995
- const rawMemberIds = payload.changes.memberIds;
996
- if (!Array.isArray(rawMemberIds))
997
- return;
998
- const revision = knownMembershipRevision(payload.changes.membershipRevision);
999
- const currentRevision = this.conversationMembershipRevisions.get(payload.conversationId);
1000
- if (revision !== undefined && currentRevision !== undefined && revision < currentRevision)
1001
- return;
1002
- const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
1003
- const hadPreviousMemberIds = this.conversationMemberIds.has(payload.conversationId);
1004
- const previousMemberIds = this.conversationMemberIds.get(payload.conversationId) ?? [];
1005
- const membershipChange = payload.membershipChange
1006
- ?? (hadPreviousMemberIds ? diffCanonMemberIds(previousMemberIds, memberIds) : null);
1007
- this.conversationMemberIds.set(payload.conversationId, memberIds);
1008
- if (revision !== undefined)
1009
- this.conversationMembershipRevisions.set(payload.conversationId, revision);
1010
- else
1011
- this.conversationMembershipRevisions.delete(payload.conversationId);
1012
- if (membershipChange) {
1013
- this.pendingMembershipChanges.set(payload.conversationId, membershipChange);
1014
- }
1015
- if (this.agentId && !memberIds.includes(this.agentId)) {
1016
- this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
1017
- this.abortActiveTurns(payload.conversationId, false);
1018
- this.debouncer.dropConversation(payload.conversationId);
1019
- this.sessionManager?.dropQueued(payload.conversationId);
1020
- this.pendingMembershipChanges.delete(payload.conversationId);
1021
- }
1022
- else {
1023
- this.rememberConversationId(payload.conversationId);
1024
- }
1025
- }
1026
- buildGroupContext(input) {
1027
- return buildCanonGroupContext({
1028
- conversation: input.conversation,
1029
- messages: [...input.history, ...input.messages],
1030
- agentId: input.agent.agentId,
1031
- ownerId: input.agent.ownerId,
1032
- ownerName: input.agent.ownerName,
1033
- membershipChange: input.membershipChange,
1034
- });
1035
- }
1036
- /**
1037
- * Shared `/control` channel poller, configured to the agent-sdk host
1038
- * profile pinned by core's characterization tests: flat 2s single-flight
1039
- * cadence, parallel conversations, signal + primitive keys (no session),
1040
- * eager signal baseline, and TTL'd primitive dedupe released on successful
1041
- * consume. The poller talks only to the scoped RTDB handle captured in
1042
- * start() — never the module-global default client.
1043
- */
1044
- ensureControlPoller() {
1045
- if (this.controlPoller)
1046
- return this.controlPoller;
1047
- if (!this.rtdbHandle)
1048
- return null;
1049
- this.controlPoller = new ControlChannelPoller({
1050
- rtdb: this.rtdbHandle,
1051
- agentId: () => this.agentId,
1052
- conversationIds: () => this.cachedConversationIds,
1053
- cadence: { kind: 'fixed', intervalMs: RUNTIME_CONTROL_POLL_INTERVAL_MS },
1054
- pollOnStart: false,
1055
- conversationConcurrency: 'parallel',
1056
- handlers: {
1057
- signal: {
1058
- handle: (event) => this.handleRuntimeSignalEvent(event),
1059
- consumeOnError: true,
1060
- },
1061
- primitive: {
1062
- handle: (event) => this.handleRuntimePrimitiveEvent(event),
1063
- consumeOnError: true,
1064
- ordering: 'sequential',
1065
- dedupeTtlMs: RUNTIME_PRIMITIVE_DEDUPE_TTL_MS,
1066
- dedupeMaxEntries: RUNTIME_PRIMITIVE_DEDUPE_MAX,
1067
- releaseDedupeOnConsume: true,
1068
- },
1069
- },
1070
- onError: (error) => {
1071
- // Read/consume failures stay silent (the legacy loop swallowed them);
1072
- // handler-scope errors are host dispatch bugs worth surfacing.
1073
- if (error.scope === 'handler') {
1074
- console.error(`[canon-sdk] Runtime control ${error.key ?? 'poll'} dispatch failed for ${error.conversationId}:`, error.error);
1075
- }
1076
- },
1077
- });
1078
- return this.controlPoller;
1079
- }
1080
- async baselineRuntimeControlSignals(conversationIds) {
1081
- if (!this.hasRuntimeSignalSupport())
1082
- return;
1083
- await this.ensureControlPoller()?.baseline(conversationIds);
1084
- }
1085
- async baselineAndStartRuntimeControlPolling() {
1086
- const generation = this.lifecycleGeneration;
1087
- await this.baselineRuntimeControlSignals(this.cachedConversationIds);
1088
- if (this.running && generation === this.lifecycleGeneration) {
1089
- this.startRuntimeControlPolling();
1090
- }
1091
- }
1092
- startRuntimeControlPolling() {
1093
- if (!this.hasRuntimeControlSupport())
1094
- return;
1095
- this.ensureControlPoller()?.start();
1096
- }
1097
- stopRuntimeControlPolling() {
1098
- this.controlPoller?.stop();
1099
- }
1100
- async handleRuntimePrimitiveEvent(event) {
1101
- // Requests only belong to this runtime once primitive handlers exist —
1102
- // defer otherwise: the request stays in RTDB AND unseen, so a handler
1103
- // registered via onPrimitive() after start still receives it.
1104
- if (!this.hasRuntimePrimitiveSupport())
1105
- return { defer: true };
1106
- const { conversationId, requestId, value } = event;
1107
- const primitive = value.id;
1108
- // Unknown primitives and unhandled ids fall through so the poller
1109
- // consumes the request without dispatching, matching the legacy loop.
1110
- if (!isRuntimePrimitiveId(primitive))
1111
- return;
1112
- const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
1113
- if (!handler)
1114
- return;
1115
- await Promise.resolve(handler({
1116
- conversationId,
1117
- primitive,
1118
- args: normalizePrimitiveArgs(value.args),
1119
- requestId,
1120
- updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
1121
- rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
1122
- alias: typeof value.alias === 'string' ? value.alias : undefined,
1123
- })).catch((error) => {
1124
- console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
1125
- });
1126
- }
1127
- async handleRuntimeSignalEvent(event) {
1128
- // Signals only belong to this runtime once signal handlers exist — defer
1129
- // otherwise: the signal stays in RTDB AND unseen, so a handler registered
1130
- // via on('interrupt' | ...) after start still receives it.
1131
- if (!this.hasRuntimeSignalSupport())
1132
- return { defer: true };
1133
- const { conversationId, type: signal, updatedAt } = event;
1134
- const handler = signal === 'new_session'
1135
- ? this.newSessionHandler
1136
- : signal === 'stop_and_drop'
1137
- ? this.stopAndDropHandler
1138
- : this.interruptHandler;
1139
- // No handler for this specific signal: fall through so the poller
1140
- // consumes the node without dispatching, matching the legacy loop.
1141
- if (!handler)
1142
- return;
1143
- const activeTurn = this.firstActiveTurn(conversationId);
1144
- const abortSignal = this.abortActiveTurns(conversationId);
1145
- const debouncedMessages = signal === 'new_session' || signal === 'stop_and_drop'
1146
- ? this.debouncer.dropConversation(conversationId) : [];
1147
- const sessionMessages = signal === 'new_session'
1148
- ? this.sessionManager?.resetSession(conversationId) ?? []
1149
- : signal === 'stop_and_drop'
1150
- ? this.sessionManager?.dropQueued(conversationId) ?? []
1151
- : [];
1152
- const droppedMessages = [...debouncedMessages, ...sessionMessages];
1153
- const droppedMessageIds = droppedMessages.map((message) => message.id);
1154
- await Promise.all(droppedMessages.map((message) => {
1155
- if (message.metadata?.inboundDisposition !== 'queued')
1156
- return Promise.resolve();
1157
- return this.apiClient.updateMessageDisposition(conversationId, message.id, 'rejected').catch(() => { });
1158
- }));
1159
- await this.publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, {
1160
- hasActiveTurn: Boolean(abortSignal),
1161
- droppedCount: droppedMessages.length,
1162
- });
1163
- await Promise.resolve(handler({
1164
- conversationId,
1165
- signal,
1166
- updatedAt: updatedAt || undefined,
1167
- abortSignal,
1168
- droppedMessageIds,
1169
- })).catch((error) => {
1170
- console.error(`[canon-sdk] Runtime ${signal} handler failed for ${conversationId}:`, error);
1171
- });
1172
- }
1173
- firstActiveTurn(conversationId) {
1174
- const turns = this.activeTurns.get(conversationId);
1175
- if (!turns || turns.size === 0)
1176
- return null;
1177
- return turns.values().next().value ?? null;
1178
- }
1179
- async publishAcceptedRuntimeSignal(conversationId, signal, activeTurn, outcome) {
1180
- if (!this.agentId)
1181
- return;
1182
- const shouldPublishInterrupted = signal === 'interrupt'
1183
- || signal === 'stop_and_drop'
1184
- || outcome.hasActiveTurn
1185
- || outcome.droppedCount > 0;
1186
- const runtimeState = shouldPublishInterrupted
1187
- ? this.createRuntimeStatePublisher()
1188
- : null;
1189
- if (runtimeState) {
1190
- await Promise.resolve(runtimeState.writeTurnState(conversationId, {
1191
- turnId: activeTurn?.turnId ?? null,
1192
- state: 'interrupted',
1193
- queueDepth: this.sessionManager?.getQueueDepth(conversationId) ?? 0,
1194
- currentSpeakerId: this.agentId,
1195
- activeMessageIds: activeTurn?.activeMessageIds ?? [],
1196
- capabilities: this.buildRuntimeCapabilities(),
1197
- ...(activeTurn?.openedAt ? { openedAt: activeTurn.openedAt } : {}),
1198
- completedAt: { '.sv': 'timestamp' },
1199
- })).catch(() => { });
1200
- }
1201
- await Promise.all([
1202
- this.apiClient.clearStreaming(conversationId).catch(() => { }),
1203
- this.typingSignals.clear(conversationId).catch(() => { }),
1204
- ]);
1205
- }
1206
- abortActiveTurns(conversationId, cancelPreparing = true) {
1207
- // Membership snapshots may arrive while a newer authoritative refresh is
1208
- // pending. Only explicit controls discard those still-preparing inputs.
1209
- for (const handoffs of cancelPreparing ? this.activeInbound.get(conversationId) ?? [] : []) {
1210
- void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
1211
- }
1212
- const controllers = this.activeAbortControllers.get(conversationId);
1213
- if (!controllers || controllers.size === 0)
1214
- return undefined;
1215
- const abortSignal = controllers.values().next().value?.signal;
1216
- for (const controller of controllers) {
1217
- controller.abort();
1218
- }
1219
- return abortSignal;
1220
- }
1221
- resolveBatchDeliveryIntent(messages) {
1222
- return messages.some((message) => normalizeTurnMetadata(message.metadata)?.deliveryIntent === 'interrupt')
1223
- ? 'interrupt'
1224
- : 'queue';
1225
- }
1226
- async markQueuedMessagesAccepted(conversationId, messages) {
1227
- await Promise.all(messages.map((message) => {
1228
- if (!message.id || normalizeTurnMetadata(message.metadata)?.inboundDisposition !== 'queued') {
1229
- return Promise.resolve();
1230
- }
1231
- return this.apiClient.updateMessageDisposition(conversationId, message.id, 'accepted_now').catch(() => { });
1232
- }));
1233
- }
1234
- async notifyMessageInterrupt(conversationId, abortSignal) {
1235
- if (!abortSignal || !this.interruptHandler)
1236
- return;
1237
- await Promise.resolve(this.interruptHandler({
1238
- conversationId,
1239
- signal: 'interrupt',
1240
- abortSignal,
1241
- droppedMessageIds: [],
1242
- })).catch((error) => {
1243
- console.error(`[canon-sdk] Runtime interrupt handler failed for ${conversationId}:`, error);
1244
- });
1245
- }
1246
- /**
1247
- * Builds a runtime-state publisher bound to this agent's scoped,
1248
- * endpoint-backed handle (captured in start()). The publisher requires
1249
- * that handle, so each agent uses its own CanonClient credentials.
1250
- */
1251
- createRuntimeStatePublisher() {
1252
- if (!this.agentId)
1253
- return null;
1254
- // start() assigns rtdbHandle before agentId, so this can only be null
1255
- // when the agent has not started — same condition as the guard above.
1256
- if (!this.rtdbHandle)
1257
- return null;
1258
- this.runtimeStatePublisher ??= createRuntimeStatePublisher({
1259
- agentId: this.agentId,
1260
- clientType: this.options.clientType ?? 'generic',
1261
- hostMode: this.options.runtimeControlSurface === 'host',
1262
- rtdb: this.rtdbHandle,
1263
- });
1264
- return this.runtimeStatePublisher;
1265
- }
1266
- requireRuntimeStatePublisher() {
1267
- const publisher = this.createRuntimeStatePublisher();
1268
- if (!publisher) {
1269
- throw new Error('Canon agent must be started before publishing runtime operations.');
1270
- }
1271
- return publisher;
1272
- }
1273
- async handleMessages(conversationId, messages, provenanceByMessageId, handoffs = new Map()) {
1274
- const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
1275
- const consumed = messages.filter((message) => !actionableMessages.includes(message));
1276
- void disposeInboundHandoffs(selectInboundHandoffs(handoffs, consumed.map((message) => message.id)), 'cancel', 'interaction-consumed');
1277
- if (actionableMessages.length === 0)
1278
- return;
1279
- messages = actionableMessages;
1280
- handoffs = selectInboundHandoffs(handoffs, messages.map((message) => message.id));
1281
- if (!this.handler) {
1282
- await disposeInboundHandoffs(handoffs, 'release', 'no-handler');
1283
- console.warn(`[canon-sdk] No message handler registered — input for ${conversationId} is deferred. Call agent.on('message', handler) before starting.`);
1284
- return;
1285
- }
1286
- const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
1287
- const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
1288
- const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
1289
- const notification = this.notifyMessageInterrupt(conversationId, abortSignal);
1290
- if (this.sessionManager) {
1291
- await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages, batchHandoffs) => {
1292
- await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId, batchHandoffs, notification);
1293
- }, { toFront: shouldInterrupt, handoffs });
1294
- }
1295
- else {
1296
- await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId, handoffs, notification);
1297
- }
1298
- }
1299
- async executeHandler(conversationId, messages, session, provenanceByMessageId, handoffs = new Map(), notification) {
1300
- const batches = this.activeInbound.get(conversationId) ?? new Set();
1301
- batches.add(handoffs);
1302
- this.activeInbound.set(conversationId, batches);
1303
- try {
1304
- await notification;
1305
- await this.executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs);
1306
- }
1307
- finally {
1308
- batches.delete(handoffs);
1309
- if (batches.size === 0)
1310
- this.activeInbound.delete(conversationId);
1311
- // Queued/deferred inputs go back to the engine's durable retry loop.
1312
- // Submitted effects cannot be released: failures remain uncertain.
1313
- await disposeInboundHandoffs(handoffs, 'release', 'sdk-batch-finished');
1314
- }
1315
- }
1316
- async executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs) {
1317
- if (!this.handler || messages.length === 0 || handoffs.size !== messages.length
1318
- || [...handoffs.values()].some((handoff) => handoff.phase !== 'queued'))
1319
- return;
1320
- // Refresh admission before freezing output policy or constructing context.
1321
- const endpoint = await this.apiClient.getEndpoint();
1322
- const cancelInputs = (reason) => disposeInboundHandoffs(handoffs, 'cancel', reason);
1323
- let conversation;
1324
- try {
1325
- conversation = await this.apiClient.getConversation(conversationId);
1326
- }
1327
- catch (error) {
1328
- console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
1329
- return;
1330
- }
1331
- if (!conversation) {
1332
- await cancelInputs('conversation-unavailable');
1333
- return;
1334
- }
1335
- this.rememberConversationMembers([conversation]);
1336
- const currentMembers = this.conversationMemberIds.get(conversationId);
1337
- if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
1338
- await cancelInputs('membership-lost');
1339
- return;
1340
- }
1341
- this.rememberConversationId(conversationId);
1342
- await this.markQueuedMessagesAccepted(conversationId, messages);
1343
- const acceptedMembers = this.conversationMemberIds.get(conversationId);
1344
- if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
1345
- await cancelInputs('membership-lost');
1346
- return;
1347
- }
1348
- const turnId = randomUUID();
1349
- const turnOpenedAt = Date.now();
1350
- let turnState = 'thinking';
1351
- let shouldPersistTurnState = false;
1352
- // `ctx.turn.noReply()`: the handler chose to end this turn without posting.
1353
- // Governs TEARDOWN only — `replyFinal` still sends, because in the SDK the
1354
- // developer's two explicit calls are two explicit decisions.
1355
- let deliberatelySilent = false;
1356
- // The FIRST noReply call's reason (one silence decision per turn), carried
1357
- // to teardown where the outcome report fires. Only its presence rides the
1358
- // wire — see reportNoReplyOutcome's sentinel.
1359
- let noReplyReason;
1360
- // Set when the handler threw or the turn was aborted. Deliberate silence
1361
- // must never blank the live node on those paths: the streamed content is
1362
- // all that survives a turn that died mid-flight.
1363
- let turnEndedAbnormally = false;
1364
- let durableMessageSequence = 0;
1365
- // The freshest message in the batch is the turn's trigger — same convention
1366
- // as the provenance lookup for turn verbosity below.
1367
- const triggeringMessageId = messages[messages.length - 1]?.id;
1368
- const triggeringReplyAuthority = messages[messages.length - 1]?.replyAuthority;
1369
- const agentId = this.agentId;
1370
- const runtimeState = this.createRuntimeStatePublisher();
1371
- const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
1372
- const abortController = new AbortController();
1373
- abortController.signal.addEventListener('abort', () => {
1374
- void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
1375
- }, { once: true });
1376
- const throwIfAborted = () => {
1377
- if (abortController.signal.aborted) {
1378
- throw createTurnAbortError();
1379
- }
1380
- };
1381
- const activeControllers = this.activeAbortControllers.get(conversationId) ?? new Set();
1382
- activeControllers.add(abortController);
1383
- this.activeAbortControllers.set(conversationId, activeControllers);
1384
- const activeTurns = this.activeTurns.get(conversationId) ?? new Map();
1385
- activeTurns.set(abortController, {
1386
- turnId,
1387
- openedAt: turnOpenedAt,
1388
- activeMessageIds: messages.map((message) => message.id).filter(Boolean),
1389
- });
1390
- this.activeTurns.set(conversationId, activeTurns);
1391
- const writeTurn = async (state) => {
1392
- if (!runtimeState || !agentId)
1393
- return;
1394
- turnState = state;
1395
- const isOpenTurn = state === 'thinking'
1396
- || state === 'streaming'
1397
- || state === 'tool'
1398
- || state === 'waiting_input';
1399
- await Promise.resolve(runtimeState.writeTurnState(conversationId, {
1400
- turnId,
1401
- state,
1402
- queueDepth: queueDepth(),
1403
- currentSpeakerId: agentId,
1404
- capabilities: this.buildRuntimeCapabilities(),
1405
- openedAt: turnOpenedAt,
1406
- ...(isOpenTurn ? { turnUpdatedAt: Date.now() } : {}),
1407
- ...(state === 'completed' || state === 'interrupted' || state === 'idle'
1408
- ? { completedAt: { '.sv': 'timestamp' } }
1409
- : {}),
1410
- })).catch(() => { });
1411
- };
1412
- // Resolve once from the refreshed roster before constructing the output
1413
- // controller or publishing the first thinking seed. Keep it fixed for the
1414
- // turn so the final trail agrees with what was actually narrated.
1415
- const inboundProvenance = provenanceByMessageId?.get(triggeringMessageId ?? '')?.conversation;
1416
- const inboundRoster = this.conversationMemberIds.get(conversationId) ?? inboundProvenance?.memberCount;
1417
- const inboundConversationType = inboundRoster != null
1418
- ? resolveConversationPolicyScope(inboundRoster)
1419
- : normalizeTurnVerbosityConversationType(inboundProvenance?.type);
1420
- const turnVerbosity = resolveTurnVerbosity({
1421
- configured: selectConfiguredTurnVerbosity(this.options.turnVerbosity, inboundConversationType),
1422
- conversationType: inboundConversationType,
1423
- });
1424
- const turnOutput = createTurnOutputController({
1425
- turnId,
1426
- // `'status'` keeps every controller call site working and drops only the
1427
- // RTDB write, which covers all ~12 of them at once — the thinking seed
1428
- // and its keepalive, `turn.appendDelta`/`appendBlock`/`setTool`, the
1429
- // approval block, and `replyProgress`'s implicit live half. Blocks still
1430
- // accumulate in memory, so the trail gate below is a SEPARATE decision.
1431
- mode: turnVerbosity === 'quiet' ? 'status' : 'snapshot',
1432
- writeSnapshot: (snapshot) => this.apiClient.setStreaming(buildTurnStreamingRequest({ conversationId, snapshot })),
1433
- clearSnapshot: () => this.apiClient.clearStreaming(conversationId),
1434
- });
1435
- const setLiveState = async (state, text, streamingStatus) => {
1436
- throwIfAborted();
1437
- await writeTurn(state);
1438
- if (streamingStatus) {
1439
- await turnOutput.setStatus(streamingStatus, text ?? '');
1440
- }
1441
- };
1442
- /**
1443
- * Put the turn back to work after an interaction request settles.
1444
- *
1445
- * Both halves matter: the dots were cleared when the turn parked (the
1446
- * clients suppress an agent's dots on `waiting_input`), and `/turn-state`
1447
- * plus the live node are still sitting on `waiting_input`. The error paths
1448
- * need this as much as the success paths — a request that failed still
1449
- * leaves the handler running, and a quiet turn has no live row to stand in
1450
- * for the missing dots. Aborts still propagate: `setLiveState` re-checks
1451
- * the signal, which is how an interrupt lands during cleanup.
1452
- */
1453
- const resumeTurnFromWaiting = async () => {
1454
- try {
1455
- await this.typingSignals.start(conversationId, 'thinking');
1456
- }
1457
- catch { }
1458
- await setLiveState('thinking', 'Thinking...', 'thinking');
1459
- };
1460
- if (turnVerbosity === 'quiet') {
1461
- // A quiet turn never writes to `/streaming`, so it cannot rely on the
1462
- // 'Thinking...' seed below to overwrite a node an earlier verbose turn
1463
- // left behind — and a surviving node under the PREVIOUS turn's id would
1464
- // sit there, invisible, for this whole turn, then be deleted at teardown:
1465
- // `onStreamingCleared` would salvage it into a durable bubble landing
1466
- // AFTER this turn's answer. Deleting at turn open puts that salvage back
1467
- // where the design intends it, ahead of the answer. Both coding hosts do
1468
- // the same, one for free (its seed clears unconditionally) and one
1469
- // explicitly.
1470
- await turnOutput.clear().catch(() => { });
1471
- }
1472
- // Show thinking indicator and keep it alive (5s client-side expiry)
1473
- try {
1474
- await this.typingSignals.start(conversationId, 'thinking');
1475
- }
1476
- catch {
1477
- // Non-critical
1478
- }
1479
- await setLiveState('thinking', 'Thinking...', 'thinking');
1480
- const thinkingKeepalive = setInterval(() => {
1481
- if (turnState === 'thinking') {
1482
- turnOutput.setStatus('thinking', 'Thinking...').catch(() => { });
1483
- }
1484
- }, 3500);
1485
- try {
1486
- // Fetch hydrated history/context from API
1487
- const page = await this.apiClient.getMessagesPage(conversationId, this.options.historyLimit);
1488
- const history = page.messages;
1489
- const historyById = new Map(history.map((message) => [message.id, message]));
1490
- const hydratedMessages = messages.map((message) => {
1491
- const hydrated = historyById.get(message.id);
1492
- if (hydrated)
1493
- return hydrated;
1494
- return {
1495
- ...message,
1496
- attachments: message.attachments ?? [],
1497
- };
1498
- });
1499
- // If sessions enabled, seed the session with fetched history
1500
- if (this.sessionManager && session) {
1501
- this.sessionManager.seedHistory(conversationId, history);
1502
- }
1503
- // Build reply functions
1504
- const replyFinal = async (text, options, delivery) => {
1505
- throwIfAborted();
1506
- try {
1507
- await this.typingSignals.start(conversationId, 'typing');
1508
- }
1509
- catch { }
1510
- throwIfAborted();
1511
- const sendOptions = withActiveSelfContext(options);
1512
- // Separate from the controller's mode on purpose: `'status'` silences
1513
- // the live writes but still accumulates blocks, so `getFinalTrail()`
1514
- // would happily hand back a full trail for a turn that narrated
1515
- // nothing.
1516
- const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
1517
- const finalOptions = {
1518
- ...sendOptions,
1519
- metadata: {
1520
- ...(sendOptions.metadata ?? {}),
1521
- turnId,
1522
- turnSemantics: 'turn_complete',
1523
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
1524
- },
1525
- };
1526
- let result;
1527
- try {
1528
- result = await sendDurableMessage(text, finalOptions, ['sdk', 'final', conversationId, turnId], delivery);
1529
- }
1530
- catch (error) {
1531
- const chunked = isChunkedSendMessageError(error) ? error : null;
1532
- if (!chunked || chunked.deliveredMessageIds.length === 0 || sendOptions.messageId || isAbortLikeError(error) || isPendingCanonOperation(error)) {
1533
- throw error;
1534
- }
1535
- const requestedReplyBehavior = sendOptions.metadata?.replyBehavior;
1536
- const notice = await sendDurableMessage(SDK_PARTIAL_FINAL_NOTICE, {
1537
- ...sendOptions,
1538
- messageId: buildSdkMessageId(['sdk', 'final-incomplete', conversationId, turnId]),
1539
- metadata: {
1540
- turnId,
1541
- turnSemantics: 'turn_complete',
1542
- ...(requestedReplyBehavior ? { replyBehavior: requestedReplyBehavior } : {}),
1543
- },
1544
- }, ['sdk', 'final-incomplete', conversationId, turnId]);
1545
- result = {
1546
- messageId: notice.messageId,
1547
- messageIds: [...chunked.deliveredMessageIds, ...notice.messageIds],
1548
- };
1549
- }
1550
- await sleep(FINAL_MESSAGE_HANDOFF_MS);
1551
- try {
1552
- await this.typingSignals.clear(conversationId);
1553
- }
1554
- catch { }
1555
- return result;
1556
- };
1557
- const replyProgress = async (text, options) => {
1558
- throwIfAborted();
1559
- // Owner ruling D1: in quiet the IMPLICIT half of this call — the live
1560
- // `/streaming` narration — is dropped by the controller's `'status'`
1561
- // mode, while the turn state it also publishes stays. The EXPLICIT half
1562
- // below does not: `durable: true` is a developer asking Canon to post a
1563
- // message, structurally the same act as `replyFinal`, and silently
1564
- // no-op'ing it would leave a caller reading `durable: true` from a send
1565
- // that never happened. The result object therefore keeps describing
1566
- // what actually occurred in both modes.
1567
- await setLiveState('streaming', text, 'streaming');
1568
- if (!options?.durable) {
1569
- return { turnId, durable: false, messageId: null };
1570
- }
1571
- throwIfAborted();
1572
- // Durable progress skips `sendDurableMessage` (see the chunking note
1573
- // below) but it is still a durable message in the timeline, so it must
1574
- // advance the same counter teardown reads for the silence report.
1575
- durableMessageSequence += 1;
1576
- const { durable: _durable, ...sendOptions } = options;
1577
- const sendOptionsWithContext = withActiveSelfContext(sendOptions);
1578
- // Progress text is caller-controlled too, so it can also blow past
1579
- // Canon's 4 KB cap. It is split here rather than through core's chunked
1580
- // sender on purpose: chunk parts carry a `messageChunk` marker, and
1581
- // Canon clients read a progress message that has one as durable answer
1582
- // content (chat-domain's `isEphemeralProgressMessage`). Routing progress
1583
- // through the chunked sender would therefore leave an oversized status
1584
- // update in the timeline forever, while the same update under the cap is
1585
- // folded into the turn trail once the turn ends. Plain progress parts
1586
- // keep long and short updates behaving identically, and each part stays
1587
- // a single unretried send, exactly as one progress update always was.
1588
- const parts = splitTextByUtf8Bytes(text);
1589
- const messageIds = [];
1590
- for (const [index, part] of parts.entries()) {
1591
- throwIfAborted();
1592
- const partMessageId = parts.length > 1
1593
- ? chunkPartMessageId(sendOptionsWithContext.messageId, index + 1)
1594
- : sendOptionsWithContext.messageId;
1595
- const result = await this.apiClient.sendMessage(conversationId, part, {
1596
- ...sendOptionsWithContext,
1597
- ...(partMessageId !== undefined ? { messageId: partMessageId } : {}),
1598
- metadata: {
1599
- ...(sendOptionsWithContext.metadata ?? {}),
1600
- turnId,
1601
- turnSemantics: 'progress',
1602
- },
1603
- });
1604
- messageIds.push(result.messageId);
1605
- }
1606
- return {
1607
- turnId,
1608
- durable: true,
1609
- messageId: messageIds[messageIds.length - 1],
1610
- messageIds,
1611
- };
1612
- };
1613
- // Enrich history messages with isOwner
1614
- if (this.agentContext?.ownerId) {
1615
- const ownerId = this.agentContext.ownerId;
1616
- for (const m of history) {
1617
- m.isOwner = m.senderId === ownerId;
1618
- }
1619
- }
1620
- const latestMessage = hydratedMessages[hydratedMessages.length - 1] ?? null;
1621
- const triggeringHumanId = latestMessage?.senderType === 'human'
1622
- ? normalizeResponseUserId(latestMessage.senderId)
1623
- : undefined;
1624
- let replyContext = latestMessage
1625
- ? await hydrateCanonReplyContext({
1626
- client: this.apiClient, conversationId, message: latestMessage, messages: history,
1627
- })
1628
- : null;
1629
- const selectedSelfContext = selectInboundSelfContexts({
1630
- selfContexts: page.selfContexts,
1631
- replyTo: latestMessage?.replyTo,
1632
- linkedSelfContextId: latestMessage?.id
1633
- ? page.activeSelfContextIdByMessageId?.[latestMessage.id]
1634
- : null,
1635
- messages: page.messages,
1636
- replyContext,
1637
- });
1638
- const selfContexts = selectedSelfContext.selfContexts;
1639
- const activeSelfContextId = selectedSelfContext.activeSelfContextId;
1640
- const withActiveSelfContext = (options) => {
1641
- const base = { ...(options ?? {}) };
1642
- const withContext = base.selfContextId !== undefined || !activeSelfContextId
1643
- ? base
1644
- : { ...base, selfContextId: activeSelfContextId };
1645
- return withContext.replyAuthority !== undefined || !triggeringReplyAuthority
1646
- ? withContext
1647
- : { ...withContext, replyAuthority: triggeringReplyAuthority };
1648
- };
1649
- // Core's chunked sender walks the parts in a plain loop and knows nothing
1650
- // about this turn's abort signal, so a stop landing after part 1 would
1651
- // otherwise let the rest of the answer post anyway — including the last
1652
- // part, which completes the turn. Sending every part through a client that
1653
- // re-checks the signal first is the sdk-side guard for that; the `sleep`
1654
- // override below only covers a retry backoff.
1655
- const abortAwareClient = Object.create(this.apiClient, {
1656
- sendMessage: {
1657
- value: (targetConversationId, targetText, targetOptions, delivery) => {
1658
- throwIfAborted();
1659
- return delivery?.operationId
1660
- ? this.apiClient.sendMessage(targetConversationId, targetText, targetOptions, delivery)
1661
- : this.apiClient.sendMessage(targetConversationId, targetText, targetOptions);
1662
- },
1663
- },
1664
- });
1665
- // Canon rejects any message whose text is over 4 KB of UTF-8 with a 400,
1666
- // which no retry can fix — an oversized reply used to throw out of the
1667
- // handler with nothing durable written, so a long answer just vanished.
1668
- // Core's chunked sender splits such a reply into ordered `-part-N`
1669
- // messages instead, completing the turn on the last part only. A reply
1670
- // that FITS still goes out as a single message under the plain id with
1671
- // untouched metadata, so the common case (and the interim→final handoff
1672
- // that keys on that id) is unchanged.
1673
- const sendDurableMessage = async (text, options, fallbackMessageIdParts, delivery) => {
1674
- // Counts every durable send the turn attempts, not just the ones that
1675
- // needed a generated id: teardown reads `durableMessageSequence` to
1676
- // decide whether the turn genuinely ended silent, and a reply sent
1677
- // under an explicit caller messageId must not look like silence.
1678
- durableMessageSequence += 1;
1679
- const messageId = options?.messageId
1680
- ?? buildSdkMessageId([...fallbackMessageIdParts, durableMessageSequence]);
1681
- const { messageIds } = await sendMessageWithRetryChunked(abortAwareClient, conversationId, text, {
1682
- ...(options ?? {}),
1683
- messageId,
1684
- }, {
1685
- signal: abortController.signal,
1686
- ...(delivery?.operationId ? { operationId: delivery.operationId } : {}),
1687
- ...(delivery?.replacesOperationId ? { replacesOperationId: delivery.replacesOperationId } : {}),
1688
- }, {
1689
- resumable: true,
1690
- });
1691
- // `messageId` stays the single id callers expect. When the text was
1692
- // chunked it points at the LAST part — the message that carries
1693
- // turn_complete and the turn trail, so replies and reactions thread off
1694
- // the end of the answer, not its head. `messageIds` has the full set.
1695
- return { messageId: messageIds[messageIds.length - 1], messageIds };
1696
- };
1697
- // Runtime context must come from authenticated agent metadata. A synthetic
1698
- // generation cannot authorize an endpoint or a new owner's native work.
1699
- const agent = this.agentContext ?? await this.apiClient.getAgentMe();
1700
- const provenance = latestMessage
1701
- ? resolveRuntimeProvenance({
1702
- provenance: provenanceByMessageId?.get(latestMessage.id) ?? null,
1703
- conversationId,
1704
- conversationType: resolveConversationPolicyScope(conversation.memberIds),
1705
- memberCount: conversation.memberIds.length,
1706
- senderId: latestMessage.senderId,
1707
- senderName: latestMessage.senderName ?? latestMessage.senderId,
1708
- senderType: latestMessage.senderType,
1709
- isOwner: latestMessage.isOwner,
1710
- agentId: agent.agentId,
1711
- mentions: latestMessage.mentions,
1712
- activeSelfContextId,
1713
- selfContexts,
1714
- })
1715
- : resolveRuntimeProvenance({
1716
- conversationId,
1717
- conversationType: resolveConversationPolicyScope(conversation.memberIds),
1718
- memberCount: conversation.memberIds.length,
1719
- senderId: '',
1720
- senderType: 'human',
1721
- isOwner: false,
1722
- agentId: agent.agentId,
1723
- activeSelfContextId,
1724
- selfContexts,
1725
- });
1726
- if (replyContext?.found && replyContext.attachments?.length) {
1727
- try {
1728
- const materializedReply = await materializeReplyContextMedia(replyContext, {
1729
- agentId: agent.agentId,
1730
- conversationId,
1731
- });
1732
- replyContext = materializedReply.replyContext;
1733
- }
1734
- catch (error) {
1735
- console.error(`[canon-sdk] Failed to materialize reply context media for ${conversationId}:`, error instanceof Error ? error.message : error);
1736
- }
1737
- }
1738
- const membershipChange = this.pendingMembershipChanges.get(conversationId) ?? null;
1739
- this.pendingMembershipChanges.delete(conversationId);
1740
- const groupContext = this.buildGroupContext({
1741
- conversation,
1742
- history,
1743
- messages: hydratedMessages,
1744
- agent,
1745
- membershipChange,
1746
- });
1747
- const participationHistory = buildParticipationHistorySnapshot(history);
1748
- const turnContext = buildCanonTurnContextV2({
1749
- content: latestMessage ? renderCanonHostInboundContent(latestMessage) : '[Empty message]',
1750
- conversationId,
1751
- participantContext: {
1752
- conversationType: resolveConversationPolicyScope(conversation.memberIds),
1753
- memberCount: conversation.memberIds.length,
1754
- senderType: latestMessage?.senderType ?? provenance.sender.type,
1755
- senderName: latestMessage?.senderName
1756
- ?? provenance.sender.name
1757
- ?? latestMessage?.senderId
1758
- ?? provenance.sender.id
1759
- ?? 'unknown',
1760
- isOwner: provenance.sender.isOwner,
1761
- mentionedAgent: provenance.mentionedAgent,
1762
- ...(groupContext ? { groupContext } : {}),
1763
- ...(groupContext && membershipChange ? { groupContextMode: 'membership_change' } : {}),
1764
- recentSenderTypes: participationHistory.recentSenderTypes,
1765
- recentHumanCount: participationHistory.recentHumanCount,
1766
- recentAgentCount: participationHistory.recentAgentCount,
1767
- consecutiveAgentTurns: participationHistory.consecutiveAgentTurns,
1768
- currentAgentStreakStartedByHuman: participationHistory.currentAgentStreakStartedByHuman,
1769
- },
1770
- behavior: page.behavior ?? conversation.behavior,
1771
- selfContexts,
1772
- activeSelfContextId,
1773
- provenance,
1774
- replyContext,
1775
- message: latestMessage ?? undefined,
1776
- });
1777
- const requestedTurnMode = latestMessage
1778
- ? normalizeTurnMetadata(latestMessage.metadata)?.requestedTurnMode ?? null
1779
- : null;
1780
- // Build context methods bound to this conversation
1781
- const deleteMessage = (messageId) => this.apiClient.deleteMessage(conversationId, messageId);
1782
- const markAsRead = () => this.apiClient.markAsRead(conversationId);
1783
- const leave = () => this.apiClient.leaveConversation(conversationId);
1784
- const react = (messageId, emoji) => this.apiClient.react(conversationId, messageId, emoji);
1785
- const addMember = (userId) => this.apiClient.addMember(conversationId, userId);
1786
- const removeMember = (userId) => this.apiClient.removeMember(conversationId, userId);
1787
- const communicate = (input) => this.communicate(input);
1788
- const requestApproval = async (request) => {
1789
- throwIfAborted();
1790
- const manager = this.ensureApprovalManager(agent);
1791
- if (!manager) {
1792
- return { decision: 'deny' };
1793
- }
1794
- shouldPersistTurnState = true;
1795
- try {
1796
- try {
1797
- await turnOutput.addBlock({
1798
- id: `approval:${request.runtimeId ?? request.toolName}:${turnId}`,
1799
- kind: 'approval',
1800
- status: 'pending',
1801
- title: request.toolSummary ?? request.toolName,
1802
- summary: request.risk ?? request.category,
1803
- });
1804
- await turnOutput.waitingInput();
1805
- }
1806
- catch { }
1807
- await writeTurn('waiting_input');
1808
- try {
1809
- await this.typingSignals.clear(conversationId);
1810
- }
1811
- catch { }
1812
- const responseUserId = normalizeResponseUserId(request.responseUserId)
1813
- ?? triggeringHumanId;
1814
- // Session-wide rules are owner-scoped. The effective responder, not
1815
- // merely the member who triggered the turn, determines that scope.
1816
- const mustIsolateSessionRules = Boolean(responseUserId && responseUserId !== agent.ownerId);
1817
- const result = await manager.requestApproval(conversationId, request.toolName, request.toolInput ?? {}, {
1818
- riskLevel: request.riskLevel,
1819
- risk: request.risk,
1820
- category: request.category,
1821
- runtimeId: request.runtimeId,
1822
- turnId: request.turnId ?? turnId,
1823
- native: request.native,
1824
- toolSummary: request.toolSummary,
1825
- details: request.details,
1826
- ...(responseUserId ? { responseUserId } : {}),
1827
- ignoreSessionRules: mustIsolateSessionRules ? true : request.ignoreSessionRules,
1828
- allowSessionRule: mustIsolateSessionRules ? false : request.allowSessionRule,
1829
- }, request.signal ? { signal: request.signal } : undefined);
1830
- throwIfAborted();
1831
- shouldPersistTurnState = false;
1832
- try {
1833
- await turnOutput.completeBlock(`approval:${request.runtimeId ?? request.toolName}:${turnId}`, {
1834
- summary: `Decision: ${result.decision}`,
1835
- });
1836
- }
1837
- catch { }
1838
- await resumeTurnFromWaiting();
1839
- return result;
1840
- }
1841
- catch (error) {
1842
- if (abortController.signal.aborted || isAbortLikeError(error)) {
1843
- throw error;
1844
- }
1845
- shouldPersistTurnState = false;
1846
- // The turn parked before the request went out and the handler is
1847
- // about to carry on with a denial, so put it back to work — the
1848
- // success path is not the only way out of `waiting_input`.
1849
- await resumeTurnFromWaiting();
1850
- return { decision: 'deny' };
1851
- }
1852
- };
1853
- const requestRuntimeInput = async (request) => {
1854
- throwIfAborted();
1855
- const linkedSignal = linkAbortSignals(abortController.signal, request.signal);
1856
- const inputId = safeRuntimeInputId(request.inputId, request.kind);
1857
- const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1858
- const expiresAtMs = Date.now() + timeoutMs;
1859
- let result = { status: 'timeout', inputId };
1860
- let requestCreated = false;
1861
- const ownerOnly = request.kind === 'secret'
1862
- || request.kind === 'sudo'
1863
- || request.sensitive === true
1864
- || Boolean(request.questions?.some((question) => question.isSecret));
1865
- const responseUserId = ownerOnly
1866
- ? normalizeResponseUserId(agent.ownerId)
1867
- : normalizeResponseUserId(request.responseUserId)
1868
- ?? triggeringHumanId
1869
- ?? normalizeResponseUserId(agent.ownerId);
1870
- shouldPersistTurnState = true;
1871
- try {
1872
- await this.apiClient.createRuntimeInputRequest({
1873
- conversationId,
1874
- inputId,
1875
- kind: request.kind,
1876
- expiresAt: expiresAtMs,
1877
- ...(responseUserId ? { responseUserId } : {}),
1878
- title: request.title,
1879
- prompt: request.prompt,
1880
- ...(request.choices ? { choices: request.choices } : {}),
1881
- ...(request.questions ? { questions: request.questions } : {}),
1882
- ...(request.secretName ? { secretName: request.secretName } : {}),
1883
- ...(request.native ? { native: request.native } : {}),
1884
- ...(request.sensitive !== undefined ? { sensitive: request.sensitive } : {}),
1885
- turnId: request.turnId ?? turnId,
1886
- });
1887
- requestCreated = true;
1888
- try {
1889
- await turnOutput.addBlock({
1890
- id: `input:${inputId}`,
1891
- kind: 'input',
1892
- status: 'pending',
1893
- title: request.title ?? request.prompt ?? request.kind,
1894
- summary: request.kind,
1895
- });
1896
- await turnOutput.waitingInput();
1897
- }
1898
- catch { }
1899
- await writeTurn('waiting_input');
1900
- try {
1901
- await this.typingSignals.clear(conversationId);
1902
- }
1903
- catch { }
1904
- // The shared engine owns the poll-with-abort loop + post-deadline
1905
- // consume; the request was already created above, so the descriptor's
1906
- // create is a no-op. On abort the manager fires the descriptor's
1907
- // built-in cancel (consume({ cancel: true })) via the passed signal.
1908
- result = await this.ensureRuntimeRequestManager().request('input', conversationId, { kind: request.kind }, {
1909
- requestId: inputId,
1910
- expiresAt: expiresAtMs,
1911
- signal: linkedSignal.signal,
1912
- });
1913
- const outcome = buildRuntimeInputOutcome(inputId, result.status, {
1914
- kind: request.kind,
1915
- reason: result.status,
1916
- });
1917
- await sendDurableMessage(outcome.text, {
1918
- metadata: {
1919
- ...outcome.metadata,
1920
- turnId: request.turnId ?? turnId,
1921
- turnSemantics: 'control',
1922
- replyBehavior: 'suppress_auto_reply',
1923
- },
1924
- }, ['sdk', 'runtime-input-outcome', conversationId, request.turnId ?? turnId, inputId, result.status]);
1925
- throwIfAborted();
1926
- shouldPersistTurnState = false;
1927
- try {
1928
- await turnOutput.completeBlock(`input:${inputId}`, {
1929
- summary: `Input ${result.status}`,
1930
- });
1931
- }
1932
- catch { }
1933
- await resumeTurnFromWaiting();
1934
- return result;
1935
- }
1936
- catch (error) {
1937
- if (abortController.signal.aborted || request.signal?.aborted || isAbortLikeError(error)) {
1938
- if (requestCreated) {
1939
- // Abort landing before the manager wired its cancel (e.g. during
1940
- // the pre-request writeTurn/typing round-trips) leaves the
1941
- // server-side request pending + responder-actionable; cancel it so
1942
- // a late (possibly sensitive) answer can't resolve a dead request.
1943
- await this.apiClient
1944
- .consumeRuntimeInputResponse({ conversationId, inputId, cancel: true })
1945
- .catch(() => { });
1946
- }
1947
- throw error;
1948
- }
1949
- if (!requestCreated) {
1950
- shouldPersistTurnState = false;
1951
- throw error;
1952
- }
1953
- shouldPersistTurnState = false;
1954
- // Parked, then failed rather than answered — the handler carries on
1955
- // with the fallback result, so the turn has to look like it is
1956
- // working again. The success path is not the only way out.
1957
- await resumeTurnFromWaiting();
1958
- return result;
1959
- }
1960
- finally {
1961
- linkedSignal.dispose();
1962
- }
1963
- };
1964
- const requestPlanReview = async (request) => {
1965
- throwIfAborted();
1966
- const linkedSignal = linkAbortSignals(abortController.signal, request.signal);
1967
- const planId = request.planId && RUNTIME_INPUT_ID_PATTERN.test(request.planId)
1968
- ? request.planId
1969
- : randomUUID();
1970
- const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1971
- const expiresAtMs = Date.now() + timeoutMs;
1972
- const responseUserId = normalizeResponseUserId(request.responseUserId)
1973
- ?? triggeringHumanId
1974
- ?? normalizeResponseUserId(agent.ownerId);
1975
- let created = false;
1976
- let result = { status: 'timeout', planId };
1977
- shouldPersistTurnState = true;
1978
- try {
1979
- result = await this.ensureRuntimeRequestManager().request('plan', conversationId, {
1980
- ...(request.title ? { title: request.title } : {}),
1981
- ...(request.summary ? { summary: request.summary } : {}),
1982
- ...(request.body ? { body: request.body } : {}),
1983
- ...(request.allowedPrompts ? { allowedPrompts: request.allowedPrompts } : {}),
1984
- ...(responseUserId ? { responseUserId } : {}),
1985
- turnId: request.turnId ?? turnId,
1986
- }, {
1987
- requestId: planId,
1988
- expiresAt: expiresAtMs,
1989
- responderPolicy: 'infer',
1990
- signal: linkedSignal.signal,
1991
- onCreated: async () => {
1992
- created = true;
1993
- try {
1994
- await turnOutput.addBlock({
1995
- id: `plan:${planId}`,
1996
- kind: 'input',
1997
- status: 'pending',
1998
- title: request.title ?? 'Plan review',
1999
- summary: request.summary ?? 'Review requested',
2000
- });
2001
- await turnOutput.waitingInput();
2002
- }
2003
- catch { }
2004
- await writeTurn('waiting_input');
2005
- try {
2006
- await this.typingSignals.clear(conversationId);
2007
- }
2008
- catch { }
2009
- },
2010
- });
2011
- throwIfAborted();
2012
- shouldPersistTurnState = false;
2013
- try {
2014
- await turnOutput.completeBlock(`plan:${planId}`, {
2015
- summary: `Plan ${result.status}`,
2016
- });
2017
- }
2018
- catch { }
2019
- await resumeTurnFromWaiting();
2020
- return result;
2021
- }
2022
- catch (error) {
2023
- if (abortController.signal.aborted || request.signal?.aborted || isAbortLikeError(error)) {
2024
- throw error;
2025
- }
2026
- shouldPersistTurnState = false;
2027
- if (!created)
2028
- throw error;
2029
- await resumeTurnFromWaiting();
2030
- return result;
2031
- }
2032
- finally {
2033
- linkedSignal.dispose();
2034
- }
2035
- };
2036
- const sendCard = async (request) => {
2037
- throwIfAborted();
2038
- const { cardId, expiresAtMs, routedRequest } = resolveRuntimeCardRouting({
2039
- request,
2040
- triggeringHumanId,
2041
- });
2042
- // Fire-and-forget: post the durable card and return. The backend treats an
2043
- // action-less card as display (no pending state, no response expected).
2044
- await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({
2045
- request: routedRequest,
2046
- conversationId,
2047
- cardId,
2048
- fallbackTurnId: turnId,
2049
- expiresAtMs,
2050
- }));
2051
- try {
2052
- await turnOutput.addBlock({
2053
- id: `card:${cardId}`,
2054
- kind: 'status',
2055
- status: 'completed',
2056
- title: request.card.title,
2057
- summary: request.card.template ?? 'runtime card',
2058
- });
2059
- }
2060
- catch { }
2061
- return { status: 'displayed', cardId };
2062
- };
2063
- const requestCard = async (request) => {
2064
- throwIfAborted();
2065
- // Action-less cards are fire-and-forget display/report cards — never block.
2066
- const hasActions = Array.isArray(request.card.blocks)
2067
- && request.card.blocks.some((block) => block.kind === 'actions');
2068
- if (!hasActions)
2069
- return sendCard(request);
2070
- const { cardId, expiresAtMs, routedRequest } = resolveRuntimeCardRouting({
2071
- request,
2072
- triggeringHumanId,
2073
- });
2074
- let result = { status: 'timeout', cardId };
2075
- let requestCreated = false;
2076
- let requestResolved = false;
2077
- shouldPersistTurnState = true;
2078
- try {
2079
- await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({
2080
- request: routedRequest,
2081
- conversationId,
2082
- cardId,
2083
- fallbackTurnId: turnId,
2084
- expiresAtMs,
2085
- }));
2086
- requestCreated = true;
2087
- try {
2088
- await turnOutput.addBlock({
2089
- id: `card:${cardId}`,
2090
- kind: 'input',
2091
- status: 'pending',
2092
- title: request.card.title,
2093
- summary: request.card.template ?? 'runtime card',
2094
- });
2095
- await turnOutput.waitingInput();
2096
- }
2097
- catch { }
2098
- await writeTurn('waiting_input');
2099
- try {
2100
- await this.typingSignals.clear(conversationId);
2101
- }
2102
- catch { }
2103
- // The shared engine owns the poll-with-abort loop + post-deadline
2104
- // consume; the request was already created above, so the descriptor's
2105
- // create is a no-op. On abort the manager fires the descriptor's
2106
- // built-in cancel (consume({ cancel: true })) via the passed signal.
2107
- result = await this.ensureRuntimeRequestManager().request('card', conversationId, { card: request.card }, {
2108
- requestId: cardId,
2109
- expiresAt: expiresAtMs,
2110
- signal: abortController.signal,
2111
- });
2112
- requestResolved = true;
2113
- // The interactive path never yields 'displayed' (that returns early via
2114
- // sendCard); narrow for buildRuntimeCardOutcome's resolution status.
2115
- const resolutionStatus = result.status === 'displayed' ? 'timeout' : result.status;
2116
- const outcome = buildRuntimeCardOutcome(cardId, resolutionStatus, {
2117
- reason: resolutionStatus,
2118
- });
2119
- await sendDurableMessage(outcome.text, {
2120
- metadata: {
2121
- ...outcome.metadata,
2122
- turnId: request.turnId ?? turnId,
2123
- turnSemantics: 'control',
2124
- replyBehavior: 'suppress_auto_reply',
2125
- },
2126
- }, ['sdk', 'runtime-card-outcome', conversationId, request.turnId ?? turnId, cardId, resolutionStatus]);
2127
- throwIfAborted();
2128
- shouldPersistTurnState = false;
2129
- try {
2130
- await turnOutput.completeBlock(`card:${cardId}`, {
2131
- summary: `Card ${result.status}`,
2132
- });
2133
- }
2134
- catch { }
2135
- await resumeTurnFromWaiting();
2136
- return result;
2137
- }
2138
- catch (error) {
2139
- const shouldSendInterruptedOutcome = requestCreated && !requestResolved;
2140
- if (abortController.signal.aborted || isAbortLikeError(error)) {
2141
- if (shouldSendInterruptedOutcome) {
2142
- // Cancel the server-side card if the abort landed before the
2143
- // manager wired its cancel, so a late action can't resolve a dead
2144
- // request (mirrors the runtime-input path).
2145
- await this.apiClient
2146
- .consumeRuntimeCardResponse({ conversationId, cardId, cancel: true })
2147
- .catch(() => { });
2148
- const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
2149
- await sendDurableMessage(outcome.text, {
2150
- metadata: {
2151
- ...outcome.metadata,
2152
- turnId: request.turnId ?? turnId,
2153
- turnSemantics: 'control',
2154
- replyBehavior: 'suppress_auto_reply',
2155
- },
2156
- }, ['sdk', 'runtime-card-outcome', conversationId, request.turnId ?? turnId, cardId, 'interrupted']).catch(() => { });
2157
- }
2158
- throw error;
2159
- }
2160
- if (!requestCreated) {
2161
- shouldPersistTurnState = false;
2162
- throw error;
2163
- }
2164
- shouldPersistTurnState = false;
2165
- // Parked, then failed rather than answered — the handler carries on
2166
- // with the fallback result, so the turn has to look like it is
2167
- // working again. The success path is not the only way out.
2168
- await resumeTurnFromWaiting();
2169
- return result;
2170
- }
2171
- };
2172
- const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, {
2173
- ...(options ?? {}),
2174
- signal: options?.signal ?? abortController.signal,
2175
- });
2176
- const replyWithFile = async (filePath, text = '', options) => {
2177
- throwIfAborted();
2178
- try {
2179
- await this.typingSignals.start(conversationId, 'typing');
2180
- }
2181
- catch { }
2182
- throwIfAborted();
2183
- try {
2184
- // Same gate as `replyFinal` — this send IS a final, trail included.
2185
- const turnTrail = shouldPublishTurnTrail(turnVerbosity) ? turnOutput.getFinalTrail() : [];
2186
- const result = await sendMediaFileMessage(this.apiClient, conversationId, filePath, text, {
2187
- ...(options?.replyTo ? { replyTo: options.replyTo } : {}),
2188
- ...(options?.replyToPosition != null
2189
- ? { replyToPosition: options.replyToPosition }
2190
- : {}),
2191
- ...(options?.mentions ? { mentions: options.mentions } : {}),
2192
- ...withActiveSelfContext(options?.selfContextId !== undefined
2193
- ? { selfContextId: options.selfContextId }
2194
- : undefined),
2195
- metadata: {
2196
- ...(options?.metadata ?? {}),
2197
- turnId,
2198
- turnSemantics: 'turn_complete',
2199
- ...(turnTrail.length > 0 ? { turnTrail } : {}),
2200
- },
2201
- ...(options?.fileName ? { fileName: options.fileName } : {}),
2202
- ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
2203
- ...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
2204
- ...(options?.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
2205
- signal: options?.signal ?? abortController.signal,
2206
- });
2207
- durableMessageSequence += 1;
2208
- await sleep(FINAL_MESSAGE_HANDOFF_MS);
2209
- return result;
2210
- }
2211
- finally {
2212
- try {
2213
- await this.typingSignals.clear(conversationId);
2214
- }
2215
- catch { }
2216
- }
2217
- };
2218
- // The client's own room object (its "last seen" memory spans turns),
2219
- // made on first use so a turn that never touches shared work cannot fail
2220
- // on it. Arrival reads nothing on the handler's behalf: the base of a
2221
- // change is only what the handler itself read or wrote. Names this turn
2222
- // already holds spare views a member lookup.
2223
- let artifacts;
2224
- const conversationArtifacts = () => {
2225
- if (artifacts)
2226
- return artifacts;
2227
- const ownerId = this.agentContext?.ownerId;
2228
- this.apiClient.rememberParticipantNames(conversationId, {
2229
- ...(ownerId ? { [ownerId]: this.agentContext?.ownerName } : {}),
2230
- ...Object.fromEntries([...history, ...hydratedMessages].flatMap((message) => message.senderName ? [[message.senderId, message.senderName]] : [])),
2231
- });
2232
- return artifacts = this.apiClient.artifacts(conversationId);
2233
- };
2234
- // Fence uncertain native/business effects before handing execution out.
2235
- // A crash after this write requires provider/operator reconciliation.
2236
- throwIfAborted();
2237
- const submission = await endpoint.startInbound([...handoffs.values()], () => this.handler({
2238
- messages: hydratedMessages,
2239
- history,
2240
- replyContext,
2241
- conversationId,
2242
- conversation,
2243
- ...(groupContext ? { groupContext } : {}),
2244
- replyFinal,
2245
- getReplyOperation: (messageId, delivery) => this.apiClient.getMessageOperation(conversationId, messageId, delivery),
2246
- waitForReply: async (operationId) => {
2247
- throwIfAborted();
2248
- durableMessageSequence += 1;
2249
- const result = await this.apiClient.waitForOperation(operationId, { signal: abortController.signal });
2250
- if (typeof result?.messageId !== 'string' || !result.messageId)
2251
- throw new Error('Canon returned a malformed message receipt');
2252
- try {
2253
- await this.typingSignals.clear(conversationId);
2254
- }
2255
- catch { }
2256
- return { messageId: result.messageId, messageIds: result.messageIds ?? [result.messageId] };
2257
- },
2258
- replyProgress,
2259
- deleteMessage,
2260
- markAsRead,
2261
- leave,
2262
- react,
2263
- addMember,
2264
- removeMember,
2265
- communicate,
2266
- agent,
2267
- activeSelfContextId,
2268
- selfContexts,
2269
- provenance,
2270
- turnContext,
2271
- // Lenient: a malformed reference is dropped, never fatal to the batch.
2272
- artifactContexts: hydratedMessages.flatMap((message) => {
2273
- const context = readArtifactContext(message.metadata?.artifactContext);
2274
- return context ? [{ messageId: message.id, context }] : [];
2275
- }),
2276
- get artifacts() { return conversationArtifacts(); },
2277
- requestedTurnMode,
2278
- turnVerbosity,
2279
- requestApproval,
2280
- requestRuntimeInput,
2281
- requestPlanReview,
2282
- requestCard,
2283
- sendCard,
2284
- abortSignal: abortController.signal,
2285
- media: {
2286
- materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
2287
- if (!message)
2288
- return Promise.resolve([]);
2289
- return materializeMessageMedia(message, {
2290
- agentId: agent.agentId,
2291
- conversationId,
2292
- ...(options ?? {}),
2293
- signal: options?.signal ?? abortController.signal,
2294
- });
2295
- },
2296
- uploadFile,
2297
- replyWithFile,
2298
- },
2299
- session: session
2300
- ? {
2301
- id: session.id,
2302
- messages: session.messages,
2303
- metadata: session.metadata,
2304
- queueDepth: queueDepth(),
2305
- }
2306
- : undefined,
2307
- turn: {
2308
- id: turnId,
2309
- get state() {
2310
- return turnState;
2311
- },
2312
- setThinking: async (text) => {
2313
- await setLiveState('thinking', text ?? 'Thinking...', 'thinking');
2314
- },
2315
- setStreaming: async (text) => {
2316
- await setLiveState('streaming', text, 'streaming');
2317
- },
2318
- appendDelta: (delta) => {
2319
- throwIfAborted();
2320
- turnState = 'streaming';
2321
- turnOutput.appendDelta(delta);
2322
- void writeTurn('streaming');
2323
- },
2324
- appendBlock: (block) => {
2325
- throwIfAborted();
2326
- turnState = 'streaming';
2327
- turnOutput.appendBlock(block);
2328
- void writeTurn('streaming');
2329
- },
2330
- appendTextSegmentDelta: (id, delta) => {
2331
- throwIfAborted();
2332
- turnState = 'streaming';
2333
- turnOutput.appendTextSegmentDelta(id, delta);
2334
- void writeTurn('streaming');
2335
- },
2336
- replaceTextSegmentSnapshot: (id, text) => {
2337
- throwIfAborted();
2338
- turnState = 'streaming';
2339
- turnOutput.replaceTextSegmentSnapshot(id, text);
2340
- void writeTurn('streaming');
2341
- },
2342
- addBlock: async (block) => {
2343
- throwIfAborted();
2344
- return turnOutput.addBlock(block);
2345
- },
2346
- updateBlock: async (id, patch) => {
2347
- throwIfAborted();
2348
- return turnOutput.updateBlock(id, patch);
2349
- },
2350
- completeBlock: async (id, patch) => {
2351
- throwIfAborted();
2352
- return turnOutput.completeBlock(id, patch);
2353
- },
2354
- failBlock: async (id, patch) => {
2355
- throwIfAborted();
2356
- return turnOutput.failBlock(id, patch);
2357
- },
2358
- replaceSnapshot: async (text) => {
2359
- await setLiveState('streaming', text, 'streaming');
2360
- },
2361
- flush: async () => {
2362
- await turnOutput.flush();
2363
- },
2364
- clear: async () => {
2365
- await turnOutput.clear();
2366
- },
2367
- noReply: async (reason) => {
2368
- throwIfAborted();
2369
- // First call wins — one silence decision per turn.
2370
- if (!deliberatelySilent && reason?.trim()) {
2371
- noReplyReason = reason;
2372
- }
2373
- deliberatelySilent = true;
2374
- // `reason` is handler-authored free text: log only that one was given.
2375
- console.error(`[canon-sdk] Turn chose no_reply for ${conversationId}`
2376
- + ` (reason: ${reason?.trim() ? 'given' : 'none'})`);
2377
- // The outcome report deliberately does NOT fire here: `replyFinal`
2378
- // still sends after `noReply` (the SDK carve-out — see
2379
- // TurnController.noReply in types.ts) and the handler may yet throw
2380
- // or be aborted, so only teardown knows whether the silence was
2381
- // real. It fires there, once, off the flag set above.
2382
- },
2383
- setTool: async (text) => {
2384
- await writeTurn('tool');
2385
- await turnOutput.addBlock({
2386
- id: `tool:${Date.now()}`,
2387
- kind: 'tool',
2388
- status: 'running',
2389
- title: text,
2390
- });
2391
- await turnOutput.setStatus('tool');
2392
- },
2393
- setWaitingInput: async (text) => {
2394
- shouldPersistTurnState = true;
2395
- try {
2396
- await turnOutput.waitingInput();
2397
- }
2398
- catch { }
2399
- await writeTurn('waiting_input');
2400
- try {
2401
- await this.typingSignals.clear(conversationId);
2402
- }
2403
- catch { }
2404
- if (text) {
2405
- await sendDurableMessage(text, {
2406
- metadata: {
2407
- turnId,
2408
- turnSemantics: 'control',
2409
- replyBehavior: 'suppress_auto_reply',
2410
- },
2411
- }, ['sdk', 'waiting-input-note', conversationId, turnId]);
2412
- }
2413
- },
2414
- },
2415
- }));
2416
- if (submission.status !== 'submitted')
2417
- return;
2418
- await disposeInboundHandoffs(handoffs, 'settle', 'handler-completed');
2419
- // Auto-mark conversation as read after successful processing
2420
- if (this.options.autoMarkRead) {
2421
- try {
2422
- await this.apiClient.markAsRead(conversationId);
2423
- }
2424
- catch {
2425
- // Non-critical
2426
- }
2427
- }
2428
- }
2429
- catch (err) {
2430
- turnEndedAbnormally = true;
2431
- if (abortController.signal.aborted || isAbortLikeError(err)) {
2432
- await writeTurn('interrupted');
2433
- return;
2434
- }
2435
- console.error(`[canon-sdk] Handler error for ${conversationId}:`, err);
2436
- await writeTurn('interrupted');
2437
- }
2438
- finally {
2439
- const activeControllers = this.activeAbortControllers.get(conversationId);
2440
- activeControllers?.delete(abortController);
2441
- if (activeControllers?.size === 0) {
2442
- this.activeAbortControllers.delete(conversationId);
2443
- }
2444
- const activeTurns = this.activeTurns.get(conversationId);
2445
- activeTurns?.delete(abortController);
2446
- if (activeTurns?.size === 0) {
2447
- this.activeTurns.delete(conversationId);
2448
- }
2449
- clearInterval(thinkingKeepalive);
2450
- // Sequenced AFTER the keepalive stops, and with no handoff delay in front
2451
- // of it: the 3.5 s keepalive would rewrite 'Thinking...' over a blank
2452
- // scheduled earlier, and every other runtime retires the row and the
2453
- // typing dots together the moment a turn goes silent. `replyFinal`'s
2454
- // handoff window overlaps a durable message that has already landed;
2455
- // here there is nothing to hand off to, and holding the indicator would
2456
- // read as "started to answer, then gave up".
2457
- const silentTeardown = deliberatelySilent && !turnEndedAbnormally;
2458
- // Telemetry parity with the server-answered verb path, decided HERE
2459
- // because only teardown knows how the turn actually ended: `noReply`
2460
- // does not gate `replyFinal` in the SDK, and the handler may throw or be
2461
- // aborted after choosing silence — either would make a report sent at
2462
- // noReply-call time record silence for a turn that delivered or died.
2463
- // `durableMessageSequence` counts the turn's durable sends: any durable
2464
- // message means the turn did not end silent, deliberate flag or not.
2465
- // reportNoReplyOutcome is fire-and-forget and never throws, so a failed
2466
- // or rejecting report degrades to exactly the pre-report behavior.
2467
- if (silentTeardown && durableMessageSequence === 0) {
2468
- reportNoReplyOutcome(this.apiClient, {
2469
- conversationId,
2470
- ...(triggeringMessageId ? { messageId: triggeringMessageId } : {}),
2471
- ...(noReplyReason ? { reason: noReplyReason } : {}),
2472
- });
2473
- }
2474
- // Always clear typing when done
2475
- try {
2476
- await this.typingSignals.clear(conversationId);
2477
- }
2478
- catch { }
2479
- try {
2480
- // Deliberate silence blanks the node (text '' AND an explicit empty
2481
- // blocks array) before deleting it, so onStreamingCleared sees empty
2482
- // content and does not salvage the turn's narration into a durable
2483
- // bubble. A crashed or interrupted turn keeps the plain clear: there
2484
- // the salvage is the only record of what the turn managed to say.
2485
- if (silentTeardown)
2486
- await turnOutput.blankAndClear();
2487
- else
2488
- await turnOutput.clear();
2489
- }
2490
- catch { }
2491
- if (runtimeState && !shouldPersistTurnState) {
2492
- await Promise.resolve(runtimeState.clearTurnState(conversationId)).catch(() => { });
2493
- }
2494
- }
2495
- }
2496
- // Static registration helpers (unauthenticated)
2497
- static async register(options) {
2498
- const { environmentId, baseUrl, streamUrl, rtdbUrl, firebaseApiKey, ...body } = options;
2499
- const connection = resolveCanonRuntimeConnection({
2500
- environmentId,
2501
- apiBaseUrl: baseUrl,
2502
- streamUrl,
2503
- rtdbUrl,
2504
- firebaseWebApiKey: firebaseApiKey,
2505
- });
2506
- await verifyCanonRuntimeConnection(connection);
2507
- return CanonClient.register(connection.apiBaseUrl, body);
2508
- }
2509
- static async checkStatus(requestId, options) {
2510
- const connection = resolveCanonRuntimeConnection({
2511
- environmentId: options.environmentId,
2512
- apiBaseUrl: options.baseUrl,
2513
- streamUrl: options.streamUrl,
2514
- rtdbUrl: options.rtdbUrl,
2515
- firebaseWebApiKey: options.firebaseApiKey,
2516
- });
2517
- await verifyCanonRuntimeConnection(connection);
2518
- return CanonClient.checkStatus(connection.apiBaseUrl, requestId, options.pollToken);
2519
- }
2520
- static async ackStatus(requestId, options) {
2521
- const connection = resolveCanonRuntimeConnection({
2522
- environmentId: options.environmentId,
2523
- apiBaseUrl: options.baseUrl,
2524
- streamUrl: options.streamUrl,
2525
- rtdbUrl: options.rtdbUrl,
2526
- firebaseWebApiKey: options.firebaseApiKey,
2527
- });
2528
- await verifyCanonRuntimeConnection(connection);
2529
- await CanonClient.ackRegistrationStatus(connection.apiBaseUrl, requestId, options.pollToken);
2530
- }
2531
- }