@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.
- package/dist/harness/agent-harness.d.ts +18 -3
- package/dist/harness/agent-harness.js +68 -14
- package/dist/harness/model-call.d.ts +7 -0
- package/dist/harness/model-call.js +19 -0
- package/dist/runtime/channel-store.d.ts +33 -0
- package/dist/runtime/channel-store.js +297 -11
- package/dist/runtime/session-runner.js +12 -0
- package/dist/runtime/session-streams.d.ts +5 -1
- package/dist/runtime/session-streams.js +17 -2
- package/package.json +2 -2
|
@@ -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
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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": "
|
|
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": "^
|
|
70
|
+
"@noetic-tools/types": "^3.0.0",
|
|
71
71
|
"@openrouter/agent": "^0.6.0",
|
|
72
72
|
"zod": "^4.0.0"
|
|
73
73
|
},
|