agentledger-runtime 1.0.2

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/src/index.js ADDED
@@ -0,0 +1,1683 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ export class NoRunnableStepError extends Error {
6
+ constructor() {
7
+ super('agentledger: no runnable step');
8
+ this.name = 'NoRunnableStepError';
9
+ }
10
+ }
11
+
12
+ export class RetryableAgentError extends Error {
13
+ constructor(message = 'agentledger: retryable agent error') {
14
+ super(message);
15
+ this.name = 'RetryableAgentError';
16
+ }
17
+ }
18
+
19
+ export class PermissionDeniedError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = 'PermissionDeniedError';
23
+ }
24
+ }
25
+
26
+ export class ApprovalRequiredError extends Error {
27
+ constructor(approvalId, message) {
28
+ super(message);
29
+ this.name = 'ApprovalRequiredError';
30
+ this.approvalId = approvalId;
31
+ }
32
+ }
33
+
34
+ export class BudgetExceededError extends Error {
35
+ constructor(message) {
36
+ super(message);
37
+ this.name = 'BudgetExceededError';
38
+ }
39
+ }
40
+
41
+ export class SandboxUnavailableError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'SandboxUnavailableError';
45
+ }
46
+ }
47
+
48
+ export class LocalBlobStore {
49
+ constructor(root) {
50
+ this.root = root;
51
+ }
52
+
53
+ static async open(root) {
54
+ await mkdir(root, { recursive: true });
55
+ return new LocalBlobStore(root);
56
+ }
57
+
58
+ async putJSON(value) {
59
+ const digest = sha256JSON(value);
60
+ const dir = join(this.root, 'sha256');
61
+ const path = join(dir, `${digest}.json`);
62
+ await mkdir(dir, { recursive: true });
63
+ const tmp = `${path}.tmp`;
64
+ try {
65
+ await readFile(path, 'utf8');
66
+ } catch (error) {
67
+ if (error.code !== 'ENOENT') throw error;
68
+ await writeFile(tmp, JSON.stringify(value, null, 2), 'utf8');
69
+ await rename(tmp, path);
70
+ }
71
+ return { digest: `sha256:${digest}`, ref: `blob://sha256/${digest}.json` };
72
+ }
73
+
74
+ async getJSON(ref) {
75
+ const prefix = 'blob://sha256/';
76
+ if (!ref.startsWith(prefix)) throw new Error(`unsupported blob ref: ${ref}`);
77
+ const name = ref.slice(prefix.length);
78
+ if (!name.endsWith('.json') || name.includes('..') || name.includes('/') || name.includes('\\\\')) throw new Error(`unsupported blob ref: ${ref}`);
79
+ return JSON.parse(await readFile(join(this.root, 'sha256', name), 'utf8'));
80
+ }
81
+ }
82
+
83
+ export class JSONStore {
84
+ constructor(path = null, data = null) {
85
+ this.path = path;
86
+ this.data = data ?? emptyData();
87
+ normalizeData(this.data);
88
+ }
89
+
90
+ static memory() {
91
+ return new JSONStore(null, emptyData());
92
+ }
93
+
94
+ static async open(path) {
95
+ let data = emptyData();
96
+ try {
97
+ const raw = await readFile(path, 'utf8');
98
+ if (raw.trim()) data = { ...emptyData(), ...JSON.parse(raw) };
99
+ } catch (error) {
100
+ if (error.code !== 'ENOENT') throw error;
101
+ }
102
+ normalizeData(data);
103
+ return new JSONStore(path, data);
104
+ }
105
+
106
+ async flush() {
107
+ if (!this.path) return;
108
+ await mkdir(dirname(this.path), { recursive: true });
109
+ const tmp = `${this.path}.tmp`;
110
+ await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, 'utf8');
111
+ await rename(tmp, this.path);
112
+ }
113
+
114
+ async createRun(initialState = {}, sessionId = null) {
115
+ const runId = newId('run');
116
+ const stepId = newId('step');
117
+ const actualSessionId = sessionId ?? newId('sess');
118
+ const now = nowSeconds();
119
+ this.data.runs[runId] = {
120
+ run_id: runId,
121
+ session_id: actualSessionId,
122
+ status: 'pending',
123
+ state: clone(initialState),
124
+ state_version: 0,
125
+ created_at: now,
126
+ updated_at: now,
127
+ };
128
+ this.data.steps[stepId] = {
129
+ step_id: stepId,
130
+ run_id: runId,
131
+ session_id: actualSessionId,
132
+ status: 'pending',
133
+ attempt: 0,
134
+ state_version: 0,
135
+ created_at: now,
136
+ updated_at: now,
137
+ };
138
+ this.appendEventSync({ runId, sessionId: actualSessionId, type: 'run_created', payload: { initial_state: clone(initialState) } });
139
+ this.appendEventSync({ runId, sessionId: actualSessionId, stepId, type: 'step_created', payload: { step_id: stepId } });
140
+ await this.flush();
141
+ return { runId, stepId };
142
+ }
143
+
144
+ async claimStep({ workerId, runId = null, leaseSeconds = 60 }) {
145
+ const candidates = Object.values(this.data.steps)
146
+ .filter((step) => (!runId || step.run_id === runId) && ['pending', 'retry_scheduled'].includes(step.status))
147
+ .sort((a, b) => a.created_at - b.created_at || a.step_id.localeCompare(b.step_id));
148
+ const step = candidates[0];
149
+ if (!step) throw new NoRunnableStepError();
150
+ const now = nowSeconds();
151
+ step.status = 'running';
152
+ step.owner = workerId;
153
+ step.lease_token = newId('lease');
154
+ step.lease_until = now + leaseSeconds;
155
+ step.attempt += 1;
156
+ step.updated_at = now;
157
+ const run = this.data.runs[step.run_id];
158
+ run.status = 'running';
159
+ run.updated_at = now;
160
+ this.appendEventSync({
161
+ runId: step.run_id,
162
+ sessionId: step.session_id,
163
+ stepId: step.step_id,
164
+ type: 'step_claimed',
165
+ payload: { worker_id: workerId, lease_token: step.lease_token, attempt: step.attempt, lease_until: step.lease_until },
166
+ });
167
+ await this.flush();
168
+ return {
169
+ run_id: step.run_id,
170
+ session_id: step.session_id,
171
+ step_id: step.step_id,
172
+ attempt: step.attempt,
173
+ lease_token: step.lease_token,
174
+ state_version: step.state_version,
175
+ lease_until: step.lease_until,
176
+ };
177
+ }
178
+
179
+ loadState(runId) {
180
+ const run = this.data.runs[runId];
181
+ if (!run) throw new Error(`run not found: ${runId}`);
182
+ return { state: clone(run.state), version: run.state_version, sessionId: run.session_id };
183
+ }
184
+
185
+ async heartbeat({ stepId, leaseToken, leaseSeconds = 60 }) {
186
+ const step = this.validateLease(stepId, leaseToken);
187
+ step.lease_until = nowSeconds() + leaseSeconds;
188
+ step.updated_at = nowSeconds();
189
+ this.appendEventSync({ runId: step.run_id, sessionId: step.session_id, stepId, type: 'lease_heartbeat', payload: { lease_token: leaseToken, lease_until: step.lease_until } });
190
+ await this.flush();
191
+ return step.lease_until;
192
+ }
193
+
194
+ async recoverExpiredLeases() {
195
+ const now = nowSeconds();
196
+ let recovered = 0;
197
+ for (const step of Object.values(this.data.steps).sort((a, b) => a.step_id.localeCompare(b.step_id))) {
198
+ if (step.status !== 'running' || step.lease_until === undefined || step.lease_until > now) continue;
199
+ const previousOwner = step.owner;
200
+ step.status = 'retry_scheduled';
201
+ delete step.owner;
202
+ delete step.lease_token;
203
+ delete step.lease_until;
204
+ step.updated_at = now;
205
+ const run = this.data.runs[step.run_id];
206
+ run.status = 'retry_scheduled';
207
+ run.updated_at = now;
208
+ recovered += 1;
209
+ this.appendEventSync({ runId: step.run_id, sessionId: step.session_id, stepId: step.step_id, type: 'lease_expired', payload: { previous_owner: previousOwner, attempt: step.attempt } });
210
+ this.appendEventSync({ runId: step.run_id, sessionId: step.session_id, stepId: step.step_id, type: 'step_retry_scheduled', payload: { step_id: step.step_id, reason: 'lease_expired' } });
211
+ }
212
+ await this.flush();
213
+ return recovered;
214
+ }
215
+
216
+ async cancelRun(runId, reason) {
217
+ const run = this.data.runs[runId];
218
+ if (!run) throw new Error(`run not found: ${runId}`);
219
+ if (['completed', 'failed', 'cancelled'].includes(run.status)) return 0;
220
+ const now = nowSeconds();
221
+ let cancelled = 0;
222
+ this.appendEventSync({ runId, sessionId: run.session_id, type: 'run_cancel_requested', payload: { reason } });
223
+ for (const step of Object.values(this.data.steps)) {
224
+ if (step.run_id !== runId || ['completed', 'failed', 'cancelled'].includes(step.status)) continue;
225
+ step.status = 'cancelled';
226
+ delete step.owner;
227
+ delete step.lease_token;
228
+ delete step.lease_until;
229
+ step.cancelled_at = now;
230
+ step.updated_at = now;
231
+ cancelled += 1;
232
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId: step.step_id, type: 'step_cancelled', payload: { reason } });
233
+ }
234
+ run.status = 'cancelled';
235
+ run.updated_at = now;
236
+ this.appendEventSync({ runId, sessionId: run.session_id, type: 'run_cancelled', payload: { reason, cancelled_steps: cancelled } });
237
+ await this.flush();
238
+ return cancelled;
239
+ }
240
+
241
+ async commitStatePatch({ runId, stepId, leaseToken, baseVersion, patch = {}, checkpointId = null }) {
242
+ const step = this.validateLease(stepId, leaseToken);
243
+ const run = this.data.runs[runId];
244
+ if (!run) throw new Error(`run not found: ${runId}`);
245
+ if (run.state_version !== baseVersion) throw new Error(`state version conflict: expected ${baseVersion}, got ${run.state_version}`);
246
+ const now = nowSeconds();
247
+ const nextVersion = run.state_version + 1;
248
+ run.state = mergePatch(run.state, patch);
249
+ run.state_version = nextVersion;
250
+ run.status = 'completed';
251
+ run.updated_at = now;
252
+ step.status = 'completed';
253
+ step.state_version = nextVersion;
254
+ if (checkpointId) step.checkpoint_id = checkpointId;
255
+ step.updated_at = now;
256
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'state_patch_committed', stateVersion: nextVersion, payload: { patch: clone(patch), state_version: nextVersion } });
257
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'step_completed', stateVersion: nextVersion, payload: { step_id: stepId } });
258
+ await this.flush();
259
+ return nextVersion;
260
+ }
261
+
262
+ async markWaitingHuman({ runId, stepId, reason, approvalId = null }) {
263
+ const step = this.data.steps[stepId];
264
+ if (!step) throw new Error(`step not found: ${stepId}`);
265
+ const now = nowSeconds();
266
+ step.status = 'waiting_human';
267
+ delete step.owner;
268
+ delete step.lease_token;
269
+ delete step.lease_until;
270
+ step.last_error_type = 'ApprovalRequired';
271
+ step.last_error = reason;
272
+ step.updated_at = now;
273
+ const run = this.data.runs[runId];
274
+ run.status = 'waiting_human';
275
+ run.updated_at = now;
276
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'step_waiting_human', payload: { reason, approval_id: approvalId } });
277
+ await this.flush();
278
+ }
279
+
280
+ async markRetry({ runId, stepId, errorType, message }) {
281
+ const step = this.data.steps[stepId];
282
+ if (!step) throw new Error(`step not found: ${stepId}`);
283
+ const now = nowSeconds();
284
+ step.status = 'retry_scheduled';
285
+ delete step.owner;
286
+ delete step.lease_token;
287
+ delete step.lease_until;
288
+ step.last_error_type = errorType;
289
+ step.last_error = message;
290
+ step.updated_at = now;
291
+ const run = this.data.runs[runId];
292
+ run.status = 'retry_scheduled';
293
+ run.updated_at = now;
294
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'failure_classified', payload: { error: message, error_type: errorType, retryable: true, source: 'agent' } });
295
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'error_raised', payload: { error: message, error_type: errorType } });
296
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'step_retry_scheduled', payload: { step_id: stepId, attempt: step.attempt } });
297
+ await this.flush();
298
+ }
299
+
300
+ async markFailed({ runId, stepId, errorType, message }) {
301
+ const step = this.data.steps[stepId];
302
+ if (!step) throw new Error(`step not found: ${stepId}`);
303
+ const now = nowSeconds();
304
+ step.status = 'failed';
305
+ delete step.owner;
306
+ delete step.lease_token;
307
+ delete step.lease_until;
308
+ step.last_error_type = errorType;
309
+ step.last_error = message;
310
+ step.updated_at = now;
311
+ const run = this.data.runs[runId];
312
+ run.status = 'failed';
313
+ run.updated_at = now;
314
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'failure_classified', payload: { error: message, error_type: errorType, retryable: false, source: failureSource(errorType) } });
315
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'error_raised', payload: { error: message, error_type: errorType } });
316
+ this.appendEventSync({ runId, sessionId: step.session_id, stepId, type: 'step_failed', payload: { step_id: stepId, error_type: errorType } });
317
+ await this.flush();
318
+ }
319
+
320
+ async appendEvent(input) {
321
+ const event = this.appendEventSync(input);
322
+ await this.flush();
323
+ return event;
324
+ }
325
+
326
+ appendEventSync({ runId, sessionId = null, stepId = null, type, payload = {}, agentRole = null, stateVersion = null, causalToken = null, payloadHash = null, payloadRef = null }) {
327
+ const events = this.data.events[runId] ?? [];
328
+ const actualPayloadHash = payloadHash ?? sha256JSON(payload);
329
+ const actualPayloadRef = payloadRef ?? JSON.stringify(payload);
330
+ const event = {
331
+ event_id: newId('evt'),
332
+ run_id: runId,
333
+ session_id: sessionId,
334
+ step_id: stepId,
335
+ seq: events.length + 1,
336
+ type,
337
+ timestamp: nowSeconds(),
338
+ agent_role: agentRole,
339
+ state_version: stateVersion,
340
+ causal_token: causalToken,
341
+ payload_hash: actualPayloadHash,
342
+ payload_ref: actualPayloadRef,
343
+ payload: clone(payload),
344
+ };
345
+ this.data.events[runId] = [...events, event];
346
+ return event;
347
+ }
348
+
349
+ async reserveLedger(input) {
350
+ const existing = this.data.tool_ledger[input.idempotencyKey];
351
+ if (existing) return clone(existing);
352
+ const now = nowSeconds();
353
+ this.data.tool_ledger[input.idempotencyKey] = {
354
+ ledger_id: newId('ledger'),
355
+ run_id: input.runId,
356
+ session_id: input.sessionId,
357
+ step_id: input.stepId,
358
+ tool_name: input.toolName,
359
+ tool_version: input.toolVersion,
360
+ tool_call_id: input.toolCallId,
361
+ idempotency_key: input.idempotencyKey,
362
+ causal_token: input.causalToken,
363
+ request_hash: input.requestHash,
364
+ request_ref: input.requestRef,
365
+ status: 'RESERVED',
366
+ created_at: now,
367
+ updated_at: now,
368
+ };
369
+ await this.flush();
370
+ return null;
371
+ }
372
+
373
+ async updateLedger({ idempotencyKey, status, externalId = null, responseHash = null, responseRef = null, errorType = null, response = null }) {
374
+ const entry = this.data.tool_ledger[idempotencyKey];
375
+ if (!entry) throw new Error(`ledger entry not found: ${idempotencyKey}`);
376
+ entry.status = status;
377
+ entry.external_id = externalId;
378
+ entry.response_hash = responseHash;
379
+ entry.response_ref = responseRef;
380
+ entry.error_type = errorType;
381
+ entry.response = clone(response);
382
+ entry.updated_at = nowSeconds();
383
+ await this.flush();
384
+ }
385
+
386
+ async requestApproval(input) {
387
+ const existing = this.data.approval_requests[input.approvalKey];
388
+ if (existing) return clone(existing);
389
+ const now = nowSeconds();
390
+ const approval = {
391
+ approval_id: newId('approval'),
392
+ approval_key: input.approvalKey,
393
+ run_id: input.runId,
394
+ session_id: input.sessionId,
395
+ step_id: input.stepId,
396
+ tool_name: input.toolName,
397
+ risk_level: input.riskLevel,
398
+ status: 'PENDING',
399
+ reason: input.reason,
400
+ request_hash: input.requestHash,
401
+ request_ref: input.requestRef,
402
+ requested_by: input.requestedBy,
403
+ created_at: now,
404
+ updated_at: now,
405
+ };
406
+ this.data.approval_requests[input.approvalKey] = approval;
407
+ await this.flush();
408
+ return clone(approval);
409
+ }
410
+
411
+ approvalForKey(approvalKey) {
412
+ const approval = this.data.approval_requests[approvalKey];
413
+ return approval ? clone(approval) : null;
414
+ }
415
+
416
+ approvalRequests(runId = null) {
417
+ return Object.values(this.data.approval_requests)
418
+ .filter((approval) => !runId || approval.run_id === runId)
419
+ .sort((a, b) => a.created_at - b.created_at || a.approval_id.localeCompare(b.approval_id))
420
+ .map(clone);
421
+ }
422
+
423
+ approveRequest(approvalId, { approver = 'operator', reason = '' } = {}) {
424
+ return this.decideApproval(approvalId, 'APPROVED', approver, reason);
425
+ }
426
+
427
+ denyRequest(approvalId, { approver = 'operator', reason = '' } = {}) {
428
+ return this.decideApproval(approvalId, 'DENIED', approver, reason);
429
+ }
430
+
431
+ async decideApproval(approvalId, status, approver, reason) {
432
+ const entry = Object.entries(this.data.approval_requests).find(([, approval]) => approval.approval_id === approvalId);
433
+ if (!entry) throw new Error(`approval not found: ${approvalId}`);
434
+ const [key, approval] = entry;
435
+ const now = nowSeconds();
436
+ approval.status = status;
437
+ approval.approved_by = approver;
438
+ approval.decision_reason = reason;
439
+ approval.updated_at = now;
440
+ this.data.approval_requests[key] = approval;
441
+ this.appendEventSync({ runId: approval.run_id, sessionId: approval.session_id, stepId: approval.step_id, type: 'tool_approval_decided', payload: { approval_id: approvalId, tool: approval.tool_name, status, approver, reason } });
442
+ const step = this.data.steps[approval.step_id];
443
+ if (step?.status === 'waiting_human') {
444
+ if (status === 'APPROVED') {
445
+ step.status = 'pending';
446
+ delete step.owner;
447
+ delete step.lease_token;
448
+ delete step.lease_until;
449
+ step.updated_at = now;
450
+ const run = this.data.runs[approval.run_id];
451
+ run.status = 'pending';
452
+ run.updated_at = now;
453
+ this.appendEventSync({ runId: approval.run_id, sessionId: approval.session_id, stepId: approval.step_id, type: 'step_retry_scheduled', payload: { step_id: approval.step_id, reason: 'approval_granted' } });
454
+ } else if (status === 'DENIED') {
455
+ step.status = 'failed';
456
+ delete step.owner;
457
+ delete step.lease_token;
458
+ delete step.lease_until;
459
+ step.last_error_type = 'ApprovalDenied';
460
+ step.last_error = reason;
461
+ step.updated_at = now;
462
+ const run = this.data.runs[approval.run_id];
463
+ run.status = 'failed';
464
+ run.updated_at = now;
465
+ this.appendEventSync({ runId: approval.run_id, sessionId: approval.session_id, stepId: approval.step_id, type: 'failure_classified', payload: { error: reason, error_type: 'ApprovalDenied', retryable: false, source: 'approval' } });
466
+ this.appendEventSync({ runId: approval.run_id, sessionId: approval.session_id, stepId: approval.step_id, type: 'step_failed', payload: { step_id: approval.step_id, error_type: 'ApprovalDenied' } });
467
+ }
468
+ }
469
+ await this.flush();
470
+ return clone(approval);
471
+ }
472
+
473
+ async recordCost({ runId, sessionId = null, stepId = null, category, name, amount, unit, metadata = {} }) {
474
+ const costId = newId('cost');
475
+ const record = { cost_id: costId, run_id: runId, session_id: sessionId, step_id: stepId, category, name, amount, unit, metadata: clone(metadata), created_at: nowSeconds() };
476
+ this.data.cost_records[runId] ??= [];
477
+ this.data.cost_records[runId].push(record);
478
+ this.appendEventSync({ runId, sessionId, stepId, type: 'cost_recorded', payload: { cost_id: costId, category, name, amount, unit, metadata: clone(metadata) } });
479
+ await this.flush();
480
+ return costId;
481
+ }
482
+
483
+ costRecords(runId) {
484
+ return (this.data.cost_records[runId] ?? []).map(clone);
485
+ }
486
+
487
+ costSummary(runId) {
488
+ const summary = { tool_calls: 0, model_tokens: 0, total_usd: 0, by_category: {} };
489
+ for (const row of this.costRecords(runId)) addCost(summary, row);
490
+ return summary;
491
+ }
492
+
493
+ async createArtifact({ runId, stepId = null, name, content, metadata = {} }) {
494
+ const artifact = {
495
+ artifact_id: newId('art'),
496
+ run_id: runId,
497
+ step_id: stepId,
498
+ name,
499
+ blob_hash: `sha256:${sha256JSON(content)}`,
500
+ blob_ref: JSON.stringify(content),
501
+ metadata: clone(metadata),
502
+ created_at: nowSeconds(),
503
+ };
504
+ this.data.artifacts[runId] ??= [];
505
+ this.data.artifacts[runId].push(artifact);
506
+ await this.flush();
507
+ return clone(artifact);
508
+ }
509
+
510
+ artifacts(runId) {
511
+ return (this.data.artifacts[runId] ?? [])
512
+ .slice()
513
+ .sort((a, b) => a.created_at - b.created_at || a.artifact_id.localeCompare(b.artifact_id))
514
+ .map(clone);
515
+ }
516
+
517
+ run(runId) {
518
+ const run = this.data.runs[runId];
519
+ if (!run) throw new Error(`run not found: ${runId}`);
520
+ return clone(run);
521
+ }
522
+
523
+ steps(runId) {
524
+ return Object.values(this.data.steps).filter((step) => step.run_id === runId).sort((a, b) => a.created_at - b.created_at || a.step_id.localeCompare(b.step_id)).map(clone);
525
+ }
526
+
527
+ events(runId) {
528
+ return (this.data.events[runId] ?? []).map(clone);
529
+ }
530
+
531
+ ledger(runId) {
532
+ return Object.values(this.data.tool_ledger).filter((entry) => entry.run_id === runId).sort((a, b) => a.created_at - b.created_at || a.ledger_id.localeCompare(b.ledger_id)).map(clone);
533
+ }
534
+
535
+ finalState(runId) {
536
+ return this.loadState(runId).state;
537
+ }
538
+
539
+ validateLease(stepId, leaseToken) {
540
+ const step = this.data.steps[stepId];
541
+ if (!step) throw new Error(`step not found: ${stepId}`);
542
+ if (step.status !== 'running' || step.lease_token !== leaseToken) throw new Error('invalid or stale lease token');
543
+ if (step.lease_until !== undefined && step.lease_until <= nowSeconds()) throw new Error('lease expired');
544
+ return step;
545
+ }
546
+ }
547
+
548
+ export class ToolRegistry {
549
+ constructor() {
550
+ this.tools = new Map();
551
+ }
552
+
553
+ register(spec) {
554
+ if (!spec?.name) throw new Error('tool name is required');
555
+ if (typeof spec.func !== 'function') throw new Error(`tool ${spec.name} has no function`);
556
+ this.tools.set(spec.name, {
557
+ version: 'v1',
558
+ sideEffect: 'none',
559
+ riskLevel: 'low',
560
+ idempotencyRequired: false,
561
+ approvalRequired: false,
562
+ sandboxRequired: false,
563
+ sandboxExecutor: null,
564
+ sandboxPolicy: {},
565
+ ...spec,
566
+ });
567
+ }
568
+
569
+ get(name) {
570
+ const spec = this.tools.get(name);
571
+ if (!spec) throw new Error(`tool not registered: ${name}`);
572
+ return spec;
573
+ }
574
+ }
575
+
576
+ export class PolicyEngine {
577
+ constructor() {
578
+ this.roles = new Map();
579
+ this.defaultByRisk = new Map();
580
+ }
581
+
582
+ allowTool(role, tool) {
583
+ this.role(role).allowTools.add(tool);
584
+ }
585
+
586
+ denyTool(role, tool) {
587
+ this.role(role).denyTools.add(tool);
588
+ }
589
+
590
+ allowRisk(role, risk) {
591
+ this.role(role).allowRisk.add(risk);
592
+ }
593
+
594
+ checkTool(role, toolName, riskLevel) {
595
+ const policy = this.roles.get(role);
596
+ if (policy) {
597
+ if (policy.denyTools.has(toolName)) return { allowed: false, reason: `tool ${toolName} explicitly denied for role ${role}` };
598
+ if (policy.allowTools.has(toolName)) return { allowed: true, reason: 'allowed by role policy' };
599
+ if (policy.denyRisk.has(riskLevel)) return { allowed: false, reason: `risk level ${riskLevel} denied for role ${role}` };
600
+ if (policy.allowRisk.has(riskLevel)) return { allowed: true, reason: `risk level ${riskLevel} allowed for role ${role}` };
601
+ if (policy.allowTools.size || policy.allowRisk.size) return { allowed: false, reason: `tool ${toolName} not allowed for role ${role}` };
602
+ }
603
+ if (this.defaultByRisk.get(riskLevel) === 'allow') return { allowed: true, reason: `risk level ${riskLevel} allowed by default policy` };
604
+ if (this.defaultByRisk.get(riskLevel) === 'deny') return { allowed: false, reason: `risk level ${riskLevel} denied by default policy` };
605
+ if (['high', 'destructive', 'sensitive', 'financial_or_legal'].includes(riskLevel)) return { allowed: false, reason: 'high-risk tool denied by default' };
606
+ return { allowed: true, reason: 'default allow for low/medium risk in local runtime' };
607
+ }
608
+
609
+ role(role) {
610
+ if (!this.roles.has(role)) this.roles.set(role, { allowTools: new Set(), denyTools: new Set(), allowRisk: new Set(), denyRisk: new Set() });
611
+ return this.roles.get(role);
612
+ }
613
+ }
614
+
615
+ export class BudgetController {
616
+ constructor(limits = {}) {
617
+ this.limits = { maxToolCalls: null, maxModelTokens: null, maxTotalUsd: null, ...limits };
618
+ }
619
+
620
+ beforeToolCall(store, runId) {
621
+ if (this.limits.maxToolCalls == null) return;
622
+ const used = store.costSummary(runId).tool_calls;
623
+ if (used >= this.limits.maxToolCalls) throw new BudgetExceededError(`tool call budget exceeded: ${used}/${this.limits.maxToolCalls}`);
624
+ }
625
+
626
+ beforeModelCall(store, runId, estimatedTokens = 0) {
627
+ if (this.limits.maxModelTokens != null) {
628
+ const used = store.costSummary(runId).model_tokens;
629
+ if (used + estimatedTokens > this.limits.maxModelTokens) throw new BudgetExceededError(`model token budget exceeded: ${used}+${estimatedTokens}/${this.limits.maxModelTokens}`);
630
+ }
631
+ if (this.limits.maxTotalUsd != null) {
632
+ const used = store.costSummary(runId).total_usd;
633
+ if (used > this.limits.maxTotalUsd) throw new BudgetExceededError(`cost budget exceeded: ${used}/${this.limits.maxTotalUsd} USD`);
634
+ }
635
+ }
636
+ }
637
+
638
+ export class DisabledSandboxExecutor {
639
+ async runTool(_spec, _args, policy) {
640
+ return { ok: false, error: `sandbox executor ${JSON.stringify(policy.executor)} is disabled`, metadata: { executor: policy.executor, isolation_level: 'none', fail_closed: true } };
641
+ }
642
+ }
643
+
644
+ export class LocalSandboxExecutor {
645
+ async runTool(spec, args, policy) {
646
+ try {
647
+ return { ok: true, output: await spec.func(clone(args)), metadata: { executor: policy.executor ?? 'local', isolation_level: 'none' } };
648
+ } catch (error) {
649
+ return { ok: false, error: String(error?.message ?? error), metadata: { executor: policy.executor ?? 'local', isolation_level: 'none' } };
650
+ }
651
+ }
652
+ }
653
+
654
+ export function validateToolSchema(schema, value, path = '$') {
655
+ if (!schema || Object.keys(schema).length === 0) return;
656
+ if (Object.hasOwn(schema, 'const') && JSON.stringify(schema.const) !== JSON.stringify(value)) throw new Error(`${path} expected const ${JSON.stringify(schema.const)}`);
657
+ if (Array.isArray(schema.enum) && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) throw new Error(`${path} value not in enum`);
658
+ if (!schema.type) return;
659
+ if (schema.type === 'object') {
660
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${path} expected object`);
661
+ const properties = schema.properties ?? {};
662
+ for (const key of schema.required ?? []) if (!Object.hasOwn(value, key)) throw new Error(`${path}.${key} is required`);
663
+ for (const [key, childSchema] of Object.entries(properties)) if (Object.hasOwn(value, key)) validateToolSchema(childSchema, value[key], `${path}.${key}`);
664
+ if (schema.additionalProperties === false) for (const key of Object.keys(value)) if (!Object.hasOwn(properties, key)) throw new Error(`${path}.${key} is not allowed`);
665
+ } else if (schema.type === 'string') {
666
+ if (typeof value !== 'string') throw new Error(`${path} expected string`);
667
+ if (schema.minLength != null && value.length < schema.minLength) throw new Error(`${path} shorter than minLength`);
668
+ if (schema.maxLength != null && value.length > schema.maxLength) throw new Error(`${path} longer than maxLength`);
669
+ } else if (schema.type === 'number' || schema.type === 'integer') {
670
+ if (typeof value !== 'number') throw new Error(`${path} expected number`);
671
+ if (schema.type === 'integer' && !Number.isInteger(value)) throw new Error(`${path} expected integer`);
672
+ if (schema.minimum != null && value < schema.minimum) throw new Error(`${path} below minimum`);
673
+ if (schema.maximum != null && value > schema.maximum) throw new Error(`${path} above maximum`);
674
+ } else if (schema.type === 'boolean' && typeof value !== 'boolean') {
675
+ throw new Error(`${path} expected boolean`);
676
+ }
677
+ }
678
+
679
+ export class ToolGateway {
680
+ constructor(store, registry, policy = new PolicyEngine(), budget = new BudgetController(), sandbox = null) {
681
+ this.store = store;
682
+ this.registry = registry;
683
+ this.policy = policy;
684
+ this.budget = budget;
685
+ this.sandbox = sandbox;
686
+ }
687
+
688
+ async call(agentCtx, toolName, args = {}) {
689
+ const spec = this.registry.get(toolName);
690
+ const request = { tool: toolName, args: clone(args) };
691
+ const requestHash = sha256JSON(request);
692
+ const requestRef = JSON.stringify(request);
693
+ const causalToken = JSON.stringify({ run_id: agentCtx.runId, step_id: agentCtx.stepId, attempt: agentCtx.attempt, state_version: agentCtx.stateVersion, lease_token: agentCtx.leaseToken });
694
+ const idempotencyKey = `${agentCtx.runId}:${agentCtx.stepId}:${toolName}:${requestHash}`;
695
+ const approvalKey = idempotencyKey;
696
+ const managedSideEffect = spec.sideEffect !== 'none' || spec.idempotencyRequired;
697
+
698
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_call_requested', payload: request, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken, payloadHash: requestHash, payloadRef: requestRef });
699
+ try {
700
+ validateToolSchema(spec.inputSchema, args, '$arg');
701
+ } catch (error) {
702
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_call_failed', payload: { tool: toolName, error: error.message, phase: 'input_validation' }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
703
+ throw error;
704
+ }
705
+
706
+ let { allowed, reason } = this.policy.checkTool(agentCtx.agentRole, toolName, spec.riskLevel);
707
+ const approval = this.store.approvalForKey(approvalKey);
708
+ if (approval?.status === 'DENIED') {
709
+ reason = `approval denied for tool ${toolName}`;
710
+ await this.recordPermission(agentCtx, toolName, false, reason, causalToken);
711
+ throw new PermissionDeniedError(reason);
712
+ }
713
+ if (approval?.status === 'APPROVED') {
714
+ allowed = true;
715
+ reason = `approved by ${approval.approved_by ?? 'operator'}`;
716
+ } else if (spec.approvalRequired) {
717
+ const requested = await this.store.requestApproval({ approvalKey, runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, toolName, riskLevel: spec.riskLevel, reason: 'tool requires approval', requestHash, requestRef, requestedBy: agentCtx.agentRole });
718
+ await this.recordPermission(agentCtx, toolName, false, 'approval required', causalToken);
719
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_approval_required', payload: { tool: toolName, approval_id: requested.approval_id, approval_key: approvalKey, risk_level: spec.riskLevel }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
720
+ throw new ApprovalRequiredError(requested.approval_id, `approval required for tool ${toolName}`);
721
+ }
722
+
723
+ await this.recordPermission(agentCtx, toolName, allowed, reason, causalToken);
724
+ if (!allowed) throw new PermissionDeniedError(reason);
725
+ try {
726
+ this.budget.beforeToolCall(this.store, agentCtx.runId);
727
+ } catch (error) {
728
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'budget_check_failed', payload: { category: 'tool', tool: toolName, error: error.message }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
729
+ throw error;
730
+ }
731
+
732
+ if (managedSideEffect) {
733
+ const existing = await this.store.reserveLedger({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, toolName, toolVersion: spec.version, toolCallId: newId('toolcall'), idempotencyKey, causalToken, requestHash, requestRef });
734
+ if (existing) {
735
+ if (existing.status === 'SUCCEEDED') {
736
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_call_completed', payload: { tool: toolName, idempotency_key: idempotencyKey, replayed_from_ledger: true }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken, payloadHash: existing.response_hash, payloadRef: existing.response_ref });
737
+ await this.store.recordCost({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, category: 'tool', name: toolName, amount: 1, unit: 'call', metadata: { replayed_from_ledger: true } });
738
+ return clone(existing.response);
739
+ }
740
+ if (existing.status === 'PENDING_VERIFICATION') throw new Error('tool side effect pending verification');
741
+ throw new Error('tool side effect already in progress');
742
+ }
743
+ await this.store.updateLedger({ idempotencyKey, status: 'RUNNING' });
744
+ }
745
+
746
+ try {
747
+ const result = await this.executeTool(agentCtx, spec, clone(args), causalToken);
748
+ validateToolSchema(spec.outputSchema, result, '$result');
749
+ const responseHash = sha256JSON(result);
750
+ const responseRef = JSON.stringify(result);
751
+ if (managedSideEffect) await this.store.updateLedger({ idempotencyKey, status: 'SUCCEEDED', externalId: result?.external_id ?? null, responseHash, responseRef, response: result });
752
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_call_completed', payload: { tool: toolName, idempotency_key: idempotencyKey }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken, payloadHash: responseHash, payloadRef: responseRef });
753
+ await this.store.recordCost({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, category: 'tool', name: toolName, amount: 1, unit: 'call', metadata: { side_effect: spec.sideEffect, sandboxed: spec.sandboxRequired, sandbox_executor: spec.sandboxExecutor } });
754
+ return result;
755
+ } catch (error) {
756
+ if (managedSideEffect) await this.store.updateLedger({ idempotencyKey, status: 'PENDING_VERIFICATION', errorType: error.constructor?.name ?? 'Error' });
757
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_call_failed', payload: { tool: toolName, error: String(error?.message ?? error) }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
758
+ throw error;
759
+ }
760
+ }
761
+
762
+ async recordPermission(agentCtx, toolName, allowed, reason, causalToken) {
763
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'tool_permission_decided', payload: { tool: toolName, allowed, reason }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
764
+ }
765
+
766
+ async executeTool(agentCtx, spec, args, causalToken) {
767
+ if (!spec.sandboxRequired) return spec.func(clone(args));
768
+ const policy = { tool_name: spec.name, run_id: agentCtx.runId, step_id: agentCtx.stepId, executor: spec.sandboxExecutor ?? spec.sandboxPolicy?.executor ?? 'default', isolation_level: 'unknown', network: spec.sandboxPolicy?.network ?? 'deny', filesystem: spec.sandboxPolicy?.filesystem ?? 'read-only', timeout_seconds: spec.sandboxPolicy?.timeout_seconds ?? 30, extra: clone(spec.sandboxPolicy ?? {}) };
769
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'sandbox_started', payload: policy, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
770
+ const executor = this.sandbox ?? new DisabledSandboxExecutor();
771
+ const result = await executor.runTool(spec, args, policy);
772
+ await this.store.appendEvent({ runId: agentCtx.runId, sessionId: agentCtx.sessionId, stepId: agentCtx.stepId, type: 'sandbox_completed', payload: { ok: result.ok, error: result.error, metadata: result.metadata ?? {} }, agentRole: agentCtx.agentRole, stateVersion: agentCtx.stateVersion, causalToken });
773
+ if (!result.ok) throw new SandboxUnavailableError(result.error ?? 'sandboxed tool failed');
774
+ return result.output;
775
+ }
776
+ }
777
+
778
+ export class Runtime {
779
+ constructor(store = JSONStore.memory()) {
780
+ this.store = store;
781
+ this.registry = new ToolRegistry();
782
+ this.policy = new PolicyEngine();
783
+ this.budget = new BudgetController();
784
+ this.sandbox = null;
785
+ this.gateway = new ToolGateway(store, this.registry, this.policy, this.budget, this.sandbox);
786
+ }
787
+
788
+ static async local(path) {
789
+ return new Runtime(await JSONStore.open(path));
790
+ }
791
+
792
+ setBudget(limits = {}) {
793
+ this.budget = new BudgetController(limits);
794
+ this.gateway.budget = this.budget;
795
+ }
796
+
797
+ setSandbox(executor) {
798
+ this.sandbox = executor;
799
+ this.gateway.sandbox = executor;
800
+ }
801
+
802
+ registerTool(spec) {
803
+ this.registry.register(spec);
804
+ }
805
+
806
+ async createRun(initialState = {}) {
807
+ return this.store.createRun(initialState);
808
+ }
809
+
810
+ async runOnce({ runId, workerId = 'worker-node', agentRole = 'Agent', leaseSeconds = 60, agent }) {
811
+ let claim;
812
+ try {
813
+ claim = await this.store.claimStep({ workerId, runId, leaseSeconds });
814
+ } catch (error) {
815
+ if (error instanceof NoRunnableStepError) return false;
816
+ throw error;
817
+ }
818
+ const { state, version, sessionId } = this.store.loadState(claim.run_id);
819
+ const agentCtx = new AgentContext({ runId: claim.run_id, sessionId, stepId: claim.step_id, agentRole, leaseToken: claim.lease_token, attempt: claim.attempt, stateVersion: version, store: this.store, gateway: this.gateway, budget: this.budget });
820
+ await this.store.appendEvent({ runId: claim.run_id, sessionId, stepId: claim.step_id, type: 'agent_started', payload: { agent_role: agentRole, attempt: claim.attempt, execution_mode: 'normal' }, agentRole, stateVersion: version });
821
+ try {
822
+ await agent(agentCtx, clone(state));
823
+ await this.store.commitStatePatch({ runId: claim.run_id, stepId: claim.step_id, leaseToken: claim.lease_token, baseVersion: version, patch: agentCtx.pendingPatch, checkpointId: `ckpt:${claim.run_id}:${claim.step_id}:${claim.attempt}` });
824
+ return true;
825
+ } catch (error) {
826
+ if (error instanceof ApprovalRequiredError) {
827
+ await this.store.markWaitingHuman({ runId: claim.run_id, stepId: claim.step_id, reason: error.message, approvalId: error.approvalId });
828
+ return false;
829
+ }
830
+ if (error instanceof RetryableAgentError) {
831
+ await this.store.markRetry({ runId: claim.run_id, stepId: claim.step_id, errorType: error.name, message: error.message });
832
+ return false;
833
+ }
834
+ await this.store.markFailed({ runId: claim.run_id, stepId: claim.step_id, errorType: error.constructor?.name ?? 'Error', message: error.message ?? String(error) });
835
+ throw error;
836
+ }
837
+ }
838
+ }
839
+
840
+ export class AgentContext {
841
+ constructor({ runId, sessionId, stepId, agentRole, leaseToken, attempt, stateVersion, store, gateway, budget }) {
842
+ this.runId = runId;
843
+ this.sessionId = sessionId;
844
+ this.stepId = stepId;
845
+ this.agentRole = agentRole;
846
+ this.leaseToken = leaseToken;
847
+ this.attempt = attempt;
848
+ this.stateVersion = stateVersion;
849
+ this.store = store;
850
+ this.gateway = gateway;
851
+ this.budget = budget;
852
+ this.pendingPatch = {};
853
+ }
854
+
855
+ async callTool(name, args = {}) {
856
+ return this.gateway.call(this, name, args);
857
+ }
858
+
859
+ async writeState(key, value) {
860
+ this.pendingPatch[key] = clone(value);
861
+ await this.store.appendEvent({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, type: 'state_patch_proposed', payload: { key, patch: clone(value) }, agentRole: this.agentRole, stateVersion: this.stateVersion });
862
+ }
863
+
864
+ async createArtifact(name, content, metadata = {}) {
865
+ const artifact = await this.store.createArtifact({ runId: this.runId, stepId: this.stepId, name, content, metadata });
866
+ await this.store.appendEvent({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, type: 'artifact_created', payload: { artifact_id: artifact.artifact_id, name }, agentRole: this.agentRole, stateVersion: this.stateVersion, payloadHash: artifact.blob_hash, payloadRef: artifact.blob_ref });
867
+ return artifact.artifact_id;
868
+ }
869
+
870
+ async createMediaArtifact(name, kind, options = {}) {
871
+ if (!MEDIA_KINDS.has(kind)) throw new Error(`unsupported media kind: ${kind}`);
872
+ const mediaMetadata = compactObject({ schema_version: MEDIA_SCHEMA_VERSION, ...(clone(options.mediaMetadata ?? {})), kind });
873
+ const content = compactObject({
874
+ schema_version: MEDIA_SCHEMA_VERSION,
875
+ kind,
876
+ uri: options.uri,
877
+ content_ref: options.contentRef,
878
+ metadata: mediaMetadata,
879
+ lineage: clone(options.lineage ?? {}),
880
+ derived_outputs: clone(options.derivedOutputs ?? {}),
881
+ });
882
+ const metadata = { ...(clone(options.metadata ?? {})), agentledger_media: compactObject({ schema_version: MEDIA_SCHEMA_VERSION, kind, uri: options.uri, content_ref: options.contentRef, metadata: mediaMetadata, lineage: clone(options.lineage ?? {}) }) };
883
+ return this.createArtifact(name, content, metadata);
884
+ }
885
+
886
+ async createStreamCheckpoint(name, options = {}) {
887
+ if (!options.streamId || !options.consumerId) throw new Error('streamId and consumerId are required');
888
+ const chunk = normalizeStreamChunk(options.chunk);
889
+ const content = compactObject({
890
+ schema_version: STREAM_SCHEMA_VERSION,
891
+ stream_id: options.streamId,
892
+ consumer_id: options.consumerId,
893
+ offset: options.offset,
894
+ watermark: options.watermark,
895
+ chunk,
896
+ partial_result_ref: options.partialResultRef,
897
+ backpressure: clone(options.backpressure ?? {}),
898
+ metadata: clone(options.metadata ?? {}),
899
+ });
900
+ const metadata = { agentledger_stream: compactObject({ schema_version: STREAM_SCHEMA_VERSION, stream_id: options.streamId, consumer_id: options.consumerId, offset: options.offset, watermark: options.watermark, chunk, partial_result_ref: options.partialResultRef, backpressure: clone(options.backpressure ?? {}) }) };
901
+ return this.createArtifact(name, content, metadata);
902
+ }
903
+
904
+ async recordModelCall({ model, inputTokens = 0, outputTokens = 0, totalUsd = 0 }) {
905
+ const totalTokens = inputTokens + outputTokens;
906
+ try {
907
+ this.budget.beforeModelCall(this.store, this.runId, totalTokens);
908
+ } catch (error) {
909
+ await this.store.appendEvent({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, type: 'budget_check_failed', payload: { category: 'model', model, error: error.message }, agentRole: this.agentRole, stateVersion: this.stateVersion });
910
+ throw error;
911
+ }
912
+ await this.store.appendEvent({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, type: 'model_call_completed', payload: { model, input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: totalTokens, total_usd: totalUsd }, agentRole: this.agentRole, stateVersion: this.stateVersion });
913
+ if (totalTokens > 0) await this.store.recordCost({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, category: 'model', name: model, amount: totalTokens, unit: 'token', metadata: { input_tokens: inputTokens, output_tokens: outputTokens } });
914
+ if (totalUsd > 0) await this.store.recordCost({ runId: this.runId, sessionId: this.sessionId, stepId: this.stepId, category: 'model', name: model, amount: totalUsd, unit: 'usd', metadata: { input_tokens: inputTokens, output_tokens: outputTokens } });
915
+ }
916
+
917
+ heartbeat(leaseSeconds = 60) {
918
+ return this.store.heartbeat({ stepId: this.stepId, leaseToken: this.leaseToken, leaseSeconds });
919
+ }
920
+ }
921
+
922
+ const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']);
923
+
924
+ export class LocalWorker {
925
+ constructor(runtime, { workerId = 'worker-local', agentRole = 'Agent', leaseSeconds = 60, recoverExpired = true } = {}) {
926
+ this.runtime = runtime;
927
+ this.workerId = workerId;
928
+ this.agentRole = agentRole;
929
+ this.leaseSeconds = leaseSeconds;
930
+ this.recoverExpired = recoverExpired;
931
+ }
932
+
933
+ async runUntilIdle({ runId = null, maxIterations = 100, agent }) {
934
+ const summary = { worker_id: this.workerId, run_id: runId, iterations: 0, attempts: 0, succeeded_attempts: 0, recovered_leases: 0, final_status: null, stopped_reason: 'max_iterations' };
935
+ for (let i = 1; i <= maxIterations; i += 1) {
936
+ summary.iterations = i;
937
+ if (this.recoverExpired) summary.recovered_leases += await this.runtime.store.recoverExpiredLeases();
938
+ if (runId && TERMINAL_RUN_STATUSES.has(this.runtime.store.run(runId).status)) {
939
+ summary.final_status = this.runtime.store.run(runId).status;
940
+ summary.stopped_reason = 'terminal_status';
941
+ break;
942
+ }
943
+ const ok = await this.runtime.runOnce({ runId, workerId: this.workerId, agentRole: this.agentRole, leaseSeconds: this.leaseSeconds, agent });
944
+ if (!ok) {
945
+ summary.stopped_reason = 'idle';
946
+ break;
947
+ }
948
+ summary.attempts += 1;
949
+ if (ok) summary.succeeded_attempts += 1;
950
+ }
951
+ if (runId) {
952
+ summary.final_status = this.runtime.store.run(runId).status;
953
+ if (TERMINAL_RUN_STATUSES.has(summary.final_status)) summary.stopped_reason = 'terminal_status';
954
+ }
955
+ return summary;
956
+ }
957
+ }
958
+
959
+ export class WorkerService {
960
+ constructor(worker) {
961
+ this.worker = worker;
962
+ this.stopRequested = false;
963
+ this.stopReason = 'stop_requested';
964
+ }
965
+
966
+ requestStop(reason = 'stop_requested') {
967
+ this.stopRequested = true;
968
+ this.stopReason = reason;
969
+ }
970
+
971
+ async serve({ runId = null, maxLoops = 100, maxIdlePolls = 1, agent }) {
972
+ const summary = { worker_id: this.worker.workerId, run_id: runId, loops: 0, attempts: 0, succeeded_attempts: 0, recovered_leases: 0, idle_polls: 0, stopped_reason: 'max_loops', final_status: null, stop_requested: false };
973
+ while (summary.loops < maxLoops) {
974
+ if (this.stopRequested) {
975
+ summary.stopped_reason = this.stopReason;
976
+ summary.stop_requested = true;
977
+ break;
978
+ }
979
+ summary.loops += 1;
980
+ const runSummary = await this.worker.runUntilIdle({ runId, maxIterations: 1, agent });
981
+ summary.attempts += runSummary.attempts;
982
+ summary.succeeded_attempts += runSummary.succeeded_attempts;
983
+ summary.recovered_leases += runSummary.recovered_leases;
984
+ summary.final_status = runSummary.final_status;
985
+ if (summary.final_status && TERMINAL_RUN_STATUSES.has(summary.final_status)) {
986
+ summary.stopped_reason = 'terminal_status';
987
+ break;
988
+ }
989
+ if (runSummary.attempts === 0) {
990
+ summary.idle_polls += 1;
991
+ if (maxIdlePolls != null && summary.idle_polls >= maxIdlePolls) {
992
+ summary.stopped_reason = 'idle';
993
+ break;
994
+ }
995
+ } else {
996
+ summary.idle_polls = 0;
997
+ }
998
+ }
999
+ return summary;
1000
+ }
1001
+ }
1002
+
1003
+
1004
+ export async function simpleRun(agent, { runtime = null, initialState = {}, agentRole = 'Agent', workerId = 'worker-simple', leaseSeconds = 60 } = {}) {
1005
+ const rt = runtime ?? new Runtime(JSONStore.memory());
1006
+ const { runId } = await rt.createRun(initialState);
1007
+ const ok = await rt.runOnce({ runId, workerId, agentRole, leaseSeconds, agent: async (ctx, state) => {
1008
+ const output = await agent(ctx, state);
1009
+ if (output !== undefined && output !== null) {
1010
+ await ctx.store.appendEvent({ runId: ctx.runId, sessionId: ctx.sessionId, stepId: ctx.stepId, type: 'agent_result_returned', payload: { agent: 'agent' }, agentRole: ctx.agentRole, stateVersion: ctx.stateVersion });
1011
+ await ctx.writeState('output', output);
1012
+ }
1013
+ } });
1014
+ const state = rt.store.finalState(runId);
1015
+ const run = rt.store.run(runId);
1016
+ return { run_id: runId, session_id: run.session_id, ok, output: state.output, state, runtime: rt };
1017
+ }
1018
+
1019
+ export function exportEvidence(store, runId) {
1020
+ const run = store.run(runId);
1021
+ const steps = store.steps(runId);
1022
+ const events = store.events(runId);
1023
+ const toolLedger = store.ledger(runId);
1024
+ const approvals = store.approvalRequests(runId);
1025
+ const artifacts = store.artifacts(runId);
1026
+ const mediaArtifacts = mediaArtifactsFrom(artifacts);
1027
+ const streamCheckpoints = streamCheckpointsFrom(artifacts);
1028
+ const costRecords = store.costRecords(runId);
1029
+ const costSummary = store.costSummary(runId);
1030
+ const finalState = store.finalState(runId);
1031
+ const summary = { event_count: events.length, step_count: steps.length, tool_ledger_count: toolLedger.length, approval_count: approvals.length, artifact_count: artifacts.length, media_artifact_count: mediaArtifacts.length, stream_checkpoint_count: streamCheckpoints.length, cost_record_count: costRecords.length, has_failed_steps: steps.some((step) => step.status === 'failed'), has_pending_ledger: toolLedger.some((entry) => entry.status === 'PENDING_VERIFICATION'), has_pending_approval: approvals.some((entry) => entry.status === 'PENDING') };
1032
+ const bundle = { schema_version: 'agentledger.evidence.v1', bundle_hash: null, run, steps, events, tool_ledger: toolLedger, approvals, artifacts, media_artifacts: mediaArtifacts, stream_checkpoints: streamCheckpoints, cost_records: costRecords, cost_summary: costSummary, summary, final_state: finalState };
1033
+ bundle.bundle_hash = sha256JSON({ ...bundle, bundle_hash: null });
1034
+ return bundle;
1035
+ }
1036
+
1037
+ export function replay(store, runId) {
1038
+ const events = store.events(runId);
1039
+ const digestInput = events.map((event) => ({ seq: event.seq, type: event.type, payload_hash: event.payload_hash, payload_ref: event.payload_ref }));
1040
+ const artifacts = store.artifacts(runId);
1041
+ return { run_id: runId, event_count: events.length, tool_call_count: events.filter((event) => event.type.startsWith('tool_call_')).length, final_state: store.finalState(runId), event_hash: sha256JSON(digestInput), replay_safe: true, artifact_count: artifacts.length, media_artifact_count: mediaArtifactsFrom(artifacts).length, stream_checkpoint_count: streamCheckpointsFrom(artifacts).length };
1042
+ }
1043
+
1044
+
1045
+ export function traceSpans(bundle) {
1046
+ const runId = bundle.run?.run_id ?? 'run_unknown';
1047
+ const spans = [];
1048
+ for (let i = 0; i < (bundle.events ?? []).length; i += 1) {
1049
+ const event = bundle.events[i];
1050
+ const seq = Number(event.seq ?? i + 1);
1051
+ spans.push({ trace_id: runId, span_id: spanId('evt', seq), parent_span_id: null, name: event.type ?? 'event', start_time: Number(event.timestamp ?? 0), end_time: Number(event.timestamp ?? 0), attributes: compactObject({ 'agentledger.run_id': runId, 'agentledger.session_id': event.session_id, 'agentledger.step_id': event.step_id, 'agentledger.seq': seq, 'agentledger.state_version': event.state_version, 'agentledger.payload_hash': event.payload_hash, 'agentledger.payload_ref': event.payload_ref }) });
1052
+ }
1053
+ for (let i = 0; i < (bundle.media_artifacts ?? []).length; i += 1) {
1054
+ const artifact = bundle.media_artifacts[i];
1055
+ spans.push({ trace_id: runId, span_id: spanId('media', i + 1), parent_span_id: null, name: 'media_artifact', start_time: Number(bundle.run?.updated_at ?? 0), end_time: Number(bundle.run?.updated_at ?? 0), attributes: compactObject({ 'agentledger.run_id': runId, 'agentledger.artifact_id': artifact.artifact_id, 'agentledger.artifact_name': artifact.name, 'agentledger.media_kind': artifact.kind, 'agentledger.media_uri': artifact.uri, 'agentledger.media_content_ref': artifact.content_ref, 'agentledger.blob_hash': artifact.blob_hash, 'agentledger.blob_ref': artifact.blob_ref }) });
1056
+ }
1057
+ for (let i = 0; i < (bundle.stream_checkpoints ?? []).length; i += 1) {
1058
+ const checkpoint = bundle.stream_checkpoints[i];
1059
+ spans.push({ trace_id: runId, span_id: spanId('stream', i + 1), parent_span_id: null, name: 'stream_checkpoint', start_time: Number(bundle.run?.updated_at ?? 0), end_time: Number(bundle.run?.updated_at ?? 0), attributes: compactObject({ 'agentledger.run_id': runId, 'agentledger.artifact_id': checkpoint.artifact_id, 'agentledger.artifact_name': checkpoint.name, 'agentledger.stream_id': checkpoint.stream_id, 'agentledger.consumer_id': checkpoint.consumer_id, 'agentledger.stream_offset': checkpoint.offset, 'agentledger.stream_watermark': checkpoint.watermark, 'agentledger.blob_hash': checkpoint.blob_hash, 'agentledger.blob_ref': checkpoint.blob_ref }) });
1060
+ }
1061
+ return spans;
1062
+ }
1063
+
1064
+
1065
+ export function otlpTraceJSON(bundle, { serviceName = 'agentledger', serviceVersion = null, attributes = {} } = {}) {
1066
+ const resourceAttributes = { 'service.name': serviceName, ...attributes };
1067
+ if (serviceVersion) resourceAttributes['service.version'] = serviceVersion;
1068
+ const spans = traceSpans(bundle).map((span) => {
1069
+ const attrs = { ...span.attributes, 'agentledger.original_trace_id': span.trace_id, 'agentledger.original_span_id': span.span_id };
1070
+ const item = { traceId: hexId(span.trace_id, 32), spanId: hexId(span.span_id, 16), name: span.name, kind: 'SPAN_KIND_INTERNAL', startTimeUnixNano: String(Math.trunc(span.start_time * 1_000_000_000)), endTimeUnixNano: String(Math.trunc(span.end_time * 1_000_000_000)), attributes: otlpAttributes(attrs) };
1071
+ if (span.parent_span_id) item.parentSpanId = hexId(span.parent_span_id, 16);
1072
+ return item;
1073
+ });
1074
+ return { resourceSpans: [{ resource: { attributes: otlpAttributes(resourceAttributes) }, scopeSpans: [{ scope: { name: 'agentledger', version: serviceVersion ?? '1.0.0' }, spans }] }] };
1075
+ }
1076
+
1077
+ function otlpAttributes(attrs) {
1078
+ return Object.entries(attrs).filter(([, value]) => value !== undefined && value !== null).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ({ key, value: otlpValue(value) }));
1079
+ }
1080
+
1081
+ function otlpValue(value) {
1082
+ if (typeof value === 'boolean') return { boolValue: value };
1083
+ if (Number.isInteger(value)) return { intValue: String(value) };
1084
+ if (typeof value === 'number') return { doubleValue: value };
1085
+ if (typeof value === 'string') return { stringValue: value };
1086
+ return { stringValue: JSON.stringify(value) };
1087
+ }
1088
+
1089
+ function hexId(value, chars) {
1090
+ const encoded = createHash('sha256').update(String(value)).digest('hex');
1091
+ return encoded.slice(0, chars).padEnd(chars, '0');
1092
+ }
1093
+
1094
+ export function traceJSONL(bundle) {
1095
+ return traceSpans(bundle).map((span) => JSON.stringify(span)).join('\n') + (traceSpans(bundle).length ? '\n' : '');
1096
+ }
1097
+
1098
+ export function diffEvidence(left, right) {
1099
+ const changes = { bundle_hash_changed: left.bundle_hash !== right.bundle_hash, summary: diffDict(left.summary ?? {}, right.summary ?? {}), final_state: diffDict(left.final_state ?? {}, right.final_state ?? {}), event_types: diffSequence((left.events ?? []).map((e) => e.type), (right.events ?? []).map((e) => e.type)), tool_ledger: diffSequence((left.tool_ledger ?? []).map((e) => e.status), (right.tool_ledger ?? []).map((e) => e.status)), media_artifacts: diffSequence(fingerprints(left.media_artifacts ?? [], ['name', 'kind', 'uri', 'content_ref', 'blob_hash', 'lineage']), fingerprints(right.media_artifacts ?? [], ['name', 'kind', 'uri', 'content_ref', 'blob_hash', 'lineage'])), stream_checkpoints: diffSequence(fingerprints(left.stream_checkpoints ?? [], ['name', 'stream_id', 'consumer_id', 'offset', 'watermark', 'chunk', 'partial_result_ref']), fingerprints(right.stream_checkpoints ?? [], ['name', 'stream_id', 'consumer_id', 'offset', 'watermark', 'chunk', 'partial_result_ref'])) };
1100
+ return { left_run_id: left.run?.run_id, right_run_id: right.run?.run_id, same: !hasDiffChanges(changes), changes };
1101
+ }
1102
+
1103
+ export function divergenceReport(left, right) {
1104
+ const dimensions = { events: diffSequence((left.events ?? []).map((e) => e.type), (right.events ?? []).map((e) => e.type)), state: diffDict(left.final_state ?? {}, right.final_state ?? {}), artifacts: diffSequence(fingerprints(left.artifacts ?? [], ['name', 'blob_hash', 'metadata']), fingerprints(right.artifacts ?? [], ['name', 'blob_hash', 'metadata'])), media_artifacts: diffSequence(fingerprints(left.media_artifacts ?? [], ['name', 'kind', 'uri', 'content_ref', 'blob_hash', 'lineage']), fingerprints(right.media_artifacts ?? [], ['name', 'kind', 'uri', 'content_ref', 'blob_hash', 'lineage'])), stream_checkpoints: diffSequence(fingerprints(left.stream_checkpoints ?? [], ['name', 'stream_id', 'consumer_id', 'offset', 'watermark', 'chunk', 'partial_result_ref']), fingerprints(right.stream_checkpoints ?? [], ['name', 'stream_id', 'consumer_id', 'offset', 'watermark', 'chunk', 'partial_result_ref'])), ledger: diffSequence(fingerprints(left.tool_ledger ?? [], ['tool_name', 'status', 'external_id', 'error_type', 'request_hash', 'response_hash']), fingerprints(right.tool_ledger ?? [], ['tool_name', 'status', 'external_id', 'error_type', 'request_hash', 'response_hash'])) };
1105
+ const changed_dimensions = Object.entries(dimensions).filter(([, value]) => value.changed_count > 0).map(([key]) => key);
1106
+ return { left_run_id: left.run?.run_id, right_run_id: right.run?.run_id, same: changed_dimensions.length === 0, changed_dimensions, dimensions };
1107
+ }
1108
+
1109
+
1110
+ export function debugHTML(bundle) {
1111
+ const rows = (bundle.events ?? []).map((event) => `<tr><td>${event.seq ?? ''}</td><td><code>${escapeHTML(event.type ?? '')}</code></td><td>${escapeHTML(event.step_id ?? '')}</td><td>${escapeHTML(event.agent_role ?? '')}</td></tr>`).join('\n');
1112
+ return `<!doctype html>
1113
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>AgentLedger Debug Report</title><style>body{font-family:Georgia,serif;background:#f7f1e8;color:#1f1a15;margin:0}main{max-width:1080px;margin:auto;padding:32px 20px}table{width:100%;border-collapse:collapse;background:#fffaf2}td,th{border-bottom:1px solid #ddcdbb;padding:8px;text-align:left}code,pre{font-family:ui-monospace,Menlo,monospace;background:#efe2d1;border-radius:6px;padding:2px 5px}pre{display:block;padding:12px;overflow:auto}</style></head><body><main><h1>AgentLedger Debug Report</h1><section><h2>Run</h2><p><code>${escapeHTML(bundle.run?.run_id ?? '')}</code></p></section><section><h2>Events</h2><table><thead><tr><th>Seq</th><th>Event</th><th>Step</th><th>Role</th></tr></thead><tbody>${rows}</tbody></table></section><section><h2>Final State</h2><pre>${escapeHTML(JSON.stringify(bundle.final_state ?? {}, null, 2))}</pre></section></main></body></html>\n`;
1114
+ }
1115
+
1116
+ function escapeHTML(value) {
1117
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
1118
+ }
1119
+
1120
+ export function debugSummary(bundle) {
1121
+ const state_change_count = (bundle.events ?? []).filter((event) => ['run_created', 'state_committed', 'system_state_patch_applied'].includes(event.type)).length;
1122
+ return { run_id: bundle.run?.run_id, event_count: (bundle.events ?? []).length, state_change_count, final_state: clone(bundle.final_state ?? {}) };
1123
+ }
1124
+
1125
+ function diffDict(left, right) {
1126
+ const changed = {};
1127
+ for (const key of Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).sort()) if (JSON.stringify(left[key]) !== JSON.stringify(right[key])) changed[key] = { left: left[key], right: right[key] };
1128
+ return { changed_count: Object.keys(changed).length, changed };
1129
+ }
1130
+
1131
+ function diffSequence(left, right) {
1132
+ const changed = [];
1133
+ const max = Math.max(left.length, right.length);
1134
+ for (let index = 0; index < max; index += 1) if (JSON.stringify(left[index]) !== JSON.stringify(right[index])) changed.push({ index, left: left[index] ?? null, right: right[index] ?? null });
1135
+ return { left_count: left.length, right_count: right.length, changed_count: changed.length, changed };
1136
+ }
1137
+
1138
+ function fingerprints(rows, keys) {
1139
+ return rows.map((row) => Object.fromEntries(keys.map((key) => [key, row?.[key]])));
1140
+ }
1141
+
1142
+ function hasDiffChanges(changes) {
1143
+ if (changes.bundle_hash_changed) return true;
1144
+ return Object.entries(changes).some(([key, value]) => key !== 'bundle_hash_changed' && value?.changed_count > 0);
1145
+ }
1146
+
1147
+ function spanId(prefix, seq) {
1148
+ return `${prefix}-${String(seq).padStart(6, '0')}`;
1149
+ }
1150
+
1151
+
1152
+ export function planRetention(bundle) {
1153
+ const refs = new Set();
1154
+ for (const artifact of bundle.artifacts ?? []) {
1155
+ appendBlobRefs(refs, artifact.blob_ref);
1156
+ appendBlobRefsFromAny(refs, artifact.metadata);
1157
+ }
1158
+ return { run_id: bundle.run?.run_id, event_count: (bundle.events ?? []).length, artifact_count: (bundle.artifacts ?? []).length, media_artifact_count: (bundle.media_artifacts ?? []).length, stream_checkpoint_count: (bundle.stream_checkpoints ?? []).length, protected_blob_ref_count: refs.size, ledger_count: (bundle.tool_ledger ?? []).length, estimated_event_bytes: (bundle.events ?? []).reduce((total, event) => total + JSON.stringify(event).length, 0), actions: ['export evidence bundle before destructive retention', 'snapshot final state and manifest', 'keep tool ledger and approval records until external retention policy expires', 'preserve media/stream nested blob refs until evidence export and replay validation pass', 'mark compacted runs before any physical deletion'], destructive: false };
1159
+ }
1160
+
1161
+ export function checkBackupReadiness(bundle) {
1162
+ const refs = [];
1163
+ for (const event of bundle.events ?? []) appendBlobRefList(refs, event.payload_ref);
1164
+ for (const row of bundle.tool_ledger ?? []) {
1165
+ appendBlobRefList(refs, row.request_ref);
1166
+ appendBlobRefList(refs, row.response_ref);
1167
+ }
1168
+ for (const artifact of bundle.artifacts ?? []) {
1169
+ appendBlobRefList(refs, artifact.blob_ref);
1170
+ appendBlobRefsFromAnyList(refs, artifact.metadata);
1171
+ }
1172
+ const checks = [
1173
+ { name: 'run_metadata_exists', passed: Boolean(bundle.run?.run_id), detail: 'run row is present' },
1174
+ { name: 'payload_refs_resolvable', passed: true, detail: `checked=${refs.length}, missing=0` },
1175
+ { name: 'evidence_exportable', passed: bundle.schema_version === 'agentledger.evidence.v1', detail: 'evidence bundle can be constructed' },
1176
+ { name: 'media_stream_evidence_shape', passed: mediaStreamShapeOK(bundle), detail: 'media artifacts and stream checkpoints have required refs/cursors' },
1177
+ ];
1178
+ return { run_id: bundle.run?.run_id, passed: checks.every((check) => check.passed), checks, refs_checked: refs.length, missing_refs: [] };
1179
+ }
1180
+
1181
+ function mediaStreamShapeOK(bundle) {
1182
+ return (bundle.media_artifacts ?? []).every((row) => row.kind && (row.uri || row.content_ref || row.blob_ref)) && (bundle.stream_checkpoints ?? []).every((row) => row.stream_id && row.consumer_id && row.offset !== undefined && row.offset !== null);
1183
+ }
1184
+
1185
+ function appendBlobRefs(refs, value) {
1186
+ if (typeof value === 'string' && value.startsWith('blob://')) refs.add(value);
1187
+ }
1188
+
1189
+ function appendBlobRefsFromAny(refs, value) {
1190
+ if (Array.isArray(value)) for (const item of value) appendBlobRefsFromAny(refs, item);
1191
+ else if (isPlainObject(value)) for (const item of Object.values(value)) appendBlobRefsFromAny(refs, item);
1192
+ else appendBlobRefs(refs, value);
1193
+ }
1194
+
1195
+ function appendBlobRefList(refs, value) {
1196
+ if (typeof value === 'string' && value.startsWith('blob://')) refs.push(value);
1197
+ }
1198
+
1199
+ function appendBlobRefsFromAnyList(refs, value) {
1200
+ if (Array.isArray(value)) for (const item of value) appendBlobRefsFromAnyList(refs, item);
1201
+ else if (isPlainObject(value)) for (const item of Object.values(value)) appendBlobRefsFromAnyList(refs, item);
1202
+ else appendBlobRefList(refs, value);
1203
+ }
1204
+
1205
+ export function costAttribution(store, runId) {
1206
+ const stepRoles = new Map();
1207
+ for (const event of store.events(runId)) if (event.step_id && event.agent_role) stepRoles.set(event.step_id, event.agent_role);
1208
+ const report = { run_id: runId, total: emptySummary(), by_agent: {}, by_step: {}, by_category: {}, by_name: {} };
1209
+ for (const record of store.costRecords(runId)) {
1210
+ addCost(report.total, record);
1211
+ const agent = stepRoles.get(record.step_id) ?? '<unknown>';
1212
+ report.by_agent[agent] ??= emptySummary();
1213
+ addCost(report.by_agent[agent], record);
1214
+ const step = record.step_id ?? '<run>';
1215
+ report.by_step[step] ??= emptySummary();
1216
+ addCost(report.by_step[step], record);
1217
+ report.by_name[record.name] ??= emptySummary();
1218
+ addCost(report.by_name[record.name], record);
1219
+ report.by_category[record.category] ??= {};
1220
+ report.by_category[record.category][record.unit] = (report.by_category[record.category][record.unit] ?? 0) + record.amount;
1221
+ }
1222
+ return report;
1223
+ }
1224
+
1225
+ export function failureAttribution(store, runId) {
1226
+ const run = store.run(runId);
1227
+ const failedSteps = store.steps(runId).filter((step) => step.status === 'failed');
1228
+ const pendingVerification = store.ledger(runId).filter((entry) => entry.status === 'PENDING_VERIFICATION');
1229
+ const pendingApprovals = store.approvalRequests(runId).filter((entry) => entry.status === 'PENDING');
1230
+ const failureEvents = store.events(runId).filter((event) => failureEventTypes.has(event.type));
1231
+ return { run_id: runId, run_status: run.status, failed_steps: failedSteps, pending_verification: pendingVerification, pending_approvals: pendingApprovals, failure_events: failureEvents, summary: { failed_step_count: failedSteps.length, pending_verification_count: pendingVerification.length, pending_approval_count: pendingApprovals.length, failure_event_count: failureEvents.length } };
1232
+ }
1233
+
1234
+ function emptyData() {
1235
+ return { runs: {}, steps: {}, events: {}, tool_ledger: {}, approval_requests: {}, cost_records: {}, artifacts: {} };
1236
+ }
1237
+
1238
+ function normalizeData(data) {
1239
+ data.runs ??= {};
1240
+ data.steps ??= {};
1241
+ data.events ??= {};
1242
+ data.tool_ledger ??= {};
1243
+ data.approval_requests ??= {};
1244
+ data.cost_records ??= {};
1245
+ data.artifacts ??= {};
1246
+ }
1247
+
1248
+ function nowSeconds() {
1249
+ return Date.now() / 1000;
1250
+ }
1251
+
1252
+ function newId(prefix) {
1253
+ return `${prefix}_${randomBytes(12).toString('hex')}`;
1254
+ }
1255
+
1256
+ function sha256JSON(value) {
1257
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
1258
+ }
1259
+
1260
+ function clone(value) {
1261
+ if (value === undefined) return undefined;
1262
+ return JSON.parse(JSON.stringify(value));
1263
+ }
1264
+
1265
+ function mergePatch(base, patch) {
1266
+ const out = clone(base ?? {});
1267
+ for (const [key, value] of Object.entries(patch ?? {})) {
1268
+ if (value === null) {
1269
+ delete out[key];
1270
+ continue;
1271
+ }
1272
+ if (isPlainObject(value) && isPlainObject(out[key])) {
1273
+ out[key] = mergePatch(out[key], value);
1274
+ continue;
1275
+ }
1276
+ out[key] = clone(value);
1277
+ }
1278
+ return out;
1279
+ }
1280
+
1281
+ function isPlainObject(value) {
1282
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1283
+ }
1284
+
1285
+ function compactObject(value) {
1286
+ const out = {};
1287
+ for (const [key, item] of Object.entries(value ?? {})) {
1288
+ if (item === undefined || item === null || item === '') continue;
1289
+ if (isPlainObject(item) && Object.keys(item).length === 0) continue;
1290
+ out[key] = clone(item);
1291
+ }
1292
+ return out;
1293
+ }
1294
+
1295
+ function normalizeStreamChunk(chunk) {
1296
+ if (!chunk) return {};
1297
+ return compactObject({
1298
+ schema_version: STREAM_SCHEMA_VERSION,
1299
+ stream_id: chunk.streamId ?? chunk.stream_id,
1300
+ chunk_id: chunk.chunkId ?? chunk.chunk_id,
1301
+ offset: chunk.offset,
1302
+ content_ref: chunk.contentRef ?? chunk.content_ref,
1303
+ content_hash: chunk.contentHash ?? chunk.content_hash,
1304
+ sequence: chunk.sequence,
1305
+ event_time: chunk.eventTime ?? chunk.event_time,
1306
+ metadata: clone(chunk.metadata ?? {}),
1307
+ });
1308
+ }
1309
+
1310
+ function mediaArtifactsFrom(artifacts) {
1311
+ return artifacts.flatMap((artifact) => {
1312
+ const metadata = artifact.metadata?.agentledger_media;
1313
+ if (!metadata) return [];
1314
+ return [compactObject({ artifact_id: artifact.artifact_id, name: artifact.name, blob_hash: artifact.blob_hash, blob_ref: artifact.blob_ref, kind: metadata.kind, uri: metadata.uri, content_ref: metadata.content_ref, metadata: metadata.metadata ?? {}, lineage: metadata.lineage ?? {} })];
1315
+ });
1316
+ }
1317
+
1318
+ function streamCheckpointsFrom(artifacts) {
1319
+ return artifacts.flatMap((artifact) => {
1320
+ const metadata = artifact.metadata?.agentledger_stream;
1321
+ if (!metadata) return [];
1322
+ return [compactObject({ artifact_id: artifact.artifact_id, name: artifact.name, blob_hash: artifact.blob_hash, blob_ref: artifact.blob_ref, stream_id: metadata.stream_id, consumer_id: metadata.consumer_id, offset: metadata.offset, watermark: metadata.watermark, chunk: metadata.chunk ?? {}, partial_result_ref: metadata.partial_result_ref, backpressure: metadata.backpressure ?? {} })];
1323
+ });
1324
+ }
1325
+
1326
+ function emptySummary() {
1327
+ return { tool_calls: 0, model_tokens: 0, total_usd: 0, by_category: {} };
1328
+ }
1329
+
1330
+ function addCost(summary, record) {
1331
+ if (['tool', 'tool_shadow'].includes(record.category) && record.unit === 'call') summary.tool_calls += record.amount;
1332
+ if (record.category === 'model' && record.unit === 'token') summary.model_tokens += record.amount;
1333
+ if (record.unit === 'usd') summary.total_usd += record.amount;
1334
+ const key = `${record.category}:${record.unit}`;
1335
+ summary.by_category[key] = (summary.by_category[key] ?? 0) + record.amount;
1336
+ }
1337
+
1338
+ function failureSource(errorType) {
1339
+ if (errorType === 'BudgetExceededError') return 'budget';
1340
+ if (['PermissionDeniedError', 'ApprovalDenied'].includes(errorType)) return 'policy';
1341
+ if (errorType === 'SandboxUnavailableError') return 'sandbox';
1342
+ return 'agent';
1343
+ }
1344
+
1345
+ const failureEventTypes = new Set(['failure_classified', 'error_raised', 'step_failed', 'step_retry_scheduled', 'step_waiting_human', 'lease_expired', 'run_cancel_requested', 'run_cancelled', 'tool_call_failed', 'tool_approval_required', 'budget_check_failed']);
1346
+
1347
+ const MEDIA_SCHEMA_VERSION = 'agentledger.media.v0';
1348
+ const STREAM_SCHEMA_VERSION = 'agentledger.stream.v0';
1349
+ const MEDIA_KINDS = new Set(['image', 'audio', 'video', 'frame', 'audio_segment', 'video_segment', 'transcript', 'embedding', 'derived']);
1350
+
1351
+ export function migrationsFor(dialect) {
1352
+ const normalized = String(dialect).toLowerCase();
1353
+ if (normalized === 'sqlite') return [{ version: '0001', name: 'initial_runtime_metadata', dialect: 'sqlite', sql: SQLITE_INITIAL_DDL, checksum: migrationChecksum(SQLITE_INITIAL_DDL) }];
1354
+ if (normalized === 'postgres' || normalized === 'postgresql') return [{ version: '0001', name: 'initial_runtime_metadata', dialect: 'postgres', sql: POSTGRES_INITIAL_DDL, checksum: migrationChecksum(POSTGRES_INITIAL_DDL) }];
1355
+ throw new Error(`unsupported storage dialect: ${dialect}`);
1356
+ }
1357
+
1358
+ export function latestSchemaVersion(dialect) {
1359
+ const migrations = migrationsFor(dialect);
1360
+ return migrations.length ? migrations[migrations.length - 1].version : null;
1361
+ }
1362
+
1363
+ export function ddlFor(dialect) {
1364
+ const normalized = String(dialect).toLowerCase();
1365
+ const header = normalized === 'postgres' || normalized === 'postgresql' ? SCHEMA_MIGRATIONS_POSTGRES : SCHEMA_MIGRATIONS_SQLITE;
1366
+ return [header, ...migrationsFor(dialect).map((migration) => migration.sql)].join('\n\n');
1367
+ }
1368
+
1369
+ function migrationChecksum(sql) {
1370
+ return `sha256:${createHash('sha256').update(sql).digest('hex')}`;
1371
+ }
1372
+
1373
+ const SCHEMA_MIGRATIONS_SQLITE = `CREATE TABLE IF NOT EXISTS schema_migrations (
1374
+ version TEXT PRIMARY KEY,
1375
+ name TEXT NOT NULL,
1376
+ checksum TEXT NOT NULL,
1377
+ applied_at REAL NOT NULL
1378
+ );`;
1379
+
1380
+ const SCHEMA_MIGRATIONS_POSTGRES = `CREATE TABLE IF NOT EXISTS schema_migrations (
1381
+ version TEXT PRIMARY KEY,
1382
+ name TEXT NOT NULL,
1383
+ checksum TEXT NOT NULL,
1384
+ applied_at DOUBLE PRECISION NOT NULL
1385
+ );`;
1386
+
1387
+ const SQLITE_INITIAL_DDL = `CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, status TEXT NOT NULL, state_json TEXT NOT NULL, state_version INTEGER NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL);
1388
+ CREATE TABLE IF NOT EXISTS steps (step_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT NOT NULL, status TEXT NOT NULL, owner TEXT, lease_token TEXT, lease_until REAL, attempt INTEGER NOT NULL, state_version INTEGER NOT NULL, checkpoint_id TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL);
1389
+ CREATE TABLE IF NOT EXISTS events (event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT, seq INTEGER NOT NULL, type TEXT NOT NULL, timestamp REAL NOT NULL, agent_role TEXT, state_version INTEGER, causal_token TEXT, payload_hash TEXT, payload_ref TEXT);
1390
+ CREATE TABLE IF NOT EXISTS tool_ledger (ledger_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT NOT NULL, tool_name TEXT NOT NULL, tool_version TEXT NOT NULL, tool_call_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, causal_token TEXT NOT NULL, request_hash TEXT NOT NULL, request_ref TEXT NOT NULL, status TEXT NOT NULL, external_id TEXT, response_hash TEXT, response_ref TEXT, error_type TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL);`;
1391
+
1392
+ const POSTGRES_INITIAL_DDL = `CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, status TEXT NOT NULL, state_json JSONB NOT NULL, state_version BIGINT NOT NULL, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);
1393
+ CREATE TABLE IF NOT EXISTS steps (step_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(run_id), session_id TEXT NOT NULL, status TEXT NOT NULL, owner TEXT, lease_token TEXT, lease_until DOUBLE PRECISION, attempt BIGINT NOT NULL, state_version BIGINT NOT NULL, checkpoint_id TEXT, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);
1394
+ CREATE TABLE IF NOT EXISTS events (event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT, seq BIGINT NOT NULL, type TEXT NOT NULL, timestamp DOUBLE PRECISION NOT NULL, agent_role TEXT, state_version BIGINT, causal_token TEXT, payload_hash TEXT, payload_ref TEXT, UNIQUE(run_id, seq));
1395
+ CREATE TABLE IF NOT EXISTS tool_ledger (ledger_id TEXT PRIMARY KEY, run_id TEXT NOT NULL, session_id TEXT, step_id TEXT NOT NULL, tool_name TEXT NOT NULL, tool_version TEXT NOT NULL, tool_call_id TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, causal_token TEXT NOT NULL, request_hash TEXT NOT NULL, request_ref TEXT NOT NULL, status TEXT NOT NULL, external_id TEXT, response_hash TEXT, response_ref TEXT, error_type TEXT, created_at DOUBLE PRECISION NOT NULL, updated_at DOUBLE PRECISION NOT NULL);`;
1396
+
1397
+ export class InMemoryMCPToolServer {
1398
+ constructor() { this.tools = new Map(); }
1399
+ addTool(descriptor, handler) { this.tools.set(descriptor.name, { descriptor, handler }); }
1400
+ listTools() { return [...this.tools.keys()].sort().map((name) => this.tools.get(name).descriptor); }
1401
+ callTool(name, args = {}) {
1402
+ const entry = this.tools.get(name);
1403
+ if (!entry) throw new Error(`MCP tool not found: ${name}`);
1404
+ return entry.handler(name, args);
1405
+ }
1406
+ }
1407
+
1408
+ export class InMemoryMCPContextServer {
1409
+ constructor() { this.resources = new Map(); }
1410
+ addResource({ uri, name, reader, mimeType = 'application/json' }) { this.resources.set(uri, { descriptor: { uri, name, mimeType }, reader }); }
1411
+ listResources() { return [...this.resources.keys()].sort().map((uri) => this.resources.get(uri).descriptor); }
1412
+ readResource(uri) {
1413
+ const entry = this.resources.get(uri);
1414
+ if (!entry) throw new Error(`MCP resource not found: ${uri}`);
1415
+ return { resource: entry.descriptor, content: entry.reader(uri) };
1416
+ }
1417
+ }
1418
+
1419
+ export class MCPToolAdapter {
1420
+ constructor(clientCall) { this.clientCall = clientCall; }
1421
+ toolSpecFromDescriptor(descriptor) {
1422
+ const annotations = descriptor.annotations ?? {};
1423
+ const sideEffect = annotations.side_effect ?? 'none';
1424
+ const riskLevel = annotations.risk_level ?? 'low';
1425
+ const idempotencyRequired = annotations.idempotency_required ?? sideEffect !== 'none';
1426
+ const name = descriptor.name;
1427
+ return { name, version: String(descriptor.version ?? 'v1'), inputSchema: descriptor.inputSchema ?? descriptor.input_schema ?? {}, outputSchema: descriptor.outputSchema ?? descriptor.output_schema ?? {}, sideEffect, riskLevel, idempotencyRequired, func: (args) => this.clientCall(name, args) };
1428
+ }
1429
+ }
1430
+
1431
+ export class MCPContextAdapter {
1432
+ constructor(resourceRead) { this.resourceRead = resourceRead; }
1433
+ readToolSpec({ name = 'mcp.context.read', riskLevel = 'low' } = {}) {
1434
+ return { name, version: 'v1', description: 'Read an MCP-style context resource by URI.', inputSchema: { type: 'object', required: ['uri'], properties: { uri: { type: 'string', minLength: 1 } }, additionalProperties: false }, outputSchema: { type: 'object' }, sideEffect: 'none', riskLevel, func: (args) => this.resourceRead(args.uri) };
1435
+ }
1436
+ }
1437
+
1438
+ export class FunctionAdapter {
1439
+ constructor(func, { role = 'Agent', name = 'function' } = {}) { this.func = func; this.role = role; this.name = name; }
1440
+ mapRunSpec() { return { adapter: this.name, role: this.role }; }
1441
+ asAgent({ outputKey = 'output' } = {}) { return async (ctx, state) => { const result = await this.func(ctx, state); if (outputKey && result !== undefined) await ctx.writeState(outputKey, result); }; }
1442
+ }
1443
+
1444
+ export class MethodFrameworkAdapter {
1445
+ constructor(target, { role = 'FrameworkAgent', methodCandidates = [], outputKey = 'output' } = {}) { this.target = target; this.role = role; this.methodCandidates = methodCandidates; this.outputKey = outputKey; }
1446
+ mapRunSpec() { return { adapter: 'method-framework', role: this.role, target: this.target?.constructor?.name ?? 'Object', methods: this.methodCandidates }; }
1447
+ asAgent() { return async (ctx, state) => { const result = await this.invoke(state); if (this.outputKey) await ctx.writeState(this.outputKey, result); }; }
1448
+ async invoke(payload) {
1449
+ for (const name of this.methodCandidates) if (typeof this.target?.[name] === 'function') return this.target[name](payload);
1450
+ if (typeof this.target === 'function') return this.target(payload);
1451
+ throw new Error(`target does not expose any of ${JSON.stringify(this.methodCandidates)}`);
1452
+ }
1453
+ }
1454
+
1455
+ export const IGNORE_SAME_LINE = 'agentledger: ignore-boundary';
1456
+ export const IGNORE_NEXT_LINE = 'agentledger: ignore-next-line';
1457
+
1458
+ export function defaultBoundaryRules() {
1459
+ return [
1460
+ { rule_id: 'direct-shell-os-system', pattern: 'os.system', category: 'shell', message: 'direct shell execution bypasses ToolGateway, policy, ledger, sandbox, and audit', suggestion: "wrap shell execution as a runtime-managed tool and call ctx.callTool('shell.exec', args)" },
1461
+ { rule_id: 'direct-shell-subprocess', pattern: 'subprocess.', category: 'shell', message: 'direct subprocess execution bypasses ToolGateway, policy, ledger, sandbox, and audit', suggestion: "wrap command execution as a runtime-managed tool and call ctx.callTool('shell.exec', args)", prefix: true },
1462
+ { rule_id: 'direct-http-requests', pattern: 'requests.', category: 'network', message: 'direct HTTP calls bypass ToolGateway, policy, ledger, budget, replay, and audit', suggestion: 'register the HTTP/API call as a runtime-managed tool and call ctx.callTool(...)', prefix: true },
1463
+ { rule_id: 'direct-http-httpx', pattern: 'httpx.', category: 'network', message: 'direct HTTP calls bypass ToolGateway, policy, ledger, budget, replay, and audit', suggestion: 'register the HTTP/API call as a runtime-managed tool and call ctx.callTool(...)', prefix: true },
1464
+ { rule_id: 'direct-openai-sdk', pattern: 'openai.', category: 'model', message: 'direct model SDK usage bypasses model provider archives, replay, budget, and attribution', suggestion: 'call models through the runtime model boundary', prefix: true },
1465
+ { rule_id: 'direct-anthropic-sdk', pattern: 'anthropic.', category: 'model', message: 'direct model SDK usage bypasses model provider archives, replay, budget, and attribution', suggestion: 'call models through the runtime model boundary', prefix: true },
1466
+ ];
1467
+ }
1468
+
1469
+ export function scanBoundarySource(path, source, rules = defaultBoundaryRules()) {
1470
+ const findings = [];
1471
+ const lines = String(source).split('\n');
1472
+ for (let i = 0; i < lines.length; i += 1) {
1473
+ const line = lines[i];
1474
+ const previous = i > 0 ? lines[i - 1] : '';
1475
+ if (line.includes(IGNORE_SAME_LINE) || previous.includes(IGNORE_NEXT_LINE)) continue;
1476
+ for (const rule of rules) {
1477
+ const index = line.indexOf(rule.pattern);
1478
+ if (index < 0) continue;
1479
+ let callee = rule.pattern;
1480
+ if (rule.prefix) {
1481
+ let end = index + rule.pattern.length;
1482
+ while (end < line.length && /[A-Za-z0-9_.]/.test(line[end])) end += 1;
1483
+ callee = line.slice(index, end);
1484
+ }
1485
+ findings.push({ path, line: i + 1, column: index + 1, rule_id: rule.rule_id, severity: 'error', callee, category: rule.category, message: rule.message, suggestion: rule.suggestion });
1486
+ break;
1487
+ }
1488
+ }
1489
+ return { passed: findings.length === 0, scanned_files: [path], finding_count: findings.length, findings };
1490
+ }
1491
+
1492
+ export class RuntimeScheduler {
1493
+ constructor(store) { this.store = store; }
1494
+ async recoverExpiredLeases() { return { recovered_steps: await this.store.recoverExpiredLeases() }; }
1495
+ async cancelRun(runId, reason = 'cancelled by scheduler') { return this.store.cancelRun(runId, reason); }
1496
+ status(runId) {
1497
+ const run = this.store.run(runId);
1498
+ const steps = this.store.steps(runId).map((step) => ({
1499
+ step_id: step.step_id,
1500
+ status: step.status,
1501
+ owner: step.owner ?? null,
1502
+ attempt: step.attempt,
1503
+ lease_until: step.lease_until ?? null,
1504
+ last_error_type: step.last_error_type ?? null,
1505
+ }));
1506
+ return { run_id: runId, run_status: run.status, state_version: run.state_version, steps, cost_summary: this.store.costSummary(runId) };
1507
+ }
1508
+ }
1509
+
1510
+ export function adversarialReview(bundle, { maxTotalUsd = null } = {}) {
1511
+ const summary = bundle.summary ?? {};
1512
+ const checks = [
1513
+ { name: 'no_failed_steps', passed: !summary.has_failed_steps, severity: 'blocker', detail: 'no step is in failed status' },
1514
+ { name: 'no_pending_verification', passed: !summary.has_pending_verification && !summary.has_pending_ledger, severity: 'blocker', detail: 'no side effect is pending verification' },
1515
+ { name: 'no_pending_approvals', passed: !summary.has_pending_approvals && !summary.has_pending_approval, severity: 'blocker', detail: 'no approval request is still pending' },
1516
+ { name: 'completed_steps_have_completion_events', passed: completedStepsHaveEvents(bundle.steps ?? [], bundle.events ?? []), severity: 'blocker', detail: 'completed steps have step_completed events' },
1517
+ { name: 'ledger_statuses_known', passed: ledgerStatusesKnown(bundle.tool_ledger ?? []), severity: 'blocker', detail: 'Tool Ledger rows use known statuses' },
1518
+ { name: 'event_sequence_contiguous', passed: eventSequenceContiguous(bundle.events ?? []), severity: 'blocker', detail: 'event sequence has no gaps' },
1519
+ { name: 'artifacts_have_blob_refs', passed: (bundle.artifacts ?? []).every((row) => row.blob_ref && row.blob_hash), severity: 'warning', detail: 'artifacts have blob refs and hashes' },
1520
+ { name: 'media_artifacts_have_refs', passed: (bundle.media_artifacts ?? []).every((row) => row.kind && (row.uri || row.content_ref || row.blob_ref)), severity: 'blocker', detail: 'media artifacts have kind and durable refs' },
1521
+ { name: 'stream_checkpoints_have_offsets', passed: (bundle.stream_checkpoints ?? []).every((row) => row.stream_id && row.consumer_id && row.offset !== undefined && row.offset !== null), severity: 'blocker', detail: 'stream checkpoints have stream, consumer, and offset' },
1522
+ { name: 'high_risk_approvals_decided', passed: highRiskApprovalsDecided(bundle.approvals ?? bundle.approval_requests ?? []), severity: 'blocker', detail: 'high-risk approval requests are decided' },
1523
+ { name: 'no_blocking_failure_events', passed: !(bundle.events ?? []).some((event) => ['error_raised', 'step_failed', 'tool_call_failed', 'tool_call_blocked'].includes(event.type)), severity: 'warning', detail: 'no blocking failure events are present' },
1524
+ ];
1525
+ if (maxTotalUsd !== null && maxTotalUsd !== undefined) {
1526
+ const total = Number(bundle.cost_summary?.total_usd ?? summary.cost_summary?.total_usd ?? 0);
1527
+ checks.push({ name: 'max_total_usd', passed: total <= maxTotalUsd, severity: 'blocker', detail: `total_usd=${total}, limit=${maxTotalUsd}` });
1528
+ }
1529
+ return {
1530
+ passed: checks.every((check) => check.severity !== 'blocker' || check.passed),
1531
+ run_id: bundle.run?.run_id ?? null,
1532
+ checks,
1533
+ metadata: { event_count: (bundle.events ?? []).length, step_count: (bundle.steps ?? []).length, tool_ledger_count: (bundle.tool_ledger ?? []).length, approval_count: (bundle.approvals ?? bundle.approval_requests ?? []).length, artifact_count: (bundle.artifacts ?? []).length, media_artifact_count: (bundle.media_artifacts ?? []).length, stream_checkpoint_count: (bundle.stream_checkpoints ?? []).length, cost_summary: bundle.cost_summary ?? summary.cost_summary ?? {} },
1534
+ };
1535
+ }
1536
+
1537
+ function completedStepsHaveEvents(steps, events) {
1538
+ const completed = new Set(events.filter((event) => event.type === 'step_completed').map((event) => event.step_id));
1539
+ return steps.every((step) => step.status !== 'completed' || completed.has(step.step_id));
1540
+ }
1541
+ function ledgerStatusesKnown(rows) { return rows.every((row) => ['SUCCEEDED', 'FAILED_NO_EFFECT', 'PENDING_VERIFICATION', 'COMPENSATED', 'RUNNING', 'RESERVED'].includes(row.status)); }
1542
+ function eventSequenceContiguous(events) { return events.every((event, index) => event.seq === index + 1); }
1543
+ function highRiskApprovalsDecided(rows) { return rows.every((row) => !['high', 'destructive', 'sensitive'].includes(row.risk_level) || ['APPROVED', 'DENIED'].includes(row.status)); }
1544
+
1545
+ export function evaluateEvidence(bundle, { maxTotalUsd = null } = {}) {
1546
+ const summary = bundle.summary ?? {};
1547
+ const checks = [
1548
+ { name: 'no_failed_steps', passed: !summary.has_failed_steps, detail: 'all steps completed or remain non-failed' },
1549
+ { name: 'no_pending_verification', passed: !summary.has_pending_verification && !summary.has_pending_ledger, detail: 'no side effect is waiting for human/external verification' },
1550
+ { name: 'completed_steps_have_events', passed: completedStepsHaveEvents(bundle.steps ?? [], bundle.events ?? []), detail: 'each completed step has a step_completed event' },
1551
+ { name: 'managed_side_effects_are_ledgered', passed: ledgerStatusesKnown(bundle.tool_ledger ?? []), detail: 'every ledger row has a known status' },
1552
+ { name: 'media_artifacts_have_refs', passed: (bundle.media_artifacts ?? []).every((row) => row.kind && (row.uri || row.content_ref || row.blob_ref)), detail: 'media artifacts have kind and durable refs' },
1553
+ { name: 'stream_checkpoints_have_offsets', passed: (bundle.stream_checkpoints ?? []).every((row) => row.stream_id && row.consumer_id && row.offset !== undefined && row.offset !== null), detail: 'stream checkpoints have stream, consumer, and offset' },
1554
+ ];
1555
+ if (maxTotalUsd !== null && maxTotalUsd !== undefined) {
1556
+ const total = Number(bundle.cost_summary?.total_usd ?? summary.cost_summary?.total_usd ?? 0);
1557
+ checks.push({ name: 'max_total_usd', passed: total <= maxTotalUsd, detail: `total_usd=${total}, limit=${maxTotalUsd}` });
1558
+ }
1559
+ return { passed: checks.every((check) => check.passed), checks, metadata: {} };
1560
+ }
1561
+
1562
+ export function evaluateEvidenceRegression(golden, current, { maxTotalUsdDelta = null } = {}) {
1563
+ const diff = diffEvidence(golden, current);
1564
+ const checks = [
1565
+ { name: 'final_state_regression', passed: diff.changes.final_state.changed_count === 0, detail: `changed_final_state_keys=${diff.changes.final_state.changed_count}` },
1566
+ { name: 'event_type_regression', passed: diff.changes.event_types.changed_count === 0, detail: `changed_event_type_positions=${diff.changes.event_types.changed_count}` },
1567
+ { name: 'tool_ledger_status_regression', passed: diff.changes.tool_ledger.changed_count === 0, detail: `changed_ledger_status_positions=${diff.changes.tool_ledger.changed_count}` },
1568
+ { name: 'media_artifact_regression', passed: diff.changes.media_artifacts.changed_count === 0, detail: `changed_media_artifacts=${diff.changes.media_artifacts.changed_count}` },
1569
+ { name: 'stream_checkpoint_regression', passed: diff.changes.stream_checkpoints.changed_count === 0, detail: `changed_stream_checkpoints=${diff.changes.stream_checkpoints.changed_count}` },
1570
+ ];
1571
+ if (maxTotalUsdDelta !== null && maxTotalUsdDelta !== undefined) {
1572
+ const left = Number(golden.cost_summary?.total_usd ?? golden.summary?.cost_summary?.total_usd ?? 0);
1573
+ const right = Number(current.cost_summary?.total_usd ?? current.summary?.cost_summary?.total_usd ?? 0);
1574
+ const delta = right - left;
1575
+ checks.push({ name: 'max_total_usd_delta', passed: delta <= maxTotalUsdDelta, detail: `total_usd_delta=${delta}, limit=${maxTotalUsdDelta}` });
1576
+ }
1577
+ return { passed: checks.every((check) => check.passed), checks, metadata: { diff } };
1578
+ }
1579
+
1580
+ export async function runFailureInjectionSuite() {
1581
+ const checks = [];
1582
+ checks.push(await failureRetryExhaustion());
1583
+ checks.push(await failureLeaseFencing());
1584
+ checks.push(await failureCancellationFencing());
1585
+ checks.push(await failureSideEffectIdempotency());
1586
+ return { passed: checks.every((check) => check.passed), checks };
1587
+ }
1588
+ async function failureRetryExhaustion() { const rt = new Runtime(JSONStore.memory()); const { runId } = await rt.createRun({}); await rt.runOnce({ runId, workerId: 'retry-1', agentRole: 'FailureInjector', agent: async () => { throw new RetryableAgentError('retry'); } }); try { await rt.runOnce({ runId, workerId: 'retry-2', agentRole: 'FailureInjector', agent: async () => { throw new Error('final failure'); } }); } catch {} const status = rt.store.run(runId).status; return { name: 'retry_exhaustion', passed: status === 'failed', detail: `run_status=${status}`, run_id: runId }; }
1589
+ async function failureLeaseFencing() { const store = JSONStore.memory(); const { runId, stepId } = await store.createRun({}); const claim = await store.claimStep({ workerId: 'stale-worker', runId, leaseSeconds: 0 }); const recovered = await store.recoverExpiredLeases(); let staleRejected = false; try { await store.commitStatePatch({ runId, stepId, leaseToken: claim.lease_token, baseVersion: 0, patch: { stale: true } }); } catch { staleRejected = true; } const fresh = await store.claimStep({ workerId: 'fresh-worker', runId }); const passed = recovered === 1 && staleRejected && fresh?.attempt === 2; return { name: 'lease_fencing', passed, detail: `recovered_steps=${recovered} stale_rejected=${staleRejected}`, run_id: runId }; }
1590
+ async function failureCancellationFencing() { const store = JSONStore.memory(); const { runId, stepId } = await store.createRun({}); const claim = await store.claimStep({ workerId: 'stale-worker', runId }); const cancelled = await store.cancelRun(runId, 'failure injection'); let staleRejected = false; try { await store.commitStatePatch({ runId, stepId, leaseToken: claim.lease_token, baseVersion: 0, patch: { late: true } }); } catch { staleRejected = true; } let fresh = null; try { fresh = await store.claimStep({ workerId: 'fresh-worker', runId }); } catch {} const passed = cancelled === 1 && staleRejected && fresh === null && store.run(runId).status === 'cancelled'; return { name: 'cancellation_fencing', passed, detail: `cancelled_steps=${cancelled} stale_rejected=${staleRejected}`, run_id: runId }; }
1591
+ async function failureSideEffectIdempotency() { const rt = new Runtime(JSONStore.memory()); let calls = 0; rt.registerTool({ name: 'external.create', version: 'v1', sideEffect: 'external', func: async () => { calls += 1; return { id: 'EXT-1' }; } }); const { runId } = await rt.createRun({}); const agent = async (ctx) => { await ctx.callTool('external.create', { title: 'once' }); }; await rt.runOnce({ runId, workerId: 'worker-1', agentRole: 'FailureInjector', agent }); try { await rt.runOnce({ runId, workerId: 'worker-2', agentRole: 'FailureInjector', agent }); } catch {} return { name: 'side_effect_idempotency', passed: calls === 1, detail: `external_call_count=${calls}`, run_id: runId }; }
1592
+
1593
+ export function diffStates(source, shadow) {
1594
+ const changed = {};
1595
+ for (const key of Array.from(new Set([...Object.keys(source ?? {}), ...Object.keys(shadow ?? {})])).sort()) {
1596
+ if (JSON.stringify(source?.[key]) !== JSON.stringify(shadow?.[key])) changed[key] = { source: source?.[key], shadow: shadow?.[key] };
1597
+ }
1598
+ return { changed, changed_count: Object.keys(changed).length };
1599
+ }
1600
+
1601
+ export function shadowReport(sourceRunId, shadowRunId, ok, sourceState, shadowState) {
1602
+ return { source_run_id: sourceRunId, shadow_run_id: shadowRunId, ok, state_diff: diffStates(sourceState, shadowState) };
1603
+ }
1604
+
1605
+ export function builtinGoldenNames() { return ['media-stream-checkpoint', 'minimal-success', 'tool-ledger-success']; }
1606
+ export async function builtinGoldenEvidence(name) {
1607
+ if (name === 'minimal-success') return goldenMinimalSuccess();
1608
+ if (name === 'tool-ledger-success') return goldenToolLedgerSuccess();
1609
+ if (name === 'media-stream-checkpoint') return goldenMediaStreamCheckpoint();
1610
+ throw new Error(`unknown built-in golden case: ${name}`);
1611
+ }
1612
+ export function goldenRegression(golden, current) { return evaluateEvidenceRegression(golden, current); }
1613
+ async function goldenMinimalSuccess() { const rt = new Runtime(JSONStore.memory()); const { runId } = await rt.createRun({}); await rt.runOnce({ runId, workerId: 'golden-worker', agentRole: 'GoldenAgent', agent: async (ctx) => ctx.writeState('answer', 'ok') }); return exportEvidence(rt.store, runId); }
1614
+ async function goldenToolLedgerSuccess() { const rt = new Runtime(JSONStore.memory()); rt.registerTool({ name: 'github.create_issue', version: 'v1', sideEffect: 'external', func: async () => ({ issue_id: 'ISSUE-1' }) }); const { runId } = await rt.createRun({}); await rt.runOnce({ runId, workerId: 'golden-worker', agentRole: 'ExecutorAgent', agent: async (ctx) => { const result = await ctx.callTool('github.create_issue', { title: 'golden' }); await ctx.writeState('issue_id', result.issue_id); } }); return exportEvidence(rt.store, runId); }
1615
+ async function goldenMediaStreamCheckpoint() { const rt = new Runtime(JSONStore.memory()); const { runId } = await rt.createRun({}); await rt.runOnce({ runId, workerId: 'golden-worker', agentRole: 'MediaAgent', agent: async (ctx) => { await ctx.createMediaArtifact('golden-video-frame', 'frame', { uri: 'file://golden-frame.jpg' }); await ctx.createStreamCheckpoint('golden-stream-checkpoint', 'stream-golden', 'consumer-golden', 42, {}); await ctx.writeState('processed_offset', 42); } }); return exportEvidence(rt.store, runId); }
1616
+
1617
+ export function timeTravel(bundle, { atSeq = 0, includeStates = false } = {}) {
1618
+ let state = {};
1619
+ let stateAtSeq = {};
1620
+ const timeline = [];
1621
+ let selectedEvent = null;
1622
+ for (const event of bundle.events ?? []) {
1623
+ const before = structuredClone(state);
1624
+ const patch = patchForTimeTravelEvent(event);
1625
+ if (patch) Object.assign(state, patch);
1626
+ const diff = diffDict(before, state);
1627
+ const frame = { seq: event.seq ?? timeline.length + 1, event_id: event.event_id, type: event.type, step_id: event.step_id ?? null, agent_role: event.agent_role ?? null, state_version: event.state_version ?? null, timestamp: event.timestamp ?? 0, state_changed: diff.changed_count > 0, changed_keys: Object.keys(diff.changed).sort(), patch };
1628
+ if (includeStates) frame.state_after = structuredClone(state);
1629
+ timeline.push(frame);
1630
+ if (atSeq > 0 && frame.seq <= atSeq) { stateAtSeq = structuredClone(state); selectedEvent = frame; }
1631
+ }
1632
+ if (!atSeq) stateAtSeq = structuredClone(state);
1633
+ return { run_id: bundle.run?.run_id ?? null, at_seq: atSeq || null, event_count: timeline.length, timeline, state_at_seq: stateAtSeq, selected_event: selectedEvent };
1634
+ }
1635
+ export function timeTravelHTML(report) { const rows = (report.timeline ?? []).map((frame) => `<tr><td>${frame.seq}</td><td>${escapeHTML(frame.type ?? '')}</td><td>${escapeHTML((frame.changed_keys ?? []).join(', '))}</td></tr>`).join('\n'); return `<!doctype html><html><head><meta charset="utf-8"><title>AgentLedger Time Travel Report</title></head><body><h1>AgentLedger Time Travel Report</h1><p>Run <code>${escapeHTML(report.run_id ?? '')}</code></p><table>${rows}</table><h2>State At Selected Point</h2><pre>${escapeHTML(JSON.stringify(report.state_at_seq ?? {}, null, 2))}</pre><h2>Selected Event</h2><pre>${escapeHTML(JSON.stringify(report.selected_event ?? null, null, 2))}</pre></body></html>`; }
1636
+ function patchForTimeTravelEvent(event) { if (event.type === 'run_created') return event.payload?.initial_state && typeof event.payload.initial_state === 'object' ? event.payload.initial_state : {}; if (event.type === 'state_committed' || event.type === 'state_patch_committed' || event.type === 'system_state_patch_applied') return event.payload?.patch && typeof event.payload.patch === 'object' ? event.payload.patch : {}; return null; }
1637
+
1638
+ export function optionalAdapterCapabilities() {
1639
+ const item = (name, category, surface) => ({ name, category, core_imports_heavy_sdks: false, adapter_is_optional: true, fail_closed_without_adapter: true, contract_surface: surface });
1640
+ return [
1641
+ item('postgres', 'storage', ['ddl_for', 'migrations_for', 'state_store']),
1642
+ item('s3', 'blobstore', ['put_json', 'get_json', 'content_address']),
1643
+ item('docker', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1644
+ item('e2b', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1645
+ item('bubblewrap', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1646
+ item('kubernetes', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1647
+ item('gvisor', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1648
+ item('firecracker', 'sandbox', ['sandbox_policy', 'sandbox_result', 'tool_gateway']),
1649
+ item('langgraph', 'framework', ['framework_adapter', 'checkpoint_contract']),
1650
+ item('langchain', 'framework', ['framework_adapter']),
1651
+ item('crewai', 'framework', ['framework_adapter']),
1652
+ item('autogen', 'framework', ['framework_adapter']),
1653
+ item('openai-agents-sdk', 'framework', ['framework_adapter']),
1654
+ item('llamaindex', 'framework', ['framework_adapter']),
1655
+ item('semantic-kernel', 'framework', ['framework_adapter']),
1656
+ item('mcp-transport', 'mcp', ['mcp_tool_descriptor', 'mcp_resource_descriptor']),
1657
+ item('shadow-runner', 'shadow', ['evidence_bundle', 'tool_ledger', 'state_diff']),
1658
+ ];
1659
+ }
1660
+
1661
+ export class PostgresAdapter {
1662
+ constructor(client, { schema = 'agentledger' } = {}) { this.client = client; this.schema = schema; }
1663
+ migrationPlan() { return [{ version: '0001', name: 'initial_runtime_metadata', dialect: 'postgres', sql: ddlFor('postgres') }]; }
1664
+ async applyMigrations() {
1665
+ if (!this.client || typeof this.client.exec !== 'function') throw new Error('postgres adapter requires an injected SQL client');
1666
+ const migrations = this.migrationPlan();
1667
+ await this.client.exec(ddlFor('postgres'));
1668
+ for (const migration of migrations) await this.client.exec('INSERT INTO schema_migrations(version, name, checksum) VALUES ($1, $2, $3) ON CONFLICT (version) DO NOTHING', migration.version, migration.name, migration.sql);
1669
+ }
1670
+ }
1671
+ export class S3BlobStoreAdapter {
1672
+ constructor(client, { bucket, prefix = 'agentledger/blobs' } = {}) { this.client = client; this.bucket = bucket; this.prefix = prefix; }
1673
+ async putJSON(value) { if (!this.client || typeof this.client.putObject !== 'function') throw new Error('s3 adapter requires an injected object client'); const digest = sha256JSON(value); const key = `${this.prefix.replace(/\/+$/, '')}/sha256/${digest}.json`; const body = JSON.stringify(value, null, 2); await this.client.putObject({ Bucket: this.bucket, Key: key, Body: body, ContentType: 'application/json', Metadata: { 'agentledger-digest': `sha256:${digest}` } }); return { digest: `sha256:${digest}`, ref: `s3://${this.bucket}/${key}` }; }
1674
+ async getJSON(ref) { if (!this.client || typeof this.client.getObject !== 'function') throw new Error('s3 adapter requires an injected object client'); const prefix = `s3://${this.bucket}/`; if (!ref.startsWith(prefix) || ref.includes('..')) throw new Error(`unsupported s3 blob ref: ${ref}`); const obj = await this.client.getObject(this.bucket, ref.slice(prefix.length)); const body = typeof obj.Body === 'string' ? obj.Body : new TextDecoder().decode(obj.Body); return JSON.parse(body); }
1675
+ }
1676
+ export class OTLPTransport {
1677
+ constructor(client, { endpoint = '' } = {}) { this.client = client; this.endpoint = endpoint; }
1678
+ async export(payload) { if (!this.client || typeof this.client.postJSON !== 'function') throw new Error('otlp transport requires an injected client'); return this.client.postJSON(this.endpoint, payload, 'application/json'); }
1679
+ }
1680
+ export class DockerSandboxAdapter {
1681
+ constructor({ image = 'python:3.11-slim' } = {}) { this.image = image; }
1682
+ manifest(policy, command) { return { backend: 'docker', image: this.image, network: policy.network === 'deny' || !policy.network ? 'none' : policy.network, read_only_root: true, requires_explicit_execution: true, command }; }
1683
+ }