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