@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.
- package/LICENSE +21 -0
- package/README.md +393 -0
- package/dist/src/checkpoint.d.ts +97 -0
- package/dist/src/checkpoint.d.ts.map +1 -0
- package/dist/src/checkpoint.js +200 -0
- package/dist/src/checkpoint.js.map +1 -0
- package/dist/src/execution-transcript.d.ts +30 -0
- package/dist/src/execution-transcript.d.ts.map +1 -0
- package/dist/src/execution-transcript.js +70 -0
- package/dist/src/execution-transcript.js.map +1 -0
- package/dist/src/executor.d.ts +49 -0
- package/dist/src/executor.d.ts.map +1 -0
- package/dist/src/executor.js +390 -0
- package/dist/src/executor.js.map +1 -0
- package/dist/src/guards.d.ts +7 -0
- package/dist/src/guards.d.ts.map +1 -0
- package/dist/src/guards.js +13 -0
- package/dist/src/guards.js.map +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +10 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/ledger.d.ts +12 -0
- package/dist/src/ledger.d.ts.map +1 -0
- package/dist/src/ledger.js +53 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/resumable-executor.d.ts +39 -0
- package/dist/src/resumable-executor.d.ts.map +1 -0
- package/dist/src/resumable-executor.js +354 -0
- package/dist/src/resumable-executor.js.map +1 -0
- package/dist/src/retry.d.ts +7 -0
- package/dist/src/retry.d.ts.map +1 -0
- package/dist/src/retry.js +25 -0
- package/dist/src/retry.js.map +1 -0
- package/dist/src/tool-envelope.d.ts +14 -0
- package/dist/src/tool-envelope.d.ts.map +1 -0
- package/dist/src/tool-envelope.js +84 -0
- package/dist/src/tool-envelope.js.map +1 -0
- package/dist/tests/resumable.test.d.ts +2 -0
- package/dist/tests/resumable.test.d.ts.map +1 -0
- package/dist/tests/resumable.test.js +337 -0
- package/dist/tests/resumable.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +29 -0
- package/src/checkpoint.ts +311 -0
- package/src/execution-transcript.ts +108 -0
- package/src/executor.ts +540 -0
- package/src/guards.ts +21 -0
- package/src/index.ts +9 -0
- package/src/ledger.ts +63 -0
- package/src/resumable-executor.ts +471 -0
- package/src/retry.ts +37 -0
- package/src/tool-envelope.ts +113 -0
- package/tests/resumable.test.ts +421 -0
- 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
|
+
}
|