@noetic-tools/core 0.3.0 → 1.0.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/harness/agent-harness.d.ts +2 -2
- package/dist/harness/agent-harness.js +17 -5
- package/dist/harness/model-call.js +9 -8
- package/dist/harness/model-schema.d.ts +8 -0
- package/dist/harness/model-schema.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/interpreter/execute-action.js +19 -2
- package/dist/interpreter/execute-control.js +59 -4
- package/dist/runtime/channel-store.d.ts +42 -2
- package/dist/runtime/channel-store.js +232 -35
- package/dist/runtime/context-impl.d.ts +17 -1
- package/dist/runtime/context-impl.js +30 -3
- package/dist/runtime/durable/stall-nudge.d.ts +16 -9
- package/dist/runtime/durable/stall-nudge.js +29 -10
- package/dist/runtime/event-broadcaster.d.ts +9 -1
- package/dist/runtime/event-broadcaster.js +43 -19
- package/package.json +4 -4
|
@@ -152,8 +152,8 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
152
152
|
cwdInit?: string;
|
|
153
153
|
_broadcaster?: EventBroadcaster;
|
|
154
154
|
}): Context;
|
|
155
|
-
send<T>(channel: Channel<T>, value: T,
|
|
156
|
-
recv<T>(channel: Channel<T>,
|
|
155
|
+
send<T>(channel: Channel<T>, value: T, ctx: Context): Promise<void>;
|
|
156
|
+
recv<T>(channel: Channel<T>, ctx: Context, opts?: {
|
|
157
157
|
timeout?: number;
|
|
158
158
|
}): Promise<T>;
|
|
159
159
|
tryRecv<T>(channel: Channel<T>, _ctx: Context): T | null;
|
|
@@ -137,7 +137,11 @@ export class AgentHarness {
|
|
|
137
137
|
this.client = opts._testCallModel ? undefined : createClient(opts.llm);
|
|
138
138
|
this.channelStore = new ChannelStore();
|
|
139
139
|
this.traceExporter = opts.traceExporter ?? new NoopExporter();
|
|
140
|
-
this.layerStateStore =
|
|
140
|
+
this.layerStateStore =
|
|
141
|
+
opts.layerStateStore ??
|
|
142
|
+
createLayerStateStore((layerId, hook, error) => {
|
|
143
|
+
console.warn(`[noetic] memory layer '${layerId}' ${hook} error:`, error);
|
|
144
|
+
});
|
|
141
145
|
this.recallCache = createRecallCache();
|
|
142
146
|
this.defaultDeliveryMode = opts.defaultDeliveryMode ?? 'next-turn';
|
|
143
147
|
this.streamIdleTimeoutMs = opts.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
|
|
@@ -366,11 +370,19 @@ export class AgentHarness {
|
|
|
366
370
|
cwdState: resolveContextCwdState(this.rootCwdState, opts?.parent, cwdInit),
|
|
367
371
|
});
|
|
368
372
|
}
|
|
369
|
-
send(channel, value,
|
|
370
|
-
|
|
373
|
+
send(channel, value, ctx) {
|
|
374
|
+
// Internal sender: back-pressured on full queue channels. The calling
|
|
375
|
+
// context's abort signal rejects a parked send with 'cancelled'.
|
|
376
|
+
const signal = ctx instanceof ContextImpl ? ctx.abortSignal : undefined;
|
|
377
|
+
return this.channelStore.send(channel, value, {
|
|
378
|
+
signal,
|
|
379
|
+
});
|
|
371
380
|
}
|
|
372
|
-
recv(channel,
|
|
373
|
-
|
|
381
|
+
recv(channel, ctx, opts) {
|
|
382
|
+
// Wire the calling context's abort signal so ctx.abort() rejects a
|
|
383
|
+
// pending recv with { kind: 'cancelled' } instead of hanging.
|
|
384
|
+
const signal = ctx instanceof ContextImpl ? ctx.abortSignal : undefined;
|
|
385
|
+
return this.channelStore.recv(channel, opts?.timeout, signal);
|
|
374
386
|
}
|
|
375
387
|
tryRecv(channel, _ctx) {
|
|
376
388
|
return this.channelStore.tryRecv(channel);
|
|
@@ -400,7 +400,6 @@ export class AgentHarnessModelCaller {
|
|
|
400
400
|
await this.executeToolRound({
|
|
401
401
|
functionCalls,
|
|
402
402
|
request,
|
|
403
|
-
roundItemSchemas,
|
|
404
403
|
allItems,
|
|
405
404
|
conversationInput,
|
|
406
405
|
round,
|
|
@@ -517,7 +516,7 @@ export class AgentHarnessModelCaller {
|
|
|
517
516
|
};
|
|
518
517
|
}
|
|
519
518
|
async executeToolRound(params) {
|
|
520
|
-
const { functionCalls, request,
|
|
519
|
+
const { functionCalls, request, allItems, conversationInput } = params;
|
|
521
520
|
params.emitIfAllowed('tool_round_started', {
|
|
522
521
|
round: params.round,
|
|
523
522
|
toolCount: functionCalls.length,
|
|
@@ -538,7 +537,6 @@ export class AgentHarnessModelCaller {
|
|
|
538
537
|
await this.executeFunctionCall({
|
|
539
538
|
fc,
|
|
540
539
|
request,
|
|
541
|
-
roundItemSchemas,
|
|
542
540
|
allItems,
|
|
543
541
|
conversationInput,
|
|
544
542
|
emitIfAllowed: params.emitIfAllowed,
|
|
@@ -550,7 +548,7 @@ export class AgentHarnessModelCaller {
|
|
|
550
548
|
});
|
|
551
549
|
}
|
|
552
550
|
async executeFunctionCall(params) {
|
|
553
|
-
const { fc, request,
|
|
551
|
+
const { fc, request, allItems, conversationInput } = params;
|
|
554
552
|
params.emitIfAllowed('tool_call_started', {
|
|
555
553
|
name: fc.name,
|
|
556
554
|
callId: fc.callId,
|
|
@@ -563,7 +561,6 @@ export class AgentHarnessModelCaller {
|
|
|
563
561
|
this.recordMalformedToolCall({
|
|
564
562
|
fc,
|
|
565
563
|
request,
|
|
566
|
-
roundItemSchemas,
|
|
567
564
|
allItems,
|
|
568
565
|
conversationInput,
|
|
569
566
|
emitIfAllowed: params.emitIfAllowed,
|
|
@@ -582,10 +579,13 @@ export class AgentHarnessModelCaller {
|
|
|
582
579
|
harness: this.opts.harness,
|
|
583
580
|
layers: request.layers,
|
|
584
581
|
});
|
|
582
|
+
// Owner-scoped result validation: a tool's `toolResults` schemas apply
|
|
583
|
+
// only to that tool's own result items. Harness-level `opts.itemSchemas`
|
|
584
|
+
// stay global by design; sibling tools' schemas never reject this item.
|
|
585
585
|
const outputItem = createToolResultItem({
|
|
586
586
|
output: toolResult.output,
|
|
587
587
|
callId: fc.callId,
|
|
588
|
-
roundItemSchemas,
|
|
588
|
+
roundItemSchemas: this.opts.itemSchemas.extend(toolForCall?.itemSchemas),
|
|
589
589
|
tool: toolForCall,
|
|
590
590
|
callItem: fc,
|
|
591
591
|
args: parsedArgs,
|
|
@@ -605,13 +605,14 @@ export class AgentHarnessModelCaller {
|
|
|
605
605
|
});
|
|
606
606
|
}
|
|
607
607
|
recordMalformedToolCall(params) {
|
|
608
|
-
const { fc, request,
|
|
608
|
+
const { fc, request, allItems, conversationInput } = params;
|
|
609
609
|
const errorOutput = `Error: malformed JSON in tool arguments: ${fc.arguments}`;
|
|
610
610
|
const toolForCall = request.tools?.find((tool) => tool.name === fc.name);
|
|
611
|
+
// Owner-scoped result validation (see executeFunctionCall).
|
|
611
612
|
const outputItem = createToolResultItem({
|
|
612
613
|
output: errorOutput,
|
|
613
614
|
callId: fc.callId,
|
|
614
|
-
roundItemSchemas,
|
|
615
|
+
roundItemSchemas: this.opts.itemSchemas.extend(toolForCall?.itemSchemas),
|
|
615
616
|
tool: toolForCall,
|
|
616
617
|
callItem: fc,
|
|
617
618
|
error: true,
|
|
@@ -5,6 +5,14 @@ export declare function buildItemSchemaRegistry({ base, layers, tools, }: {
|
|
|
5
5
|
layers?: ReadonlyArray<MemoryLayer>;
|
|
6
6
|
tools?: ReadonlyArray<Tool>;
|
|
7
7
|
}): ItemSchemaRegistry;
|
|
8
|
+
/**
|
|
9
|
+
* Build and validate a `function_call_output` item for a tool result.
|
|
10
|
+
*
|
|
11
|
+
* `roundItemSchemas` must be the OWNER-SCOPED registry for the called tool
|
|
12
|
+
* (harness base extended with only that tool's `itemSchemas`), so one tool's
|
|
13
|
+
* `toolResults` schemas never reject a sibling tool's result items. Tools
|
|
14
|
+
* without `itemSchemas` fall back to the base structural parse.
|
|
15
|
+
*/
|
|
8
16
|
export declare function createToolResultItem({ output, callId, roundItemSchemas, tool, callItem, args, result, error, }: {
|
|
9
17
|
output: string;
|
|
10
18
|
callId: string;
|
|
@@ -24,6 +24,14 @@ export function buildItemSchemaRegistry({ base, layers, tools, }) {
|
|
|
24
24
|
.extend(collectLayerItemSchemaExtensions(layers))
|
|
25
25
|
.extend(collectToolItemSchemaExtensions(tools));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Build and validate a `function_call_output` item for a tool result.
|
|
29
|
+
*
|
|
30
|
+
* `roundItemSchemas` must be the OWNER-SCOPED registry for the called tool
|
|
31
|
+
* (harness base extended with only that tool's `itemSchemas`), so one tool's
|
|
32
|
+
* `toolResults` schemas never reject a sibling tool's result items. Tools
|
|
33
|
+
* without `itemSchemas` fall back to the base structural parse.
|
|
34
|
+
*/
|
|
27
35
|
export function createToolResultItem({ output, callId, roundItemSchemas, tool, callItem, args, result, error, }) {
|
|
28
36
|
const baseItem = {
|
|
29
37
|
id: crypto.randomUUID(),
|
package/dist/index.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export { execute } from './interpreter/execute.js';
|
|
|
55
55
|
/** @public */
|
|
56
56
|
/** @public */
|
|
57
57
|
/** @public */
|
|
58
|
-
export type { DurableTaskState,
|
|
58
|
+
export type { DurableTaskState, FactExtractor, FactSearcher, HistoryWindowConfig, ObservationalMemoryConfig, ObservationalState, PlanEnterSessionCallback, PlanExecutionEntry, PlanExitCallback, PlanMemoryConfig, PlanState, TemporalFact, TemporalMemoryConfig, TemporalSearchResult, WorkingMemoryConfig, WorkingMemoryState, } from '@noetic-tools/memory';
|
|
59
59
|
/** @public */
|
|
60
60
|
/** @public */
|
|
61
61
|
/** @public */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Action step handlers: run, llm, spawn, provide, tool.
|
|
3
3
|
*/
|
|
4
|
-
import { createMessage, estimateTokens, extractAssistantText, frameworkCast, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
|
|
4
|
+
import { createMessage, estimateTokens, extractAssistantText, frameworkCast, isNoeticError, NoeticConfigError, NoeticErrorImpl, } from '@noetic-tools/types';
|
|
5
5
|
import { ZodError } from 'zod';
|
|
6
6
|
import { allocateBudgets, assembleView, buildToolExecutionContext, ContextImpl, commitLayerUsage, computeLayerUsage, contextToExecCtx, DEFAULT_PROJECTION, defaultItemSchemaRegistry, emitFrameworkEvent, getBroadcaster, resolveLayerTools, returnLayers, snapshotCwdState, spawnLayers, } from './action-deps.js';
|
|
7
7
|
import { SteeringAction } from './action-types.js';
|
|
@@ -15,10 +15,23 @@ export async function executeRun(step, input, ctx) {
|
|
|
15
15
|
const maxAttempts = retry?.maxAttempts ?? 1;
|
|
16
16
|
let lastError = new Error('No attempts executed');
|
|
17
17
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
18
|
+
// Cancellation is not a retriable error (spec 09) — an abort that
|
|
19
|
+
// arrived between attempts must stop the loop before re-executing.
|
|
20
|
+
if (ctx.aborted) {
|
|
21
|
+
throw new NoeticErrorImpl({
|
|
22
|
+
kind: 'cancelled',
|
|
23
|
+
reason: ctx.abortReason ?? 'context aborted',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
18
26
|
try {
|
|
19
27
|
return await step.execute(input, ctx);
|
|
20
28
|
}
|
|
21
29
|
catch (e) {
|
|
30
|
+
// Rethrow cancellation immediately — retrying it would re-run side
|
|
31
|
+
// effects after abort and bury the 'cancelled' kind under step_failed.
|
|
32
|
+
if (isNoeticError(e) && e.noeticError.kind === 'cancelled') {
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
22
35
|
lastError = e instanceof Error ? e : new Error(String(e));
|
|
23
36
|
if (attempt < maxAttempts - 1 && retry) {
|
|
24
37
|
const delay = computeDelay(retry, attempt);
|
|
@@ -290,6 +303,11 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
290
303
|
emit: step.emit,
|
|
291
304
|
};
|
|
292
305
|
const response = await baseCtx.harness.callModel(request);
|
|
306
|
+
// Track tokens/cost for EVERY model call, including responses steering
|
|
307
|
+
// subsequently rejects (Deny throw / Guide retry below) — the spend is
|
|
308
|
+
// real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
|
|
309
|
+
// tokens accumulate across all LLM calls).
|
|
310
|
+
trackUsage(baseCtx, response);
|
|
293
311
|
if (hasLayers) {
|
|
294
312
|
const decision = await baseCtx.harness.afterModelCall(layers, response, baseCtx);
|
|
295
313
|
if (decision.action === SteeringAction.Deny) {
|
|
@@ -320,7 +338,6 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
320
338
|
if (isMutableContext(baseCtx)) {
|
|
321
339
|
baseCtx.lastStepMeta = meta;
|
|
322
340
|
}
|
|
323
|
-
trackUsage(baseCtx, response);
|
|
324
341
|
commitLayerUsage(baseCtx, computeLayerUsage({
|
|
325
342
|
ctx: baseCtx,
|
|
326
343
|
modelId: resolvedModel,
|
|
@@ -135,9 +135,46 @@ function classifyResults(settled, paths) {
|
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
137
|
async function executeAll(step, paths, _input, ctx, childContexts, executeStep, concurrency) {
|
|
138
|
-
|
|
138
|
+
// Fail-fast (specs 03/09): the first genuine path failure aborts every
|
|
139
|
+
// sibling child context. Cancellation is cooperative — in-flight siblings
|
|
140
|
+
// are still awaited (they stop at their next step boundary / blocked
|
|
141
|
+
// channel op) so fork_partial's succeeded/failed stay accurate; paths
|
|
142
|
+
// still queued behind the concurrency limit are skipped outright. Skipped
|
|
143
|
+
// and aborted paths appear in `failed` with kind 'cancelled'.
|
|
144
|
+
let firstFailureSeen = false;
|
|
145
|
+
const tasks = paths.map((path, i) => async () => {
|
|
146
|
+
if (firstFailureSeen) {
|
|
147
|
+
throw new NoeticErrorImpl({
|
|
148
|
+
kind: 'cancelled',
|
|
149
|
+
reason: `fork '${step.id}' sibling failed`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
return await executeStep(path, _input, frameworkCast(childContexts[i]));
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
const isCancellation = isNoeticError(e) && e.noeticError.kind === 'cancelled';
|
|
157
|
+
if (!isCancellation && !firstFailureSeen) {
|
|
158
|
+
firstFailureSeen = true;
|
|
159
|
+
for (let j = 0; j < childContexts.length; j++) {
|
|
160
|
+
if (j !== i) {
|
|
161
|
+
childContexts[j].abort(`fork '${step.id}' sibling failed`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
throw e;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
139
168
|
const settled = await runWithConcurrency(tasks, concurrency);
|
|
140
169
|
const { succeeded, failed } = classifyResults(settled, paths);
|
|
170
|
+
// If the PARENT context was aborted mid-fork, the whole fork is cancelled —
|
|
171
|
+
// surface 'cancelled', not a partial-failure shape (spec 09, item 3).
|
|
172
|
+
if (ctx.aborted) {
|
|
173
|
+
throw new NoeticErrorImpl({
|
|
174
|
+
kind: 'cancelled',
|
|
175
|
+
reason: ctx.abortReason ?? 'context aborted',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
141
178
|
if (failed.length > 0) {
|
|
142
179
|
throw new NoeticErrorImpl({
|
|
143
180
|
kind: 'fork_partial',
|
|
@@ -208,9 +245,17 @@ async function executeRace(step, paths, input, ctx, childContexts, executeStep,
|
|
|
208
245
|
}
|
|
209
246
|
});
|
|
210
247
|
}
|
|
211
|
-
async function executeSettle(step, paths, input,
|
|
248
|
+
async function executeSettle(step, paths, input, ctx, childContexts, executeStep, concurrency) {
|
|
212
249
|
const tasks = paths.map((path, i) => () => executeStep(path, input, frameworkCast(childContexts[i])));
|
|
213
250
|
const settled = await runWithConcurrency(tasks, concurrency);
|
|
251
|
+
// Parent aborted mid-fork: the fork is cancelled, merge never runs
|
|
252
|
+
// (spec 09, Cancellation item 3 — same rule as mode 'all').
|
|
253
|
+
if (ctx.aborted) {
|
|
254
|
+
throw new NoeticErrorImpl({
|
|
255
|
+
kind: 'cancelled',
|
|
256
|
+
reason: ctx.abortReason ?? 'context aborted',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
214
259
|
const results = settled.map((result, i) => {
|
|
215
260
|
if (result.status === 'fulfilled') {
|
|
216
261
|
return {
|
|
@@ -225,7 +270,7 @@ async function executeSettle(step, paths, input, _ctx, childContexts, executeSte
|
|
|
225
270
|
error: toNoeticError(result.reason, paths[i].id),
|
|
226
271
|
};
|
|
227
272
|
});
|
|
228
|
-
return step.merge(results,
|
|
273
|
+
return step.merge(results, ctx);
|
|
229
274
|
}
|
|
230
275
|
function hasTextField(value) {
|
|
231
276
|
return typeof value === 'object' && value !== null && 'text' in value;
|
|
@@ -242,7 +287,11 @@ async function recvInboxWithTimeout(ctx, step) {
|
|
|
242
287
|
timeout: step.parkTimeout,
|
|
243
288
|
});
|
|
244
289
|
}
|
|
245
|
-
catch {
|
|
290
|
+
catch (e) {
|
|
291
|
+
// Cancellation must terminate the loop, not read as "no message".
|
|
292
|
+
if (isNoeticError(e) && e.noeticError.kind === 'cancelled') {
|
|
293
|
+
throw e;
|
|
294
|
+
}
|
|
246
295
|
// Expected: channel_timeout error when parkTimeout expires with no message.
|
|
247
296
|
return null;
|
|
248
297
|
}
|
|
@@ -307,6 +356,12 @@ export async function executeLoop(step, input, ctx, executeStep) {
|
|
|
307
356
|
if (!step.onError || !isNoeticError(e)) {
|
|
308
357
|
throw e;
|
|
309
358
|
}
|
|
359
|
+
// Cancellation is not a retriable error (spec 09): it must terminate
|
|
360
|
+
// the loop promptly rather than be consumed by 'retry'/'skip'. Same
|
|
361
|
+
// guard as executeEvery's 'continue' policy below.
|
|
362
|
+
if (e.noeticError.kind === 'cancelled') {
|
|
363
|
+
throw e;
|
|
364
|
+
}
|
|
310
365
|
const action = step.onError(e.noeticError, ctx);
|
|
311
366
|
if (action === 'retry') {
|
|
312
367
|
continue;
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { Channel, ChannelHandle, ExternalChannel } from '@noetic-tools/types';
|
|
2
|
+
/** Options accepted by internal (back-pressured) `send`. */
|
|
3
|
+
export interface ChannelSendOpts {
|
|
4
|
+
/**
|
|
5
|
+
* How long a sender may stay parked on a full queue before rejecting with
|
|
6
|
+
* `channel_timeout`. Default 30s; `0` disables the timeout.
|
|
7
|
+
*/
|
|
8
|
+
timeout?: number;
|
|
9
|
+
/** Abort signal — rejects a parked sender with `cancelled`. */
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
2
12
|
export declare class ChannelStore {
|
|
3
13
|
private channels;
|
|
4
14
|
private closedExecutions;
|
|
@@ -9,10 +19,40 @@ export declare class ChannelStore {
|
|
|
9
19
|
* an `unsubscribe` so the caller can detach if it cancels first.
|
|
10
20
|
*/
|
|
11
21
|
subscribeWake<T>(channel: Channel<T>, callback: () => void): () => void;
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Internal-sender write with back-pressure (spec 06). Delivery is
|
|
24
|
+
* synchronous when possible; the returned promise only parks when a queue
|
|
25
|
+
* channel is at capacity:
|
|
26
|
+
*
|
|
27
|
+
* - **value**: store last-write-wins value, drain ALL parked recv waiters,
|
|
28
|
+
* resolve immediately.
|
|
29
|
+
* - **queue**: hand off to a parked recv waiter or push below capacity —
|
|
30
|
+
* resolve immediately. At capacity, park the sender FIFO until a consumer
|
|
31
|
+
* dequeues an item; reject `channel_timeout` after `opts.timeout`
|
|
32
|
+
* (default 30s) or `cancelled` when `opts.signal` aborts.
|
|
33
|
+
* - **topic**: deliver to current subscribers, resolve immediately.
|
|
34
|
+
*
|
|
35
|
+
* External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
|
|
36
|
+
*/
|
|
37
|
+
send<T>(channel: Channel<T>, value: T, opts?: ChannelSendOpts): Promise<void>;
|
|
38
|
+
/** Park an internal sender on a full queue until a slot frees (FIFO). */
|
|
39
|
+
private parkSender;
|
|
40
|
+
/**
|
|
41
|
+
* A dequeue freed a queue slot — shift the oldest parked sender's value
|
|
42
|
+
* into it (FIFO) and resolve that sender's promise.
|
|
43
|
+
*/
|
|
44
|
+
private promotePendingSender;
|
|
45
|
+
/** Fire-and-reset the one-shot wake subscribers for a channel state. */
|
|
46
|
+
private fireWakes;
|
|
47
|
+
recv<T>(channel: Channel<T>, timeout?: number, signal?: AbortSignal): Promise<T>;
|
|
14
48
|
tryRecv<T>(channel: Channel<T>): T | null;
|
|
15
49
|
private waitWithTimeout;
|
|
50
|
+
/**
|
|
51
|
+
* External-sender write: synchronous, never back-pressured (spec 06,
|
|
52
|
+
* External Sender Back-Pressure). At capacity the OLDEST queued item is
|
|
53
|
+
* dropped (with a warning) so external callers never block.
|
|
54
|
+
*/
|
|
55
|
+
private sendExternal;
|
|
16
56
|
getHandle<T>(channel: ExternalChannel<T>, executionId: string): ChannelHandle<T>;
|
|
17
57
|
closeExecution(executionId: string): void;
|
|
18
58
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { frameworkCast, NoeticErrorImpl } from '@noetic-tools/types';
|
|
2
|
-
function isExternalChannel(ch) {
|
|
3
|
-
return 'external' in ch && ch.external === true;
|
|
4
|
-
}
|
|
5
2
|
const MAX_TOPIC_TIMEOUT = 300_000; // 5 minutes
|
|
3
|
+
const DEFAULT_SEND_TIMEOUT = 30_000;
|
|
4
|
+
const RESOLVED = Promise.resolve();
|
|
5
|
+
/** Build the `cancelled` error a blocked channel operation rejects with on abort. */
|
|
6
|
+
function cancelledError(signal) {
|
|
7
|
+
return new NoeticErrorImpl({
|
|
8
|
+
kind: 'cancelled',
|
|
9
|
+
reason: typeof signal.reason === 'string' ? signal.reason : undefined,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
6
12
|
export class ChannelStore {
|
|
7
13
|
channels = new Map();
|
|
8
14
|
closedExecutions = new Set();
|
|
@@ -16,6 +22,7 @@ export class ChannelStore {
|
|
|
16
22
|
queue: [],
|
|
17
23
|
capacity: channel.capacity ?? 1_000,
|
|
18
24
|
queueWaiters: [],
|
|
25
|
+
pendingSenders: [],
|
|
19
26
|
topicSubscribers: new Set(),
|
|
20
27
|
wakeSubscribers: new Set(),
|
|
21
28
|
};
|
|
@@ -39,14 +46,38 @@ export class ChannelStore {
|
|
|
39
46
|
subscribers.delete(callback);
|
|
40
47
|
};
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Internal-sender write with back-pressure (spec 06). Delivery is
|
|
51
|
+
* synchronous when possible; the returned promise only parks when a queue
|
|
52
|
+
* channel is at capacity:
|
|
53
|
+
*
|
|
54
|
+
* - **value**: store last-write-wins value, drain ALL parked recv waiters,
|
|
55
|
+
* resolve immediately.
|
|
56
|
+
* - **queue**: hand off to a parked recv waiter or push below capacity —
|
|
57
|
+
* resolve immediately. At capacity, park the sender FIFO until a consumer
|
|
58
|
+
* dequeues an item; reject `channel_timeout` after `opts.timeout`
|
|
59
|
+
* (default 30s) or `cancelled` when `opts.signal` aborts.
|
|
60
|
+
* - **topic**: deliver to current subscribers, resolve immediately.
|
|
61
|
+
*
|
|
62
|
+
* External callers use `ChannelHandle.send` (sync, drop-oldest) instead.
|
|
63
|
+
*/
|
|
64
|
+
send(channel, value, opts) {
|
|
43
65
|
const state = this.getOrCreate(channel);
|
|
66
|
+
let result = RESOLVED;
|
|
44
67
|
switch (state.mode) {
|
|
45
68
|
case 'value':
|
|
46
69
|
state.currentValue = value;
|
|
47
70
|
state.hasValue = true;
|
|
71
|
+
// Value-mode reads are non-consuming (last-write-wins), so once
|
|
72
|
+
// hasValue flips true EVERY parked waiter must be drained — waking
|
|
73
|
+
// only one would strand the rest until channel_timeout while new
|
|
74
|
+
// recv calls succeed instantly. Splice in place: timeout timers and
|
|
75
|
+
// abort listeners close over the array identity.
|
|
48
76
|
if (state.valueWaiters.length > 0) {
|
|
49
|
-
state.valueWaiters.
|
|
77
|
+
const drained = state.valueWaiters.splice(0, state.valueWaiters.length);
|
|
78
|
+
for (const waiter of drained) {
|
|
79
|
+
waiter.resolve(value);
|
|
80
|
+
}
|
|
50
81
|
}
|
|
51
82
|
break;
|
|
52
83
|
case 'queue':
|
|
@@ -57,13 +88,8 @@ export class ChannelStore {
|
|
|
57
88
|
state.queue.push(value);
|
|
58
89
|
}
|
|
59
90
|
else {
|
|
60
|
-
// At capacity
|
|
61
|
-
|
|
62
|
-
if (isExternalChannel(channel)) {
|
|
63
|
-
state.queue.shift();
|
|
64
|
-
state.queue.push(value);
|
|
65
|
-
}
|
|
66
|
-
// For internal, drop the new value
|
|
91
|
+
// At capacity — park the sender (back-pressure).
|
|
92
|
+
result = this.parkSender(state, channel.name, value, opts);
|
|
67
93
|
}
|
|
68
94
|
break;
|
|
69
95
|
case 'topic':
|
|
@@ -72,30 +98,128 @@ export class ChannelStore {
|
|
|
72
98
|
}
|
|
73
99
|
break;
|
|
74
100
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
this.fireWakes(state);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
/** Park an internal sender on a full queue until a slot frees (FIFO). */
|
|
105
|
+
parkSender(state, channelName, value, opts) {
|
|
106
|
+
const timeout = opts?.timeout ?? DEFAULT_SEND_TIMEOUT;
|
|
107
|
+
const signal = opts?.signal;
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
return Promise.reject(cancelledError(signal));
|
|
110
|
+
}
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
let timer = null;
|
|
113
|
+
let removeAbortListener = null;
|
|
114
|
+
const cleanup = () => {
|
|
115
|
+
if (timer) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
removeAbortListener?.();
|
|
119
|
+
};
|
|
120
|
+
const entry = {
|
|
121
|
+
value,
|
|
122
|
+
resolve: () => {
|
|
123
|
+
cleanup();
|
|
124
|
+
resolve();
|
|
125
|
+
},
|
|
126
|
+
reject: (e) => {
|
|
127
|
+
cleanup();
|
|
128
|
+
reject(e);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
state.pendingSenders.push(entry);
|
|
132
|
+
const removeEntry = () => {
|
|
133
|
+
const idx = state.pendingSenders.indexOf(entry);
|
|
134
|
+
if (idx < 0) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
state.pendingSenders.splice(idx, 1);
|
|
138
|
+
return true;
|
|
139
|
+
};
|
|
140
|
+
if (timeout > 0) {
|
|
141
|
+
timer = setTimeout(() => {
|
|
142
|
+
if (removeEntry()) {
|
|
143
|
+
entry.reject(new NoeticErrorImpl({
|
|
144
|
+
kind: 'channel_timeout',
|
|
145
|
+
channelName,
|
|
146
|
+
timeout,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}, timeout);
|
|
83
150
|
}
|
|
151
|
+
if (signal) {
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
if (removeEntry()) {
|
|
154
|
+
entry.reject(cancelledError(signal));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
signal.addEventListener('abort', onAbort, {
|
|
158
|
+
once: true,
|
|
159
|
+
});
|
|
160
|
+
removeAbortListener = () => {
|
|
161
|
+
signal.removeEventListener('abort', onAbort);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A dequeue freed a queue slot — shift the oldest parked sender's value
|
|
168
|
+
* into it (FIFO) and resolve that sender's promise.
|
|
169
|
+
*/
|
|
170
|
+
promotePendingSender(state) {
|
|
171
|
+
if (state.pendingSenders.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (state.queue.length >= state.capacity) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const sender = state.pendingSenders.shift();
|
|
178
|
+
state.queue.push(sender.value);
|
|
179
|
+
sender.resolve();
|
|
180
|
+
}
|
|
181
|
+
/** Fire-and-reset the one-shot wake subscribers for a channel state. */
|
|
182
|
+
fireWakes(state) {
|
|
183
|
+
if (state.wakeSubscribers.size === 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Swap in a fresh set before firing so a callback that re-subscribes
|
|
187
|
+
// (typical for `every` re-arming on its next iteration) lands on the
|
|
188
|
+
// new set and survives until the next send.
|
|
189
|
+
const wakers = state.wakeSubscribers;
|
|
190
|
+
state.wakeSubscribers = new Set();
|
|
191
|
+
for (const wake of wakers) {
|
|
192
|
+
wake();
|
|
84
193
|
}
|
|
85
194
|
}
|
|
86
|
-
async recv(channel, timeout = 30_000) {
|
|
195
|
+
async recv(channel, timeout = 30_000, signal) {
|
|
196
|
+
// An already-aborted context must reject promptly rather than park a
|
|
197
|
+
// waiter that nothing will ever wake (spec 09, Cancellation item 2).
|
|
198
|
+
if (signal?.aborted) {
|
|
199
|
+
throw cancelledError(signal);
|
|
200
|
+
}
|
|
87
201
|
const state = this.getOrCreate(channel);
|
|
88
202
|
switch (state.mode) {
|
|
89
203
|
case 'value':
|
|
90
204
|
if (state.hasValue) {
|
|
91
205
|
return state.currentValue;
|
|
92
206
|
}
|
|
93
|
-
return this.waitWithTimeout(state.valueWaiters, channel.name, timeout);
|
|
94
|
-
case 'queue':
|
|
207
|
+
return this.waitWithTimeout(state.valueWaiters, channel.name, timeout, signal);
|
|
208
|
+
case 'queue': {
|
|
95
209
|
if (state.queue.length > 0) {
|
|
96
|
-
|
|
210
|
+
const head = state.queue.shift();
|
|
211
|
+
this.promotePendingSender(state);
|
|
212
|
+
return head;
|
|
97
213
|
}
|
|
98
|
-
|
|
214
|
+
// capacity-0 edge: senders can be parked while the queue is empty —
|
|
215
|
+
// hand the oldest parked value straight to this receiver.
|
|
216
|
+
if (state.pendingSenders.length > 0) {
|
|
217
|
+
const sender = state.pendingSenders.shift();
|
|
218
|
+
sender.resolve();
|
|
219
|
+
return sender.value;
|
|
220
|
+
}
|
|
221
|
+
return this.waitWithTimeout(state.queueWaiters, channel.name, timeout, signal);
|
|
222
|
+
}
|
|
99
223
|
case 'topic': {
|
|
100
224
|
// Clamp timeout to prevent indefinite subscriber leaks
|
|
101
225
|
let effectiveTimeout = timeout;
|
|
@@ -104,8 +228,10 @@ export class ChannelStore {
|
|
|
104
228
|
effectiveTimeout = MAX_TOPIC_TIMEOUT;
|
|
105
229
|
}
|
|
106
230
|
return new Promise((resolve, reject) => {
|
|
231
|
+
let removeAbortListener = null;
|
|
107
232
|
const timer = setTimeout(() => {
|
|
108
233
|
state.topicSubscribers.delete(handler);
|
|
234
|
+
removeAbortListener?.();
|
|
109
235
|
reject(new NoeticErrorImpl({
|
|
110
236
|
kind: 'channel_timeout',
|
|
111
237
|
channelName: channel.name,
|
|
@@ -115,9 +241,23 @@ export class ChannelStore {
|
|
|
115
241
|
const handler = (value) => {
|
|
116
242
|
clearTimeout(timer);
|
|
117
243
|
state.topicSubscribers.delete(handler);
|
|
244
|
+
removeAbortListener?.();
|
|
118
245
|
resolve(value);
|
|
119
246
|
};
|
|
120
247
|
state.topicSubscribers.add(handler);
|
|
248
|
+
if (signal) {
|
|
249
|
+
const onAbort = () => {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
state.topicSubscribers.delete(handler);
|
|
252
|
+
reject(cancelledError(signal));
|
|
253
|
+
};
|
|
254
|
+
signal.addEventListener('abort', onAbort, {
|
|
255
|
+
once: true,
|
|
256
|
+
});
|
|
257
|
+
removeAbortListener = () => {
|
|
258
|
+
signal.removeEventListener('abort', onAbort);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
121
261
|
});
|
|
122
262
|
}
|
|
123
263
|
}
|
|
@@ -127,32 +267,55 @@ export class ChannelStore {
|
|
|
127
267
|
switch (state.mode) {
|
|
128
268
|
case 'value':
|
|
129
269
|
return state.hasValue ? state.currentValue : null;
|
|
130
|
-
case 'queue':
|
|
131
|
-
|
|
270
|
+
case 'queue': {
|
|
271
|
+
if (state.queue.length > 0) {
|
|
272
|
+
const head = state.queue.shift();
|
|
273
|
+
this.promotePendingSender(state);
|
|
274
|
+
return head;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
132
278
|
case 'topic':
|
|
133
279
|
return null;
|
|
134
280
|
}
|
|
135
281
|
}
|
|
136
|
-
waitWithTimeout(waiters, channelName, timeout) {
|
|
282
|
+
waitWithTimeout(waiters, channelName, timeout, signal) {
|
|
137
283
|
return new Promise((resolve, reject) => {
|
|
138
284
|
let timer = null;
|
|
139
|
-
|
|
285
|
+
let removeAbortListener = null;
|
|
286
|
+
// Symmetric cleanup: whichever path settles the waiter (delivery,
|
|
287
|
+
// timeout, abort) must release both the timer and the abort listener.
|
|
288
|
+
const cleanup = () => {
|
|
140
289
|
if (timer) {
|
|
141
290
|
clearTimeout(timer);
|
|
142
291
|
}
|
|
292
|
+
removeAbortListener?.();
|
|
293
|
+
};
|
|
294
|
+
const wrappedResolve = (v) => {
|
|
295
|
+
cleanup();
|
|
143
296
|
resolve(v);
|
|
144
297
|
};
|
|
298
|
+
const wrappedReject = (e) => {
|
|
299
|
+
cleanup();
|
|
300
|
+
reject(e);
|
|
301
|
+
};
|
|
145
302
|
const entry = {
|
|
146
303
|
resolve: wrappedResolve,
|
|
147
|
-
reject,
|
|
304
|
+
reject: wrappedReject,
|
|
148
305
|
};
|
|
149
306
|
waiters.push(entry);
|
|
307
|
+
const removeEntry = () => {
|
|
308
|
+
const idx = waiters.indexOf(entry);
|
|
309
|
+
if (idx < 0) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
waiters.splice(idx, 1);
|
|
313
|
+
return true;
|
|
314
|
+
};
|
|
150
315
|
if (timeout > 0) {
|
|
151
316
|
timer = setTimeout(() => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
waiters.splice(idx, 1);
|
|
155
|
-
reject(new NoeticErrorImpl({
|
|
317
|
+
if (removeEntry()) {
|
|
318
|
+
wrappedReject(new NoeticErrorImpl({
|
|
156
319
|
kind: 'channel_timeout',
|
|
157
320
|
channelName,
|
|
158
321
|
timeout,
|
|
@@ -160,8 +323,42 @@ export class ChannelStore {
|
|
|
160
323
|
}
|
|
161
324
|
}, timeout);
|
|
162
325
|
}
|
|
326
|
+
if (signal) {
|
|
327
|
+
const onAbort = () => {
|
|
328
|
+
if (removeEntry()) {
|
|
329
|
+
wrappedReject(cancelledError(signal));
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
signal.addEventListener('abort', onAbort, {
|
|
333
|
+
once: true,
|
|
334
|
+
});
|
|
335
|
+
removeAbortListener = () => {
|
|
336
|
+
signal.removeEventListener('abort', onAbort);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
163
339
|
});
|
|
164
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* External-sender write: synchronous, never back-pressured (spec 06,
|
|
343
|
+
* External Sender Back-Pressure). At capacity the OLDEST queued item is
|
|
344
|
+
* dropped (with a warning) so external callers never block.
|
|
345
|
+
*/
|
|
346
|
+
sendExternal(channel, value) {
|
|
347
|
+
const state = this.getOrCreate(channel);
|
|
348
|
+
const isFullQueue = state.mode === 'queue' &&
|
|
349
|
+
state.queueWaiters.length === 0 &&
|
|
350
|
+
state.queue.length >= state.capacity;
|
|
351
|
+
if (isFullQueue) {
|
|
352
|
+
console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping oldest message (external sender).`);
|
|
353
|
+
state.queue.shift();
|
|
354
|
+
state.queue.push(value);
|
|
355
|
+
this.fireWakes(state);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// Below capacity (or non-queue mode) the internal path resolves
|
|
359
|
+
// synchronously — nothing can park here.
|
|
360
|
+
void this.send(channel, value);
|
|
361
|
+
}
|
|
165
362
|
getHandle(channel, executionId) {
|
|
166
363
|
const store = this;
|
|
167
364
|
return {
|
|
@@ -176,7 +373,7 @@ export class ChannelStore {
|
|
|
176
373
|
channelName: channel.name,
|
|
177
374
|
});
|
|
178
375
|
}
|
|
179
|
-
store.
|
|
376
|
+
store.sendExternal(channel, value);
|
|
180
377
|
},
|
|
181
378
|
};
|
|
182
379
|
}
|
|
@@ -34,6 +34,15 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
34
34
|
private _completed;
|
|
35
35
|
private _aborted;
|
|
36
36
|
private _abortReason?;
|
|
37
|
+
/**
|
|
38
|
+
* Per-context abort fan-out. `abort()` fires it so operations blocked on
|
|
39
|
+
* this context (channel `recv` waiters, parked back-pressure senders)
|
|
40
|
+
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
41
|
+
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
42
|
+
* constructs its own ContextImpl and therefore its own controller —
|
|
43
|
+
* aborting a child never rejects the parent's waiters.
|
|
44
|
+
*/
|
|
45
|
+
private readonly _abortController;
|
|
37
46
|
private _memory?;
|
|
38
47
|
/**
|
|
39
48
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -77,7 +86,7 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
77
86
|
recv<T>(ch: Channel<T>, opts?: {
|
|
78
87
|
timeout?: number;
|
|
79
88
|
}): Promise<T>;
|
|
80
|
-
send<T>(ch: Channel<T>, value: T): void
|
|
89
|
+
send<T>(ch: Channel<T>, value: T): Promise<void>;
|
|
81
90
|
tryRecv<T>(ch: Channel<T>): T | null;
|
|
82
91
|
checkpoint(): Promise<void>;
|
|
83
92
|
complete<T>(value: T): void;
|
|
@@ -85,6 +94,13 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
85
94
|
get completionValue(): unknown;
|
|
86
95
|
get abortReason(): string | undefined;
|
|
87
96
|
abort(reason?: string): void;
|
|
97
|
+
/**
|
|
98
|
+
* @internal
|
|
99
|
+
* Abort signal scoped to this context. Channel operations register on it
|
|
100
|
+
* so `abort()` rejects them promptly. Not part of the public Context
|
|
101
|
+
* interface.
|
|
102
|
+
*/
|
|
103
|
+
get abortSignal(): AbortSignal;
|
|
88
104
|
/**
|
|
89
105
|
* @internal
|
|
90
106
|
* Push a frame onto the execution frontier. Called by `execute()` at the
|
|
@@ -48,6 +48,15 @@ export class ContextImpl {
|
|
|
48
48
|
_completed = false;
|
|
49
49
|
_aborted = false;
|
|
50
50
|
_abortReason;
|
|
51
|
+
/**
|
|
52
|
+
* Per-context abort fan-out. `abort()` fires it so operations blocked on
|
|
53
|
+
* this context (channel `recv` waiters, parked back-pressure senders)
|
|
54
|
+
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
55
|
+
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
56
|
+
* constructs its own ContextImpl and therefore its own controller —
|
|
57
|
+
* aborting a child never rejects the parent's waiters.
|
|
58
|
+
*/
|
|
59
|
+
_abortController = new AbortController();
|
|
51
60
|
_memory;
|
|
52
61
|
/**
|
|
53
62
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -110,13 +119,18 @@ export class ContextImpl {
|
|
|
110
119
|
if (!this.channelStore) {
|
|
111
120
|
return Promise.reject(new Error('No channel store configured'));
|
|
112
121
|
}
|
|
113
|
-
return this.channelStore.recv(ch, opts?.timeout);
|
|
122
|
+
return this.channelStore.recv(ch, opts?.timeout, this._abortController.signal);
|
|
114
123
|
}
|
|
115
124
|
send(ch, value) {
|
|
116
125
|
if (!this.channelStore) {
|
|
117
|
-
|
|
126
|
+
return Promise.reject(new Error('No channel store configured'));
|
|
118
127
|
}
|
|
119
|
-
|
|
128
|
+
// Internal sender: back-pressured on full queue channels (default 30s →
|
|
129
|
+
// channel_timeout); aborting this context rejects a parked send with
|
|
130
|
+
// 'cancelled' via the shared abort signal.
|
|
131
|
+
return this.channelStore.send(ch, value, {
|
|
132
|
+
signal: this._abortController.signal,
|
|
133
|
+
});
|
|
120
134
|
}
|
|
121
135
|
tryRecv(ch) {
|
|
122
136
|
if (!this.channelStore) {
|
|
@@ -145,6 +159,19 @@ export class ContextImpl {
|
|
|
145
159
|
abort(reason) {
|
|
146
160
|
this._aborted = true;
|
|
147
161
|
this._abortReason = reason;
|
|
162
|
+
// Reject everything blocked on this context (channel recv waiters,
|
|
163
|
+
// parked back-pressure senders) with { kind: 'cancelled' }. Idempotent —
|
|
164
|
+
// a second abort() leaves the already-aborted signal untouched.
|
|
165
|
+
this._abortController.abort(reason ?? 'context aborted');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* @internal
|
|
169
|
+
* Abort signal scoped to this context. Channel operations register on it
|
|
170
|
+
* so `abort()` rejects them promptly. Not part of the public Context
|
|
171
|
+
* interface.
|
|
172
|
+
*/
|
|
173
|
+
get abortSignal() {
|
|
174
|
+
return this._abortController.signal;
|
|
148
175
|
}
|
|
149
176
|
/**
|
|
150
177
|
* @internal
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* and without the agent asking for external input — i.e. the agent
|
|
6
6
|
* stopped without calling either a terminal tool or an ask-user-style
|
|
7
7
|
* tool. That's a soft failure: rather than wait forever, we nudge the
|
|
8
|
-
* agent once to remind it of its options
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* agent once to remind it of its options and wait for the nudged turn
|
|
9
|
+
* to actually complete (`harness.execute` is enqueue-only, so the hook
|
|
10
|
+
* awaits `getAgentResponse` — raced against the signal — before
|
|
11
|
+
* re-checking). If the second turn also ends in the same stalled shape
|
|
12
|
+
* we escalate: the caller-supplied `onStall` callback records the
|
|
13
|
+
* escalation (task pause, metrics, whatever the domain demands) and we
|
|
14
|
+
* settle the signal ourselves with a canonical "stalled" outcome so
|
|
15
|
+
* the parent unwinds cleanly.
|
|
13
16
|
*
|
|
14
17
|
* The helper factors the state machine out of the loop so callers can
|
|
15
18
|
* opt in per-runner. Task-specific concerns (how "pending external
|
|
@@ -74,9 +77,13 @@ export declare function createNudgeMessage(opts: CreateNudgeMessageOpts): InputM
|
|
|
74
77
|
* - the caller reports pending external input (`hasPendingExternal`
|
|
75
78
|
* returns true — e.g. an outstanding ask-user request).
|
|
76
79
|
*
|
|
77
|
-
* Otherwise it sends the nudge message via `harness.execute
|
|
78
|
-
* for
|
|
79
|
-
* the
|
|
80
|
-
*
|
|
80
|
+
* Otherwise it sends the nudge message via `harness.execute` and waits
|
|
81
|
+
* for the nudged turn to actually complete. `execute()` only enqueues
|
|
82
|
+
* the message — the turn runs asynchronously through the session
|
|
83
|
+
* runner — so the hook awaits `harness.getAgentResponse(threadId)`
|
|
84
|
+
* (raced against `signal.done` so a terminal tool that settles the
|
|
85
|
+
* signal mid-turn short-circuits the wait) before re-checking the same
|
|
86
|
+
* two conditions. If the agent still hasn't progressed, the hook fires
|
|
87
|
+
* `onStall` and resolves the signal with `buildStalledOutcome()`.
|
|
81
88
|
*/
|
|
82
89
|
export declare function createStallNudgeHook<TOutcome>(opts: StallNudgeOpts<TOutcome>): (ctx: AfterFirstTurnContext) => Promise<void>;
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
* and without the agent asking for external input — i.e. the agent
|
|
6
6
|
* stopped without calling either a terminal tool or an ask-user-style
|
|
7
7
|
* tool. That's a soft failure: rather than wait forever, we nudge the
|
|
8
|
-
* agent once to remind it of its options
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* agent once to remind it of its options and wait for the nudged turn
|
|
9
|
+
* to actually complete (`harness.execute` is enqueue-only, so the hook
|
|
10
|
+
* awaits `getAgentResponse` — raced against the signal — before
|
|
11
|
+
* re-checking). If the second turn also ends in the same stalled shape
|
|
12
|
+
* we escalate: the caller-supplied `onStall` callback records the
|
|
13
|
+
* escalation (task pause, metrics, whatever the domain demands) and we
|
|
14
|
+
* settle the signal ourselves with a canonical "stalled" outcome so
|
|
15
|
+
* the parent unwinds cleanly.
|
|
13
16
|
*
|
|
14
17
|
* The helper factors the state machine out of the loop so callers can
|
|
15
18
|
* opt in per-runner. Task-specific concerns (how "pending external
|
|
@@ -55,10 +58,14 @@ export function createNudgeMessage(opts) {
|
|
|
55
58
|
* - the caller reports pending external input (`hasPendingExternal`
|
|
56
59
|
* returns true — e.g. an outstanding ask-user request).
|
|
57
60
|
*
|
|
58
|
-
* Otherwise it sends the nudge message via `harness.execute
|
|
59
|
-
* for
|
|
60
|
-
* the
|
|
61
|
-
*
|
|
61
|
+
* Otherwise it sends the nudge message via `harness.execute` and waits
|
|
62
|
+
* for the nudged turn to actually complete. `execute()` only enqueues
|
|
63
|
+
* the message — the turn runs asynchronously through the session
|
|
64
|
+
* runner — so the hook awaits `harness.getAgentResponse(threadId)`
|
|
65
|
+
* (raced against `signal.done` so a terminal tool that settles the
|
|
66
|
+
* signal mid-turn short-circuits the wait) before re-checking the same
|
|
67
|
+
* two conditions. If the agent still hasn't progressed, the hook fires
|
|
68
|
+
* `onStall` and resolves the signal with `buildStalledOutcome()`.
|
|
62
69
|
*/
|
|
63
70
|
export function createStallNudgeHook(opts) {
|
|
64
71
|
return async (ctx) => {
|
|
@@ -72,7 +79,19 @@ export function createStallNudgeHook(opts) {
|
|
|
72
79
|
await opts.harness.execute(opts.nudgeMessage, {
|
|
73
80
|
threadId: opts.threadId,
|
|
74
81
|
});
|
|
75
|
-
|
|
82
|
+
// Wait for the nudged turn to genuinely finish. A one-microtask wait
|
|
83
|
+
// would escalate before the agent's LLM round trip even starts —
|
|
84
|
+
// `execute()` resolves as soon as the message is enqueued. Errors from
|
|
85
|
+
// `getAgentResponse` are swallowed: a failed response fetch must not
|
|
86
|
+
// crash the hook, it just means we proceed to the stall re-check.
|
|
87
|
+
await Promise.race([
|
|
88
|
+
opts.harness
|
|
89
|
+
.getAgentResponse({
|
|
90
|
+
threadId: opts.threadId,
|
|
91
|
+
})
|
|
92
|
+
.catch(() => undefined),
|
|
93
|
+
opts.signal.done.then(() => undefined, () => undefined),
|
|
94
|
+
]);
|
|
76
95
|
if (ctx.signalSettled()) {
|
|
77
96
|
return;
|
|
78
97
|
}
|
|
@@ -45,7 +45,13 @@ export declare class EventBroadcaster {
|
|
|
45
45
|
}
|
|
46
46
|
declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
|
|
47
47
|
private cursor;
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Parked `next()` calls, FIFO. The AsyncIterator protocol allows pipelined
|
|
50
|
+
* `next()` calls (calling again before the previous promise settles), so a
|
|
51
|
+
* single waiter slot would orphan all but the latest call — the overwritten
|
|
52
|
+
* promises would never settle. `notify()` drains in arrival order.
|
|
53
|
+
*/
|
|
54
|
+
private readonly waiters;
|
|
49
55
|
private returned;
|
|
50
56
|
private readonly broadcaster;
|
|
51
57
|
constructor(broadcaster: EventBroadcaster);
|
|
@@ -54,6 +60,8 @@ declare class BroadcastIterator implements AsyncIterator<StreamEvent> {
|
|
|
54
60
|
/** Called by the broadcaster when new data or completion is available. */
|
|
55
61
|
notify(): void;
|
|
56
62
|
next(): Promise<IteratorResult<StreamEvent>>;
|
|
63
|
+
/** Settle every parked waiter with `{ done: true }` — used on early exit. */
|
|
64
|
+
private settleWaiters;
|
|
57
65
|
throw(err: Error): Promise<IteratorResult<StreamEvent>>;
|
|
58
66
|
return(): Promise<IteratorResult<StreamEvent>>;
|
|
59
67
|
private tryRead;
|
|
@@ -101,7 +101,13 @@ export class EventBroadcaster {
|
|
|
101
101
|
//#region BroadcastIterator
|
|
102
102
|
class BroadcastIterator {
|
|
103
103
|
cursor = 0;
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Parked `next()` calls, FIFO. The AsyncIterator protocol allows pipelined
|
|
106
|
+
* `next()` calls (calling again before the previous promise settles), so a
|
|
107
|
+
* single waiter slot would orphan all but the latest call — the overwritten
|
|
108
|
+
* promises would never settle. `notify()` drains in arrival order.
|
|
109
|
+
*/
|
|
110
|
+
waiters = [];
|
|
105
111
|
returned = false;
|
|
106
112
|
broadcaster;
|
|
107
113
|
constructor(broadcaster) {
|
|
@@ -113,20 +119,22 @@ class BroadcastIterator {
|
|
|
113
119
|
}
|
|
114
120
|
/** Called by the broadcaster when new data or completion is available. */
|
|
115
121
|
notify() {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
this.waiter = undefined;
|
|
121
|
-
try {
|
|
122
|
-
const result = this.tryRead();
|
|
123
|
-
if (result) {
|
|
124
|
-
w.resolve(result);
|
|
122
|
+
while (this.waiters.length > 0) {
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = this.tryRead();
|
|
125
126
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
catch (err) {
|
|
128
|
+
// Reject so the error propagates through the async iterator. The
|
|
129
|
+
// broadcaster stays finished-with-error, so the loop drains every
|
|
130
|
+
// remaining waiter with the same rejection.
|
|
131
|
+
this.waiters.shift().reject(err);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!result) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.waiters.shift().resolve(result);
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
async next() {
|
|
@@ -136,25 +144,41 @@ class BroadcastIterator {
|
|
|
136
144
|
value: undefined,
|
|
137
145
|
};
|
|
138
146
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
// Only read ahead when no one is parked — a pipelined next() must queue
|
|
148
|
+
// BEHIND earlier calls so events are handed out in FIFO order.
|
|
149
|
+
if (this.waiters.length === 0) {
|
|
150
|
+
const result = this.tryRead();
|
|
151
|
+
if (result) {
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
142
154
|
}
|
|
143
155
|
return new Promise((resolve, reject) => {
|
|
144
|
-
this.
|
|
156
|
+
this.waiters.push({
|
|
145
157
|
resolve,
|
|
146
158
|
reject,
|
|
147
|
-
};
|
|
159
|
+
});
|
|
148
160
|
});
|
|
149
161
|
}
|
|
162
|
+
/** Settle every parked waiter with `{ done: true }` — used on early exit. */
|
|
163
|
+
settleWaiters() {
|
|
164
|
+
const drained = this.waiters.splice(0, this.waiters.length);
|
|
165
|
+
for (const waiter of drained) {
|
|
166
|
+
waiter.resolve({
|
|
167
|
+
done: true,
|
|
168
|
+
value: undefined,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
150
172
|
async throw(err) {
|
|
151
173
|
this.returned = true;
|
|
152
174
|
this.broadcaster.removeIterator(this);
|
|
175
|
+
this.settleWaiters();
|
|
153
176
|
throw err;
|
|
154
177
|
}
|
|
155
178
|
async return() {
|
|
156
179
|
this.returned = true;
|
|
157
180
|
this.broadcaster.removeIterator(this);
|
|
181
|
+
this.settleWaiters();
|
|
158
182
|
return {
|
|
159
183
|
done: true,
|
|
160
184
|
value: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noetic-tools/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Step primitives, interpreter, runtime, and memory layer contract for Noetic agents.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -64,15 +64,15 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@noetic-tools/memory": "^0.
|
|
68
|
-
"@noetic-tools/types": "^0.
|
|
67
|
+
"@noetic-tools/memory": "^1.0.1",
|
|
68
|
+
"@noetic-tools/types": "^1.0.0",
|
|
69
69
|
"@openrouter/agent": "^0.6.0",
|
|
70
70
|
"zod": "^4.0.0"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
73
|
"build": "bunx tsc -p tsconfig.build.json && bun scripts/add-js-extensions.ts",
|
|
74
74
|
"verify:esm": "bun scripts/verify-node-esm.ts",
|
|
75
|
-
"prepublishOnly": "rm -rf dist && bun run build && bun run verify:esm && bun scripts/pin-workspace-deps.ts",
|
|
75
|
+
"prepublishOnly": "rm -rf dist && bun run build && bun run verify:esm && bun scripts/pin-workspace-deps.ts && bun scripts/strip-dev-conditions.ts",
|
|
76
76
|
"test": "bun test",
|
|
77
77
|
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov",
|
|
78
78
|
"test:ci": "bun test --coverage --coverage-reporter=lcov",
|