@effect-uai/core 0.4.0 → 0.5.1
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/{AiError-csR8Bhxx.d.mts → AiError-CAX_48RU.d.mts} +2 -2
- package/dist/{AiError-csR8Bhxx.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/{Image-DxyXqzAM.d.mts → Image-HNmMpMTh.d.mts} +4 -4
- package/dist/{Image-DxyXqzAM.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-Hg5AsYxl.d.mts → Items-BH8xUkoR.d.mts} +3 -3
- package/dist/{Items-Hg5AsYxl.d.mts.map → Items-BH8xUkoR.d.mts.map} +1 -1
- package/dist/{StructuredFormat-Cl41C56K.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-B8B5qVEy.d.mts → Tool-87ViKCCO.d.mts} +20 -4
- package/dist/Tool-87ViKCCO.d.mts.map +1 -0
- package/dist/Turn-0CwCAyVe.d.mts +388 -0
- package/dist/Turn-0CwCAyVe.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +1 -1
- package/dist/domain/AiError.mjs +1 -1
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +1 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Turn.d.mts +2 -2
- package/dist/domain/Turn.mjs +22 -4
- package/dist/domain/Turn.mjs.map +1 -1
- package/dist/domain/Turn.test.d.mts +1 -0
- package/dist/domain/Turn.test.mjs +136 -0
- package/dist/domain/Turn.test.mjs.map +1 -0
- package/dist/embedding-model/Embedding.d.mts +15 -3
- package/dist/embedding-model/Embedding.d.mts.map +1 -1
- package/dist/embedding-model/Embedding.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
- package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
- package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
- package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/language-model/LanguageModel.d.mts +30 -8
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs +33 -3
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.d.mts +1 -0
- package/dist/language-model/LanguageModel.test.mjs +143 -0
- package/dist/language-model/LanguageModel.test.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +94 -11
- package/dist/loop/Loop.d.mts.map +1 -1
- package/dist/loop/Loop.mjs +92 -26
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +171 -3
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +1 -1
- package/dist/observability/Metrics.d.mts +1 -1
- package/dist/observability/Metrics.mjs +1 -1
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +7 -12
- package/dist/streaming/JSONL.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +2 -2
- package/dist/structured-format/StructuredFormat.mjs +9 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
- package/dist/structured-format/StructuredFormat.test.mjs +70 -0
- package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -1
- package/dist/testing/MockMusicGenerator.mjs +2 -2
- package/dist/testing/MockMusicGenerator.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +23 -18
- package/dist/testing/MockProvider.d.mts.map +1 -1
- package/dist/testing/MockProvider.mjs +56 -72
- package/dist/testing/MockProvider.mjs.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -1
- package/dist/testing/MockSpeechSynthesizer.mjs +2 -2
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -1
- package/dist/testing/MockTranscriber.d.mts.map +1 -1
- package/dist/testing/MockTranscriber.mjs +2 -2
- package/dist/testing/MockTranscriber.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +1 -1
- package/dist/tool/Outcome.d.mts +1 -1
- package/dist/tool/Resolvers.d.mts +65 -8
- package/dist/tool/Resolvers.d.mts.map +1 -1
- package/dist/tool/Resolvers.mjs +8 -12
- package/dist/tool/Resolvers.mjs.map +1 -1
- package/dist/tool/Resolvers.test.mjs +6 -5
- package/dist/tool/Resolvers.test.mjs.map +1 -1
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +18 -1
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.d.mts +1 -0
- package/dist/tool/Tool.test.mjs +66 -0
- package/dist/tool/Tool.test.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +4 -6
- package/dist/tool/Toolkit.d.mts.map +1 -1
- package/dist/tool/Toolkit.mjs +14 -43
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +1 -1
- package/package.json +1 -1
- package/src/domain/AiError.ts +1 -1
- package/src/domain/Items.ts +1 -1
- package/src/domain/Turn.test.ts +141 -0
- package/src/domain/Turn.ts +50 -43
- package/src/embedding-model/Embedding.ts +23 -0
- package/src/embedding-model/EmbeddingModel.test.ts +92 -0
- package/src/embedding-model/EmbeddingModel.ts +30 -20
- package/src/language-model/LanguageModel.test.ts +170 -0
- package/src/language-model/LanguageModel.ts +64 -1
- package/src/loop/Loop.test.ts +256 -3
- package/src/loop/Loop.ts +225 -49
- package/src/observability/Metrics.ts +1 -1
- package/src/streaming/JSONL.ts +9 -18
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +4 -6
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +4 -6
- package/src/testing/MockTranscriber.ts +4 -6
- package/src/tool/Resolvers.test.ts +8 -5
- package/src/tool/Resolvers.ts +17 -19
- package/src/tool/Tool.test.ts +105 -0
- package/src/tool/Tool.ts +20 -0
- package/src/tool/Toolkit.ts +49 -50
- package/dist/StructuredFormat-Cl41C56K.d.mts.map +0 -1
- package/dist/Tool-B8B5qVEy.d.mts.map +0 -1
- package/dist/Turn-7geUcKsf.d.mts +0 -194
- package/dist/Turn-7geUcKsf.d.mts.map +0 -1
package/src/loop/Loop.ts
CHANGED
|
@@ -18,14 +18,17 @@
|
|
|
18
18
|
* `Loop.nextAfter` / `Loop.stopAfter` helpers to terminate cleanly.
|
|
19
19
|
*/
|
|
20
20
|
import {
|
|
21
|
+
Array as Arr,
|
|
21
22
|
Cause,
|
|
22
23
|
Channel,
|
|
23
24
|
Data,
|
|
24
25
|
Effect,
|
|
25
26
|
Exit,
|
|
26
27
|
Function,
|
|
28
|
+
Match,
|
|
27
29
|
Option,
|
|
28
30
|
Ref,
|
|
31
|
+
Result,
|
|
29
32
|
Scope,
|
|
30
33
|
Stream,
|
|
31
34
|
SubscriptionRef,
|
|
@@ -40,12 +43,22 @@ import { isTurnComplete, type Turn, type TurnEvent } from "../domain/Turn.js"
|
|
|
40
43
|
/**
|
|
41
44
|
* The tagged union a body emits per pull. `Value` carries a payload that
|
|
42
45
|
* flows downstream. `Next` ends the current iteration and continues with a
|
|
43
|
-
* new state. `Stop` ends the loop entirely.
|
|
46
|
+
* new state. `Stop` ends the loop entirely with no carried state.
|
|
47
|
+
* `StopWith` also ends the loop but carries a final state that `loopFrom`
|
|
48
|
+
* will thread to the next input and `loopWithState` will write to its
|
|
49
|
+
* `SubscriptionRef` before the loop ends. Plain `loop` has no next
|
|
50
|
+
* iteration to apply it to and treats `StopWith` like `Stop`.
|
|
51
|
+
*
|
|
52
|
+
* `Stop` is intentionally `{}` so the bare `stopEvent` / `stop` helpers
|
|
53
|
+
* don't constrain `S` from a body's stream type — every body has a `Stop`
|
|
54
|
+
* variant in its union, and forcing `S` to flow through it would break
|
|
55
|
+
* inference whenever the body never uses `next` / `stopWith`.
|
|
44
56
|
*/
|
|
45
57
|
export type Event<A, S> = Data.TaggedEnum<{
|
|
46
58
|
Value: { readonly value: A }
|
|
47
59
|
Next: { readonly state: S }
|
|
48
60
|
Stop: {}
|
|
61
|
+
StopWith: { readonly state: S }
|
|
49
62
|
}>
|
|
50
63
|
|
|
51
64
|
interface EventDef extends Data.TaggedEnum.WithGenerics<2> {
|
|
@@ -60,9 +73,21 @@ export const value = <A>(a: A): Event<A, never> => Event.Value({ value: a })
|
|
|
60
73
|
/** End the current iteration and continue with a new state. */
|
|
61
74
|
export const next = <S>(state: S): Event<never, S> => Event.Next({ state })
|
|
62
75
|
|
|
63
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* The terminal `Stop` event with no carried state. Use `stop` (the Stream)
|
|
78
|
+
* to end a loop body without communicating a final state.
|
|
79
|
+
*/
|
|
64
80
|
export const stopEvent: Event<never, never> = Event.Stop()
|
|
65
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Terminal event that ends the loop AND carries a final state. For
|
|
84
|
+
* `loopFrom` this is the natural "this input is done, here's the state to
|
|
85
|
+
* carry forward to the next input" signal — symmetric with `next(s)` but
|
|
86
|
+
* ending the inner loop instead of continuing it. For `loopWithState` the
|
|
87
|
+
* carried state is written to the `SubscriptionRef` before the loop ends.
|
|
88
|
+
*/
|
|
89
|
+
export const stopWith = <S>(state: S): Event<never, S> => Event.StopWith({ state })
|
|
90
|
+
|
|
66
91
|
/**
|
|
67
92
|
* A single-element stream that ends the loop. Return this from a body when
|
|
68
93
|
* there's nothing else to emit; equivalent to `stopAfter(Stream.empty)` but
|
|
@@ -74,22 +99,47 @@ export const stop: Stream.Stream<Event<never, never>> = Stream.succeed(stopEvent
|
|
|
74
99
|
* Pipe a raw `Stream<A>` into the loop's emit shape, then terminate the
|
|
75
100
|
* iteration with `next(state)`. Common shape for "stream this turn's
|
|
76
101
|
* deltas, then continue with updated history."
|
|
102
|
+
*
|
|
103
|
+
* Dual: data-first `nextAfter(stream, state)` and data-last
|
|
104
|
+
* `stream.pipe(nextAfter(state))` both work.
|
|
77
105
|
*/
|
|
78
|
-
export const nextAfter
|
|
79
|
-
stream: Stream.Stream<A, E, R>,
|
|
80
|
-
state: S,
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
export const nextAfter: {
|
|
107
|
+
<S>(state: S): <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<Event<A, S>, E, R>
|
|
108
|
+
<S, A, E, R>(stream: Stream.Stream<A, E, R>, state: S): Stream.Stream<Event<A, S>, E, R>
|
|
109
|
+
} = Function.dual(
|
|
110
|
+
2,
|
|
111
|
+
<S, A, E, R>(stream: Stream.Stream<A, E, R>, state: S): Stream.Stream<Event<A, S>, E, R> =>
|
|
112
|
+
Stream.concat(Stream.map(stream, value), Stream.fromIterable([next(state)])),
|
|
113
|
+
)
|
|
83
114
|
|
|
84
115
|
/**
|
|
85
116
|
* Pipe a raw `Stream<A>` into the loop's emit shape, then terminate the
|
|
86
117
|
* loop. Common shape for "stream this turn's deltas, then we're done."
|
|
118
|
+
*
|
|
119
|
+
* Unary on the stream — already pipe-compatible via `stream.pipe(stopAfter)`.
|
|
87
120
|
*/
|
|
88
121
|
export const stopAfter = <A, E, R>(
|
|
89
122
|
stream: Stream.Stream<A, E, R>,
|
|
90
123
|
): Stream.Stream<Event<A, never>, E, R> =>
|
|
91
124
|
Stream.concat(Stream.map(stream, value), Stream.fromIterable([stopEvent]))
|
|
92
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Pipe a raw `Stream<A>` into the loop's emit shape, then terminate with
|
|
128
|
+
* `stopWith(state)`. The natural "emit final outputs, advance state, end
|
|
129
|
+
* this input's inner loop" shape for `loopFrom`.
|
|
130
|
+
*
|
|
131
|
+
* Dual: data-first `stopWithAfter(stream, state)` and data-last
|
|
132
|
+
* `stream.pipe(stopWithAfter(state))` both work.
|
|
133
|
+
*/
|
|
134
|
+
export const stopWithAfter: {
|
|
135
|
+
<S>(state: S): <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<Event<A, S>, E, R>
|
|
136
|
+
<S, A, E, R>(stream: Stream.Stream<A, E, R>, state: S): Stream.Stream<Event<A, S>, E, R>
|
|
137
|
+
} = Function.dual(
|
|
138
|
+
2,
|
|
139
|
+
<S, A, E, R>(stream: Stream.Stream<A, E, R>, state: S): Stream.Stream<Event<A, S>, E, R> =>
|
|
140
|
+
Stream.concat(Stream.map(stream, value), Stream.fromIterable([stopWith(state)])),
|
|
141
|
+
)
|
|
142
|
+
|
|
93
143
|
/**
|
|
94
144
|
* General `nextAfter` variant: drain `stream` to the consumer, fold elements
|
|
95
145
|
* into an accumulator, and at end-of-stream emit one `next(build(finalAcc))`.
|
|
@@ -97,26 +147,44 @@ export const stopAfter = <A, E, R>(
|
|
|
97
147
|
* Subsumes `nextAfter` when state is constant (`reduce: (s, _) => s`,
|
|
98
148
|
* `build: (s) => s`). Used by `Toolkit.continueWith` to collect tool
|
|
99
149
|
* results and build next state without exposing a Ref to recipes.
|
|
150
|
+
*
|
|
151
|
+
* Dual: data-first `nextAfterFold(stream, initial, reduce, build)` and
|
|
152
|
+
* data-last `stream.pipe(nextAfterFold(initial, reduce, build))` both work.
|
|
100
153
|
*/
|
|
101
|
-
export const nextAfterFold
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
): Stream.Stream<Event<A, S>, E, R>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
154
|
+
export const nextAfterFold: {
|
|
155
|
+
<A, B, S>(
|
|
156
|
+
initial: B,
|
|
157
|
+
reduce: (acc: B, a: A) => B,
|
|
158
|
+
build: (b: B) => S,
|
|
159
|
+
): <E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<Event<A, S>, E, R>
|
|
160
|
+
<A, B, S, E, R>(
|
|
161
|
+
stream: Stream.Stream<A, E, R>,
|
|
162
|
+
initial: B,
|
|
163
|
+
reduce: (acc: B, a: A) => B,
|
|
164
|
+
build: (b: B) => S,
|
|
165
|
+
): Stream.Stream<Event<A, S>, E, R>
|
|
166
|
+
} = Function.dual(
|
|
167
|
+
4,
|
|
168
|
+
<A, B, S, E, R>(
|
|
169
|
+
stream: Stream.Stream<A, E, R>,
|
|
170
|
+
initial: B,
|
|
171
|
+
reduce: (acc: B, a: A) => B,
|
|
172
|
+
build: (b: B) => S,
|
|
173
|
+
): Stream.Stream<Event<A, S>, E, R> =>
|
|
174
|
+
Stream.unwrap(
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
const ref = yield* Ref.make(initial)
|
|
177
|
+
const tapped = stream.pipe(
|
|
178
|
+
Stream.tap((a) => Ref.update(ref, (acc) => reduce(acc, a))),
|
|
179
|
+
Stream.map(value),
|
|
180
|
+
)
|
|
181
|
+
const continuation = Stream.fromEffect(
|
|
182
|
+
Ref.get(ref).pipe(Effect.map((acc) => next(build(acc)))),
|
|
183
|
+
)
|
|
184
|
+
return tapped.pipe(Stream.concat(continuation))
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
)
|
|
120
188
|
|
|
121
189
|
// ---------------------------------------------------------------------------
|
|
122
190
|
// onTurnComplete - turn-aware stream operator for loop bodies
|
|
@@ -125,7 +193,7 @@ export const nextAfterFold = <A, B, S, E, R>(
|
|
|
125
193
|
/**
|
|
126
194
|
* Lift a provider's `Stream<TurnEvent>` into a loop body's `Stream<Event<TurnEvent | A, S>>`.
|
|
127
195
|
* Each delta passes through as `value(delta)` (including the terminal
|
|
128
|
-
* `
|
|
196
|
+
* `TurnComplete`, so the consumer sees turn boundaries naturally). Once
|
|
129
197
|
* the terminal arrives, `then(turn)` runs and its returned stream of loop
|
|
130
198
|
* events (typically tool outputs followed by `next(state)` or `stop`) is
|
|
131
199
|
* concatenated.
|
|
@@ -133,16 +201,28 @@ export const nextAfterFold = <A, B, S, E, R>(
|
|
|
133
201
|
* Pre-pipe transforms (`Stream.tap` / `Stream.map` / `Stream.filter`) on
|
|
134
202
|
* the raw delta stream cover anything an `emit`-style callback would do.
|
|
135
203
|
*
|
|
136
|
-
* If the upstream ends without a `
|
|
204
|
+
* If the upstream ends without a `TurnComplete`, the resulting stream
|
|
137
205
|
* fails with `AiError.IncompleteTurn`. Catch it via `Stream.catchTag` if
|
|
138
206
|
* you want to recover.
|
|
207
|
+
*
|
|
208
|
+
* Dual: data-first `onTurnComplete(deltas, then)` and data-last
|
|
209
|
+
* `deltas.pipe(onTurnComplete(then))` both work.
|
|
139
210
|
*/
|
|
140
|
-
export const onTurnComplete
|
|
211
|
+
export const onTurnComplete: {
|
|
141
212
|
<S, A, E2 = never, R2 = never>(
|
|
142
213
|
then: (turn: Turn) => Effect.Effect<Stream.Stream<Event<A, S>, E2, R2>, E2, R2>,
|
|
143
|
-
)
|
|
144
|
-
|
|
214
|
+
): <E, R>(
|
|
215
|
+
deltas: Stream.Stream<TurnEvent, E, R>,
|
|
216
|
+
) => Stream.Stream<Event<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
|
|
217
|
+
<S, A, E, R, E2 = never, R2 = never>(
|
|
145
218
|
deltas: Stream.Stream<TurnEvent, E, R>,
|
|
219
|
+
then: (turn: Turn) => Effect.Effect<Stream.Stream<Event<A, S>, E2, R2>, E2, R2>,
|
|
220
|
+
): Stream.Stream<Event<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2>
|
|
221
|
+
} = Function.dual(
|
|
222
|
+
2,
|
|
223
|
+
<S, A, E, R, E2, R2>(
|
|
224
|
+
deltas: Stream.Stream<TurnEvent, E, R>,
|
|
225
|
+
then: (turn: Turn) => Effect.Effect<Stream.Stream<Event<A, S>, E2, R2>, E2, R2>,
|
|
146
226
|
): Stream.Stream<Event<TurnEvent | A, S>, E | E2 | IncompleteTurn, R | R2> =>
|
|
147
227
|
Stream.unwrap(
|
|
148
228
|
Effect.gen(function* () {
|
|
@@ -158,14 +238,15 @@ export const onTurnComplete =
|
|
|
158
238
|
const continuation = Stream.unwrap(
|
|
159
239
|
Effect.gen(function* () {
|
|
160
240
|
const opt = yield* Ref.get(turnRef)
|
|
161
|
-
if (Option.isNone(opt)) return yield*
|
|
241
|
+
if (Option.isNone(opt)) return yield* new IncompleteTurn({})
|
|
162
242
|
return yield* then(opt.value)
|
|
163
243
|
}),
|
|
164
244
|
)
|
|
165
245
|
|
|
166
246
|
return Stream.concat(events, continuation)
|
|
167
247
|
}),
|
|
168
|
-
)
|
|
248
|
+
),
|
|
249
|
+
)
|
|
169
250
|
|
|
170
251
|
// ---------------------------------------------------------------------------
|
|
171
252
|
// Internal helpers
|
|
@@ -192,17 +273,18 @@ const closeBody = <S, A, E, R>(
|
|
|
192
273
|
*/
|
|
193
274
|
const partitionChunk = <A, S>(
|
|
194
275
|
chunk: ReadonlyArray<Event<A, S>>,
|
|
195
|
-
): {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
276
|
+
): {
|
|
277
|
+
readonly values: ReadonlyArray<A>
|
|
278
|
+
readonly decision: Option.Option<Event<A, S>>
|
|
279
|
+
} => {
|
|
280
|
+
const [valueEvents, rest] = Arr.span(
|
|
281
|
+
chunk,
|
|
282
|
+
(e): e is Event<A, S> & { _tag: "Value" } => e._tag === "Value",
|
|
283
|
+
)
|
|
284
|
+
return {
|
|
285
|
+
values: valueEvents.map((e) => e.value),
|
|
286
|
+
decision: Arr.head(rest),
|
|
204
287
|
}
|
|
205
|
-
return { values, decision: undefined }
|
|
206
288
|
}
|
|
207
289
|
|
|
208
290
|
// ---------------------------------------------------------------------------
|
|
@@ -283,17 +365,20 @@ export const loop: {
|
|
|
283
365
|
|
|
284
366
|
const { values, decision } = partitionChunk(chunk)
|
|
285
367
|
|
|
286
|
-
if (decision
|
|
368
|
+
if (Option.isSome(decision)) {
|
|
287
369
|
yield* closeActive(active, Exit.void)
|
|
288
|
-
if (decision._tag === "Stop") {
|
|
370
|
+
if (decision.value._tag === "Stop" || decision.value._tag === "StopWith") {
|
|
371
|
+
// `loop` has no next iteration to apply StopWith's state to;
|
|
372
|
+
// the state lands in `loopFrom`'s outer ref or
|
|
373
|
+
// `loopWithState`'s SubscriptionRef via their taps.
|
|
289
374
|
done = true
|
|
290
|
-
} else if (decision._tag === "Next") {
|
|
291
|
-
state = decision.state
|
|
375
|
+
} else if (decision.value._tag === "Next") {
|
|
376
|
+
state = decision.value.state
|
|
292
377
|
}
|
|
293
378
|
}
|
|
294
379
|
|
|
295
380
|
// Emit the values seen so far if any. Chunks from a Stream pull
|
|
296
|
-
// are non-empty, so when `decision
|
|
381
|
+
// are non-empty, so when `decision` is `None` every event was
|
|
297
382
|
// a `Value` and `values` is non-empty here. With a decision and
|
|
298
383
|
// no preceding values, fall through to the next iteration.
|
|
299
384
|
if (isNonEmpty(values)) return values
|
|
@@ -306,6 +391,91 @@ export const loop: {
|
|
|
306
391
|
),
|
|
307
392
|
)
|
|
308
393
|
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// loopFrom - stream-driven sibling of loop. One input item runs a full
|
|
396
|
+
// multi-turn inner loop.
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
type LoopFromBody<S, I, A, E, R> = (
|
|
400
|
+
state: S,
|
|
401
|
+
input: I,
|
|
402
|
+
) => Stream.Stream<Event<A, S>, E, R> | Effect.Effect<Stream.Stream<Event<A, S>, E, R>, E, R>
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Input-driven sibling of `loop`. For each item pulled from the input
|
|
406
|
+
* stream, runs an inner seed-driven `loop` whose body is
|
|
407
|
+
* `(s) => body(s, item)`. State is threaded across input items.
|
|
408
|
+
*
|
|
409
|
+
* **Per-input semantics — the body emits standard `Event<A, S>`:**
|
|
410
|
+
* - `value(a)`: emit `a` downstream
|
|
411
|
+
* - `next(s)`: re-run the body with the SAME input and new state `s`
|
|
412
|
+
* (multi-turn within one input — e.g. multiple model turns + tool
|
|
413
|
+
* calls for one document)
|
|
414
|
+
* - `stop`: end this input's inner loop, advance to the next input
|
|
415
|
+
* (state preserved)
|
|
416
|
+
* - body stream ending without a decision: same as `stop` (advance)
|
|
417
|
+
*
|
|
418
|
+
* **Outer termination:** the input stream ending. To halt programmatically
|
|
419
|
+
* from within, end the input stream upstream (`Stream.takeWhile`, a
|
|
420
|
+
* `SubscriptionRef` gate, etc.). Reserving `stop` for per-item
|
|
421
|
+
* advancement is what makes the common "stream of documents, multi-turn
|
|
422
|
+
* conversation per document" shape readable.
|
|
423
|
+
*
|
|
424
|
+
* Dual: data-first `loopFrom(input, initial, body)` and data-last
|
|
425
|
+
* `input.pipe(loopFrom(initial, body))` both work.
|
|
426
|
+
*/
|
|
427
|
+
export const loopFrom: {
|
|
428
|
+
<S, I, A, E, R>(
|
|
429
|
+
initial: S,
|
|
430
|
+
body: LoopFromBody<S, I, A, E, R>,
|
|
431
|
+
): <EI, RI>(input: Stream.Stream<I, EI, RI>) => Stream.Stream<A, E | EI, R | RI>
|
|
432
|
+
<S, I, A, E, R, EI, RI>(
|
|
433
|
+
input: Stream.Stream<I, EI, RI>,
|
|
434
|
+
initial: S,
|
|
435
|
+
body: LoopFromBody<S, I, A, E, R>,
|
|
436
|
+
): Stream.Stream<A, E | EI, R | RI>
|
|
437
|
+
} = Function.dual(
|
|
438
|
+
3,
|
|
439
|
+
<S, I, A, E, R, EI, RI>(
|
|
440
|
+
input: Stream.Stream<I, EI, RI>,
|
|
441
|
+
initial: S,
|
|
442
|
+
body: LoopFromBody<S, I, A, E, R>,
|
|
443
|
+
): Stream.Stream<A, E | EI, R | RI> =>
|
|
444
|
+
Stream.unwrap(
|
|
445
|
+
Effect.gen(function* () {
|
|
446
|
+
const stateRef = yield* Ref.make<S>(initial)
|
|
447
|
+
return input.pipe(
|
|
448
|
+
Stream.flatMap((item) =>
|
|
449
|
+
Stream.unwrap(
|
|
450
|
+
Effect.gen(function* () {
|
|
451
|
+
const state = yield* Ref.get(stateRef)
|
|
452
|
+
// Capture Next states (and stopWith's final state) into the
|
|
453
|
+
// outer ref so the LAST state seen in this input's inner
|
|
454
|
+
// loop is what the next input starts from.
|
|
455
|
+
const wrappedBody = (s: S) => {
|
|
456
|
+
const result = body(s, item)
|
|
457
|
+
const stream = Effect.isEffect(result) ? Stream.unwrap(result) : result
|
|
458
|
+
return stream.pipe(
|
|
459
|
+
Stream.tap((event) =>
|
|
460
|
+
Match.value(event).pipe(
|
|
461
|
+
Match.tags({
|
|
462
|
+
Next: (e) => Ref.set(stateRef, e.state),
|
|
463
|
+
StopWith: (e) => Ref.set(stateRef, e.state),
|
|
464
|
+
}),
|
|
465
|
+
Match.orElse(() => Effect.void),
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
return loop(state, wrappedBody)
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
),
|
|
474
|
+
)
|
|
475
|
+
}),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
|
|
309
479
|
// ---------------------------------------------------------------------------
|
|
310
480
|
// loopWithState - same body protocol, plus a live state observable.
|
|
311
481
|
// ---------------------------------------------------------------------------
|
|
@@ -343,7 +513,13 @@ export const loopWithState = <S, A, E, R>(
|
|
|
343
513
|
const tap = (stream: Stream.Stream<Event<A, S>, E, R>): Stream.Stream<Event<A, S>, E, R> =>
|
|
344
514
|
stream.pipe(
|
|
345
515
|
Stream.tap((event) =>
|
|
346
|
-
|
|
516
|
+
Match.value(event).pipe(
|
|
517
|
+
Match.tags({
|
|
518
|
+
Next: (e) => SubscriptionRef.set(stateRef, e.state),
|
|
519
|
+
StopWith: (e) => SubscriptionRef.set(stateRef, e.state),
|
|
520
|
+
}),
|
|
521
|
+
Match.orElse(() => Effect.void),
|
|
522
|
+
),
|
|
347
523
|
),
|
|
348
524
|
)
|
|
349
525
|
|
|
@@ -52,7 +52,7 @@ export type RatePoint<A> = {
|
|
|
52
52
|
* The weight is the unit you care about - bytes, tokens, error count, etc.
|
|
53
53
|
* For tokens-per-second on `TurnEvent`, pass:
|
|
54
54
|
*
|
|
55
|
-
* `(d) => d.
|
|
55
|
+
* `(d) => d._tag === "TextDelta" ? countTokens(d.text) : 0`
|
|
56
56
|
*
|
|
57
57
|
* Use any tokenizer you like; the library does not ship one.
|
|
58
58
|
*/
|
package/src/streaming/JSONL.ts
CHANGED
|
@@ -61,23 +61,17 @@ export const fromBytes = <E, R>(
|
|
|
61
61
|
* decode errors both surface as a `JsonParseError` so callers can `catchTag`
|
|
62
62
|
* uniformly.
|
|
63
63
|
*/
|
|
64
|
-
export const parse =
|
|
65
|
-
|
|
66
|
-
<E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<A, JsonParseError | E, R> =>
|
|
64
|
+
export const parse = <A, I>(schema: Schema.Codec<A, I>) => {
|
|
65
|
+
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
|
|
66
|
+
return <E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<A, JsonParseError | E, R> =>
|
|
67
67
|
self.pipe(
|
|
68
68
|
Stream.mapEffect((line) =>
|
|
69
|
-
Effect.
|
|
70
|
-
try: () => JSON.parse(line) as unknown,
|
|
71
|
-
catch: (cause) => new JsonParseError({ line, cause }),
|
|
72
|
-
}).pipe(
|
|
73
|
-
Effect.flatMap((value) =>
|
|
74
|
-
Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
75
|
-
Effect.mapError((cause) => new JsonParseError({ line, cause })),
|
|
76
|
-
),
|
|
77
|
-
),
|
|
78
|
-
),
|
|
69
|
+
decode(line).pipe(Effect.mapError((cause) => new JsonParseError({ line, cause }))),
|
|
79
70
|
),
|
|
80
71
|
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const decodeUnknownFromJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
|
|
81
75
|
|
|
82
76
|
/**
|
|
83
77
|
* Best-effort parse of a single JSON frame. Returns the parsed value or
|
|
@@ -85,11 +79,8 @@ export const parse =
|
|
|
85
79
|
* non-JSON or partially-received frames silently rather than fail the
|
|
86
80
|
* entire session over one bad frame.
|
|
87
81
|
*/
|
|
88
|
-
export const parseSafe = (raw: string) =>
|
|
89
|
-
Effect.
|
|
90
|
-
try: () => JSON.parse(raw) as unknown,
|
|
91
|
-
catch: () => undefined,
|
|
92
|
-
}).pipe(Effect.orElseSucceed(() => undefined))
|
|
82
|
+
export const parseSafe = (raw: string): Effect.Effect<unknown> =>
|
|
83
|
+
decodeUnknownFromJson(raw).pipe(Effect.orElseSucceed(() => undefined))
|
|
93
84
|
|
|
94
85
|
const encoder = new TextEncoder()
|
|
95
86
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Effect, Exit, Filter, Result, Schema, Stream } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import {
|
|
4
|
+
decodeJsonLines,
|
|
5
|
+
decodeJsonLinesRecoverable,
|
|
6
|
+
fromEffectSchema,
|
|
7
|
+
JsonParseError,
|
|
8
|
+
StructuredDecodeError,
|
|
9
|
+
} from "./StructuredFormat.js"
|
|
10
|
+
|
|
11
|
+
const Item = Schema.Struct({ id: Schema.Number, name: Schema.String })
|
|
12
|
+
type Item = typeof Item.Type
|
|
13
|
+
const itemFormat = fromEffectSchema(Item, { name: "Item" })
|
|
14
|
+
|
|
15
|
+
const linesOf = (...xs: ReadonlyArray<string>): Stream.Stream<string> => Stream.fromIterable(xs)
|
|
16
|
+
|
|
17
|
+
const collect = <A, E>(s: Stream.Stream<A, E>) =>
|
|
18
|
+
Effect.runPromise(Stream.runCollect(s).pipe(Effect.map((c) => Array.from(c))))
|
|
19
|
+
|
|
20
|
+
describe("decodeJsonLinesRecoverable", () => {
|
|
21
|
+
it("yields a Success for each well-formed line", async () => {
|
|
22
|
+
const out = await collect(
|
|
23
|
+
linesOf('{"id":1,"name":"a"}', '{"id":2,"name":"b"}').pipe(
|
|
24
|
+
decodeJsonLinesRecoverable(itemFormat),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(out).toHaveLength(2)
|
|
29
|
+
expect(Result.isSuccess(out[0]!)).toBe(true)
|
|
30
|
+
expect(Result.isSuccess(out[1]!)).toBe(true)
|
|
31
|
+
if (Result.isSuccess(out[0]!) && Result.isSuccess(out[1]!)) {
|
|
32
|
+
expect(out[0]!.success).toEqual<Item>({ id: 1, name: "a" })
|
|
33
|
+
expect(out[1]!.success).toEqual<Item>({ id: 2, name: "b" })
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("yields a Failure for a malformed JSON line WITHOUT aborting the stream", async () => {
|
|
38
|
+
const out = await collect(
|
|
39
|
+
linesOf('{"id":1,"name":"a"}', "not json at all", '{"id":3,"name":"c"}').pipe(
|
|
40
|
+
decodeJsonLinesRecoverable(itemFormat),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
expect(out).toHaveLength(3)
|
|
45
|
+
expect(Result.isSuccess(out[0]!)).toBe(true)
|
|
46
|
+
expect(Result.isFailure(out[1]!)).toBe(true)
|
|
47
|
+
expect(Result.isSuccess(out[2]!)).toBe(true)
|
|
48
|
+
if (Result.isFailure(out[1]!)) {
|
|
49
|
+
expect(out[1]!.failure).toBeInstanceOf(JsonParseError)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("yields a Failure for a schema-invalid line without aborting", async () => {
|
|
54
|
+
const out = await collect(
|
|
55
|
+
linesOf(
|
|
56
|
+
'{"id":1,"name":"a"}',
|
|
57
|
+
'{"id":"not-a-number","name":"b"}', // schema fail
|
|
58
|
+
'{"id":3,"name":"c"}',
|
|
59
|
+
).pipe(decodeJsonLinesRecoverable(itemFormat)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(out).toHaveLength(3)
|
|
63
|
+
expect(Result.isSuccess(out[0]!)).toBe(true)
|
|
64
|
+
expect(Result.isFailure(out[1]!)).toBe(true)
|
|
65
|
+
expect(Result.isSuccess(out[2]!)).toBe(true)
|
|
66
|
+
if (Result.isFailure(out[1]!)) {
|
|
67
|
+
expect(out[1]!.failure).toBeInstanceOf(StructuredDecodeError)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("propagates upstream errors normally (only DECODE failures are lifted into Result)", async () => {
|
|
72
|
+
const boom = new Error("upstream broke")
|
|
73
|
+
const stream = Stream.concat(linesOf('{"id":1,"name":"a"}'), Stream.fail(boom)).pipe(
|
|
74
|
+
decodeJsonLinesRecoverable(itemFormat),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(stream)))
|
|
78
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("composes with filter-success / log-and-continue", async () => {
|
|
82
|
+
const out = await collect(
|
|
83
|
+
linesOf('{"id":1,"name":"a"}', "garbage", '{"id":2,"name":"b"}').pipe(
|
|
84
|
+
decodeJsonLinesRecoverable(itemFormat),
|
|
85
|
+
Stream.filterMap(Filter.fromPredicateOption(Result.getSuccess)),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(out).toEqual<Array<Item>>([
|
|
90
|
+
{ id: 1, name: "a" },
|
|
91
|
+
{ id: 2, name: "b" },
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe("decodeJsonLines (fail-fast, sanity)", () => {
|
|
97
|
+
it("aborts the stream on the first bad line", async () => {
|
|
98
|
+
const stream = linesOf('{"id":1,"name":"a"}', "garbage", '{"id":3,"name":"c"}').pipe(
|
|
99
|
+
decodeJsonLines(itemFormat),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(stream)))
|
|
103
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
-
import { Data, Effect, Match, Schema, Stream, pipe } from "effect"
|
|
2
|
+
import { Data, Effect, Match, Result, Schema, Stream, pipe } from "effect"
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Types
|
|
@@ -158,3 +158,16 @@ export const decodeJsonLines =
|
|
|
158
158
|
self: Stream.Stream<string, E, R>,
|
|
159
159
|
): Stream.Stream<A, E | JsonParseError | StructuredDecodeError, R> =>
|
|
160
160
|
self.pipe(Stream.mapEffect(parseJson(format)))
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Like {@link decodeJsonLines}, but each line yields a `Result` instead of
|
|
164
|
+
* failing the stream. Use when one bad line shouldn't abort the rest —
|
|
165
|
+
* log-and-continue, or partial-recovery with a corrective re-prompt.
|
|
166
|
+
* Upstream errors (the input stream's own `E`) still propagate normally.
|
|
167
|
+
*/
|
|
168
|
+
export const decodeJsonLinesRecoverable =
|
|
169
|
+
<A>(format: StructuredFormat<A>) =>
|
|
170
|
+
<E, R>(
|
|
171
|
+
self: Stream.Stream<string, E, R>,
|
|
172
|
+
): Stream.Stream<Result.Result<A, JsonParseError | StructuredDecodeError>, E, R> =>
|
|
173
|
+
self.pipe(Stream.mapEffect((line) => Effect.result(parseJson(format)(line))))
|
|
@@ -47,12 +47,10 @@ const makeService = (
|
|
|
47
47
|
const i = yield* Ref.getAndUpdate(gCursor, (n) => n + 1)
|
|
48
48
|
const scripted = script.results ?? []
|
|
49
49
|
if (i >= scripted.length) {
|
|
50
|
-
return yield*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}),
|
|
55
|
-
)
|
|
50
|
+
return yield* new AiError.InvalidRequest({
|
|
51
|
+
provider: "mock",
|
|
52
|
+
raw: `MockMusicGenerator exhausted: ${scripted.length} results scripted, but call ${i + 1} was made`,
|
|
53
|
+
})
|
|
56
54
|
}
|
|
57
55
|
return scripted[i]!
|
|
58
56
|
}),
|