@noetic-tools/core 4.0.0 → 5.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.
@@ -136,6 +136,8 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
136
136
  readonly _contextLayers?: ContextLayer[];
137
137
  private readonly client?;
138
138
  private readonly channelStore;
139
+ /** Re-entrant `run()` depth per root context id — see `executeClosingChannels`. */
140
+ private readonly rootRunDepth;
139
141
  private readonly callModelOverride?;
140
142
  private readonly defaultDeliveryMode;
141
143
  private readonly streamIdleTimeoutMs;
@@ -195,6 +197,15 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
195
197
  /** Run all layer `init` hooks before the first step executes. Per spec 11,
196
198
  * init MUST complete before any recall fires so layer state is populated. */
197
199
  private initAndRun;
200
+ /**
201
+ * Run `execute`, closing the execution's external channels when the
202
+ * OUTERMOST run on a root context finishes (spec 06 Lifecycle: handles flip
203
+ * `closed`, channel streams end). Patterns re-enter `run()` with the same
204
+ * root context (`ctx.harness.run(step, input, ctx)`), so a depth count —
205
+ * not the return itself — marks root completion. Child-context runs never
206
+ * close anything.
207
+ */
208
+ private executeClosingChannels;
198
209
  /**
199
210
  * Idempotently run layer `init` hooks for `ctx` (keyed by `ctx.id`). Init
200
211
  * MUST complete before any recall/store fires so init-bearing layers are not
@@ -227,6 +238,7 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
227
238
  }): Promise<T>;
228
239
  tryRecv<T>(channel: Channel<T>, _ctx: Context): T | null;
229
240
  getChannelHandle<T>(channel: ExternalChannel<T>, executionId: string): ChannelHandle<T>;
241
+ getChannelStream<T>(channel: ExternalChannel<T>, executionId: string): AsyncIterable<T>;
230
242
  /** Resolves layer-provided tools and merges with step tools into ctx.unifiedTools. */
231
243
  private setUnifiedTools;
232
244
  private toExecCtx;
