@effect-uai/core 0.3.0 → 0.5.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.
Files changed (169) hide show
  1. package/dist/{AiError-CBuPHVKA.d.mts → AiError-CAX_48RU.d.mts} +27 -5
  2. package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
  3. package/dist/Audio-BfCTGnH3.d.mts +61 -0
  4. package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
  5. package/dist/{Image-BZmKfIdq.d.mts → Image-HNmMpMTh.d.mts} +1 -1
  6. package/dist/{Image-BZmKfIdq.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
  7. package/dist/{Items-CB8Bo3FI.d.mts → Items-DqbaJoz7.d.mts} +5 -5
  8. package/dist/{Items-CB8Bo3FI.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
  9. package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
  10. package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
  11. package/dist/{Tool-DjVufH7i.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
  12. package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
  13. package/dist/Turn-ChbL2foc.d.mts +388 -0
  14. package/dist/Turn-ChbL2foc.d.mts.map +1 -0
  15. package/dist/domain/AiError.d.mts +2 -2
  16. package/dist/domain/AiError.mjs +19 -3
  17. package/dist/domain/AiError.mjs.map +1 -1
  18. package/dist/domain/Audio.d.mts +2 -0
  19. package/dist/domain/Audio.mjs +14 -0
  20. package/dist/domain/Audio.mjs.map +1 -0
  21. package/dist/domain/Image.d.mts +1 -1
  22. package/dist/domain/Items.d.mts +1 -1
  23. package/dist/domain/Items.mjs +1 -1
  24. package/dist/domain/Items.mjs.map +1 -1
  25. package/dist/domain/Music.d.mts +116 -0
  26. package/dist/domain/Music.d.mts.map +1 -0
  27. package/dist/domain/Music.mjs +29 -0
  28. package/dist/domain/Music.mjs.map +1 -0
  29. package/dist/domain/Transcript.d.mts +95 -0
  30. package/dist/domain/Transcript.d.mts.map +1 -0
  31. package/dist/domain/Transcript.mjs +22 -0
  32. package/dist/domain/Transcript.mjs.map +1 -0
  33. package/dist/domain/Turn.d.mts +2 -2
  34. package/dist/domain/Turn.mjs +22 -4
  35. package/dist/domain/Turn.mjs.map +1 -1
  36. package/dist/domain/Turn.test.d.mts +1 -0
  37. package/dist/domain/Turn.test.mjs +136 -0
  38. package/dist/domain/Turn.test.mjs.map +1 -0
  39. package/dist/embedding-model/Embedding.d.mts +15 -3
  40. package/dist/embedding-model/Embedding.d.mts.map +1 -1
  41. package/dist/embedding-model/Embedding.mjs.map +1 -1
  42. package/dist/embedding-model/EmbeddingModel.d.mts +33 -17
  43. package/dist/embedding-model/EmbeddingModel.d.mts.map +1 -1
  44. package/dist/embedding-model/EmbeddingModel.mjs.map +1 -1
  45. package/dist/embedding-model/EmbeddingModel.test.d.mts +1 -0
  46. package/dist/embedding-model/EmbeddingModel.test.mjs +59 -0
  47. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -0
  48. package/dist/index.d.mts +13 -7
  49. package/dist/index.mjs +7 -1
  50. package/dist/language-model/LanguageModel.d.mts +30 -8
  51. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  52. package/dist/language-model/LanguageModel.mjs +33 -3
  53. package/dist/language-model/LanguageModel.mjs.map +1 -1
  54. package/dist/language-model/LanguageModel.test.d.mts +1 -0
  55. package/dist/language-model/LanguageModel.test.mjs +143 -0
  56. package/dist/language-model/LanguageModel.test.mjs.map +1 -0
  57. package/dist/loop/Loop.d.mts +94 -11
  58. package/dist/loop/Loop.d.mts.map +1 -1
  59. package/dist/loop/Loop.mjs +92 -26
  60. package/dist/loop/Loop.mjs.map +1 -1
  61. package/dist/loop/Loop.test.mjs +171 -3
  62. package/dist/loop/Loop.test.mjs.map +1 -1
  63. package/dist/music-generator/MusicGenerator.d.mts +77 -0
  64. package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
  65. package/dist/music-generator/MusicGenerator.mjs +51 -0
  66. package/dist/music-generator/MusicGenerator.mjs.map +1 -0
  67. package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
  68. package/dist/music-generator/MusicGenerator.test.mjs +154 -0
  69. package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
  70. package/dist/observability/Metrics.d.mts +1 -1
  71. package/dist/observability/Metrics.mjs +1 -1
  72. package/dist/observability/Metrics.mjs.map +1 -1
  73. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +96 -0
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
  75. package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
  76. package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
  77. package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
  78. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
  79. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
  80. package/dist/streaming/JSONL.d.mts +10 -3
  81. package/dist/streaming/JSONL.d.mts.map +1 -1
  82. package/dist/streaming/JSONL.mjs +15 -9
  83. package/dist/streaming/JSONL.mjs.map +1 -1
  84. package/dist/structured-format/StructuredFormat.d.mts +2 -2
  85. package/dist/structured-format/StructuredFormat.mjs +9 -1
  86. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  87. package/dist/structured-format/StructuredFormat.test.d.mts +1 -0
  88. package/dist/structured-format/StructuredFormat.test.mjs +70 -0
  89. package/dist/structured-format/StructuredFormat.test.mjs.map +1 -0
  90. package/dist/testing/MockMusicGenerator.d.mts +39 -0
  91. package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
  92. package/dist/testing/MockMusicGenerator.mjs +96 -0
  93. package/dist/testing/MockMusicGenerator.mjs.map +1 -0
  94. package/dist/testing/MockProvider.d.mts +23 -18
  95. package/dist/testing/MockProvider.d.mts.map +1 -1
  96. package/dist/testing/MockProvider.mjs +56 -72
  97. package/dist/testing/MockProvider.mjs.map +1 -1
  98. package/dist/testing/MockSpeechSynthesizer.d.mts +37 -0
  99. package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
  100. package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
  101. package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
  102. package/dist/testing/MockTranscriber.d.mts +37 -0
  103. package/dist/testing/MockTranscriber.d.mts.map +1 -0
  104. package/dist/testing/MockTranscriber.mjs +77 -0
  105. package/dist/testing/MockTranscriber.mjs.map +1 -0
  106. package/dist/tool/HistoryCheck.d.mts +1 -1
  107. package/dist/tool/Outcome.d.mts +1 -1
  108. package/dist/tool/Resolvers.d.mts +65 -8
  109. package/dist/tool/Resolvers.d.mts.map +1 -1
  110. package/dist/tool/Resolvers.mjs +8 -12
  111. package/dist/tool/Resolvers.mjs.map +1 -1
  112. package/dist/tool/Resolvers.test.mjs +6 -5
  113. package/dist/tool/Resolvers.test.mjs.map +1 -1
  114. package/dist/tool/Tool.d.mts +2 -2
  115. package/dist/tool/Tool.mjs +18 -1
  116. package/dist/tool/Tool.mjs.map +1 -1
  117. package/dist/tool/Tool.test.d.mts +1 -0
  118. package/dist/tool/Tool.test.mjs +66 -0
  119. package/dist/tool/Tool.test.mjs.map +1 -0
  120. package/dist/tool/Toolkit.d.mts +4 -6
  121. package/dist/tool/Toolkit.d.mts.map +1 -1
  122. package/dist/tool/Toolkit.mjs +14 -43
  123. package/dist/tool/Toolkit.mjs.map +1 -1
  124. package/dist/transcriber/Transcriber.d.mts +101 -0
  125. package/dist/transcriber/Transcriber.d.mts.map +1 -0
  126. package/dist/transcriber/Transcriber.mjs +49 -0
  127. package/dist/transcriber/Transcriber.mjs.map +1 -0
  128. package/dist/transcriber/Transcriber.test.d.mts +1 -0
  129. package/dist/transcriber/Transcriber.test.mjs +130 -0
  130. package/dist/transcriber/Transcriber.test.mjs.map +1 -0
  131. package/package.json +37 -1
  132. package/src/domain/AiError.ts +22 -1
  133. package/src/domain/Audio.ts +88 -0
  134. package/src/domain/Items.ts +1 -1
  135. package/src/domain/Music.ts +121 -0
  136. package/src/domain/Transcript.ts +83 -0
  137. package/src/domain/Turn.test.ts +141 -0
  138. package/src/domain/Turn.ts +50 -43
  139. package/src/embedding-model/Embedding.ts +23 -0
  140. package/src/embedding-model/EmbeddingModel.test.ts +92 -0
  141. package/src/embedding-model/EmbeddingModel.ts +30 -20
  142. package/src/index.ts +6 -0
  143. package/src/language-model/LanguageModel.test.ts +170 -0
  144. package/src/language-model/LanguageModel.ts +64 -1
  145. package/src/loop/Loop.test.ts +256 -3
  146. package/src/loop/Loop.ts +225 -49
  147. package/src/music-generator/MusicGenerator.test.ts +170 -0
  148. package/src/music-generator/MusicGenerator.ts +123 -0
  149. package/src/observability/Metrics.ts +1 -1
  150. package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
  151. package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
  152. package/src/streaming/JSONL.ts +16 -13
  153. package/src/structured-format/StructuredFormat.test.ts +105 -0
  154. package/src/structured-format/StructuredFormat.ts +14 -1
  155. package/src/testing/MockMusicGenerator.ts +168 -0
  156. package/src/testing/MockProvider.ts +126 -105
  157. package/src/testing/MockSpeechSynthesizer.ts +163 -0
  158. package/src/testing/MockTranscriber.ts +137 -0
  159. package/src/tool/Resolvers.test.ts +8 -5
  160. package/src/tool/Resolvers.ts +17 -19
  161. package/src/tool/Tool.test.ts +105 -0
  162. package/src/tool/Tool.ts +20 -0
  163. package/src/tool/Toolkit.ts +49 -50
  164. package/src/transcriber/Transcriber.test.ts +125 -0
  165. package/src/transcriber/Transcriber.ts +127 -0
  166. package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +0 -1
  167. package/dist/Tool-DjVufH7i.d.mts.map +0 -1
  168. package/dist/Turn-OPaILVIB.d.mts +0 -194
  169. package/dist/Turn-OPaILVIB.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
- /** The terminal `Stop` event. Use `stop` (the Stream) to end a loop body. */
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 = <S, A, E, R>(
79
- stream: Stream.Stream<A, E, R>,
80
- state: S,
81
- ): Stream.Stream<Event<A, S>, E, R> =>
82
- Stream.concat(Stream.map(stream, value), Stream.fromIterable([next(state)]))
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 = <A, B, S, E, R>(
102
- stream: Stream.Stream<A, E, R>,
103
- initial: B,
104
- reduce: (acc: B, a: A) => B,
105
- build: (b: B) => S,
106
- ): Stream.Stream<Event<A, S>, E, R> =>
107
- Stream.unwrap(
108
- Effect.gen(function* () {
109
- const ref = yield* Ref.make(initial)
110
- const tapped = stream.pipe(
111
- Stream.tap((a) => Ref.update(ref, (acc) => reduce(acc, a))),
112
- Stream.map(value),
113
- )
114
- const continuation = Stream.fromEffect(
115
- Ref.get(ref).pipe(Effect.map((acc) => next(build(acc)))),
116
- )
117
- return tapped.pipe(Stream.concat(continuation))
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
- * `turn_complete`, so the consumer sees turn boundaries naturally). Once
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 `turn_complete`, the resulting stream
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
- <E, R>(
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* Effect.fail(new IncompleteTurn({}))
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
- ): { readonly values: Array<A>; readonly decision: Event<A, S> | undefined } => {
196
- const values: Array<A> = []
197
- for (let i = 0; i < chunk.length; i++) {
198
- const event = chunk[i]!
199
- if (event._tag === "Value") {
200
- values.push(event.value)
201
- } else {
202
- return { values, decision: event }
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 !== undefined) {
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 === undefined` every event was
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
- event._tag === "Next" ? SubscriptionRef.set(stateRef, event.state) : Effect.void,
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
 
@@ -0,0 +1,170 @@
1
+ import { Effect, Stream } from "effect"
2
+ import { describe, expect, expectTypeOf, it } from "vitest"
3
+ import type * as AiError from "../domain/AiError.js"
4
+ import type { AudioChunk, AudioFormat } from "../domain/Audio.js"
5
+ import { configInput, promptsInput, type MusicResult } from "../domain/Music.js"
6
+ import * as MockMusicGenerator from "../testing/MockMusicGenerator.js"
7
+ import * as MusicGenerator from "./MusicGenerator.js"
8
+
9
+ const mp3Format: AudioFormat = {
10
+ container: "mp3",
11
+ encoding: "mp3",
12
+ sampleRate: 44100,
13
+ channels: 2,
14
+ }
15
+
16
+ const result: MusicResult = {
17
+ format: mp3Format,
18
+ bytes: new Uint8Array([0xff, 0xfb, 0x90, 0x00]),
19
+ durationSeconds: 30,
20
+ lyrics: "[Verse]\nhello\n",
21
+ watermark: { kind: "synthid" },
22
+ }
23
+
24
+ const chunk = (n: number): AudioChunk => ({ bytes: new Uint8Array([n]) })
25
+
26
+ describe("MusicGenerator.generate", () => {
27
+ it("returns the scripted MusicResult", async () => {
28
+ const mock = MockMusicGenerator.layer({ results: [result] })
29
+ const program = MusicGenerator.generate({
30
+ model: "mock-music",
31
+ prompts: "upbeat indie pop",
32
+ })
33
+ const out = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
34
+ expect(out.bytes).toEqual(result.bytes)
35
+ expect(out.durationSeconds).toBe(30)
36
+ expect(out.watermark?.kind).toBe("synthid")
37
+ expect(out.lyrics).toContain("[Verse]")
38
+ })
39
+
40
+ it("records the request shape on the recorder", async () => {
41
+ const mock = MockMusicGenerator.layer({ results: [result, result] })
42
+ const program = Effect.gen(function* () {
43
+ yield* MusicGenerator.generate({ model: "m", prompts: "techno" })
44
+ yield* MusicGenerator.generate({
45
+ model: "m",
46
+ prompts: [
47
+ { text: "synthwave", weight: 1.0 },
48
+ { text: "80s movie OST", weight: 0.4 },
49
+ ],
50
+ bpm: 120,
51
+ instrumental: true,
52
+ })
53
+ return yield* mock.recorder
54
+ })
55
+ const rec = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
56
+ expect(rec.generateCalls.length).toBe(2)
57
+ expect(rec.generateCalls[1]!.bpm).toBe(120)
58
+ expect(rec.generateCalls[1]!.instrumental).toBe(true)
59
+ expect(Array.isArray(rec.generateCalls[1]!.prompts)).toBe(true)
60
+ })
61
+ })
62
+
63
+ describe("MusicGenerator.streamGeneration", () => {
64
+ it("emits scripted chunks", async () => {
65
+ const mock = MockMusicGenerator.layer({
66
+ streamGenerationChunks: [[chunk(1), chunk(2), chunk(3)]],
67
+ })
68
+ const program = Stream.runCollect(
69
+ MusicGenerator.streamGeneration({ model: "m", prompts: "ambient" }),
70
+ )
71
+ const out = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
72
+ expect(out.map((c) => Array.from(c.bytes))).toEqual([[1], [2], [3]])
73
+ })
74
+ })
75
+
76
+ describe("MusicGenerator capability marker (compile-time)", () => {
77
+ const sgfReq: MusicGenerator.CommonStreamGenerateMusicRequest = {
78
+ model: "m",
79
+ prompts: "",
80
+ }
81
+
82
+ it("requires `MusicInteractiveSession` on the R channel of streamGenerationFrom", () => {
83
+ const inputs = Stream.fromIterable([promptsInput([{ text: "techno" }])])
84
+ const audio = inputs.pipe(MusicGenerator.streamGenerationFrom(sgfReq))
85
+ expectTypeOf(audio).toEqualTypeOf<
86
+ Stream.Stream<
87
+ AudioChunk,
88
+ AiError.AiError,
89
+ MusicGenerator.MusicGenerator | MusicGenerator.MusicInteractiveSession
90
+ >
91
+ >()
92
+ })
93
+
94
+ it("does NOT require `MusicInteractiveSession` for sync `generate`", () => {
95
+ const eff = MusicGenerator.generate({ model: "m", prompts: "ambient" })
96
+ expectTypeOf(eff).toEqualTypeOf<
97
+ Effect.Effect<MusicResult, AiError.AiError, MusicGenerator.MusicGenerator>
98
+ >()
99
+ })
100
+
101
+ it("does NOT require `MusicInteractiveSession` for `streamGeneration`", () => {
102
+ const audio = MusicGenerator.streamGeneration({ model: "m", prompts: "ambient" })
103
+ expectTypeOf(audio).toEqualTypeOf<
104
+ Stream.Stream<AudioChunk, AiError.AiError, MusicGenerator.MusicGenerator>
105
+ >()
106
+ })
107
+
108
+ it("a layer without the marker leaves `MusicInteractiveSession` unsatisfied in R", () => {
109
+ const noMarker = MockMusicGenerator.layerWithoutInteractive({})
110
+ const inputs = Stream.fromIterable([promptsInput([{ text: "techno" }])])
111
+ const audio = inputs.pipe(MusicGenerator.streamGenerationFrom(sgfReq))
112
+ const program = Stream.runDrain(audio).pipe(Effect.provide(noMarker.layer))
113
+ expectTypeOf(program).toEqualTypeOf<
114
+ Effect.Effect<void, AiError.AiError, MusicGenerator.MusicInteractiveSession>
115
+ >()
116
+ })
117
+
118
+ it("a full layer (with marker) clears R to never", () => {
119
+ const fullMock = MockMusicGenerator.layer({
120
+ streamGenerationFromChunks: [[]],
121
+ })
122
+ const inputs = Stream.fromIterable([promptsInput([{ text: "techno" }])])
123
+ const audio = inputs.pipe(MusicGenerator.streamGenerationFrom(sgfReq))
124
+ const program = Stream.runDrain(audio).pipe(Effect.provide(fullMock.layer))
125
+ expectTypeOf(program).toEqualTypeOf<Effect.Effect<void, AiError.AiError, never>>()
126
+ })
127
+ })
128
+
129
+ describe("MusicGenerator.streamGenerationFrom", () => {
130
+ const sgfReq: MusicGenerator.CommonStreamGenerateMusicRequest = {
131
+ model: "lyria-realtime-001",
132
+ prompts: "",
133
+ }
134
+
135
+ it("drains a session-input stream and emits scripted audio", async () => {
136
+ const mock = MockMusicGenerator.layer({
137
+ streamGenerationFromChunks: [[chunk(10), chunk(20)]],
138
+ })
139
+ const inputs = Stream.fromIterable([
140
+ promptsInput([{ text: "minimal techno", weight: 1.0 }]),
141
+ configInput({ bpm: 124 }),
142
+ promptsInput([
143
+ { text: "minimal techno", weight: 1.0 },
144
+ { text: "1980s synthwave", weight: 0.3 },
145
+ ]),
146
+ ])
147
+ const audio = inputs.pipe(MusicGenerator.streamGenerationFrom(sgfReq))
148
+ const out = await Effect.runPromise(Stream.runCollect(audio).pipe(Effect.provide(mock.layer)))
149
+ expect(out.map((c) => Array.from(c.bytes))).toEqual([[10], [20]])
150
+ })
151
+
152
+ it("records the request on the streamGenerationFrom call channel", async () => {
153
+ const mock = MockMusicGenerator.layer({
154
+ streamGenerationFromChunks: [[chunk(42)]],
155
+ })
156
+ const program = Effect.gen(function* () {
157
+ yield* Stream.runDrain(
158
+ Stream.fromIterable([promptsInput([{ text: "x" }])]).pipe(
159
+ MusicGenerator.streamGenerationFrom(sgfReq),
160
+ ),
161
+ )
162
+ return yield* mock.recorder
163
+ })
164
+ const rec = await Effect.runPromise(program.pipe(Effect.provide(mock.layer)))
165
+ expect(rec.streamGenerationFromCalls.length).toBe(1)
166
+ expect(rec.streamGenerationFromCalls[0]!.model).toBe("lyria-realtime-001")
167
+ expect(rec.generateCalls.length).toBe(0)
168
+ expect(rec.streamGenerationCalls.length).toBe(0)
169
+ })
170
+ })