@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.
@@ -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, _ctx: Context): void;
156
- recv<T>(channel: Channel<T>, _ctx: Context, opts?: {
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 = opts.layerStateStore ?? createLayerStateStore();
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, _ctx) {
370
- this.channelStore.send(channel, value);
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, _ctx, opts) {
373
- return this.channelStore.recv(channel, opts?.timeout);
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, roundItemSchemas, allItems, conversationInput } = params;
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, roundItemSchemas, allItems, conversationInput } = params;
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, roundItemSchemas, allItems, conversationInput } = params;
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, DurableTaskStateConfig, FactExtractor, FactSearcher, HistoryWindowConfig, ObservationalMemoryConfig, ObservationalState, PlanEnterSessionCallback, PlanExecutionEntry, PlanExitCallback, PlanMemoryConfig, PlanState, TemporalFact, TemporalMemoryConfig, TemporalSearchResult, WorkingMemoryConfig, WorkingMemoryState, } from '@noetic-tools/memory';
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
- const tasks = paths.map((path, i) => () => executeStep(path, _input, frameworkCast(childContexts[i])));
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, _ctx, childContexts, executeStep, concurrency) {
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, _ctx);
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
- send<T>(channel: Channel<T>, value: T): void;
13
- recv<T>(channel: Channel<T>, timeout?: number): Promise<T>;
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
- send(channel, value) {
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.shift().resolve(value);
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
- console.warn(`[noetic] Channel '${channel.name}': queue at capacity (${state.capacity}), dropping message.`);
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
- if (state.wakeSubscribers.size > 0) {
76
- // Swap in a fresh set before firing so a callback that re-subscribes
77
- // (typical for `every` re-arming on its next iteration) lands on the
78
- // new set and survives until the next send.
79
- const wakers = state.wakeSubscribers;
80
- state.wakeSubscribers = new Set();
81
- for (const wake of wakers) {
82
- wake();
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
- return state.queue.shift();
210
+ const head = state.queue.shift();
211
+ this.promotePendingSender(state);
212
+ return head;
97
213
  }
98
- return this.waitWithTimeout(state.queueWaiters, channel.name, timeout);
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
- return state.queue.length > 0 ? state.queue.shift() : null;
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
- const wrappedResolve = (v) => {
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
- const idx = waiters.indexOf(entry);
153
- if (idx >= 0) {
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.send(channel, value);
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
- throw new Error('No channel store configured');
126
+ return Promise.reject(new Error('No channel store configured'));
118
127
  }
119
- this.channelStore.send(ch, value);
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. If the second turn also
9
- * ends in the same stalled shape we escalate: the caller-supplied
10
- * `onStall` callback records the escalation (task pause, metrics,
11
- * whatever the domain demands) and we settle the signal ourselves with
12
- * a canonical "stalled" outcome so the parent unwinds cleanly.
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`, waits
78
- * for microtasks to flush, and re-checks the same two conditions. If
79
- * the agent still hasn't progressed, the hook fires `onStall` and
80
- * resolves the signal with `buildStalledOutcome()`.
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. If the second turn also
9
- * ends in the same stalled shape we escalate: the caller-supplied
10
- * `onStall` callback records the escalation (task pause, metrics,
11
- * whatever the domain demands) and we settle the signal ourselves with
12
- * a canonical "stalled" outcome so the parent unwinds cleanly.
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`, waits
59
- * for microtasks to flush, and re-checks the same two conditions. If
60
- * the agent still hasn't progressed, the hook fires `onStall` and
61
- * resolves the signal with `buildStalledOutcome()`.
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
- await Promise.resolve();
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
- private waiter?;
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
- waiter;
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
- if (!this.waiter) {
117
- return;
118
- }
119
- const w = this.waiter;
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
- catch (err) {
128
- // Reject the waiter's promise so the error propagates through the async iterator
129
- w.reject(err);
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
- const result = this.tryRead();
140
- if (result) {
141
- return result;
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.waiter = {
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.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.2.0",
68
- "@noetic-tools/types": "^0.2.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",