@noetic-tools/context 1.6.0 → 1.8.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/context/cache-anchoring.d.ts +7 -0
- package/dist/context/cache-anchoring.js +16 -0
- package/dist/context/exec-context-factory.js +16 -0
- package/dist/context/hook-call.d.ts +43 -0
- package/dist/context/hook-call.js +148 -0
- package/dist/context/layer-lifecycle.d.ts +28 -2
- package/dist/context/layer-lifecycle.js +231 -91
- package/dist/context/parent-updates.d.ts +44 -0
- package/dist/context/parent-updates.js +117 -0
- package/dist/context/span-export.d.ts +7 -0
- package/dist/context/span-export.js +13 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/package.json +2 -2
|
@@ -27,6 +27,13 @@ export declare function pinKey(layer: ContextLayer): string;
|
|
|
27
27
|
export declare function createContextCacheStore(): ContextCacheStore;
|
|
28
28
|
/** Drop a lineage's epoch — used when a child execution ends. */
|
|
29
29
|
export declare function dropLineage(store: ContextCacheStore, key: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Drop the pins of layers that left a lineage's layer set — a closing
|
|
32
|
+
* `withContext` boundary. A pin outlives its layer by design (dropping it
|
|
33
|
+
* shortens the cached prefix), so a scoped layer must be unpinned explicitly or
|
|
34
|
+
* its last render keeps replaying after its scope ends.
|
|
35
|
+
*/
|
|
36
|
+
export declare function unpinLayers(store: ContextCacheStore, key: string, layers: ContextLayer[]): void;
|
|
30
37
|
/** Read a layer's churn record, creating an empty one on first sight. */
|
|
31
38
|
export declare function churnFor(store: ContextCacheStore, key: string): LayerChurn;
|
|
32
39
|
/** Share of watched assemblies in which a layer's output changed, 0–1. */
|
|
@@ -66,6 +66,22 @@ export function dropLineage(store, key) {
|
|
|
66
66
|
store.epochs.delete(key);
|
|
67
67
|
store.pendingReanchor.delete(key);
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Drop the pins of layers that left a lineage's layer set — a closing
|
|
71
|
+
* `withContext` boundary. A pin outlives its layer by design (dropping it
|
|
72
|
+
* shortens the cached prefix), so a scoped layer must be unpinned explicitly or
|
|
73
|
+
* its last render keeps replaying after its scope ends.
|
|
74
|
+
*/
|
|
75
|
+
export function unpinLayers(store, key, layers) {
|
|
76
|
+
const epoch = store.epochs.get(key);
|
|
77
|
+
if (!epoch) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const layer of layers) {
|
|
81
|
+
epoch.pins.delete(pinKey(layer));
|
|
82
|
+
epoch.autoBand.delete(pinKey(layer));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
69
85
|
/** Read a layer's churn record, creating an empty one on first sight. */
|
|
70
86
|
export function churnFor(store, key) {
|
|
71
87
|
const found = store.churn.get(key);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { estimateTokens } from '@noetic-tools/types';
|
|
2
|
+
import { exportSpans } from './span-export.js';
|
|
2
3
|
/**
|
|
3
4
|
* Maps a full `Context` to the lightweight `ExecutionContext` consumed by context layer hooks.
|
|
4
5
|
* Centralised here so the mapping stays consistent across agent-harness, layer-api, and the
|
|
@@ -24,7 +25,22 @@ export function contextToExecCtx(ctx, overrides) {
|
|
|
24
25
|
trace: {
|
|
25
26
|
setAttribute: (key, value) => ctx.span.setAttribute(key, value),
|
|
26
27
|
addEvent: (name, attributes) => ctx.span.addEvent(name, attributes),
|
|
28
|
+
createSpan: (name) => openSpan(ctx, name),
|
|
27
29
|
},
|
|
28
30
|
readLayerState: (layerId) => ctx.harness.getLayerState(readLayerStateId, layerId),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
33
|
+
/** Opens a span under `ctx.span` that exports itself to the harness when it ends. */
|
|
34
|
+
function openSpan(ctx, name) {
|
|
35
|
+
const span = ctx.harness.createSpan(name, ctx.span);
|
|
36
|
+
return {
|
|
37
|
+
setAttribute: (key, value) => span.setAttribute(key, value),
|
|
38
|
+
addEvent: (event, attributes) => span.addEvent(event, attributes),
|
|
39
|
+
end: async () => {
|
|
40
|
+
span.end();
|
|
41
|
+
await exportSpans(ctx.harness.traceExporter, [
|
|
42
|
+
span,
|
|
43
|
+
]);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timed, traced calls into context layer hooks. Each call of a traced hook is
|
|
3
|
+
* recorded as one `layer.hook` record shaped as a `LayerTraceSpan`: a child
|
|
4
|
+
* span of the context's span when the context can open one
|
|
5
|
+
* (`ctx.trace.createSpan`), otherwise an event on the context's span. Never
|
|
6
|
+
* both, so a backend sees each call once.
|
|
7
|
+
*/
|
|
8
|
+
import type { ExecutionContext, LayerTraceSpan } from '@noetic-tools/types';
|
|
9
|
+
type TracedHook = LayerTraceSpan['hook'];
|
|
10
|
+
interface HookCall<T> {
|
|
11
|
+
ctx: ExecutionContext;
|
|
12
|
+
layerId: string;
|
|
13
|
+
hook: TracedHook;
|
|
14
|
+
/** Milliseconds before the call fails as a timeout; `0` or less waits forever. */
|
|
15
|
+
timeout: number;
|
|
16
|
+
run: () => Promise<T>;
|
|
17
|
+
/** Reads a recall's budget and item count from the hook's result. */
|
|
18
|
+
measure?: (result: T) => Pick<LayerTraceSpan, 'budget' | 'itemCount'>;
|
|
19
|
+
}
|
|
20
|
+
/** Name of the span (or span event) recording one layer hook call. */
|
|
21
|
+
export declare const LAYER_HOOK_EVENT = "layer.hook";
|
|
22
|
+
/** Attributes of a `layer.hook` span. A span times itself, so it carries no duration. */
|
|
23
|
+
export declare const LayerHookAttr: {
|
|
24
|
+
readonly LAYER_ID: "noetic.layer.id";
|
|
25
|
+
readonly HOOK: "noetic.layer.hook";
|
|
26
|
+
readonly STATUS: "noetic.layer.status";
|
|
27
|
+
readonly ITEM_COUNT: "noetic.layer.item_count";
|
|
28
|
+
readonly BUDGET_ALLOCATED: "noetic.layer.budget.allocated";
|
|
29
|
+
readonly BUDGET_USED: "noetic.layer.budget.used";
|
|
30
|
+
readonly BUDGET_YIELDED: "noetic.layer.budget.yielded";
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Runs a layer hook under its timeout and records a `layer.hook` span (or
|
|
34
|
+
* event) for it. Rethrows whatever the hook threw (or the timeout), so callers
|
|
35
|
+
* keep their own failure handling. The span opens before the hook runs and ends
|
|
36
|
+
* once it settles.
|
|
37
|
+
*/
|
|
38
|
+
export declare function callHook<T>({ ctx, layerId, hook, timeout, run, measure, }: HookCall<T>): Promise<T>;
|
|
39
|
+
/** Records a hook the layer defines but the lifecycle did not call. */
|
|
40
|
+
export declare function traceSkipped(ctx: ExecutionContext, layerId: string, hook: TracedHook): Promise<void>;
|
|
41
|
+
/** Runs a hook under a timeout without tracing it, for calls that must leave no trace (a peek recall). */
|
|
42
|
+
export declare function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timed, traced calls into context layer hooks. Each call of a traced hook is
|
|
3
|
+
* recorded as one `layer.hook` record shaped as a `LayerTraceSpan`: a child
|
|
4
|
+
* span of the context's span when the context can open one
|
|
5
|
+
* (`ctx.trace.createSpan`), otherwise an event on the context's span. Never
|
|
6
|
+
* both, so a backend sees each call once.
|
|
7
|
+
*/
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region Public API
|
|
10
|
+
/** Name of the span (or span event) recording one layer hook call. */
|
|
11
|
+
export const LAYER_HOOK_EVENT = 'layer.hook';
|
|
12
|
+
/** Attributes of a `layer.hook` span. A span times itself, so it carries no duration. */
|
|
13
|
+
export const LayerHookAttr = {
|
|
14
|
+
LAYER_ID: 'noetic.layer.id',
|
|
15
|
+
HOOK: 'noetic.layer.hook',
|
|
16
|
+
STATUS: 'noetic.layer.status',
|
|
17
|
+
ITEM_COUNT: 'noetic.layer.item_count',
|
|
18
|
+
BUDGET_ALLOCATED: 'noetic.layer.budget.allocated',
|
|
19
|
+
BUDGET_USED: 'noetic.layer.budget.used',
|
|
20
|
+
BUDGET_YIELDED: 'noetic.layer.budget.yielded',
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Runs a layer hook under its timeout and records a `layer.hook` span (or
|
|
24
|
+
* event) for it. Rethrows whatever the hook threw (or the timeout), so callers
|
|
25
|
+
* keep their own failure handling. The span opens before the hook runs and ends
|
|
26
|
+
* once it settles.
|
|
27
|
+
*/
|
|
28
|
+
export async function callHook({ ctx, layerId, hook, timeout, run, measure, }) {
|
|
29
|
+
const span = ctx.trace.createSpan?.(LAYER_HOOK_EVENT);
|
|
30
|
+
const started = performance.now();
|
|
31
|
+
const outcome = await settle(withTimeout(run(), timeout));
|
|
32
|
+
const duration = performance.now() - started;
|
|
33
|
+
if (outcome.ok) {
|
|
34
|
+
await traceHook(ctx, span, {
|
|
35
|
+
layerId,
|
|
36
|
+
hook,
|
|
37
|
+
duration,
|
|
38
|
+
status: 'ok',
|
|
39
|
+
...measure?.(outcome.value),
|
|
40
|
+
});
|
|
41
|
+
return outcome.value;
|
|
42
|
+
}
|
|
43
|
+
await traceHook(ctx, span, {
|
|
44
|
+
layerId,
|
|
45
|
+
hook,
|
|
46
|
+
duration,
|
|
47
|
+
status: outcome.error instanceof HookTimeout ? 'timeout' : 'error',
|
|
48
|
+
error: {
|
|
49
|
+
message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
throw outcome.error;
|
|
53
|
+
}
|
|
54
|
+
/** Records a hook the layer defines but the lifecycle did not call. */
|
|
55
|
+
export async function traceSkipped(ctx, layerId, hook) {
|
|
56
|
+
await traceHook(ctx, ctx.trace.createSpan?.(LAYER_HOOK_EVENT), {
|
|
57
|
+
layerId,
|
|
58
|
+
hook,
|
|
59
|
+
duration: 0,
|
|
60
|
+
status: 'skipped',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Runs a hook under a timeout without tracing it, for calls that must leave no trace (a peek recall). */
|
|
64
|
+
export async function withTimeout(promise, ms) {
|
|
65
|
+
if (ms <= 0) {
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
68
|
+
let timer;
|
|
69
|
+
return Promise.race([
|
|
70
|
+
promise.finally(() => clearTimeout(timer)),
|
|
71
|
+
new Promise((_, reject) => {
|
|
72
|
+
timer = setTimeout(() => reject(new HookTimeout(ms)), ms);
|
|
73
|
+
}),
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region Helpers
|
|
78
|
+
class HookTimeout extends Error {
|
|
79
|
+
constructor(ms) {
|
|
80
|
+
super(`Timeout after ${ms}ms`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function settle(promise) {
|
|
84
|
+
try {
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
value: await promise,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function traceHook(ctx, span, record) {
|
|
98
|
+
if (!span) {
|
|
99
|
+
ctx.trace.addEvent(LAYER_HOOK_EVENT, eventAttributes(record));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const [key, value] of Object.entries(spanAttributes(record))) {
|
|
103
|
+
span.setAttribute(key, value);
|
|
104
|
+
}
|
|
105
|
+
await span.end();
|
|
106
|
+
}
|
|
107
|
+
function spanAttributes(record) {
|
|
108
|
+
const attributes = {
|
|
109
|
+
[LayerHookAttr.LAYER_ID]: record.layerId,
|
|
110
|
+
[LayerHookAttr.HOOK]: record.hook,
|
|
111
|
+
[LayerHookAttr.STATUS]: record.status,
|
|
112
|
+
};
|
|
113
|
+
if (record.itemCount !== undefined) {
|
|
114
|
+
attributes[LayerHookAttr.ITEM_COUNT] = record.itemCount;
|
|
115
|
+
}
|
|
116
|
+
if (record.budget) {
|
|
117
|
+
attributes[LayerHookAttr.BUDGET_ALLOCATED] = record.budget.allocated;
|
|
118
|
+
attributes[LayerHookAttr.BUDGET_USED] = record.budget.used;
|
|
119
|
+
attributes[LayerHookAttr.BUDGET_YIELDED] = record.budget.yielded;
|
|
120
|
+
}
|
|
121
|
+
if (record.error) {
|
|
122
|
+
attributes.error = true;
|
|
123
|
+
attributes['error.message'] = record.error.message;
|
|
124
|
+
}
|
|
125
|
+
return attributes;
|
|
126
|
+
}
|
|
127
|
+
/** Span event attributes hold only scalars, so nested fields flatten to dotted keys. */
|
|
128
|
+
function eventAttributes(record) {
|
|
129
|
+
const attributes = {
|
|
130
|
+
layerId: record.layerId,
|
|
131
|
+
hook: record.hook,
|
|
132
|
+
duration: record.duration,
|
|
133
|
+
status: record.status,
|
|
134
|
+
};
|
|
135
|
+
if (record.itemCount !== undefined) {
|
|
136
|
+
attributes.itemCount = record.itemCount;
|
|
137
|
+
}
|
|
138
|
+
if (record.budget) {
|
|
139
|
+
attributes['budget.allocated'] = record.budget.allocated;
|
|
140
|
+
attributes['budget.used'] = record.budget.used;
|
|
141
|
+
attributes['budget.yielded'] = record.budget.yielded;
|
|
142
|
+
}
|
|
143
|
+
if (record.error) {
|
|
144
|
+
attributes['error.message'] = record.error.message;
|
|
145
|
+
}
|
|
146
|
+
return attributes;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ContextLayer, DecisionRecord, ExecutionContext, Item, ItemLog, ItemSchemaRegistry, LLMResponse, ScopedStorage, SteeringDecision, StorageAdapter } from '@noetic-tools/types';
|
|
2
|
+
import type { LiveChild } from './parent-updates.js';
|
|
2
3
|
export interface LayerStateStore {
|
|
3
4
|
get<T>(executionId: string, layerId: string): T | undefined;
|
|
4
5
|
set<T>(executionId: string, layerId: string, state: T): void;
|
|
@@ -13,6 +14,13 @@ export interface LayerStateStore {
|
|
|
13
14
|
*/
|
|
14
15
|
has?(executionId: string, layerId: string): boolean;
|
|
15
16
|
cleanup(executionId: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Forget one layer's state, durable target, and disabled mark for an
|
|
19
|
+
* execution, leaving the execution's other layers alone. Optional so custom
|
|
20
|
+
* stores keep compiling; without it, a scoped dispose leaves the state in
|
|
21
|
+
* place for the next `init` to overwrite.
|
|
22
|
+
*/
|
|
23
|
+
drop?(executionId: string, layerId: string): void;
|
|
16
24
|
diagnostic: (layerId: string, hook: string, error: unknown) => void;
|
|
17
25
|
/**
|
|
18
26
|
* Register a durable write-through target for a layer's state under an
|
|
@@ -28,7 +36,8 @@ export interface LayerStateStore {
|
|
|
28
36
|
flush?(executionId: string): Promise<void>;
|
|
29
37
|
/**
|
|
30
38
|
* Mark a layer disabled for an execution (its `init` failed with
|
|
31
|
-
* `onInitError: 'disable'
|
|
39
|
+
* `onInitError: 'disable'`, or its `onSpawn` returned `null` or failed for
|
|
40
|
+
* a child execution). Disabled layers are skipped by every later
|
|
32
41
|
* hook. Optional so custom stores keep compiling; without explicit
|
|
33
42
|
* tracking, the lifecycle falls back to the legacy sentinel (init-bearing
|
|
34
43
|
* layer with no state).
|
|
@@ -36,6 +45,17 @@ export interface LayerStateStore {
|
|
|
36
45
|
disable?(executionId: string, layerId: string): void;
|
|
37
46
|
/** Whether a layer was disabled for an execution. */
|
|
38
47
|
isDisabled?(executionId: string, layerId: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Register a running child. From now on every `set()` of the parent's state
|
|
50
|
+
* for a layer is delivered to that layer's `onParentUpdate` in the child, in
|
|
51
|
+
* the background. `cleanup(childExecutionId)` forgets the child. Optional so
|
|
52
|
+
* custom stores keep compiling; without it, children get no parent updates.
|
|
53
|
+
*/
|
|
54
|
+
adopt?(child: LiveChild): void;
|
|
55
|
+
/** Stop parent updates to a child and wait for deliveries already running. */
|
|
56
|
+
release?(childExecutionId: string): Promise<void>;
|
|
57
|
+
/** Number of registered children, for leak checks. */
|
|
58
|
+
liveChildren?(): number;
|
|
39
59
|
}
|
|
40
60
|
interface InitLayersParams {
|
|
41
61
|
layers: ContextLayer[];
|
|
@@ -113,6 +133,12 @@ interface DisposeLayersParams {
|
|
|
113
133
|
layers: ContextLayer[];
|
|
114
134
|
ctx: ExecutionContext;
|
|
115
135
|
store: LayerStateStore;
|
|
136
|
+
/**
|
|
137
|
+
* The layers share their execution with layers that stay live (a
|
|
138
|
+
* `withContext` boundary closing). Drop only these layers' state instead of
|
|
139
|
+
* cleaning up the whole execution.
|
|
140
|
+
*/
|
|
141
|
+
scoped?: boolean;
|
|
116
142
|
}
|
|
117
143
|
interface BeforeToolCallLayersParams {
|
|
118
144
|
layers: ContextLayer[];
|
|
@@ -231,7 +257,7 @@ interface NotifyDecisionParams {
|
|
|
231
257
|
* what an earlier one recorded.
|
|
232
258
|
*/
|
|
233
259
|
export declare function notifyDecision({ layers, record, ctx, store, recallCache, }: NotifyDecisionParams): Promise<void>;
|
|
234
|
-
export declare function disposeLayers({ layers, ctx, store }: DisposeLayersParams): Promise<void>;
|
|
260
|
+
export declare function disposeLayers({ layers, ctx, store, scoped, }: DisposeLayersParams): Promise<void>;
|
|
235
261
|
export declare function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas, storage, }: SpawnLayersParams): Promise<{
|
|
236
262
|
layerId: string;
|
|
237
263
|
childState: unknown;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createMessage, defaultItemSchemaRegistry, estimateTokens, frameworkCast, NoeticConfigError, NoeticErrorImpl, SteeringAction, } from '@noetic-tools/types';
|
|
2
|
+
import { callHook, traceSkipped, withTimeout } from './hook-call.js';
|
|
3
|
+
import { createLiveChildren } from './parent-updates.js';
|
|
2
4
|
import { createScopedStorage, resolveScopeKey } from './scope.js';
|
|
3
5
|
/** Maximum allowed re-render depth to prevent infinite loops */
|
|
4
6
|
const MAX_RERENDER_DEPTH = 3;
|
|
@@ -28,7 +30,7 @@ export function createLayerStateStore(diagnostic) {
|
|
|
28
30
|
}
|
|
29
31
|
entry.inFlight = null;
|
|
30
32
|
}
|
|
31
|
-
|
|
33
|
+
const store = {
|
|
32
34
|
get(executionId, layerId) {
|
|
33
35
|
return frameworkCast(states.get(executionId)?.get(layerId));
|
|
34
36
|
},
|
|
@@ -39,6 +41,7 @@ export function createLayerStateStore(diagnostic) {
|
|
|
39
41
|
states.set(executionId, execMap);
|
|
40
42
|
}
|
|
41
43
|
execMap.set(layerId, state);
|
|
44
|
+
children.notify(executionId, layerId, state);
|
|
42
45
|
const entry = durables.get(executionId)?.get(layerId);
|
|
43
46
|
if (!entry) {
|
|
44
47
|
return;
|
|
@@ -87,12 +90,34 @@ export function createLayerStateStore(diagnostic) {
|
|
|
87
90
|
return disabled.get(executionId)?.has(layerId) ?? false;
|
|
88
91
|
},
|
|
89
92
|
cleanup(executionId) {
|
|
93
|
+
children.drop(executionId);
|
|
90
94
|
states.delete(executionId);
|
|
91
95
|
durables.delete(executionId);
|
|
92
96
|
disabled.delete(executionId);
|
|
93
97
|
},
|
|
98
|
+
drop(executionId, layerId) {
|
|
99
|
+
states.get(executionId)?.delete(layerId);
|
|
100
|
+
durables.get(executionId)?.delete(layerId);
|
|
101
|
+
disabled.get(executionId)?.delete(layerId);
|
|
102
|
+
},
|
|
103
|
+
adopt(child) {
|
|
104
|
+
children.adopt(child);
|
|
105
|
+
},
|
|
106
|
+
release(childExecutionId) {
|
|
107
|
+
return children.release(childExecutionId);
|
|
108
|
+
},
|
|
109
|
+
liveChildren() {
|
|
110
|
+
return children.size;
|
|
111
|
+
},
|
|
94
112
|
diagnostic: report,
|
|
95
113
|
};
|
|
114
|
+
const children = createLiveChildren(store, (executionId, layer) => isLayerDisabled({
|
|
115
|
+
store,
|
|
116
|
+
executionId,
|
|
117
|
+
layer,
|
|
118
|
+
state: store.get(executionId, layer.id),
|
|
119
|
+
}));
|
|
120
|
+
return store;
|
|
96
121
|
}
|
|
97
122
|
/**
|
|
98
123
|
* Whether a layer must be skipped for this execution — either explicitly
|
|
@@ -136,18 +161,6 @@ function recallCacheKey(layer, ctx) {
|
|
|
136
161
|
return `${resolveScopeKey(layer.scope, ctx)}:${layer.id}`;
|
|
137
162
|
}
|
|
138
163
|
//#endregion
|
|
139
|
-
async function withTimeout(promise, ms) {
|
|
140
|
-
if (ms <= 0) {
|
|
141
|
-
return promise;
|
|
142
|
-
}
|
|
143
|
-
let timer;
|
|
144
|
-
return Promise.race([
|
|
145
|
-
promise.finally(() => clearTimeout(timer)),
|
|
146
|
-
new Promise((_, reject) => {
|
|
147
|
-
timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
|
|
148
|
-
}),
|
|
149
|
-
]);
|
|
150
|
-
}
|
|
151
164
|
export function mostRestrictive(decisions) {
|
|
152
165
|
const guidances = [];
|
|
153
166
|
let hasGuide = false;
|
|
@@ -202,18 +215,25 @@ export async function initLayers({ layers, ctx, storage, store, keep = false, })
|
|
|
202
215
|
if (!layer.hooks.init) {
|
|
203
216
|
continue;
|
|
204
217
|
}
|
|
218
|
+
const init = layer.hooks.init;
|
|
205
219
|
if (keep && store.has?.(ctx.executionId, layer.id)) {
|
|
220
|
+
await traceSkipped(ctx, layer.id, 'init');
|
|
206
221
|
continue;
|
|
207
222
|
}
|
|
208
223
|
const scopeKey = resolveScopeKey(layer.scope, ctx);
|
|
209
224
|
const scopedStorage = createScopedStorage(storage, layer.id, scopeKey);
|
|
210
225
|
try {
|
|
211
|
-
const
|
|
212
|
-
const result = await withTimeout(layer.hooks.init({
|
|
213
|
-
storage: scopedStorage,
|
|
214
|
-
scopeKey,
|
|
226
|
+
const result = await callHook({
|
|
215
227
|
ctx,
|
|
216
|
-
|
|
228
|
+
layerId: layer.id,
|
|
229
|
+
hook: 'init',
|
|
230
|
+
timeout: layer.timeouts?.init ?? 1e4,
|
|
231
|
+
run: () => init({
|
|
232
|
+
storage: scopedStorage,
|
|
233
|
+
scopeKey,
|
|
234
|
+
ctx,
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
217
237
|
store.set(ctx.executionId, layer.id, result.state);
|
|
218
238
|
}
|
|
219
239
|
catch (e) {
|
|
@@ -272,6 +292,9 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
|
|
|
272
292
|
layer,
|
|
273
293
|
state: stored,
|
|
274
294
|
})) {
|
|
295
|
+
if (!peek) {
|
|
296
|
+
await traceSkipped(ctx, layer.id, 'recall');
|
|
297
|
+
}
|
|
275
298
|
continue; // was disabled
|
|
276
299
|
}
|
|
277
300
|
// Skipping the write is not enough for a peek: a hook may mutate the
|
|
@@ -281,15 +304,27 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
|
|
|
281
304
|
continue;
|
|
282
305
|
}
|
|
283
306
|
const budget = budgets.get(layer.id) ?? 0;
|
|
307
|
+
const recall = layer.hooks.recall;
|
|
308
|
+
const run = () => recall({
|
|
309
|
+
log,
|
|
310
|
+
query,
|
|
311
|
+
ctx,
|
|
312
|
+
state,
|
|
313
|
+
budget,
|
|
314
|
+
});
|
|
315
|
+
const timeout = layer.timeouts?.recall ?? 5e3;
|
|
284
316
|
try {
|
|
285
|
-
|
|
286
|
-
const result =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
317
|
+
// A peek renders without leaving a trace, so it records no event either.
|
|
318
|
+
const result = peek
|
|
319
|
+
? await withTimeout(run(), timeout)
|
|
320
|
+
: await callHook({
|
|
321
|
+
ctx,
|
|
322
|
+
layerId: layer.id,
|
|
323
|
+
hook: 'recall',
|
|
324
|
+
timeout,
|
|
325
|
+
run,
|
|
326
|
+
measure: (output) => measureRecall(output, budget),
|
|
327
|
+
});
|
|
293
328
|
if (!result) {
|
|
294
329
|
continue;
|
|
295
330
|
}
|
|
@@ -324,6 +359,24 @@ export async function recallLayers({ layers, query, ctx, log, budgets, store, it
|
|
|
324
359
|
return results;
|
|
325
360
|
}
|
|
326
361
|
const PEEK_UNSAFE = Symbol('peek-unsafe');
|
|
362
|
+
/** Budget and item count for a `layer.hook` recall event. */
|
|
363
|
+
function measureRecall(output, allocated) {
|
|
364
|
+
const measured = (used, itemCount) => ({
|
|
365
|
+
budget: {
|
|
366
|
+
allocated,
|
|
367
|
+
used,
|
|
368
|
+
yielded: Math.max(0, allocated - used),
|
|
369
|
+
},
|
|
370
|
+
itemCount,
|
|
371
|
+
});
|
|
372
|
+
if (!output) {
|
|
373
|
+
return measured(0, 0);
|
|
374
|
+
}
|
|
375
|
+
if (typeof output === 'string') {
|
|
376
|
+
return measured(estimateTokens(output), 1);
|
|
377
|
+
}
|
|
378
|
+
return measured(output.tokenCount, output.items.length);
|
|
379
|
+
}
|
|
327
380
|
/** A deep copy, or `PEEK_UNSAFE` when the state cannot be copied (it holds functions). */
|
|
328
381
|
function copyForPeek(state) {
|
|
329
382
|
try {
|
|
@@ -415,6 +468,7 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
|
|
|
415
468
|
layer,
|
|
416
469
|
state,
|
|
417
470
|
})) {
|
|
471
|
+
await traceSkipped(ctx, layer.id, 'store');
|
|
418
472
|
continue;
|
|
419
473
|
}
|
|
420
474
|
snapshots.push({
|
|
@@ -424,15 +478,20 @@ export async function storeLayers({ layers, response, ctx, log, store, recallCac
|
|
|
424
478
|
});
|
|
425
479
|
}
|
|
426
480
|
await Promise.allSettled(snapshots.map(async ({ layer, state, storeFn }) => {
|
|
427
|
-
const timeout = layer.timeouts?.store ?? 3e4;
|
|
428
481
|
try {
|
|
429
|
-
const result = await
|
|
430
|
-
newItems: response.items,
|
|
431
|
-
log,
|
|
432
|
-
response,
|
|
482
|
+
const result = await callHook({
|
|
433
483
|
ctx,
|
|
434
|
-
|
|
435
|
-
|
|
484
|
+
layerId: layer.id,
|
|
485
|
+
hook: 'store',
|
|
486
|
+
timeout: layer.timeouts?.store ?? 3e4,
|
|
487
|
+
run: () => storeFn({
|
|
488
|
+
newItems: response.items,
|
|
489
|
+
log,
|
|
490
|
+
response,
|
|
491
|
+
ctx,
|
|
492
|
+
state,
|
|
493
|
+
}),
|
|
494
|
+
});
|
|
436
495
|
// `'state' in result` (not `!== undefined`) so a layer can explicitly
|
|
437
496
|
// clear its state by returning `{ state: undefined }`. The store's
|
|
438
497
|
// durable write-through mirrors the write (clearing deletes the
|
|
@@ -472,14 +531,22 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
|
|
|
472
531
|
layer,
|
|
473
532
|
state,
|
|
474
533
|
})) {
|
|
534
|
+
await traceSkipped(ctx, layer.id, 'onDecision');
|
|
475
535
|
continue;
|
|
476
536
|
}
|
|
537
|
+
const onDecision = layer.hooks.onDecision;
|
|
477
538
|
try {
|
|
478
|
-
const result = await
|
|
479
|
-
state,
|
|
480
|
-
record,
|
|
539
|
+
const result = await callHook({
|
|
481
540
|
ctx,
|
|
482
|
-
|
|
541
|
+
layerId: layer.id,
|
|
542
|
+
hook: 'onDecision',
|
|
543
|
+
timeout: layer.timeouts?.onDecision ?? 5e3,
|
|
544
|
+
run: () => onDecision({
|
|
545
|
+
state,
|
|
546
|
+
record,
|
|
547
|
+
ctx,
|
|
548
|
+
}),
|
|
549
|
+
});
|
|
483
550
|
if (result && 'state' in result) {
|
|
484
551
|
store.set(ctx.executionId, layer.id, result.state);
|
|
485
552
|
if (recallCache && layer.recallMode === 'eventual') {
|
|
@@ -493,7 +560,7 @@ export async function notifyDecision({ layers, record, ctx, store, recallCache,
|
|
|
493
560
|
}
|
|
494
561
|
await store.flush?.(ctx.executionId);
|
|
495
562
|
}
|
|
496
|
-
export async function disposeLayers({ layers, ctx, store }) {
|
|
563
|
+
export async function disposeLayers({ layers, ctx, store, scoped = false, }) {
|
|
497
564
|
// Sequential, REVERSE array order
|
|
498
565
|
const reversed = [
|
|
499
566
|
...layers,
|
|
@@ -511,13 +578,20 @@ export async function disposeLayers({ layers, ctx, store }) {
|
|
|
511
578
|
layer,
|
|
512
579
|
state,
|
|
513
580
|
})) {
|
|
581
|
+
await traceSkipped(ctx, layer.id, 'dispose');
|
|
514
582
|
continue;
|
|
515
583
|
}
|
|
584
|
+
const dispose = layer.hooks.dispose;
|
|
516
585
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
586
|
+
await callHook({
|
|
587
|
+
ctx,
|
|
588
|
+
layerId: layer.id,
|
|
589
|
+
hook: 'dispose',
|
|
590
|
+
timeout: layer.timeouts?.dispose ?? 5e3,
|
|
591
|
+
run: () => dispose({
|
|
592
|
+
state,
|
|
593
|
+
}),
|
|
594
|
+
});
|
|
521
595
|
}
|
|
522
596
|
catch (e) {
|
|
523
597
|
store.diagnostic(layer.id, 'dispose', e);
|
|
@@ -525,7 +599,13 @@ export async function disposeLayers({ layers, ctx, store }) {
|
|
|
525
599
|
}
|
|
526
600
|
// Settle any in-flight durable mirror writes before dropping registrations.
|
|
527
601
|
await store.flush?.(ctx.executionId);
|
|
528
|
-
|
|
602
|
+
if (!scoped) {
|
|
603
|
+
store.cleanup(ctx.executionId);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
for (const layer of layers) {
|
|
607
|
+
store.drop?.(ctx.executionId, layer.id);
|
|
608
|
+
}
|
|
529
609
|
}
|
|
530
610
|
export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSchemas = defaultItemSchemaRegistry, storage, }) {
|
|
531
611
|
if (storage) {
|
|
@@ -554,25 +634,40 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
|
|
|
554
634
|
layer,
|
|
555
635
|
state: parentState,
|
|
556
636
|
})) {
|
|
637
|
+
await traceSkipped(parentCtx, layer.id, 'onSpawn');
|
|
557
638
|
continue;
|
|
558
639
|
}
|
|
640
|
+
const onSpawn = layer.hooks.onSpawn;
|
|
559
641
|
try {
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
642
|
+
const result = await callHook({
|
|
643
|
+
ctx: parentCtx,
|
|
644
|
+
layerId: layer.id,
|
|
645
|
+
hook: 'onSpawn',
|
|
646
|
+
timeout: layer.timeouts?.onSpawn ?? 1e4,
|
|
647
|
+
run: () => onSpawn({
|
|
648
|
+
parentState,
|
|
649
|
+
childCtx,
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
// A `null` result opts the layer out of this child: every later hook
|
|
653
|
+
// skips it for the child's execution.
|
|
654
|
+
if (!result) {
|
|
655
|
+
store.disable?.(childCtx.executionId, layer.id);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
// `childState: null` still injects items; the child just gets no state.
|
|
659
|
+
if (result.childState !== null) {
|
|
566
660
|
store.set(childCtx.executionId, layer.id, result.childState);
|
|
567
|
-
results.push({
|
|
568
|
-
layerId: layer.id,
|
|
569
|
-
childState: result.childState,
|
|
570
|
-
items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
|
|
571
|
-
});
|
|
572
661
|
}
|
|
662
|
+
results.push({
|
|
663
|
+
layerId: layer.id,
|
|
664
|
+
childState: result.childState,
|
|
665
|
+
items: layerItemSchemas(layer, itemSchemas).parseMany(result.items ?? []),
|
|
666
|
+
});
|
|
573
667
|
}
|
|
574
668
|
catch (e) {
|
|
575
669
|
store.diagnostic(layer.id, 'onSpawn', e);
|
|
670
|
+
store.disable?.(childCtx.executionId, layer.id);
|
|
576
671
|
}
|
|
577
672
|
}
|
|
578
673
|
return results;
|
|
@@ -585,6 +680,9 @@ export async function spawnLayers({ layers, parentCtx, childCtx, store, itemSche
|
|
|
585
680
|
* `spawn_summary_failed` diagnostic plus a span event on the child's span.
|
|
586
681
|
*/
|
|
587
682
|
export async function returnLayers({ layers, stepId, parentCtx, childCtx, childLog, result, store, }) {
|
|
683
|
+
// The child has finished: no more parent updates, and the ones in flight
|
|
684
|
+
// land before its state is merged.
|
|
685
|
+
await store.release?.(childCtx.executionId);
|
|
588
686
|
const sorted = [
|
|
589
687
|
...layers,
|
|
590
688
|
].sort((a, b) => a.slot - b.slot);
|
|
@@ -599,17 +697,24 @@ export async function returnLayers({ layers, stepId, parentCtx, childCtx, childL
|
|
|
599
697
|
// initialized state can still be seeded from the child. Skip only when the
|
|
600
698
|
// child produced nothing.
|
|
601
699
|
if (childState === undefined) {
|
|
700
|
+
await traceSkipped(childCtx, layer.id, 'onReturn');
|
|
602
701
|
continue;
|
|
603
702
|
}
|
|
703
|
+
const onReturn = layer.hooks.onReturn;
|
|
604
704
|
try {
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
705
|
+
const returnResult = await callHook({
|
|
706
|
+
ctx: childCtx,
|
|
707
|
+
layerId: layer.id,
|
|
708
|
+
hook: 'onReturn',
|
|
709
|
+
timeout: layer.timeouts?.onReturn ?? 1e4,
|
|
710
|
+
run: () => onReturn({
|
|
711
|
+
childState,
|
|
712
|
+
childLog,
|
|
713
|
+
parentState,
|
|
714
|
+
result: currentResult,
|
|
715
|
+
childCtx,
|
|
716
|
+
}),
|
|
717
|
+
});
|
|
613
718
|
if (returnResult?.parentState !== undefined) {
|
|
614
719
|
store.set(parentCtx.executionId, layer.id, returnResult.parentState);
|
|
615
720
|
}
|
|
@@ -648,16 +753,23 @@ export async function completeLayers({ layers, ctx, log, outcome, store, }) {
|
|
|
648
753
|
layer,
|
|
649
754
|
state,
|
|
650
755
|
})) {
|
|
756
|
+
await traceSkipped(ctx, layer.id, 'onComplete');
|
|
651
757
|
continue;
|
|
652
758
|
}
|
|
759
|
+
const onComplete = layer.hooks.onComplete;
|
|
653
760
|
try {
|
|
654
|
-
const
|
|
655
|
-
const result = await withTimeout(layer.hooks.onComplete({
|
|
656
|
-
log,
|
|
761
|
+
const result = await callHook({
|
|
657
762
|
ctx,
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
763
|
+
layerId: layer.id,
|
|
764
|
+
hook: 'onComplete',
|
|
765
|
+
timeout: layer.timeouts?.onComplete ?? 3e4,
|
|
766
|
+
run: () => onComplete({
|
|
767
|
+
log,
|
|
768
|
+
ctx,
|
|
769
|
+
state,
|
|
770
|
+
outcome,
|
|
771
|
+
}),
|
|
772
|
+
});
|
|
661
773
|
if (result && 'state' in result) {
|
|
662
774
|
store.set(ctx.executionId, layer.id, result.state);
|
|
663
775
|
}
|
|
@@ -684,17 +796,24 @@ export async function beforeToolCallLayers({ layers, toolName, toolArgs, callId,
|
|
|
684
796
|
layer,
|
|
685
797
|
state,
|
|
686
798
|
})) {
|
|
799
|
+
await traceSkipped(ctx, layer.id, 'beforeToolCall');
|
|
687
800
|
continue;
|
|
688
801
|
}
|
|
802
|
+
const beforeToolCall = layer.hooks.beforeToolCall;
|
|
689
803
|
try {
|
|
690
|
-
const
|
|
691
|
-
const result = await withTimeout(layer.hooks.beforeToolCall({
|
|
692
|
-
toolName,
|
|
693
|
-
toolArgs,
|
|
694
|
-
callId,
|
|
804
|
+
const result = await callHook({
|
|
695
805
|
ctx,
|
|
696
|
-
|
|
697
|
-
|
|
806
|
+
layerId: layer.id,
|
|
807
|
+
hook: 'beforeToolCall',
|
|
808
|
+
timeout: layer.timeouts?.beforeToolCall ?? 5e3,
|
|
809
|
+
run: () => beforeToolCall({
|
|
810
|
+
toolName,
|
|
811
|
+
toolArgs,
|
|
812
|
+
callId,
|
|
813
|
+
ctx,
|
|
814
|
+
state,
|
|
815
|
+
}),
|
|
816
|
+
});
|
|
698
817
|
if (result.state !== undefined) {
|
|
699
818
|
store.set(ctx.executionId, layer.id, result.state);
|
|
700
819
|
}
|
|
@@ -737,15 +856,22 @@ export async function afterModelCallLayers({ layers, response, ctx, store, }) {
|
|
|
737
856
|
layer,
|
|
738
857
|
state,
|
|
739
858
|
})) {
|
|
859
|
+
await traceSkipped(ctx, layer.id, 'afterModelCall');
|
|
740
860
|
continue;
|
|
741
861
|
}
|
|
862
|
+
const afterModelCall = layer.hooks.afterModelCall;
|
|
742
863
|
try {
|
|
743
|
-
const
|
|
744
|
-
const result = await withTimeout(layer.hooks.afterModelCall({
|
|
745
|
-
response,
|
|
864
|
+
const result = await callHook({
|
|
746
865
|
ctx,
|
|
747
|
-
|
|
748
|
-
|
|
866
|
+
layerId: layer.id,
|
|
867
|
+
hook: 'afterModelCall',
|
|
868
|
+
timeout: layer.timeouts?.afterModelCall ?? 1e4,
|
|
869
|
+
run: () => afterModelCall({
|
|
870
|
+
response,
|
|
871
|
+
ctx,
|
|
872
|
+
state,
|
|
873
|
+
}),
|
|
874
|
+
});
|
|
749
875
|
if (result.state !== undefined) {
|
|
750
876
|
store.set(ctx.executionId, layer.id, result.state);
|
|
751
877
|
}
|
|
@@ -785,15 +911,22 @@ export async function projectHistoryLayers({ layers, items, ctx, store, }) {
|
|
|
785
911
|
layer,
|
|
786
912
|
state,
|
|
787
913
|
})) {
|
|
914
|
+
await traceSkipped(ctx, layer.id, 'projectHistory');
|
|
788
915
|
continue;
|
|
789
916
|
}
|
|
917
|
+
const projectHistory = layer.hooks.projectHistory;
|
|
790
918
|
try {
|
|
791
|
-
const
|
|
792
|
-
const result = await withTimeout(layer.hooks.projectHistory({
|
|
793
|
-
items: current,
|
|
919
|
+
const result = await callHook({
|
|
794
920
|
ctx,
|
|
795
|
-
|
|
796
|
-
|
|
921
|
+
layerId: layer.id,
|
|
922
|
+
hook: 'projectHistory',
|
|
923
|
+
timeout: layer.timeouts?.projectHistory ?? 5e3,
|
|
924
|
+
run: () => projectHistory({
|
|
925
|
+
items: current,
|
|
926
|
+
ctx,
|
|
927
|
+
state,
|
|
928
|
+
}),
|
|
929
|
+
});
|
|
797
930
|
current = result.items;
|
|
798
931
|
}
|
|
799
932
|
catch (e) {
|
|
@@ -831,16 +964,23 @@ export async function runAppendPipeline({ layers, items, ctx, log, store, }) {
|
|
|
831
964
|
layer,
|
|
832
965
|
state,
|
|
833
966
|
})) {
|
|
967
|
+
await traceSkipped(ctx, layer.id, 'onItemAppend');
|
|
834
968
|
continue;
|
|
835
969
|
}
|
|
970
|
+
const onItemAppend = layer.hooks.onItemAppend;
|
|
836
971
|
try {
|
|
837
|
-
const
|
|
838
|
-
const result = await withTimeout(layer.hooks.onItemAppend({
|
|
839
|
-
items: currentItems,
|
|
840
|
-
log,
|
|
972
|
+
const result = await callHook({
|
|
841
973
|
ctx,
|
|
842
|
-
|
|
843
|
-
|
|
974
|
+
layerId: layer.id,
|
|
975
|
+
hook: 'onItemAppend',
|
|
976
|
+
timeout: layer.timeouts?.onItemAppend ?? DEFAULT_ON_ITEM_APPEND_TIMEOUT,
|
|
977
|
+
run: () => onItemAppend({
|
|
978
|
+
items: currentItems,
|
|
979
|
+
log,
|
|
980
|
+
ctx,
|
|
981
|
+
state,
|
|
982
|
+
}),
|
|
983
|
+
});
|
|
844
984
|
// Update items for next layer in pipeline
|
|
845
985
|
currentItems = result.items;
|
|
846
986
|
// Update state if provided
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live children and `onParentUpdate` delivery. A child execution is live from
|
|
3
|
+
* the moment its spawn (or inParallel path) starts running until it returns or
|
|
4
|
+
* is cleaned up. While it is live, every write of a parent layer's state is
|
|
5
|
+
* handed to the same layer's `onParentUpdate` in the child.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContextLayer, ExecutionContext, ItemLog, ItemSchemaRegistry } from '@noetic-tools/types';
|
|
8
|
+
/** @public A running child execution that receives its parent's layer writes. */
|
|
9
|
+
export interface LiveChild {
|
|
10
|
+
/** Execution whose layer writes the child receives. */
|
|
11
|
+
parentExecutionId: string;
|
|
12
|
+
/** The child's execution context; hooks run on it and trace onto its span. */
|
|
13
|
+
ctx: ExecutionContext;
|
|
14
|
+
/** The layers active in the child. */
|
|
15
|
+
layers: ContextLayer[];
|
|
16
|
+
/** The child's item log, where returned items are appended. */
|
|
17
|
+
log: ItemLog;
|
|
18
|
+
itemSchemas?: ItemSchemaRegistry;
|
|
19
|
+
}
|
|
20
|
+
/** The slice of the layer state store that delivery reads and writes through. */
|
|
21
|
+
export interface ParentUpdateStore {
|
|
22
|
+
get<T>(executionId: string, layerId: string): T | undefined;
|
|
23
|
+
set<T>(executionId: string, layerId: string, state: T): void;
|
|
24
|
+
diagnostic: (layerId: string, hook: string, error: unknown) => void;
|
|
25
|
+
}
|
|
26
|
+
/** Whether a layer's hooks are switched off for an execution. */
|
|
27
|
+
type SkipCheck = (executionId: string, layer: ContextLayer) => boolean;
|
|
28
|
+
export interface LiveChildren {
|
|
29
|
+
adopt(child: LiveChild): void;
|
|
30
|
+
/** Hands a parent write to every live child of `parentExecutionId`. */
|
|
31
|
+
notify(parentExecutionId: string, layerId: string, state: unknown): void;
|
|
32
|
+
/** Stops new deliveries to a child and waits for the ones already running. */
|
|
33
|
+
release(childExecutionId: string): Promise<void>;
|
|
34
|
+
/** Forgets a child at once; a delivery still running discards its result. */
|
|
35
|
+
drop(childExecutionId: string): void;
|
|
36
|
+
/** Number of live children, for leak checks. */
|
|
37
|
+
readonly size: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Builds the live-child registry a layer state store dispatches through.
|
|
41
|
+
* `store` is read lazily, so it may be the object under construction.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createLiveChildren(store: ParentUpdateStore, skips: SkipCheck): LiveChildren;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live children and `onParentUpdate` delivery. A child execution is live from
|
|
3
|
+
* the moment its spawn (or inParallel path) starts running until it returns or
|
|
4
|
+
* is cleaned up. While it is live, every write of a parent layer's state is
|
|
5
|
+
* handed to the same layer's `onParentUpdate` in the child.
|
|
6
|
+
*/
|
|
7
|
+
import { defaultItemSchemaRegistry } from '@noetic-tools/types';
|
|
8
|
+
import { callHook } from './hook-call.js';
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region Public API
|
|
11
|
+
const DEFAULT_TIMEOUT = 5e3;
|
|
12
|
+
/**
|
|
13
|
+
* Builds the live-child registry a layer state store dispatches through.
|
|
14
|
+
* `store` is read lazily, so it may be the object under construction.
|
|
15
|
+
*/
|
|
16
|
+
export function createLiveChildren(store, skips) {
|
|
17
|
+
const byChild = new Map();
|
|
18
|
+
const byParent = new Map();
|
|
19
|
+
function forget(childExecutionId) {
|
|
20
|
+
const entry = byChild.get(childExecutionId);
|
|
21
|
+
if (!entry) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
byChild.delete(childExecutionId);
|
|
25
|
+
const siblings = byParent.get(entry.child.parentExecutionId);
|
|
26
|
+
siblings?.delete(entry);
|
|
27
|
+
if (siblings?.size === 0) {
|
|
28
|
+
byParent.delete(entry.child.parentExecutionId);
|
|
29
|
+
}
|
|
30
|
+
entry.open = false;
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
adopt(child) {
|
|
35
|
+
const entry = {
|
|
36
|
+
child,
|
|
37
|
+
queue: Promise.resolve(),
|
|
38
|
+
open: true,
|
|
39
|
+
gone: false,
|
|
40
|
+
};
|
|
41
|
+
byChild.set(child.ctx.executionId, entry);
|
|
42
|
+
let siblings = byParent.get(child.parentExecutionId);
|
|
43
|
+
if (!siblings) {
|
|
44
|
+
siblings = new Set();
|
|
45
|
+
byParent.set(child.parentExecutionId, siblings);
|
|
46
|
+
}
|
|
47
|
+
siblings.add(entry);
|
|
48
|
+
},
|
|
49
|
+
notify(parentExecutionId, layerId, state) {
|
|
50
|
+
const siblings = byParent.get(parentExecutionId);
|
|
51
|
+
if (!siblings) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const entry of siblings) {
|
|
55
|
+
const layer = entry.child.layers.find((l) => l.id === layerId);
|
|
56
|
+
if (!layer?.hooks.onParentUpdate) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
entry.queue = entry.queue.then(() => deliver({
|
|
60
|
+
entry,
|
|
61
|
+
layer,
|
|
62
|
+
parentState: state,
|
|
63
|
+
store,
|
|
64
|
+
skips,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async release(childExecutionId) {
|
|
69
|
+
const entry = forget(childExecutionId);
|
|
70
|
+
await entry?.queue;
|
|
71
|
+
},
|
|
72
|
+
drop(childExecutionId) {
|
|
73
|
+
const entry = forget(childExecutionId);
|
|
74
|
+
if (entry) {
|
|
75
|
+
entry.gone = true;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
get size() {
|
|
79
|
+
return byChild.size;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Runs one `onParentUpdate` call. Never rejects: failures become diagnostics. */
|
|
84
|
+
async function deliver({ entry, layer, parentState, store, skips }) {
|
|
85
|
+
const { ctx, log, itemSchemas = defaultItemSchemaRegistry } = entry.child;
|
|
86
|
+
const onParentUpdate = layer.hooks.onParentUpdate;
|
|
87
|
+
if (!entry.open || !onParentUpdate || skips(ctx.executionId, layer)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const result = await callHook({
|
|
92
|
+
ctx,
|
|
93
|
+
layerId: layer.id,
|
|
94
|
+
hook: 'onParentUpdate',
|
|
95
|
+
timeout: layer.timeouts?.onParentUpdate ?? DEFAULT_TIMEOUT,
|
|
96
|
+
run: () => onParentUpdate({
|
|
97
|
+
parentState,
|
|
98
|
+
childState: store.get(ctx.executionId, layer.id),
|
|
99
|
+
childCtx: ctx,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
// A child cleaned up mid-call has no state left to write into.
|
|
103
|
+
if (!result || entry.gone) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if ('childState' in result) {
|
|
107
|
+
store.set(ctx.executionId, layer.id, result.childState);
|
|
108
|
+
}
|
|
109
|
+
for (const item of itemSchemas.extend(layer.itemSchemas).parseMany(result.items ?? [])) {
|
|
110
|
+
log.append(item);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
store.diagnostic(layer.id, 'onParentUpdate', e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Span, TraceExporter } from '@noetic-tools/types';
|
|
2
|
+
/**
|
|
3
|
+
* Hands ended spans to `exporter` without letting it fail the traced work: a
|
|
4
|
+
* rejecting (or throwing) exporter is reported once as a warning and
|
|
5
|
+
* swallowed, so tracing never changes what a hook, step, or call returns.
|
|
6
|
+
*/
|
|
7
|
+
export declare function exportSpans(exporter: TraceExporter, spans: Span[]): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hands ended spans to `exporter` without letting it fail the traced work: a
|
|
3
|
+
* rejecting (or throwing) exporter is reported once as a warning and
|
|
4
|
+
* swallowed, so tracing never changes what a hook, step, or call returns.
|
|
5
|
+
*/
|
|
6
|
+
export async function exportSpans(exporter, spans) {
|
|
7
|
+
try {
|
|
8
|
+
await exporter.export(spans);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
console.warn('[noetic] trace export failed; the traced work is unaffected:', error);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* ContextLayer contract, lifecycle/budget/projection machinery, and built-in
|
|
4
4
|
* layer implementations.
|
|
5
5
|
*/
|
|
6
|
-
export type { ContextScope, ExecutionContext, ExecutionOutcome, LayerCallModelRequest, } from '@noetic-tools/types';
|
|
6
|
+
export type { ContextScope, ExecutionContext, ExecutionOutcome, ExecutionSpan, LayerCallModelRequest, } from '@noetic-tools/types';
|
|
7
7
|
export * from '@noetic-tools/types/contract';
|
|
8
8
|
export * from './context/budget.js';
|
|
9
9
|
export * from './context/cache-anchoring.js';
|
|
10
10
|
export * from './context/exec-context-factory.js';
|
|
11
11
|
export * from './context/function-call-utils.js';
|
|
12
|
+
export { LAYER_HOOK_EVENT, LayerHookAttr } from './context/hook-call.js';
|
|
12
13
|
export * from './context/layer-api.js';
|
|
13
14
|
export * from './context/layer-lifecycle.js';
|
|
14
15
|
export * from './context/layer-provides.js';
|
|
@@ -24,7 +25,9 @@ export * from './context/layers/steering.js';
|
|
|
24
25
|
export * from './context/layers/task-state.js';
|
|
25
26
|
export * from './context/layers/temporal.js';
|
|
26
27
|
export * from './context/layers/tool-calls.js';
|
|
28
|
+
export type { LiveChild } from './context/parent-updates.js';
|
|
27
29
|
export * from './context/projector.js';
|
|
28
30
|
export * from './context/scope.js';
|
|
31
|
+
export * from './context/span-export.js';
|
|
29
32
|
export * from './context/storage-batch.js';
|
|
30
33
|
export * from './context/strip-unresolved.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export * from './context/budget.js';
|
|
|
11
11
|
export * from './context/cache-anchoring.js';
|
|
12
12
|
export * from './context/exec-context-factory.js';
|
|
13
13
|
export * from './context/function-call-utils.js';
|
|
14
|
+
export { LAYER_HOOK_EVENT, LayerHookAttr } from './context/hook-call.js';
|
|
14
15
|
export * from './context/layer-api.js';
|
|
15
16
|
export * from './context/layer-lifecycle.js';
|
|
16
17
|
export * from './context/layer-provides.js';
|
|
@@ -28,5 +29,6 @@ export * from './context/layers/temporal.js';
|
|
|
28
29
|
export * from './context/layers/tool-calls.js';
|
|
29
30
|
export * from './context/projector.js';
|
|
30
31
|
export * from './context/scope.js';
|
|
32
|
+
export * from './context/span-export.js';
|
|
31
33
|
export * from './context/storage-batch.js';
|
|
32
34
|
export * from './context/strip-unresolved.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noetic-tools/context",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Context layer system for Noetic agents: the ContextLayer contract, lifecycle, budget/projection machinery, and built-in layer implementations.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"zod": "^4.0.0"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@noetic-tools/types": "^3.
|
|
47
|
+
"@noetic-tools/types": "^3.9.0",
|
|
48
48
|
"zod": "^4.0.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|