@@ -146,6 +146,8 @@ export class AgentHarness {
146
146
  _contextLayers;
147
147
  client;
148
148
  channelStore;
149
+ /** Re-entrant `run()` depth per root context id — see `executeClosingChannels`. */
150
+ rootRunDepth = new Map();
149
151
  callModelOverride;
150
152
  defaultDeliveryMode;
151
153
  streamIdleTimeoutMs;
@@ -424,13 +426,46 @@ export class AgentHarness {
424
426
  // configuring `context` + `storage` must "just work" without going through
425
427
  // `execute()`/`seedSessionHistory`. Idempotent — see `ensureLayersInit`.
426
428
  await this.ensureLayersInit(ctx);
427
- return execute(s, input, ctx);
429
+ return this.executeClosingChannels(s, input, ctx);
428
430
  }
429
431
  /** Run all layer `init` hooks before the first step executes. Per spec 11,
430
432
  * init MUST complete before any recall fires so layer state is populated. */
431
433
  async initAndRun(s, input, ctx) {
432
434
  await this.ensureLayersInit(ctx);
433
- return execute(s, input, ctx);
435
+ return this.executeClosingChannels(s, input, ctx);
436
+ }
437
+ /**
438
+ * Run `execute`, closing the execution's external channels when the
439
+ * OUTERMOST run on a root context finishes (spec 06 Lifecycle: handles flip
440
+ * `closed`, channel streams end). Patterns re-enter `run()` with the same
441
+ * root context (`ctx.harness.run(step, input, ctx)`), so a depth count —
442
+ * not the return itself — marks root completion. Child-context runs never
443
+ * close anything.
444
+ */
445
+ async executeClosingChannels(s, input, ctx) {
446
+ if (ctx.parent !== null) {
447
+ return execute(s, input, ctx);
448
+ }
449
+ const depth = this.rootRunDepth.get(ctx.id) ?? 0;
450
+ if (depth === 0) {
451
+ // A restored or sequentially re-run root context reuses its id — clear
452
+ // any closure from a previous completion so its channels work again.
453
+ this.channelStore.openExecution(ctx.id);
454
+ }
455
+ this.rootRunDepth.set(ctx.id, depth + 1);
456
+ try {
457
+ return await execute(s, input, ctx);
458
+ }
459
+ finally {
460
+ const remaining = (this.rootRunDepth.get(ctx.id) ?? 1) - 1;
461
+ if (remaining > 0) {
462
+ this.rootRunDepth.set(ctx.id, remaining);
463
+ }
464
+ else {
465
+ this.rootRunDepth.delete(ctx.id);
466
+ this.channelStore.closeExecution(ctx.id);
467
+ }
468
+ }
434
469
  }
435
470
  /**
436
471
  * Idempotently run layer `init` hooks for `ctx` (keyed by `ctx.id`). Init
@@ -512,6 +547,9 @@ export class AgentHarness {
512
547
  getChannelHandle(channel, executionId) {
513
548
  return this.channelStore.getHandle(channel, executionId);
514
549
  }
550
+ getChannelStream(channel, executionId) {
551
+ return this.channelStore.subscribe(channel, executionId);
552
+ }
515
553
  /** Resolves layer-provided tools and merges with step tools into ctx.unifiedTools. */
516
554
  setUnifiedTools(ctx, stepTools) {
517
555
  const layers = ctx.layers;
@@ -744,6 +782,11 @@ export class AgentHarness {
744
782
  for (const node of tree.reverse()) {
745
783
  await this.teardownCancelledContext(node);
746
784
  }
785
+ // Cancellation completes the root execution (spec 06 Lifecycle): close
786
+ // its external channels so handles flip `closed` and streams end.
787
+ if (ctx.parent === null) {
788
+ this.channelStore.closeExecution(ctx.id);
789
+ }
747
790
  }
748
791
  /**
749
792
  * Run the layer lifecycle's abort path for one cancelled context:
@@ -12,6 +12,15 @@ export interface ChannelSendOpts {
12
12
  export declare class ChannelStore {
13
13
  private channels;
14
14
  private closedExecutions;
15
+ /**
16
+ * Ids a `ChannelHandle` was issued for. Handles have no dispose lifecycle,
17
+ * so these are the only ids whose closure must be durably recorded (for
18
+ * `handle.closed` / `channel_closed`). Streams are closed directly through
19
+ * `subscribersByExecution`, so stream-only ids never accumulate here — a
20
+ * session harness closing one root id per turn stays bounded.
21
+ */
22
+ private handleObserved;
23
+ private subscribersByExecution;
15
24
  private getOrCreate;
16
25
  /**
17
26
  * Subscribe to wake notifications on a channel without consuming any value.
@@ -53,6 +62,30 @@ export declare class ChannelStore {
53
62
  * dropped (with a warning) so external callers never block.
54
63
  */
55
64
  private sendExternal;
65
+ /**
66
+ * External-reader subscription (spec 06, External Subscriptions). Delivery
67
+ * by mode: `topic` is a persistent non-lossy tap (per-subscriber bounded
68
+ * buffer, no replay of pre-subscribe values), `queue` competes with internal
69
+ * `recv` waiters FIFO (external consumption frees parked senders), `value`
70
+ * yields the current value then conflated updates.
71
+ *
72
+ * Channel state is keyed by NAME — `executionId` bounds only the
73
+ * subscription's lifetime (it ends when `closeExecution(executionId)`
74
+ * runs; queued values present at close drain first), never which values
75
+ * are delivered. An id that is never closed yields an unbounded stream
76
+ * the caller ends with `iterator.return()`. External waiters never time
77
+ * out — they end via close instead. The returned iterable owns a single
78
+ * iterator (generator semantics): subscribe again for a second consumer.
79
+ */
80
+ subscribe<T>(channel: ExternalChannel<T>, executionId: string): AsyncIterable<T>;
81
+ /** Shift the queue head and promote the oldest parked sender into the freed slot. */
82
+ private dequeueHead;
56
83
  getHandle<T>(channel: ExternalChannel<T>, executionId: string): ChannelHandle<T>;
57
84
  closeExecution(executionId: string): void;
85
+ /**
86
+ * A root run is (re)starting on this execution id: clear any previous
87
+ * closure so checkpoint-restored and sequentially re-run root contexts get
88
+ * working channels again instead of a permanently poisoned id.
89
+ */
90
+ openExecution(executionId: string): void;
58
91
  }
@@ -12,6 +12,15 @@ function cancelledError(signal) {
12
12
  export class ChannelStore {
13
13
  channels = new Map();
14
14
  closedExecutions = new Set();
15
+ /**
16
+ * Ids a `ChannelHandle` was issued for. Handles have no dispose lifecycle,
17
+ * so these are the only ids whose closure must be durably recorded (for
18
+ * `handle.closed` / `channel_closed`). Streams are closed directly through
19
+ * `subscribersByExecution`, so stream-only ids never accumulate here — a
20
+ * session harness closing one root id per turn stays bounded.
21
+ */
22
+ handleObserved = new Set();
23
+ subscribersByExecution = new Map();
15
24
  getOrCreate(channel) {
16
25
  let state = frameworkCast(this.channels.get(channel.name));
17
26
  if (!state) {
@@ -25,6 +34,7 @@ export class ChannelStore {
25
34
  pendingSenders: [],
26
35
  topicSubscribers: new Set(),
27
36
  wakeSubscribers: new Set(),
37
+ externalSubscribers: new Set(),
28
38
  };
29
39
  this.channels.set(channel.name, frameworkCast(state));
30
40
  }
@@ -79,6 +89,9 @@ export class ChannelStore {
79
89
  waiter.resolve(value);
80
90
  }
81
91
  }
92
+ for (const sub of state.externalSubscribers) {
93
+ sub.push(value);
94
+ }
82
95
  break;
83
96
  case 'queue':
84
97
  if (state.queueWaiters.length > 0) {
@@ -96,6 +109,9 @@ export class ChannelStore {
96
109
  for (const sub of state.topicSubscribers) {
97
110
  sub(value);
98
111
  }
112
+ for (const sub of state.externalSubscribers) {
113
+ sub.push(value);
114
+ }
99
115
  break;
100
116
  }
101
117
  this.fireWakes(state);
@@ -206,10 +222,9 @@ export class ChannelStore {
206
222
  }
207
223
  return this.waitWithTimeout(state.valueWaiters, channel.name, timeout, signal);
208
224
  case 'queue': {
209
- if (state.queue.length > 0) {
210
- const head = state.queue.shift();
211
- this.promotePendingSender(state);
212
- return head;
225
+ const head = this.dequeueHead(state);
226
+ if (head) {
227
+ return head.value;
213
228
  }
214
229
  // capacity-0 edge: senders can be parked while the queue is empty —
215
230
  // hand the oldest parked value straight to this receiver.
@@ -268,12 +283,10 @@ export class ChannelStore {
268
283
  case 'value':
269
284
  return state.hasValue ? state.currentValue : null;
270
285
  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;
286
+ // Explicit head check (not `?? null`): a stored `undefined` must come
287
+ // back as `undefined`, distinguishable from the empty-queue sentinel.
288
+ const head = this.dequeueHead(state);
289
+ return head ? head.value : null;
277
290
  }
278
291
  case 'topic':
279
292
  return null;
@@ -359,7 +372,96 @@ export class ChannelStore {
359
372
  // synchronously — nothing can park here.
360
373
  void this.send(channel, value);
361
374
  }
375
+ /**
376
+ * External-reader subscription (spec 06, External Subscriptions). Delivery
377
+ * by mode: `topic` is a persistent non-lossy tap (per-subscriber bounded
378
+ * buffer, no replay of pre-subscribe values), `queue` competes with internal
379
+ * `recv` waiters FIFO (external consumption frees parked senders), `value`
380
+ * yields the current value then conflated updates.
381
+ *
382
+ * Channel state is keyed by NAME — `executionId` bounds only the
383
+ * subscription's lifetime (it ends when `closeExecution(executionId)`
384
+ * runs; queued values present at close drain first), never which values
385
+ * are delivered. An id that is never closed yields an unbounded stream
386
+ * the caller ends with `iterator.return()`. External waiters never time
387
+ * out — they end via close instead. The returned iterable owns a single
388
+ * iterator (generator semantics): subscribe again for a second consumer.
389
+ */
390
+ subscribe(channel, executionId) {
391
+ if (this.closedExecutions.has(executionId)) {
392
+ return CLOSED_ITERABLE;
393
+ }
394
+ const state = this.getOrCreate(channel);
395
+ const subscriber = new ExternalSubscriber({
396
+ channelName: channel.name,
397
+ conflate: state.mode === 'value',
398
+ takeShared: state.mode === 'queue'
399
+ ? () => {
400
+ const head = this.dequeueHead(state);
401
+ if (head) {
402
+ return head;
403
+ }
404
+ // capacity-0 edge: hand a parked sender's value straight over.
405
+ const sender = state.pendingSenders.shift();
406
+ if (!sender) {
407
+ return null;
408
+ }
409
+ sender.resolve();
410
+ return {
411
+ value: sender.value,
412
+ };
413
+ }
414
+ : undefined,
415
+ parkShared: state.mode === 'queue'
416
+ ? (waiter) => {
417
+ state.queueWaiters.push(waiter);
418
+ return () => {
419
+ const idx = state.queueWaiters.indexOf(waiter);
420
+ if (idx >= 0) {
421
+ state.queueWaiters.splice(idx, 1);
422
+ }
423
+ };
424
+ }
425
+ : undefined,
426
+ onFinish: () => {
427
+ state.externalSubscribers.delete(subscriber);
428
+ const byExecution = this.subscribersByExecution.get(executionId);
429
+ if (byExecution) {
430
+ byExecution.delete(erased);
431
+ if (byExecution.size === 0) {
432
+ this.subscribersByExecution.delete(executionId);
433
+ }
434
+ }
435
+ },
436
+ });
437
+ const erased = frameworkCast(subscriber);
438
+ if (state.mode !== 'queue') {
439
+ state.externalSubscribers.add(subscriber);
440
+ }
441
+ if (state.mode === 'value' && state.hasValue) {
442
+ subscriber.push(state.currentValue);
443
+ }
444
+ let byExecution = this.subscribersByExecution.get(executionId);
445
+ if (!byExecution) {
446
+ byExecution = new Set();
447
+ this.subscribersByExecution.set(executionId, byExecution);
448
+ }
449
+ byExecution.add(erased);
450
+ return subscriber;
451
+ }
452
+ /** Shift the queue head and promote the oldest parked sender into the freed slot. */
453
+ dequeueHead(state) {
454
+ if (state.queue.length === 0) {
455
+ return null;
456
+ }
457
+ const head = state.queue.shift();
458
+ this.promotePendingSender(state);
459
+ return {
460
+ value: head,
461
+ };
462
+ }
362
463
  getHandle(channel, executionId) {
464
+ this.handleObserved.add(executionId);
363
465
  const store = this;
364
466
  return {
365
467
  get closed() {
@@ -378,6 +480,190 @@ export class ChannelStore {
378
480
  };
379
481
  }
380
482
  closeExecution(executionId) {
381
- this.closedExecutions.add(executionId);
483
+ // Closure is durably recorded only for ids with issued handles — see
484
+ // `handleObserved`. Consequence: a handle taken AFTER an execution
485
+ // completed reads `closed === false`, and a stream subscribed after a
486
+ // stream-only execution completed waits instead of ending; take handles
487
+ // and subscribe before running.
488
+ if (this.handleObserved.has(executionId)) {
489
+ this.closedExecutions.add(executionId);
490
+ }
491
+ const subscribers = this.subscribersByExecution.get(executionId);
492
+ if (!subscribers) {
493
+ return;
494
+ }
495
+ this.subscribersByExecution.delete(executionId);
496
+ for (const subscriber of subscribers) {
497
+ subscriber.close();
498
+ }
499
+ }
500
+ /**
501
+ * A root run is (re)starting on this execution id: clear any previous
502
+ * closure so checkpoint-restored and sequentially re-run root contexts get
503
+ * working channels again instead of a permanently poisoned id.
504
+ */
505
+ openExecution(executionId) {
506
+ this.closedExecutions.delete(executionId);
507
+ }
508
+ }
509
+ const MAX_EXTERNAL_BUFFER = 1_000;
510
+ const CLOSED_ITERABLE = {
511
+ [Symbol.asyncIterator]() {
512
+ return this;
513
+ },
514
+ next() {
515
+ return Promise.resolve({
516
+ value: undefined,
517
+ done: true,
518
+ });
519
+ },
520
+ };
521
+ /**
522
+ * The iterator behind `ChannelStore.subscribe`. Topic/value subscribers are
523
+ * push-fed via `push()`; queue subscribers pull from the shared queue state so
524
+ * they compete fairly with internal `recv` waiters. `close()` (execution
525
+ * completed) lets already-buffered values drain before ending; `return()`
526
+ * (consumer walked away) drops them.
527
+ */
528
+ class ExternalSubscriber {
529
+ opts;
530
+ buffer = [];
531
+ waiters = [];
532
+ parked = [];
533
+ done = false;
534
+ /** Consumer called return(): stop pulling from the shared queue. */
535
+ detached = false;
536
+ constructor(opts) {
537
+ this.opts = opts;
538
+ }
539
+ [Symbol.asyncIterator]() {
540
+ return this;
541
+ }
542
+ /** Deliver a value to this subscriber (topic/value modes). */
543
+ push(value) {
544
+ if (this.done) {
545
+ return;
546
+ }
547
+ const waiter = this.waiters.shift();
548
+ if (waiter) {
549
+ waiter({
550
+ value,
551
+ done: false,
552
+ });
553
+ return;
554
+ }
555
+ if (this.opts.conflate) {
556
+ this.buffer = [
557
+ value,
558
+ ];
559
+ return;
560
+ }
561
+ if (this.buffer.length >= MAX_EXTERNAL_BUFFER) {
562
+ console.warn(`[noetic] Channel '${this.opts.channelName}': external subscriber buffer at capacity (${MAX_EXTERNAL_BUFFER}), dropping oldest value.`);
563
+ this.buffer.shift();
564
+ }
565
+ this.buffer.push(value);
566
+ }
567
+ next() {
568
+ if (this.buffer.length > 0) {
569
+ return Promise.resolve({
570
+ value: this.buffer.shift(),
571
+ done: false,
572
+ });
573
+ }
574
+ // Live shared-queue reads stop at close — close() snapshots the queue
575
+ // into this.buffer, so a closed iterator can never steal values sent to
576
+ // a later execution on the same channel name.
577
+ const taken = this.done ? null : this.opts.takeShared?.();
578
+ if (taken) {
579
+ return Promise.resolve({
580
+ value: taken.value,
581
+ done: false,
582
+ });
583
+ }
584
+ if (this.done) {
585
+ return Promise.resolve({
586
+ value: undefined,
587
+ done: true,
588
+ });
589
+ }
590
+ if (this.opts.parkShared) {
591
+ return this.parkInSharedQueue(this.opts.parkShared);
592
+ }
593
+ return new Promise((resolve) => {
594
+ this.waiters.push(resolve);
595
+ });
596
+ }
597
+ return() {
598
+ this.detached = true;
599
+ this.buffer = [];
600
+ this.close();
601
+ return Promise.resolve({
602
+ value: undefined,
603
+ done: true,
604
+ });
605
+ }
606
+ /** End the iterator: settle every pending `next()` and unregister. Buffered values still drain. */
607
+ close() {
608
+ if (this.done) {
609
+ return;
610
+ }
611
+ // Snapshot the shared queue's current contents into the private buffer
612
+ // ("buffered values drain first") — after this the iterator never touches
613
+ // live channel state again. A detached iterator (return()) walked away
614
+ // and drains nothing.
615
+ if (!this.detached && this.opts.takeShared) {
616
+ for (let taken = this.opts.takeShared(); taken; taken = this.opts.takeShared()) {
617
+ this.buffer.push(taken.value);
618
+ }
619
+ }
620
+ this.done = true;
621
+ this.opts.onFinish();
622
+ for (const entry of this.parked.splice(0)) {
623
+ entry.unpark();
624
+ entry.settleDone();
625
+ }
626
+ for (const waiter of this.waiters.splice(0)) {
627
+ waiter({
628
+ value: undefined,
629
+ done: true,
630
+ });
631
+ }
632
+ }
633
+ /**
634
+ * Queue mode: wait in the channel's shared waiter FIFO. No timer attaches,
635
+ * so external waiters never reject with `channel_timeout` — `close()` ends
636
+ * them instead.
637
+ */
638
+ parkInSharedQueue(parkShared) {
639
+ return new Promise((resolve) => {
640
+ const entry = {
641
+ unpark: () => { },
642
+ settleDone: () => resolve({
643
+ value: undefined,
644
+ done: true,
645
+ }),
646
+ };
647
+ const removeParked = () => {
648
+ const idx = this.parked.indexOf(entry);
649
+ if (idx >= 0) {
650
+ this.parked.splice(idx, 1);
651
+ }
652
+ };
653
+ entry.unpark = parkShared({
654
+ resolve: (value) => {
655
+ removeParked();
656
+ resolve({
657
+ value,
658
+ done: false,
659
+ });
660
+ },
661
+ reject: () => {
662
+ removeParked();
663
+ entry.settleDone();
664
+ },
665
+ });
666
+ this.parked.push(entry);
667
+ });
382
668
  }
383
669
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "type": "module",
5
5
  "description": "Step primitives, interpreter, runtime, and context layer contract for Noetic agents.",
6
6
  "license": "Apache-2.0",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@noetic-tools/context": "^1.0.0",
70
- "@noetic-tools/types": "^2.3.0",
70
+ "@noetic-tools/types": "^3.0.0",
71
71
  "@openrouter/agent": "^0.6.0",
72
72
  "zod": "^4.0.0"
73
73
  },