@ddse/acm-runtime 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +393 -0
  3. package/dist/src/checkpoint.d.ts +97 -0
  4. package/dist/src/checkpoint.d.ts.map +1 -0
  5. package/dist/src/checkpoint.js +200 -0
  6. package/dist/src/checkpoint.js.map +1 -0
  7. package/dist/src/execution-transcript.d.ts +30 -0
  8. package/dist/src/execution-transcript.d.ts.map +1 -0
  9. package/dist/src/execution-transcript.js +70 -0
  10. package/dist/src/execution-transcript.js.map +1 -0
  11. package/dist/src/executor.d.ts +49 -0
  12. package/dist/src/executor.d.ts.map +1 -0
  13. package/dist/src/executor.js +390 -0
  14. package/dist/src/executor.js.map +1 -0
  15. package/dist/src/guards.d.ts +7 -0
  16. package/dist/src/guards.d.ts.map +1 -0
  17. package/dist/src/guards.js +13 -0
  18. package/dist/src/guards.js.map +1 -0
  19. package/dist/src/index.d.ts +9 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +10 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/ledger.d.ts +12 -0
  24. package/dist/src/ledger.d.ts.map +1 -0
  25. package/dist/src/ledger.js +53 -0
  26. package/dist/src/ledger.js.map +1 -0
  27. package/dist/src/resumable-executor.d.ts +39 -0
  28. package/dist/src/resumable-executor.d.ts.map +1 -0
  29. package/dist/src/resumable-executor.js +354 -0
  30. package/dist/src/resumable-executor.js.map +1 -0
  31. package/dist/src/retry.d.ts +7 -0
  32. package/dist/src/retry.d.ts.map +1 -0
  33. package/dist/src/retry.js +25 -0
  34. package/dist/src/retry.js.map +1 -0
  35. package/dist/src/tool-envelope.d.ts +14 -0
  36. package/dist/src/tool-envelope.d.ts.map +1 -0
  37. package/dist/src/tool-envelope.js +84 -0
  38. package/dist/src/tool-envelope.js.map +1 -0
  39. package/dist/tests/resumable.test.d.ts +2 -0
  40. package/dist/tests/resumable.test.d.ts.map +1 -0
  41. package/dist/tests/resumable.test.js +337 -0
  42. package/dist/tests/resumable.test.js.map +1 -0
  43. package/dist/tsconfig.tsbuildinfo +1 -0
  44. package/package.json +29 -0
  45. package/src/checkpoint.ts +311 -0
  46. package/src/execution-transcript.ts +108 -0
  47. package/src/executor.ts +540 -0
  48. package/src/guards.ts +21 -0
  49. package/src/index.ts +9 -0
  50. package/src/ledger.ts +63 -0
  51. package/src/resumable-executor.ts +471 -0
  52. package/src/retry.ts +37 -0
  53. package/src/tool-envelope.ts +113 -0
  54. package/tests/resumable.test.ts +421 -0
  55. package/tsconfig.json +11 -0
