@noetic-tools/core 4.0.0 → 5.1.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;
@@ -241,9 +253,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
241
253
  * the same arrangement `executeLLM` builds: harness-level context layers'
242
254
  * recall outputs concatenated with the session's accumulated history.
243
255
  *
244
- * Read-mostly: `recallLayers` writes layer-state snapshots to
245
- * `layerStateStore` exactly as a real turn would, so successive previews
246
- * remain consistent with what the next real turn produces.
256
+ * Runs layer `init` on the throwaway preview context first — exactly what
257
+ * the next real turn's `ensureLayersInit` would do, re-hydrating thread/
258
+ * resource-scoped state from storage. Without it, every init-bearing layer
259
+ * is treated as disabled by the recall lifecycle and the preview silently
260
+ * degenerates to bare history. The preview execution's store entries are
261
+ * dropped afterwards so repeated previews don't grow the layer-state store.
247
262
  */
248
263
  previewRequestItems(scope?: SessionScope): Promise<ReadonlyArray<Item>>;
249
264
  storeLayers(layers: ContextLayer[], response: LLMResponse, ctx: Context): Promise<void>;
@@ -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;
@@ -593,9 +631,12 @@ export class AgentHarness {
593
631
  * the same arrangement `executeLLM` builds: harness-level context layers'
594
632
  * recall outputs concatenated with the session's accumulated history.
595
633
  *
596
- * Read-mostly: `recallLayers` writes layer-state snapshots to
597
- * `layerStateStore` exactly as a real turn would, so successive previews
598
- * remain consistent with what the next real turn produces.
634
+ * Runs layer `init` on the throwaway preview context first — exactly what
635
+ * the next real turn's `ensureLayersInit` would do, re-hydrating thread/
636
+ * resource-scoped state from storage. Without it, every init-bearing layer
637
+ * is treated as disabled by the recall lifecycle and the preview silently
638
+ * degenerates to bare history. The preview execution's store entries are
639
+ * dropped afterwards so repeated previews don't grow the layer-state store.
599
640
  */
600
641
  async previewRequestItems(scope) {
601
642
  const threadId = scope?.threadId ?? DEFAULT_THREAD_ID;
@@ -616,16 +657,24 @@ export class AgentHarness {
616
657
  if (layers.length === 0) {
617
658
  return historyItems;
618
659
  }
619
- const recallResults = await this.recallLayers(layers, '', ctx);
620
- const layerOutputItems = recallResults.flatMap((r) => r.items);
621
- if (layerOutputItems.length === 0) {
622
- return historyItems;
660
+ try {
661
+ await this.ensureLayersInit(ctx);
662
+ const recallResults = await this.recallLayers(layers, '', ctx);
663
+ const layerOutputItems = recallResults.flatMap((r) => r.items);
664
+ if (layerOutputItems.length === 0) {
665
+ return historyItems;
666
+ }
667
+ return assembleView({
668
+ systemPromptItems: [],
669
+ layerOutputItems,
670
+ historyItems,
671
+ });
672
+ }
673
+ finally {
674
+ await this.layerStateStore.flush?.(ctx.id);
675
+ this.layerStateStore.cleanup(ctx.id);
676
+ this.initializedExecutions.delete(ctx.id);
623
677
  }
624
- return assembleView({
625
- systemPromptItems: [],
626
- layerOutputItems,
627
- historyItems,
628
- });
629
678
  }
630
679
  async storeLayers(layers, response, ctx) {
631
680
  const storage = this.config.storage;
@@ -744,6 +793,11 @@ export class AgentHarness {
744
793
  for (const node of tree.reverse()) {
745
794
  await this.teardownCancelledContext(node);
746
795
  }
796
+ // Cancellation completes the root execution (spec 06 Lifecycle): close
797
+ // its external channels so handles flip `closed` and streams end.
798
+ if (ctx.parent === null) {
799
+ this.channelStore.closeExecution(ctx.id);
800
+ }
747
801
  }
748
802
  /**
749
803
  * Run the layer lifecycle's abort path for one cancelled context:
@@ -34,6 +34,13 @@ export declare class AgentHarnessModelCaller {
34
34
  private readonly opts;
35
35
  constructor(opts: AgentHarnessModelCallerOpts);
36
36
  private newSpanCollector;
37
+ /**
38
+ * Broadcast a framework-authored item so `getItemStream` carries it — tool
39
+ * results and injected messages never appear in the SDK stream. Emitted
40
+ * unconditionally: item streaming, like the SDK item events, is not subject
41
+ * to `step.emit` filtering.
42
+ */
43
+ private emitItemAppended;
37
44
  private callOverriddenModel;
38
45
  private requireModelClient;
39
46
  callModel(request: CallModelRequest): Promise<LLMResponse>;
@@ -302,6 +302,22 @@ export class AgentHarnessModelCaller {
302
302
  newSpanCollector(request) {
303
303
  return new CallSpanCollector(this.opts.harness, this.opts.traceExporter, request.parentSpan ?? request.ctx?.span ?? null, request.nodeId);
304
304
  }
305
+ /**
306
+ * Broadcast a framework-authored item so `getItemStream` carries it — tool
307
+ * results and injected messages never appear in the SDK stream. Emitted
308
+ * unconditionally: item streaming, like the SDK item events, is not subject
309
+ * to `step.emit` filtering.
310
+ */
311
+ emitItemAppended(ctx, item) {
312
+ emitFrameworkEvent({
313
+ broadcaster: getBroadcaster(ctx),
314
+ agentName: this.opts.agentName,
315
+ eventType: 'item_appended',
316
+ data: {
317
+ item,
318
+ },
319
+ });
320
+ }
305
321
  async callOverriddenModel(request) {
306
322
  if (!this.opts.callModelOverride) {
307
323
  throw new Error('No callModel override configured.');
@@ -557,6 +573,7 @@ export class AgentHarnessModelCaller {
557
573
  ],
558
574
  });
559
575
  ctx.itemLog.append(userItem);
576
+ this.emitItemAppended(ctx, userItem);
560
577
  }
561
578
  emitIfAllowed('inbox_injected', {
562
579
  round,
@@ -706,6 +723,7 @@ export class AgentHarnessModelCaller {
706
723
  callId: fc.callId,
707
724
  output: toolResult.output,
708
725
  });
726
+ this.emitItemAppended(request.ctx, outputItem);
709
727
  params.emitIfAllowed('tool_call_completed', {
710
728
  name: fc.name,
711
729
  callId: fc.callId,
@@ -731,6 +749,7 @@ export class AgentHarnessModelCaller {
731
749
  callId: fc.callId,
732
750
  output: errorOutput,
733
751
  });
752
+ this.emitItemAppended(request.ctx, outputItem);
734
753
  params.emitIfAllowed('tool_call_completed', {
735
754
  name: fc.name,
736
755
  callId: fc.callId,
@@ -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
  }
@@ -174,6 +174,18 @@ export class SessionRunner {
174
174
  messageIds: messages.map((m) => m.id),
175
175
  },
176
176
  });
177
+ // Input items never appear in the SDK stream — item_appended is what
178
+ // carries them into getItemStream (and any other log-faithful consumer).
179
+ for (const item of items) {
180
+ emitFrameworkEvent({
181
+ broadcaster: this.broadcaster,
182
+ agentName: this.agentName,
183
+ eventType: 'item_appended',
184
+ data: {
185
+ item,
186
+ },
187
+ });
188
+ }
177
189
  const turn = {
178
190
  turnId,
179
191
  session: this,
@@ -6,5 +6,9 @@ export declare function filterTextStream(broadcaster: EventBroadcaster): AsyncIt
6
6
  export declare function filterReasoningStream(broadcaster: EventBroadcaster): AsyncIterable<string>;
7
7
  /** @internal Yield cumulative StreamingItem snapshots. Uses roundOffset keyed by
8
8
  * response.created so accumulators across multiple tool rounds and across
9
- * multiple turns within a session don't collide. */
9
+ * multiple turns within a session don't collide.
10
+ *
11
+ * SDK events cover model output only; framework-authored items — turn input
12
+ * messages, between-rounds injections, tool results — arrive via the
13
+ * `item_appended` framework event and are yielded already complete. */
10
14
  export declare function buildItemStream(broadcaster: EventBroadcaster, itemSchemas?: ItemSchemaRegistry): AsyncIterable<StreamingItem>;
@@ -1,4 +1,4 @@
1
- import { defaultItemSchemaRegistry } from '@noetic-tools/types';
1
+ import { defaultItemSchemaRegistry, frameworkCast } from '@noetic-tools/types';
2
2
  //#endregion
3
3
  //#region Helpers
4
4
  function isItemRecord(value) {
@@ -138,11 +138,26 @@ export async function* filterReasoningStream(broadcaster) {
138
138
  }
139
139
  /** @internal Yield cumulative StreamingItem snapshots. Uses roundOffset keyed by
140
140
  * response.created so accumulators across multiple tool rounds and across
141
- * multiple turns within a session don't collide. */
141
+ * multiple turns within a session don't collide.
142
+ *
143
+ * SDK events cover model output only; framework-authored items — turn input
144
+ * messages, between-rounds injections, tool results — arrive via the
145
+ * `item_appended` framework event and are yielded already complete. */
142
146
  export async function* buildItemStream(broadcaster, itemSchemas = defaultItemSchemaRegistry) {
143
147
  const accumulators = new Map();
144
148
  let roundOffset = 0;
145
149
  for await (const event of broadcaster) {
150
+ if (event.source === 'framework') {
151
+ if (event.type.endsWith(':item_appended') &&
152
+ typeof event.data.item === 'object' &&
153
+ event.data.item !== null) {
154
+ yield {
155
+ ...frameworkCast(event.data.item),
156
+ isComplete: true,
157
+ };
158
+ }
159
+ continue;
160
+ }
146
161
  if (event.source !== 'sdk') {
147
162
  continue;
148
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noetic-tools/core",
3
- "version": "4.0.0",
3
+ "version": "5.1.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
  },