@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.
- package/dist/{AiError-CBuPHVKA.d.mts → AiError-CAX_48RU.d.mts} +27 -5
- package/dist/{AiError-CBuPHVKA.d.mts.map → AiError-CAX_48RU.d.mts.map} +1 -1
- package/dist/Audio-BfCTGnH3.d.mts +61 -0
- package/dist/Audio-BfCTGnH3.d.mts.map +1 -0
- package/dist/{Image-BZmKfIdq.d.mts → Image-HNmMpMTh.d.mts} +1 -1
- package/dist/{Image-BZmKfIdq.d.mts.map → Image-HNmMpMTh.d.mts.map} +1 -1
- package/dist/{Items-CB8Bo3FI.d.mts → Items-DqbaJoz7.d.mts} +5 -5
- package/dist/{Items-CB8Bo3FI.d.mts.map → Items-DqbaJoz7.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BWq5Hd1O.d.mts → StructuredFormat-BbN4dosH.d.mts} +11 -4
- package/dist/StructuredFormat-BbN4dosH.d.mts.map +1 -0
- package/dist/{Tool-DjVufH7i.d.mts → Tool-Y0__Py1H.d.mts} +20 -4
- package/dist/Tool-Y0__Py1H.d.mts.map +1 -0
- package/dist/Turn-ChbL2foc.d.mts +388 -0
- package/dist/Turn-ChbL2foc.d.mts.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +19 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +2 -0
- package/dist/domain/Audio.mjs +14 -0
- package/dist/domain/Audio.mjs.map +1 -0
- 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/Music.d.mts +116 -0
- package/dist/domain/Music.d.mts.map +1 -0
- package/dist/domain/Music.mjs +29 -0
- package/dist/domain/Music.mjs.map +1 -0
- package/dist/domain/Transcript.d.mts +95 -0
- package/dist/domain/Transcript.d.mts.map +1 -0
- package/dist/domain/Transcript.mjs +22 -0
- package/dist/domain/Transcript.mjs.map +1 -0
- 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 +13 -7
- package/dist/index.mjs +7 -1
- 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 +77 -0
- package/dist/music-generator/MusicGenerator.d.mts.map +1 -0
- package/dist/music-generator/MusicGenerator.mjs +51 -0
- package/dist/music-generator/MusicGenerator.mjs.map +1 -0
- package/dist/music-generator/MusicGenerator.test.d.mts +1 -0
- package/dist/music-generator/MusicGenerator.test.mjs +154 -0
- package/dist/music-generator/MusicGenerator.test.mjs.map +1 -0
- 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 +96 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs +48 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.mjs.map +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.d.mts +1 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +112 -0
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs.map +1 -0
- package/dist/streaming/JSONL.d.mts +10 -3
- package/dist/streaming/JSONL.d.mts.map +1 -1
- package/dist/streaming/JSONL.mjs +15 -9
- 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 +39 -0
- package/dist/testing/MockMusicGenerator.d.mts.map +1 -0
- package/dist/testing/MockMusicGenerator.mjs +96 -0
- package/dist/testing/MockMusicGenerator.mjs.map +1 -0
- 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 +37 -0
- package/dist/testing/MockSpeechSynthesizer.d.mts.map +1 -0
- package/dist/testing/MockSpeechSynthesizer.mjs +95 -0
- package/dist/testing/MockSpeechSynthesizer.mjs.map +1 -0
- package/dist/testing/MockTranscriber.d.mts +37 -0
- package/dist/testing/MockTranscriber.d.mts.map +1 -0
- package/dist/testing/MockTranscriber.mjs +77 -0
- package/dist/testing/MockTranscriber.mjs.map +1 -0
- 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 +101 -0
- package/dist/transcriber/Transcriber.d.mts.map +1 -0
- package/dist/transcriber/Transcriber.mjs +49 -0
- package/dist/transcriber/Transcriber.mjs.map +1 -0
- package/dist/transcriber/Transcriber.test.d.mts +1 -0
- package/dist/transcriber/Transcriber.test.mjs +130 -0
- package/dist/transcriber/Transcriber.test.mjs.map +1 -0
- package/package.json +37 -1
- package/src/domain/AiError.ts +22 -1
- package/src/domain/Audio.ts +88 -0
- package/src/domain/Items.ts +1 -1
- package/src/domain/Music.ts +121 -0
- package/src/domain/Transcript.ts +83 -0
- 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/index.ts +6 -0
- 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/music-generator/MusicGenerator.test.ts +170 -0
- package/src/music-generator/MusicGenerator.ts +123 -0
- package/src/observability/Metrics.ts +1 -1
- package/src/speech-synthesizer/SpeechSynthesizer.test.ts +141 -0
- package/src/speech-synthesizer/SpeechSynthesizer.ts +131 -0
- package/src/streaming/JSONL.ts +16 -13
- package/src/structured-format/StructuredFormat.test.ts +105 -0
- package/src/structured-format/StructuredFormat.ts +14 -1
- package/src/testing/MockMusicGenerator.ts +168 -0
- package/src/testing/MockProvider.ts +126 -105
- package/src/testing/MockSpeechSynthesizer.ts +163 -0
- package/src/testing/MockTranscriber.ts +137 -0
- 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/src/transcriber/Transcriber.test.ts +125 -0
- package/src/transcriber/Transcriber.ts +127 -0
- package/dist/StructuredFormat-BWq5Hd1O.d.mts.map +0 -1
- package/dist/Tool-DjVufH7i.d.mts.map +0 -1
- package/dist/Turn-OPaILVIB.d.mts +0 -194
- package/dist/Turn-OPaILVIB.d.mts.map +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Layer, Option, Ref, Schedule, Stream } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import * as AiError from "../domain/AiError.js"
|
|
4
|
+
import * as Items from "../domain/Items.js"
|
|
5
|
+
import { type Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
|
+
import { LanguageModel, retry, turn } from "./LanguageModel.js"
|
|
7
|
+
import * as MockProvider from "../testing/MockProvider.js"
|
|
8
|
+
|
|
9
|
+
const oneTextTurn = (text: string): Turn => ({
|
|
10
|
+
items: [Items.assistantText(text)],
|
|
11
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
12
|
+
stop_reason: "stop",
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe("LanguageModel.turn", () => {
|
|
16
|
+
it("returns the assembled Turn from the terminal TurnComplete event", async () => {
|
|
17
|
+
const expected = oneTextTurn("hello world")
|
|
18
|
+
const program = turn({ history: [Items.userText("hi")], model: "mock" })
|
|
19
|
+
|
|
20
|
+
const result = await Effect.runPromise(
|
|
21
|
+
program.pipe(Effect.provide(MockProvider.layer([expected]))),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
expect(result).toEqual(expected)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("fails with IncompleteTurn when the stream ends without TurnComplete", async () => {
|
|
28
|
+
// Custom service whose stream emits a single TextDelta and then ends.
|
|
29
|
+
const broken = Layer.succeed(LanguageModel, {
|
|
30
|
+
streamTurn: () => Stream.fromIterable<TurnEvent>([TurnEvent.TextDelta({ text: "partial" })]),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const program = turn({ history: [Items.userText("hi")], model: "mock" })
|
|
34
|
+
const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(broken))))
|
|
35
|
+
|
|
36
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
37
|
+
if (Exit.isFailure(exit)) {
|
|
38
|
+
const failure = Cause.findErrorOption(exit.cause)
|
|
39
|
+
expect(Option.isSome(failure)).toBe(true)
|
|
40
|
+
if (Option.isSome(failure)) {
|
|
41
|
+
expect(failure.value).toBeInstanceOf(AiError.IncompleteTurn)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("propagates an AiError raised by streamTurn", async () => {
|
|
47
|
+
const rateLimited = new AiError.RateLimited({ provider: "mock", raw: null })
|
|
48
|
+
const failing = Layer.succeed(LanguageModel, {
|
|
49
|
+
streamTurn: () => Stream.fail<AiError.AiError>(rateLimited),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const program = turn({ history: [], model: "mock" })
|
|
53
|
+
const exit = await Effect.runPromise(Effect.exit(program.pipe(Effect.provide(failing))))
|
|
54
|
+
|
|
55
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
56
|
+
if (Exit.isFailure(exit)) {
|
|
57
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(rateLimited))
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("returns the LAST TurnComplete when the stream contains multiple (defensive)", async () => {
|
|
62
|
+
// A misbehaving provider might emit two TurnComplete events; turn
|
|
63
|
+
// should pick the last one (the most recent assembled Turn).
|
|
64
|
+
const first = oneTextTurn("first")
|
|
65
|
+
const second = oneTextTurn("second")
|
|
66
|
+
const weird = Layer.succeed(LanguageModel, {
|
|
67
|
+
streamTurn: () =>
|
|
68
|
+
Stream.fromIterable<TurnEvent>([
|
|
69
|
+
TurnEvent.TurnComplete({ turn: first }),
|
|
70
|
+
TurnEvent.TurnComplete({ turn: second }),
|
|
71
|
+
]),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const program = turn({ history: [], model: "mock" })
|
|
75
|
+
const result = await Effect.runPromise(program.pipe(Effect.provide(weird)))
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual(second)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("LanguageModel.retry", () => {
|
|
82
|
+
const textDelta = (text: string): TurnEvent => TurnEvent.TextDelta({ text })
|
|
83
|
+
const textTurn = (text: string): Turn => ({
|
|
84
|
+
items: [Items.assistantText(text)],
|
|
85
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
86
|
+
stop_reason: "stop",
|
|
87
|
+
})
|
|
88
|
+
const completeEvent = (text: string): TurnEvent =>
|
|
89
|
+
TurnEvent.TurnComplete({ turn: textTurn(text) })
|
|
90
|
+
|
|
91
|
+
// Builds a stream that emits a failure or success based on attempt counter.
|
|
92
|
+
// Each call to the returned Effect produces a fresh attempt stream.
|
|
93
|
+
const attemptStream = (
|
|
94
|
+
attempts: Ref.Ref<number>,
|
|
95
|
+
plan: ReadonlyArray<Stream.Stream<TurnEvent, AiError.AiError>>,
|
|
96
|
+
): Stream.Stream<TurnEvent, AiError.AiError> =>
|
|
97
|
+
Stream.unwrap(
|
|
98
|
+
Ref.getAndUpdate(attempts, (n) => n + 1).pipe(
|
|
99
|
+
Effect.map((n) => plan[Math.min(n, plan.length - 1)]!),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
it("retries on RateLimited and yields the success on the next attempt", async () => {
|
|
104
|
+
const program = Effect.gen(function* () {
|
|
105
|
+
const attempts = yield* Ref.make(0)
|
|
106
|
+
const stream = attemptStream(attempts, [
|
|
107
|
+
Stream.fail(new AiError.RateLimited({ provider: "mock", raw: null })),
|
|
108
|
+
Stream.fromIterable([textDelta("ok"), completeEvent("ok")]),
|
|
109
|
+
]).pipe(retry(Schedule.recurs(3)))
|
|
110
|
+
const events = yield* Stream.runCollect(stream)
|
|
111
|
+
const count = yield* Ref.get(attempts)
|
|
112
|
+
return { events: Array.from(events), count }
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const { events, count } = await Effect.runPromise(program)
|
|
116
|
+
expect(count).toBe(2)
|
|
117
|
+
expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TurnComplete"])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("surfaces the underlying retryable failure when retries are exhausted", async () => {
|
|
121
|
+
const cause = new AiError.Unavailable({ provider: "mock", raw: null })
|
|
122
|
+
const stream = Stream.fail<AiError.AiError>(cause).pipe(retry(Schedule.recurs(2)))
|
|
123
|
+
|
|
124
|
+
const exit = await Effect.runPromise(Effect.exit(Stream.runCollect(stream)))
|
|
125
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
126
|
+
if (Exit.isFailure(exit)) {
|
|
127
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("bypasses retry for non-retryable AiError (ContentFiltered)", async () => {
|
|
132
|
+
const program = Effect.gen(function* () {
|
|
133
|
+
const attempts = yield* Ref.make(0)
|
|
134
|
+
const cause = new AiError.ContentFiltered({ provider: "mock", raw: null })
|
|
135
|
+
const stream = attemptStream(attempts, [Stream.fail(cause)]).pipe(retry(Schedule.recurs(5)))
|
|
136
|
+
const exit = yield* Effect.exit(Stream.runCollect(stream))
|
|
137
|
+
const count = yield* Ref.get(attempts)
|
|
138
|
+
return { exit, count, cause }
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const { exit, count, cause } = await Effect.runPromise(program)
|
|
142
|
+
expect(count).toBe(1) // no retry happened
|
|
143
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
144
|
+
if (Exit.isFailure(exit)) {
|
|
145
|
+
expect(Cause.findErrorOption(exit.cause)).toEqual(Option.some(cause))
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("preserves deltas emitted before a retryable failure (and replays on retry)", async () => {
|
|
150
|
+
// Documents the 'replays on retry' caveat in the JSDoc — first attempt
|
|
151
|
+
// emits a delta then fails; second attempt is a clean success. Consumer
|
|
152
|
+
// sees the first delta twice (once from the failed attempt, once from
|
|
153
|
+
// the replay).
|
|
154
|
+
const program = Effect.gen(function* () {
|
|
155
|
+
const attempts = yield* Ref.make(0)
|
|
156
|
+
const stream = attemptStream(attempts, [
|
|
157
|
+
Stream.concat(
|
|
158
|
+
Stream.succeed<TurnEvent>(textDelta("partial")),
|
|
159
|
+
Stream.fail(new AiError.Timeout({ provider: "mock", raw: null })),
|
|
160
|
+
),
|
|
161
|
+
Stream.fromIterable([textDelta("partial"), completeEvent("done")]),
|
|
162
|
+
]).pipe(retry(Schedule.recurs(1)))
|
|
163
|
+
const events = yield* Stream.runCollect(stream)
|
|
164
|
+
return Array.from(events)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const events = await Effect.runPromise(program)
|
|
168
|
+
expect(events.map((e) => e._tag)).toEqual(["TextDelta", "TextDelta", "TurnComplete"])
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Effect, Stream } from "effect"
|
|
1
|
+
import { Array as Arr, Context, Data, Effect, Option, type Schedule, Stream } from "effect"
|
|
2
2
|
import * as AiError from "../domain/AiError.js"
|
|
3
3
|
import type { Item } from "../domain/Items.js"
|
|
4
4
|
import type * as StructuredFormat from "../structured-format/StructuredFormat.js"
|
|
@@ -51,3 +51,66 @@ export const streamTurn = (
|
|
|
51
51
|
request: CommonRequest,
|
|
52
52
|
): Stream.Stream<TurnEvent, AiError.AiError, LanguageModel> =>
|
|
53
53
|
Stream.unwrap(Effect.map(LanguageModel.asEffect(), (m) => m.streamTurn(request)))
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drain `streamTurn` and return the assembled `Turn` from the terminal
|
|
57
|
+
* `TurnComplete` event. Fails with `IncompleteTurn` if the stream ends
|
|
58
|
+
* without one. Derived from `streamTurn`; providers get it for free.
|
|
59
|
+
*/
|
|
60
|
+
export const turn = (
|
|
61
|
+
request: CommonRequest,
|
|
62
|
+
): Effect.Effect<Turn, AiError.AiError | AiError.IncompleteTurn, LanguageModel> =>
|
|
63
|
+
streamTurn(request).pipe(
|
|
64
|
+
Stream.runCollect,
|
|
65
|
+
Effect.flatMap((events) =>
|
|
66
|
+
Arr.findLast(events, isTurnComplete).pipe(
|
|
67
|
+
Option.match({
|
|
68
|
+
onNone: () => Effect.fail(new AiError.IncompleteTurn({})),
|
|
69
|
+
onSome: (e) => Effect.succeed(e.turn),
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// retry — retry the retryable subset of AiError, let other failures escape
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Internal wrapper around the retryable subset of `AiError`. */
|
|
80
|
+
export class Retryable extends Data.TaggedError("RetryableAi")<{
|
|
81
|
+
readonly cause: AiError.RateLimited | AiError.Unavailable | AiError.Timeout
|
|
82
|
+
}> {}
|
|
83
|
+
|
|
84
|
+
const isRetryable = (
|
|
85
|
+
e: AiError.AiError,
|
|
86
|
+
): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout =>
|
|
87
|
+
e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout"
|
|
88
|
+
|
|
89
|
+
// Lift events to Items, non-retryable failures to Terminal values (escape
|
|
90
|
+
// retry), retryable failures to wrapped errors (only thing retry sees).
|
|
91
|
+
type Lifted<A> =
|
|
92
|
+
| { readonly _tag: "Item"; readonly value: A }
|
|
93
|
+
| { readonly _tag: "Terminal"; readonly cause: AiError.AiError }
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retry a stream of `AiError` on the retryable subset
|
|
97
|
+
* (`RateLimited | Unavailable | Timeout`). Other failures bypass the
|
|
98
|
+
* schedule and propagate unchanged. Like all `Stream.retry`, the entire
|
|
99
|
+
* stream re-runs — deltas before the failure replay on the next attempt.
|
|
100
|
+
*/
|
|
101
|
+
export const retry =
|
|
102
|
+
<Out>(schedule: Schedule.Schedule<Out, Retryable>) =>
|
|
103
|
+
<A, R>(stream: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>
|
|
104
|
+
stream.pipe(
|
|
105
|
+
Stream.map((value): Lifted<A> => ({ _tag: "Item", value })),
|
|
106
|
+
Stream.catchIf(
|
|
107
|
+
isRetryable,
|
|
108
|
+
(cause) => Stream.fail(new Retryable({ cause })),
|
|
109
|
+
(cause) => Stream.succeed<Lifted<A>>({ _tag: "Terminal", cause }),
|
|
110
|
+
),
|
|
111
|
+
Stream.retry(schedule),
|
|
112
|
+
Stream.catchTag("RetryableAi", (e) => Stream.fail<AiError.AiError>(e.cause)),
|
|
113
|
+
Stream.flatMap((item) =>
|
|
114
|
+
item._tag === "Item" ? Stream.succeed(item.value) : Stream.fail(item.cause),
|
|
115
|
+
),
|
|
116
|
+
)
|
package/src/loop/Loop.test.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { Deferred, Effect, Fiber, Latch, Ref, Stream, SubscriptionRef } from "effect"
|
|
2
|
-
import { describe, expect, it } from "vitest"
|
|
1
|
+
import { Deferred, Effect, Fiber, Latch, pipe, Ref, Stream, SubscriptionRef } from "effect"
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
3
|
+
import * as AiError from "../domain/AiError.js"
|
|
4
|
+
import { TurnEvent } from "../domain/Turn.js"
|
|
3
5
|
import {
|
|
4
6
|
type Event,
|
|
5
7
|
loop,
|
|
8
|
+
loopFrom,
|
|
6
9
|
loopWithState,
|
|
7
10
|
next,
|
|
8
11
|
nextAfter,
|
|
9
|
-
|
|
12
|
+
onTurnComplete,
|
|
13
|
+
stop,
|
|
10
14
|
stopAfter,
|
|
15
|
+
stopEvent,
|
|
16
|
+
stopWith,
|
|
11
17
|
value,
|
|
12
18
|
} from "./Loop.js"
|
|
13
19
|
|
|
@@ -101,6 +107,80 @@ describe("Loop.loop", () => {
|
|
|
101
107
|
expect(result).toEqual([0, 1, 2])
|
|
102
108
|
})
|
|
103
109
|
|
|
110
|
+
it("type: data-last (pipe) form preserves the body's E channel", () => {
|
|
111
|
+
// Regression for the prior inference bug: when used as
|
|
112
|
+
// `pipe(initial, loop(body))`, the body's E must propagate to the outer
|
|
113
|
+
// stream instead of collapsing to `never`. Generics live on the outer
|
|
114
|
+
// return of each overload, so neither calling form can erase them.
|
|
115
|
+
const result = pipe(
|
|
116
|
+
{ count: 0 },
|
|
117
|
+
loop((_state) => Stream.fail(new AiError.RateLimited({ provider: "test", raw: null }))),
|
|
118
|
+
)
|
|
119
|
+
type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
|
|
120
|
+
expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("type: data-first form preserves the body's E channel", () => {
|
|
124
|
+
const result = loop({ count: 0 }, (_state) =>
|
|
125
|
+
Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
|
|
126
|
+
)
|
|
127
|
+
type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
|
|
128
|
+
expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("type: onTurnComplete inside loop infers S and A from the handler without annotation", () => {
|
|
132
|
+
// Regression: when piped through loop, onTurnComplete's handler return
|
|
133
|
+
// type (Stream<Event<A, S>>) is the single source of truth for the loop
|
|
134
|
+
// body's element type. The previous workaround required explicit
|
|
135
|
+
// <S, A> at the call site. Now the loop's outer-return generics pull
|
|
136
|
+
// them through automatically.
|
|
137
|
+
type LoopState = { readonly turns: number }
|
|
138
|
+
type ToolEvent = { readonly _tag: "tool"; readonly name: string }
|
|
139
|
+
|
|
140
|
+
const result = pipe(
|
|
141
|
+
{ turns: 0 } as LoopState,
|
|
142
|
+
loop((state) =>
|
|
143
|
+
Effect.gen(function* () {
|
|
144
|
+
const deltas: Stream.Stream<TurnEvent> = Stream.empty
|
|
145
|
+
return deltas.pipe(
|
|
146
|
+
onTurnComplete(() =>
|
|
147
|
+
Effect.sync(() =>
|
|
148
|
+
state.turns >= 1
|
|
149
|
+
? stop
|
|
150
|
+
: nextAfter(Stream.succeed<ToolEvent>({ _tag: "tool", name: "x" }), {
|
|
151
|
+
turns: state.turns + 1,
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
type Element = typeof result extends Stream.Stream<infer X, unknown, unknown> ? X : never
|
|
161
|
+
expectTypeOf<Element>().toEqualTypeOf<TurnEvent | ToolEvent>()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("onTurnComplete: data-first form (Function.dual) works at runtime", async () => {
|
|
165
|
+
// Pin both calling forms: deltas.pipe(onTurnComplete(handler)) and
|
|
166
|
+
// onTurnComplete(deltas, handler). Same dispatch as loop's dual.
|
|
167
|
+
const turnComplete: TurnEvent = TurnEvent.TurnComplete({
|
|
168
|
+
turn: { items: [], usage: { input_tokens: 0, output_tokens: 0 }, stop_reason: "stop" },
|
|
169
|
+
})
|
|
170
|
+
const textDelta: TurnEvent = TurnEvent.TextDelta({ text: "hi" })
|
|
171
|
+
const deltas: Stream.Stream<TurnEvent> = Stream.fromIterable([textDelta, turnComplete])
|
|
172
|
+
|
|
173
|
+
const dataFirst = onTurnComplete(deltas, () => Effect.sync(() => stop))
|
|
174
|
+
const dataLast = deltas.pipe(onTurnComplete(() => Effect.sync(() => stop)))
|
|
175
|
+
|
|
176
|
+
const a = await Effect.runPromise(Stream.runCollect(dataFirst))
|
|
177
|
+
const b = await Effect.runPromise(Stream.runCollect(dataLast))
|
|
178
|
+
|
|
179
|
+
// Two value(delta) wraps + one stop sentinel from the handler.
|
|
180
|
+
expect(a.length).toBe(3)
|
|
181
|
+
expect(b.length).toBe(3)
|
|
182
|
+
})
|
|
183
|
+
|
|
104
184
|
it("is stack-safe and linear-time across many iterations", async () => {
|
|
105
185
|
// 100k iterations far exceeds V8's typical stack depth (~10–15k frames).
|
|
106
186
|
const N = 100_000
|
|
@@ -522,3 +602,176 @@ describe("Loop.loopWithState", () => {
|
|
|
522
602
|
expect(await Effect.runPromise(program)).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3])
|
|
523
603
|
})
|
|
524
604
|
})
|
|
605
|
+
|
|
606
|
+
describe("Loop.loopFrom", () => {
|
|
607
|
+
it("runs a multi-turn inner loop per input until the body emits stop", async () => {
|
|
608
|
+
// Per input: emit (input + turnsSoFar) twice, then stop. State counts
|
|
609
|
+
// total turns ACROSS inputs. Demonstrates that `next(s)` continues with
|
|
610
|
+
// the SAME input, multiple times per input — not one body call per item.
|
|
611
|
+
const result = await Effect.runPromise(
|
|
612
|
+
Stream.fromIterable(["a", "b"]).pipe(
|
|
613
|
+
loopFrom(0, (turns: number, input: string) => {
|
|
614
|
+
if (turns >= 2 * (input === "a" ? 1 : 2)) return Stream.fromIterable([stopEvent])
|
|
615
|
+
return Stream.fromIterable([value(`${input}:${turns}`), next(turns + 1)])
|
|
616
|
+
}),
|
|
617
|
+
Stream.runCollect,
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
// input="a": turns 0,1 → emit "a:0","a:1"; turns=2 → stop. State threads.
|
|
622
|
+
// input="b": turns 2,3 → emit "b:2","b:3"; turns=4 → stop.
|
|
623
|
+
expect(result).toEqual(["a:0", "a:1", "b:2", "b:3"])
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it("threads state across inputs (audio-pipeline shape)", async () => {
|
|
627
|
+
// History accumulates across inputs. Each input emits its joined view of
|
|
628
|
+
// history+input, then `stopWith` ends the inner loop AND carries the
|
|
629
|
+
// updated history to the next input.
|
|
630
|
+
const result = await Effect.runPromise(
|
|
631
|
+
Stream.fromIterable(["x", "y", "z"]).pipe(
|
|
632
|
+
loopFrom([] as ReadonlyArray<string>, (history: ReadonlyArray<string>, input: string) =>
|
|
633
|
+
Stream.fromIterable([
|
|
634
|
+
value([...history, input].join(",")),
|
|
635
|
+
stopWith([...history, input]),
|
|
636
|
+
]),
|
|
637
|
+
),
|
|
638
|
+
Stream.runCollect,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
expect(result).toEqual(["x", "x,y", "x,y,z"])
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it("simulates a stream of documents with multi-turn tool calls per document", async () => {
|
|
646
|
+
// Document arrives → model "thinks" (one text turn) → calls a tool
|
|
647
|
+
// (one tool turn) → emits final text (one text turn) → done.
|
|
648
|
+
// Three turns per document, two documents.
|
|
649
|
+
type Turn =
|
|
650
|
+
| { readonly kind: "text"; readonly doc: string; readonly text: string }
|
|
651
|
+
| { readonly kind: "tool"; readonly doc: string; readonly tool: string }
|
|
652
|
+
type State = { readonly turn: number; readonly totalTurns: number }
|
|
653
|
+
|
|
654
|
+
const result = await Effect.runPromise(
|
|
655
|
+
Stream.fromIterable(["doc1", "doc2"]).pipe(
|
|
656
|
+
loopFrom({ turn: 0, totalTurns: 0 } as State, (state, doc: string) => {
|
|
657
|
+
// Each document runs three turns then stops.
|
|
658
|
+
if (state.turn === 0) {
|
|
659
|
+
return Stream.fromIterable([
|
|
660
|
+
value<Turn>({ kind: "text", doc, text: "thinking" }),
|
|
661
|
+
next({ turn: 1, totalTurns: state.totalTurns + 1 }),
|
|
662
|
+
])
|
|
663
|
+
}
|
|
664
|
+
if (state.turn === 1) {
|
|
665
|
+
return Stream.fromIterable([
|
|
666
|
+
value<Turn>({ kind: "tool", doc, tool: "search" }),
|
|
667
|
+
next({ turn: 2, totalTurns: state.totalTurns + 1 }),
|
|
668
|
+
])
|
|
669
|
+
}
|
|
670
|
+
// Final turn — `stopWith` emits the final value, advances state
|
|
671
|
+
// (reset turn to 0 for the next document, bump totalTurns), and
|
|
672
|
+
// ends this document's inner loop in one shot.
|
|
673
|
+
return Stream.fromIterable([
|
|
674
|
+
value<Turn>({ kind: "text", doc, text: "final" }),
|
|
675
|
+
stopWith({ turn: 0, totalTurns: state.totalTurns + 1 }),
|
|
676
|
+
])
|
|
677
|
+
}),
|
|
678
|
+
Stream.runCollect,
|
|
679
|
+
),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
expect(result).toEqual([
|
|
683
|
+
{ kind: "text", doc: "doc1", text: "thinking" },
|
|
684
|
+
{ kind: "tool", doc: "doc1", tool: "search" },
|
|
685
|
+
{ kind: "text", doc: "doc1", text: "final" },
|
|
686
|
+
{ kind: "text", doc: "doc2", text: "thinking" },
|
|
687
|
+
{ kind: "tool", doc: "doc2", tool: "search" },
|
|
688
|
+
{ kind: "text", doc: "doc2", text: "final" },
|
|
689
|
+
])
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it("ends cleanly when the input stream ends mid-conversation", async () => {
|
|
693
|
+
// Single-input case: body advances via `next` then stops cleanly.
|
|
694
|
+
const result = await Effect.runPromise(
|
|
695
|
+
Stream.fromIterable(["only"]).pipe(
|
|
696
|
+
loopFrom(0, (turns: number, input: string) =>
|
|
697
|
+
turns >= 2
|
|
698
|
+
? Stream.fromIterable([stopEvent])
|
|
699
|
+
: Stream.fromIterable([value(`${input}:${turns}`), next(turns + 1)]),
|
|
700
|
+
),
|
|
701
|
+
Stream.runCollect,
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
expect(result).toEqual(["only:0", "only:1"])
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it("body's `stop` advances to the next input (does NOT halt the whole stream)", async () => {
|
|
709
|
+
// Three inputs, body always stops on its first emission. All three
|
|
710
|
+
// are processed — `stop` is per-input, not global. To halt the whole
|
|
711
|
+
// stream, end the INPUT stream upstream.
|
|
712
|
+
const result = await Effect.runPromise(
|
|
713
|
+
Stream.fromIterable([1, 2, 3]).pipe(
|
|
714
|
+
loopFrom(0, (_state: number, input: number) =>
|
|
715
|
+
Stream.fromIterable([value(input * 10), stopEvent]),
|
|
716
|
+
),
|
|
717
|
+
Stream.runCollect,
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
expect(result).toEqual([10, 20, 30])
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
it("data-first form (Function.dual) runs identically to data-last", async () => {
|
|
725
|
+
const inputs = Stream.fromIterable([1, 2])
|
|
726
|
+
const result = await Effect.runPromise(
|
|
727
|
+
Stream.runCollect(
|
|
728
|
+
loopFrom(inputs, 0, (state: number, input: number) =>
|
|
729
|
+
state >= input
|
|
730
|
+
? Stream.fromIterable([stopEvent])
|
|
731
|
+
: Stream.fromIterable([value(state + input), next(state + 1)]),
|
|
732
|
+
),
|
|
733
|
+
),
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
// input=1: state=0 → emit 1, state→1; state=1≥1 → stop.
|
|
737
|
+
// input=2: state=1 → emit 3, state→2; state=2≥2 → stop.
|
|
738
|
+
expect(result).toEqual([1, 3])
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it("supports Effect-returning bodies (parity with loop)", async () => {
|
|
742
|
+
const result = await Effect.runPromise(
|
|
743
|
+
Stream.fromIterable(["a"]).pipe(
|
|
744
|
+
loopFrom(0, (turns: number, input: string) =>
|
|
745
|
+
Effect.gen(function* () {
|
|
746
|
+
const cur = yield* Effect.succeed(turns)
|
|
747
|
+
if (cur >= 2) return Stream.fromIterable([stopEvent])
|
|
748
|
+
return Stream.fromIterable([value(`${input}:${cur}`), next(cur + 1)])
|
|
749
|
+
}),
|
|
750
|
+
),
|
|
751
|
+
Stream.runCollect,
|
|
752
|
+
),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
expect(result).toEqual(["a:0", "a:1"])
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it("type: data-last (pipe) form preserves the body's E channel", () => {
|
|
759
|
+
const result = pipe(
|
|
760
|
+
Stream.fromIterable([1]),
|
|
761
|
+
loopFrom(0, (_state: number, _input: number) =>
|
|
762
|
+
Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
|
|
766
|
+
expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited>()
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
it("type: data-first form preserves the body's E channel and unifies with input's E", () => {
|
|
770
|
+
const input: Stream.Stream<number, Error> = Stream.fail(new Error("boom"))
|
|
771
|
+
const result = loopFrom(input, 0, (_state: number, _i: number) =>
|
|
772
|
+
Stream.fail(new AiError.RateLimited({ provider: "test", raw: null })),
|
|
773
|
+
)
|
|
774
|
+
type E = typeof result extends Stream.Stream<unknown, infer X, unknown> ? X : never
|
|
775
|
+
expectTypeOf<E>().toEqualTypeOf<AiError.RateLimited | Error>()
|
|
776
|
+
})
|
|
777
|
+
})
|