@@ -0,0 +1,471 @@
1
+ // Resumable executor with checkpoint and resume support
2
+ import {
3
+ InternalContextScopeImpl,
4
+ type Goal,
5
+ type Context,
6
+ type Plan,
7
+ type CapabilityRegistry,
8
+ type ToolRegistry,
9
+ type PolicyEngine,
10
+ type StreamSink,
11
+ type RunContext,
12
+ ExternalContextProviderAdapter,
13
+ } from '@ddse/acm-sdk';
14
+ import { evaluateGuard } from './guards.js';
15
+ import { MemoryLedger } from './ledger.js';
16
+ import { withRetry } from './retry.js';
17
+ import {
18
+ createCheckpoint,
19
+ validateCheckpoint,
20
+ MemoryCheckpointStore,
21
+ type Checkpoint,
22
+ type CheckpointStore,
23
+ type CheckpointState,
24
+ } from './checkpoint.js';
25
+ import type { ExecutePlanOptions, ExecutePlanResult, TaskExecutionRecord } from './executor.js';
26
+ import { buildTaskNarrative, synthesizeGoalSummary } from './executor.js';
27
+ import { createInstrumentedToolGetter } from './tool-envelope.js';
28
+
29
+ /**
30
+ * Extended options for resumable execution
31
+ */
32
+ export interface ResumableExecutePlanOptions extends ExecutePlanOptions {
33
+ resumeFrom?: string; // Checkpoint ID to resume from
34
+ checkpointInterval?: number; // Checkpoint after N tasks (default: 1)
35
+ checkpointStore?: CheckpointStore; // Storage backend
36
+ runId?: string; // Execution run identifier
37
+ }
38
+
39
+ /**
40
+ * Execute a plan with checkpoint and resume support
41
+ */
42
+ export async function executeResumablePlan(
43
+ options: ResumableExecutePlanOptions
44
+ ): Promise<ExecutePlanResult> {
45
+ const {
46
+ goal,
47
+ context,
48
+ plan,
49
+ capabilityRegistry,
50
+ toolRegistry,
51
+ policy,
52
+ verify,
53
+ stream,
54
+ nucleusFactory,
55
+ nucleusConfig,
56
+ contextProvider,
57
+ resumeFrom,
58
+ checkpointInterval = 1,
59
+ checkpointStore = new MemoryCheckpointStore(),
60
+ runId = `run-${Date.now()}`,
61
+ } = options;
62
+
63
+ const ledger = options.ledger ?? new MemoryLedger();
64
+ let outputs: Record<string, any> = {};
65
+ let executionRecords: Record<string, TaskExecutionRecord> = {};
66
+ const policyContext: Record<string, any> = {};
67
+ const metrics = { costUsd: 0, elapsedSec: 0 };
68
+ let startTime = Date.now();
69
+ let executed = new Set<string>();
70
+ let tasksExecutedSinceCheckpoint = 0;
71
+
72
+ // Restore from checkpoint if resuming
73
+ if (resumeFrom) {
74
+ console.log(`Resuming from checkpoint: ${resumeFrom}`);
75
+ const checkpoint = await checkpointStore.get(runId, resumeFrom);
76
+
77
+ if (!checkpoint) {
78
+ throw new Error(`Checkpoint not found: ${resumeFrom}`);
79
+ }
80
+
81
+ if (!validateCheckpoint(checkpoint)) {
82
+ throw new Error(`Invalid checkpoint: ${resumeFrom}`);
83
+ }
84
+
85
+ // Restore state
86
+ outputs = checkpoint.state.outputs;
87
+ executionRecords = checkpoint.state.executionRecords ?? {};
88
+ executed = new Set(checkpoint.state.executed);
89
+ metrics.costUsd = checkpoint.state.metrics.costUsd;
90
+ metrics.elapsedSec = checkpoint.state.metrics.elapsedSec;
91
+
92
+ // Restore ledger
93
+ for (const entry of checkpoint.state.ledger) {
94
+ ledger.append(entry.type, entry.details);
95
+ }
96
+
97
+ console.log(`Restored ${executed.size} completed tasks`);
98
+ startTime = Date.now() - metrics.elapsedSec * 1000;
99
+ } else {
100
+ // Log plan selection for new execution
101
+ ledger.append('PLAN_SELECTED', {
102
+ planId: plan.id,
103
+ contextRef: plan.contextRef,
104
+ capabilityMapVersion: plan.capabilityMapVersion,
105
+ });
106
+ }
107
+
108
+ // Helper to create checkpoint
109
+ const saveCheckpoint = async () => {
110
+ const state: CheckpointState = {
111
+ goal,
112
+ context,
113
+ plan,
114
+ outputs,
115
+ executionRecords,
116
+ executed: Array.from(executed),
117
+ ledger: ledger.getEntries() as any[],
118
+ metrics: { ...metrics },
119
+ };
120
+
121
+ const checkpoint = createCheckpoint(runId, state);
122
+ await checkpointStore.put(runId, checkpoint);
123
+
124
+ if (stream) {
125
+ stream.emit('checkpoint', {
126
+ checkpointId: checkpoint.id,
127
+ tasksCompleted: executed.size,
128
+ });
129
+ }
130
+
131
+ return checkpoint;
132
+ };
133
+
134
+ // Build execution order based on edges
135
+ const pending = plan.tasks.filter(t => !executed.has(t.id));
136
+
137
+ while (pending.length > 0) {
138
+ const readyTasks = pending.filter(taskSpec => {
139
+ // Skip already executed tasks
140
+ if (executed.has(taskSpec.id)) {
141
+ return false;
142
+ }
143
+
144
+ // Check if all dependencies are satisfied
145
+ const incomingEdges = plan.edges.filter(e => e.to === taskSpec.id);
146
+ if (incomingEdges.length === 0) {
147
+ return true; // No dependencies
148
+ }
149
+
150
+ return incomingEdges.every(edge => {
151
+ if (!executed.has(edge.from)) {
152
+ return false;
153
+ }
154
+
155
+ // Evaluate guard if present
156
+ if (edge.guard) {
157
+ const guardResult = evaluateGuard(edge.guard, {
158
+ context,
159
+ outputs,
160
+ policy: policyContext,
161
+ });
162
+
163
+ ledger.append('GUARD_EVAL', {
164
+ edge: `${edge.from}->${edge.to}`,
165
+ guard: edge.guard,
166
+ result: guardResult,
167
+ });
168
+
169
+ return guardResult;
170
+ }
171
+
172
+ return true;
173
+ });
174
+ });
175
+
176
+ if (readyTasks.length === 0) {
177
+ break; // No more tasks can be executed
178
+ }
179
+
180
+ // Execute ready tasks
181
+ for (const taskSpec of readyTasks) {
182
+ pending.splice(pending.indexOf(taskSpec), 1);
183
+
184
+ const capabilityName = taskSpec.capabilityRef || taskSpec.capability;
185
+ if (!capabilityName) {
186
+ throw new Error(`Task ${taskSpec.id} missing capability reference`);
187
+ }
188
+
189
+ const task = capabilityRegistry.resolve(capabilityName);
190
+ if (!task) {
191
+ throw new Error(`Task not found for capability: ${capabilityName}`);
192
+ }
193
+
194
+ const nucleusAllowedTools = new Set<string>(nucleusConfig.allowedTools ?? []);
195
+ for (const tool of taskSpec.tools ?? []) {
196
+ nucleusAllowedTools.add(tool.name);
197
+ }
198
+
199
+ const nucleus = nucleusFactory({
200
+ goalId: goal.id,
201
+ goalIntent: goal.intent,
202
+ planId: plan.id,
203
+ taskId: taskSpec.id,
204
+ contextRef: plan.contextRef,
205
+ context,
206
+ llmCall: nucleusConfig.llmCall,
207
+ hooks: nucleusConfig.hooks,
208
+ allowedTools: Array.from(nucleusAllowedTools),
209
+ });
210
+
211
+ const internalScope = new InternalContextScopeImpl(entry => {
212
+ ledger.append(entry.type, entry.details);
213
+ });
214
+ nucleus.setInternalContext(internalScope);
215
+
216
+ const getTool = createInstrumentedToolGetter({
217
+ taskId: taskSpec.id,
218
+ capability: capabilityName,
219
+ toolRegistry,
220
+ ledger,
221
+ });
222
+
223
+ // Build run context
224
+ const runContext: RunContext = {
225
+ goal,
226
+ context,
227
+ outputs,
228
+ metrics,
229
+ getTool,
230
+ getCapabilityRegistry: () => capabilityRegistry,
231
+ stream,
232
+ nucleus,
233
+ internalContext: internalScope,
234
+ };
235
+
236
+ let preflight = await nucleus.preflight();
237
+ if (preflight.status === 'NEEDS_CONTEXT') {
238
+ const requestedDirectives = preflight.retrievalDirectives;
239
+
240
+ ledger.append('CONTEXT_INTERNALIZED', {
241
+ taskId: taskSpec.id,
242
+ directives: requestedDirectives,
243
+ status: contextProvider ? 'requested' : 'unhandled',
244
+ });
245
+
246
+ if (!contextProvider) {
247
+ throw new Error(
248
+ `Task ${taskSpec.id} requires additional context retrieval: ${requestedDirectives.join(', ')}`
249
+ );
250
+ }
251
+
252
+ await contextProvider.fulfill({
253
+ directives: requestedDirectives,
254
+ scope: internalScope,
255
+ runContext,
256
+ nucleus,
257
+ });
258
+
259
+ preflight = await nucleus.preflight();
260
+ if (preflight.status === 'NEEDS_CONTEXT') {
261
+ throw new Error(
262
+ `Task ${taskSpec.id} still requires additional context after adapter execution: ${preflight.retrievalDirectives.join(', ')}`
263
+ );
264
+ }
265
+
266
+ ledger.append('CONTEXT_INTERNALIZED', {
267
+ taskId: taskSpec.id,
268
+ directives: requestedDirectives,
269
+ status: 'resolved',
270
+ });
271
+ }
272
+
273
+ // Policy pre-check
274
+ if (policy) {
275
+ const policyInput = task.policyInput?.(runContext, taskSpec.input) ?? {};
276
+ const decision = await policy.evaluate('task.pre', {
277
+ taskId: taskSpec.id,
278
+ capability: taskSpec.capability,
279
+ ...policyInput,
280
+ });
281
+
282
+ ledger.append('POLICY_PRE', {
283
+ taskId: taskSpec.id,
284
+ decision,
285
+ });
286
+
287
+ if (!decision.allow) {
288
+ throw new Error(`Policy denied task ${taskSpec.id}: ${decision.reason}`);
289
+ }
290
+
291
+ policyContext[taskSpec.id] = decision;
292
+ }
293
+
294
+ // Execute task with retry
295
+ const ledgerBaseline = ledger.getEntries().length;
296
+
297
+ ledger.append('TASK_START', {
298
+ taskId: taskSpec.id,
299
+ capability: capabilityName,
300
+ input: taskSpec.input,
301
+ });
302
+
303
+ stream?.emit('task', { taskId: taskSpec.id, status: 'running' });
304
+
305
+ try {
306
+ const executeTask = async () => task.execute(runContext, taskSpec.input);
307
+
308
+ const retryConfig = taskSpec.retry || (taskSpec.retryPolicy
309
+ ? {
310
+ attempts: taskSpec.retryPolicy.maxAttempts || 3,
311
+ backoff: 'exp' as const,
312
+ }
313
+ : undefined);
314
+
315
+ const output = retryConfig
316
+ ? await withRetry(executeTask, retryConfig)
317
+ : await executeTask();
318
+
319
+ outputs[taskSpec.id] = output;
320
+
321
+ // Policy post-check
322
+ if (policy) {
323
+ const decision = await policy.evaluate('task.post', {
324
+ taskId: taskSpec.id,
325
+ output,
326
+ });
327
+
328
+ ledger.append('POLICY_POST', {
329
+ taskId: taskSpec.id,
330
+ decision,
331
+ });
332
+ }
333
+
334
+ // Verification
335
+ if (verify && taskSpec.verification && taskSpec.verification.length > 0) {
336
+ const verified = await verify(taskSpec.id, output, taskSpec.verification);
337
+
338
+ ledger.append('VERIFICATION', {
339
+ taskId: taskSpec.id,
340
+ expressions: taskSpec.verification,
341
+ result: verified,
342
+ });
343
+
344
+ if (!verified) {
345
+ throw new Error(`Verification failed for task ${taskSpec.id}`);
346
+ }
347
+ }
348
+
349
+ const postcheck = await nucleus.postcheck(output);
350
+ if (postcheck.status === 'NEEDS_COMPENSATION') {
351
+ ledger.append('ERROR', {
352
+ taskId: taskSpec.id,
353
+ stage: 'NUCLEUS_POSTCHECK',
354
+ message: postcheck.reason,
355
+ });
356
+ throw new Error(`Task ${taskSpec.id} requires compensation: ${postcheck.reason}`);
357
+ }
358
+ if (postcheck.status === 'ESCALATE') {
359
+ ledger.append('ERROR', {
360
+ taskId: taskSpec.id,
361
+ stage: 'NUCLEUS_POSTCHECK',
362
+ message: postcheck.reason,
363
+ });
364
+ throw new Error(`Task ${taskSpec.id} escalated: ${postcheck.reason}`);
365
+ }
366
+
367
+ const narrative = buildTaskNarrative(ledger, ledgerBaseline, taskSpec.id, postcheck);
368
+
369
+ executionRecords[taskSpec.id] = {
370
+ output,
371
+ narrative,
372
+ };
373
+
374
+ ledger.append('TASK_END', {
375
+ taskId: taskSpec.id,
376
+ output,
377
+ narrative,
378
+ });
379
+
380
+ stream?.emit('task', { taskId: taskSpec.id, status: 'completed', output, narrative });
381
+
382
+ executed.add(taskSpec.id);
383
+ tasksExecutedSinceCheckpoint++;
384
+
385
+ // Create checkpoint if interval reached
386
+ if (tasksExecutedSinceCheckpoint >= checkpointInterval) {
387
+ await saveCheckpoint();
388
+ tasksExecutedSinceCheckpoint = 0;
389
+ }
390
+ } catch (err) {
391
+ const error = err as Error;
392
+ ledger.append('ERROR', {
393
+ taskId: taskSpec.id,
394
+ error: error.message,
395
+ stack: error.stack,
396
+ });
397
+
398
+ stream?.emit('task', { taskId: taskSpec.id, status: 'failed', error: error.message });
399
+
400
+ // Save checkpoint before throwing to allow resume
401
+ await saveCheckpoint();
402
+
403
+ throw error;
404
+ }
405
+ }
406
+ }
407
+
408
+ // Final checkpoint
409
+ if (tasksExecutedSinceCheckpoint > 0) {
410
+ await saveCheckpoint();
411
+ }
412
+
413
+ const goalSummary = await synthesizeGoalSummary({
414
+ goal,
415
+ plan,
416
+ executionRecords,
417
+ context,
418
+ nucleusFactory,
419
+ nucleusConfig,
420
+ ledger,
421
+ stream,
422
+ });
423
+
424
+ metrics.elapsedSec = (Date.now() - startTime) / 1000;
425
+
426
+ return {
427
+ outputsByTask: executionRecords,
428
+ ledger: ledger.getEntries(),
429
+ goalSummary,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * ResumableExecutor class for managing resumable executions
435
+ */
436
+ export class ResumableExecutor {
437
+ constructor(
438
+ private checkpointStore: CheckpointStore = new MemoryCheckpointStore()
439
+ ) {}
440
+
441
+ /**
442
+ * Execute a plan with checkpointing enabled
443
+ */
444
+ async execute(options: ResumableExecutePlanOptions): Promise<ExecutePlanResult> {
445
+ return executeResumablePlan({
446
+ ...options,
447
+ checkpointStore: options.checkpointStore ?? this.checkpointStore,
448
+ });
449
+ }
450
+
451
+ /**
452
+ * List available checkpoints for a run
453
+ */
454
+ async listCheckpoints(runId: string) {
455
+ return this.checkpointStore.list(runId);
456
+ }
457
+
458
+ /**
459
+ * Get a specific checkpoint
460
+ */
461
+ async getCheckpoint(runId: string, checkpointId?: string): Promise<Checkpoint | null> {
462
+ return this.checkpointStore.get(runId, checkpointId);
463
+ }
464
+
465
+ /**
466
+ * Prune old checkpoints
467
+ */
468
+ async pruneCheckpoints(runId: string, keepLast: number = 5): Promise<void> {
469
+ return this.checkpointStore.prune(runId, keepLast);
470
+ }
471
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Retry logic with backoff
2
+ export async function withRetry<T>(
3
+ fn: () => Promise<T>,
4
+ config: {
5
+ attempts: number;
6
+ backoff: 'fixed' | 'exp';
7
+ baseMs?: number;
8
+ jitter?: boolean;
9
+ }
10
+ ): Promise<T> {
11
+ const baseMs = config.baseMs ?? 1000;
12
+ let lastError: Error | undefined;
13
+
14
+ for (let attempt = 0; attempt < config.attempts; attempt++) {
15
+ try {
16
+ return await fn();
17
+ } catch (err) {
18
+ lastError = err as Error;
19
+
20
+ if (attempt < config.attempts - 1) {
21
+ let delayMs = baseMs;
22
+
23
+ if (config.backoff === 'exp') {
24
+ delayMs = baseMs * Math.pow(2, attempt);
25
+ }
26
+
27
+ if (config.jitter) {
28
+ delayMs = delayMs * (0.5 + Math.random() * 0.5);
29
+ }
30
+
31
+ await new Promise(resolve => setTimeout(resolve, delayMs));
32
+ }
33
+ }
34
+ }
35
+
36
+ throw lastError;
37
+ }
@@ -0,0 +1,113 @@
1
+ import { createHash } from 'crypto';
2
+ import type { Tool, ToolCallEnvelope, ToolRegistry } from '@ddse/acm-sdk';
3
+ import type { MemoryLedger } from './ledger.js';
4
+
5
+ type ToolGetterOptions = {
6
+ taskId: string;
7
+ capability: string;
8
+ toolRegistry: ToolRegistry;
9
+ ledger?: MemoryLedger;
10
+ };
11
+
12
+ type InstrumentedTool = Tool<any, any> & {
13
+ call(input: any, idemKey?: string): Promise<any>;
14
+ };
15
+
16
+ function computeDigest(payload: unknown): string {
17
+ const normalized = typeof payload === 'string' ? payload : JSON.stringify(payload ?? {});
18
+ const hash = createHash('sha256');
19
+ hash.update(normalized);
20
+ return hash.digest('hex').substring(0, 32);
21
+ }
22
+
23
+ function cloneWithInstrumentedCall(
24
+ toolName: string,
25
+ tool: Tool<any, any>,
26
+ options: ToolGetterOptions
27
+ ): InstrumentedTool {
28
+ const instrumented = Object.create(tool) as InstrumentedTool;
29
+
30
+ instrumented.call = async (input: any, idemKey?: string) => {
31
+ const start = Date.now();
32
+ const envelopeBase: ToolCallEnvelope = {
33
+ id: idemKey ?? `${options.taskId}-${toolName}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
34
+ name: toolName,
35
+ input: input ?? {},
36
+ metadata: {
37
+ timestamp: start,
38
+ digest: computeDigest(input ?? {}),
39
+ },
40
+ };
41
+
42
+ options.ledger?.append('TOOL_CALL', {
43
+ stage: 'start',
44
+ taskId: options.taskId,
45
+ capability: options.capability,
46
+ tool: toolName,
47
+ envelope: envelopeBase,
48
+ });
49
+
50
+ try {
51
+ const result = await tool.call.call(tool, input, idemKey);
52
+ const completed: ToolCallEnvelope = {
53
+ ...envelopeBase,
54
+ output: result,
55
+ metadata: {
56
+ ...envelopeBase.metadata,
57
+ duration_ms: Date.now() - start,
58
+ },
59
+ };
60
+
61
+ options.ledger?.append('TOOL_CALL', {
62
+ stage: 'complete',
63
+ taskId: options.taskId,
64
+ capability: options.capability,
65
+ tool: toolName,
66
+ envelope: completed,
67
+ });
68
+
69
+ return result;
70
+ } catch (error: any) {
71
+ const errEnvelope: ToolCallEnvelope = {
72
+ ...envelopeBase,
73
+ error: {
74
+ code: 'ERROR',
75
+ message: error?.message ?? String(error),
76
+ },
77
+ metadata: {
78
+ ...envelopeBase.metadata,
79
+ duration_ms: Date.now() - start,
80
+ },
81
+ };
82
+
83
+ options.ledger?.append('TOOL_CALL', {
84
+ stage: 'error',
85
+ taskId: options.taskId,
86
+ capability: options.capability,
87
+ tool: toolName,
88
+ envelope: errEnvelope,
89
+ });
90
+
91
+ throw error;
92
+ }
93
+ };
94
+
95
+ return instrumented;
96
+ }
97
+
98
+ export function createInstrumentedToolGetter(options: ToolGetterOptions) {
99
+ const cache = new Map<Tool<any, any>, InstrumentedTool>();
100
+
101
+ return (toolName: string) => {
102
+ const tool = options.toolRegistry.get(toolName);
103
+ if (!tool) {
104
+ return undefined;
105
+ }
106
+
107
+ if (!cache.has(tool)) {
108
+ cache.set(tool, cloneWithInstrumentedCall(toolName, tool, options));
109
+ }
110
+
111
+ return cache.get(tool);
112
+ };
113
+ }