@noetic-tools/core 2.3.0 → 2.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/dist/builders/tool-builder.d.ts +5 -1
- package/dist/builders/tool-builder.js +2 -0
- package/dist/harness/agent-harness.d.ts +7 -1
- package/dist/harness/agent-harness.js +34 -2
- package/dist/harness/deps/runtime.d.ts +2 -0
- package/dist/harness/deps/runtime.js +1 -0
- package/dist/interpreter/execute-action.js +4 -0
- package/dist/interpreter/execute-control.js +8 -1
- package/dist/interpreter/execute.js +43 -0
- package/dist/runtime/context-impl.d.ts +39 -1
- package/dist/runtime/context-impl.js +44 -1
- package/dist/runtime/durable/harness-checkpoints.d.ts +2 -0
- package/dist/runtime/durable/harness-checkpoints.js +19 -0
- package/dist/runtime/durable/step-ledger.d.ts +80 -0
- package/dist/runtime/durable/step-ledger.js +131 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FunctionCallItem, FunctionCallOutputItem, Item, ItemSchemaExtensions, Tool, ToolExecutionContext, ToolMemoryDeclaration, ToolResultExtensionItem } from '@noetic-tools/types';
|
|
1
|
+
import type { FunctionCallItem, FunctionCallOutputItem, Item, ItemSchemaExtensions, Tool, ToolExecutionContext, ToolMemoryDeclaration, ToolResultExtensionItem, ToolUiDeclaration } from '@noetic-tools/types';
|
|
2
2
|
import type { ZodTypeAny, z } from 'zod';
|
|
3
3
|
interface ToolConfig<I extends ZodTypeAny, O extends ZodTypeAny> {
|
|
4
4
|
name: string;
|
|
@@ -18,6 +18,8 @@ interface ToolConfig<I extends ZodTypeAny, O extends ZodTypeAny> {
|
|
|
18
18
|
needsApproval?: boolean;
|
|
19
19
|
/** Optional memory declaration — the runtime generates a MemoryLayer from this via toolMemoryLayer(). */
|
|
20
20
|
memory?: ToolMemoryDeclaration;
|
|
21
|
+
/** Optional UI declaration — the runtime emits the rendered fragments at call/progress/result points. */
|
|
22
|
+
ui?: ToolUiDeclaration<I, O>;
|
|
21
23
|
}
|
|
22
24
|
interface GeneratorToolConfig<I extends ZodTypeAny, E extends ZodTypeAny, O extends ZodTypeAny> {
|
|
23
25
|
name: string;
|
|
@@ -38,6 +40,8 @@ interface GeneratorToolConfig<I extends ZodTypeAny, E extends ZodTypeAny, O exte
|
|
|
38
40
|
needsApproval?: boolean;
|
|
39
41
|
/** Optional memory declaration — the runtime generates a MemoryLayer from this via toolMemoryLayer(). */
|
|
40
42
|
memory?: ToolMemoryDeclaration;
|
|
43
|
+
/** Optional UI declaration — the runtime emits the rendered fragments at call/progress/result points. */
|
|
44
|
+
ui?: ToolUiDeclaration<I, O, z.infer<E>>;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Creates a typed Tool with Zod schema inference for input and output.
|
|
@@ -36,6 +36,7 @@ export function tool(config) {
|
|
|
36
36
|
execute: config.execute,
|
|
37
37
|
needsApproval: config.needsApproval,
|
|
38
38
|
memory: config.memory,
|
|
39
|
+
ui: config.ui,
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
@@ -56,6 +57,7 @@ export function toolWithGenerator(config) {
|
|
|
56
57
|
execute: config.execute,
|
|
57
58
|
needsApproval: config.needsApproval,
|
|
58
59
|
memory: config.memory,
|
|
60
|
+
ui: config.ui,
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ItemSchemaRegistry } from '@noetic-tools/types';
|
|
2
2
|
import type { DetachedSpawnOverrides } from './deps/interpreter.js';
|
|
3
3
|
import type { LayerStateStore, RecallCache } from './deps/memory.js';
|
|
4
|
-
import type { CheckpointStore, EventBroadcaster } from './deps/runtime.js';
|
|
4
|
+
import type { CheckpointStore, EventBroadcaster, StepLedgerStore } from './deps/runtime.js';
|
|
5
5
|
import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextMemory, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, MemoryLayer, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
|
|
6
6
|
export { createStreamIdleWatchdog } from './model-call.js';
|
|
7
7
|
interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<string, unknown>> {
|
|
@@ -105,6 +105,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
105
105
|
* @internal
|
|
106
106
|
*/
|
|
107
107
|
readonly checkpointStore?: CheckpointStore;
|
|
108
|
+
/**
|
|
109
|
+
* Append-only step ledger backing resume. Present exactly when durability is on
|
|
110
|
+
* (a `CheckpointStore` is configured), so a zero-config harness records nothing.
|
|
111
|
+
* @internal
|
|
112
|
+
*/
|
|
113
|
+
readonly stepLedgerStore?: StepLedgerStore;
|
|
108
114
|
private readonly initialStep?;
|
|
109
115
|
/** Harness-wide tool pool merged into every context's `unifiedTools`. */
|
|
110
116
|
private readonly harnessTools;
|
|
@@ -4,7 +4,7 @@ import { NoopExporter } from '../observability/trace-exporter.js';
|
|
|
4
4
|
import { createInMemoryFsAdapter, createInMemoryShellAdapter, createInMemorySubprocessAdapter, OpenRouter, } from './deps/adapters.js';
|
|
5
5
|
import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, } from './deps/interpreter.js';
|
|
6
6
|
import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/memory.js';
|
|
7
|
-
import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, createInMemoryStorage, filterReasoningStream, filterTextStream, restoreFromCheckpoint, SessionRunner, snapshotCwdState, } from './deps/runtime.js';
|
|
7
|
+
import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
|
|
8
8
|
import { SteeringAction } from './deps/types.js';
|
|
9
9
|
import { AgentHarnessModelCaller } from './model-call.js';
|
|
10
10
|
export { createStreamIdleWatchdog } from './model-call.js';
|
|
@@ -124,6 +124,12 @@ export class AgentHarness {
|
|
|
124
124
|
* @internal
|
|
125
125
|
*/
|
|
126
126
|
checkpointStore;
|
|
127
|
+
/**
|
|
128
|
+
* Append-only step ledger backing resume. Present exactly when durability is on
|
|
129
|
+
* (a `CheckpointStore` is configured), so a zero-config harness records nothing.
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
stepLedgerStore;
|
|
127
133
|
initialStep;
|
|
128
134
|
/** Harness-wide tool pool merged into every context's `unifiedTools`. */
|
|
129
135
|
harnessTools;
|
|
@@ -181,6 +187,11 @@ export class AgentHarness {
|
|
|
181
187
|
this.shell = opts.shell ?? createInMemoryShellAdapter();
|
|
182
188
|
this.subprocess = opts.subprocess ?? createInMemorySubprocessAdapter();
|
|
183
189
|
this.checkpointStore = opts.checkpointStore;
|
|
190
|
+
this.stepLedgerStore = opts.checkpointStore
|
|
191
|
+
? createStepLedgerStore({
|
|
192
|
+
storage: this.config.storage ?? createInMemoryStorage(),
|
|
193
|
+
})
|
|
194
|
+
: undefined;
|
|
184
195
|
this.initialStep = opts.initialStep;
|
|
185
196
|
this.harnessTools = opts.tools ?? [];
|
|
186
197
|
this._memory = opts.memory;
|
|
@@ -433,6 +444,10 @@ export class AgentHarness {
|
|
|
433
444
|
await this.initLayers(layers, ctx, storage);
|
|
434
445
|
}
|
|
435
446
|
detachedSpawn(s, input, parentCtx, overrides) {
|
|
447
|
+
/* NOT a checkpoint boundary, despite spec 23 listing one here: a DetachedHandle's
|
|
448
|
+
* settle is only observable via `await()`, and calling it internally would consume
|
|
449
|
+
* the result the caller is holding. The handle manifest is the adapter's own
|
|
450
|
+
* durability surface (`listLive`/`reattach`), so nothing is lost. */
|
|
436
451
|
return dispatchStepThroughAdapter(this, s, input, parentCtx, overrides);
|
|
437
452
|
}
|
|
438
453
|
createContext(opts) {
|
|
@@ -442,13 +457,27 @@ export class AgentHarness {
|
|
|
442
457
|
base: this.itemSchemas,
|
|
443
458
|
layers: effectiveMemory,
|
|
444
459
|
});
|
|
460
|
+
/* The execution id is chosen here rather than inside ContextImpl so the ledger
|
|
461
|
+
* can be keyed to it — the ledger's storage keys and `ctx.id` must agree for a
|
|
462
|
+
* later `restore()` to find what this run recorded. */
|
|
463
|
+
const executionId = crypto.randomUUID();
|
|
445
464
|
return new ContextImpl({
|
|
446
465
|
...rest,
|
|
466
|
+
id: executionId,
|
|
447
467
|
harness: this,
|
|
448
468
|
channelStore: this.channelStore,
|
|
449
469
|
layers: effectiveMemory,
|
|
450
470
|
itemSchemas,
|
|
451
471
|
cwdState: resolveContextCwdState(this.rootCwdState, opts?.parent, cwdInit),
|
|
472
|
+
// Without this `ctx.checkpoint()` is inert, and the boundaries below never
|
|
473
|
+
// persist anything (a no-op when no CheckpointStore is configured).
|
|
474
|
+
checkpointFn: (c) => this.checkpoint(c),
|
|
475
|
+
ledger: this.stepLedgerStore
|
|
476
|
+
? new StepLedger({
|
|
477
|
+
executionId,
|
|
478
|
+
store: this.stepLedgerStore,
|
|
479
|
+
})
|
|
480
|
+
: undefined,
|
|
452
481
|
});
|
|
453
482
|
}
|
|
454
483
|
send(channel, value, ctx) {
|
|
@@ -704,13 +733,16 @@ export class AgentHarness {
|
|
|
704
733
|
rerenderRequests: [],
|
|
705
734
|
};
|
|
706
735
|
}
|
|
707
|
-
|
|
736
|
+
const piped = await runAppendPipeline({
|
|
708
737
|
layers,
|
|
709
738
|
items,
|
|
710
739
|
ctx: this.toExecCtx(ctx),
|
|
711
740
|
log: ctx.itemLog,
|
|
712
741
|
store: this.layerStateStore,
|
|
713
742
|
});
|
|
743
|
+
// Layer state can mutate as items land, so the snapshot must follow the fold.
|
|
744
|
+
await this.checkpoint(ctx);
|
|
745
|
+
return piped;
|
|
714
746
|
}
|
|
715
747
|
async executeRerender(requests, layers, ctx, budgets, query) {
|
|
716
748
|
return executeRerender({
|
|
@@ -3,6 +3,8 @@ export { ContextImpl } from '../../runtime/context-impl.js';
|
|
|
3
3
|
export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
|
|
4
4
|
export type { CheckpointStore } from '../../runtime/durable/index.js';
|
|
5
5
|
export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
|
|
6
|
+
export type { StepLedgerEntry, StepLedgerStore } from '../../runtime/durable/step-ledger.js';
|
|
7
|
+
export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
|
|
6
8
|
export type { EventBroadcaster } from '../../runtime/event-broadcaster.js';
|
|
7
9
|
export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
|
|
8
10
|
export type { QueuedMessage } from '../../runtime/message-queue.js';
|
|
@@ -2,6 +2,7 @@ export { ChannelStore } from '../../runtime/channel-store.js';
|
|
|
2
2
|
export { ContextImpl } from '../../runtime/context-impl.js';
|
|
3
3
|
export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
|
|
4
4
|
export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
|
|
5
|
+
export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
|
|
5
6
|
export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
|
|
6
7
|
export { SessionRunner } from '../../runtime/session-runner.js';
|
|
7
8
|
export { buildItemStream, filterReasoningStream, filterTextStream, } from '../../runtime/session-streams.js';
|
|
@@ -591,6 +591,10 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
|
|
|
591
591
|
unifiedTools: childUnifiedTools.length > 0 ? childUnifiedTools : undefined,
|
|
592
592
|
cwdState: snapshotCwdState(baseCtx),
|
|
593
593
|
id: childId,
|
|
594
|
+
// The spawn's own path prefixes the child's, so a resumed run lines its steps up
|
|
595
|
+
// with the ones recorded inside the same spawn rather than at the root.
|
|
596
|
+
pathPrefix: baseCtx instanceof ContextImpl ? baseCtx.currentPath() : '',
|
|
597
|
+
ledger: baseCtx instanceof ContextImpl ? baseCtx.ledger : undefined,
|
|
594
598
|
});
|
|
595
599
|
try {
|
|
596
600
|
// Execute the child step
|
|
@@ -23,11 +23,18 @@ function createChildContexts(ctx, count, stepId) {
|
|
|
23
23
|
const threadId = isContextImpl(ctx) ? ctx.threadId : crypto.randomUUID();
|
|
24
24
|
const resourceId = isContextImpl(ctx) ? ctx.resourceId : undefined;
|
|
25
25
|
const channelStore = getContextChannelStore(ctx);
|
|
26
|
+
const parentPath = isContextImpl(ctx) ? ctx.currentPath() : '';
|
|
27
|
+
const parentLedger = isContextImpl(ctx) ? ctx.ledger : undefined;
|
|
26
28
|
return Array.from({
|
|
27
29
|
length: count,
|
|
28
|
-
}, () => new ContextImpl({
|
|
30
|
+
}, (_unused, index) => new ContextImpl({
|
|
29
31
|
harness: ctx.harness,
|
|
30
32
|
parent: ctx,
|
|
33
|
+
/* Ledger paths must stay unique across sibling paths: a child ContextImpl
|
|
34
|
+
* starts with an empty frontier, so without a prefix every path would
|
|
35
|
+
* restart at the root and collide with its siblings. */
|
|
36
|
+
pathPrefix: `${parentPath}[${index}]`,
|
|
37
|
+
ledger: parentLedger,
|
|
31
38
|
items: [
|
|
32
39
|
...ctx.itemLog.items,
|
|
33
40
|
],
|
|
@@ -172,6 +172,32 @@ export async function execute(step, input, ctx) {
|
|
|
172
172
|
input,
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
|
+
/* Resume: a previous run that completed this exact step replays its recorded
|
|
176
|
+
* output instead of re-running it. Captured before dispatch and held for the
|
|
177
|
+
* record below, because `leaveStep` pops the path on the way out. */
|
|
178
|
+
const ledgerPath = impl && step.id.length > 0 ? impl.currentPath() : undefined;
|
|
179
|
+
const ledger = impl?.ledger;
|
|
180
|
+
if (ledger && ledgerPath !== undefined && !ledger.isEmpty) {
|
|
181
|
+
const replayed = ledger.take(ledgerPath, {
|
|
182
|
+
id: step.id,
|
|
183
|
+
kind: step.kind,
|
|
184
|
+
});
|
|
185
|
+
if (replayed) {
|
|
186
|
+
if (impl && step.id.length > 0) {
|
|
187
|
+
impl.leaveStep(step.id);
|
|
188
|
+
}
|
|
189
|
+
emitFrameworkEvent({
|
|
190
|
+
broadcaster,
|
|
191
|
+
agentName,
|
|
192
|
+
eventType: 'step_replayed',
|
|
193
|
+
data: {
|
|
194
|
+
stepId: step.id,
|
|
195
|
+
kind: step.kind,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
return frameworkCast(replayed.output);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
175
201
|
let result;
|
|
176
202
|
try {
|
|
177
203
|
switch (step.kind) {
|
|
@@ -243,5 +269,22 @@ export async function execute(step, input, ctx) {
|
|
|
243
269
|
data: completedData,
|
|
244
270
|
});
|
|
245
271
|
}
|
|
272
|
+
/* Record the completed step so a resumed run replays this output rather than
|
|
273
|
+
* re-running the step. Only successes are recorded: a step that threw must run
|
|
274
|
+
* again. */
|
|
275
|
+
if (ledger && ledgerPath !== undefined) {
|
|
276
|
+
await ledger.record({
|
|
277
|
+
path: ledgerPath,
|
|
278
|
+
stepId: step.id,
|
|
279
|
+
kind: step.kind,
|
|
280
|
+
output: result,
|
|
281
|
+
completedAt: new Date().toISOString(),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/* Durability boundary (spec 23): snapshot after each completed step, so a crash
|
|
285
|
+
* lands on a checkpoint that includes the item-log and layer-state mutations this
|
|
286
|
+
* step produced. A no-op unless the harness has a CheckpointStore, and a failing
|
|
287
|
+
* save is logged rather than thrown — durability must never fail a good step. */
|
|
288
|
+
await baseCtx.checkpoint();
|
|
246
289
|
return result;
|
|
247
290
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentHarnessContract, Channel, ChannelStore, Context, ContextMemory, CwdState, EventBroadcaster, FrontierFrame, FsAdapter, Item, ItemLog, ItemSchemaRegistry, LastLayerUsage, MemoryLayer, Span, StepMeta, TokenUsage, Tool } from './context-deps.js';
|
|
2
|
+
import type { StepLedger } from './durable/step-ledger.js';
|
|
2
3
|
export declare class ContextImpl implements Context<ContextMemory> {
|
|
3
4
|
readonly id: string;
|
|
4
5
|
stepCount: number;
|
|
@@ -53,6 +54,31 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
53
54
|
* @internal
|
|
54
55
|
*/
|
|
55
56
|
private readonly _frontier;
|
|
57
|
+
/**
|
|
58
|
+
* Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
|
|
59
|
+
* `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
private readonly _segments;
|
|
63
|
+
/**
|
|
64
|
+
* Path this context's steps hang under. Empty on a root context; a fork/spawn child
|
|
65
|
+
* inherits its parent's current path plus a discriminator, so a path key stays unique
|
|
66
|
+
* across the whole step tree rather than restarting per child context.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
private readonly _pathPrefix;
|
|
70
|
+
/**
|
|
71
|
+
* Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
|
|
72
|
+
* iteration gets `#0`, `#1`, … instead of colliding on one key.
|
|
73
|
+
* @internal
|
|
74
|
+
*/
|
|
75
|
+
private readonly _occurrences;
|
|
76
|
+
/**
|
|
77
|
+
* Completion ledger for the execution this context belongs to. Shared by reference
|
|
78
|
+
* with fork/spawn children — one execution, one ledger.
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
readonly ledger?: StepLedger;
|
|
56
82
|
constructor(opts: {
|
|
57
83
|
harness: AgentHarnessContract;
|
|
58
84
|
parent?: Context;
|
|
@@ -62,7 +88,11 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
62
88
|
resourceId?: string;
|
|
63
89
|
span?: Span;
|
|
64
90
|
channelStore?: ChannelStore;
|
|
65
|
-
checkpointFn?: () => Promise<void>;
|
|
91
|
+
checkpointFn?: (ctx: Context) => Promise<void>;
|
|
92
|
+
/** Path this context's steps hang under (fork/spawn children inherit one). */
|
|
93
|
+
pathPrefix?: string;
|
|
94
|
+
/** The execution's completion ledger, shared with children by reference. */
|
|
95
|
+
ledger?: StepLedger;
|
|
66
96
|
layers?: MemoryLayer[];
|
|
67
97
|
unifiedTools?: ReadonlyArray<Tool>;
|
|
68
98
|
itemSchemas?: ItemSchemaRegistry;
|
|
@@ -108,6 +138,14 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
108
138
|
* stack of steps currently in-flight on this context.
|
|
109
139
|
*/
|
|
110
140
|
enterStep(frame: FrontierFrame): void;
|
|
141
|
+
/**
|
|
142
|
+
* @internal
|
|
143
|
+
* The execution path of the step currently on top of the frontier — the ledger key
|
|
144
|
+
* identifying this exact dispatch. Stable across a replay given the same control
|
|
145
|
+
* flow, which is what lets a resumed run line recorded outputs up with the steps
|
|
146
|
+
* that produced them.
|
|
147
|
+
*/
|
|
148
|
+
currentPath(): string;
|
|
111
149
|
/**
|
|
112
150
|
* @internal
|
|
113
151
|
* Pop the top frame. Called when a step resolves (success or failure)
|
|
@@ -67,6 +67,31 @@ export class ContextImpl {
|
|
|
67
67
|
* @internal
|
|
68
68
|
*/
|
|
69
69
|
_frontier = [];
|
|
70
|
+
/**
|
|
71
|
+
* Path segment for each in-flight frame, parallel to `_frontier`. Kept separate so
|
|
72
|
+
* `serialiseFrontier()` keeps emitting the published `FrontierFrame` shape.
|
|
73
|
+
* @internal
|
|
74
|
+
*/
|
|
75
|
+
_segments = [];
|
|
76
|
+
/**
|
|
77
|
+
* Path this context's steps hang under. Empty on a root context; a fork/spawn child
|
|
78
|
+
* inherits its parent's current path plus a discriminator, so a path key stays unique
|
|
79
|
+
* across the whole step tree rather than restarting per child context.
|
|
80
|
+
* @internal
|
|
81
|
+
*/
|
|
82
|
+
_pathPrefix;
|
|
83
|
+
/**
|
|
84
|
+
* Dispatch counts per `<parent path>/<step id>`, so a loop body re-entered on each
|
|
85
|
+
* iteration gets `#0`, `#1`, … instead of colliding on one key.
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
_occurrences = new Map();
|
|
89
|
+
/**
|
|
90
|
+
* Completion ledger for the execution this context belongs to. Shared by reference
|
|
91
|
+
* with fork/spawn children — one execution, one ledger.
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
ledger;
|
|
70
95
|
constructor(opts) {
|
|
71
96
|
this.id = opts.id ?? crypto.randomUUID();
|
|
72
97
|
this._createdAt = Date.now();
|
|
@@ -79,6 +104,8 @@ export class ContextImpl {
|
|
|
79
104
|
this.resourceId = opts.resourceId;
|
|
80
105
|
this.channelStore = opts.channelStore;
|
|
81
106
|
this._checkpointFn = opts.checkpointFn;
|
|
107
|
+
this._pathPrefix = opts.pathPrefix ?? '';
|
|
108
|
+
this.ledger = opts.ledger;
|
|
82
109
|
this.layers = opts.layers;
|
|
83
110
|
this.unifiedTools = opts.unifiedTools;
|
|
84
111
|
this.itemSchemas = opts.itemSchemas ?? defaultItemSchemaRegistry;
|
|
@@ -140,7 +167,7 @@ export class ContextImpl {
|
|
|
140
167
|
}
|
|
141
168
|
async checkpoint() {
|
|
142
169
|
if (this._checkpointFn) {
|
|
143
|
-
await this._checkpointFn();
|
|
170
|
+
await this._checkpointFn(this);
|
|
144
171
|
}
|
|
145
172
|
}
|
|
146
173
|
complete(value) {
|
|
@@ -180,8 +207,23 @@ export class ContextImpl {
|
|
|
180
207
|
* stack of steps currently in-flight on this context.
|
|
181
208
|
*/
|
|
182
209
|
enterStep(frame) {
|
|
210
|
+
const parent = this.currentPath();
|
|
211
|
+
const key = `${parent}/${frame.stepId}`;
|
|
212
|
+
const occurrence = this._occurrences.get(key) ?? 0;
|
|
213
|
+
this._occurrences.set(key, occurrence + 1);
|
|
214
|
+
this._segments.push(`/${frame.stepId}#${occurrence}`);
|
|
183
215
|
this._frontier.push(frame);
|
|
184
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* @internal
|
|
219
|
+
* The execution path of the step currently on top of the frontier — the ledger key
|
|
220
|
+
* identifying this exact dispatch. Stable across a replay given the same control
|
|
221
|
+
* flow, which is what lets a resumed run line recorded outputs up with the steps
|
|
222
|
+
* that produced them.
|
|
223
|
+
*/
|
|
224
|
+
currentPath() {
|
|
225
|
+
return this._pathPrefix + this._segments.join('');
|
|
226
|
+
}
|
|
185
227
|
/**
|
|
186
228
|
* @internal
|
|
187
229
|
* Pop the top frame. Called when a step resolves (success or failure)
|
|
@@ -198,6 +240,7 @@ export class ContextImpl {
|
|
|
198
240
|
console.warn(`ContextImpl.leaveStep: expected "${expectedStepId}" on top of frontier but saw "${top.stepId}".`);
|
|
199
241
|
}
|
|
200
242
|
this._frontier.pop();
|
|
243
|
+
this._segments.pop();
|
|
201
244
|
}
|
|
202
245
|
/**
|
|
203
246
|
* @internal
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LayerStateStore, MemoryLayer } from '@noetic-tools/memory';
|
|
2
2
|
import type { Context, Item, ItemSchemaRegistry } from '@noetic-tools/types';
|
|
3
3
|
import type { CheckpointStore } from './checkpoint-store.js';
|
|
4
|
+
import type { StepLedgerStore } from './step-ledger.js';
|
|
4
5
|
/**
|
|
5
6
|
* Minimum harness surface `captureCheckpoint` / `restoreFromCheckpoint`
|
|
6
7
|
* require. `AgentHarness` already satisfies this shape via its
|
|
@@ -11,6 +12,7 @@ import type { CheckpointStore } from './checkpoint-store.js';
|
|
|
11
12
|
*/
|
|
12
13
|
export interface CheckpointHarnessHandle {
|
|
13
14
|
readonly checkpointStore?: CheckpointStore;
|
|
15
|
+
readonly stepLedgerStore?: StepLedgerStore;
|
|
14
16
|
readonly layerStateStore: LayerStateStore;
|
|
15
17
|
readonly itemSchemas: ItemSchemaRegistry;
|
|
16
18
|
readonly _memory?: MemoryLayer[];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CheckpointSchemaVersion } from '../../types/checkpoint.js';
|
|
2
2
|
import { ContextImpl } from '../context-impl.js';
|
|
3
|
+
import { StepLedger } from './step-ledger.js';
|
|
3
4
|
//#endregion
|
|
4
5
|
//#region captureCheckpoint
|
|
5
6
|
/**
|
|
@@ -98,6 +99,24 @@ export async function restoreFromCheckpoint(h, executionId) {
|
|
|
98
99
|
writable: false,
|
|
99
100
|
enumerable: true,
|
|
100
101
|
});
|
|
102
|
+
/* Attach the recovered completion ledger. `createContext` already built a fresh
|
|
103
|
+
* one keyed to a throwaway id; the restored context needs the entries recorded
|
|
104
|
+
* under THIS execution, or `execute()` would replay nothing and re-run the whole
|
|
105
|
+
* tree. Assigned the same way `id` is, to keep `createContext`'s public options
|
|
106
|
+
* free of resume-only fields. */
|
|
107
|
+
if (h.stepLedgerStore) {
|
|
108
|
+
const recovered = await h.stepLedgerStore.load(executionId);
|
|
109
|
+
Object.defineProperty(ctx, 'ledger', {
|
|
110
|
+
value: new StepLedger({
|
|
111
|
+
executionId,
|
|
112
|
+
store: h.stepLedgerStore,
|
|
113
|
+
recovered,
|
|
114
|
+
}),
|
|
115
|
+
configurable: false,
|
|
116
|
+
writable: false,
|
|
117
|
+
enumerable: true,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
101
120
|
}
|
|
102
121
|
return ctx;
|
|
103
122
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The step completion ledger: what a resumed execution replays instead of re-running.
|
|
3
|
+
*
|
|
4
|
+
* The frontier records which steps were IN FLIGHT at snapshot time; it says nothing
|
|
5
|
+
* about which ones finished, and a finished step leaves no trace once `leaveStep` pops
|
|
6
|
+
* it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
|
|
7
|
+
* not merely a "done" flag: steps consume the previous step's value, and an `llm` step
|
|
8
|
+
* re-run to catch up produces a different one than the rest of the run already saw.
|
|
9
|
+
*
|
|
10
|
+
* Entries are stored one key per step so appends are O(1). Folding the whole ledger
|
|
11
|
+
* into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
|
|
12
|
+
* step — O(n²) bytes over a run.
|
|
13
|
+
*/
|
|
14
|
+
import type { StorageAdapter } from '@noetic-tools/memory';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
/** @public One completed step, keyed by its execution path. */
|
|
17
|
+
export declare const StepLedgerEntrySchema: z.ZodObject<{
|
|
18
|
+
path: z.ZodString;
|
|
19
|
+
stepId: z.ZodString;
|
|
20
|
+
kind: z.ZodString;
|
|
21
|
+
output: z.ZodUnknown;
|
|
22
|
+
completedAt: z.ZodString;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
/** @public A completed step recorded for replay. */
|
|
25
|
+
export type StepLedgerEntry = z.infer<typeof StepLedgerEntrySchema>;
|
|
26
|
+
/** Storage key prefix owning every ledger entry for one execution. */
|
|
27
|
+
export declare function stepLedgerPrefix(executionId: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* @public
|
|
30
|
+
* Durable append-only ledger for one harness's executions. Backed by the same
|
|
31
|
+
* `StorageAdapter` as the checkpoint store; it reserves the `:ledger:` suffix that
|
|
32
|
+
* `CheckpointKeys` already sets aside.
|
|
33
|
+
*/
|
|
34
|
+
export interface StepLedgerStore {
|
|
35
|
+
/** Record a completed step. Sequence numbers order entries within an execution. */
|
|
36
|
+
append: (executionId: string, seq: number, entry: StepLedgerEntry) => Promise<void>;
|
|
37
|
+
/** Every recorded entry for an execution, keyed by path. Corrupt rows are skipped. */
|
|
38
|
+
load: (executionId: string) => Promise<Map<string, StepLedgerEntry>>;
|
|
39
|
+
/** Drop an execution's ledger (paired with `CheckpointStore.clear`). */
|
|
40
|
+
clear: (executionId: string) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export declare function createStepLedgerStore(opts: {
|
|
43
|
+
storage: StorageAdapter;
|
|
44
|
+
}): StepLedgerStore;
|
|
45
|
+
/**
|
|
46
|
+
* @public
|
|
47
|
+
* The in-memory ledger a single execution carries: entries recovered from a previous
|
|
48
|
+
* run (available for replay) plus the sequence counter for newly recorded ones.
|
|
49
|
+
*
|
|
50
|
+
* Shared by reference across fork/spawn children so one execution has one ledger —
|
|
51
|
+
* path keys are globally unique across the step tree, so a flat map is correct.
|
|
52
|
+
*/
|
|
53
|
+
export declare class StepLedger {
|
|
54
|
+
/** Recovered entries, by path. Consumed as replay proceeds. */
|
|
55
|
+
private readonly replayable;
|
|
56
|
+
private seq;
|
|
57
|
+
private readonly store?;
|
|
58
|
+
private readonly executionId;
|
|
59
|
+
constructor(opts: {
|
|
60
|
+
executionId: string;
|
|
61
|
+
store?: StepLedgerStore;
|
|
62
|
+
recovered?: Map<string, StepLedgerEntry>;
|
|
63
|
+
});
|
|
64
|
+
/** True when this execution has nothing recovered to replay (the common, fresh case). */
|
|
65
|
+
get isEmpty(): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The recorded output for `path`, when a previous run completed the same step there.
|
|
68
|
+
* A divergence (different step id or kind at this path) discards the entry and every
|
|
69
|
+
* entry recorded beneath it, then returns undefined so the step runs fresh — the
|
|
70
|
+
* subtree's recorded outputs belong to a branch that no longer exists.
|
|
71
|
+
*/
|
|
72
|
+
take(path: string, step: {
|
|
73
|
+
id: string;
|
|
74
|
+
kind: string;
|
|
75
|
+
}): StepLedgerEntry | undefined;
|
|
76
|
+
/** Drop `path` and everything recorded below it. */
|
|
77
|
+
private discardSubtree;
|
|
78
|
+
/** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
|
|
79
|
+
record(entry: StepLedgerEntry): Promise<void>;
|
|
80
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The step completion ledger: what a resumed execution replays instead of re-running.
|
|
3
|
+
*
|
|
4
|
+
* The frontier records which steps were IN FLIGHT at snapshot time; it says nothing
|
|
5
|
+
* about which ones finished, and a finished step leaves no trace once `leaveStep` pops
|
|
6
|
+
* it. Resume therefore needs a separate record — and it must carry each step's OUTPUT,
|
|
7
|
+
* not merely a "done" flag: steps consume the previous step's value, and an `llm` step
|
|
8
|
+
* re-run to catch up produces a different one than the rest of the run already saw.
|
|
9
|
+
*
|
|
10
|
+
* Entries are stored one key per step so appends are O(1). Folding the whole ledger
|
|
11
|
+
* into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
|
|
12
|
+
* step — O(n²) bytes over a run.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** @public One completed step, keyed by its execution path. */
|
|
16
|
+
export const StepLedgerEntrySchema = z.object({
|
|
17
|
+
/** Execution path key — see `ContextImpl.currentPath()`. Unique per dispatch. */
|
|
18
|
+
path: z.string().min(1),
|
|
19
|
+
stepId: z.string(),
|
|
20
|
+
kind: z.string(),
|
|
21
|
+
/** Replayed verbatim in place of re-running the step. */
|
|
22
|
+
output: z.unknown(),
|
|
23
|
+
completedAt: z.string(),
|
|
24
|
+
});
|
|
25
|
+
/** Storage key prefix owning every ledger entry for one execution. */
|
|
26
|
+
export function stepLedgerPrefix(executionId) {
|
|
27
|
+
return `execution:${executionId}:ledger:`;
|
|
28
|
+
}
|
|
29
|
+
/** Zero-pad so lexicographic key order matches dispatch order under `list()`. */
|
|
30
|
+
function seqKey(executionId, seq) {
|
|
31
|
+
return `${stepLedgerPrefix(executionId)}${String(seq).padStart(8, '0')}`;
|
|
32
|
+
}
|
|
33
|
+
export function createStepLedgerStore(opts) {
|
|
34
|
+
const { storage } = opts;
|
|
35
|
+
return {
|
|
36
|
+
append: async (executionId, seq, entry) => {
|
|
37
|
+
await storage.set(seqKey(executionId, seq), entry);
|
|
38
|
+
},
|
|
39
|
+
load: async (executionId) => {
|
|
40
|
+
const keys = await storage.list(stepLedgerPrefix(executionId));
|
|
41
|
+
const byPath = new Map();
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const raw = await storage.get(key);
|
|
44
|
+
if (raw === null || raw === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const parsed = StepLedgerEntrySchema.safeParse(raw);
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
/* A row we cannot read is a step we cannot replay — it simply re-runs.
|
|
50
|
+
* Dropping it is safe; failing the resume over it would not be. */
|
|
51
|
+
console.warn(`StepLedger: discarding unreadable entry "${key}": ${parsed.error.message}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
byPath.set(parsed.data.path, parsed.data);
|
|
55
|
+
}
|
|
56
|
+
return byPath;
|
|
57
|
+
},
|
|
58
|
+
clear: async (executionId) => {
|
|
59
|
+
const keys = await storage.list(stepLedgerPrefix(executionId));
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
await storage.delete(key);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* @public
|
|
68
|
+
* The in-memory ledger a single execution carries: entries recovered from a previous
|
|
69
|
+
* run (available for replay) plus the sequence counter for newly recorded ones.
|
|
70
|
+
*
|
|
71
|
+
* Shared by reference across fork/spawn children so one execution has one ledger —
|
|
72
|
+
* path keys are globally unique across the step tree, so a flat map is correct.
|
|
73
|
+
*/
|
|
74
|
+
export class StepLedger {
|
|
75
|
+
/** Recovered entries, by path. Consumed as replay proceeds. */
|
|
76
|
+
replayable;
|
|
77
|
+
seq;
|
|
78
|
+
store;
|
|
79
|
+
executionId;
|
|
80
|
+
constructor(opts) {
|
|
81
|
+
this.executionId = opts.executionId;
|
|
82
|
+
this.store = opts.store;
|
|
83
|
+
this.replayable = opts.recovered ?? new Map();
|
|
84
|
+
this.seq = this.replayable.size;
|
|
85
|
+
}
|
|
86
|
+
/** True when this execution has nothing recovered to replay (the common, fresh case). */
|
|
87
|
+
get isEmpty() {
|
|
88
|
+
return this.replayable.size === 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The recorded output for `path`, when a previous run completed the same step there.
|
|
92
|
+
* A divergence (different step id or kind at this path) discards the entry and every
|
|
93
|
+
* entry recorded beneath it, then returns undefined so the step runs fresh — the
|
|
94
|
+
* subtree's recorded outputs belong to a branch that no longer exists.
|
|
95
|
+
*/
|
|
96
|
+
take(path, step) {
|
|
97
|
+
const entry = this.replayable.get(path);
|
|
98
|
+
if (!entry) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (entry.stepId !== step.id || entry.kind !== step.kind) {
|
|
102
|
+
this.discardSubtree(path);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
/** Drop `path` and everything recorded below it. */
|
|
108
|
+
discardSubtree(path) {
|
|
109
|
+
for (const key of [
|
|
110
|
+
...this.replayable.keys(),
|
|
111
|
+
]) {
|
|
112
|
+
if (key === path || key.startsWith(`${path}/`)) {
|
|
113
|
+
this.replayable.delete(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
|
|
118
|
+
async record(entry) {
|
|
119
|
+
if (!this.store) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const seq = this.seq;
|
|
123
|
+
this.seq += 1;
|
|
124
|
+
try {
|
|
125
|
+
await this.store.append(this.executionId, seq, entry);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.warn(`StepLedger: failed to record "${entry.path}":`, error instanceof Error ? error.message : String(error));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|