dyadic 1.3.1 → 1.7.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/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/dist/blackbox.d.ts +266 -0
- package/dist/blackbox.js +852 -0
- package/dist/index.d.ts +1095 -11
- package/dist/index.js +560 -29
- package/dist/otel.d.ts +103 -0
- package/dist/otel.js +305 -0
- package/package.json +5 -5
package/dist/blackbox.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
// Dyadic Agent Black Box for TypeScript and JavaScript: record your agent's runs, check evidence bundles offline.
|
|
2
|
+
//
|
|
3
|
+
// const recorder = new BlackBoxRecorder(client); // a Dyadic client whose key may send Black Box telemetry
|
|
4
|
+
// await recorder.run({ title: "Book a room", agent: "planner" }, async (run) => {
|
|
5
|
+
// run.declare({ allowedTools: ["book_room"], allowedHosts: ["rooms.example.com"] });
|
|
6
|
+
// await run.modelCall("gpt-4.1", { input: messages }, async (call) => {
|
|
7
|
+
// const reply = await llm(messages);
|
|
8
|
+
// call.responded({ usage: { input_tokens: 812, output_tokens: 40 }, toolCalls: [{ id: "call_1", name: "book_room" }] });
|
|
9
|
+
// return reply;
|
|
10
|
+
// });
|
|
11
|
+
// await run.toolCall("book_room", { args: { room: "Orion" }, effect: true, hosts: ["rooms.example.com"], callId: "call_1" },
|
|
12
|
+
// async (tool) => tool.result(await bookRoom("Orion")));
|
|
13
|
+
// run.verified("booking", "confirmation ORN-1042 read back from the booking API");
|
|
14
|
+
// });
|
|
15
|
+
//
|
|
16
|
+
// Recording never blocks: events queue (bounded; telemetry is dropped first, every drop leaves a gap in this process's
|
|
17
|
+
// own sequence numbers and a note) and are sent in batches with an Idempotency-Key. Spans nest explicitly: a span's
|
|
18
|
+
// own methods (span.toolCall, span.agent, ...) make its children. Content (inputs, outputs, tool arguments and results)
|
|
19
|
+
// is sent only with { captureContent: true }, after `redact` (secrets by default). Everything is recorded as *reported*
|
|
20
|
+
// by your application. The same canonical JSON (RFC 8785), hashes and Ed25519 checks as the service and the Python SDK,
|
|
21
|
+
// tested against sdk/contracts/blackbox.
|
|
22
|
+
export const BUNDLE_FORMAT = "dyadic.blackbox.bundle/1";
|
|
23
|
+
const GENESIS_PREFIX = "dyadic.blackbox.genesis/1:";
|
|
24
|
+
const COMMIT_PREFIX = "dyadic.blackbox.commit/1\u0000";
|
|
25
|
+
const TELEMETRY = new Set(["note", "span.started", "span.ended", "stream.summary", "retry"]);
|
|
26
|
+
export const LIMITATIONS = [
|
|
27
|
+
"A hash chain shows recorded events were not altered, removed or reordered afterwards; it cannot show that activity the recorder never observed did not happen.",
|
|
28
|
+
"Events with basis 'reported' are what a producer outside the platform said happened; the platform did not see them.",
|
|
29
|
+
"A signature shows the holder of the signing key issued the checkpoint. Anyone holding the key (or a compromised signing service) could sign a different history; compare key ids with keys published independently.",
|
|
30
|
+
"Events after the last checkpoint are chained but not signed.",
|
|
31
|
+
"Timestamps come from the clock named in each event; clocks can be wrong or skewed.",
|
|
32
|
+
"Analysis in the bundle (graph, findings) is not covered by any signature and is interpretation, not evidence.",
|
|
33
|
+
];
|
|
34
|
+
// -- canonical JSON, hashes, trace context ------------------------------------------------------------
|
|
35
|
+
/** RFC 8785 canonical JSON: sorted keys (UTF-16 code units), no whitespace, ECMAScript numbers and string escapes. */
|
|
36
|
+
export function canonical(value) {
|
|
37
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
38
|
+
return JSON.stringify(value);
|
|
39
|
+
if (typeof value === "number") {
|
|
40
|
+
if (!Number.isFinite(value))
|
|
41
|
+
throw new TypeError("NaN and infinity have no JSON form");
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(value))
|
|
45
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
46
|
+
if (typeof value === "object") {
|
|
47
|
+
const record = value;
|
|
48
|
+
return `{${Object.keys(record).filter((k) => record[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${canonical(record[k])}`).join(",")}}`;
|
|
49
|
+
}
|
|
50
|
+
throw new TypeError(`${typeof value} is not JSON`);
|
|
51
|
+
}
|
|
52
|
+
async function subtle() {
|
|
53
|
+
const web = globalThis.crypto;
|
|
54
|
+
if (web?.subtle)
|
|
55
|
+
return web.subtle;
|
|
56
|
+
const name = "node:crypto"; // Node 18 keeps WebCrypto here; never reached where globalThis.crypto exists
|
|
57
|
+
const node = await import(name);
|
|
58
|
+
return node.webcrypto.subtle;
|
|
59
|
+
}
|
|
60
|
+
const encoder = new TextEncoder();
|
|
61
|
+
const hex = (bytes) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
62
|
+
export async function sha256Hex(data) {
|
|
63
|
+
return hex(await (await subtle()).digest("SHA-256", typeof data === "string" ? encoder.encode(data) : data));
|
|
64
|
+
}
|
|
65
|
+
export const genesis = (runId) => sha256Hex(GENESIS_PREFIX + runId);
|
|
66
|
+
function fromBase64(text) {
|
|
67
|
+
const binary = atob(text);
|
|
68
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
69
|
+
}
|
|
70
|
+
/** Base64 that isn't is a failed check, never an exception: bundles are hostile input. */
|
|
71
|
+
function maybeBase64(text) {
|
|
72
|
+
try {
|
|
73
|
+
return typeof text === "string" ? fromBase64(text) : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function commitment(salt, value) {
|
|
80
|
+
const head = encoder.encode(COMMIT_PREFIX);
|
|
81
|
+
const body = encoder.encode(canonical(value));
|
|
82
|
+
const all = new Uint8Array(head.length + salt.length + body.length);
|
|
83
|
+
all.set(head, 0);
|
|
84
|
+
all.set(salt, head.length);
|
|
85
|
+
all.set(body, head.length + salt.length);
|
|
86
|
+
return sha256Hex(all);
|
|
87
|
+
}
|
|
88
|
+
export async function ed25519Verify(publicKey, message, signature) {
|
|
89
|
+
try {
|
|
90
|
+
const s = await subtle();
|
|
91
|
+
const key = await s.importKey("raw", publicKey, { name: "Ed25519" }, false, ["verify"]);
|
|
92
|
+
return await s.verify({ name: "Ed25519" }, key, signature, message);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Ids need to be unique, not secret: WebCrypto where the runtime has it globally (Node 19+, browsers, Deno, Bun),
|
|
99
|
+
// else Math.random (Node 18 keeps WebCrypto only in node:crypto, which can't be loaded synchronously).
|
|
100
|
+
function fill(out) {
|
|
101
|
+
const web = globalThis.crypto;
|
|
102
|
+
if (web?.getRandomValues)
|
|
103
|
+
web.getRandomValues(out);
|
|
104
|
+
else
|
|
105
|
+
for (let i = 0; i < out.length; i++)
|
|
106
|
+
out[i] = Math.floor(Math.random() * 256);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
function randomHex(bytes) {
|
|
110
|
+
const value = hex(fill(new Uint8Array(bytes)));
|
|
111
|
+
return /^0+$/.test(value) ? randomHex(bytes) : value;
|
|
112
|
+
}
|
|
113
|
+
export const newTraceId = () => randomHex(16);
|
|
114
|
+
export const newSpanId = () => randomHex(8);
|
|
115
|
+
const uuid = () => {
|
|
116
|
+
const b = fill(new Uint8Array(16));
|
|
117
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
118
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
119
|
+
const h = hex(b);
|
|
120
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
121
|
+
};
|
|
122
|
+
/** { traceId, spanId, sampled } from a W3C traceparent header (lowercase hex only), or null. */
|
|
123
|
+
export function parseTraceparent(header) {
|
|
124
|
+
const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec((header ?? "").trim());
|
|
125
|
+
if (!match || match[1] === "ff" || /^0+$/.test(match[2]) || /^0+$/.test(match[3]))
|
|
126
|
+
return null;
|
|
127
|
+
return { traceId: match[2], spanId: match[3], sampled: (parseInt(match[4], 16) & 1) === 1 };
|
|
128
|
+
}
|
|
129
|
+
export const formatTraceparent = (traceId, spanId, sampled = true) => `00-${traceId}-${spanId}-${sampled ? "01" : "00"}`;
|
|
130
|
+
// -- redaction --------------------------------------------------------------------------------------------
|
|
131
|
+
const SECRET_PATTERNS = [
|
|
132
|
+
["private_key", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
|
|
133
|
+
["jwt", /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
|
|
134
|
+
["dyadic", /\bdy[ar][-_][A-Za-z0-9_-]{16,}/g],
|
|
135
|
+
["provider", /\bsk-(?:ant-|or-v1-|proj-)?[A-Za-z0-9_-]{16,}/g],
|
|
136
|
+
["github", /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})/g],
|
|
137
|
+
["aws", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
|
|
138
|
+
["bearer", /\b(?:[Bb]earer|[Bb]asic)\s+[A-Za-z0-9._~+/=-]{16,}/g],
|
|
139
|
+
];
|
|
140
|
+
const SECRET_FIELDS = new Set(["password", "passwd", "secret", "clientsecret", "token", "accesstoken", "refreshtoken", "apikey", "xapikey",
|
|
141
|
+
"authorization", "cookie", "setcookie", "privatekey", "sessionid", "credentials"]);
|
|
142
|
+
/** Credentials replaced with [REDACTED:<kind>] before anything leaves the process (the service redacts again, and more). */
|
|
143
|
+
export function redactSecrets(value, depth = 0) {
|
|
144
|
+
if (depth > 32)
|
|
145
|
+
return "[nested too deeply]";
|
|
146
|
+
if (typeof value === "string")
|
|
147
|
+
return SECRET_PATTERNS.reduce((text, [kind, pattern]) => text.replace(pattern, `[REDACTED:${kind}]`), value);
|
|
148
|
+
if (Array.isArray(value))
|
|
149
|
+
return value.map((v) => redactSecrets(v, depth + 1));
|
|
150
|
+
if (value && typeof value === "object") {
|
|
151
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SECRET_FIELDS.has(k.toLowerCase().replace(/[^a-z0-9]/g, "")) && v !== null && v !== "" ? "[REDACTED:field]" : redactSecrets(v, depth + 1)]));
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
function canonicalText(text) {
|
|
156
|
+
if (typeof text !== "string")
|
|
157
|
+
return null;
|
|
158
|
+
try {
|
|
159
|
+
const value = JSON.parse(text);
|
|
160
|
+
return canonical(value) === text ? value : null;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** The same checks as the service's and the Python SDK's verifier. `trustedKeys` maps key ids to base64 public keys. */
|
|
167
|
+
export async function verifyBundle(bundle, trustedKeys) {
|
|
168
|
+
const checks = [], errors = [], warnings = [];
|
|
169
|
+
const check = (name, ok, detail = "") => { checks.push({ name, ok, detail }); if (!ok)
|
|
170
|
+
errors.push(detail ? `${name}: ${detail}` : name); return ok; };
|
|
171
|
+
const report = (total, sealed) => ({ ok: errors.length === 0, events: total, signed_through: sealed,
|
|
172
|
+
trusted_keys: Boolean(trustedKeys), checks, errors, warnings, limitations: LIMITATIONS });
|
|
173
|
+
if (!bundle || bundle.format !== BUNDLE_FORMAT) {
|
|
174
|
+
check("format", false, `not a ${BUNDLE_FORMAT} bundle`);
|
|
175
|
+
return report(0, 0);
|
|
176
|
+
}
|
|
177
|
+
const runId = String(bundle.run?.id ?? ""), tenant = String(bundle.run?.tenant_ref ?? "");
|
|
178
|
+
const events = [...(bundle.events ?? [])].sort((a, b) => Number(a.seq) - Number(b.seq));
|
|
179
|
+
let previous = await genesis(runId), expected = 1, broken = 0;
|
|
180
|
+
const envelopes = [];
|
|
181
|
+
for (const item of events) {
|
|
182
|
+
const seq = Number(item.seq);
|
|
183
|
+
if (seq !== expected) {
|
|
184
|
+
check(`event ${expected}`, false, `missing: the chain jumps from ${expected - 1} to ${seq}`);
|
|
185
|
+
broken++;
|
|
186
|
+
expected = seq;
|
|
187
|
+
}
|
|
188
|
+
const envelope = canonicalText(item.canonical);
|
|
189
|
+
if (envelope === null) {
|
|
190
|
+
check(`event ${seq}`, false, "not canonical JSON");
|
|
191
|
+
broken++;
|
|
192
|
+
previous = String(item.hash ?? "");
|
|
193
|
+
expected = seq + 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const digest = await sha256Hex(item.canonical);
|
|
197
|
+
const problems = [];
|
|
198
|
+
if (digest !== item.hash)
|
|
199
|
+
problems.push("its hash doesn't match its text (changed after it was recorded)");
|
|
200
|
+
if (envelope.seq !== seq)
|
|
201
|
+
problems.push("its text names another position");
|
|
202
|
+
if (envelope.run !== runId || envelope.tenant !== tenant)
|
|
203
|
+
problems.push("its text names another run or tenant");
|
|
204
|
+
if (envelope.prev !== previous)
|
|
205
|
+
problems.push("it doesn't follow the event before it (an event was removed, added or reordered)");
|
|
206
|
+
if (problems.length) {
|
|
207
|
+
check(`event ${seq}`, false, problems.join("; "));
|
|
208
|
+
broken++;
|
|
209
|
+
}
|
|
210
|
+
envelopes.push(envelope);
|
|
211
|
+
previous = digest;
|
|
212
|
+
expected = seq + 1;
|
|
213
|
+
}
|
|
214
|
+
if (!broken)
|
|
215
|
+
check("chain", true, `${envelopes.length} events, unbroken from the genesis value`);
|
|
216
|
+
const bundled = {};
|
|
217
|
+
for (const key of bundle.keys ?? [])
|
|
218
|
+
if (key?.key_id && key?.public_key)
|
|
219
|
+
bundled[key.key_id] = key.public_key;
|
|
220
|
+
const keys = trustedKeys ?? bundled;
|
|
221
|
+
if (!trustedKeys)
|
|
222
|
+
warnings.push("No keys were given: the bundle's own keys were used, which shows only that it is consistent with itself. Compare its key ids with the keys the platform publishes.");
|
|
223
|
+
const hashes = new Map(events.map((e) => [Number(e.seq), e.hash]));
|
|
224
|
+
let sealed = 0;
|
|
225
|
+
for (const cp of bundle.checkpoints ?? []) {
|
|
226
|
+
const label = `checkpoint ${cp.tenant_seq ?? "?"}`;
|
|
227
|
+
const payload = canonicalText(cp.payload);
|
|
228
|
+
if (payload === null) {
|
|
229
|
+
check(label, false, "not canonical JSON");
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const publicKey = keys[cp.key_id];
|
|
233
|
+
if (!publicKey) {
|
|
234
|
+
check(label, false, `signed with key '${cp.key_id}', which isn't among the keys given`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const signature = maybeBase64(cp.signature), key = maybeBase64(publicKey);
|
|
238
|
+
if (!signature || !key) {
|
|
239
|
+
check(label, false, "the signature or key isn't base64");
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!(await ed25519Verify(key, encoder.encode(cp.payload), signature))) {
|
|
243
|
+
check(label, false, "the signature doesn't verify");
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (cp.hash && (await sha256Hex(cp.payload)) !== cp.hash) {
|
|
247
|
+
check(label, false, "its hash doesn't match its payload");
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (payload.kind === "run" || payload.kind === "erasure") {
|
|
251
|
+
if (payload.run !== runId) {
|
|
252
|
+
check(label, false, "it is about another run");
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (hashes.get(Number(payload.seq)) !== payload.head) {
|
|
256
|
+
check(label, false, `it signed a different event ${payload.seq} than the bundle holds`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
sealed = Math.max(sealed, Number(payload.seq));
|
|
260
|
+
}
|
|
261
|
+
check(label, true, `${payload.kind} checkpoint signed by ${cp.key_id}, covering events 1..${payload.seq}`);
|
|
262
|
+
}
|
|
263
|
+
const chain = [...(bundle.tenant_chain ?? [])].sort((a, b) => Number(a.tenant_seq) - Number(b.tenant_seq));
|
|
264
|
+
let prior = null, chainOk = chain.length > 0;
|
|
265
|
+
for (const entry of chain) {
|
|
266
|
+
const payload = canonicalText(entry.payload);
|
|
267
|
+
const publicKey = keys[entry.key_id];
|
|
268
|
+
const signature = maybeBase64(entry.signature), key = maybeBase64(publicKey);
|
|
269
|
+
if (payload === null || !signature || !key || !(await ed25519Verify(key, encoder.encode(entry.payload), signature))) {
|
|
270
|
+
check("tenant chain", false, `checkpoint ${entry.tenant_seq} isn't canonical or signed by a given key`);
|
|
271
|
+
chainOk = false;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (prior && (Number(payload.tenant_seq) !== prior.seq + 1 || payload.prev !== prior.hash)) {
|
|
275
|
+
check("tenant chain", false, `the account's checkpoint log breaks before ${payload.tenant_seq}`);
|
|
276
|
+
chainOk = false;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
prior = { seq: Number(payload.tenant_seq), hash: await sha256Hex(entry.payload) };
|
|
280
|
+
}
|
|
281
|
+
if (chainOk)
|
|
282
|
+
check("tenant chain", true, `${chain.length} of the account's checkpoints link without a gap`);
|
|
283
|
+
const commits = new Map();
|
|
284
|
+
for (const envelope of envelopes)
|
|
285
|
+
for (const ref of envelope.content ?? [])
|
|
286
|
+
if (ref?.ref)
|
|
287
|
+
commits.set(ref.ref, ref.commit);
|
|
288
|
+
let bad = 0;
|
|
289
|
+
for (const item of bundle.content ?? []) {
|
|
290
|
+
if (!commits.has(item.ref)) {
|
|
291
|
+
check(`content ${item.ref}`, false, "no event refers to it");
|
|
292
|
+
bad++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const salt = maybeBase64(item.salt);
|
|
296
|
+
if (!salt || (await commitment(salt, item.value)) !== commits.get(item.ref)) {
|
|
297
|
+
check(`content ${item.ref}`, false, "doesn't match the commitment its event recorded");
|
|
298
|
+
bad++;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if ((bundle.content ?? []).length && !bad)
|
|
302
|
+
check("content", true, `${bundle.content.length} pieces match their commitments`);
|
|
303
|
+
await verifyAnchors((bundle.anchors ?? []).filter((a) => a && typeof a === "object"), chain, bundle.checkpoints ?? [], tenant, check, warnings);
|
|
304
|
+
if (events.length && sealed < events.length)
|
|
305
|
+
warnings.push(`Events ${sealed + 1}..${events.length} are after the last valid checkpoint: chained, not signed.`);
|
|
306
|
+
if (!checks.some((c) => c.name.startsWith("checkpoint") && c.ok))
|
|
307
|
+
warnings.push("No valid checkpoint: nothing in the bundle is signed.");
|
|
308
|
+
return report(events.length, sealed);
|
|
309
|
+
}
|
|
310
|
+
// -- anchors: RFC 3161 timestamps over a Merkle root of checkpoint heads ------------------------------------
|
|
311
|
+
const OID_SIGNED_DATA = "1.2.840.113549.1.7.2";
|
|
312
|
+
const OID_TST_INFO = "1.2.840.113549.1.9.16.1.4";
|
|
313
|
+
const HASH_OIDS = { "1.3.14.3.2.26": "sha1", "2.16.840.1.101.3.4.2.4": "sha224", "2.16.840.1.101.3.4.2.1": "sha256",
|
|
314
|
+
"2.16.840.1.101.3.4.2.2": "sha384", "2.16.840.1.101.3.4.2.3": "sha512" };
|
|
315
|
+
const MAX_TOKEN = 256 * 1024;
|
|
316
|
+
function readTlv(data, pos = 0) {
|
|
317
|
+
if (pos + 2 > data.length)
|
|
318
|
+
throw new Error("truncated");
|
|
319
|
+
const tag = data[pos];
|
|
320
|
+
if ((tag & 0x1f) === 0x1f)
|
|
321
|
+
throw new Error("high tag numbers are not used here");
|
|
322
|
+
let length = data[pos + 1], at = pos + 2;
|
|
323
|
+
if (length === 0x80)
|
|
324
|
+
throw new Error("indefinite lengths are not DER");
|
|
325
|
+
if (length & 0x80) {
|
|
326
|
+
const count = length & 0x7f;
|
|
327
|
+
if (count > 4 || at + count > data.length)
|
|
328
|
+
throw new Error("bad length");
|
|
329
|
+
length = 0;
|
|
330
|
+
for (let i = 0; i < count; i++)
|
|
331
|
+
length = length * 256 + data[at + i];
|
|
332
|
+
at += count;
|
|
333
|
+
}
|
|
334
|
+
if (at + length > data.length)
|
|
335
|
+
throw new Error("a length runs past the end");
|
|
336
|
+
return { tag, value: data.subarray(at, at + length), end: at + length };
|
|
337
|
+
}
|
|
338
|
+
function derChildren(value) {
|
|
339
|
+
const out = [];
|
|
340
|
+
for (let pos = 0; pos < value.length;) {
|
|
341
|
+
const item = readTlv(value, pos);
|
|
342
|
+
out.push(item);
|
|
343
|
+
pos = item.end;
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
function derOne(data) {
|
|
348
|
+
const item = readTlv(data);
|
|
349
|
+
if (item.end !== data.length)
|
|
350
|
+
throw new Error("trailing bytes");
|
|
351
|
+
return item;
|
|
352
|
+
}
|
|
353
|
+
function derExpect(item, tag, what) {
|
|
354
|
+
if (!item)
|
|
355
|
+
throw new Error(`${what}: missing`);
|
|
356
|
+
if (item.tag !== tag)
|
|
357
|
+
throw new Error(`${what}: expected tag 0x${tag.toString(16).padStart(2, "0")}, found 0x${item.tag.toString(16).padStart(2, "0")}`);
|
|
358
|
+
return item;
|
|
359
|
+
}
|
|
360
|
+
function derOid(item) {
|
|
361
|
+
const value = derExpect(item, 0x06, "OBJECT IDENTIFIER").value;
|
|
362
|
+
const arcs = [];
|
|
363
|
+
let current = 0;
|
|
364
|
+
for (const byte of value) {
|
|
365
|
+
current = current * 128 + (byte & 0x7f);
|
|
366
|
+
if (!(byte & 0x80)) {
|
|
367
|
+
arcs.push(current);
|
|
368
|
+
current = 0;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!arcs.length)
|
|
372
|
+
throw new Error("an empty OBJECT IDENTIFIER");
|
|
373
|
+
const first = arcs[0] < 40 ? [0, arcs[0]] : arcs[0] < 80 ? [1, arcs[0] - 40] : [2, arcs[0] - 80];
|
|
374
|
+
return [...first, ...arcs.slice(1)].join(".");
|
|
375
|
+
}
|
|
376
|
+
/** What an RFC 3161 TimeStampToken says it stamps (its TSTInfo's messageImprint) and when (genTime). Unchecked: the
|
|
377
|
+
* authority's CMS signature over it is not verified here. */
|
|
378
|
+
export function readTimestampToken(token) {
|
|
379
|
+
if (token.length > MAX_TOKEN)
|
|
380
|
+
throw new Error("the token is too large");
|
|
381
|
+
const info = derChildren(derExpect(derOne(token), 0x30, "ContentInfo").value);
|
|
382
|
+
if (info.length !== 2 || derOid(info[0]) !== OID_SIGNED_DATA || info[1].tag !== 0xa0)
|
|
383
|
+
throw new Error("not a CMS SignedData");
|
|
384
|
+
const fields = derChildren(derExpect(derOne(info[1].value), 0x30, "SignedData").value);
|
|
385
|
+
if (fields.length < 4)
|
|
386
|
+
throw new Error("short SignedData");
|
|
387
|
+
const encap = derChildren(derExpect(fields[2], 0x30, "EncapsulatedContentInfo").value);
|
|
388
|
+
if (derOid(encap[0]) !== OID_TST_INFO)
|
|
389
|
+
throw new Error("the signed content is not a TSTInfo");
|
|
390
|
+
if (encap.length < 2 || encap[1].tag !== 0xa0)
|
|
391
|
+
throw new Error("the token carries no TSTInfo");
|
|
392
|
+
const econtent = derExpect(derOne(encap[1].value), 0x04, "eContent").value;
|
|
393
|
+
const tst = derChildren(derExpect(derOne(econtent), 0x30, "TSTInfo").value);
|
|
394
|
+
if (tst.length < 5)
|
|
395
|
+
throw new Error("short TSTInfo");
|
|
396
|
+
const imprint = derChildren(derExpect(tst[2], 0x30, "MessageImprint").value);
|
|
397
|
+
const algorithm = derOid(derChildren(derExpect(imprint[0], 0x30, "AlgorithmIdentifier").value)[0]);
|
|
398
|
+
const digest = hex(derExpect(imprint[1], 0x04, "hashedMessage").value);
|
|
399
|
+
const time = new TextDecoder().decode(derExpect(tst[4], 0x18, "genTime").value);
|
|
400
|
+
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d{1,9})?Z$/.exec(time);
|
|
401
|
+
if (!match)
|
|
402
|
+
throw new Error("bad time");
|
|
403
|
+
return { hashAlg: HASH_OIDS[algorithm] ?? algorithm, digest,
|
|
404
|
+
genTime: `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${match[7] ?? ""}Z` };
|
|
405
|
+
}
|
|
406
|
+
function fromHex(text) {
|
|
407
|
+
if (typeof text !== "string" || !/^(?:[0-9a-fA-F]{2})*$/.test(text))
|
|
408
|
+
throw new Error("not hex");
|
|
409
|
+
return Uint8Array.from(text.match(/../g) ?? [], (pair) => parseInt(pair, 16));
|
|
410
|
+
}
|
|
411
|
+
async function sha256Bytes(...parts) {
|
|
412
|
+
const all = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
413
|
+
let at = 0;
|
|
414
|
+
for (const part of parts) {
|
|
415
|
+
all.set(part, at);
|
|
416
|
+
at += part.length;
|
|
417
|
+
}
|
|
418
|
+
return new Uint8Array(await (await subtle()).digest("SHA-256", all));
|
|
419
|
+
}
|
|
420
|
+
/** A head's leaf in an anchoring batch (RFC 6962 hashing): sha256(0x00 || canonical({v, tenant, tenant_seq, head})). */
|
|
421
|
+
export const anchorLeaf = (tenant, tenantSeq, head) => sha256Bytes(new Uint8Array([0]), encoder.encode(canonical({ v: 1, tenant, tenant_seq: tenantSeq, head })));
|
|
422
|
+
/** The root a leaf and its proof lead to: each step's sibling on its side, nodes sha256(0x01 || left || right). */
|
|
423
|
+
export async function foldProof(leaf, proof) {
|
|
424
|
+
let value = leaf;
|
|
425
|
+
for (const step of proof) {
|
|
426
|
+
if (!step || typeof step !== "object")
|
|
427
|
+
throw new Error("a proof step isn't an object");
|
|
428
|
+
const sibling = fromHex(step.hash);
|
|
429
|
+
value = step.side === "left" ? await sha256Bytes(new Uint8Array([1]), sibling, value) : await sha256Bytes(new Uint8Array([1]), value, sibling);
|
|
430
|
+
}
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
/** The anchor checks of the service's verifier, except the authority's signature on its token (which needs CMS, X.509
|
|
434
|
+
* and RSA or ECDSA work this verifier doesn't do): reported as unchecked, never as verified. */
|
|
435
|
+
async function verifyAnchors(anchors, chain, checkpoints, tenant, check, warnings) {
|
|
436
|
+
if (!anchors.length)
|
|
437
|
+
return;
|
|
438
|
+
const logged = new Map();
|
|
439
|
+
for (const entry of chain)
|
|
440
|
+
if (typeof entry?.payload === "string")
|
|
441
|
+
logged.set(Number(entry.tenant_seq ?? 0), await sha256Hex(entry.payload));
|
|
442
|
+
const sealedAt = Math.max(0, ...checkpoints.filter((c) => c && typeof c === "object").map((c) => Number(c.tenant_seq ?? 0) || 0));
|
|
443
|
+
for (const anchor of anchors) {
|
|
444
|
+
const name = `anchor at checkpoint ${anchor.tenant_seq}`;
|
|
445
|
+
let seq, top, token;
|
|
446
|
+
try {
|
|
447
|
+
seq = Number(anchor.tenant_seq);
|
|
448
|
+
if (!Number.isInteger(seq))
|
|
449
|
+
throw new Error("tenant_seq");
|
|
450
|
+
const head = String(anchor.head);
|
|
451
|
+
top = fromHex(String(anchor.root));
|
|
452
|
+
if (String(anchor.tenant) !== tenant) {
|
|
453
|
+
check(name, false, "it is for another account");
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (logged.get(seq) !== head) {
|
|
457
|
+
check(name, false, "the checkpoint it names isn't in the bundle's checkpoint log");
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (seq < sealedAt) {
|
|
461
|
+
check(name, false, "it comes before the run's last checkpoint, so it doesn't cover it");
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (!Array.isArray(anchor.proof ?? []))
|
|
465
|
+
throw new Error("proof");
|
|
466
|
+
if (hex(await foldProof(await anchorLeaf(tenant, seq, head), anchor.proof ?? [])) !== hex(top)) {
|
|
467
|
+
check(name, false, "its Merkle proof doesn't lead to the anchored root");
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
token = maybeBase64(anchor.token);
|
|
471
|
+
if (!token)
|
|
472
|
+
throw new Error("token");
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
check(name, false, `unreadable (${error.message})`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
let stamped;
|
|
479
|
+
try {
|
|
480
|
+
stamped = readTimestampToken(token);
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
check(name, false, `the time-stamping authority's token: not a usable timestamp token: ${error.message}`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (!Object.values(HASH_OIDS).includes(stamped.hashAlg)) {
|
|
487
|
+
check(name, false, `the time-stamping authority's token: unknown imprint hash ${stamped.hashAlg}`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (stamped.digest !== hex(top)) {
|
|
491
|
+
check(name, false, "the time-stamping authority's token: the token stamps a different digest");
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
check(name, true, `the checkpoint log through ${seq} is under the anchored root, which the token (${anchor.tsa ?? "an authority"}) stamps at `
|
|
495
|
+
+ `${stamped.genTime}; the token's signature was not checked by this verifier`);
|
|
496
|
+
warnings.push(`${name}: the time-stamping authority's signature on its token was not checked (this verifier reads the token but `
|
|
497
|
+
+ "does no CMS signature or certificate checks), so the time rests on the token's contents alone; check it with the Python SDK's "
|
|
498
|
+
+ "verify_bundle or the bundle's verify.py.");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// -- webhooks (Standard Webhooks) ---------------------------------------------------------------------------
|
|
502
|
+
/** The message isn't one the sink signed, or its timestamp is too far from now. */
|
|
503
|
+
export class WebhookVerificationError extends Error {
|
|
504
|
+
constructor(message) { super(message); this.name = "WebhookVerificationError"; }
|
|
505
|
+
}
|
|
506
|
+
function headerValue(headers, name) {
|
|
507
|
+
const pick = (value) => (Array.isArray(value) ? String(value[0] ?? "") : value === undefined || value === null ? "" : String(value));
|
|
508
|
+
const getter = headers.get;
|
|
509
|
+
if (typeof getter === "function") {
|
|
510
|
+
const found = getter.call(headers, name);
|
|
511
|
+
if (found !== undefined && found !== null)
|
|
512
|
+
return pick(found);
|
|
513
|
+
}
|
|
514
|
+
const entries = typeof headers[Symbol.iterator] === "function"
|
|
515
|
+
? headers : Object.entries(headers ?? {});
|
|
516
|
+
for (const [key, value] of entries)
|
|
517
|
+
if (String(key).toLowerCase() === name)
|
|
518
|
+
return pick(value);
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
function webhookKey(secret) {
|
|
522
|
+
const text = secret.trim().replace(/^whsec_/, "");
|
|
523
|
+
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text))
|
|
524
|
+
throw new WebhookVerificationError("The secret isn't a whsec_ secret (base64 after the prefix).");
|
|
525
|
+
return fromBase64(text);
|
|
526
|
+
}
|
|
527
|
+
const toBytes = (body) => typeof body === "string" ? encoder.encode(body) : body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
528
|
+
function toBase64(bytes) {
|
|
529
|
+
let binary = "";
|
|
530
|
+
for (const byte of bytes)
|
|
531
|
+
binary += String.fromCharCode(byte);
|
|
532
|
+
return btoa(binary);
|
|
533
|
+
}
|
|
534
|
+
/** The webhook-signature value the service sends for `body` (to test your own receiver). */
|
|
535
|
+
export async function signWebhook(secret, messageId, timestamp, body) {
|
|
536
|
+
const s = await subtle();
|
|
537
|
+
const key = await s.importKey("raw", webhookKey(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
538
|
+
const head = encoder.encode(`${messageId}.${Math.trunc(timestamp)}.`), raw = toBytes(body);
|
|
539
|
+
const message = new Uint8Array(head.length + raw.length);
|
|
540
|
+
message.set(head, 0);
|
|
541
|
+
message.set(raw, head.length);
|
|
542
|
+
return `v1,${toBase64(new Uint8Array(await s.sign("HMAC", key, message)))}`;
|
|
543
|
+
}
|
|
544
|
+
function sameText(a, b) {
|
|
545
|
+
if (a.length !== b.length)
|
|
546
|
+
return false;
|
|
547
|
+
let diff = 0;
|
|
548
|
+
for (let i = 0; i < a.length; i++)
|
|
549
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
550
|
+
return diff === 0;
|
|
551
|
+
}
|
|
552
|
+
/** A Black Box webhook, parsed, once its signature matches and its timestamp is within `tolerance` seconds (300) of now
|
|
553
|
+
* (`now`, in Unix seconds, for tests); otherwise WebhookVerificationError says why. Pass the body exactly as it arrived (the
|
|
554
|
+
* raw text or bytes, before any JSON parsing) and the request's headers (a Headers object, Node's req.headers, or a plain
|
|
555
|
+
* object: case doesn't matter). Several space-separated signatures (a secret being rotated) are fine. WebCrypto: Node 18+,
|
|
556
|
+
* browsers, Deno, Bun, workers. */
|
|
557
|
+
export async function verifyWebhook(body, headers, secret, options = {}) {
|
|
558
|
+
const id = headerValue(headers, "webhook-id").trim(), stamp = headerValue(headers, "webhook-timestamp").trim();
|
|
559
|
+
const signatures = headerValue(headers, "webhook-signature").trim();
|
|
560
|
+
if (!id || !stamp || !signatures)
|
|
561
|
+
throw new WebhookVerificationError("The webhook-id, webhook-timestamp and webhook-signature headers are required.");
|
|
562
|
+
if (!/^\d+$/.test(stamp))
|
|
563
|
+
throw new WebhookVerificationError("webhook-timestamp isn't a Unix time in seconds.");
|
|
564
|
+
const tolerance = options.tolerance ?? 300, now = options.now ?? Date.now() / 1000;
|
|
565
|
+
if (Math.abs(now - Number(stamp)) > tolerance) {
|
|
566
|
+
throw new WebhookVerificationError(`The message's timestamp is more than ${tolerance} seconds from now (a replay, or a wrong clock).`);
|
|
567
|
+
}
|
|
568
|
+
const expected = (await signWebhook(secret, id, Number(stamp), body)).slice(3);
|
|
569
|
+
const matched = signatures.split(/\s+/).filter((part) => part.startsWith("v1,")).map((part) => sameText(part.slice(3), expected));
|
|
570
|
+
if (!matched.includes(true))
|
|
571
|
+
throw new WebhookVerificationError("No signature matches: the body was changed, or it was signed with another secret.");
|
|
572
|
+
try {
|
|
573
|
+
return JSON.parse(new TextDecoder().decode(toBytes(body)));
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
throw new WebhookVerificationError("The signature matches but the body isn't JSON.");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const now = () => new Date().toISOString().replace(/\.(\d{3})Z$/, ".$1000Z");
|
|
580
|
+
const clean = (attrs = {}) => JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(attrs).filter(([, v]) => v !== undefined && v !== null))));
|
|
581
|
+
export class BlackBoxRecorder {
|
|
582
|
+
constructor(client, options = {}) {
|
|
583
|
+
this.stats = { queued: 0, sent: 0, dropped: 0, failedBatches: 0 };
|
|
584
|
+
this.queue = [];
|
|
585
|
+
this.dropped = new Map();
|
|
586
|
+
this.timer = null;
|
|
587
|
+
this.sending = Promise.resolve();
|
|
588
|
+
const send = options.send ?? (client ? (batch, key) => client.blackbox.send(batch, { idempotencyKey: key }) : null);
|
|
589
|
+
if (!send)
|
|
590
|
+
throw new Error("BlackBoxRecorder needs a Dyadic client or options.send");
|
|
591
|
+
this.send = send;
|
|
592
|
+
this.options = { environment: options.environment ?? "production", captureContent: options.captureContent ?? false,
|
|
593
|
+
redact: options.redact === undefined ? redactSecrets : options.redact, batchSize: options.batchSize ?? 200,
|
|
594
|
+
flushIntervalMs: options.flushIntervalMs ?? 1000, maxQueue: Math.max(10, options.maxQueue ?? 10000), maxRetries: options.maxRetries ?? 5,
|
|
595
|
+
onError: options.onError };
|
|
596
|
+
}
|
|
597
|
+
/** Start a run; with `body`, run it and end it (failed if it throws). */
|
|
598
|
+
start(params) {
|
|
599
|
+
return new BlackBoxRun(this, params).start();
|
|
600
|
+
}
|
|
601
|
+
async run(params, body) {
|
|
602
|
+
const run = this.start(params);
|
|
603
|
+
try {
|
|
604
|
+
const value = await body(run);
|
|
605
|
+
run.end("completed");
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
run.end("failed", { error });
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** @internal */
|
|
614
|
+
enqueue(run, event) {
|
|
615
|
+
if (this.queue.length >= this.options.maxQueue) {
|
|
616
|
+
let index = this.queue.findIndex(([, e]) => TELEMETRY.has(e.type));
|
|
617
|
+
if (index < 0)
|
|
618
|
+
index = 0;
|
|
619
|
+
const [droppedRun] = this.queue.splice(index, 1)[0];
|
|
620
|
+
this.dropped.set(droppedRun.id, [droppedRun, (this.dropped.get(droppedRun.id)?.[1] ?? 0) + 1]);
|
|
621
|
+
this.stats.dropped++;
|
|
622
|
+
}
|
|
623
|
+
this.queue.push([run, event]);
|
|
624
|
+
this.stats.queued++;
|
|
625
|
+
if (this.queue.length >= this.options.batchSize)
|
|
626
|
+
void this.flush();
|
|
627
|
+
else if (!this.timer) {
|
|
628
|
+
this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, this.options.flushIntervalMs);
|
|
629
|
+
this.timer?.unref?.();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/** Send everything queued; resolves when it has been sent (or given up on). */
|
|
633
|
+
flush() {
|
|
634
|
+
this.sending = this.sending.then(() => this.drain());
|
|
635
|
+
return this.sending;
|
|
636
|
+
}
|
|
637
|
+
async drain() {
|
|
638
|
+
while (this.queue.length || this.dropped.size) {
|
|
639
|
+
const items = this.queue.splice(0, this.options.batchSize);
|
|
640
|
+
const groups = new Map();
|
|
641
|
+
for (const [run, event] of items) {
|
|
642
|
+
if (!groups.has(run.id))
|
|
643
|
+
groups.set(run.id, [run, []]);
|
|
644
|
+
groups.get(run.id)[1].push(event);
|
|
645
|
+
}
|
|
646
|
+
for (const [id, [run, count]] of this.dropped) {
|
|
647
|
+
if (!groups.has(id))
|
|
648
|
+
groups.set(id, [run, []]);
|
|
649
|
+
groups.get(id)[1].push(run.noteEvent(`this process dropped ${count} events (its recorder's queue was full)`, { sdk_dropped: count }));
|
|
650
|
+
}
|
|
651
|
+
this.dropped.clear();
|
|
652
|
+
for (const [run, events] of groups.values())
|
|
653
|
+
await this.deliver(run, events);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async deliver(run, events) {
|
|
657
|
+
const batch = { run: run.descriptor(), events, sent_at: now() };
|
|
658
|
+
const key = (await sha256Hex(`${run.id}:${events[0].id}:${events[events.length - 1].id}:${events.length}`)).slice(0, 40);
|
|
659
|
+
for (let attempt = 0;; attempt++) {
|
|
660
|
+
try {
|
|
661
|
+
await this.send(batch, key);
|
|
662
|
+
this.stats.sent += events.length;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
const status = Number(error.status);
|
|
667
|
+
if ((status >= 400 && status < 500 && status !== 429) || attempt >= this.options.maxRetries) {
|
|
668
|
+
this.stats.failedBatches++;
|
|
669
|
+
this.stats.dropped += events.length;
|
|
670
|
+
this.options.onError?.(error, batch);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
await new Promise((resolve) => { const t = setTimeout(resolve, Math.min(30000, 500 * 2 ** attempt)); t?.unref?.(); });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const KINDS = {
|
|
679
|
+
run: ["run.started", "run.completed", "run.failed"], span: ["span.started", "span.ended", "span.ended"],
|
|
680
|
+
model: ["model.requested", "model.responded", "model.failed"], tool: ["tool.requested", "tool.completed", "tool.failed"],
|
|
681
|
+
agent: ["agent.invoked", "agent.completed", "agent.failed"],
|
|
682
|
+
};
|
|
683
|
+
/** A timed piece of a run. Its methods make its children. */
|
|
684
|
+
export class BlackBoxSpan {
|
|
685
|
+
constructor(run, kind, name, parentId, agent, attrs = {}, content, spanId) {
|
|
686
|
+
this.run = run;
|
|
687
|
+
this.kind = kind;
|
|
688
|
+
this.name = name;
|
|
689
|
+
this.parentId = parentId;
|
|
690
|
+
this.agent = agent;
|
|
691
|
+
this.attrs = attrs;
|
|
692
|
+
this.content = content;
|
|
693
|
+
this.ended = false;
|
|
694
|
+
this.startedAt = Date.now();
|
|
695
|
+
this.spanId = spanId ?? newSpanId();
|
|
696
|
+
}
|
|
697
|
+
/** @internal */
|
|
698
|
+
begin() { this.run.emit(KINDS[this.kind][0], this, { name: this.name, attrs: this.attrs, content: this.content }); return this; }
|
|
699
|
+
finish(type, status, attrs = {}, content) {
|
|
700
|
+
if (this.ended)
|
|
701
|
+
return;
|
|
702
|
+
this.ended = true;
|
|
703
|
+
this.run.emit(type, this, { status, name: this.name, attrs: { duration_ms: Date.now() - this.startedAt, ...attrs }, content });
|
|
704
|
+
}
|
|
705
|
+
end(attrs = {}) { this.finish(KINDS[this.kind][1], "ok", attrs); }
|
|
706
|
+
fail(error, attrs = {}) {
|
|
707
|
+
this.finish(KINDS[this.kind][2], "error", { error_type: error instanceof Error ? error.name : String(error ?? "error").slice(0, 120), ...attrs });
|
|
708
|
+
}
|
|
709
|
+
traceparent() { return formatTraceparent(this.run.traceId, this.spanId); }
|
|
710
|
+
async around(child, body) {
|
|
711
|
+
child.begin();
|
|
712
|
+
if (!body)
|
|
713
|
+
return undefined;
|
|
714
|
+
try {
|
|
715
|
+
const value = await body(child);
|
|
716
|
+
if (!child.ended)
|
|
717
|
+
child.end();
|
|
718
|
+
return value;
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
child.fail(error);
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
modelCall(model, options = {}, body) {
|
|
726
|
+
const { input, ...attrs } = options;
|
|
727
|
+
return this.around(new ModelCallSpan(this.run, "model", model, this.spanId, this.agent, { model, ...attrs }, input === undefined ? undefined : { "model.input": input }), body);
|
|
728
|
+
}
|
|
729
|
+
toolCall(name, options = {}, body) {
|
|
730
|
+
const { args, kind = "function", effect = false, hosts, callId, risk, ...attrs } = options;
|
|
731
|
+
const egress = ["external_api", "mcp", "browser", "communication"].includes(kind) ? "data" : undefined;
|
|
732
|
+
return this.around(new ToolCallSpan(this.run, "tool", name, this.spanId, this.agent, { kind, effect, hosts: hosts ? [...new Set(hosts)].sort() : undefined,
|
|
733
|
+
call_id: callId, risk, egress, arg_keys: args && typeof args === "object" && !Array.isArray(args) ? Object.keys(args).sort() : undefined, ...attrs }, args === undefined ? undefined : { "tool.args": args }), body);
|
|
734
|
+
}
|
|
735
|
+
agentSpan(agentId, options = {}, body) {
|
|
736
|
+
const { name, task, ...attrs } = options;
|
|
737
|
+
return this.around(new BlackBoxSpan(this.run, "agent", agentId, this.spanId, agentId, { agent: agentId, agent_name: name ?? agentId, ...attrs }, task === undefined ? undefined : { "agent.task": task }), body);
|
|
738
|
+
}
|
|
739
|
+
span(name, attrs = {}, body) {
|
|
740
|
+
return this.around(new BlackBoxSpan(this.run, "span", name, this.spanId, this.agent, attrs), body);
|
|
741
|
+
}
|
|
742
|
+
/** A decision your own policy made (allow, deny, require_approval). */
|
|
743
|
+
policy(decision, rule, options = {}) {
|
|
744
|
+
this.run.emit("policy.decision", this, { status: ["allow", "deny", "require_approval"].includes(decision) ? decision : "", name: options.tool ?? "",
|
|
745
|
+
attrs: { decision, rule, tool: options.tool, reason: options.reason, policy: options.policy, policy_version: options.version, subject: options.tool ? "tool" : undefined } });
|
|
746
|
+
}
|
|
747
|
+
note(text, attrs = {}) { this.run.emit("note", this, { attrs: { text, ...attrs } }); }
|
|
748
|
+
}
|
|
749
|
+
export class ModelCallSpan extends BlackBoxSpan {
|
|
750
|
+
/** What the model returned. Never pass hidden reasoning: it is not recorded. */
|
|
751
|
+
responded(options = {}) {
|
|
752
|
+
const usage = options.usage ?? {};
|
|
753
|
+
this.finish("model.responded", "ok", { input_tokens: usage.input_tokens ?? usage.prompt_tokens, output_tokens: usage.output_tokens ?? usage.completion_tokens,
|
|
754
|
+
cost_usd: usage.cost_usd, response_model: options.responseModel, response_id: options.responseId, finish_reason: options.finishReason,
|
|
755
|
+
tool_calls: options.toolCalls?.map((c) => ({ id: c.id, name: c.name })) }, options.output === undefined ? undefined : { "model.output": options.output });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
export class ToolCallSpan extends BlackBoxSpan {
|
|
759
|
+
executing() { this.run.emit("tool.executing", this, { name: this.name, attrs: { write_ahead: true } }); }
|
|
760
|
+
/** It worked, as the tool reports: a claim, until `run.verified` records evidence. */
|
|
761
|
+
result(value, attrs = {}) {
|
|
762
|
+
this.finish("tool.completed", "ok", { ok: true, ...attrs }, value === undefined ? undefined : { "tool.result": value });
|
|
763
|
+
}
|
|
764
|
+
blocked(rule, reason = "", decision = "deny") {
|
|
765
|
+
this.policy(decision, rule, { tool: this.name, reason });
|
|
766
|
+
this.finish("tool.blocked", decision === "deny" ? "blocked" : "pending", { rule, decision });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
export class BlackBoxRun extends BlackBoxSpan {
|
|
770
|
+
constructor(recorder, params) {
|
|
771
|
+
const parsed = parseTraceparent(params.traceparent);
|
|
772
|
+
const traceId = parsed?.traceId ?? newTraceId();
|
|
773
|
+
super(null, "run", params.agent ?? "run", parsed?.spanId ?? "", params.agent ?? "");
|
|
774
|
+
this.recorder = recorder;
|
|
775
|
+
this.seq = 0;
|
|
776
|
+
this.run = this;
|
|
777
|
+
this.id = params.runId ?? uuid();
|
|
778
|
+
this.traceId = traceId;
|
|
779
|
+
this.title = params.title ?? "";
|
|
780
|
+
this.agentName = params.agentName ?? params.agent ?? "";
|
|
781
|
+
this.parentHeader = parsed ? params.traceparent.trim() : null;
|
|
782
|
+
this.remoteParent = parsed?.spanId ?? "";
|
|
783
|
+
}
|
|
784
|
+
descriptor() {
|
|
785
|
+
return { id: this.id, trace_id: this.traceId, agent: { id: this.agent, name: this.agentName }, title: this.title,
|
|
786
|
+
environment: this.recorder.options.environment, root_span_id: this.spanId, ...(this.parentHeader ? { parent: this.parentHeader } : {}) };
|
|
787
|
+
}
|
|
788
|
+
/** @internal */
|
|
789
|
+
event(type, span, fields = {}) {
|
|
790
|
+
const event = { id: uuid().replace(/-/g, ""), type, status: fields.status ?? "", name: (fields.name ?? "").slice(0, 200), time: now(),
|
|
791
|
+
seq: ++this.seq, span_id: span.spanId, parent_span_id: span === this ? this.remoteParent : span.parentId, agent: span.agent || this.agent,
|
|
792
|
+
attrs: clean(fields.attrs) };
|
|
793
|
+
const options = this.recorder.options;
|
|
794
|
+
if (fields.content && options.captureContent) {
|
|
795
|
+
const kept = clean(fields.content);
|
|
796
|
+
if (Object.keys(kept).length)
|
|
797
|
+
event.content = (options.redact ? options.redact(kept) : kept);
|
|
798
|
+
}
|
|
799
|
+
return event;
|
|
800
|
+
}
|
|
801
|
+
/** @internal */
|
|
802
|
+
emit(type, span, fields = {}) { this.recorder.enqueue(this, this.event(type, span, fields)); }
|
|
803
|
+
/** @internal */
|
|
804
|
+
noteEvent(text, attrs) { return this.event("note", this, { attrs: { text, ...attrs } }); }
|
|
805
|
+
start() { this.emit("run.started", this, { name: this.agent, attrs: { title: this.title, sdk: "dyadic-js" } }); return this; }
|
|
806
|
+
end(status = "completed", options = {}) {
|
|
807
|
+
if (typeof status !== "string")
|
|
808
|
+
return this.end("completed");
|
|
809
|
+
if (this.ended)
|
|
810
|
+
return;
|
|
811
|
+
this.ended = true;
|
|
812
|
+
const type = { completed: "run.completed", failed: "run.failed", cancelled: "run.cancelled", requires_action: "run.requires_action" }[status];
|
|
813
|
+
const attrs = {};
|
|
814
|
+
if (options.error !== undefined)
|
|
815
|
+
attrs.error_type = options.error instanceof Error ? options.error.name : String(options.error).slice(0, 120);
|
|
816
|
+
this.emit(type, this, { status: { completed: "ok", failed: "error", cancelled: "cancelled", requires_action: "pending" }[status], attrs });
|
|
817
|
+
}
|
|
818
|
+
/** The task and its boundaries: the Black Box flags tools, hosts or steps beyond them. */
|
|
819
|
+
declare(options = {}) {
|
|
820
|
+
const { prompt, allowedTools, allowedHosts, maxSteps, ...attrs } = options;
|
|
821
|
+
this.emit("task.declared", this, { attrs: { allowed_tools: allowedTools ? [...new Set(allowedTools)].sort() : undefined,
|
|
822
|
+
allowed_hosts: allowedHosts ? [...new Set(allowedHosts)].sort() : undefined, max_steps: maxSteps, prompt_chars: prompt?.length, ...attrs },
|
|
823
|
+
content: prompt === undefined ? undefined : { "task.prompt": prompt } });
|
|
824
|
+
}
|
|
825
|
+
approval(tool, decision, options = {}) {
|
|
826
|
+
this.emit("approval.decided", this, { status: decision, name: tool, attrs: { tool, decision, approval_id: options.approvalId, decided_by: options.by } });
|
|
827
|
+
}
|
|
828
|
+
/** You checked an effect independently (read back what was written): evidence, not a claim. */
|
|
829
|
+
verified(kind, evidence, attrs = {}) {
|
|
830
|
+
this.emit("outcome.verified", this, { status: "ok", attrs: { kind, evidence, ...attrs } });
|
|
831
|
+
}
|
|
832
|
+
unverified(kind, reason) { this.emit("outcome.unverified", this, { status: "error", attrs: { kind, reason } }); }
|
|
833
|
+
}
|
|
834
|
+
/** Every tool an MCP client calls, recorded on `span`: the official TypeScript SDK's Client (callTool({ name, arguments })), or anything shaped like it. */
|
|
835
|
+
export function instrumentMcpClient(client, span, options = {}) {
|
|
836
|
+
const original = client.callTool.bind(client);
|
|
837
|
+
if (client.callTool._dyadicBlackBox)
|
|
838
|
+
return client;
|
|
839
|
+
const readOnly = new Set(options.readOnly ?? []);
|
|
840
|
+
const wrapped = async (params, ...rest) => span.toolCall(params.name, { args: params.arguments, kind: "mcp", effect: !readOnly.has(params.name), hosts: options.host ? [options.host] : undefined,
|
|
841
|
+
mcp_server: options.server, mcp_tool: params.name }, async (tool) => {
|
|
842
|
+
const result = await original(params, ...rest);
|
|
843
|
+
if (result?.isError)
|
|
844
|
+
tool.fail("mcp_tool_error", { detail: "the server returned isError" });
|
|
845
|
+
else
|
|
846
|
+
tool.result(result?.structuredContent ?? result?.content);
|
|
847
|
+
return result;
|
|
848
|
+
});
|
|
849
|
+
wrapped._dyadicBlackBox = true;
|
|
850
|
+
client.callTool = wrapped;
|
|
851
|
+
return client;
|
|
852
|
+
}
|