@lionhummer/constellation 0.6.0 → 0.9.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 +326 -160
- package/dist/index.js +431 -278
- package/dist/types/client-core/src/agent.d.ts +81 -0
- package/dist/types/client-core/src/index.d.ts +100 -131
- package/dist/types/client-core/src/input-receiver.d.ts +24 -0
- package/dist/types/sdk/src/index.d.ts +4 -7
- package/examples/quickstart.mjs +3 -3
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
// ../client-core/src/error-details.ts
|
|
2
|
-
function callerErrorDetails(error) {
|
|
3
|
-
let cause = error;
|
|
4
|
-
const seen = new Set;
|
|
5
|
-
for (let depth = 0;depth < 8 && cause instanceof Error && !seen.has(cause); depth++) {
|
|
6
|
-
seen.add(cause);
|
|
7
|
-
if (!(cause.cause instanceof Error))
|
|
8
|
-
break;
|
|
9
|
-
cause = cause.cause;
|
|
10
|
-
}
|
|
11
|
-
const record = cause && typeof cause === "object" ? cause : {};
|
|
12
|
-
const name = cause instanceof Error ? cause.name.slice(0, 80) : "CallerError";
|
|
13
|
-
const code = typeof record.code === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(record.code) ? record.code : undefined;
|
|
14
|
-
const raw = cause instanceof Error ? cause.message : "Caller handler threw a non-Error value";
|
|
15
|
-
const message = (/Failed query:|\bparams:/i.test(raw) ? "Caller database operation failed; see caller logs" : raw).replace(/\b(?:Bearer\s+)[^\s,;]+/gi, "Bearer [redacted]").replace(/((?:password|token|secret|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, "$1[redacted]").replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[redacted]@").slice(0, 512);
|
|
16
|
-
return { name, message, ...code ? { code } : {} };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
1
|
// ../client-core/src/index.ts
|
|
20
2
|
class ConstellationError extends Error {
|
|
21
3
|
status;
|
|
@@ -27,6 +9,66 @@ class ConstellationError extends Error {
|
|
|
27
9
|
this.name = "ConstellationError";
|
|
28
10
|
}
|
|
29
11
|
}
|
|
12
|
+
function observationBytes(observations) {
|
|
13
|
+
const spans = (observations.spans ?? []).map((s) => ({ ...s, stepId: s.stepId ?? null, attemptId: s.attemptId ?? null, parentSpanId: s.parentSpanId ?? null, endedAt: s.endedAt ?? null, attributes: s.attributes ?? null }));
|
|
14
|
+
const logs = (observations.logs ?? []).map((l) => ({ ...l, stepId: l.stepId ?? null, spanId: l.spanId ?? null, attributes: l.attributes ?? null }));
|
|
15
|
+
return new TextEncoder().encode(JSON.stringify({ spans, logs })).byteLength;
|
|
16
|
+
}
|
|
17
|
+
var TIMESTAMP = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})$/i;
|
|
18
|
+
function normalizeTimestamp(value) {
|
|
19
|
+
const match = TIMESTAMP.exec(value);
|
|
20
|
+
if (!match)
|
|
21
|
+
return value;
|
|
22
|
+
const millis = Date.parse(`${match[1]}.${(match[2] ?? "").padEnd(3, "0").slice(0, 3)}${match[3].toUpperCase()}`);
|
|
23
|
+
if (!Number.isFinite(millis))
|
|
24
|
+
return value;
|
|
25
|
+
const normalized = new Date(millis).toISOString();
|
|
26
|
+
return /^\d{4}-/.test(normalized) ? normalized : value;
|
|
27
|
+
}
|
|
28
|
+
function normalizeObservations(observations) {
|
|
29
|
+
return {
|
|
30
|
+
...observations,
|
|
31
|
+
...observations.spans ? { spans: observations.spans.map((span) => ({
|
|
32
|
+
...span,
|
|
33
|
+
startedAt: normalizeTimestamp(span.startedAt),
|
|
34
|
+
...span.endedAt ? { endedAt: normalizeTimestamp(span.endedAt) } : {}
|
|
35
|
+
})) } : {},
|
|
36
|
+
...observations.logs ? { logs: observations.logs.map((log) => ({ ...log, timestamp: normalizeTimestamp(log.timestamp) })) } : {}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function validateObservations(observations) {
|
|
40
|
+
const spans = observations.spans ?? [], logs = observations.logs ?? [];
|
|
41
|
+
const ids = new Set;
|
|
42
|
+
const uuid = (id) => !id || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) && id !== "00000000-0000-0000-0000-000000000000";
|
|
43
|
+
const attributes = (value) => value == null || typeof value === "object" && !Array.isArray(value);
|
|
44
|
+
const invalid = () => {
|
|
45
|
+
throw new ConstellationError(400, "invalid_telemetry", "Telemetry exceeds limits or contains invalid observations");
|
|
46
|
+
};
|
|
47
|
+
if (spans.length > 512 || logs.length > 128 || observationBytes(observations) > 1024 * 1024)
|
|
48
|
+
invalid();
|
|
49
|
+
for (const span of spans) {
|
|
50
|
+
const start = Date.parse(span.startedAt), end = span.endedAt ? Date.parse(span.endedAt) : NaN;
|
|
51
|
+
if (!span.id || !uuid(span.id) || !uuid(span.stepId) || !uuid(span.attemptId) || !uuid(span.parentSpanId) || !attributes(span.attributes) || ids.has(span.id) || !span.name.trim() || new TextEncoder().encode(span.name).byteLength > 256 || !Number.isFinite(start) || !Number.isFinite(end) || end < start || !["ok", "error", "cancelled"].includes(span.status) || span.parentSpanId === span.id)
|
|
52
|
+
invalid();
|
|
53
|
+
ids.add(span.id);
|
|
54
|
+
}
|
|
55
|
+
const parents = new Map(spans.map((span) => [span.id, span.parentSpanId]));
|
|
56
|
+
for (const span of spans) {
|
|
57
|
+
const seen = new Set;
|
|
58
|
+
let id = span.id;
|
|
59
|
+
while (id) {
|
|
60
|
+
if (seen.has(id))
|
|
61
|
+
invalid();
|
|
62
|
+
seen.add(id);
|
|
63
|
+
id = parents.get(id) ?? undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const log of logs) {
|
|
67
|
+
if (!log.id || !uuid(log.id) || !uuid(log.stepId) || !uuid(log.spanId) || !attributes(log.attributes) || ids.has(log.id) || !log.message || new TextEncoder().encode(log.message).byteLength > 8192 || !["debug", "info", "warn", "error"].includes(log.level) || !Number.isFinite(Date.parse(log.timestamp)))
|
|
68
|
+
invalid();
|
|
69
|
+
ids.add(log.id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
30
72
|
function uuid7() {
|
|
31
73
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
32
74
|
let ms = Date.now();
|
|
@@ -39,7 +81,6 @@ function uuid7() {
|
|
|
39
81
|
const h = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
40
82
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
41
83
|
}
|
|
42
|
-
var terminal = (s) => ["completed", "failed", "cancelled"].includes(s);
|
|
43
84
|
function queryString(values) {
|
|
44
85
|
const params = new URLSearchParams;
|
|
45
86
|
for (const [key, value] of Object.entries(values))
|
|
@@ -61,14 +102,6 @@ async function pause(ms, signal) {
|
|
|
61
102
|
signal?.addEventListener("abort", abort, { once: true });
|
|
62
103
|
});
|
|
63
104
|
}
|
|
64
|
-
async function bounded(promise, signal) {
|
|
65
|
-
signal.throwIfAborted();
|
|
66
|
-
return new Promise((resolve, reject) => {
|
|
67
|
-
const abort = () => reject(signal.reason);
|
|
68
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
69
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
105
|
|
|
73
106
|
class RequestClient {
|
|
74
107
|
#fetcher;
|
|
@@ -99,8 +132,22 @@ class RequestClient {
|
|
|
99
132
|
}
|
|
100
133
|
return response.status === 204 ? undefined : response.json();
|
|
101
134
|
}
|
|
102
|
-
#submit(input, signal) {
|
|
103
|
-
|
|
135
|
+
async#submit(input, signal) {
|
|
136
|
+
const prepared = JSON.parse(JSON.stringify(input));
|
|
137
|
+
if (prepared.telemetry) {
|
|
138
|
+
prepared.telemetry = normalizeObservations(prepared.telemetry);
|
|
139
|
+
validateObservations(prepared.telemetry);
|
|
140
|
+
}
|
|
141
|
+
for (let attempt = 0;; attempt++) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.#call("/v1/requests", "POST", prepared, signal);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
signal?.throwIfAborted();
|
|
146
|
+
if (attempt >= 2 || error instanceof ConstellationError && error.status !== 429 && error.status < 500)
|
|
147
|
+
throw error;
|
|
148
|
+
await pause(100 * 2 ** attempt, signal);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
104
151
|
}
|
|
105
152
|
#inspect(id, signal) {
|
|
106
153
|
return this.#call(`/v1/requests/${encodeURIComponent(id)}`, "GET", undefined, signal);
|
|
@@ -132,80 +179,18 @@ class RequestClient {
|
|
|
132
179
|
params.set(k, String(v));
|
|
133
180
|
return this.#call(`/v1/requests?${params}`, "GET", undefined, signal);
|
|
134
181
|
}
|
|
135
|
-
#cancel(id, signal) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const renewal = (async () => {
|
|
145
|
-
while (!signal.aborted) {
|
|
146
|
-
try {
|
|
147
|
-
await pause(15000, signal);
|
|
148
|
-
} catch {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
if (signal.aborted)
|
|
152
|
-
return;
|
|
153
|
-
try {
|
|
154
|
-
await this.#claim(id, inputId, claimId, signal);
|
|
155
|
-
} catch (error) {
|
|
156
|
-
controller.abort(error);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
})();
|
|
161
|
-
return {
|
|
162
|
-
signal,
|
|
163
|
-
stop: async () => {
|
|
164
|
-
controller.abort();
|
|
165
|
-
await renewal;
|
|
182
|
+
async#cancel(id, signal) {
|
|
183
|
+
for (let attempt = 0;; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
return await this.#call(`/v1/requests/${encodeURIComponent(id)}/cancel`, "POST", {}, signal);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
signal?.throwIfAborted();
|
|
188
|
+
if (attempt >= 7 || !(error instanceof ConstellationError) || error.status !== 409 || error.code !== "conflict")
|
|
189
|
+
throw error;
|
|
190
|
+
await pause(Math.min(25 * 2 ** attempt, 200), signal);
|
|
166
191
|
}
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
async#whileClaimed(id, inputId, claimId, leased, operation, signal) {
|
|
170
|
-
const lease = leased ? this.maintainClaim(id, inputId, claimId, signal) : null;
|
|
171
|
-
try {
|
|
172
|
-
return await operation(lease?.signal ?? signal);
|
|
173
|
-
} finally {
|
|
174
|
-
await lease?.stop();
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
|
-
#respond(id, inputId, response, signal) {
|
|
178
|
-
return this.#call(`/v1/requests/${encodeURIComponent(id)}/inputs/${encodeURIComponent(inputId)}`, "POST", response, signal);
|
|
179
|
-
}
|
|
180
|
-
#addSpans(id, spans, signal) {
|
|
181
|
-
return this.#call(`/v1/requests/${encodeURIComponent(id)}/spans`, "POST", spans, signal);
|
|
182
|
-
}
|
|
183
|
-
#addLogs(id, logs, signal) {
|
|
184
|
-
return this.#call(`/v1/requests/${encodeURIComponent(id)}/logs`, "POST", logs, signal);
|
|
185
|
-
}
|
|
186
|
-
async#startSpan(id, name, options = {}, signal) {
|
|
187
|
-
const span = {
|
|
188
|
-
id: uuid7(),
|
|
189
|
-
name,
|
|
190
|
-
startedAt: options.startedAt ?? new Date().toISOString(),
|
|
191
|
-
status: "running",
|
|
192
|
-
...options
|
|
193
|
-
};
|
|
194
|
-
await this.#addSpans(id, [span], signal);
|
|
195
|
-
let closed;
|
|
196
|
-
return {
|
|
197
|
-
id: span.id,
|
|
198
|
-
end: (options2 = {}, signal2) => {
|
|
199
|
-
closed = {
|
|
200
|
-
...span,
|
|
201
|
-
endedAt: options2.endedAt ?? closed?.endedAt ?? new Date().toISOString(),
|
|
202
|
-
status: options2.status ?? closed?.status ?? "ok",
|
|
203
|
-
attributes: options2.attributes ?? closed?.attributes ?? span.attributes
|
|
204
|
-
};
|
|
205
|
-
return this.#addSpans(id, [closed, ...options2.spans ?? []], signal2);
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
194
|
async* #events(id, options = {}) {
|
|
210
195
|
let after = options.after ?? 0;
|
|
211
196
|
while (true) {
|
|
@@ -227,6 +212,11 @@ class RequestClient {
|
|
|
227
212
|
if (!response)
|
|
228
213
|
continue;
|
|
229
214
|
if (!response.ok || !response.body) {
|
|
215
|
+
if (response.status === 429 || response.status >= 500) {
|
|
216
|
+
await response.body?.cancel();
|
|
217
|
+
await pause(500, options.signal);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
230
220
|
throw new ConstellationError(response.status, "stream_error", `Event stream returned ${response.status}`);
|
|
231
221
|
}
|
|
232
222
|
const reader = response.body.pipeThrough(new TextDecoderStream).getReader();
|
|
@@ -250,12 +240,10 @@ class RequestClient {
|
|
|
250
240
|
if (!data)
|
|
251
241
|
continue;
|
|
252
242
|
const event = JSON.parse(data);
|
|
253
|
-
if (event.
|
|
243
|
+
if (event.sequence <= after)
|
|
254
244
|
continue;
|
|
255
|
-
after = event.
|
|
245
|
+
after = event.sequence;
|
|
256
246
|
yield event;
|
|
257
|
-
if (["request.completed", "request.failed", "request.cancelled"].includes(event.type))
|
|
258
|
-
return;
|
|
259
247
|
}
|
|
260
248
|
}
|
|
261
249
|
} catch {
|
|
@@ -263,166 +251,39 @@ class RequestClient {
|
|
|
263
251
|
} finally {
|
|
264
252
|
await reader.cancel().catch(() => {});
|
|
265
253
|
}
|
|
266
|
-
const inspection = await this.#
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
254
|
+
const inspection = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/state`, options.signal);
|
|
255
|
+
if (["completed", "failed", "cancelled"].includes(inspection.request.status)) {
|
|
256
|
+
while (true) {
|
|
257
|
+
const before = after;
|
|
258
|
+
const tail = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/events?after=${after}`, options.signal);
|
|
259
|
+
for (const event of tail.items) {
|
|
260
|
+
if (event.sequence <= after)
|
|
261
|
+
continue;
|
|
262
|
+
after = event.sequence;
|
|
263
|
+
yield event;
|
|
264
|
+
}
|
|
265
|
+
if (tail.items.length === 0)
|
|
266
|
+
return;
|
|
267
|
+
if (after === before)
|
|
268
|
+
throw new Error("Replay page did not advance the event cursor");
|
|
274
269
|
}
|
|
275
|
-
if (tail.items.length < 500)
|
|
276
|
-
return;
|
|
277
|
-
continue;
|
|
278
270
|
}
|
|
279
271
|
await pause(500, options.signal);
|
|
280
272
|
}
|
|
281
273
|
}
|
|
282
|
-
async#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const state = await this.#inspect(id, options.signal);
|
|
291
|
-
const inspectEnded = new Date().toISOString();
|
|
292
|
-
const inspectMs = performance.now() - inspectClock;
|
|
293
|
-
options.onUpdate?.(state);
|
|
294
|
-
if (terminal(state.request.status))
|
|
295
|
-
return state;
|
|
296
|
-
if (options.targetInputId) {
|
|
297
|
-
const target = state.inputs.find((input) => input.id === options.targetInputId);
|
|
298
|
-
if (!target)
|
|
299
|
-
throw new Error(`Input ${options.targetInputId} is not in request ${id}`);
|
|
300
|
-
if (target.resolvedAt)
|
|
301
|
-
return state;
|
|
302
|
-
}
|
|
303
|
-
let handled = false;
|
|
304
|
-
for (const pending of state.inputs.filter((i) => !i.resolvedAt && (!options.targetInputId || i.id === options.targetInputId))) {
|
|
305
|
-
if (pending.claimId && pending.claimId !== consumerId && !(pending.claimUntil && Date.parse(pending.claimUntil) <= Date.now()))
|
|
306
|
-
continue;
|
|
307
|
-
if (pending.kind === "before_model" || pending.kind === "after_model" || pending.kind === "after_step") {
|
|
308
|
-
const name = pending.kind === "before_model" ? "beforeModel" : pending.kind === "after_model" ? "afterModel" : "afterStep";
|
|
309
|
-
const handler = options.checkpoints?.[name];
|
|
310
|
-
if (!handler)
|
|
311
|
-
continue;
|
|
312
|
-
try {
|
|
313
|
-
await this.#claim(id, pending.id, consumerId, options.signal);
|
|
314
|
-
} catch (error) {
|
|
315
|
-
if (error instanceof ConstellationError && error.status === 409)
|
|
316
|
-
continue;
|
|
317
|
-
throw error;
|
|
318
|
-
}
|
|
319
|
-
await this.#whileClaimed(id, pending.id, consumerId, true, async (activeSignal) => {
|
|
320
|
-
const span = await this.#startSpan(id, `caller.${name}`, { stepId: pending.stepId ?? undefined, inputId: pending.id }, activeSignal);
|
|
321
|
-
let closed = false;
|
|
322
|
-
try {
|
|
323
|
-
const signal = activeSignal ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)]) : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
|
|
324
|
-
const response = await bounded(handler(pending.payload, state, { inputId: pending.id, parentSpanId: span.id, signal }), signal);
|
|
325
|
-
await span.end({}, activeSignal);
|
|
326
|
-
closed = true;
|
|
327
|
-
await this.#claim(id, pending.id, consumerId, activeSignal);
|
|
328
|
-
await this.#respond(id, pending.id, { ...response, claimId: consumerId }, activeSignal);
|
|
329
|
-
} catch (error) {
|
|
330
|
-
if (!activeSignal?.aborted && !closed)
|
|
331
|
-
await span.end({ status: "error", attributes: { error: callerErrorDetails(error) } }, activeSignal).catch(() => {});
|
|
332
|
-
throw error;
|
|
333
|
-
}
|
|
334
|
-
}, options.signal);
|
|
335
|
-
handled = true;
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const userInput = options.userInput;
|
|
339
|
-
if (pending.kind === "user_input" && userInput) {
|
|
340
|
-
try {
|
|
341
|
-
await this.#claim(id, pending.id, consumerId, options.signal);
|
|
342
|
-
} catch (error) {
|
|
343
|
-
if (error instanceof ConstellationError && error.status === 409)
|
|
344
|
-
continue;
|
|
345
|
-
throw error;
|
|
346
|
-
}
|
|
347
|
-
await this.#whileClaimed(id, pending.id, consumerId, true, async (activeSignal) => {
|
|
348
|
-
const signal = activeSignal ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)]) : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
|
|
349
|
-
const result = await bounded(userInput(state, { inputId: pending.id, signal }), signal);
|
|
350
|
-
await this.#claim(id, pending.id, consumerId, activeSignal);
|
|
351
|
-
await this.#respond(id, pending.id, { result, claimId: consumerId }, activeSignal);
|
|
352
|
-
}, options.signal);
|
|
353
|
-
handled = true;
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
if (pending.kind !== "tool")
|
|
357
|
-
continue;
|
|
358
|
-
const claimStarted = new Date().toISOString();
|
|
359
|
-
const claimClock = performance.now();
|
|
360
|
-
const tool = options.tools?.[pending.payload.name ?? ""];
|
|
361
|
-
if (!tool && !pending.payload.validationError)
|
|
362
|
-
continue;
|
|
363
|
-
try {
|
|
364
|
-
await this.#claim(id, pending.id, consumerId, options.signal);
|
|
365
|
-
} catch (error) {
|
|
366
|
-
if (error instanceof ConstellationError && error.status === 409)
|
|
367
|
-
continue;
|
|
274
|
+
async#replayRead(path, signal) {
|
|
275
|
+
for (;; ) {
|
|
276
|
+
signal?.throwIfAborted();
|
|
277
|
+
try {
|
|
278
|
+
return await this.#call(path, "GET", undefined, signal);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
signal?.throwIfAborted();
|
|
281
|
+
if (error instanceof ConstellationError && error.status !== 429 && error.status < 500)
|
|
368
282
|
throw error;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
await this.#whileClaimed(id, pending.id, consumerId, true, async (activeSignal) => {
|
|
373
|
-
const span = await this.#startSpan(id, `tool.${pending.payload.name}`, { stepId: pending.stepId ?? undefined, inputId: pending.id }, activeSignal);
|
|
374
|
-
const phases = [
|
|
375
|
-
{ id: uuid7(), inputId: pending.id, name: "caller.input.inspect", startedAt: inspectStarted, endedAt: inspectEnded, status: "ok", attributes: { elapsedMs: inspectMs } },
|
|
376
|
-
{ id: uuid7(), inputId: pending.id, name: "caller.input.claim", startedAt: claimStarted, endedAt: claimEnded, status: "ok", attributes: { elapsedMs: claimMs } }
|
|
377
|
-
];
|
|
378
|
-
let result;
|
|
379
|
-
let isError = false;
|
|
380
|
-
let errorDetails;
|
|
381
|
-
try {
|
|
382
|
-
if (pending.payload.validationError)
|
|
383
|
-
throw new Error(pending.payload.validationError);
|
|
384
|
-
if (!tool)
|
|
385
|
-
throw new Error("Tool unavailable");
|
|
386
|
-
const toolSignal = activeSignal ? AbortSignal.any([activeSignal, AbortSignal.timeout(options.toolTimeoutMs ?? 30000)]) : AbortSignal.timeout(options.toolTimeoutMs ?? 30000);
|
|
387
|
-
result = await bounded(tool(pending.payload.arguments ?? {}, {
|
|
388
|
-
inputId: pending.id,
|
|
389
|
-
requestId: id,
|
|
390
|
-
parentSpanId: span.id,
|
|
391
|
-
recordSpan: (phase) => phases.push({ ...phase, inputId: pending.id, parentSpanId: span.id }),
|
|
392
|
-
signal: toolSignal
|
|
393
|
-
}), toolSignal);
|
|
394
|
-
} catch (error) {
|
|
395
|
-
activeSignal?.throwIfAborted();
|
|
396
|
-
isError = true;
|
|
397
|
-
errorDetails = callerErrorDetails(error);
|
|
398
|
-
result = { error: error instanceof Error ? error.message : "Tool failed" };
|
|
399
|
-
}
|
|
400
|
-
await span.end({ status: isError ? "error" : "ok", spans: phases, ...errorDetails ? { attributes: { error: errorDetails } } : {} }, activeSignal);
|
|
401
|
-
await this.#claim(id, pending.id, consumerId, activeSignal);
|
|
402
|
-
const responseStarted = new Date().toISOString();
|
|
403
|
-
const responseClock = performance.now();
|
|
404
|
-
let responseStatus = "ok";
|
|
405
|
-
try {
|
|
406
|
-
await this.#respond(id, pending.id, { result, isError, claimId: consumerId }, activeSignal);
|
|
407
|
-
} catch (error) {
|
|
408
|
-
responseStatus = "error";
|
|
409
|
-
throw error;
|
|
410
|
-
} finally {
|
|
411
|
-
const responseSpan = { id: uuid7(), inputId: pending.id, name: "caller.input.respond", startedAt: responseStarted, endedAt: new Date().toISOString(), status: responseStatus, attributes: { elapsedMs: performance.now() - responseClock } };
|
|
412
|
-
await this.#addSpans(id, [responseSpan], activeSignal ? AbortSignal.any([activeSignal, AbortSignal.timeout(2000)]) : AbortSignal.timeout(2000)).catch((error) => {
|
|
413
|
-
if (!(error instanceof ConstellationError && (error.status === 409 || error.status === 404)))
|
|
414
|
-
console.warn("Constellation input-response timing could not be recorded");
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
}, options.signal);
|
|
418
|
-
handled = true;
|
|
419
|
-
}
|
|
420
|
-
if (options.targetInputId) {
|
|
421
|
-
if (!handled)
|
|
422
|
-
throw new Error(`Input ${options.targetInputId} could not be handled`);
|
|
423
|
-
return this.#inspect(id, options.signal);
|
|
283
|
+
if (error instanceof SyntaxError)
|
|
284
|
+
throw error;
|
|
285
|
+
await pause(500, signal);
|
|
424
286
|
}
|
|
425
|
-
await pause(500, options.signal);
|
|
426
287
|
}
|
|
427
288
|
}
|
|
428
289
|
get models() {
|
|
@@ -433,9 +294,10 @@ class RequestClient {
|
|
|
433
294
|
}
|
|
434
295
|
get requests() {
|
|
435
296
|
return {
|
|
436
|
-
|
|
297
|
+
submit: (input, signal) => this.#submit(input, signal),
|
|
437
298
|
preview: (input, signal) => this.#resolve(input, signal),
|
|
438
299
|
get: (id, signal) => this.#inspect(id, signal),
|
|
300
|
+
getState: (id, signal) => this.#call(`/v1/requests/${encodeURIComponent(id)}/state`, "GET", undefined, signal),
|
|
439
301
|
getSummary: (id, signal) => this.#inspectSummary(id, signal),
|
|
440
302
|
getTimings: (id, signal) => this.#call(`/v1/requests/${encodeURIComponent(id)}/timings`, "GET", undefined, signal),
|
|
441
303
|
list: (query = {}, signal) => this.#list(query, signal),
|
|
@@ -450,22 +312,306 @@ class RequestClient {
|
|
|
450
312
|
getHttpBody: (id, attemptId, query, signal) => this.#call(`/v1/requests/${encodeURIComponent(id)}/http/${encodeURIComponent(attemptId)}/body${queryString(query)}`, "GET", undefined, signal)
|
|
451
313
|
};
|
|
452
314
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
315
|
+
}
|
|
316
|
+
// ../client-core/src/error-details.ts
|
|
317
|
+
function callerErrorDetails(error) {
|
|
318
|
+
let cause = error;
|
|
319
|
+
const seen = new Set;
|
|
320
|
+
for (let depth = 0;depth < 8 && cause instanceof Error && !seen.has(cause); depth++) {
|
|
321
|
+
seen.add(cause);
|
|
322
|
+
if (!(cause.cause instanceof Error))
|
|
323
|
+
break;
|
|
324
|
+
cause = cause.cause;
|
|
460
325
|
}
|
|
461
|
-
|
|
326
|
+
const record = cause && typeof cause === "object" ? cause : {};
|
|
327
|
+
const name = cause instanceof Error ? cause.name.slice(0, 80) : "CallerError";
|
|
328
|
+
const code = typeof record.code === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(record.code) ? record.code : undefined;
|
|
329
|
+
const raw = cause instanceof Error ? cause.message : "Caller handler threw a non-Error value";
|
|
330
|
+
const message = (/Failed query:|\bparams:/i.test(raw) ? "Caller database operation failed; see caller logs" : raw).replace(/\b(?:Bearer\s+)[^\s,;]+/gi, "Bearer [redacted]").replace(/((?:password|token|secret|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, "$1[redacted]").replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[redacted]@").slice(0, 512);
|
|
331
|
+
return { name, message, ...code ? { code } : {} };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ../client-core/src/permanent-codes.json
|
|
335
|
+
var permanent_codes_default = [
|
|
336
|
+
"bad_request",
|
|
337
|
+
"observations_expired",
|
|
338
|
+
"response_conflict"
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
// ../client-core/src/agent.ts
|
|
342
|
+
var PERMANENT_RESPONSE_CODES = new Set(permanent_codes_default);
|
|
343
|
+
function truncateUtf8(value, limit) {
|
|
344
|
+
let result = "", bytes = 0;
|
|
345
|
+
for (const raw of value) {
|
|
346
|
+
const point = raw.codePointAt(0);
|
|
347
|
+
const char = point >= 55296 && point <= 57343 ? "�" : raw;
|
|
348
|
+
const width = new TextEncoder().encode(char).byteLength;
|
|
349
|
+
if (bytes + width > limit)
|
|
350
|
+
break;
|
|
351
|
+
result += char;
|
|
352
|
+
bytes += width;
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
function cleanJson(value) {
|
|
357
|
+
if (typeof value === "string")
|
|
358
|
+
return truncateUtf8(value, Infinity);
|
|
359
|
+
if (Array.isArray(value))
|
|
360
|
+
return value.map(cleanJson);
|
|
361
|
+
if (value && typeof value === "object")
|
|
362
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [truncateUtf8(key, Infinity), cleanJson(item)]));
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
class PermanentEventError extends Error {
|
|
367
|
+
status;
|
|
368
|
+
code;
|
|
369
|
+
constructor(status, code, message) {
|
|
370
|
+
super(message);
|
|
371
|
+
this.status = status;
|
|
372
|
+
this.code = code;
|
|
373
|
+
this.name = "PermanentEventError";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
class HookConfigurationError extends PermanentEventError {
|
|
378
|
+
constructor(message) {
|
|
379
|
+
super(400, "invalid_hook_configuration", message);
|
|
380
|
+
this.name = "HookConfigurationError";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
class EventReceiver {
|
|
385
|
+
store;
|
|
386
|
+
constructor(store) {
|
|
387
|
+
this.store = store;
|
|
388
|
+
}
|
|
389
|
+
async receive(event) {
|
|
390
|
+
await this.store.accept(event);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
class ClientTelemetry {
|
|
395
|
+
parentSpanId;
|
|
396
|
+
spans = [];
|
|
397
|
+
logs = [];
|
|
398
|
+
constructor(parentSpanId) {
|
|
399
|
+
this.parentSpanId = parentSpanId;
|
|
400
|
+
}
|
|
401
|
+
log(level, message, attributes) {
|
|
402
|
+
this.logs.push({
|
|
403
|
+
id: uuid7(),
|
|
404
|
+
level,
|
|
405
|
+
message,
|
|
406
|
+
timestamp: new Date().toISOString(),
|
|
407
|
+
spanId: this.parentSpanId,
|
|
408
|
+
attributes
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
async span(name, attributes, work) {
|
|
412
|
+
const id = uuid7();
|
|
413
|
+
const startedAt = new Date().toISOString();
|
|
414
|
+
const started = performance.now();
|
|
415
|
+
const child = new ClientTelemetry(id);
|
|
416
|
+
let status = "ok";
|
|
417
|
+
let failure;
|
|
418
|
+
try {
|
|
419
|
+
return await work(child);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
status = "error";
|
|
422
|
+
failure = callerErrorDetails(error);
|
|
423
|
+
throw error;
|
|
424
|
+
} finally {
|
|
425
|
+
this.spans.push({
|
|
426
|
+
id,
|
|
427
|
+
name,
|
|
428
|
+
parentSpanId: this.parentSpanId,
|
|
429
|
+
startedAt,
|
|
430
|
+
endedAt: new Date().toISOString(),
|
|
431
|
+
status,
|
|
432
|
+
attributes: {
|
|
433
|
+
...attributes,
|
|
434
|
+
elapsedMs: performance.now() - started,
|
|
435
|
+
...failure ? { error: failure } : {}
|
|
436
|
+
}
|
|
437
|
+
}, ...child.spans);
|
|
438
|
+
this.logs.push(...child.logs);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
snapshot() {
|
|
442
|
+
const spans = structuredClone(this.spans.slice(0, 512));
|
|
443
|
+
const logs = structuredClone(this.logs.slice(0, 128));
|
|
444
|
+
for (const span of spans) {
|
|
445
|
+
span.name = truncateUtf8(span.name, 256).trim() || "(unnamed span)";
|
|
446
|
+
if (span.attributes)
|
|
447
|
+
span.attributes = cleanJson(span.attributes);
|
|
448
|
+
if (span.endedAt && span.endedAt < span.startedAt)
|
|
449
|
+
span.endedAt = span.startedAt;
|
|
450
|
+
}
|
|
451
|
+
for (const log of logs) {
|
|
452
|
+
log.message = truncateUtf8(log.message, 8192);
|
|
453
|
+
if (log.attributes)
|
|
454
|
+
log.attributes = cleanJson(log.attributes);
|
|
455
|
+
if (!log.message)
|
|
456
|
+
log.message = "(empty log)";
|
|
457
|
+
}
|
|
458
|
+
const bytes = () => observationBytes({ spans, logs });
|
|
459
|
+
if (bytes() > 1024 * 1024) {
|
|
460
|
+
for (const item of [...spans, ...logs]) {
|
|
461
|
+
if (new TextEncoder().encode(JSON.stringify(item.attributes ?? {})).byteLength > 4096)
|
|
462
|
+
item.attributes = { omitted: true, reason: "telemetry_byte_limit" };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
while (bytes() > 1024 * 1024 && (logs.length || spans.length > 1)) {
|
|
466
|
+
if (logs.length)
|
|
467
|
+
logs.pop();
|
|
468
|
+
else
|
|
469
|
+
spans.pop();
|
|
470
|
+
}
|
|
471
|
+
const ids = new Set(spans.map((span) => span.id));
|
|
472
|
+
for (const log of logs)
|
|
473
|
+
if (log.spanId && !ids.has(log.spanId))
|
|
474
|
+
delete log.spanId;
|
|
475
|
+
if (spans.length && (spans.length < this.spans.length || logs.length < this.logs.length)) {
|
|
476
|
+
spans[0].attributes = { ...spans[0].attributes, omittedSpans: this.spans.length - spans.length, omittedLogs: this.logs.length - logs.length };
|
|
477
|
+
}
|
|
478
|
+
while (bytes() > 1024 * 1024) {
|
|
479
|
+
if (logs.length)
|
|
480
|
+
logs.pop();
|
|
481
|
+
else if (spans.length > 1)
|
|
482
|
+
spans.pop();
|
|
483
|
+
else if (spans.length)
|
|
484
|
+
spans[0].attributes = { omitted: true, reason: "telemetry_byte_limit" };
|
|
485
|
+
else
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
return { spans, logs };
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function prepareResponseBody(event, hooks, telemetry, signal) {
|
|
492
|
+
const context = { event, signal, telemetry };
|
|
493
|
+
const observe = () => ({
|
|
494
|
+
action: "observe",
|
|
495
|
+
idempotencyKey: event.id,
|
|
496
|
+
inReplyTo: { requestId: event.requestId, eventId: event.id },
|
|
497
|
+
telemetry: telemetry.snapshot()
|
|
498
|
+
});
|
|
499
|
+
signal?.throwIfAborted();
|
|
500
|
+
if (event.type !== "model.completed") {
|
|
501
|
+
await telemetry.span("client.model_error", { type: event.type }, async (t) => {
|
|
502
|
+
await hooks.onError?.(event.data, { ...context, telemetry: t });
|
|
503
|
+
});
|
|
504
|
+
return observe();
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
const calls = event.data.message.toolCalls ?? [];
|
|
508
|
+
const ids = new Set(calls.map((c) => c.id));
|
|
509
|
+
if (ids.size !== calls.length)
|
|
510
|
+
throw new Error("Duplicate tool-call IDs in model result");
|
|
511
|
+
const concurrency = hooks.toolConcurrency ?? 8;
|
|
512
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 128)
|
|
513
|
+
throw new HookConfigurationError("toolConcurrency must be between 1 and 128");
|
|
514
|
+
const results = new Array(calls.length);
|
|
515
|
+
let next = 0;
|
|
516
|
+
const executions = await Promise.allSettled(Array.from({ length: Math.min(concurrency, calls.length) }, async () => {
|
|
517
|
+
for (;; ) {
|
|
518
|
+
const index = next++;
|
|
519
|
+
if (index >= calls.length)
|
|
520
|
+
return;
|
|
521
|
+
signal?.throwIfAborted();
|
|
522
|
+
const call = calls[index];
|
|
523
|
+
const handler = hooks.tools?.[call.name];
|
|
524
|
+
if (!handler)
|
|
525
|
+
throw new HookConfigurationError(`No handler for tool ${call.name}`);
|
|
526
|
+
const result = await telemetry.span(`tool.${call.name}`, { toolCallId: call.id }, (t) => handler(call.arguments, {
|
|
527
|
+
...context,
|
|
528
|
+
telemetry: t,
|
|
529
|
+
toolCallId: call.id,
|
|
530
|
+
effectId: JSON.stringify([event.id, call.id])
|
|
531
|
+
}));
|
|
532
|
+
results[index] = { id: call.id, result };
|
|
533
|
+
}
|
|
534
|
+
}));
|
|
535
|
+
const failed = executions.find((result) => result.status === "rejected");
|
|
536
|
+
if (failed)
|
|
537
|
+
throw failed.reason;
|
|
538
|
+
const decision = await telemetry.span("client.step_complete", {}, (t) => hooks.onStepComplete ? hooks.onStepComplete({
|
|
539
|
+
...context,
|
|
540
|
+
event,
|
|
541
|
+
toolResults: results,
|
|
542
|
+
telemetry: t
|
|
543
|
+
}) : Promise.resolve(calls.length ? "continue" : "stop"));
|
|
544
|
+
if (decision === "stop")
|
|
545
|
+
return observe();
|
|
546
|
+
if (!hooks.prepareRequest)
|
|
547
|
+
throw new HookConfigurationError("prepareRequest is required to continue");
|
|
548
|
+
const input = await telemetry.span("client.prepare_request", {}, (t) => hooks.prepareRequest({
|
|
549
|
+
...context,
|
|
550
|
+
event,
|
|
551
|
+
toolResults: results,
|
|
552
|
+
telemetry: t
|
|
553
|
+
}));
|
|
462
554
|
return {
|
|
463
|
-
|
|
464
|
-
|
|
555
|
+
...input,
|
|
556
|
+
idempotencyKey: event.id,
|
|
557
|
+
inReplyTo: { requestId: event.requestId, eventId: event.id },
|
|
558
|
+
telemetry: telemetry.snapshot()
|
|
465
559
|
};
|
|
560
|
+
} catch (error) {
|
|
561
|
+
signal?.throwIfAborted();
|
|
562
|
+
if (!hooks.onError) {
|
|
563
|
+
if (!(error instanceof HookConfigurationError))
|
|
564
|
+
throw error;
|
|
565
|
+
telemetry.log("error", error.message, { code: "invalid_hook_configuration" });
|
|
566
|
+
return { ...observe(), error: { code: error.code, message: error.message } };
|
|
567
|
+
}
|
|
568
|
+
await telemetry.span("client.error", { error: callerErrorDetails(error) }, (t) => hooks.onError(error, { ...context, telemetry: t }));
|
|
569
|
+
return observe();
|
|
466
570
|
}
|
|
467
|
-
|
|
468
|
-
|
|
571
|
+
}
|
|
572
|
+
async function prepareResponse(event, hooks, signal) {
|
|
573
|
+
const telemetry = new ClientTelemetry;
|
|
574
|
+
const response = await telemetry.span("client.processing", { eventId: event.id, eventType: event.type }, (child) => prepareResponseBody(event, hooks, child, signal));
|
|
575
|
+
if (response)
|
|
576
|
+
response.telemetry = telemetry.snapshot();
|
|
577
|
+
return response;
|
|
578
|
+
}
|
|
579
|
+
async function processEvent(client, store, event, hooks, signal) {
|
|
580
|
+
let stage = "prepare";
|
|
581
|
+
try {
|
|
582
|
+
const response = await store.prepare(event, () => prepareResponse(event, hooks, signal));
|
|
583
|
+
let receipt = null;
|
|
584
|
+
if (response) {
|
|
585
|
+
stage = "submit";
|
|
586
|
+
try {
|
|
587
|
+
receipt = await client.requests.submit(response, signal);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (error instanceof ConstellationError && PERMANENT_RESPONSE_CODES.has(error.code)) {
|
|
590
|
+
const rejection = new PermanentEventError(error.status, error.code, error.message);
|
|
591
|
+
await store.reject(event.id, rejection);
|
|
592
|
+
throw rejection;
|
|
593
|
+
}
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
if ("action" in response && response.action === "observe" && response.error) {
|
|
597
|
+
const failure = new HookConfigurationError(response.error.message);
|
|
598
|
+
await store.reject(event.id, failure);
|
|
599
|
+
throw failure;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
stage = "checkpoint";
|
|
603
|
+
await store.complete(event.id, receipt);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (!(error instanceof PermanentEventError) && store.retry) {
|
|
606
|
+
const detail = error instanceof ConstellationError ? { status: error.status, code: error.code, message: error.message } : { status: 0, code: error instanceof Error && error.name === "TimeoutError" ? "timeout" : stage === "submit" ? "transport_error" : `event_${stage}_failed`, message: error instanceof Error ? error.message : "Event processing failed" };
|
|
607
|
+
try {
|
|
608
|
+
await store.retry(event.id, detail);
|
|
609
|
+
} catch (monitorError) {
|
|
610
|
+
if (error && typeof error === "object")
|
|
611
|
+
Object.assign(error, { retryHookError: monitorError });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
throw error;
|
|
469
615
|
}
|
|
470
616
|
}
|
|
471
617
|
|
|
@@ -598,15 +744,14 @@ class Constellation extends RequestClient {
|
|
|
598
744
|
const event = body;
|
|
599
745
|
if (typeof event.requestId !== "string" || !event.requestId)
|
|
600
746
|
throw new Error("Invalid Constellation request ID");
|
|
601
|
-
if (typeof event.
|
|
602
|
-
if (
|
|
603
|
-
|
|
747
|
+
if (typeof event.id === "string" && event.id && Number.isSafeInteger(event.sequence) && typeof event.createdAt === "string" && Number.isFinite(Date.parse(event.createdAt)) && event.data && typeof event.data === "object" && !Array.isArray(event.data) && ["model.completed", "model.failed", "model.cancelled"].includes(String(event.type))) {
|
|
748
|
+
if (event.type === "model.completed") {
|
|
749
|
+
const data = event.data;
|
|
750
|
+
if (!data.message || typeof data.message !== "object" || data.message.role !== "assistant" || !data.result || typeof data.result !== "object")
|
|
751
|
+
throw new Error("Invalid completed model result");
|
|
752
|
+
}
|
|
604
753
|
return event;
|
|
605
754
|
}
|
|
606
|
-
if (event.kind === "request.events" && Number.isSafeInteger(event.afterSeq) && Array.isArray(event.events))
|
|
607
|
-
return event;
|
|
608
|
-
if (["request.completed", "request.failed", "request.cancelled"].includes(String(event.kind)) && Number.isSafeInteger(event.eventSeq))
|
|
609
|
-
return event;
|
|
610
755
|
throw new Error("Unknown Constellation webhook event");
|
|
611
756
|
}
|
|
612
757
|
};
|
|
@@ -614,6 +759,14 @@ class Constellation extends RequestClient {
|
|
|
614
759
|
}
|
|
615
760
|
export {
|
|
616
761
|
uuid7,
|
|
762
|
+
processEvent,
|
|
763
|
+
prepareResponse,
|
|
764
|
+
normalizeTimestamp,
|
|
765
|
+
PermanentEventError,
|
|
766
|
+
PERMANENT_RESPONSE_CODES,
|
|
767
|
+
HookConfigurationError,
|
|
768
|
+
EventReceiver,
|
|
617
769
|
ConstellationError,
|
|
618
|
-
Constellation
|
|
770
|
+
Constellation,
|
|
771
|
+
ClientTelemetry
|
|
619
772
|
};
|