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